Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Per-package detail lives in the GitHub release tagged `<npm-name>@<version>`.

- **`@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<T>(id)`, `useActiveLeafJourneyInstance(rootId)`, and `useActiveLeafJourneyState<T>(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<TModules>`. 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<TInput = unknown>` (backwards-compatible — existing usages default to the wide form). The `simulateJourney` `JourneySimulator<TModules, TState>`'s `step` / `currentStep` / `history` now use the typed union, so tests can assert on per-entry input shapes without `Record<string, unknown>` 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<typeof simulateJourney>[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).
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/journey-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,12 @@ export interface JourneyInstance<TState = unknown> {
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;
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,13 @@ export interface ModuleEntryProps<TInput, TExits extends ExitPointMap = {}> {
* 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;
}

/**
Expand Down
30 changes: 28 additions & 2 deletions packages/journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ Two additive (optional) fields on `ModuleDescriptor`:
| `entryPoints` | `{ [name]: { component, input?, allowBack?, buildInput? } }`<br>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<TInput, TExits>` typed props for the component - `{ input, exit, goBack? }`, with `exit(name, output)` cross-checked against `TExits` at compile time.
`ModuleEntryProps<TInput, TExits>` 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.

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/journeys/src/outlet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -348,6 +348,7 @@ export function JourneyOutlet(props: JourneyOutletProps): ReactNode {
input: step.input,
exit,
goBack,
goForward,
}),
),
);
Expand Down
Loading
Loading