From 7903c105d1042fe70f0fc5b04319ca0a5815b9c5 Mon Sep 17 00:00:00 2001 From: Diogo Silva Date: Thu, 14 May 2026 18:02:15 +0100 Subject: [PATCH] feat(journeys,core): add runtime.goForward + JourneyInstance.future redo stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverse of `runtime.goBack`. `goBack` was destructive — it popped the current step + rollback snapshot off the instance record and threw the data away, so a shell wiring browser Forward to journey navigation had no way to redo the rewind. The URL would drift ahead of the runtime with no recovery path beyond re-firing the original exit (which requires the step component's form data + side effects, neither of which the shell has). `InstanceRecord` now carries a `future` stack of `{ step, state, snapshot }` entries. `dispatchGoBack` pushes the step it rewinds from (plus the state captured at that moment and the rollback snapshot it just popped) onto `future` before mutating; the new `dispatchGoForward` inverts the operation — pops the top, pushes the current step back onto `history`, restores `state` verbatim, re-attaches the snapshot to `rollbackSnapshots` so a subsequent `goBack` rewinds correctly. `goForward` does NOT re-run the original transition handler. The future entry holds the post-transition state directly; redo restores it without invoking side effects (API calls, telemetry, persistence in the transition body would double-fire on every Forward press). If a shell needs the business logic to re-execute, it fires the exit itself instead of calling this method. Forward-stack invalidation mirrors the browser. `applyTransition` clears `future` on any next / complete / abort arm — a fresh navigation supersedes whatever the user could have redone. `invoke` leaves it intact (parent step doesn't advance while a child runs). `JourneyInstance.future` exposes the stack as a read-only `readonly JourneyStep[]` so shells can gate a Forward button on `instance.future.length > 0` and compare `future[future.length - 1]` against an incoming URL before calling `runtime.goForward`. The full `FutureEntry` (with state + snapshot) stays internal — consumers only need to know what step a redo would land on. The stack is transient: not persisted, reset on hydrate, reset when a record is recycled by `startFresh`. Reload starts with an empty forward stack, same as opening a new browser tab. Test coverage: eight scenarios in `runtime-go-forward.test.ts` — single goBack→goForward, state restoration, multi-step rewind+redo, no-op when stack empty, no-op for unknown ids, no-op on terminal instance, future invalidation on fresh exit, history symmetry so subsequent goBack still works after a redo. Existing 399 tests stay green. --- CHANGELOG.md | 1 + packages/core/src/journey-contracts.ts | 23 + packages/core/src/types.ts | 7 + packages/journeys/README.md | 30 +- packages/journeys/src/outlet.tsx | 3 +- .../journeys/src/runtime-go-forward.test.ts | 525 ++++++++++++++++++ packages/journeys/src/runtime.ts | 93 +++- .../journeys/src/simulate-journey.test.ts | 62 +++ packages/journeys/src/simulate-journey.ts | 19 + packages/journeys/src/testing.ts | 28 + 10 files changed, 786 insertions(+), 5 deletions(-) create mode 100644 packages/journeys/src/runtime-go-forward.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ceaf8c..d567239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Per-package detail lives in the GitHub release tagged `@`. - **`@modular-react/core`** — `ModuleEntryPoint.buildInput?: (state) => TInput`. When declared on an entry, the journey runtime calls it on every step entry (initial start, forward push, `goBack` pop, resume-into-step) AND on any same-step state change (an `{ invoke }` carrying `state`, or a resume that bumps state without advancing) and uses the result as the step's `input`. Lets a back-navigated form re-render against the journey state accumulated by earlier exits instead of the input frozen at first push. Opt-in — entries without `buildInput` keep the current cache-on-push behaviour. Throws abort the instance with a typed `JourneySystemAbortReason` (`"build-input-threw"`); in `debug` mode a one-time warning fires when the handler's `next.input` would have been overridden by a differing `buildInput` result. Authors annotate the `state` parameter with the hosting journey's `TState` (the module surface stays journey-agnostic). - **`@modular-react/core`** — `JourneyRuntime.goBack(id: InstanceId): void` on the public surface. Equivalent to the `goBack` prop the runtime hands the active step's component, but addressable by instance id so a shell that owns its own back button (browser `popstate`, hardware back, breadcrumb) can wire `popstate → runtime.goBack(id)` directly instead of capturing the active step's callback through a React context. No-op under the same conditions as the closure form (unknown id, terminal / loading, child in flight, no `allowBack`, empty history). +- **`@modular-react/core`** — `JourneyRuntime.goForward(id: InstanceId): void` and `JourneyInstance.future: readonly JourneyStep[]`. Inverse of `goBack`: each `goBack` pushes the step it rewinds from (plus the post-transition state and the popped rollback snapshot) onto a per-instance future stack; `goForward` pops it and restores all three so the runtime lands back at the step the user just rewound from. Lets shells wire browser Forward (or a redo button) to journey navigation instead of having the URL drift ahead of the runtime. Does NOT re-run the transition handler (side effects don't double-fire) or `buildInput` (the captured `step.input` was already built against the same state being restored — asymmetric with `goBack`, which re-runs `buildInput` on re-entry). For a `rollback`-mode entry, any edits the user made between the rewind and the redo are discarded. Cleared by any fresh exit-driven transition (matches browser back/forward semantics — `invoke` leaves the stack intact since the parent's step doesn't advance). Mirrored as `ModuleEntryProps.goForward?: () => void` on the per-step props (present only when the future stack is non-empty — most shells will wire Forward at the shell level via the runtime method), and as `simulator.goForward()` / `harness.goForward(id)` for headless testing. Transient: not persisted, reset on hydrate and on `start` recycle. The exposed `instance.future` is bare-step (no internal state / snapshot) so consumers can gate UI on `instance.future.length > 0` without reaching into runtime internals. - **`@modular-react/journeys`** — `useJourneyInstance(id)`, `useJourneyState(id)`, `useActiveLeafJourneyInstance(rootId)`, and `useActiveLeafJourneyState(rootId)`. React hooks that subscribe to a runtime instance via `useSyncExternalStore`. The `*Instance` variants return the full `JourneyInstance | null`; the state variants are sugar over `instance?.state`. The leaf variants walk `activeChildId` and re-subscribe as the chain grows / shrinks. Prefer the `*Instance` variants when the host needs `step` / `status` / `terminalPayload`, or when the leaf may be any of several journeys (`inst.journeyId` is a natural discriminator). - **`@modular-react/core`** — new public type `JourneyStepFor`. Discriminated union of every concrete `JourneyStep` reachable in a journey's module map; narrowing on `moduleId` + `entry` surfaces the entry's typed `input` without a cast. `JourneyStep` itself becomes `JourneyStep` (backwards-compatible — existing usages default to the wide form). The `simulateJourney` `JourneySimulator`'s `step` / `currentStep` / `history` now use the typed union, so tests can assert on per-entry input shapes without `Record` casts. - **`@modular-react/journeys`** — `simulateJourney` gains a fourth generic, `TOutput = unknown`. Journeys with a concrete terminal payload type are now assignable to the simulator's parameter without the `as unknown as Parameters[0]` cast required by the previous `unknown`-output signature. `SimulateJourneyOptions.modules` lets headless tests bind module descriptors so `buildInput` re-runs at every step entry (without descriptors, the runtime falls back to the cached handler-supplied input — identical to pre-`buildInput` behaviour). diff --git a/packages/core/src/journey-contracts.ts b/packages/core/src/journey-contracts.ts index 45d9e46..a75a347 100644 --- a/packages/core/src/journey-contracts.ts +++ b/packages/core/src/journey-contracts.ts @@ -483,6 +483,12 @@ export interface JourneyInstance { readonly status: JourneyStatus; readonly step: JourneyStep | null; readonly history: readonly JourneyStep[]; + /** + * Redo stack — top (`future[future.length - 1]`) is the step a + * subsequent `runtime.goForward` would restore. Empty after any + * exit-driven transition. Not persisted. + */ + readonly future: readonly JourneyStep[]; readonly state: TState; /** Payload from the terminal transition; undefined until the journey ends. */ readonly terminalPayload?: unknown; @@ -703,6 +709,23 @@ export interface JourneyRuntime { * forms as a hint that the call is presently a no-op. */ goBack(id: InstanceId): void; + /** + * Inverse of {@link goBack}: re-apply the most recent rewind by + * restoring the captured post-transition `state` + `step` so the + * runtime lands back where the user just rewound from. Lets shells + * wire browser Forward / a redo button to journey navigation. + * + * **Does NOT re-run the transition handler.** Side effects from the + * original handler (API calls, telemetry, etc.) are not re-invoked. + * For a `rollback`-mode entry, any edits the user made between the + * rewind and the redo are discarded — the captured state wins. + * + * No-op when the id is unknown, the instance is terminal / loading, + * a child journey is in flight, or the future stack is empty (any + * fresh `exit`-driven transition clears it, matching browser + * back/forward). + */ + goForward(id: InstanceId): void; /** * Force-terminate an instance. Fires `onAbandon` if still active; no-op if * the instance is already terminal or unknown. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0d5d6c6..8d54e7b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -400,6 +400,13 @@ export interface ModuleEntryProps { * entry declared `allowBack` and the host has a prior step in history. */ readonly goBack?: () => void; + /** + * Host-provided "redo" callback — inverse of `goBack`. Present + * only when the future stack has a redo target. Most shells wire + * Forward at the shell level (browser button); this is for steps + * that surface an in-page redo control. + */ + readonly goForward?: () => void; } /** diff --git a/packages/journeys/README.md b/packages/journeys/README.md index 7447c4f..24bf9a7 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -365,7 +365,7 @@ Two additive (optional) fields on `ModuleDescriptor`: | `entryPoints` | `{ [name]: { component, input?, allowBack?, buildInput? } }`
or `{ [name]: { lazy: () => import("./X"), fallback?, input?, allowBack?, buildInput? } }` | Typed ways to open the module. A module can expose several. Each entry is either eager (a directly-bound component) or lazy (a dynamic-import factory — see [Pattern - lazy entry-points](#pattern---lazy-entry-points-code-splitting-per-step)). `buildInput?: (state) => TInput` derives the step's input from the host journey's state at every entry — see [Pattern - `buildInput` for re-entered forms](#pattern---buildinput-for-re-entered-forms). | | `exitPoints` | `{ [name]: { output? } }` | The module's full outcome vocabulary. | -`ModuleEntryProps` typed props for the component - `{ input, exit, goBack? }`, with `exit(name, output)` cross-checked against `TExits` at compile time. +`ModuleEntryProps` typed props for the component - `{ input, exit, goBack?, goForward? }`, with `exit(name, output)` cross-checked against `TExits` at compile time. `goForward` is the inverse of `goBack` — present only when the future / redo stack has an entry to restore (i.e. the user just called `goBack` and no fresh exit has cleared the redo target). Most shells wire Forward at the shell level (browser button); the per-component prop is for steps that surface an in-page redo control. Exits are **module-level, not per-entry** - every entry on a module shares the same `exitPoints` vocabulary. The journey's transition map (not the module) decides which exits a given entry actually uses, so two entries on the same module can map the same exit name to entirely different next steps. @@ -394,6 +394,19 @@ transitions: { A `resolveManifest()` error surfaces if the two sides disagree. +### `goForward` — redoing a `goBack` + +Each `goBack` pushes the step it rewinds from (plus state + rollback snapshot) onto a per-instance **future stack**. `runtime.goForward(id)` (or `ModuleEntryProps.goForward?.()`) pops the top of that stack and restores the runtime to the rewound step. The captured *post-transition* state wins — for a `rollback`-mode entry, edits the user made between the rewind and the redo are discarded. + +Key points: + +- **Not gated by `allowBack`.** The future entry was created by a `goBack` that already passed both opt-ins; the inverse is always valid. +- **Not the same as re-firing the exit.** Transition handlers don't re-run on `goForward`, so API calls / telemetry / persistence in the handler body fire once, not twice. If business logic needs to re-execute, the shell fires the exit itself. +- **Not symmetric with `goBack` on `buildInput`.** `goBack` re-runs `buildInput` against the rolled-back state. `goForward` trusts the step's captured `input` verbatim (the input was already built against the same state being restored; re-running a non-pure `buildInput` would diverge from what the original transition produced). +- **Cleared by any fresh exit.** A `{ next | complete | abort }` arm wipes the future stack (matches browsers — a new navigation drops Forward). `invoke` leaves it intact (parent step doesn't advance while a child runs). +- **Transient.** Not persisted: reload always starts with an empty future stack, same as opening a new browser tab. +- **Inspectable.** `JourneyInstance.future: readonly JourneyStep[]` exposes the stack so shells can gate a Forward button on `instance.future.length > 0` without reaching into runtime internals. Top of stack is the next step a redo would land on. + ### Transition handlers are pure and synchronous - No `await`. @@ -609,7 +622,7 @@ Mismatched declarations are caught at `resolveManifest()` / `resolve()` time via ### Pattern - `buildInput` for re-entered forms -Without `buildInput`, a step's `input` is captured at first push and reused on every later entry — so a back-navigated form re-renders against the snapshot it was opened with, not the values accumulated by the user's later edits. `buildInput` flips that default: the runtime calls it on every step entry (initial start, forward push, `goBack` pop, resume-into-step) AND when a resume bumps state on the same step without advancing it. The returned value becomes the live `input`. (An `{ invoke }` arm carrying `state` is the one excluded case — the parent's form is paused while the child runs, so rebuilding the hidden input would be wasted work; `buildInput` re-fires naturally on the resume's `{ next }`.) +Without `buildInput`, a step's `input` is captured at first push and reused on every later entry — so a back-navigated form re-renders against the snapshot it was opened with, not the values accumulated by the user's later edits. `buildInput` flips that default: the runtime calls it on every step entry (initial start, forward push, `goBack` pop, resume-into-step) AND when a resume bumps state on the same step without advancing it. The returned value becomes the live `input`. Two excluded cases: (1) an `{ invoke }` arm carrying `state` — the parent's form is paused while the child runs, so rebuilding the hidden input would be wasted work; `buildInput` re-fires naturally on the resume's `{ next }`. (2) `runtime.goForward` — the redo restores a captured step whose `input` was already built against the same state being restored, so re-deriving would diverge from what the original transition produced. ```ts interface ProjectState { @@ -1455,6 +1468,19 @@ interface JourneyRuntime { */ goBack(id: InstanceId): void; + /** + * Redo the most recent `goBack` — restore the captured post-transition + * `state` + `step` so the runtime lands back where the user just + * rewound from. Lets shells wire browser Forward (or a redo button) + * to journey navigation instead of letting the URL drift ahead of + * the runtime. Does NOT re-run the transition handler; the captured + * state wins, so for `rollback`-mode entries any edits made between + * the rewind and the redo are discarded. No-op when the future + * stack is empty (any fresh exit clears it, matching browser + * back/forward semantics). + */ + goForward(id: InstanceId): void; + /** * Force-terminate an instance. Fires `onAbandon` if still active; * no-op if already terminal or unknown. diff --git a/packages/journeys/src/outlet.tsx b/packages/journeys/src/outlet.tsx index efe4903..1674714 100644 --- a/packages/journeys/src/outlet.tsx +++ b/packages/journeys/src/outlet.tsx @@ -286,7 +286,7 @@ export function JourneyOutlet(props: JourneyOutletProps): ReactNode { const record = internals.__getRecord(instance.id); const reg = internals.__getRegistered(instance.journeyId); if (!record || !reg) return null; - const { exit, goBack } = internals.__bindStepCallbacks(record, reg); + const { exit, goBack, goForward } = internals.__bindStepCallbacks(record, reg); const handleError = (err: unknown): void => { // Registration-level onError fires on every component throw — shell @@ -348,6 +348,7 @@ export function JourneyOutlet(props: JourneyOutletProps): ReactNode { input: step.input, exit, goBack, + goForward, }), ), ); diff --git a/packages/journeys/src/runtime-go-forward.test.ts b/packages/journeys/src/runtime-go-forward.test.ts new file mode 100644 index 0000000..67098b4 --- /dev/null +++ b/packages/journeys/src/runtime-go-forward.test.ts @@ -0,0 +1,525 @@ +import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { describe, expect, it, vi } from "vitest"; + +import { defineJourney } from "./define-journey.js"; +import { createJourneyRuntime } from "./runtime.js"; +import { createTestHarness } from "./testing.js"; + +const exits = { + next: defineExit(), +} as const; + +const stepA = defineModule({ + id: "a", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + }), + }, +}); + +const stepB = defineModule({ + id: "b", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + }), + }, +}); + +const stepC = defineModule({ + id: "c", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + }), + }, +}); + +type Modules = { + readonly a: typeof stepA; + readonly b: typeof stepB; + readonly c: typeof stepC; +}; + +interface State { + readonly stamp: number; +} + +const journey = defineJourney()({ + id: "three-step", + version: "1.0.0", + initialState: () => ({ stamp: 0 }), + start: () => ({ module: "a", entry: "show", input: undefined }), + transitions: { + a: { + show: { + next: ({ state }) => ({ + state: { stamp: state.stamp + 1 }, + next: { module: "b", entry: "show", input: undefined }, + }), + }, + }, + b: { + show: { + allowBack: true, + next: ({ state }) => ({ + state: { stamp: state.stamp + 10 }, + next: { module: "c", entry: "show", input: undefined }, + }), + }, + }, + c: { + show: { + allowBack: true, + next: ({ state }) => ({ state, complete: undefined }), + }, + }, + }, +}); + +function setup() { + const runtime = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: { a: stepA, b: stepB, c: stepC }, + }); + const id = runtime.start(journey.id, undefined); + const harness = createTestHarness(runtime); + return { runtime, id, harness }; +} + +describe("runtime.goForward(id)", () => { + it("re-applies the last goBack — restores the step the user just rewound from", () => { + const { runtime, id, harness } = setup(); + + harness.fireExit(id, "next"); // a → b + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + + runtime.goBack(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("a"); + + runtime.goForward(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + }); + + it("restores the state captured at the rewound step (not the rolled-back state)", () => { + const { runtime, id, harness } = setup(); + + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 0 }); + harness.fireExit(id, "next"); // a → b, state becomes { stamp: 1 } + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 1 }); + + runtime.goBack(id); + // `preserve-state` keeps state at the rolled-back value (no + // rollback snapshot was taken because the entry isn't 'rollback' + // mode), so state stays as {stamp:1} after the rewind. + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 1 }); + + runtime.goForward(id); + // Redo restores the post-transition state captured at the moment + // of `goBack` — same { stamp: 1 } reference content here. + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 1 }); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + }); + + it("works across multiple rewinds: back twice, forward twice", () => { + const { runtime, id, harness } = setup(); + + harness.fireExit(id, "next"); // a → b + harness.fireExit(id, "next"); // b → c + expect(runtime.getInstance(id)?.step?.moduleId).toBe("c"); + + runtime.goBack(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + runtime.goBack(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("a"); + + runtime.goForward(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + runtime.goForward(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("c"); + }); + + it("is a no-op when the future stack is empty (no preceding goBack)", () => { + const { runtime, id, harness } = setup(); + + harness.fireExit(id, "next"); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + + runtime.goForward(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + }); + + it("is a no-op for unknown ids", () => { + const { runtime } = setup(); + expect(() => runtime.goForward("does-not-exist")).not.toThrow(); + }); + + it("is a no-op on a loading instance", async () => { + // Async persistence holds the instance in `status: "loading"` while + // `load()` is unresolved. `goForward` must be safe to call in that + // window — the `status !== "active"` guard short-circuits before + // any state mutation. + let resolveLoad: (blob: unknown) => void = () => {}; + const loadPromise = new Promise((r) => { + resolveLoad = r; + }); + const rt = createJourneyRuntime( + [ + { + definition: journey, + options: { + persistence: { + keyFor: () => "k:goforward-loading", + load: () => loadPromise, + save: async () => {}, + remove: async () => {}, + } as never, + }, + }, + ], + { modules: { a: stepA, b: stepB, c: stepC } }, + ); + const id = rt.start(journey.id, undefined); + expect(rt.getInstance(id)?.status).toBe("loading"); + + expect(() => rt.goForward(id)).not.toThrow(); + expect(rt.getInstance(id)?.status).toBe("loading"); + expect(rt.getInstance(id)?.future).toEqual([]); + + // Let the load settle so the test doesn't leak a pending promise + // into the next case. + resolveLoad(null); + await Promise.resolve(); + await Promise.resolve(); + }); + + it("is a no-op on a terminal instance", () => { + const { runtime, id, harness } = setup(); + + harness.fireExit(id, "next"); // a → b + runtime.goBack(id); // rewind so the future stack is non-empty + expect(runtime.getInstance(id)?.step?.moduleId).toBe("a"); + + runtime.end(id); + expect(runtime.getInstance(id)?.status).toBe("aborted"); + + runtime.goForward(id); + // Terminal instance must not get pulled back into "active" by a + // stray redo call — the guard short-circuits before any state + // mutation. + expect(runtime.getInstance(id)?.status).toBe("aborted"); + }); + + it("a fresh exit-driven transition clears the future stack (browser semantics)", () => { + const { runtime, id, harness } = setup(); + + harness.fireExit(id, "next"); // a → b + harness.fireExit(id, "next"); // b → c + runtime.goBack(id); // c → b — future now has [c] + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + + // User re-submits from step b — a new forward navigation. The + // forward stack should drop, just like a browser does when the + // user navigates somewhere new from a back-pressed location. + harness.fireExit(id, "next"); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("c"); + + runtime.goForward(id); + // Future was cleared by the exit above; the redo is now a no-op + // and we stay at c. + expect(runtime.getInstance(id)?.step?.moduleId).toBe("c"); + }); + + it("restores history + rollbackSnapshots so subsequent goBack still works", () => { + const { runtime, id, harness } = setup(); + + harness.fireExit(id, "next"); // a → b + expect(runtime.getInstance(id)?.history).toHaveLength(1); + + runtime.goBack(id); + expect(runtime.getInstance(id)?.history).toHaveLength(0); + + runtime.goForward(id); + // After redo, the rewound step is back at the top of history so + // pressing Back again behaves identically to before the rewind. + expect(runtime.getInstance(id)?.history).toHaveLength(1); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + + runtime.goBack(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("a"); + }); + + it("exposes the redo target on the public JourneyInstance.future snapshot", () => { + const { runtime, id, harness } = setup(); + + expect(runtime.getInstance(id)?.future).toEqual([]); + + harness.fireExit(id, "next"); // a → b + expect(runtime.getInstance(id)?.future).toEqual([]); + + runtime.goBack(id); // b → a, future now [b] + const future = runtime.getInstance(id)?.future ?? []; + expect(future).toHaveLength(1); + // Top of stack = next redo target. The full FutureEntry (state + + // snapshot) is internal; the public surface is just bare steps. + expect(future[future.length - 1]?.moduleId).toBe("b"); + expect(future[future.length - 1]?.entry).toBe("show"); + + runtime.goForward(id); + expect(runtime.getInstance(id)?.future).toEqual([]); + }); + + // Caveat: in practice the future stack is always empty by the time a + // child is in flight (`goBack` is itself a no-op during invoke, and + // exit-driven `invoke` clears `future` via `applyTransition`), so the + // assertion below effectively exercises the empty-future guard rather + // than the `activeChildId` guard. Real coverage of the latter would + // need to mutate `record.future` directly, which we don't do. Keeping + // the test as a "no mutation while a child is in flight" safety net. + it("does not mutate the runtime while a child journey is in flight", () => { + const childExits = { done: defineExit() } as const; + const childMod = defineModule({ + id: "child", + version: "1.0.0", + exitPoints: childExits, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + }), + }, + }); + type ChildModules = { readonly child: typeof childMod }; + const child = defineJourney>()({ + id: "child", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "child", entry: "show", input: undefined }), + transitions: { child: { show: { done: () => ({ complete: undefined }) } } }, + }); + + const parentExits = { next: defineExit(), invokeChild: defineExit() } as const; + const parentMod = defineModule({ + id: "p", + version: "1.0.0", + exitPoints: parentExits, + entryPoints: { + a: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + }), + b: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + }), + }, + }); + type ParentModules = { readonly p: typeof parentMod }; + const parent = defineJourney>()({ + id: "parent-invoke", + version: "1.0.0", + invokes: [{ id: "child", __input: undefined } as never], + initialState: () => ({}), + start: () => ({ module: "p", entry: "a", input: undefined }), + transitions: { + p: { + a: { + next: () => ({ next: { module: "p", entry: "b", input: undefined } }), + }, + b: { + allowBack: true, + invokeChild: () => ({ + invoke: { handle: { id: "child" } as never, input: undefined, resume: "back" }, + }), + }, + }, + }, + resumes: { + p: { + b: { + back: ({ state }) => ({ state }), + }, + }, + }, + }); + + const runtime = createJourneyRuntime( + [ + { definition: parent, options: undefined }, + { definition: child, options: undefined }, + ], + { modules: { p: parentMod, child: childMod } }, + ); + const id = runtime.start(parent.id, undefined); + const harness = createTestHarness(runtime); + + harness.fireExit(id, "next"); // a → b + runtime.goBack(id); // b → a, future has [b] + expect(runtime.getInstance(id)?.future).toHaveLength(1); + + // Re-advance to b, then invoke a child from b. + harness.fireExit(id, "next"); // a → b (clears future) + expect(runtime.getInstance(id)?.future).toHaveLength(0); + harness.fireExit(id, "invokeChild"); // b invokes child + expect(runtime.getInstance(id)?.activeChildId).not.toBeNull(); + + // Even if we tried to populate the future stack now, `goBack` on the + // parent is also a no-op while a child is in flight — so there's no + // realistic way to have a non-empty future and an active child + // concurrently. The behavior still has to be safe: assert + // goForward doesn't mutate. + const before = runtime.getInstance(id)?.step; + runtime.goForward(id); + expect(runtime.getInstance(id)?.step).toEqual(before); + }); + + it("re-arms hasRollbackSnapshot on goForward of a rollback-mode entry", () => { + // Use a journey whose target step opts into 'rollback' mode so + // applyTransition captures a real (non-undefined) snapshot at the + // pre-transition state. + const exits2 = { advance: defineExit() } as const; + const start = defineModule({ + id: "start", + version: "1.0.0", + exitPoints: exits2, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "rollback", + }), + }, + }); + const target = defineModule({ + id: "target", + version: "1.0.0", + exitPoints: exits2, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "rollback", + }), + }, + }); + type Mods = { readonly start: typeof start; readonly target: typeof target }; + const j = defineJourney()({ + id: "rollback-redo", + version: "1.0.0", + initialState: () => ({ stamp: 0 }), + start: () => ({ module: "start", entry: "show", input: undefined }), + transitions: { + start: { + show: { + advance: ({ state }) => ({ + state: { stamp: state.stamp + 1 }, + next: { module: "target", entry: "show", input: undefined }, + }), + }, + }, + target: { + show: { + allowBack: true, + advance: ({ state }) => ({ state, complete: undefined }), + }, + }, + }, + }); + + const runtime = createJourneyRuntime([{ definition: j, options: undefined }], { + modules: { start, target }, + }); + const id = runtime.start(j.id, undefined); + const harness = createTestHarness(runtime); + + harness.fireExit(id, "advance"); // start → target, state becomes {stamp:1} + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 1 }); + + runtime.goBack(id); + // Rollback restored the pre-transition state. + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 0 }); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("start"); + + runtime.goForward(id); + // Captured post-transition state restored verbatim — this is the + // documented "edits between rewind and redo are discarded" path. + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 1 }); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("target"); + + // Going back again must still rewind correctly, which depends on + // the popped snapshot being re-attached to rollbackSnapshots and + // the `hasRollbackSnapshot` flag being recomputed. + runtime.goBack(id); + expect(runtime.getInstance(id)?.state).toEqual({ stamp: 0 }); + }); + + it("notifies subscribers on goForward", () => { + // useSyncExternalStore-backed hooks depend on this — without a + // notify call the React tree wouldn't observe the redo. + const { runtime, id, harness } = setup(); + harness.fireExit(id, "next"); + runtime.goBack(id); + + let notified = 0; + const unsubscribe = runtime.subscribe(id, () => { + notified += 1; + }); + runtime.goForward(id); + unsubscribe(); + + expect(notified).toBeGreaterThan(0); + }); + + it("persists the post-redo blob so a reload would restore the redone step", async () => { + // The redo path isn't persistence-aware on its own — it relies on + // the same `schedulePersist` call every other transition path + // makes. Pin the contract: after goBack + goForward, the most + // recent saved blob should reflect the redone step, not the + // pre-redo state. + const store = new Map(); + const persistence = { + keyFor: () => "two-step:goforward", + load: (k: string) => (store.get(k) as any) ?? null, + save: vi.fn(async (k: string, b: any) => { + store.set(k, b); + }), + remove: async (k: string) => { + store.delete(k); + }, + }; + const rt = createJourneyRuntime( + [{ definition: journey, options: { persistence: persistence as never } }], + { modules: { a: stepA, b: stepB, c: stepC } }, + ); + const id = rt.start(journey.id, undefined); + const harness = createTestHarness(rt); + + harness.fireExit(id, "next"); // a → b + rt.goBack(id); // b → a, future has [b] + persistence.save.mockClear(); + + rt.goForward(id); // a → b again + // Let the scheduled persist micro-task flush. + await Promise.resolve(); + await Promise.resolve(); + + expect(persistence.save).toHaveBeenCalled(); + const blob = persistence.save.mock.calls.at(-1)?.[1] as { step?: { moduleId: string } }; + expect(blob?.step?.moduleId).toBe("b"); + }); +}); diff --git a/packages/journeys/src/runtime.ts b/packages/journeys/src/runtime.ts index a77f871..9f45318 100644 --- a/packages/journeys/src/runtime.ts +++ b/packages/journeys/src/runtime.ts @@ -54,6 +54,16 @@ const DEFAULT_MAX_CALL_STACK_DEPTH = 16; */ const DEFAULT_MAX_RESUME_BOUNCES_PER_STEP = 8; +/** + * Redo target captured at `goBack` time: the step + state to restore + * and the snapshot to put back on `rollbackSnapshots`. + */ +interface FutureEntry { + readonly step: JourneyStep; + readonly state: TState; + readonly snapshot: TState | undefined; +} + export interface InstanceRecord { id: InstanceId; journeyId: string; @@ -64,6 +74,8 @@ export interface InstanceRecord { rollbackSnapshots: (TState | undefined)[]; /** True when any entry in `rollbackSnapshots` holds a real snapshot. */ hasRollbackSnapshot: boolean; + /** Redo stack. See `JourneyRuntime.goForward`. */ + future: FutureEntry[]; state: TState; terminalPayload: unknown; startedAt: string; @@ -111,11 +123,12 @@ export interface InstanceRecord { revision: number; /** Cached snapshot keyed by `revision`; rebuilt on the next read if stale. */ cachedSnapshot: { revision: number; instance: JourneyInstance } | null; - /** Cached exit/goBack closures keyed by stepToken. */ + /** Cached exit/goBack/goForward closures keyed by stepToken. */ cachedCallbacks: { stepToken: number; exit: (name: string, output?: unknown) => void; goBack: (() => void) | undefined; + goForward: (() => void) | undefined; } | null; /** * Per-step bounce counter for the resume-bounce-limit guard. Set when @@ -1273,6 +1286,9 @@ export function createJourneyRuntime( // the reset uniformly. if ("next" in result || "complete" in result || "abort" in result) { record.resumeBouncesAtStep = null; + // Any non-`invoke` arm invalidates the redo stack — matches + // browser back/forward, where a fresh navigation drops Forward. + if (record.future.length > 0) record.future = []; } if ("next" in result) { @@ -1776,6 +1792,8 @@ export function createJourneyRuntime( const previousStep = record.history.pop()!; const snapshot = record.rollbackSnapshots.pop(); + // Capture the redo target before we mutate `step` / `state`. + record.future.push({ step, state: record.state, snapshot }); const mode = entryAllowBackMode(step); if (mode === "rollback" && snapshot !== undefined) { record.state = snapshot; @@ -1811,6 +1829,44 @@ export function createJourneyRuntime( notify(record); } + // Inverse of `dispatchGoBack`. See `JourneyRuntime.goForward` JSDoc + // for restoration semantics. + function dispatchGoForward(record: InstanceRecord, reg: RegisteredJourney, stepToken: number) { + if (record.status !== "active") return; + if (record.activeChildId) return; + if (record.stepToken !== stepToken) return; + if (record.future.length === 0) return; + + const fromStep = record.step; + if (!fromStep) return; + + const next = record.future.pop()!; + // Restore history symmetrically to goBack's pop. The step we're + // leaving (current) becomes the new top of history; the snapshot + // we popped earlier on goBack rejoins `rollbackSnapshots` so a + // subsequent goBack rewinds correctly. + record.history.push(fromStep); + record.rollbackSnapshots.push(next.snapshot); + // Push can only flip the flag to true, never to false (we never + // remove a real snapshot here) — avoid the O(n) rescan that + // `dispatchGoBack` needs after its pop. + if (next.snapshot !== undefined) record.hasRollbackSnapshot = true; + record.state = next.state; + // Trust the captured step's built input — re-running buildInput + // here would derive against the just-restored state and could + // diverge from what the original transition produced. Asymmetric + // with `dispatchGoBack`, which re-runs buildInput on re-entry. + record.step = next.step; + record.resumeBouncesAtStep = null; + record.stepToken += 1; + record.updatedAt = nowIso(); + record.cachedCallbacks = null; + fireOnTransition(reg, record, fromStep, record.step, null); + const persistence = reg.options?.persistence; + if (persistence) schedulePersist(record, persistence); + notify(record); + } + function bindStepCallbacks(record: InstanceRecord, reg: RegisteredJourney) { if (record.cachedCallbacks && record.cachedCallbacks.stepToken === record.stepToken) { return record.cachedCallbacks; @@ -1842,7 +1898,18 @@ export function createJourneyRuntime( dispatchGoBack(record, reg, token); } : undefined; - record.cachedCallbacks = { stepToken: token, exit, goBack }; + // Forward / redo closure — gated on the future stack having an + // entry to restore. Unlike `goBack` this does not check + // `journeyAllowsBack` / `entryAllowBackMode`: the future was only + // populated by a `goBack` that already passed those guards, so + // redoing the inverse is always valid by construction. + const canGoForward = record.future.length > 0; + const goForward = canGoForward + ? () => { + dispatchGoForward(record, reg, token); + } + : undefined; + record.cachedCallbacks = { stepToken: token, exit, goBack, goForward }; return record.cachedCallbacks; } @@ -1860,12 +1927,18 @@ export function createJourneyRuntime( // snapshot. Cheap — bounded by the history cap, and only rebuilt when // the revision bumps. const historySnapshot: readonly JourneyStep[] = [...record.history]; + // Map the future stack to bare steps for the public snapshot — the + // full `FutureEntry` (with captured state + rollback snapshot) is + // an internal detail of how `goForward` restores. Consumers only + // need to know what step a redo would land on. + const futureSnapshot: readonly JourneyStep[] = record.future.map((f) => f.step); const instance: JourneyInstance = { id: record.id, journeyId: record.journeyId, status: record.status, step: record.step, history: historySnapshot, + future: futureSnapshot, state: record.state, terminalPayload: record.status === "completed" || record.status === "aborted" @@ -1899,6 +1972,7 @@ export function createJourneyRuntime( history: [], rollbackSnapshots: [], hasRollbackSnapshot: false, + future: [], state: initialState, terminalPayload: undefined, startedAt, @@ -1940,6 +2014,7 @@ export function createJourneyRuntime( record.history = []; record.rollbackSnapshots = []; record.hasRollbackSnapshot = false; + record.future = []; } const rawStart = stepFromSpec(def.start(record.state, input)); const builtStart = withBuiltInput(rawStart, record.state); @@ -1992,6 +2067,8 @@ export function createJourneyRuntime( record.state = blob.state; record.step = blob.step; record.history = [...blob.history]; + // Redo stack is transient; hydration always starts empty. + record.future = []; if (blob.rollbackSnapshots) { record.rollbackSnapshots = blob.rollbackSnapshots.map((s) => s === null ? undefined : s, @@ -2460,6 +2537,17 @@ export function createJourneyRuntime( dispatchGoBack(record, reg, record.stepToken); }, + goForward(id) { + const record = instances.get(id); + if (!record) return; + const reg = definitions.get(record.journeyId); + if (!reg) return; + // Same id-based stale-token argument as `goBack` above: + // `dispatchGoForward`'s own guards cover terminal / loading / + // active-child / empty-future cases. + dispatchGoForward(record, reg, record.stepToken); + }, + end(id, reason) { const record = instances.get(id); if (!record) return; @@ -2651,6 +2739,7 @@ export interface JourneyRuntimeInternals { ): { exit: (name: string, output?: unknown) => void; goBack?: () => void; + goForward?: () => void; stepToken: number; }; __getRecord(id: InstanceId): InstanceRecord | undefined; diff --git a/packages/journeys/src/simulate-journey.test.ts b/packages/journeys/src/simulate-journey.test.ts index c8c8bcb..4f5cc3c 100644 --- a/packages/journeys/src/simulate-journey.test.ts +++ b/packages/journeys/src/simulate-journey.test.ts @@ -108,4 +108,66 @@ describe("simulateJourney", () => { sim.fireExit("pick", { pick: "b" }); expect(secondEvent.history.length).toBe(historyLengthAtEmit); }); + + it("goForward is a no-op when the future stack is empty", () => { + // Matches the documented contract on JourneySimulator (and the + // public `runtime.goForward`). The harness throws in this case + // — the simulator must not propagate that. + const sim = simulateJourney(journey); + expect(() => sim.goForward()).not.toThrow(); + expect(sim.currentStep).toEqual({ moduleId: "menu", entry: "choose", input: undefined }); + }); + + it("goBack + goForward round-trips through the future stack", () => { + const exits2 = { next: defineExit() } as const; + const a = defineModule({ + id: "a", + version: "1.0.0", + exitPoints: exits2, + entryPoints: { + show: defineEntry({ + component: (() => null) as any, + input: schema(), + allowBack: "preserve-state", + }), + }, + }); + const b = defineModule({ + id: "b", + version: "1.0.0", + exitPoints: exits2, + entryPoints: { + show: defineEntry({ + component: (() => null) as any, + input: schema(), + allowBack: "preserve-state", + }), + }, + }); + type M = { readonly a: typeof a; readonly b: typeof b }; + const twoStep = defineJourney>()({ + id: "two-step-sim", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "show", input: undefined }), + transitions: { + a: { + show: { + next: () => ({ next: { module: "b", entry: "show", input: undefined } }), + }, + }, + b: { show: { allowBack: true, next: () => ({ complete: undefined }) } }, + }, + }); + + const sim = simulateJourney(twoStep); + sim.fireExit("next"); + expect(sim.currentStep.moduleId).toBe("b"); + + sim.goBack(); + expect(sim.currentStep.moduleId).toBe("a"); + + sim.goForward(); + expect(sim.currentStep.moduleId).toBe("b"); + }); }); diff --git a/packages/journeys/src/simulate-journey.ts b/packages/journeys/src/simulate-journey.ts index d5d0706..e62ce32 100644 --- a/packages/journeys/src/simulate-journey.ts +++ b/packages/journeys/src/simulate-journey.ts @@ -55,6 +55,18 @@ export interface JourneySimulator { fireExit(name: string, output?: unknown): void; goBack(): void; + /** + * Mirror of `runtime.goForward(id)` — re-apply the most recent + * rewind that `goBack` performed on this simulator's instance. + * + * **No-op when the future stack is empty** — matches the runtime's + * `goForward` semantics. This is asymmetric with `goBack`, which + * throws when invoked on an empty history (so misordered test + * setup fails loudly). A misordered `goForward` in a test will + * silently no-op, so assert the resulting `step` / `state` rather + * than relying on an exception to catch the mistake. + */ + goForward(): void; end(reason?: unknown): void; /** * Serialize the simulator's current instance into the same blob shape @@ -301,6 +313,13 @@ function wrapInstanceAsSim( goBack() { harness.goBack(instanceId); }, + goForward() { + // Delegate to the runtime directly, not `harness.goForward` — + // the harness throws when the future stack is empty (so test + // authors notice misordered setup), but the simulator's + // contract is the same no-op shape as `runtime.goForward`. + runtime.goForward(instanceId); + }, end(reason) { runtime.end(instanceId, reason); }, diff --git a/packages/journeys/src/testing.ts b/packages/journeys/src/testing.ts index a409c81..d4dc66f 100644 --- a/packages/journeys/src/testing.ts +++ b/packages/journeys/src/testing.ts @@ -34,6 +34,7 @@ export interface InstanceSnapshot { export interface JourneyTestHarness { fireExit(id: InstanceId, name: string, output?: unknown): void; goBack(id: InstanceId): void; + goForward(id: InstanceId): void; inspect(id: InstanceId): InstanceSnapshot; } @@ -107,6 +108,33 @@ export function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness { } callbacks.goBack(); }, + goForward(id) { + const record = recordOrThrow(id); + const reg = internals.__getRegistered(record.journeyId); + if (!reg) { + throw new Error( + `[@modular-react/journeys/testing] Journey "${record.journeyId}" is not registered with this runtime.`, + ); + } + if (record.status === "loading") { + throw new Error( + `[@modular-react/journeys/testing] goForward() called on instance "${id}" while status=loading. ` + + `Await the runtime's async load probe before dispatching.`, + ); + } + const callbacks = internals.__bindStepCallbacks(record, reg); + if (!callbacks.goForward) { + // Empty future stack is the common case — fail loudly with a + // hint so the test author doesn't silently no-op past a + // missing `goBack` setup or a future-clearing exit between + // the rewind and the redo attempt. + throw new Error( + `[@modular-react/journeys/testing] goForward is unavailable on instance "${id}". ` + + `The runtime's future stack is empty — call goBack first, and don't fire a new exit between the rewind and the redo.`, + ); + } + callbacks.goForward(); + }, inspect(id: InstanceId): InstanceSnapshot { const record = recordOrThrow(id); // Snapshot — `history` is a live array on the runtime record and will