diff --git a/CHANGELOG.md b/CHANGELOG.md index de02bd8..0ceaf8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Per-package detail lives in the GitHub release tagged `@`. ## Unreleased +### Added — journey runtime additions (EXP-1848 adoption follow-up) + +- **`@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/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). + ### Behavior changes - **`@modular-react/core`** — `buildNavigationManifest` (and therefore `useNavigation`) now breaks ties on `order` by preserving insertion order instead of label string comparison. Items declared first render first when `order` is unset or equal: modules in registration order, items in the order declared in each module's `navigation` array, plugin-contributed items last. Labels are no longer a tiebreaker — the previous fallback sorted by i18n-key name (e.g. `appShell.nav.assets` < `appShell.nav.projects`), which produced surprising orderings unrelated to translated text. Apps that relied (intentionally or not) on alphabetical-by-key fallback should set explicit `order` values to lock in the desired sequence. diff --git a/packages/core/src/entry-exit.test-d.ts b/packages/core/src/entry-exit.test-d.ts index 0d6e338..e34160a 100644 --- a/packages/core/src/entry-exit.test-d.ts +++ b/packages/core/src/entry-exit.test-d.ts @@ -10,7 +10,7 @@ import { expectTypeOf, test } from "vitest"; import type { ComponentType, ReactNode } from "react"; -import { defineEntry, schema } from "./entry-exit.js"; +import { buildInputFor, defineEntry, schema } from "./entry-exit.js"; import type { EagerModuleEntryPoint, LazyEntryComponent, @@ -100,3 +100,54 @@ test("ModuleEntryPoint is the union of Eager and Lazy variants", () => { type Expected = EagerModuleEntryPoint | LazyModuleEntryPoint; expectTypeOf>().toEqualTypeOf(); }); + +// ----------------------------------------------------------------------------- +// `buildInputFor` — typed wrapper for `buildInput` that bakes TState into the +// factory's `state` parameter. The returned function matches the entry's +// declared `(state: unknown) => TInput` shape regardless of consumer strictness. +// ----------------------------------------------------------------------------- + +interface ProjectState { + readonly draftName: string; + readonly draftEmail: string; +} + +test("buildInputFor narrows state to TState inside the factory and preserves TInput", () => { + const factory = buildInputFor()((state) => { + expectTypeOf(state).toEqualTypeOf(); + return { previousName: state.draftName }; + }); + expectTypeOf(factory).toEqualTypeOf<(state: unknown) => { previousName: string }>(); +}); + +test("buildInputFor wraps cleanly into defineEntry({ buildInput })", () => { + interface NameInput { + readonly previousName: string; + } + const NameComponent = ((_props: ModuleEntryProps) => null) as ComponentType< + ModuleEntryProps + >; + const entry = defineEntry({ + component: NameComponent, + input: schema(), + buildInput: buildInputFor()((state) => ({ + previousName: state.draftName, + })), + }); + expectTypeOf(entry).toMatchTypeOf>(); +}); + +test("buildInputFor's wrapper enforces the contextually-expected TInput when one is supplied", () => { + // When assigned to a position with an expected `(state: unknown) => TInput` + // shape, the inner factory's return is checked against TInput. + const ok: (state: unknown) => { previousName: string } = buildInputFor()( + (state) => ({ previousName: state.draftName }), + ); + expectTypeOf(ok).toEqualTypeOf<(state: unknown) => { previousName: string }>(); + + // @ts-expect-error — factory returns `{ wrong: number }`, not assignable to `{ previousName: string }`. + const bad: (state: unknown) => { previousName: string } = buildInputFor()(() => ({ + wrong: 1, + })); + void bad; +}); diff --git a/packages/core/src/entry-exit.ts b/packages/core/src/entry-exit.ts index 50892df..2fab3fd 100644 --- a/packages/core/src/entry-exit.ts +++ b/packages/core/src/entry-exit.ts @@ -35,6 +35,48 @@ export function defineEntry(entry: ModuleEntryPoint): ModuleEntr return entry; } +/** + * Typed wrapper for `defineEntry({ buildInput })` that bakes the + * hosting journey's `TState` into the factory's `state` parameter. + * Curried so callers spell `TState` explicitly while `TInput` infers + * from the function body. The visual shape matches `defineJourney`'s + * curry — explicit generics in the outer call, inferred ones in the + * inner — though the motivation differs: `defineJourney` uses two + * calls to work around TypeScript's lack of partial inference, while + * `buildInputFor` uses them so `TInput` infers cleanly from the + * function body alone. + * + * ```ts + * defineEntry({ + * component: NameForm, + * input: schema<{ previousName: string }>(), + * buildInput: buildInputFor()((state) => ({ + * previousName: state.draftName, + * })), + * }); + * ``` + * + * **What this catches**: the inline-annotation alternative — + * `buildInput: (state: ProjectState) => …` — relies on TypeScript + * allowing a narrower-parameter function to assign to the entry's + * declared `(state: unknown) => TInput`. Under `strictFunctionTypes` (or + * stricter consumer configs) that assignment can fail; this helper + * does the unknown→TState cast inside its body, so the *outer* signature + * always matches the entry's declared shape regardless of consumer + * strictness. + * + * **What this does NOT catch**: a mismatch between the spelled `TState` + * and the host journey's actual state type. Modules are + * journey-agnostic — nothing at the module-declaration site knows which + * journey will run them. Annotate carefully; integration / harness + * tests are the safety net for the cross-cut. + */ +export const buildInputFor = + () => + (fn: (state: TState) => TInput): ((state: unknown) => TInput) => + (state: unknown) => + fn(state as TState); + /** * Type-only brand that preserves inference of `TOutput` on a single * {@link ExitPointSchema}. Called with no arguments and no runtime cost — diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9fbc775..0ad2eb5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export type { // Entry / exit helpers export { + buildInputFor, defineEntry, defineExit, defineExitContract, @@ -120,6 +121,7 @@ export type { ExitOutputOf, StepSpec, JourneyStep, + JourneyStepFor, ExitCtx, TransitionResult, EntryTransitions, diff --git a/packages/core/src/journey-contracts.ts b/packages/core/src/journey-contracts.ts index 23aed91..45d9e46 100644 --- a/packages/core/src/journey-contracts.ts +++ b/packages/core/src/journey-contracts.ts @@ -87,13 +87,36 @@ export type StepSpec = { }[EntryNamesOf & string]; }[keyof TModules & string]; -/** Snapshot of a single step in a journey's history / current position. */ -export interface JourneyStep { +/** + * Snapshot of a single step in a journey's history / current position. + * The runtime stores history as the wide `JourneyStep` form; + * typed simulator / harness surfaces narrow at their boundary via + * {@link JourneyStepFor}. + * + * @see {@link JourneyStepFor} — discriminated-union counterpart that + * narrows `input` by `moduleId` + `entry`. + */ +export interface JourneyStep { readonly moduleId: string; readonly entry: string; - readonly input: unknown; + readonly input: TInput; } +/** + * Discriminated union of every concrete `JourneyStep` reachable in a + * journey's module map. Narrowing on `moduleId` + `entry` picks the + * correct `input` type. + */ +export type JourneyStepFor = { + [M in keyof TModules & string]: { + [E in EntryNamesOf & string]: { + readonly moduleId: M; + readonly entry: E; + readonly input: EntryInputOf; + }; + }[EntryNamesOf & string]; +}[keyof TModules & string]; + /** Context passed to a transition handler. */ export interface ExitCtx { readonly state: TState; @@ -664,6 +687,22 @@ export interface JourneyRuntime { isRegistered(journeyId: string): boolean; /** Subscribe to changes for one instance. Returns unsubscribe. */ subscribe(id: InstanceId, listener: () => void): () => void; + /** + * Pop the current step and re-enter the previous one — equivalent to the + * `goBack` callback the host hands the active step's component via + * {@link ModuleEntryProps.goBack}, but addressable by `instanceId` so a + * shell that owns its own back button (browser `popstate`, hardware back + * key, breadcrumb navigation) doesn't have to thread the active step's + * callback through a React context. + * + * No-op when the id is unknown, the instance is terminal / loading, + * a child journey is in flight (parent steps are paused while a child + * runs), the journey's transition does not opt in via `allowBack: true`, + * or history is empty. Matches the closure form: the `goBack` prop is + * `undefined` under the same conditions, and shells should treat both + * forms as a hint that the call is presently a no-op. + */ + goBack(id: InstanceId): void; /** * Force-terminate an instance. Fires `onAbandon` if still active; no-op if * the instance is already terminal or unknown. @@ -707,6 +746,13 @@ export function isTerminal(instance: JourneyInstance): boolean { * shells can narrow against this union when interpreting an abort * outcome — see {@link JourneySystemAbortReason} for the per-code * payload shape and {@link isJourneySystemAbort} for the type guard. + * + * **These string codes are reserved by the runtime.** An author-supplied + * `{ abort: { reason: "" } }` payload will be + * misidentified as a system abort by {@link isJourneySystemAbort}. Pick + * a distinct `reason` for author aborts (e.g. namespace with your + * app slug: `"acme.user-cancelled"`) to keep the narrowing predicate + * accurate. */ export type JourneySystemAbortReasonCode = | "invoke-cycle" @@ -727,7 +773,8 @@ export type JourneySystemAbortReasonCode = | "transition-error" | "transition-returned-promise" | "exit-payload-invalid" - | "exit-payload-invalid-async"; + | "exit-payload-invalid-async" + | "build-input-threw"; /** * Discriminated union of every abort payload the runtime emits. Each arm @@ -854,6 +901,22 @@ export type JourneySystemAbortReason = */ readonly reason: "exit-payload-invalid-async"; readonly exit: string; + } + | { + /** + * An entry's `buildInput(state)` factory threw while the runtime + * was deriving the step's input at entry time (initial start, + * forward push, `goBack` pop, or rebuild after a state-changing + * resume / invoke). The instance is aborted rather than entered + * with a half-built / stale input — leaving the form mounted with + * the pre-throw cached input would silently mis-render against + * accumulated state, which is exactly the bug `buildInput` exists + * to fix. + */ + readonly reason: "build-input-threw"; + readonly moduleId: string; + readonly entry: string; + readonly error: unknown; }; const JOURNEY_SYSTEM_ABORT_REASON_CODES: ReadonlySet = @@ -877,6 +940,7 @@ const JOURNEY_SYSTEM_ABORT_REASON_CODES: ReadonlySet = "transition-returned-promise", "exit-payload-invalid", "exit-payload-invalid-async", + "build-input-threw", ]); /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6d20e6f..0d5d6c6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -252,6 +252,26 @@ interface ModuleEntryPointBase { * false (default) — no `goBack` prop is supplied to the component. */ readonly allowBack?: "preserve-state" | "rollback" | false; + /** + * Optional factory that derives this entry's input from the current + * journey state every time the step is entered (push, pop / `goBack`, + * invoke return, initial start). When present, the runtime ignores the + * `input` value a transition handler placed on `next` and replaces it + * with `buildInput(state)`. + * + * Use this for steps that present data accumulated by earlier exits — + * back-navigating to a previous step then sees the up-to-date values + * the user already entered, instead of the stale snapshot the step was + * first pushed with. + * + * The `state` parameter is typed `unknown` at the module surface — + * modules don't know which journey hosts them, so authors annotate + * explicitly: `buildInput: (state) => { const s = state as MyState; … }`, + * or narrow via a parameter annotation when TS allows it. Pure, + * synchronous, called on the hot path; do not allocate work here that + * should run inside the component instead. + */ + readonly buildInput?: (state: unknown) => TInput; } /** diff --git a/packages/journeys/README.md b/packages/journeys/README.md index 8b0218c..7447c4f 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -360,10 +360,10 @@ See the [customer-onboarding-journey example](../../examples/react-router/custom Two additive (optional) fields on `ModuleDescriptor`: -| Field | Shape | Purpose | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `entryPoints` | `{ [name]: { component, input?, allowBack? } }`
or `{ [name]: { lazy: () => import("./X"), fallback?, input?, allowBack? } }` | 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)). | -| `exitPoints` | `{ [name]: { output? } }` | The module's full outcome vocabulary. | +| Field | Shape | Purpose | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. @@ -607,6 +607,88 @@ transitions: { Mismatched declarations are caught at `resolveManifest()` / `resolve()` time via `validateJourneyContracts` - the journey's `allowBack: true` with an entry that declared `allowBack: false` (or omitted it) is an aggregated validation error, not a runtime surprise. +### 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 }`.) + +```ts +interface ProjectState { + readonly draftName: string; + readonly draftSourceLang: string; +} + +interface NameInput { + readonly previousName: string; +} + +const nameModule = defineModule({ + id: "name", + version: "1.0.0", + exitPoints: { next: defineExit<{ name: string }>() }, + entryPoints: { + edit: defineEntry({ + component: NameForm, + input: schema(), + allowBack: "preserve-state", + // Annotate `state` with the hosting journey's TState — the module + // surface itself stays journey-agnostic (typed `unknown`). + buildInput: (state: ProjectState) => ({ previousName: state.draftName }), + }), + }, +}); +``` + +The transition handler still has to stamp some `input` value on `next` (the `StepSpec` shape requires it) — the runtime just ignores it whenever `buildInput` is declared and (in `debug` mode) warns once when the handler's value would have differed from `buildInput`'s output, so the divergence is observable. Treat the handler's `input` as a placeholder when you opt into `buildInput`. + +```ts +transitions: { + source: { + pick: { + allowBack: true, + next: ({ state, output }) => ({ + state: { ...state, draftSourceLang: output.lang }, + // `previousName` will be overwritten by `buildInput` at entry time. + next: { module: "name", entry: "edit", input: { previousName: "" } }, + }), + }, + }, +}, +``` + +When the user back-navigates from a later step to this one, `buildInput(state)` runs again and the form sees the latest `draftName` — no React context, no `useEffect`-syncing local form state to journey state. + +`buildInput` must be pure and synchronous; it runs on the runtime's hot path. **A throw aborts the instance with `{ reason: "build-input-threw", moduleId, entry, error }`** (a member of `JourneySystemAbortReason`) rather than entering with a half-built or stale input — leaving the form mounted with the pre-throw cached input would silently mis-render against accumulated state, which is exactly the bug `buildInput` exists to fix. Use `isJourneySystemAbort` to narrow. + +#### Notes + +- **State typing is on the author.** The module surface types `state` as `unknown` because modules don't know which journey hosts them. Two equivalent patterns for annotating it: + + ```ts + // 1. Inline parameter annotation — terse, no extra import. + buildInput: (state: ProjectState) => ({ previousName: state.draftName }), + + // 2. `buildInputFor()` wrapper — exported from `@modular-react/core`. + // Use this when the inline pattern trips your tsconfig + // (`strictFunctionTypes` can flag the narrowed `state` parameter as not + // assignable to the declared `(state: unknown) => TInput`), or when you + // just prefer named-generic positions over arrow-parameter annotations. + buildInput: buildInputFor()((state) => ({ + previousName: state.draftName, + })), + ``` + + Neither pattern verifies that `ProjectState` matches the host journey's actual `TState` — modules are journey-agnostic, so the cross-cut isn't expressible at the entry-declaration site. A wrong `TState` annotation passes silently in both forms. Treat journey-level integration / harness tests as the safety net. + +- **Step identity churns when `buildInput` is declared.** Each entry allocates a fresh `{ moduleId, entry, input }` object even when the derived `input` is structurally identical to the prior render's. Consumers relying on `instance.step` reference equality for memoization (`onTransition` listeners, custom `useSyncExternalStore` selectors) should compare by `(step.moduleId, step.entry)` or by a structural diff on `step.input`. Entries without `buildInput` keep the cache-on-push identity guarantee. + +> **Testing tip — always pass `options.modules` for `simulateJourney`.** Without it the runtime can't resolve module descriptors, so `buildInput` falls back to the cached handler-supplied input (silently degrading the behaviour under test) and `validateJourneyContracts` warnings about unbound modules surface in test output. The `modules` option is typed as `Record>` — the bivariant `any` on `TNavItem` means a heterogeneous map like `{ name: nameModule, email: emailModule }` passes structurally even when the host app narrows `TNavItem` to a custom action type. No `as unknown as` cast required. +> +> ```ts +> simulateJourney(journey, input, { +> modules: { name: nameModule, email: emailModule }, +> }); +> ``` + ## Journey definition patterns ### Pattern - branching on exit name @@ -1362,6 +1444,17 @@ interface JourneyRuntime { /** Subscribe to changes on one instance. Returns unsubscribe. */ subscribe(id: InstanceId, listener: () => void): () => void; + /** + * Pop the active step back to the previous one. Equivalent to the + * `goBack` prop the host hands the active step's component, but + * addressable by id so a shell that owns its own back button + * (browser `popstate`, hardware back, breadcrumb) can wire it + * directly. No-op when the id is unknown, the instance is terminal + * / loading, a child journey is in flight, the transition didn't + * opt into `allowBack: true`, or history is empty. + */ + goBack(id: InstanceId): void; + /** * Force-terminate an instance. Fires `onAbandon` if still active; * no-op if already terminal or unknown. @@ -2157,6 +2250,8 @@ All imports are `import type` - modules are **not** pulled into the journey's bu `StepSpec` expands to `{ module: 'profile'; entry: 'review'; input: {…} } | { module: 'plan'; entry: 'choose'; input: {…} } | …`. Every transition result that returns `{ next: … }` narrows the `input` type against the target entry. You cannot type-check your way into passing a wrong-shaped input - but only if the modules in the type map expose narrow `entryPoints` / `exitPoints` literals (i.e. the module descriptor was typed via `const` + `as const` or via `defineModule` called without shell-level generics - the canonical authoring pattern in [Authoring patterns](#authoring-patterns)). +`JourneyStepFor` is the snapshot counterpart — same per-entry input narrowing, but keyed by `moduleId` / `entry` (matching the persisted `JourneyStep` shape) instead of `module` / `entry`. The simulator's `step` / `currentStep` / `history` use this typed form, so tests can assert on `step.input` without a `Record` cast. The runtime itself stores history as the wide `JourneyStep`; the narrow union surfaces at the simulator / harness boundary. + ### `schema()` is a type brand, not a validator ```ts @@ -2184,21 +2279,22 @@ Every export you're likely to call, grouped by role. ### From `@modular-react/core` (module authors) -| Export | Signature | Purpose | -| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `defineEntry` | overloaded — `(e: EagerModuleEntryPoint \| LazyModuleEntryPoint) => same` | Identity helper. Two forms: eager (`{ component, input?, allowBack? }`) or lazy (`{ lazy: () => import(…), fallback?, input?, allowBack? }`). Mutually exclusive at the type level. | -| `defineExit` | `() => ExitPointSchema` | Identity helper for an exit-point literal. Zero runtime cost. | -| `defineExitContract` | overloaded — `(kind) => ExitContract` or `(kind, schema) => ExitContract>` | Shared exit contract identity. Use the same returned value as the schema for an exit on every module that emits it - the journey's wildcard transitions then narrow `output` to the contract's `T` and (when a schema is provided) the runtime validates payloads at every emit. See [Pattern - shared exit contracts](#pattern---shared-exit-contracts-defineexitcontract). | -| `isExitContract` | `(schema: unknown) => schema is ExitContract` | Type predicate distinguishing a contract from a plain `ExitPointSchema`. Used by the journey runtime and validators; exported for custom hosts. | -| `ExitContract` | `{ kind: string; schema?: StandardSchemaV1; output? }` | Shape of a shared contract. Extends `ExitPointSchema`; carries an identity (`kind`) and an optional Standard Schema for runtime validation. | -| `schema` | `() => InputSchema` | Type-only brand used to carry an input/output shape. Zero runtime cost. | -| `ModuleEntryProps` | `` | Typed props for an entry component: `{ input, exit, goBack? }`. | -| `ModuleEntryPoint` | `EagerModuleEntryPoint \| LazyModuleEntryPoint` | Discriminated union — eager (`component`) or lazy (`lazy`). | -| `EagerModuleEntryPoint` / `LazyModuleEntryPoint` | `{ component, input?, allowBack?, lazy?: never }` / `{ lazy, fallback?, input?, allowBack?, component?: never }` | The two branches of the union, exported for callers that want to type a single variant explicitly. | -| `LazyEntryComponent` | `() => Promise<{ default: ComponentType<…> } \| ComponentType<…>>` | Importer signature accepted by `defineEntry({ lazy })`. Both default-export and direct-export module shapes are normalized at runtime. | -| `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. | -| `ExitFn` | `(name, output?) => void` | The function signature `exit` gets on an entry component. | -| `EntryPointMap` / `ExitPointMap` | `Record>` / `Record>` | Map shapes on `ModuleDescriptor`. | +| Export | Signature | Purpose | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defineEntry` | overloaded — `(e: EagerModuleEntryPoint \| LazyModuleEntryPoint) => same` | Identity helper. Two forms: eager (`{ component, input?, allowBack?, buildInput? }`) or lazy (`{ lazy: () => import(…), fallback?, input?, allowBack?, buildInput? }`). Mutually exclusive at the type level. | +| `buildInputFor` | `() => (fn: (state: TState) => TInput) => (state: unknown) => TInput` | Curried typed wrapper for `defineEntry({ buildInput })`. Bakes the host journey's `TState` into the factory's `state` parameter, returning the `(state: unknown) => TInput` shape the entry expects. Alternative to inline `(state: TState) =>` annotations — pick this one if `strictFunctionTypes` flags the inline pattern. See [Pattern - `buildInput`](#pattern---buildinput-for-re-entered-forms). | +| `defineExit` | `() => ExitPointSchema` | Identity helper for an exit-point literal. Zero runtime cost. | +| `defineExitContract` | overloaded — `(kind) => ExitContract` or `(kind, schema) => ExitContract>` | Shared exit contract identity. Use the same returned value as the schema for an exit on every module that emits it - the journey's wildcard transitions then narrow `output` to the contract's `T` and (when a schema is provided) the runtime validates payloads at every emit. See [Pattern - shared exit contracts](#pattern---shared-exit-contracts-defineexitcontract). | +| `isExitContract` | `(schema: unknown) => schema is ExitContract` | Type predicate distinguishing a contract from a plain `ExitPointSchema`. Used by the journey runtime and validators; exported for custom hosts. | +| `ExitContract` | `{ kind: string; schema?: StandardSchemaV1; output? }` | Shape of a shared contract. Extends `ExitPointSchema`; carries an identity (`kind`) and an optional Standard Schema for runtime validation. | +| `schema` | `() => InputSchema` | Type-only brand used to carry an input/output shape. Zero runtime cost. | +| `ModuleEntryProps` | `` | Typed props for an entry component: `{ input, exit, goBack? }`. | +| `ModuleEntryPoint` | `EagerModuleEntryPoint \| LazyModuleEntryPoint` | Discriminated union — eager (`component`) or lazy (`lazy`). | +| `EagerModuleEntryPoint` / `LazyModuleEntryPoint` | `{ component, input?, allowBack?, lazy?: never }` / `{ lazy, fallback?, input?, allowBack?, component?: never }` | The two branches of the union, exported for callers that want to type a single variant explicitly. | +| `LazyEntryComponent` | `() => Promise<{ default: ComponentType<…> } \| ComponentType<…>>` | Importer signature accepted by `defineEntry({ lazy })`. Both default-export and direct-export module shapes are normalized at runtime. | +| `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. | +| `ExitFn` | `(name, output?) => void` | The function signature `exit` gets on an entry component. | +| `EntryPointMap` / `ExitPointMap` | `Record>` / `Record>` | Map shapes on `ModuleDescriptor`. | ### Authoring (`@modular-react/journeys`) @@ -2220,13 +2316,17 @@ Every export you're likely to call, grouped by role. ### Rendering + context (`@modular-react/journeys`) -| Export | Purpose | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `JourneyProvider` | Context provider for the runtime and optional `onModuleExit`. Mount once at the shell root. | -| `useJourneyContext` | Reads the current provider value, or `null`. | -| `JourneyOutlet` | Renders the current step of a journey instance. Handles loading, error boundary, terminal, and abandon-on-unmount. By default walks the active call chain and renders the leaf — pass `leafOnly={false}` for layered presentations. | -| `useJourneyCallStack` | `(runtime, rootId) => readonly InstanceId[]` — returns the live root → … → leaf chain. Subscribes to every link so the array re-resolves when the chain shifts. | -| `ModuleTab` | Renders a single module entry outside a route. Non-journey counterpart to `JourneyOutlet`. | +| Export | Purpose | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `JourneyProvider` | Context provider for the runtime and optional `onModuleExit`. Mount once at the shell root. | +| `useJourneyContext` | Reads the current provider value, or `null`. | +| `JourneyOutlet` | Renders the current step of a journey instance. Handles loading, error boundary, terminal, and abandon-on-unmount. By default walks the active call chain and renders the leaf — pass `leafOnly={false}` for layered presentations. | +| `useJourneyCallStack` | `(runtime, rootId) => readonly InstanceId[]` — returns the live root → … → leaf chain. Subscribes to every link so the array re-resolves when the chain shifts. | +| `useJourneyInstance` | `(id: InstanceId \| null) => JourneyInstance \| null` — subscribes to a single instance via `useSyncExternalStore` and returns its full snapshot (`status`, `step`, `state`, `terminalPayload`, …). Reads the runtime from ``; returns `null` for unknown ids or no provider. Tearing-free under concurrent React. Prefer this when a host needs more than `state`. | +| `useJourneyState` | `(id: InstanceId \| null) => TState \| null` — sugar over `useJourneyInstance(id)?.state`. Use when the caller only needs `state` and the journey's `TState` is known at the call site. | +| `useActiveLeafJourneyInstance` | `(rootId: InstanceId \| null) => JourneyInstance \| null` — **the recommended primitive when the host doesn't know the leaf's depth.** Walks `activeChildId` from the root to the active leaf and returns the leaf's full `JourneyInstance`. `inst.journeyId` is a natural discriminator when the leaf may be the root parent OR any invoked descendant. Re-subscribes as the chain grows / shrinks. | +| `useActiveLeafJourneyState` | `(rootId: InstanceId \| null) => TState \| null` — sugar over `useActiveLeafJourneyInstance(rootId)?.state`. Returns a single `T`, so callers whose leaf can be any of several journeys must type it as `` and discriminate manually — usually that's a sign the caller actually wants `useActiveLeafJourneyInstance` and `inst.journeyId` to switch on. | +| `ModuleTab` | Renders a single module entry outside a route. Non-journey counterpart to `JourneyOutlet`. | ### Runtime + validation (`@modular-react/journeys`) @@ -2244,17 +2344,18 @@ Every export you're likely to call, grouped by role. ### Runtime methods (the `JourneyRuntime` returned as `manifest.journeys`) -| Method | Description | -| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `start(handle, input)` | **Preferred.** Start or resume an instance via a handle (`defineJourneyHandle`); `input` is type-checked end-to-end. Idempotent per persistence key. | -| `start(journeyId, input)` | String-id form for dynamic dispatch (e.g. navbar `{ kind: "journey-start", journeyId }`). Accepts any `input`. | -| `hydrate(journeyId, blob)` | Explicit read-only hydrate. Persistence-unlinked. Returns `InstanceId`. | -| `getInstance(id)` | Current snapshot of an instance, or `null`. Stable-identity between changes (for `useSyncExternalStore`). | -| `listInstances()` / `listDefinitions()` | Enumerate. Useful for admin tooling. | -| `isRegistered(journeyId)` | Cheap "is this id known?" predicate. Use to filter persisted shell state before calling `start()` - keeps the expected-drop path out of the exception channel. | -| `subscribe(id, listener)` | Subscribe to change notifications for one instance. Returns unsubscribe. | -| `end(id, reason?)` | Force-terminate. Fires `onAbandon` if active; treats `loading` as a direct abort without firing `onAbandon`. | -| `forget(id)` / `forgetTerminal()` | Drop terminal instances from memory. `forget` is a no-op on active/loading; `forgetTerminal` batches them all. | +| Method | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `start(handle, input)` | **Preferred.** Start or resume an instance via a handle (`defineJourneyHandle`); `input` is type-checked end-to-end. Idempotent per persistence key. | +| `start(journeyId, input)` | String-id form for dynamic dispatch (e.g. navbar `{ kind: "journey-start", journeyId }`). Accepts any `input`. | +| `hydrate(journeyId, blob)` | Explicit read-only hydrate. Persistence-unlinked. Returns `InstanceId`. | +| `getInstance(id)` | Current snapshot of an instance, or `null`. Stable-identity between changes (for `useSyncExternalStore`). | +| `listInstances()` / `listDefinitions()` | Enumerate. Useful for admin tooling. | +| `isRegistered(journeyId)` | Cheap "is this id known?" predicate. Use to filter persisted shell state before calling `start()` - keeps the expected-drop path out of the exception channel. | +| `subscribe(id, listener)` | Subscribe to change notifications for one instance. Returns unsubscribe. | +| `goBack(id)` | Pop the active step back to the previous one. Equivalent to the `goBack` prop on `ModuleEntryProps`, but addressable by id so a shell wiring browser-Back / `popstate` / breadcrumb navigation doesn't have to capture the step component's closure through a context. No-op when the id is unknown, the instance is terminal / loading, a child is in flight, the transition didn't opt into `allowBack: true`, or history is empty. | +| `end(id, reason?)` | Force-terminate. Fires `onAbandon` if active; treats `loading` as a direct abort without firing `onAbandon`. | +| `forget(id)` / `forgetTerminal()` | Drop terminal instances from memory. `forget` is a no-op on active/loading; `forgetTerminal` batches them all. | ### Registration options (passed to `registry.registerJourney`) @@ -2415,12 +2516,12 @@ interface SerializedJourney { ### Testing (`@modular-react/journeys/testing`) -| Export | Purpose | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `simulateJourney` | Headless simulator: fires exits / goBack, exposes `step` / `currentStep` (throws if terminal) / `state` / `history` / `status` / `transitions` / `terminalPayload` / `serialize()`, no React. | -| `JourneySimulator` | Type for the object returned by `simulateJourney`. | -| `createTestHarness` | Wraps a live `JourneyRuntime` so tests can fire exits, call `goBack`, and inspect instance internals without mounting ``. Replaces reaching for `getInternals` directly. | -| `JourneyTestHarness` | Type returned by `createTestHarness`. | +| Export | Purpose | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `simulateJourney` | Headless simulator: fires exits / goBack, exposes `step` / `currentStep` (throws if terminal) / `state` / `history` / `status` / `transitions` / `terminalPayload` / `serialize()`, no React. Generic over `` so a journey with a typed terminal payload is assignable without `as unknown as Parameters<…>[0]`. `step` / `currentStep` / `history` are typed as `JourneyStepFor`, so narrowing on `step.entry` surfaces the entry's `input` type. Pass `options.modules` to opt into `buildInput` resolution and `allowBack` mode lookup. | +| `JourneySimulator` | Type for the object returned by `simulateJourney`. | +| `createTestHarness` | Wraps a live `JourneyRuntime` so tests can fire exits, call `goBack`, and inspect instance internals without mounting ``. Replaces reaching for `getInternals` directly. | +| `JourneyTestHarness` | Type returned by `createTestHarness`. | ### From the router runtime packages diff --git a/packages/journeys/src/build-input.test.ts b/packages/journeys/src/build-input.test.ts new file mode 100644 index 0000000..82bab25 --- /dev/null +++ b/packages/journeys/src/build-input.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildInputFor, defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; + +import { defineJourney } from "./define-journey.js"; +import { defineJourneyHandle } from "./handle.js"; +import { createJourneyRuntime } from "./runtime.js"; +import { simulateJourney } from "./simulate-journey.js"; +import { createTestHarness } from "./testing.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface FormState { + readonly draftName: string; + readonly draftEmail: string; +} + +interface NameInput { + readonly previousName: string; +} +interface EmailInput { + readonly previousEmail: string; +} + +const stepExits = { + next: defineExit<{ name?: string; email?: string }>(), + back: defineExit(), +} as const; + +const nameModule = defineModule({ + id: "name", + version: "1.0.0", + exitPoints: stepExits, + entryPoints: { + enter: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + buildInput: (state: FormState) => ({ previousName: state.draftName }), + }), + }, +}); + +const emailModule = defineModule({ + id: "email", + version: "1.0.0", + exitPoints: stepExits, + entryPoints: { + enter: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + // `nameModule` (above) uses the inline `(state: FormState) =>` + // annotation; this one uses the `buildInputFor()` wrapper. + // The "rebuilds input from state when navigating back" test + // exercises both — keeping the two patterns side by side pins + // their runtime equivalence. + buildInput: buildInputFor()((state) => ({ + previousEmail: state.draftEmail, + })), + }), + }, +}); + +type Modules = { readonly name: typeof nameModule; readonly email: typeof emailModule }; + +const journey = defineJourney()({ + id: "form", + version: "1.0.0", + initialState: () => ({ draftName: "", draftEmail: "" }), + start: (state) => ({ + module: "name", + entry: "enter", + // The handler-supplied input is ignored at runtime because the entry + // declares `buildInput` — but it still has to be present to satisfy + // the `StepSpec` shape. Stamping it as `{ previousName: "" }` mirrors + // what authors actually write at the call site. + input: { previousName: state.draftName }, + }), + transitions: { + name: { + enter: { + allowBack: true, + next: ({ state, output }) => ({ + state: { ...state, draftName: output.name ?? state.draftName }, + next: { module: "email", entry: "enter", input: { previousEmail: "" } }, + }), + }, + }, + email: { + enter: { + allowBack: true, + next: ({ state, output }) => ({ + state: { ...state, draftEmail: output.email ?? state.draftEmail }, + complete: undefined, + }), + }, + }, + }, +}); + +describe("defineEntry({ buildInput })", () => { + it("derives the initial step's input from journey state instead of the handler-supplied value", () => { + const sim = simulateJourney(journey, undefined, { + modules: { name: nameModule, email: emailModule }, + }); + // `buildInput` ran on initial start — and the journey's initial state + // is empty, so previousName is "". + expect(sim.currentStep).toEqual({ + moduleId: "name", + entry: "enter", + input: { previousName: "" }, + }); + }); + + it("rebuilds input from state when navigating back", () => { + const sim = simulateJourney(journey, undefined, { + modules: { name: nameModule, email: emailModule }, + }); + + sim.fireExit("next", { name: "Ada" }); + expect(sim.currentStep.moduleId).toBe("email"); + expect(sim.currentStep.input).toEqual({ previousEmail: "" }); + + sim.goBack(); + // The name step is re-entered. Without buildInput, `input.previousName` + // would be the empty string captured at first push. With buildInput, + // it reflects the accumulated draftName. + expect(sim.currentStep.moduleId).toBe("name"); + expect(sim.currentStep.input).toEqual({ previousName: "Ada" }); + }); + + it("does not warn when the handler-supplied input matches what buildInput derives", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const runtime = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: { name: nameModule, email: emailModule }, + debug: true, + }); + const id = runtime.start(journey.id, undefined); + createTestHarness(runtime).fireExit(id, "next", { name: "Ada" }); + // Handler stamps `{ previousEmail: "" }`; on this first push the email + // module's `buildInput` also returns `{ previousEmail: "" }` (initial + // state). Shallow-equal → no drift warning. + expect(warn).not.toHaveBeenCalled(); + }); + + it("does NOT warn on goBack when buildInput re-derives a value that differs from the cached frame", () => { + // Regression: an earlier version compared `step.input` (the value + // stored on the popped history frame, which is itself a prior + // `buildInput` derivation) against the freshly-derived one — so + // anytime state changed between visits, the goBack pop would + // false-positive as "handler stamped a different input." The fix + // pins the drift comparison to the handler's literal stamp, which + // doesn't exist on the goBack path. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const runtime = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: { name: nameModule, email: emailModule }, + debug: true, + }); + const id = runtime.start(journey.id, undefined); + const harness = createTestHarness(runtime); + // Drive forward — state.draftName becomes "Ada", emailModule pushed. + harness.fireExit(id, "next", { name: "Ada" }); + // Pop back to name. buildInput now derives `{ previousName: "Ada" }`, + // but the frame stored `{ previousName: "" }` at first push. With + // the old comparison, this would fire the drift warning. + harness.goBack(id); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns in dev mode when the handler stamps an input that buildInput would override", () => { + interface DriftState { + readonly value: number; + } + const driftExits = { go: defineExit() } as const; + const driftSource = defineModule({ + id: "drift-source", + version: "1.0.0", + exitPoints: driftExits, + entryPoints: { + view: defineEntry({ component: (() => null) as never, input: schema() }), + }, + }); + const driftTarget = defineModule({ + id: "drift-target", + version: "1.0.0", + exitPoints: driftExits, + entryPoints: { + view: defineEntry({ + component: (() => null) as never, + input: schema<{ value: number }>(), + // Always derives `{ value: 99 }`, regardless of handler stamping. + buildInput: () => ({ value: 99 }), + }), + }, + }); + type DriftModules = { + readonly "drift-source": typeof driftSource; + readonly "drift-target": typeof driftTarget; + }; + const driftJourney = defineJourney()({ + id: "drift", + version: "1.0.0", + initialState: () => ({ value: 0 }), + start: () => ({ module: "drift-source", entry: "view", input: undefined }), + transitions: { + "drift-source": { + view: { + go: () => ({ + // Handler stamps `value: 0` — buildInput will override to 99. + next: { module: "drift-target", entry: "view", input: { value: 0 } }, + }), + }, + }, + }, + }); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const runtime = createJourneyRuntime([{ definition: driftJourney, options: undefined }], { + modules: { "drift-source": driftSource, "drift-target": driftTarget }, + debug: true, + }); + const id = runtime.start(driftJourney.id, undefined); + createTestHarness(runtime).fireExit(id, "go"); + + expect(runtime.getInstance(id)?.step?.input).toEqual({ value: 99 }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toMatch(/buildInput.*input.*discarded/); + }); + + it("aborts the instance with `build-input-threw` when buildInput throws on initial start", () => { + const error = new Error("boom"); + const explodingModule = defineModule({ + id: "explode", + version: "1.0.0", + exitPoints: stepExits, + entryPoints: { + enter: defineEntry({ + component: (() => null) as never, + input: schema<{ x: number }>(), + buildInput: () => { + throw error; + }, + }), + }, + }); + type ExplodeModules = { readonly explode: typeof explodingModule }; + const explodingJourney = defineJourney>()({ + id: "explode", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "explode", entry: "enter", input: { x: 0 } }), + transitions: {}, + }); + const runtime = createJourneyRuntime([{ definition: explodingJourney, options: undefined }], { + modules: { explode: explodingModule }, + }); + const id = runtime.start(explodingJourney.id, undefined); + const inst = runtime.getInstance(id); + expect(inst?.status).toBe("aborted"); + expect(inst?.terminalPayload).toMatchObject({ + reason: "build-input-threw", + moduleId: "explode", + entry: "enter", + error, + }); + }); + + it("re-runs buildInput on the parent's step when a resume changes state without advancing", () => { + interface ParentState { + readonly leafCount: number; + } + interface LeafInput { + readonly leafCount: number; + } + const childExits = { done: defineExit() } as const; + const childMod = defineModule({ + id: "child-mod", + version: "1.0.0", + exitPoints: childExits, + entryPoints: { + run: defineEntry({ component: (() => null) as never, input: schema() }), + }, + }); + const childJourney = defineJourney<{ readonly "child-mod": typeof childMod }, void>()({ + id: "child", + version: "1.0.0", + initialState: () => undefined, + start: () => ({ module: "child-mod", entry: "run", input: undefined }), + transitions: { + "child-mod": { run: { done: () => ({ complete: undefined }) } }, + }, + }); + const childHandle = defineJourneyHandle(childJourney); + + const parentExits = { open: defineExit() } as const; + const parentMod = defineModule({ + id: "parent-mod", + version: "1.0.0", + exitPoints: parentExits, + entryPoints: { + home: defineEntry({ + component: (() => null) as never, + input: schema(), + // The point of the test: `buildInput` should re-fire when the + // resume bumps `leafCount` without advancing the parent step. + buildInput: (state: ParentState) => ({ leafCount: state.leafCount }), + }), + }, + }); + type ParentModules = { readonly "parent-mod": typeof parentMod }; + const parentJourney = defineJourney()({ + id: "parent", + version: "1.0.0", + invokes: [childHandle], + initialState: () => ({ leafCount: 0 }), + start: () => ({ module: "parent-mod", entry: "home", input: { leafCount: 0 } }), + transitions: { + "parent-mod": { + home: { + open: () => ({ invoke: { handle: childHandle, input: undefined, resume: "back" } }), + }, + }, + }, + resumes: { + "parent-mod": { + home: { + // Bumps state but doesn't advance the parent step. + back: ({ state }) => ({ state: { leafCount: state.leafCount + 1 } }), + }, + }, + }, + }); + + const runtime = createJourneyRuntime( + [ + { definition: parentJourney, options: undefined }, + { definition: childJourney, options: undefined }, + ], + { modules: { "parent-mod": parentMod, "child-mod": childMod } }, + ); + const parentId = runtime.start(parentJourney.id, undefined); + const harness = createTestHarness(runtime); + + expect(runtime.getInstance(parentId)?.step?.input).toEqual({ leafCount: 0 }); + + harness.fireExit(parentId, "open"); + const childId = runtime.getInstance(parentId)?.activeChildId; + expect(childId).toBeTruthy(); + harness.fireExit(childId!, "done"); + + // Resume bumped state.leafCount to 1; parent step didn't change, but + // `buildInput` re-ran and the new `step.input` reflects accumulated + // state. Before the rebuild-on-state-change fix, this would still be + // `{ leafCount: 0 }` (the cached input from the initial push). + expect(runtime.getInstance(parentId)?.step?.input).toEqual({ leafCount: 1 }); + }); +}); diff --git a/packages/journeys/src/define-journey.ts b/packages/journeys/src/define-journey.ts index 58cb373..a874a41 100644 --- a/packages/journeys/src/define-journey.ts +++ b/packages/journeys/src/define-journey.ts @@ -33,6 +33,9 @@ import type { JourneyDefinition, ModuleTypeMap } from "./types.js"; * ``` * * Zero runtime cost — the definition is returned unchanged. + * + * @see `buildInputFor` in `@modular-react/core` — same visual two-call + * curry, used for the entry-side `buildInput` factory. */ export const defineJourney = // `TOutput = unknown` keeps existing two-generic call sites compiling — diff --git a/packages/journeys/src/index.ts b/packages/journeys/src/index.ts index e226e72..c38f42e 100644 --- a/packages/journeys/src/index.ts +++ b/packages/journeys/src/index.ts @@ -45,6 +45,12 @@ export { ModuleTab } from "./module-tab.js"; export type { ModuleTabProps, ModuleTabExitEvent } from "./module-tab.js"; export { JourneyProvider, useJourneyContext } from "./provider.js"; export type { JourneyProviderProps, JourneyProviderValue } from "./provider.js"; +export { + useActiveLeafJourneyInstance, + useActiveLeafJourneyState, + useJourneyInstance, + useJourneyState, +} from "./use-journey-state.js"; // Plugin — pass `journeysPlugin()` to `createRegistry({ plugins: [...] })` // to enable journey registration and outlet rendering. @@ -99,6 +105,7 @@ export type { JourneyRuntime, JourneyStatus, JourneyStep, + JourneyStepFor, JourneySystemAbortReason, JourneySystemAbortReasonCode, MaybePromise, diff --git a/packages/journeys/src/instance-hooks.ts b/packages/journeys/src/instance-hooks.ts new file mode 100644 index 0000000..796c226 --- /dev/null +++ b/packages/journeys/src/instance-hooks.ts @@ -0,0 +1,138 @@ +import { useMemo, useRef, useSyncExternalStore } from "react"; +import type { InstanceId, JourneyInstance, JourneyRuntime } from "@modular-react/core"; + +/** + * Sanity bound to break a corrupted cycle in the activeChild graph. + * Legitimate invoke nesting is not expected to approach this depth — if a + * real product stacks deeper, surface a knob via `JourneyRuntimeOptions` + * rather than raising the constant blindly. Intentionally not exported: + * the cap is implementation detail, not a knob. + */ +const MAX_CHAIN_DEPTH = 64; + +/** + * Subscribe to a single instance and return its current snapshot, or + * `null` when runtime / id is missing or the instance has been forgotten. + * Tearing-free via `useSyncExternalStore`. + */ +export function useInstanceSnapshot( + runtime: JourneyRuntime | null, + instanceId: InstanceId | null, +): JourneyInstance | null { + const subscribe = useMemo( + () => (listener: () => void) => { + if (!runtime || !instanceId) return () => {}; + return runtime.subscribe(instanceId, listener); + }, + [runtime, instanceId], + ); + const getSnapshot = () => { + if (!runtime || !instanceId) return null; + return runtime.getInstance(instanceId); + }; + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Walk `activeChildId` from `rootId` down to the deepest descendant, + * returning the full chain. Subscribes to every instance in the chain + * and re-subscribes as the chain grows / shrinks. Pass `enabled: false` + * to short-circuit the walk (collapses to `[rootId]`) — used by the + * outlet's `leafOnly` opt-out. Null `runtime` / `rootId` returns an + * empty chain so callers can invoke this hook unconditionally even + * before a runtime is mounted. + */ +export function useCallChain( + runtime: JourneyRuntime | null, + rootId: InstanceId | null, + enabled: boolean, +): readonly InstanceId[] { + const subscribe = useMemo( + () => (listener: () => void) => { + if (!runtime || !rootId) return () => {}; + const unsubs = new Map void>(); + let stopped = false; + const fire = () => { + if (stopped) return; + rewire(); + listener(); + }; + const rewire = () => { + const seen = new Set(); + let id: InstanceId | null = rootId; + let depth = 0; + while (id && depth < MAX_CHAIN_DEPTH) { + if (seen.has(id)) break; + seen.add(id); + if (!unsubs.has(id)) { + unsubs.set(id, runtime.subscribe(id, fire)); + } + const inst = runtime.getInstance(id); + id = enabled && inst ? inst.activeChildId : null; + depth += 1; + } + for (const [subscribedId, unsub] of unsubs) { + if (!seen.has(subscribedId)) { + unsub(); + unsubs.delete(subscribedId); + } + } + }; + rewire(); + return () => { + stopped = true; + for (const unsub of unsubs.values()) unsub(); + unsubs.clear(); + }; + }, + [runtime, rootId, enabled], + ); + // `resolveChain` returns a fresh array on every call; cache by the + // joined-id signature so `useSyncExternalStore` bails on identity + // checks when the chain hasn't actually shifted. + const cacheRef = useRef<{ key: string; chain: readonly InstanceId[] } | null>(null); + const getStableSnapshot = () => { + if (!runtime || !rootId) return EMPTY_CHAIN; + const fresh = resolveChain(runtime, rootId, enabled); + const key = fresh.join(">"); + if (cacheRef.current && cacheRef.current.key === key) return cacheRef.current.chain; + cacheRef.current = { key, chain: fresh }; + return fresh; + }; + return useSyncExternalStore(subscribe, getStableSnapshot, getStableSnapshot); +} + +/** + * Last id in the active chain — `rootId` when no child is in flight, + * `null` when runtime / rootId are missing. + */ +export function useLeafId( + runtime: JourneyRuntime | null, + rootId: InstanceId | null, + enabled: boolean, +): InstanceId | null { + const chain = useCallChain(runtime, rootId, enabled); + return chain[chain.length - 1] ?? rootId; +} + +const EMPTY_CHAIN: readonly InstanceId[] = Object.freeze([]); + +function resolveChain( + runtime: JourneyRuntime, + rootId: InstanceId, + enabled: boolean, +): readonly InstanceId[] { + const chain: InstanceId[] = []; + let id: InstanceId | null = rootId; + let depth = 0; + const visited = new Set(); + while (id && depth < MAX_CHAIN_DEPTH) { + if (visited.has(id)) break; + visited.add(id); + chain.push(id); + const inst = runtime.getInstance(id); + id = enabled && inst ? inst.activeChildId : null; + depth += 1; + } + return chain; +} diff --git a/packages/journeys/src/outlet.tsx b/packages/journeys/src/outlet.tsx index 13783b7..efe4903 100644 --- a/packages/journeys/src/outlet.tsx +++ b/packages/journeys/src/outlet.tsx @@ -1,13 +1,4 @@ -import { - Component, - Suspense, - createElement, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; +import { Component, Suspense, createElement, useEffect, useRef, useState } from "react"; import type { ComponentType, ReactNode } from "react"; import type { ModuleDescriptor, ModuleEntryPoint } from "@modular-react/core"; import { resolveEntryComponent } from "@modular-react/react"; @@ -15,6 +6,7 @@ import { resolveEntryComponent } from "@modular-react/react"; import { getInternals } from "./runtime.js"; import { useJourneyContext } from "./provider.js"; import { isAnnotatedTransition } from "./define-transition.js"; +import { useCallChain, useInstanceSnapshot, useLeafId } from "./instance-hooks.js"; import type { AnyJourneyDefinition, InstanceId, @@ -476,45 +468,12 @@ function DefaultError({ moduleId, error }: JourneyOutletErrorProps): ReactNode { ); } -function useInstanceSnapshot(runtime: JourneyRuntime, instanceId: InstanceId) { - const subscribe = useMemo( - () => (listener: () => void) => runtime.subscribe(instanceId, listener), - [runtime, instanceId], - ); - const getSnapshot = () => runtime.getInstance(instanceId); - const getServerSnapshot = getSnapshot; - const instance = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - return instance; -} - -/** - * Walk the active call chain from a root instance down to the leaf. The - * leaf is the first instance in the chain that does not itself have an - * `activeChildId`. Walks `activeChildId` greedily; bounded by a sanity - * cap so a corrupted cycle (which the runtime should prevent) cannot - * loop forever. - * - * Subscribes to every instance along the chain — when any link changes - * (parent invokes a child, child resumes the parent, grandchild starts), - * the consumer re-renders with a fresh leaf id. - */ -function useLeafId(runtime: JourneyRuntime, rootId: InstanceId, enabled: boolean): InstanceId { - // Chain of instance ids root → … → leaf. Recomputed on every render so - // changes to any link mid-chain take effect on the next snapshot read. - const chain = useCallChain(runtime, rootId, enabled); - return chain[chain.length - 1] ?? rootId; -} - /** * Returns the call stack for an outlet's instance — root at index 0, the * active leaf at the end, intermediate parents in between. Useful for * shells that render layered presentations (e.g. parent visible * underneath, child in a modal): mount the parent outlet with * `leafOnly={false}` and the child outlet against `chain[chain.length - 1]`. - * - * Subscribes to every instance in the chain so the array re-resolves - * when the chain shifts. Length is at least 1 (the root) for any - * registered instance. */ export function useJourneyCallStack( runtime: JourneyRuntime, @@ -523,111 +482,6 @@ export function useJourneyCallStack( return useCallChain(runtime, rootId, true); } -/** - * Shared chain-walker used by both `useLeafId` (which takes the last - * element) and `useJourneyCallStack` (which takes the whole array). - * Subscribes to every instance along the way and re-subscribes - * whenever the chain shifts so deep transitions still trigger snapshot - * reads. - */ -// Sanity bound to break a corrupted cycle in the activeChild graph; legitimate -// invoke nesting is not expected to approach this depth. If a real product -// stacks deeper, surface this through `JourneyRuntimeOptions` rather than -// raising the constant blindly. -const MAX_CHAIN_DEPTH = 64; - -function useCallChain( - runtime: JourneyRuntime, - rootId: InstanceId, - enabled: boolean, -): readonly InstanceId[] { - // useSyncExternalStore over a virtual "chain" store: subscribe to each - // instance the chain currently traverses, return a frozen-per-render - // array on snapshot read. The chain can shift while we're subscribed - // (a leaf invokes a grandchild, mid-chain instance terminates) — when - // that happens, `fire` re-walks the chain and tops up subscriptions - // for any newly-reachable instance, so deep transitions still surface. - const subscribe = useMemo( - () => (listener: () => void) => { - const unsubs = new Map void>(); - let stopped = false; - const fire = () => { - if (stopped) return; - // The chain may have grown — top up subscriptions before notifying. - // This keeps `useJourneyCallStack` correct for chains beyond depth - // 1 without paying for unnecessary work: only newly-reachable ids - // call into `runtime.subscribe`. - rewire(); - listener(); - }; - const rewire = () => { - const seen = new Set(); - let id: InstanceId | null = rootId; - let depth = 0; - while (id && depth < MAX_CHAIN_DEPTH) { - if (seen.has(id)) break; - seen.add(id); - if (!unsubs.has(id)) { - unsubs.set(id, runtime.subscribe(id, fire)); - } - const inst = runtime.getInstance(id); - id = enabled && inst ? inst.activeChildId : null; - depth += 1; - } - // Drop subscriptions for ids no longer in the chain. - for (const [subscribedId, unsub] of unsubs) { - if (!seen.has(subscribedId)) { - unsub(); - unsubs.delete(subscribedId); - } - } - }; - rewire(); - return () => { - stopped = true; - for (const unsub of unsubs.values()) unsub(); - unsubs.clear(); - }; - }, - [runtime, rootId, enabled], - ); - const getSnapshot = () => resolveChain(runtime, rootId, enabled); - // External-store snapshots must be referentially stable across reads - // when nothing has changed. `resolveChain` returns a fresh array every - // call, so we cache by rootId+enabled and re-issue when the joined-id - // signature changes — the same trick the runtime uses for instance - // snapshots via `revision`. - const cacheRef = useRef<{ key: string; chain: readonly InstanceId[] } | null>(null); - const getStableSnapshot = () => { - const fresh = getSnapshot(); - const key = fresh.join(">"); - if (cacheRef.current && cacheRef.current.key === key) return cacheRef.current.chain; - cacheRef.current = { key, chain: fresh }; - return fresh; - }; - return useSyncExternalStore(subscribe, getStableSnapshot, getStableSnapshot); -} - -function resolveChain( - runtime: JourneyRuntime, - rootId: InstanceId, - enabled: boolean, -): readonly InstanceId[] { - const chain: InstanceId[] = []; - let id: InstanceId | null = rootId; - let depth = 0; - const visited = new Set(); - while (id && depth < MAX_CHAIN_DEPTH) { - if (visited.has(id)) break; // Defensive: bail on cycle. - visited.add(id); - chain.push(id); - const inst = runtime.getInstance(id); - id = enabled && inst ? inst.activeChildId : null; - depth += 1; - } - return chain; -} - interface StepErrorBoundaryProps { readonly moduleId: string; readonly onError: (err: unknown) => void; diff --git a/packages/journeys/src/runtime-go-back.test.ts b/packages/journeys/src/runtime-go-back.test.ts new file mode 100644 index 0000000..57eeca7 --- /dev/null +++ b/packages/journeys/src/runtime-go-back.test.ts @@ -0,0 +1,95 @@ +import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { describe, expect, it } 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", + }), + }, +}); + +type Modules = { readonly a: typeof stepA; readonly b: typeof stepB }; + +const journey = defineJourney>()({ + id: "two-step", + 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 }), + }, + }, + }, +}); + +function setup() { + const runtime = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: { a: stepA, b: stepB }, + }); + const id = runtime.start(journey.id, undefined); + const harness = createTestHarness(runtime); + return { runtime, id, harness }; +} + +describe("runtime.goBack(id)", () => { + it("pops the current step back to the previous one", () => { + const { runtime, id, harness } = setup(); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("a"); + + harness.fireExit(id, "next"); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + + runtime.goBack(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("a"); + expect(runtime.getInstance(id)?.history).toHaveLength(0); + }); + + it("is a no-op for unknown ids", () => { + const { runtime } = setup(); + expect(() => runtime.goBack("does-not-exist")).not.toThrow(); + }); + + it("is a no-op when the journey transition does not declare allowBack", () => { + const { runtime, id } = setup(); + // On the initial step (a) the journey's transition declares no + // `allowBack` — calling goBack must silently no-op without throwing. + runtime.goBack(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("a"); + expect(runtime.getInstance(id)?.history).toHaveLength(0); + }); +}); diff --git a/packages/journeys/src/runtime.ts b/packages/journeys/src/runtime.ts index 47e7c06..a77f871 100644 --- a/packages/journeys/src/runtime.ts +++ b/packages/journeys/src/runtime.ts @@ -256,6 +256,127 @@ export function createJourneyRuntime( return { moduleId: spec.module, entry: spec.entry, input: spec.input }; } + /** + * Recompute `step.input` from `state` when the target entry declared + * `buildInput`. Re-entered forms (back-nav, resume-into-step, + * state-only resume) then render against accumulated state instead of + * the snapshot frozen at first push. Returns the original `step` + * reference when no factory is declared — caller-facing identity + * matters for snapshot bail-out. When `buildInput` throws, returns a + * sentinel object so callers can route the transition into an abort + * instead of leaving the instance half-transitioned. + * + * This helper does NOT warn about handler-input drift; that check + * lives at the callsite where the handler-original is in scope + * (`warnIfHandlerInputDiffers`). Reason: `step.input` on a popped + * history frame is a prior `buildInput` derivation, not a handler + * stamp, and comparing it would false-positive whenever state + * changed between visits. + */ + function withBuiltInput( + step: JourneyStep, + state: unknown, + ): JourneyStep | { readonly buildInputThrew: unknown } { + const entry = moduleMap[step.moduleId]?.entryPoints?.[step.entry]; + if (!entry?.buildInput) return step; + let derived: unknown; + try { + derived = entry.buildInput(state); + } catch (err) { + return { buildInputThrew: err }; + } + return { moduleId: step.moduleId, entry: step.entry, input: derived }; + } + + /** + * Dev-mode warning fired only when the *handler's* original `input` + * (literally `result.next.input` for the next arm, `def.start(...).input` + * for the initial start) differs from what `buildInput` derived. + * Skipped when the handler stamped `undefined` (the documented "I'm + * letting buildInput handle it" placeholder). Not called from + * `dispatchGoBack` / same-step rebuild — those have no handler-original + * to compare against. + */ + function warnIfHandlerInputDiffers( + handlerInput: unknown, + derivedInput: unknown, + step: JourneyStep, + ) { + if (!debug) return; + if (handlerInput === undefined) return; + if (shallowEqual(handlerInput, derivedInput)) return; + console.warn( + `[@modular-react/journeys] Entry "${step.moduleId}.${step.entry}" declared \`buildInput\` but the transition handler also supplied a different \`input\`. The handler's value is discarded — stamp \`input: undefined\` (or drop the field once the type allows it) to mark the override explicit.`, + ); + } + + function isBuildInputThrow( + result: JourneyStep | { readonly buildInputThrew: unknown }, + ): result is { readonly buildInputThrew: unknown } { + return "buildInputThrew" in result; + } + + /** + * Shared throw-handling for the four `withBuiltInput` callsites + * (initial start, next-arm push, goBack pop, same-step rebuild). Fires + * the registration's `onError` hook in the `"step"` phase and routes + * the failure through the standard abort machinery so persistence / + * onAbort / cascade-end logic all run as they would for a transition- + * handler throw. `where` is a one-line free-form label for the debug + * console.error so the next reader can trace which callsite tripped. + */ + function abortFromBuildInputThrow( + record: InstanceRecord, + reg: RegisteredJourney, + step: JourneyStep, + thrown: unknown, + where: string, + exitName: string | null, + ) { + if (debug) { + console.error( + `[@modular-react/journeys] buildInput threw on "${step.moduleId}.${step.entry}" (${where})`, + thrown, + ); + } + fireOnError(reg, record, thrown, step, "step"); + applyTransition( + record, + reg, + { + abort: { + reason: "build-input-threw", + moduleId: step.moduleId, + entry: step.entry, + error: thrown, + }, + }, + exitName, + ); + } + + /** + * Shallow structural equality used only by the dev-mode `buildInput` + * drift warning. Compares top-level fields with `===`; nested-object + * inputs only register as equal when both sides happen to reference + * the same nested object. The warning is therefore best-effort: + * scalar-field drift is reliably surfaced, deeply-nested drift may go + * unreported. Acceptable for an observability log; not load-bearing + * for correctness. + */ + function shallowEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + const ka = Object.keys(a as object); + const kb = Object.keys(b as object); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if ((a as Record)[k] !== (b as Record)[k]) return false; + } + return true; + } + function entryAllowBackMode(step: JourneyStep | null): "preserve-state" | "rollback" | false { if (!step) return false; const mod = moduleMap[step.moduleId]; @@ -1155,7 +1276,25 @@ export function createJourneyRuntime( } if ("next" in result) { - const nextStep = stepFromSpec(result.next); + const rawNext = stepFromSpec(result.next); + const built = withBuiltInput(rawNext, record.state); + if (isBuildInputThrow(built)) { + abortFromBuildInputThrow( + record, + reg, + rawNext, + built.buildInputThrew, + `next-arm from "${previousStep?.moduleId}.${previousStep?.entry}"`, + exitName, + ); + return; + } + const nextStep = built; + // `rawNext.input` is the handler's literal stamp from + // `result.next.input`; compare it against what `buildInput` + // actually derived. (No-op when no `buildInput` is declared: + // `built === rawNext` so the inputs are identical by reference.) + warnIfHandlerInputDiffers(rawNext.input, nextStep.input, nextStep); if (debug) { // Validation at resolveManifest() catches static misconfiguration, // but transition handlers branch at runtime and can return a @@ -1298,6 +1437,39 @@ export function createJourneyRuntime( }); } + // Step-unchanged + state-changed paths (a resume / state-only result + // that doesn't advance the step) still need to re-run `buildInput` + // so the parent's form re-renders against the accumulated state when + // the user lands back on it. Excludes the `invoke` arm — the parent + // is paused while a child runs, so rebuilding its hidden `step.input` + // is wasted work AND a throw here would abort the parent after + // `beginInvoke` already minted the child, orphaning it; the parent's + // buildInput re-fires naturally when the resume returns `{ next }`. + // Reference equality (`record.step === previousStep`, not just + // `!== null`) ensures the arm-replacement paths (next / complete / + // abort) — which already ran `withBuiltInput` — are skipped here. + if ( + "state" in result && + !("invoke" in result) && + record.step !== null && + previousStep !== null && + record.step === previousStep + ) { + const rebuilt = withBuiltInput(record.step, record.state); + if (isBuildInputThrow(rebuilt)) { + abortFromBuildInputThrow( + record, + reg, + record.step, + rebuilt.buildInputThrew, + "same-step rebuild after state change", + null, + ); + return; + } + record.step = rebuilt; + } + const persistence = reg.options?.persistence; if (persistence) { if (record.status === "active") schedulePersist(record, persistence); @@ -1609,7 +1781,22 @@ export function createJourneyRuntime( record.state = snapshot; } record.hasRollbackSnapshot = record.rollbackSnapshots.some((s) => s !== undefined); - record.step = previousStep; + // Re-run `buildInput` against the (possibly rolled-back) state so a + // back-navigated form re-renders against the accumulated journey state + // instead of the frozen first-push snapshot. + const builtBack = withBuiltInput(previousStep, record.state); + if (isBuildInputThrow(builtBack)) { + abortFromBuildInputThrow( + record, + reg, + previousStep, + builtBack.buildInputThrew, + "goBack pop", + null, + ); + return; + } + record.step = builtBack; // Same per-step reset that applyTransition performs for next/complete/abort: // moving to a different step starts a fresh per-step bounce budget, and // leaving an old counter in place would let a serialize/hydrate cycle @@ -1618,7 +1805,7 @@ export function createJourneyRuntime( record.stepToken += 1; record.updatedAt = nowIso(); record.cachedCallbacks = null; - fireOnTransition(reg, record, step, previousStep, null); + fireOnTransition(reg, record, step, record.step, null); const persistence = reg.options?.persistence; if (persistence) schedulePersist(record, persistence); notify(record); @@ -1754,7 +1941,26 @@ export function createJourneyRuntime( record.rollbackSnapshots = []; record.hasRollbackSnapshot = false; } - const startStep = stepFromSpec(def.start(record.state, input)); + const rawStart = stepFromSpec(def.start(record.state, input)); + const builtStart = withBuiltInput(rawStart, record.state); + if (isBuildInputThrow(builtStart)) { + // Routes through the standard abort machinery — populates + // terminalPayload, fires onAbort, persists, etc. — instead of + // leaking a half-built `loading` record. + abortFromBuildInputThrow( + record, + reg, + rawStart, + builtStart.buildInputThrew, + "initial start", + null, + ); + return record.id; + } + const startStep = builtStart; + // Same drift check as the next-arm site — `rawStart.input` is the + // literal `def.start(...)` stamp. + warnIfHandlerInputDiffers(rawStart.input, startStep.input, startStep); record.step = startStep; record.status = "active"; record.stepToken += 1; @@ -2240,6 +2446,20 @@ export function createJourneyRuntime( }; }, + goBack(id) { + const record = instances.get(id); + if (!record) return; + const reg = definitions.get(record.journeyId); + if (!reg) return; + // No captured token to go stale on this id-based path — the + // `record.stepToken !== stepToken` guard inside `dispatchGoBack` is + // a no-op here. Read live so the helper still gets a well-formed + // shape; all "unavailable" cases (terminal, child in flight, no + // history, transition didn't opt into `allowBack: true`) are + // handled by `dispatchGoBack`'s own guards. + dispatchGoBack(record, reg, record.stepToken); + }, + end(id, reason) { const record = instances.get(id); if (!record) return; diff --git a/packages/journeys/src/simulate-journey.test-d.ts b/packages/journeys/src/simulate-journey.test-d.ts index 0e51345..81ba6c3 100644 --- a/packages/journeys/src/simulate-journey.test-d.ts +++ b/packages/journeys/src/simulate-journey.test-d.ts @@ -63,3 +63,84 @@ describe("simulateJourney — input ergonomics", () => { simulateJourney(inputJourney); }); }); + +// ----------------------------------------------------------------------------- +// #5 — `simulateJourney`'s fourth generic flows `TOutput` so a journey with a +// concrete terminal payload type is assignable without a cast. +// ----------------------------------------------------------------------------- + +interface CompletedOutput { + readonly token: string; +} + +const typedOutputJourney = defineJourney()({ + id: "typed-output", + version: "1.0.0", + initialState: () => ({ visits: 0 }), + start: () => ({ module: "menu", entry: "choose", input: undefined }), + transitions: { + menu: { + choose: { + pick: ({ output }) => + output.pick === "a" ? { complete: { token: "alpha" } } : { complete: { token: "bravo" } }, + }, + }, + }, +}); + +describe("simulateJourney — TOutput", () => { + test("a journey with a typed TOutput is assignable to simulateJourney without casts", () => { + // Before #5: this required `as unknown as Parameters[0]` + // because the dropped `TOutput` made the journey contravariantly + // incompatible with the simulator's `unknown`-output signature. + const sim = simulateJourney(typedOutputJourney); + expectTypeOf(sim).toExtend>(); + }); +}); + +// ----------------------------------------------------------------------------- +// #6 — `sim.step.input` is the per-entry input type when narrowed by +// `step.moduleId` + `step.entry`, instead of `unknown`. +// ----------------------------------------------------------------------------- + +const profileExits = { saved: defineExit() } as const; +const profileMod = defineModule({ + id: "profile", + version: "1.0.0", + exitPoints: profileExits, + entryPoints: { + edit: defineEntry({ + component: (() => null) as never, + input: schema<{ readonly customerId: string }>(), + }), + review: defineEntry({ + component: (() => null) as never, + input: schema<{ readonly draftId: number }>(), + }), + }, +}); + +type ProfileModules = { readonly profile: typeof profileMod }; + +const profileJourney = defineJourney()({ + id: "profile-flow", + version: "1.0.0", + initialState: () => ({ stage: "edit" }), + start: () => ({ module: "profile", entry: "edit", input: { customerId: "c-1" } }), + transitions: {}, +}); + +describe("simulateJourney — JourneyStepFor narrows input by entry", () => { + test("narrowing on step.entry surfaces the per-entry input type", () => { + const sim = simulateJourney(profileJourney); + const step = sim.currentStep; + if (step.moduleId === "profile" && step.entry === "edit") { + // Before #6: `step.input` was `unknown` and the cast through + // `Record` lived in the test suite. + expectTypeOf(step.input).toEqualTypeOf<{ readonly customerId: string }>(); + } + if (step.moduleId === "profile" && step.entry === "review") { + expectTypeOf(step.input).toEqualTypeOf<{ readonly draftId: number }>(); + } + }); +}); diff --git a/packages/journeys/src/simulate-journey.ts b/packages/journeys/src/simulate-journey.ts index f3e084a..d5d0706 100644 --- a/packages/journeys/src/simulate-journey.ts +++ b/packages/journeys/src/simulate-journey.ts @@ -1,10 +1,12 @@ +import type { AnyModuleDescriptor } from "@modular-react/core"; + import { createJourneyRuntime, getInternals } from "./runtime.js"; import { createTestHarness } from "./testing.js"; import type { AnyJourneyDefinition, InstanceId, JourneyDefinition, - JourneyStep, + JourneyStepFor, ModuleTypeMap, SerializedJourney, TransitionEvent, @@ -17,11 +19,17 @@ import type { * * Intended for pure-logic unit tests of transition graphs. */ -export interface JourneySimulator<_TModules extends ModuleTypeMap, TState> { +export interface JourneySimulator { readonly journeyId: string; readonly instanceId: string; - /** Current step — null once the journey completes or aborts. */ - readonly step: JourneyStep | null; + /** + * Current step — `null` once the journey completes or aborts. + * + * Typed as the discriminated union of every concrete step in + * `TModules`, so narrowing on `step.entry` (or `step.moduleId`) + * surfaces the entry's typed `input` without a cast. + */ + readonly step: JourneyStepFor | null; /** * Same as `step`, but throws if the journey has terminated. Use this in * tests to skip optional chaining on the common "still running" path — @@ -29,9 +37,9 @@ export interface JourneySimulator<_TModules extends ModuleTypeMap, TState> { * and is far easier to debug than a `Cannot read property 'moduleId' of * null` thrown by an assertion line. */ - readonly currentStep: JourneyStep; + readonly currentStep: JourneyStepFor; readonly state: TState; - readonly history: readonly JourneyStep[]; + readonly history: readonly JourneyStepFor[]; readonly status: "loading" | "active" | "completed" | "aborted"; /** * Every `TransitionEvent` the runtime has fired since the simulator @@ -107,6 +115,29 @@ export interface SimulateJourneyOptions { * journeys all go in here. */ readonly children?: readonly AnyJourneyDefinition[]; + /** + * Module descriptors the runtime should bind for `allowBack` mode + * resolution and `buildInput` recomputation. **Required for headless + * tests that exercise `defineEntry({ buildInput })`** — without it the + * runtime falls back to the cached handler-supplied input (and + * `validateJourneyContracts` warnings about unbound modules surface in + * test output even when the test itself never touches `buildInput`). + * + * Keyed by module id; the same map shape `createJourneyRuntime` accepts. + * Typed against {@link AnyModuleDescriptor} (with `TNavItem = any`) so + * a heterogeneous map of `{ name: typeof nameModule, email: + * typeof emailModule }` passes structurally — including when the host + * app narrows `TNavItem` to a custom action shape. The bivariance is + * localized to one definition site in `@modular-react/core` instead of + * being repeated at every call site. + * + * ```ts + * simulateJourney(journey, undefined, { + * modules: { name: nameModule, email: emailModule }, + * }); + * ``` + */ + readonly modules?: Readonly>>; } /** @@ -124,8 +155,8 @@ export interface SimulateJourneyOptions { * journeys — every reachable child must be registered or the parent * will abort with `invoke-unknown-journey`. */ -export function simulateJourney( - definition: JourneyDefinition, +export function simulateJourney( + definition: JourneyDefinition, ...rest: [TInput] extends [void] ? [] | [input?: TInput] | [input: TInput, options: SimulateJourneyOptions] : [input: TInput] | [input: TInput, options: SimulateJourneyOptions] @@ -149,7 +180,10 @@ export function simulateJourney( options: { onTransition: recorder }, })), ]; - const runtime = createJourneyRuntime(registered); + const runtime = createJourneyRuntime( + registered, + options?.modules ? { modules: options.modules } : undefined, + ); const instanceId = runtime.start(definition.id, input); const harness = createTestHarness(runtime); const internals = getInternals(runtime); @@ -199,7 +233,11 @@ function wrapInstanceAsSim( journeyId, instanceId, get step() { - return snapshot().step; + // The runtime stores history as the wide `JourneyStep` form + // (it doesn't know the active entry at the type level). Cast through + // unknown to the typed simulator surface — module-map + entry + // narrowing on the consumer side recovers the per-entry input type. + return snapshot().step as JourneyStepFor | null; }, get currentStep() { const snap = snapshot(); @@ -208,13 +246,13 @@ function wrapInstanceAsSim( `[simulateJourney] no current step (status=${snap.status}). Use \`step\` if a null step is expected.`, ); } - return snap.step; + return snap.step as JourneyStepFor; }, get state() { return snapshot().state; }, get history() { - return snapshot().history; + return snapshot().history as readonly JourneyStepFor[]; }, get status() { return snapshot().status; diff --git a/packages/journeys/src/types.ts b/packages/journeys/src/types.ts index a72305d..2c97418 100644 --- a/packages/journeys/src/types.ts +++ b/packages/journeys/src/types.ts @@ -41,6 +41,7 @@ export type { JourneyRuntime, JourneyStatus, JourneyStep, + JourneyStepFor, JourneySystemAbortReason, JourneySystemAbortReasonCode, MaybePromise, diff --git a/packages/journeys/src/use-journey-state.test.tsx b/packages/journeys/src/use-journey-state.test.tsx new file mode 100644 index 0000000..af1244f --- /dev/null +++ b/packages/journeys/src/use-journey-state.test.tsx @@ -0,0 +1,221 @@ +import { act, cleanup, render } from "@testing-library/react"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import { afterEach, describe, expect, it } from "vitest"; + +import { defineJourney } from "./define-journey.js"; +import { defineJourneyHandle } from "./handle.js"; +import { JourneyProvider } from "./provider.js"; +import { createJourneyRuntime } from "./runtime.js"; +import { createTestHarness } from "./testing.js"; +import { + useActiveLeafJourneyInstance, + useActiveLeafJourneyState, + useJourneyState, +} from "./use-journey-state.js"; + +afterEach(() => { + cleanup(); +}); + +interface ParentState { + readonly counter: number; +} + +interface ChildState { + readonly note: string; +} + +const exits = { + bump: defineExit(), + start: defineExit(), + finish: defineExit(), +} as const; + +const parentMod = defineModule({ + id: "parent", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + home: defineEntry({ + component: (() => null) as never, + input: schema(), + }), + }, +}); + +const childMod = defineModule({ + id: "child", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + page: defineEntry({ + component: (() => null) as never, + input: schema(), + }), + }, +}); + +type ParentModules = { readonly parent: typeof parentMod }; +type ChildModules = { readonly child: typeof childMod }; + +const childJourney = defineJourney()({ + id: "child", + version: "1.0.0", + initialState: () => ({ note: "initial" }), + start: () => ({ module: "child", entry: "page", input: undefined }), + transitions: { + child: { + page: { + finish: ({ state }) => ({ + state: { ...state, note: "finished" }, + complete: undefined, + }), + }, + }, + }, +}); +const childHandle = defineJourneyHandle(childJourney); + +const parentJourney = defineJourney()({ + id: "parent", + version: "1.0.0", + invokes: [childHandle], + initialState: () => ({ counter: 0 }), + start: () => ({ module: "parent", entry: "home", input: undefined }), + transitions: { + parent: { + home: { + bump: ({ state }) => ({ state: { counter: state.counter + 1 } }), + start: () => ({ invoke: { handle: childHandle, input: undefined, resume: "back" } }), + }, + }, + }, + resumes: { + parent: { + home: { + back: ({ state }) => ({ state: { counter: state.counter + 10 } }), + }, + }, + }, +}); + +function setupRuntime() { + return createJourneyRuntime([ + { definition: parentJourney, options: undefined }, + { definition: childJourney, options: undefined }, + ]); +} + +describe("useJourneyState", () => { + it("subscribes to the instance and re-renders on state changes", () => { + const runtime = setupRuntime(); + const id = runtime.start(parentJourney.id, undefined); + const seen: (ParentState | null)[] = []; + + function Probe() { + const state = useJourneyState(id); + seen.push(state); + return null; + } + + render( + + + , + ); + + expect(seen.at(-1)).toEqual({ counter: 0 }); + + act(() => { + // Drive a state change through the runtime's harness. + const reg = runtime.listInstances(); + expect(reg).toContain(id); + // Use a test-harness fireExit to flip state to counter: 1. + createTestHarness(runtime).fireExit(id, "bump"); + }); + + expect(seen.at(-1)).toEqual({ counter: 1 }); + }); + + it("returns null when no runtime is mounted", () => { + let observed: ParentState | null | undefined = undefined; + function Probe() { + observed = useJourneyState("nope"); + return null; + } + render(); + expect(observed).toBeNull(); + }); +}); + +describe("useActiveLeafJourneyState", () => { + it("follows the activeChildId chain and returns the leaf instance's state", () => { + const runtime = setupRuntime(); + const rootId = runtime.start(parentJourney.id, undefined); + const seen: (ChildState | ParentState | null)[] = []; + + function Probe() { + const state = useActiveLeafJourneyState(rootId); + seen.push(state); + return null; + } + + render( + + + , + ); + + // Initially no child — the leaf is the parent itself. + expect(seen.at(-1)).toEqual({ counter: 0 }); + + act(() => { + createTestHarness(runtime).fireExit(rootId, "start"); + }); + + // Sanity: the parent actually invoked the child. + expect(runtime.getInstance(rootId)?.activeChildId).toBeTruthy(); + // Parent has invoked the child — leaf is the child's state. + expect(seen.at(-1)).toEqual({ note: "initial" }); + + act(() => { + const inst = runtime.getInstance(rootId); + const childId = inst!.activeChildId!; + createTestHarness(runtime).fireExit(childId, "finish"); + }); + + // Child completed — leaf collapses back to the parent (now counter: 10). + expect(seen.at(-1)).toEqual({ counter: 10 }); + }); +}); + +describe("useActiveLeafJourneyInstance", () => { + it("returns the full leaf JourneyInstance so callers can read step/status without pairing hooks", () => { + const runtime = setupRuntime(); + const rootId = runtime.start(parentJourney.id, undefined); + const seen: { moduleId: string | undefined; journeyId: string | undefined }[] = []; + + function Probe() { + const inst = useActiveLeafJourneyInstance(rootId); + seen.push({ moduleId: inst?.step?.moduleId, journeyId: inst?.journeyId }); + return null; + } + + render( + + + , + ); + + // No child yet — leaf is the parent's "parent.home" step. + expect(seen.at(-1)).toEqual({ moduleId: "parent", journeyId: "parent" }); + + act(() => { + createTestHarness(runtime).fireExit(rootId, "start"); + }); + + // After invoke — the hook returns the child instance, so callers can + // read `step.moduleId` directly without a separate `getInstance(leafId)`. + expect(seen.at(-1)).toEqual({ moduleId: "child", journeyId: "child" }); + }); +}); diff --git a/packages/journeys/src/use-journey-state.ts b/packages/journeys/src/use-journey-state.ts new file mode 100644 index 0000000..948becb --- /dev/null +++ b/packages/journeys/src/use-journey-state.ts @@ -0,0 +1,68 @@ +import type { InstanceId, JourneyInstance } from "@modular-react/core"; + +import { useInstanceSnapshot, useLeafId } from "./instance-hooks.js"; +import { useJourneyContext } from "./provider.js"; + +/** + * Sugar over {@link useJourneyInstance}: returns the instance's `state` + * (or `null` when the runtime / id / instance is unavailable). + * `TState` is the journey's state type — pass it at the call site. + * + * Prefer {@link useJourneyInstance} when the host needs more than + * `state` (`step` / `status` / `terminalPayload`). + */ +export function useJourneyState(instanceId: InstanceId | null): TState | null { + const inst = useJourneyInstance(instanceId); + return inst ? (inst.state as TState) : null; +} + +/** + * Subscribe to a journey instance and return its full snapshot + * (`status`, `step`, `state`, `terminalPayload`, …), or `null` when no + * `` is mounted or the id is unknown. Tearing-free via + * `useSyncExternalStore`. The primitive of which {@link useJourneyState} + * is sugar; symmetric with {@link useActiveLeafJourneyInstance} for the + * leaf-walking case. + */ +export function useJourneyInstance(instanceId: InstanceId | null): JourneyInstance | null { + const ctx = useJourneyContext(); + return useInstanceSnapshot(ctx?.runtime ?? null, instanceId); +} + +/** + * Sugar over {@link useActiveLeafJourneyInstance}: returns the leaf + * instance's `state` as `TState`. Returns `null` when no provider, the + * root id is unknown, or the leaf has been forgotten. + * + * Prefer {@link useActiveLeafJourneyInstance} when the leaf can be any + * of several journeys — typing this hook as `` + * leaves the caller without a discriminator, whereas the instance form + * gives a typed `inst.journeyId` to switch on. + */ +export function useActiveLeafJourneyState( + rootInstanceId: InstanceId | null, +): TState | null { + const inst = useActiveLeafJourneyInstance(rootInstanceId); + return inst ? (inst.state as TState) : null; +} + +/** + * Walks `activeChildId` from `rootInstanceId` down to the deepest + * descendant and returns that leaf's full `JourneyInstance`. The + * recommended primitive when the host doesn't know the leaf's depth + * (a parent that may or may not be in an invoked sub-flow) — pair with + * `inst.journeyId` as a discriminator instead of typing the state hook + * as a union and asserting manually. Re-subscribes as the chain grows + * (parent invokes a child, grandchild starts) or shrinks (child + * terminates and parent resumes). + * + * Returns `null` under the same conditions as {@link useJourneyInstance}. + */ +export function useActiveLeafJourneyInstance( + rootInstanceId: InstanceId | null, +): JourneyInstance | null { + const ctx = useJourneyContext(); + const runtime = ctx?.runtime ?? null; + const leafId = useLeafId(runtime, rootInstanceId, true); + return useInstanceSnapshot(runtime, leafId); +}