From 8a3a199d01f5f430e88768d922fc3eed72a0187e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 18:56:41 +0000 Subject: [PATCH 1/9] feat(journeys,core): fix defineModule + derive step ordering/progress (feedback items 3 & 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the next two items from docs/consumer-feedback-production-app.md. Item 3 — defineModule usable by real apps (all five variants: @modular-frontend/core + the four router cores): - Infer a trailing TDescriptor from the argument and return it verbatim, so entryPoints/exitPoints keep their literal keys instead of widening. typeof someModule now drops into a journey TransitionMap/StepSpec with zero casts. - Infer TNavItem from the navigation array (descriptor & { navigation?: readonly TNavItem[] }), defaulting to NavigationItem only when absent, so function-form `to: (ctx) => ...` type-checks with no generics while the inferred-narrow item stays assignable to a NavigationItem-typed register(). Item 4 — derive step ordering + progress from the transition graph: - resolveStepSequence(definition, options?) walks the static defineTransition targets graph and returns the ordered step list (linear, or branch-selected). - JourneyDefinition.steps: per-step { path, progressLabel } metadata, keyed and type-checked against the real modules/entries (single source of truth). - useJourneyProgress on React and Vue returns { index, total, label, steps } — the stepCount that JourneyHost (item 2) deferred, now graph-derived. Adds acceptance + unit + type tests across the touched packages; updates the changelog and marks items 3 & 4 shipped in the tracker. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KThnW7wGDMovXu8JdV4q2E --- CHANGELOG.md | 15 ++ docs/consumer-feedback-production-app.md | 52 ++++ .../src/define-module.test-d.ts | 17 +- .../angular-router-core/src/define-module.ts | 31 ++- .../frontend-core/src/define-module.test-d.ts | 56 +++++ packages/frontend-core/src/define-module.ts | 12 +- packages/frontend-core/src/index.ts | 2 + .../frontend-core/src/journey-contracts.ts | 44 ++++ packages/journeys-engine/src/index.ts | 11 + .../src/resolve-step-sequence.test-d.ts | 77 ++++++ .../src/resolve-step-sequence.test.ts | 237 ++++++++++++++++++ .../src/resolve-step-sequence.ts | 205 +++++++++++++++ packages/journeys-engine/src/types.ts | 17 ++ packages/journeys/src/index.ts | 18 ++ .../src/use-journey-progress.test.tsx | 162 ++++++++++++ packages/journeys/src/use-journey-progress.ts | 109 ++++++++ .../react-router-core/src/define-module.ts | 31 ++- .../src/define-module.test-d.ts | 120 +++++++++ .../tanstack-router-core/src/define-module.ts | 31 ++- .../tanstack-router-core/vitest.config.ts | 14 ++ packages/vue-core/src/define-module.test-d.ts | 16 +- packages/vue-core/src/define-module.ts | 31 ++- packages/vue-journeys/src/index.ts | 17 ++ .../src/use-journey-progress.test.ts | 145 +++++++++++ .../vue-journeys/src/use-journey-progress.ts | 96 +++++++ 25 files changed, 1530 insertions(+), 36 deletions(-) create mode 100644 packages/frontend-core/src/define-module.test-d.ts create mode 100644 packages/journeys-engine/src/resolve-step-sequence.test-d.ts create mode 100644 packages/journeys-engine/src/resolve-step-sequence.test.ts create mode 100644 packages/journeys-engine/src/resolve-step-sequence.ts create mode 100644 packages/journeys/src/use-journey-progress.test.tsx create mode 100644 packages/journeys/src/use-journey-progress.ts create mode 100644 packages/tanstack-router-core/src/define-module.test-d.ts create mode 100644 packages/tanstack-router-core/vitest.config.ts create mode 100644 packages/vue-journeys/src/use-journey-progress.test.ts create mode 100644 packages/vue-journeys/src/use-journey-progress.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2391f8..40d16d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,21 @@ Closes items 1 and 2 of `docs/consumer-feedback-production-app.md`: the two larg A URL cannot navigate a journey to an arbitrary step, and the API does not pretend otherwise. A step is derived from state and `JourneyStep` carries no identity (`{ moduleId, entry, input }` and nothing more), so the only positions a location can select are ones the journey has already been to (`history` → `rewindTo`) or just rewound from (`future` → `goForward`). Anything else fires `onUnresolved` and navigates nothing — the sync will not drag a user back who clicked a link out of the flow on purpose. A frame the journey refuses to leave (no `allowBack` opt-in, or a child in flight) re-asserts the current step's URL — the standard "block the Back button" shape — and fires `onBlocked`; with `go` it walks the browser forward by the rejected distance so a refused multi-entry jump keeps the intermediate entries, falling back to a (truncating) `push` only when the port has no `go`. A sync tracks the one instance it is given and does not follow child journeys — a child in flight simply reads as a blocked parent frame. +### Added — `defineModule` literal inference + function-form `nav.to` + +Closes item 3 of `docs/consumer-feedback-production-app.md`: the production consumer abandoned `defineModule` entirely — every module carried a copy-pasted apology explaining why a plain `as const` object literal was used instead — because the helper widened its literal shape and its default nav-item generic rejected function-form `to`. Both defects are now fixed, across every variant. + +- **`@modular-frontend/core`**, **`@tanstack-react-modules/core`**, **`@react-router-modules/core`**, **`@modular-vue/core`**, **`@angular-router-modules/core`** — `defineModule` now infers a trailing `TDescriptor` from its argument and returns it verbatim, so `entryPoints` / `exitPoints` keep their **literal** keys instead of widening to `EntryPointMap` / `ExitPointMap`. The acceptance test the item asked for is met: `typeof someModule` drops into a journey `TransitionMap` (and `StepSpec`) as a `TModules` member with **zero casts**, and an exit handler's `output` narrows per exit. (The neutral core already inferred `TDescriptor`; the four router cores did not — they returned the widened `ModuleDescriptor<…>`.) The four router `defineModule`s keep their router-narrowed `createRoutes`, so this is a pure return-type refinement, not a behavior change. +- **(same packages)** — `TNavItem` is now **inferred from the `navigation` array** (the parameter is typed `descriptor & { navigation?: readonly TNavItem[] }`), defaulting to `NavigationItem` only when there is no navigation. A module that resolves its href at render time (`to: (ctx) => "/x/" + ctx.id`) type-checks with **zero generics** — the old fixed `NavigationItem` default narrowed `to` to a plain `string` and rejected the resolver form. Inference (rather than defaulting the slot to the wide `NavigationItemBase` bound) is deliberate: a fixed wide default made the returned descriptor unassignable to a `NavigationItem`-typed `register()`, so the inferred-narrow item keeps registration working while still admitting function-form `to`. Explicitly spelling `TNavItem` (typed i18n labels, dynamic-href context, a typed `meta` bag) still narrows exactly as before. + +### Added — derive step ordering and progress from the transition graph + +Closes item 4 of `docs/consumer-feedback-production-app.md`: the consumer encoded each journey's flow twice — once as the transition-map graph, and again as a ~170-line hand-maintained file of ordered step arrays (in three branch-variant copies) for URL segments and "Step X of N", kept in sync only by discipline. The ordering and progress primitives now live in the library, derived from the one place the flow is already encoded. + +- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires annotated (`defineTransition`) handlers**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceRef`. +- **`@modular-frontend/core`** — `JourneyDefinition.steps?: JourneyStepMetaMap`. Per-step presentation metadata keyed by `[moduleId][entry]` exactly like `transitions` (entry keys filtered to journey-mountable entries, so a typo is a compile error), each leaf a `JourneyStepMeta` (`{ path?, progressLabel? }`). `path` overrides the URL sync's default `"moduleId/entry"` segment; `progressLabel` feeds progress UIs. One source of truth beside the transitions, not re-encoded at each `next:`. New public types: `JourneyStepMeta`, `JourneyStepMetaMap`. +- **`@modular-react/journeys`**, **`@modular-vue/journeys`** — `useJourneyProgress(instanceId, definition, options?)`. Returns `{ index, total, label, steps }`: `index` from the live instance (`history.length`, so it rewinds when the journey does), `total` / `label` / `steps` from `resolveStepSequence`. This is the `stepCount` that the journey-hosting work (item 2) deferred with "deriving it from the graph is tracked separately" — now derivable because the total comes from the graph rather than a hand-passed number. `total` is `null` when the sequence can't be walked (unannotated transitions). The React hook returns a plain object; the Vue composable returns `ComputedRef`s. `options.sequence` forwards to `resolveStepSequence` (chiefly `branch`, to make the total reflect a chosen path). `resolveStepSequence` and its types are re-exported from both bindings. + ### 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). diff --git a/docs/consumer-feedback-production-app.md b/docs/consumer-feedback-production-app.md index b5fc81b8..50192312 100644 --- a/docs/consumer-feedback-production-app.md +++ b/docs/consumer-feedback-production-app.md @@ -90,6 +90,29 @@ becomes mountable in one line. ## 3. Fix `defineModule` so real apps can use it (literal inference + function-form `nav.to`) +> **Shipped** — both defects are fixed across every `defineModule` variant +> (`@modular-frontend/core` plus the four router cores: +> `@tanstack-react-modules/core`, `@react-router-modules/core`, +> `@modular-vue/core`, `@angular-router-modules/core`). +> +> **(a) Literal inference.** Each `defineModule` now infers a trailing +> `TDescriptor` from its argument and returns it verbatim, so `entryPoints` / +> `exitPoints` keep their literal keys instead of widening to +> `EntryPointMap` / `ExitPointMap`. The acceptance test is met: +> `typeof someModule` drops into a journey `TransitionMap` (and `StepSpec`) with +> zero casts, and the exit handler's `output` narrows per exit — see +> `packages/tanstack-router-core/src/define-module.test-d.ts`. +> +> **(b) Function-form `to`.** `TNavItem` is now inferred from the `navigation` +> array (the parameter is typed `TDescriptor & { navigation?: readonly TNavItem[] }`), +> defaulting to `NavigationItem` only when there is no +> navigation. A module that resolves its href at render time (`to: (ctx) => …`) +> type-checks with zero generics. Inference — rather than defaulting the slot to +> the wide `NavigationItemBase` bound — was deliberate: a fixed wide default +> made the returned descriptor unassignable to a `NavigationItem`-typed +> `register()`, so the inferred-narrow item keeps registration working while +> still admitting the resolver form. + **Evidence.** The app does not use `defineModule` at all. Four of its modules carry a near-verbatim copy of a justification comment explaining that a plain object literal with `as const` is used instead, because (a) `defineModule` @@ -110,6 +133,35 @@ as a `TModules` member in a journey `TransitionMap` with zero casts. ## 4. Derive step ordering and progress from the transition graph +> **Shipped** — the ordering and progress primitives now live in the engine and +> both bindings. +> +> - **`resolveStepSequence(definition, options?)`** (`@modular-frontend/journeys-engine`) +> walks the transition graph statically — following the `targets` each +> `defineTransition` handler already declares — and returns the ordered step +> list from the start step forward. Linear flows resolve on their own; a +> forking flow takes an `options.branch` resolver to pick the path at each +> fork (`options.input` / `options.start` seed the first step). Returns the +> linear-with-branches spine, which is all it takes to delete the +> hand-maintained ordered-step arrays. +> - **Per-step metadata** rides on a new journey-level `steps` map +> (`steps[module][entry] = { path?, progressLabel? }`), keyed against the real +> modules/entries so a typo is a compile error. `path` overrides the URL sync's +> default `"moduleId/entry"` segment; `progressLabel` feeds progress UIs. One +> source of truth beside the transitions, not re-encoded at each `next:`. +> - **`useJourneyProgress()`** ships on both React (`@modular-react/journeys`) +> and Vue (`@modular-vue/journeys`), returning `{ index, total, label, steps }`: +> `index` from the live instance (`history.length`), `total` / `label` / +> `steps` from `resolveStepSequence`. This is the `stepCount` that item 2 +> deferred — now derivable because the total comes from the graph, not a +> hand-passed number. +> +> **Scope.** The walk requires annotated (`defineTransition`) handlers — a step +> whose transitions are all bare functions has no statically-known next step, so +> the sequence stops there (`total` reflects what is resolvable). Deriving an +> arbitrary DAG ordering is out of scope; linear-with-branches is what the +> evidence called for. + **Evidence.** Each journey's flow is encoded twice: once as the real transition-map graph, and again in a ~170-line hand-maintained file of ordered step arrays for URL segments and "Step X of N" — in _three_ branch-variant diff --git a/packages/angular-router-core/src/define-module.test-d.ts b/packages/angular-router-core/src/define-module.test-d.ts index 6a06757e..6adb5199 100644 --- a/packages/angular-router-core/src/define-module.test-d.ts +++ b/packages/angular-router-core/src/define-module.test-d.ts @@ -9,10 +9,15 @@ describe("defineModule typing", () => { const mod = defineModule({ id: "billing", version: "1.0.0", - createRoutes: () => [{ path: "billing" }], + createRoutes: (): Route[] => [{ path: "billing" }], }); - expectTypeOf(mod.createRoutes).toEqualTypeOf<(() => Route | Route[]) | undefined>(); + // `defineModule` now preserves the descriptor's *literal* shape (so a + // journey can read its entry/exit vocabulary off `typeof mod`), which means + // `createRoutes` keeps its authored signature rather than widening to the + // base `(() => Route | Route[]) | undefined`. It must still be assignable + // to the Angular-narrowed base signature. + expectTypeOf(mod.createRoutes).toExtend<(() => Route | Route[]) | undefined>(); }); it("accepts a single route, not just an array", () => { @@ -38,7 +43,13 @@ describe("defineModule typing", () => { version: "1.0.0", }); - expectTypeOf(mod).toEqualTypeOf>(); + // `defineModule` now returns the *inferred literal* rather than the widened + // `ModuleDescriptor` — that is what lets a journey read + // a module's literal entry/exit vocabulary off `typeof mod`. The explicit + // `` generics still constrain the argument, so the result + // stays assignable to the descriptor over the same deps/slots. + const asBase: ModuleDescriptor = mod; + void asBase; }); it("passes typed i18n-label keys through navigation items", () => { diff --git a/packages/angular-router-core/src/define-module.ts b/packages/angular-router-core/src/define-module.ts index eb6f6e42..e8ba2a9f 100644 --- a/packages/angular-router-core/src/define-module.ts +++ b/packages/angular-router-core/src/define-module.ts @@ -5,10 +5,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * Identity function that provides type inference for Angular Router module descriptors. * Zero runtime overhead — returns its argument unchanged. * - * See {@link NavigationItem} for the three generics that let you tighten - * navigation typing: typed i18n labels, typed dynamic-href context, and a - * typed `meta` bag for app-specific fields (permission actions, badges, - * analytics ids, etc.). + * See `NavigationItem` for the three generics that let you tighten navigation + * typing: typed i18n labels, typed dynamic-href context, and a typed `meta` + * bag for app-specific fields (permission actions, badges, analytics ids, etc.). * * ```ts * interface JourneyMeta { name: string; category: string } @@ -16,14 +15,32 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * * export default defineModule({ ... }) * ``` + * + * Two inference guarantees matter for journeys built on `typeof someModule`: + * + * 1. **Literal shape is preserved.** The trailing `TDescriptor` generic is + * inferred from the argument and returned verbatim, so `entryPoints` / + * `exitPoints` keep their *literal* keys instead of widening to + * `EntryPointMap` / `ExitPointMap`. A journey's `TransitionMap<{ m: typeof + * someModule }, …>` then resolves the module's real entry/exit vocabulary — + * no casts, no re-declaring the entry names by hand. + * 2. **Function-form `to` works without spelling `TNavItem`.** `TNavItem` is + * inferred from the `navigation` array (the `descriptor & { navigation?: + * readonly TNavItem[] }` parameter shape), defaulting to `NavigationItem` + * only when there is no navigation. That inference admits a module that + * resolves its href at render time (`to: (ctx) => …`) with zero generics — + * the old fixed `NavigationItem` default narrowed `to` to a plain `string` + * and rejected the resolver form — while keeping the inferred item narrow so + * the result stays assignable where a `NavigationItem`-typed registry + * expects it. */ export function defineModule< TSharedDependencies extends Record = Record, TSlots extends SlotMapOf = SlotMap, TMeta extends { [K in keyof TMeta]: unknown } = Record, TNavItem extends NavigationItemBase = NavigationItem, ->( - descriptor: ModuleDescriptor, -): ModuleDescriptor { + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { return descriptor; } diff --git a/packages/frontend-core/src/define-module.test-d.ts b/packages/frontend-core/src/define-module.test-d.ts new file mode 100644 index 00000000..106f7bfd --- /dev/null +++ b/packages/frontend-core/src/define-module.test-d.ts @@ -0,0 +1,56 @@ +// Type-level acceptance tests for `defineModule`'s navigation defaults. +// +// Production-feedback item 3 (b): `defineModule`'s default nav-item generic +// used to be `NavigationItem`, which narrows `to` to a plain `string`. A module +// that resolves its href from render-time context (`to: (ctx) => …`) then only +// compiled if the author spelled the fourth generic — so real apps abandoned +// the helper. The default is now the structural `NavigationItemBase` bound, +// which admits function-form `to` with zero generics while still accepting the +// plain-string form and any explicitly-narrowed `TNavItem`. +// +// Runs through vitest's typecheck pass (see vitest.config.ts). + +import { test } from "vitest"; +import { defineModule } from "./define-module.js"; +import type { NavigationItem } from "./types.js"; + +test("function-form `to` type-checks with ZERO explicit generics", () => { + const m = defineModule({ + id: "portal", + version: "1.0.0", + navigation: [ + { label: "Requests", to: (ctx: { workspaceId: string }) => `/portal/${ctx.workspaceId}` }, + ], + }); + void m; +}); + +test("plain-string `to` still type-checks with ZERO explicit generics", () => { + const m = defineModule({ + id: "settings", + version: "1.0.0", + navigation: [{ label: "Settings", to: "/settings" }], + }); + void m; +}); + +test("an explicitly-narrowed TNavItem is still honored", () => { + type AppNavItem = NavigationItem<"nav.billing", { orgId: string }, { badge?: "beta" }>; + + const m = defineModule, Record, never, AppNavItem>({ + id: "billing", + version: "1.0.0", + navigation: [ + { label: "nav.billing", to: (ctx) => `/billing/${ctx.orgId}`, meta: { badge: "beta" } }, + ], + }); + void m; + + const bad = defineModule, Record, never, AppNavItem>({ + id: "billing", + version: "1.0.0", + // @ts-expect-error — "nope" is not the narrowed `nav.billing` label union. + navigation: [{ label: "nope", to: "/billing" }], + }); + void bad; +}); diff --git a/packages/frontend-core/src/define-module.ts b/packages/frontend-core/src/define-module.ts index c2857c47..5caa1d12 100644 --- a/packages/frontend-core/src/define-module.ts +++ b/packages/frontend-core/src/define-module.ts @@ -17,7 +17,15 @@ import type { * - `TNavItem` — app-specific navigation item type. Alias * `NavigationItem` once in your app and pass * it through, so typed i18n labels, dynamic hrefs, and typed `meta` are - * enforced on every module. + * enforced on every module. When you don't pass it, it is **inferred from the + * `navigation` array** (the `descriptor & { navigation?: readonly TNavItem[] }` + * parameter shape), defaulting to `NavigationItem` only when there is no + * navigation. That inference is what lets a module use **function-form** `to` + * (`to: (ctx) => "/portal/" + ctx.workspaceId`) with zero generics: the old + * fixed `NavigationItem` default narrowed `to` to a plain `string` and + * rejected the resolver form. The inferred item stays narrow (a plain-string + * `to` infers a `string`-`to` item), so the result is still assignable where + * a `NavigationItem`-typed registry expects it. * * ```ts * interface JourneyMeta { name: string; category: string } @@ -51,6 +59,6 @@ export function defineModule< TNavItem extends NavigationItemBase = NavigationItem, TDescriptor extends ModuleDescriptor = ModuleDescriptor, ->(descriptor: TDescriptor): TDescriptor { +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { return descriptor; } diff --git a/packages/frontend-core/src/index.ts b/packages/frontend-core/src/index.ts index 44cb79d4..4ffca4d2 100644 --- a/packages/frontend-core/src/index.ts +++ b/packages/frontend-core/src/index.ts @@ -133,6 +133,8 @@ export type { StepInputSlot, JourneyStep, JourneyStepFor, + JourneyStepMeta, + JourneyStepMetaMap, ExitCtx, TransitionResult, EntryTransitions, diff --git a/packages/frontend-core/src/journey-contracts.ts b/packages/frontend-core/src/journey-contracts.ts index 55c74133..b0c268ba 100644 --- a/packages/frontend-core/src/journey-contracts.ts +++ b/packages/frontend-core/src/journey-contracts.ts @@ -207,6 +207,50 @@ export type StepSpec = }[EntryNamesByMountKindOf & string]; }[keyof TModules & string]; +/** + * Declarative per-step presentation metadata a journey attaches to a + * `(module, entry)` pair via {@link JourneyStepMetaMap}. Purely descriptive — + * the runtime never reads it to make transition decisions. Consumed by + * `resolveStepSequence` (to label a derived step list), a progress hook + * (`useJourneyProgress`), and the journey ↔ URL sync (a per-step `path` + * overrides the default `"moduleId/entry"` segment). + * + * Keeping it on the journey definition — rather than duplicated into every + * transition's returned `StepSpec` — is deliberate: the flow's ordering already + * lives in the transition graph, so its presentation should live beside it, in + * one place, instead of being re-encoded at each `next:` site. + */ +export interface JourneyStepMeta { + /** + * URL segment for this step. Overrides the sync's default + * `"moduleId/entry"` path. Should be unique within the journey — two steps + * that share a path are indistinguishable to the URL reconciler. + */ + readonly path?: string; + /** + * Human-readable label for progress UIs (breadcrumbs, "Step X — Shipping"). + * Surfaced on the resolved step and via `useJourneyProgress`. + */ + readonly progressLabel?: string; +} + +/** + * Per-step metadata for a journey, keyed by `[moduleId][entryName]` exactly + * like {@link TransitionMap}. Entry keys are filtered to journey-mountable + * entries, so declaring metadata for a composition-only entry (one that could + * never be a journey step) is a compile error. Every level is optional — + * annotate only the steps that need a `path` or `progressLabel`. + * + * This is the single source of truth item 4 of the production-feedback tracker + * asked for: URL segments and progress labels derived from the graph instead + * of hand-maintained ordered arrays that silently drift from the transitions. + */ +export type JourneyStepMetaMap = { + readonly [M in keyof TModules]?: { + readonly [E in EntryNamesByMountKindOf]?: JourneyStepMeta; + }; +}; + /** * Snapshot of a single step in a journey's history / current position. * The runtime stores history as the wide `JourneyStep` form; diff --git a/packages/journeys-engine/src/index.ts b/packages/journeys-engine/src/index.ts index 24be7c3d..dab01d8e 100644 --- a/packages/journeys-engine/src/index.ts +++ b/packages/journeys-engine/src/index.ts @@ -63,6 +63,15 @@ export { } from "./define-transition.js"; export type { AnnotatedTransitionHandler, StepRef, TerminalSentinel } from "./define-transition.js"; +// Derive an ordered step list (URL segments, "Step X of N") from the transition +// graph — the runtime companion to the catalog harvester's static extraction. +export { resolveStepSequence } from "./resolve-step-sequence.js"; +export type { + ResolvedJourneyStep, + ResolveStepSequenceOptions, + StepSequenceRef, +} from "./resolve-step-sequence.js"; + // Handles — open a journey with typed `input` without importing its runtime. export { defineJourneyHandle, invoke } from "./handle.js"; export type { JourneyHandle } from "./handle.js"; @@ -92,6 +101,8 @@ export type { JourneyStatus, JourneyStep, JourneyStepFor, + JourneyStepMeta, + JourneyStepMetaMap, JourneySystemAbortReason, JourneySystemAbortReasonCode, MaybePromise, diff --git a/packages/journeys-engine/src/resolve-step-sequence.test-d.ts b/packages/journeys-engine/src/resolve-step-sequence.test-d.ts new file mode 100644 index 00000000..e1812525 --- /dev/null +++ b/packages/journeys-engine/src/resolve-step-sequence.test-d.ts @@ -0,0 +1,77 @@ +// Type-level tests for the journey-level `steps` metadata map (item 4). +// The map must be keyed against the journey's real modules and their +// journey-mountable entries, so a typo in a module id or entry name is a +// compile error rather than dead metadata that silently never matches. + +import { test } from "vitest"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; +import { defineJourney } from "./define-journey.js"; + +const plan = defineModule({ + id: "plan", + version: "1.0.0", + exitPoints: { chosen: defineExit() }, + entryPoints: { + choose: defineEntry({ component: (() => null) as never, input: schema<{ readonly x: 1 }>() }), + }, +}); + +type Modules = { readonly plan: typeof plan }; +interface State { + readonly done: boolean; +} + +test("`steps` accepts declared module + entry keys with JourneyStepMeta values", () => { + defineJourney()({ + id: "j", + version: "1.0.0", + initialState: () => ({ done: false }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + steps: { + plan: { choose: { path: "plan", progressLabel: "Pick a plan" } }, + }, + transitions: {}, + }); +}); + +test("`steps` rejects an unknown module id", () => { + defineJourney()({ + id: "j", + version: "1.0.0", + initialState: () => ({ done: false }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + steps: { + // @ts-expect-error — "nope" is not a module in `Modules`. + nope: { choose: { path: "x" } }, + }, + transitions: {}, + }); +}); + +test("`steps` rejects an unknown entry name on a real module", () => { + defineJourney()({ + id: "j", + version: "1.0.0", + initialState: () => ({ done: false }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + steps: { + // @ts-expect-error — `plan` declares `choose`, not `missing`. + plan: { missing: { path: "x" } }, + }, + transitions: {}, + }); +}); + +test("`steps` rejects an unknown key on JourneyStepMeta", () => { + defineJourney()({ + id: "j", + version: "1.0.0", + initialState: () => ({ done: false }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + steps: { + // @ts-expect-error — `title` is not a field of JourneyStepMeta. + plan: { choose: { title: "x" } }, + }, + transitions: {}, + }); +}); diff --git a/packages/journeys-engine/src/resolve-step-sequence.test.ts b/packages/journeys-engine/src/resolve-step-sequence.test.ts new file mode 100644 index 00000000..e46893fb --- /dev/null +++ b/packages/journeys-engine/src/resolve-step-sequence.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; +import { defineJourney } from "./define-journey.js"; +import { defineTransition } from "./define-transition.js"; +import { resolveStepSequence } from "./resolve-step-sequence.js"; + +// --- Modules ----------------------------------------------------------------- + +const profile = defineModule({ + id: "profile", + version: "1.0.0", + exitPoints: { done: defineExit() }, + entryPoints: { + review: defineEntry({ + component: (() => null) as never, + input: schema<{ readonly customerId: string }>(), + }), + }, +}); + +const plan = defineModule({ + id: "plan", + version: "1.0.0", + exitPoints: { chosen: defineExit(), premium: defineExit() }, + entryPoints: { + choose: defineEntry({ component: (() => null) as never, input: schema<{ readonly x: 1 }>() }), + upsell: defineEntry({ component: (() => null) as never, input: schema<{ readonly x: 1 }>() }), + }, +}); + +const billing = defineModule({ + id: "billing", + version: "1.0.0", + exitPoints: { paid: defineExit() }, + entryPoints: { + collect: defineEntry({ component: (() => null) as never, input: schema<{ readonly x: 1 }>() }), + }, +}); + +type Modules = { + readonly profile: typeof profile; + readonly plan: typeof plan; + readonly billing: typeof billing; +}; +interface State { + readonly tier: string | null; +} + +const transition = defineTransition(); + +// --- A linear journey: profile → plan → billing → complete ------------------- + +const linear = defineJourney()({ + id: "linear", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "profile", entry: "review", input: { customerId: "c1" } }), + steps: { + profile: { review: { path: "welcome", progressLabel: "Welcome" } }, + plan: { choose: { path: "plan", progressLabel: "Pick a plan" } }, + billing: { collect: { progressLabel: "Payment" } }, + }, + transitions: { + profile: { + review: { + done: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }), + }, + }, + plan: { + choose: { + chosen: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: () => ({ next: { module: "billing", entry: "collect", input: { x: 1 } } }), + }), + }, + }, + billing: { + collect: { + paid: transition({ + targets: ["complete"], + handle: () => ({ complete: undefined }), + }), + }, + }, + }, +}); + +describe("resolveStepSequence — linear flow", () => { + it("walks the transition graph from start to the terminal step", () => { + const seq = resolveStepSequence(linear); + expect(seq.map((s) => `${s.module}/${s.entry}`)).toEqual([ + "profile/review", + "plan/choose", + "billing/collect", + ]); + }); + + it("attaches per-step path / progressLabel from `steps`", () => { + const seq = resolveStepSequence(linear); + expect(seq).toEqual([ + { module: "profile", entry: "review", path: "welcome", progressLabel: "Welcome" }, + { module: "plan", entry: "choose", path: "plan", progressLabel: "Pick a plan" }, + { module: "billing", entry: "collect", progressLabel: "Payment" }, + ]); + }); + + it("yields a total suitable for 'Step X of N'", () => { + expect(resolveStepSequence(linear).length).toBe(3); + }); + + it("honors an explicit `start` (resolve a sub-sequence mid-flow)", () => { + const seq = resolveStepSequence(linear, { start: { module: "plan", entry: "choose" } }); + expect(seq.map((s) => `${s.module}/${s.entry}`)).toEqual(["plan/choose", "billing/collect"]); + }); +}); + +// --- A branching journey: plan.choose forks to billing OR plan.upsell -------- + +const branching = defineJourney()({ + id: "branching", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + transitions: { + plan: { + choose: { + chosen: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: () => ({ next: { module: "billing", entry: "collect", input: { x: 1 } } }), + }), + premium: transition({ + targets: [{ module: "plan", entry: "upsell" }], + handle: () => ({ next: { module: "plan", entry: "upsell", input: { x: 1 } } }), + }), + }, + upsell: { + chosen: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: () => ({ next: { module: "billing", entry: "collect", input: { x: 1 } } }), + }), + }, + }, + billing: { + collect: { + paid: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, +}); + +describe("resolveStepSequence — branching flow", () => { + it("stops at a fork when no branch resolver is supplied", () => { + const seq = resolveStepSequence(branching); + // `plan/choose` has two distinct forward targets — the walk cannot pick. + expect(seq.map((s) => `${s.module}/${s.entry}`)).toEqual(["plan/choose"]); + }); + + it("follows the branch the resolver selects", () => { + const upsellPath = resolveStepSequence(branching, { + branch: ({ targets }) => targets.find((t) => t.entry === "upsell"), + }); + expect(upsellPath.map((s) => `${s.module}/${s.entry}`)).toEqual([ + "plan/choose", + "plan/upsell", + "billing/collect", + ]); + + const directPath = resolveStepSequence(branching, { + branch: ({ targets }) => targets.find((t) => t.module === "billing"), + }); + expect(directPath.map((s) => `${s.module}/${s.entry}`)).toEqual([ + "plan/choose", + "billing/collect", + ]); + }); + + it("stops the sequence when the resolver returns undefined", () => { + const seq = resolveStepSequence(branching, { branch: () => undefined }); + expect(seq.map((s) => `${s.module}/${s.entry}`)).toEqual(["plan/choose"]); + }); +}); + +// --- Edge cases -------------------------------------------------------------- + +describe("resolveStepSequence — edge cases", () => { + it("stops at a step whose transitions are bare (unannotated) handlers", () => { + const bare = defineJourney()({ + id: "bare", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "profile", entry: "review", input: { customerId: "c1" } }), + transitions: { + profile: { + review: { + // Bare function — no `targets`, so no statically-known next step. + done: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }, + }, + }, + }); + expect(resolveStepSequence(bare).map((s) => s.module)).toEqual(["profile"]); + }); + + it("breaks a cycle instead of looping forever", () => { + const cyclic = defineJourney()({ + id: "cyclic", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + transitions: { + plan: { + choose: { + chosen: transition({ + targets: [{ module: "plan", entry: "upsell" }], + handle: () => ({ next: { module: "plan", entry: "upsell", input: { x: 1 } } }), + }), + }, + upsell: { + chosen: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }), + }, + }, + }, + }); + const seq = resolveStepSequence(cyclic); + expect(seq.map((s) => `${s.module}/${s.entry}`)).toEqual(["plan/choose", "plan/upsell"]); + }); + + it("respects maxSteps", () => { + expect(resolveStepSequence(linear, { maxSteps: 2 }).length).toBe(2); + }); +}); diff --git a/packages/journeys-engine/src/resolve-step-sequence.ts b/packages/journeys-engine/src/resolve-step-sequence.ts new file mode 100644 index 00000000..2b07412f --- /dev/null +++ b/packages/journeys-engine/src/resolve-step-sequence.ts @@ -0,0 +1,205 @@ +import type { JourneyStepMeta, ModuleTypeMap } from "@modular-frontend/core"; +import type { JourneyDefinition } from "./types.js"; +import { isAnnotatedTransition, isTerminalSentinel } from "./define-transition.js"; + +/** + * One step in a resolved journey sequence — the `(module, entry)` identity plus + * any declarative {@link JourneyStepMeta} the definition attached under + * `steps[module][entry]`. + */ +export interface ResolvedJourneyStep { + readonly module: string; + readonly entry: string; + /** `path` from the step's {@link JourneyStepMeta}, if declared. */ + readonly path?: string; + /** `progressLabel` from the step's {@link JourneyStepMeta}, if declared. */ + readonly progressLabel?: string; +} + +/** A bare `(module, entry)` reference — a candidate forward step. */ +export interface StepSequenceRef { + readonly module: string; + readonly entry: string; +} + +export interface ResolveStepSequenceOptions { + /** + * Input handed to `definition.initialState` / `definition.start` to compute + * the first step. Omit for void-input journeys. Ignored when {@link start} + * is supplied. + */ + readonly input?: TInput; + /** + * Explicit first step. Skips calling `initialState` / `start` — useful when + * the start step is dynamic on input in a way that does not affect the + * sequence, or when resolving a sub-sequence from a mid-flow step. + */ + readonly start?: StepSequenceRef; + /** + * Fork resolver. When a step's transitions declare more than one distinct + * forward `(module, entry)` target, the walk cannot linearize on its own — + * this callback picks which branch to follow. Return one of `ctx.targets` + * (identity not required — matched by `module` + `entry`), or `undefined` to + * stop the sequence at this fork. Not called for steps with a single forward + * target. + */ + readonly branch?: (ctx: { + readonly module: string; + readonly entry: string; + readonly targets: readonly StepSequenceRef[]; + }) => StepSequenceRef | undefined; + /** + * Hard cap on sequence length — a backstop against a pathological graph. + * Default 256. The walk also stops on its own when it revisits a step + * (cycle) or reaches a step with no forward target. + */ + readonly maxSteps?: number; +} + +const DEFAULT_MAX_STEPS = 256; + +/** + * Derive an ordered step list for a journey by walking its transition graph + * statically, following the `targets` each {@link defineTransition} handler + * declares. Returns the linear spine from the start step forward — for a + * branching flow, pass `options.branch` to choose the path at each fork. + * + * This is the runtime companion to the catalog harvester's build-time + * destination extraction: it lets an app derive URL-segment ordering and a + * "Step X of N" total from the *one* place the flow is already encoded (the + * transitions), deleting the hand-maintained ordered-step arrays that item 4 + * of the production-feedback tracker flagged as duplicated, drift-prone glue. + * + * **Requires annotated transitions.** The walk reads each handler's `targets` + * (stamped by `defineTransition`). A step whose transitions are all *bare* + * function handlers has no statically-known forward target, so the sequence + * stops there. Terminal sentinels (`"complete"` / `"abort"` / `"invoke"`) + * carry no next step and are skipped. + * + * Each returned step carries any `path` / `progressLabel` declared under + * `definition.steps[module][entry]`. + * + * @example + * ```ts + * const steps = resolveStepSequence(checkout); + * const total = steps.length; // "Step X of N" + * const paths = steps.map((s) => s.path ?? `${s.module}/${s.entry}`); + * ``` + */ +export function resolveStepSequence< + TModules extends ModuleTypeMap, + TState, + TInput, + TOutput, + TMeta extends { [K in keyof TMeta]: unknown }, +>( + definition: JourneyDefinition, + options: ResolveStepSequenceOptions = {}, +): readonly ResolvedJourneyStep[] { + const maxSteps = normalizeMaxSteps(options.maxSteps); + + let current: StepSequenceRef | undefined = + options.start ?? deriveStart(definition, options.input); + + const sequence: ResolvedJourneyStep[] = []; + const visited = new Set(); + + while (current && sequence.length < maxSteps) { + const key = stepKey(current.module, current.entry); + if (visited.has(key)) break; // cycle — a linear spine visits each step once + visited.add(key); + + const meta = readStepMeta(definition, current.module, current.entry); + sequence.push({ + module: current.module, + entry: current.entry, + ...(meta?.path !== undefined ? { path: meta.path } : {}), + ...(meta?.progressLabel !== undefined ? { progressLabel: meta.progressLabel } : {}), + }); + + const targets = forwardTargets(definition, current.module, current.entry); + if (targets.length === 0) break; + + current = + targets.length === 1 + ? targets[0] + : options.branch?.({ module: current.module, entry: current.entry, targets }); + } + + return sequence; +} + +function normalizeMaxSteps(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_STEPS; + return Math.floor(value); +} + +function stepKey(module: string, entry: string): string { + // JSON-encode the pair so a module or entry name that itself contains the + // separator can't collide two distinct steps onto the same visited key. + return JSON.stringify([module, entry]); +} + +/** + * Compute the first step by running the definition's `initialState` + `start`. + * These are author-supplied pure factories; a journey whose `initialState` + * needs a non-void input must pass it via `options.input`. + */ +function deriveStart(definition: AnyDefinition, input: unknown): StepSequenceRef { + const state = definition.initialState(input); + const spec = definition.start(state, input); + return { module: spec.module, entry: spec.entry }; +} + +function readStepMeta( + definition: AnyDefinition, + module: string, + entry: string, +): JourneyStepMeta | undefined { + const steps = definition.steps as + | Record | undefined> + | undefined; + return steps?.[module]?.[entry]; +} + +/** + * Distinct forward `(module, entry)` targets declared by any annotated exit + * handler on `transitions[module][entry]`. Bare handlers and terminal + * sentinels contribute nothing. Order follows first-declaration order across + * exits, deduped. + */ +function forwardTargets( + definition: AnyDefinition, + module: string, + entry: string, +): readonly StepSequenceRef[] { + const transitions = definition.transitions as + | Record | undefined> | undefined> + | undefined; + const perEntry = transitions?.[module]?.[entry]; + if (!perEntry) return []; + + const refs: StepSequenceRef[] = []; + const seen = new Set(); + for (const [exitName, handler] of Object.entries(perEntry)) { + // `allowBack` is a sibling boolean flag on the per-entry map, not a handler. + if (exitName === "allowBack") continue; + if (!isAnnotatedTransition(handler)) continue; + for (const target of handler.targets) { + if (isTerminalSentinel(target)) continue; + const key = stepKey(target.module, target.entry); + if (seen.has(key)) continue; + seen.add(key); + refs.push({ module: target.module, entry: target.entry }); + } + } + return refs; +} + +/** + * Internal alias for the generic-erased definition — `resolveStepSequence` + * walks the definition structurally (module ids and entry names are strings on + * the wire), so the helpers operate on the erased shape to avoid threading the + * five generics through every internal call. + */ +type AnyDefinition = JourneyDefinition; diff --git a/packages/journeys-engine/src/types.ts b/packages/journeys-engine/src/types.ts index 0521614f..4eefbe0a 100644 --- a/packages/journeys-engine/src/types.ts +++ b/packages/journeys-engine/src/types.ts @@ -10,6 +10,7 @@ import type { JourneyHandleRef, JourneyPersistence, JourneyStep, + JourneyStepMetaMap, ModuleTypeMap, ResumeMap, SerializedJourney, @@ -43,6 +44,8 @@ export type { JourneyStatus, JourneyStep, JourneyStepFor, + JourneyStepMeta, + JourneyStepMetaMap, JourneySystemAbortReason, JourneySystemAbortReasonCode, MaybePromise, @@ -87,6 +90,20 @@ export interface JourneyDefinition< readonly transitions: TransitionMap; + /** + * Declarative per-step presentation metadata, keyed by `[moduleId][entry]` + * exactly like {@link transitions}. Each leaf is a {@link JourneyStepMeta} + * (`path`, `progressLabel`). Optional and sparse — annotate only the steps + * that need it. + * + * This is the single source of truth for URL segments and progress labels: + * `resolveStepSequence` walks the transition graph and reads a step's `path` + * / `progressLabel` from here, so an app derives "Step X of N" and its + * deep-link segments from the flow itself instead of a hand-maintained + * ordered array that silently drifts from the transitions. + */ + readonly steps?: JourneyStepMetaMap; + /** * Wildcard transitions — fall-through handlers matched by exit name * (and optionally entry name) rather than by full `[mod][entry][exit]` diff --git a/packages/journeys/src/index.ts b/packages/journeys/src/index.ts index 3006c3f0..23145e55 100644 --- a/packages/journeys/src/index.ts +++ b/packages/journeys/src/index.ts @@ -61,6 +61,12 @@ export type { UseJourneyHostOptions, } from "./journey-host.js"; +// Progress for a running instance — `{ index, total }` derived from the +// transition graph (via `resolveStepSequence`), so "Step X of N" needs no +// hand-maintained ordered-step array. +export { useJourneyProgress } from "./use-journey-progress.js"; +export type { JourneyProgress, UseJourneyProgressOptions } from "./use-journey-progress.js"; + // Journey <-> URL sync. The reconciler is framework- and router-neutral (it // lives in the engine); this hook is the React lifetime wrapper, and the app // supplies a `JourneySyncPort` for its router. @@ -139,6 +145,16 @@ export type { TerminalSentinel, } from "@modular-frontend/journeys-engine"; +// Derive an ordered step list from the transition graph — URL segments, +// "Step X of N" — instead of a hand-maintained array. `useJourneyProgress` +// builds on this; apps that want the raw sequence import it directly. +export { resolveStepSequence } from "@modular-frontend/journeys-engine"; +export type { + ResolvedJourneyStep, + ResolveStepSequenceOptions, + StepSequenceRef, +} from "@modular-frontend/journeys-engine"; + export type { AbandonCtx, AnyJourneyDefinition, @@ -164,6 +180,8 @@ export type { JourneyStatus, JourneyStep, JourneyStepFor, + JourneyStepMeta, + JourneyStepMetaMap, JourneySystemAbortReason, JourneySystemAbortReasonCode, MaybePromise, diff --git a/packages/journeys/src/use-journey-progress.test.tsx b/packages/journeys/src/use-journey-progress.test.tsx new file mode 100644 index 00000000..a1098a0b --- /dev/null +++ b/packages/journeys/src/use-journey-progress.test.tsx @@ -0,0 +1,162 @@ +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 { + createJourneyRuntime, + defineJourney, + defineTransition, +} from "@modular-frontend/journeys-engine"; +import { createTestHarness } from "@modular-frontend/journeys-engine/testing"; +import { JourneyProvider } from "./provider.js"; +import { useJourneyProgress, type JourneyProgress } from "./use-journey-progress.js"; + +afterEach(() => { + cleanup(); +}); + +const profile = defineModule({ + id: "profile", + version: "1.0.0", + exitPoints: { done: defineExit() }, + entryPoints: { + review: defineEntry({ component: (() => null) as never, input: schema() }), + }, +}); +const plan = defineModule({ + id: "plan", + version: "1.0.0", + exitPoints: { chosen: defineExit() }, + entryPoints: { + choose: defineEntry({ component: (() => null) as never, input: schema() }), + }, +}); +const billing = defineModule({ + id: "billing", + version: "1.0.0", + exitPoints: { paid: defineExit() }, + entryPoints: { + collect: defineEntry({ component: (() => null) as never, input: schema() }), + }, +}); + +type Modules = { + readonly profile: typeof profile; + readonly plan: typeof plan; + readonly billing: typeof billing; +}; +interface State { + readonly ok: boolean; +} + +const transition = defineTransition(); + +const checkout = defineJourney()({ + id: "checkout", + version: "1.0.0", + initialState: () => ({ ok: true }), + start: () => ({ module: "profile", entry: "review", input: undefined }), + steps: { + profile: { review: { progressLabel: "Welcome" } }, + plan: { choose: { progressLabel: "Pick a plan" } }, + billing: { collect: { progressLabel: "Payment" } }, + }, + transitions: { + profile: { + review: { + done: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ next: { module: "plan", entry: "choose", input: undefined } }), + }), + }, + }, + plan: { + choose: { + chosen: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: () => ({ next: { module: "billing", entry: "collect", input: undefined } }), + }), + }, + }, + billing: { + collect: { + paid: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, +}); + +describe("useJourneyProgress", () => { + it("reports index / total / label and advances as the journey does", () => { + const runtime = createJourneyRuntime([{ definition: checkout, options: undefined }]); + const id = runtime.start(checkout.id, undefined); + const seen: JourneyProgress[] = []; + + function Probe() { + seen.push(useJourneyProgress(id, checkout)); + return null; + } + render( + + + , + ); + + expect(seen.at(-1)).toMatchObject({ index: 0, total: 3, label: "Welcome" }); + expect(seen.at(-1)?.steps.map((s) => `${s.module}/${s.entry}`)).toEqual([ + "profile/review", + "plan/choose", + "billing/collect", + ]); + + act(() => { + createTestHarness(runtime).fireExit(id, "done"); + }); + expect(seen.at(-1)).toMatchObject({ index: 1, total: 3, label: "Pick a plan" }); + + act(() => { + createTestHarness(runtime).fireExit(id, "chosen"); + }); + expect(seen.at(-1)).toMatchObject({ index: 2, total: 3, label: "Payment" }); + }); + + it("derives total even before an instance exists (index 0, label null)", () => { + let observed: JourneyProgress | undefined; + function Probe() { + observed = useJourneyProgress(null, checkout); + return null; + } + render(); + expect(observed).toMatchObject({ index: 0, total: 3, label: null }); + }); + + it("returns total null when the flow's transitions are bare (unwalkable)", () => { + const bare = defineJourney()({ + id: "bare", + version: "1.0.0", + initialState: () => ({ ok: true }), + start: () => ({ module: "profile", entry: "review", input: undefined }), + transitions: { + profile: { + review: { done: () => ({ next: { module: "plan", entry: "choose", input: undefined } }) }, + }, + }, + }); + const runtime = createJourneyRuntime([{ definition: bare, options: undefined }]); + const id = runtime.start(bare.id, undefined); + let observed: JourneyProgress | undefined; + function Probe() { + observed = useJourneyProgress(id, bare); + return null; + } + render( + + + , + ); + // Only the start step is statically knowable, so total is 1 — but the point + // is it never throws and stays finite. (A fully-unresolvable start would be + // null; here `start` yields one step.) + expect(observed?.total).toBe(1); + }); +}); diff --git a/packages/journeys/src/use-journey-progress.ts b/packages/journeys/src/use-journey-progress.ts new file mode 100644 index 00000000..86caa920 --- /dev/null +++ b/packages/journeys/src/use-journey-progress.ts @@ -0,0 +1,109 @@ +import { useMemo } from "react"; +import type { InstanceId, JourneyRuntime } from "@modular-frontend/journeys-engine"; +import { + resolveStepSequence, + type JourneyDefinition, + type ModuleTypeMap, + type ResolvedJourneyStep, + type ResolveStepSequenceOptions, +} from "@modular-frontend/journeys-engine"; +import { useInstanceSnapshot } from "./instance-hooks.js"; +import { useJourneyContext } from "./provider.js"; + +export interface UseJourneyProgressOptions { + /** + * Runtime the `instanceId` belongs to. Defaults to the one from a + * surrounding ``. Pass explicitly to read progress for an + * instance on a runtime other than the ambient one. + */ + readonly runtime?: JourneyRuntime; + /** + * Forwarded to `resolveStepSequence` — most importantly `branch`, to + * linearize a forking flow, and `input`, when the start step depends on it. + */ + readonly sequence?: ResolveStepSequenceOptions; +} + +export interface JourneyProgress { + /** + * 0-based position in the flow — `history.length`, so `0` on the first step. + * Matches `useJourneyHost`'s `stepIndex`. Render "Step {index + 1} of {total}". + */ + readonly index: number; + /** + * Total number of steps in the resolved sequence, or `null` when it can't be + * derived — no instance yet, or the flow's transitions aren't annotated with + * `defineTransition` so the graph can't be walked (see `resolveStepSequence`). + */ + readonly total: number | null; + /** + * `progressLabel` of the step the instance is currently on, if the matching + * resolved step declared one. `null` otherwise. + */ + readonly label: string | null; + /** The full resolved sequence, so callers can render breadcrumbs / a stepper. */ + readonly steps: readonly ResolvedJourneyStep[]; +} + +/** + * Progress for a running journey instance — the `{ index, total }` pair item 4 + * of the production-feedback tracker asked for, plus the current step's label + * and the full resolved sequence. + * + * `index` comes from the live instance (`history.length`, so it rewinds when + * the journey does); `total` and `label` come from `resolveStepSequence`, which + * walks the definition's transition graph. Because the total is derived from + * the one place the flow is encoded, there is no second ordered-step array to + * keep in sync — the duplication item 4 flagged. + * + * The sequence is memoized on `definition` + `options.sequence`; for a forking + * flow, pass `options.sequence.branch` so the total reflects the chosen path. + * + * @example + * ```tsx + * function CheckoutRoute() { + * const { instanceId } = useJourneyHost(checkoutHandle, { cartId }); + * const { index, total, label } = useJourneyProgress(instanceId, checkoutDef); + * return ( + * <> + * {total != null && } + * {instanceId && } + * + * ); + * } + * ``` + */ +export function useJourneyProgress< + TModules extends ModuleTypeMap, + TState, + TInput, + TOutput, + TMeta extends { [K in keyof TMeta]: unknown }, +>( + instanceId: InstanceId | null, + definition: JourneyDefinition, + options: UseJourneyProgressOptions = {}, +): JourneyProgress { + const context = useJourneyContext(); + const runtime = options.runtime ?? context?.runtime ?? null; + + const instance = useInstanceSnapshot(runtime, instanceId); + + const sequenceOptions = options.sequence; + const steps = useMemo( + () => resolveStepSequence(definition, sequenceOptions), + [definition, sequenceOptions], + ); + + const index = instance ? instance.history.length : 0; + const total = steps.length > 0 ? steps.length : null; + + const current = instance?.step; + const label = + current != null + ? (steps.find((s) => s.module === current.moduleId && s.entry === current.entry) + ?.progressLabel ?? null) + : null; + + return { index, total, label, steps }; +} diff --git a/packages/react-router-core/src/define-module.ts b/packages/react-router-core/src/define-module.ts index 8a7eb982..6c6fc26f 100644 --- a/packages/react-router-core/src/define-module.ts +++ b/packages/react-router-core/src/define-module.ts @@ -5,10 +5,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * Identity function that provides type inference for React Router module descriptors. * Zero runtime overhead — returns its argument unchanged. * - * See {@link NavigationItem} for the three generics that let you tighten - * navigation typing: typed i18n labels, typed dynamic-href context, and a - * typed `meta` bag for app-specific fields (permission actions, badges, - * analytics ids, etc.). + * See `NavigationItem` for the three generics that let you tighten navigation + * typing: typed i18n labels, typed dynamic-href context, and a typed `meta` + * bag for app-specific fields (permission actions, badges, analytics ids, etc.). * * ```ts * interface JourneyMeta { name: string; category: string } @@ -16,14 +15,32 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * * export default defineModule({ ... }) * ``` + * + * Two inference guarantees matter for journeys built on `typeof someModule`: + * + * 1. **Literal shape is preserved.** The trailing `TDescriptor` generic is + * inferred from the argument and returned verbatim, so `entryPoints` / + * `exitPoints` keep their *literal* keys instead of widening to + * `EntryPointMap` / `ExitPointMap`. A journey's `TransitionMap<{ m: typeof + * someModule }, …>` then resolves the module's real entry/exit vocabulary — + * no casts, no re-declaring the entry names by hand. + * 2. **Function-form `to` works without spelling `TNavItem`.** `TNavItem` is + * inferred from the `navigation` array (the `descriptor & { navigation?: + * readonly TNavItem[] }` parameter shape), defaulting to `NavigationItem` + * only when there is no navigation. That inference admits a module that + * resolves its href at render time (`to: (ctx) => …`) with zero generics — + * the old fixed `NavigationItem` default narrowed `to` to a plain `string` + * and rejected the resolver form — while keeping the inferred item narrow so + * the result stays assignable where a `NavigationItem`-typed registry + * expects it. */ export function defineModule< TSharedDependencies extends Record = Record, TSlots extends SlotMapOf = SlotMap, TMeta extends { [K in keyof TMeta]: unknown } = Record, TNavItem extends NavigationItemBase = NavigationItem, ->( - descriptor: ModuleDescriptor, -): ModuleDescriptor { + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { return descriptor; } diff --git a/packages/tanstack-router-core/src/define-module.test-d.ts b/packages/tanstack-router-core/src/define-module.test-d.ts new file mode 100644 index 00000000..59bfb7fe --- /dev/null +++ b/packages/tanstack-router-core/src/define-module.test-d.ts @@ -0,0 +1,120 @@ +// Type-level acceptance tests for `defineModule` (TanStack Router flavor). +// +// These lock the two guarantees the production-feedback tracker's item 3 asked +// for — the reasons a real app abandoned `defineModule` in favor of a hand- +// written `as const` object literal: +// +// 1. `typeof someModule` (built via `defineModule`) works as a `TModules` +// member in a journey `TransitionMap` with ZERO casts — i.e. the literal +// `entryPoints` / `exitPoints` shapes survive instead of widening to +// `EntryPointMap` / `ExitPointMap` (which would collapse `EntryNamesOf` to +// `string` and break per-entry / per-exit narrowing). +// 2. Function-form `to` (`to: (ctx) => "/x/" + ctx.id`) type-checks WITHOUT +// having to spell the `TNavItem` generic — the default admits it. +// +// Runs through vitest's typecheck pass (see vitest.config.ts). + +import { expectTypeOf, test } from "vitest"; +import { defineEntry, defineExit, schema } from "@modular-react/core"; +import type { EntryNamesOf, ExitNamesOf, StepSpec, TransitionMap } from "@modular-react/core"; +import { defineModule } from "./define-module.js"; + +interface CheckoutState { + readonly tier: string | null; +} + +// A module defined with ZERO explicit generics. It uses function-form `to` +// (previously required spelling `TNavItem`) and declares literal entry / exit +// vocabularies a journey will reference by `typeof`. +const plan = defineModule({ + id: "plan", + version: "1.0.0", + navigation: [ + // Function-form `to` — resolves an href from render-time context. Under the + // old `TNavItem = NavigationItem` default this was a compile error (`to` + // narrowed to `string`); the `NavigationItemBase` default admits it. + { label: "Plan", to: (ctx: { workspaceId: string }) => `/plan/${ctx.workspaceId}` }, + ], + exitPoints: { + chosen: defineExit<{ readonly tier: string }>(), + cancelled: defineExit(), + }, + entryPoints: { + choose: defineEntry({ + component: (() => null) as never, + input: schema<{ readonly recommended: string }>(), + }), + compare: defineEntry({ + component: (() => null) as never, + input: schema<{ readonly ids: readonly string[] }>(), + }), + }, +}); + +type PlanModules = { readonly plan: typeof plan }; + +// ----------------------------------------------------------------------------- +// Guarantee 1 — literal shape preserved: entry/exit names are the real unions, +// not `string`. +// ----------------------------------------------------------------------------- + +test("defineModule preserves literal entry names (not widened to string)", () => { + expectTypeOf>().toEqualTypeOf<"choose" | "compare">(); +}); + +test("defineModule preserves literal exit names (not widened to string)", () => { + expectTypeOf>().toEqualTypeOf<"chosen" | "cancelled">(); +}); + +// ----------------------------------------------------------------------------- +// Guarantee 1 (cont.) — `typeof plan` drops into a journey `TransitionMap` with +// zero casts, and the exit handler's `output` / `state` are narrowed correctly. +// ----------------------------------------------------------------------------- + +test("typeof module is usable as a TModules member in a TransitionMap (no casts)", () => { + const transitions: TransitionMap = { + plan: { + choose: { + chosen: ({ output, state }) => { + // `output` is narrowed to the `chosen` exit's payload — no cast. + expectTypeOf(output).toEqualTypeOf<{ readonly tier: string }>(); + return { complete: undefined, state: { ...state, tier: output.tier } }; + }, + cancelled: () => ({ abort: { reason: "cancelled" } }), + }, + }, + }; + void transitions; +}); + +test("StepSpec over the module narrows `input` per entry (no casts)", () => { + const step: StepSpec = { + module: "plan", + entry: "compare", + input: { ids: ["a", "b"] }, + }; + void step; + + const bad: StepSpec = { + module: "plan", + entry: "compare", + // @ts-expect-error — `compare` input is `{ ids: readonly string[] }`, not `{ recommended }`. + input: { recommended: "x" }, + }; + void bad; +}); + +// ----------------------------------------------------------------------------- +// Guarantee 2 — an unknown navigation target inside a typed transition map is +// still a compile error: literal narrowing did not degrade into `any`. +// ----------------------------------------------------------------------------- + +test("an undeclared entry key in the transition map is a compile error", () => { + const transitions: TransitionMap = { + plan: { + // @ts-expect-error — `plan` declares `choose` / `compare`, not `nope`. + nope: {}, + }, + }; + void transitions; +}); diff --git a/packages/tanstack-router-core/src/define-module.ts b/packages/tanstack-router-core/src/define-module.ts index 58c2d2ee..eb35a27c 100644 --- a/packages/tanstack-router-core/src/define-module.ts +++ b/packages/tanstack-router-core/src/define-module.ts @@ -5,10 +5,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * Identity function that provides type inference for TanStack Router module descriptors. * Zero runtime overhead — returns its argument unchanged. * - * See {@link NavigationItem} for the three generics that let you tighten - * navigation typing: typed i18n labels, typed dynamic-href context, and a - * typed `meta` bag for app-specific fields (permission actions, badges, - * analytics ids, etc.). + * See `NavigationItem` for the three generics that let you tighten navigation + * typing: typed i18n labels, typed dynamic-href context, and a typed `meta` + * bag for app-specific fields (permission actions, badges, analytics ids, etc.). * * ```ts * interface JourneyMeta { name: string; category: string } @@ -16,14 +15,32 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * * export default defineModule({ ... }) * ``` + * + * Two inference guarantees matter for journeys built on `typeof someModule`: + * + * 1. **Literal shape is preserved.** The trailing `TDescriptor` generic is + * inferred from the argument and returned verbatim, so `entryPoints` / + * `exitPoints` keep their *literal* keys instead of widening to + * `EntryPointMap` / `ExitPointMap`. A journey's `TransitionMap<{ m: typeof + * someModule }, …>` then resolves the module's real entry/exit vocabulary — + * no casts, no re-declaring the entry names by hand. + * 2. **Function-form `to` works without spelling `TNavItem`.** `TNavItem` is + * inferred from the `navigation` array (the `descriptor & { navigation?: + * readonly TNavItem[] }` parameter shape), defaulting to `NavigationItem` + * only when there is no navigation. That inference admits a module that + * resolves its href at render time (`to: (ctx) => …`) with zero generics — + * the old fixed `NavigationItem` default narrowed `to` to a plain `string` + * and rejected the resolver form — while keeping the inferred item narrow so + * the result stays assignable where a `NavigationItem`-typed registry + * expects it. */ export function defineModule< TSharedDependencies extends Record = Record, TSlots extends SlotMapOf = SlotMap, TMeta extends { [K in keyof TMeta]: unknown } = Record, TNavItem extends NavigationItemBase = NavigationItem, ->( - descriptor: ModuleDescriptor, -): ModuleDescriptor { + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { return descriptor; } diff --git a/packages/tanstack-router-core/vitest.config.ts b/packages/tanstack-router-core/vitest.config.ts new file mode 100644 index 00000000..c951b3f4 --- /dev/null +++ b/packages/tanstack-router-core/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Pick up type-level assertions (`expectTypeOf(...)`, `assertType(...)`) + // from `*.test-d.ts` files. Runtime behavior tests continue to live in + // `*.test.ts`. Both are run by `pnpm test`. + typecheck: { + enabled: true, + include: ["src/**/*.test-d.ts"], + tsconfig: "./tsconfig.json", + }, + }, +}); diff --git a/packages/vue-core/src/define-module.test-d.ts b/packages/vue-core/src/define-module.test-d.ts index d6655b98..9f3f5a03 100644 --- a/packages/vue-core/src/define-module.test-d.ts +++ b/packages/vue-core/src/define-module.test-d.ts @@ -9,10 +9,14 @@ describe("defineModule typing", () => { const mod = defineModule({ id: "billing", version: "1.0.0", - createRoutes: () => [{ path: "/billing", component: {} }], + createRoutes: (): RouteRecordRaw[] => [{ path: "/billing", component: {} }], }); - expectTypeOf(mod.createRoutes).toEqualTypeOf< + // `defineModule` preserves the descriptor's *literal* shape (so journeys can + // read entry/exit vocabulary off `typeof mod`); `createRoutes` therefore + // keeps its authored signature rather than widening to the base union, but + // must remain assignable to the vue-router-narrowed base signature. + expectTypeOf(mod.createRoutes).toExtend< (() => RouteRecordRaw | RouteRecordRaw[]) | undefined >(); }); @@ -40,7 +44,13 @@ describe("defineModule typing", () => { version: "1.0.0", }); - expectTypeOf(mod).toEqualTypeOf>(); + // Returns the inferred literal rather than the widened descriptor (that is + // what lets a journey read a module's literal entry/exit vocabulary off + // `typeof mod`); the explicit `` generics still constrain + // the argument, so the result stays assignable to the descriptor over the + // same deps/slots. + const asBase: ModuleDescriptor = mod; + void asBase; }); it("passes typed i18n-label keys through navigation items", () => { diff --git a/packages/vue-core/src/define-module.ts b/packages/vue-core/src/define-module.ts index 272f86ce..59afe6b9 100644 --- a/packages/vue-core/src/define-module.ts +++ b/packages/vue-core/src/define-module.ts @@ -5,10 +5,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * Identity function that provides type inference for vue-router module descriptors. * Zero runtime overhead — returns its argument unchanged. * - * See {@link NavigationItem} for the three generics that let you tighten - * navigation typing: typed i18n labels, typed dynamic-href context, and a - * typed `meta` bag for app-specific fields (permission actions, badges, - * analytics ids, etc.). + * See `NavigationItem` for the three generics that let you tighten navigation + * typing: typed i18n labels, typed dynamic-href context, and a typed `meta` + * bag for app-specific fields (permission actions, badges, analytics ids, etc.). * * ```ts * interface JourneyMeta { name: string; category: string } @@ -16,14 +15,32 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * * export default defineModule({ ... }) * ``` + * + * Two inference guarantees matter for journeys built on `typeof someModule`: + * + * 1. **Literal shape is preserved.** The trailing `TDescriptor` generic is + * inferred from the argument and returned verbatim, so `entryPoints` / + * `exitPoints` keep their *literal* keys instead of widening to + * `EntryPointMap` / `ExitPointMap`. A journey's `TransitionMap<{ m: typeof + * someModule }, …>` then resolves the module's real entry/exit vocabulary — + * no casts, no re-declaring the entry names by hand. + * 2. **Function-form `to` works without spelling `TNavItem`.** `TNavItem` is + * inferred from the `navigation` array (the `descriptor & { navigation?: + * readonly TNavItem[] }` parameter shape), defaulting to `NavigationItem` + * only when there is no navigation. That inference admits a module that + * resolves its href at render time (`to: (ctx) => …`) with zero generics — + * the old fixed `NavigationItem` default narrowed `to` to a plain `string` + * and rejected the resolver form — while keeping the inferred item narrow so + * the result stays assignable where a `NavigationItem`-typed registry + * expects it. */ export function defineModule< TSharedDependencies extends Record = Record, TSlots extends SlotMapOf = SlotMap, TMeta extends { [K in keyof TMeta]: unknown } = Record, TNavItem extends NavigationItemBase = NavigationItem, ->( - descriptor: ModuleDescriptor, -): ModuleDescriptor { + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { return descriptor; } diff --git a/packages/vue-journeys/src/index.ts b/packages/vue-journeys/src/index.ts index 400c2999..e5e26f9c 100644 --- a/packages/vue-journeys/src/index.ts +++ b/packages/vue-journeys/src/index.ts @@ -45,6 +45,12 @@ export type { UseJourneyHostOptions, } from "./journey-host.js"; +// Vue-specific: progress for a running instance — `{ index, total }` derived +// from the transition graph (via `resolveStepSequence`), returned as +// `ComputedRef`s. +export { useJourneyProgress } from "./use-journey-progress.js"; +export type { JourneyProgress, UseJourneyProgressOptions } from "./use-journey-progress.js"; + // Vue-specific: journey <-> URL sync. The reconciler is framework- and // router-neutral (it lives in the engine); this composable is the Vue lifetime // wrapper, and the app supplies a `JourneySyncPort` for vue-router. @@ -127,6 +133,15 @@ export type { TerminalSentinel, } from "@modular-frontend/journeys-engine"; +// Derive an ordered step list from the transition graph — URL segments, +// "Step X of N". `useJourneyProgress` builds on this. +export { resolveStepSequence } from "@modular-frontend/journeys-engine"; +export type { + ResolvedJourneyStep, + ResolveStepSequenceOptions, + StepSequenceRef, +} from "@modular-frontend/journeys-engine"; + // Journey <-> location reconciler — the neutral core behind `useJourneySync`, // plus the in-memory port for tests and headless hosts. export { @@ -174,6 +189,8 @@ export type { JourneyStatus, JourneyStep, JourneyStepFor, + JourneyStepMeta, + JourneyStepMetaMap, JourneySystemAbortReason, JourneySystemAbortReasonCode, MaybePromise, diff --git a/packages/vue-journeys/src/use-journey-progress.test.ts b/packages/vue-journeys/src/use-journey-progress.test.ts new file mode 100644 index 00000000..b4161619 --- /dev/null +++ b/packages/vue-journeys/src/use-journey-progress.test.ts @@ -0,0 +1,145 @@ +import { defineComponent, h } from "vue"; +import { flushPromises, mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; +import { + createJourneyRuntime, + defineJourney, + defineTransition, +} from "@modular-frontend/journeys-engine"; +import { createTestHarness } from "@modular-frontend/journeys-engine/testing"; +import { JourneyProvider } from "./provider.js"; +import { useJourneyProgress, type JourneyProgress } from "./use-journey-progress.js"; + +const profile = defineModule({ + id: "profile", + version: "1.0.0", + exitPoints: { done: defineExit() }, + entryPoints: { + review: defineEntry({ component: (() => null) as never, input: schema() }), + }, +}); +const plan = defineModule({ + id: "plan", + version: "1.0.0", + exitPoints: { chosen: defineExit() }, + entryPoints: { + choose: defineEntry({ component: (() => null) as never, input: schema() }), + }, +}); +const billing = defineModule({ + id: "billing", + version: "1.0.0", + exitPoints: { paid: defineExit() }, + entryPoints: { + collect: defineEntry({ component: (() => null) as never, input: schema() }), + }, +}); + +type Modules = { + readonly profile: typeof profile; + readonly plan: typeof plan; + readonly billing: typeof billing; +}; +interface State { + readonly ok: boolean; +} + +const transition = defineTransition(); + +const checkout = defineJourney()({ + id: "checkout", + version: "1.0.0", + initialState: () => ({ ok: true }), + start: () => ({ module: "profile", entry: "review", input: undefined }), + steps: { + profile: { review: { progressLabel: "Welcome" } }, + plan: { choose: { progressLabel: "Pick a plan" } }, + billing: { collect: { progressLabel: "Payment" } }, + }, + transitions: { + profile: { + review: { + done: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ next: { module: "plan", entry: "choose", input: undefined } }), + }), + }, + }, + plan: { + choose: { + chosen: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: () => ({ next: { module: "billing", entry: "collect", input: undefined } }), + }), + }, + }, + billing: { + collect: { + paid: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, +}); + +function mountUnderProvider( + runtime: ReturnType, + capture: () => T, +): T { + let captured!: T; + const Probe = defineComponent({ + setup() { + captured = capture(); + return () => null; + }, + }); + mount(JourneyProvider, { + props: { runtime }, + slots: { default: () => h(Probe) }, + }); + return captured; +} + +describe("useJourneyProgress (vue)", () => { + it("reports index / total / label and tracks the journey as it advances", async () => { + const runtime = createJourneyRuntime([{ definition: checkout, options: undefined }]); + const id = runtime.start(checkout.id, undefined); + + const progress = mountUnderProvider(runtime, () => + useJourneyProgress(id, checkout), + ); + + expect(progress.index.value).toBe(0); + expect(progress.total.value).toBe(3); + expect(progress.label.value).toBe("Welcome"); + expect(progress.steps.value.map((s) => `${s.module}/${s.entry}`)).toEqual([ + "profile/review", + "plan/choose", + "billing/collect", + ]); + + createTestHarness(runtime).fireExit(id, "done"); + await flushPromises(); + expect(progress.index.value).toBe(1); + expect(progress.label.value).toBe("Pick a plan"); + + createTestHarness(runtime).fireExit(id, "chosen"); + await flushPromises(); + expect(progress.index.value).toBe(2); + expect(progress.label.value).toBe("Payment"); + }); + + it("derives total even before an instance exists", () => { + let observed!: JourneyProgress; + const Probe = defineComponent({ + setup() { + observed = useJourneyProgress(null, checkout); + return () => null; + }, + }); + mount(Probe); + expect(observed.index.value).toBe(0); + expect(observed.total.value).toBe(3); + expect(observed.label.value).toBeNull(); + }); +}); diff --git a/packages/vue-journeys/src/use-journey-progress.ts b/packages/vue-journeys/src/use-journey-progress.ts new file mode 100644 index 00000000..7da2230f --- /dev/null +++ b/packages/vue-journeys/src/use-journey-progress.ts @@ -0,0 +1,96 @@ +import { computed, toRaw, type ComputedRef, type MaybeRefOrGetter } from "vue"; +import type { InstanceId, JourneyRuntime } from "@modular-frontend/journeys-engine"; +import { + resolveStepSequence, + type JourneyDefinition, + type ModuleTypeMap, + type ResolvedJourneyStep, + type ResolveStepSequenceOptions, +} from "@modular-frontend/journeys-engine"; +import { useInstanceSnapshot } from "./instance-hooks.js"; +import { useJourneyContext } from "./provider.js"; + +export interface UseJourneyProgressOptions { + /** + * Runtime the `instanceId` belongs to. Defaults to the one provided by a + * surrounding ``. + */ + readonly runtime?: JourneyRuntime; + /** + * Forwarded to `resolveStepSequence` — chiefly `branch`, to linearize a + * forking flow, and `input`, when the start step depends on it. + */ + readonly sequence?: ResolveStepSequenceOptions; +} + +export interface JourneyProgress { + /** 0-based position (`history.length`), so `0` on the first step. */ + readonly index: ComputedRef; + /** Resolved sequence length, or `null` when it can't be derived. */ + readonly total: ComputedRef; + /** `progressLabel` of the current step, or `null`. */ + readonly label: ComputedRef; + /** The full resolved sequence — for breadcrumbs / a stepper. */ + readonly steps: ComputedRef; +} + +/** + * Vue analog of the React `useJourneyProgress` (production-feedback item 4): + * `{ index, total }` for a running instance, plus the current step's `label` + * and the full resolved sequence, all as `ComputedRef`s. + * + * `index` tracks the live instance (`history.length`); `total` / `label` come + * from `resolveStepSequence` walking the definition's transition graph, so the + * total is derived from the one place the flow is encoded — no second + * ordered-step array to keep in sync. + * + * `instanceId` accepts a plain value, a ref, or a getter (mirroring the other + * instance composables). `definition` and `options` are read once at setup. + * + * @example + * ```vue + * + * + * ``` + */ +export function useJourneyProgress< + TModules extends ModuleTypeMap, + TState, + TInput, + TOutput, + TMeta extends { [K in keyof TMeta]: unknown }, +>( + instanceId: MaybeRefOrGetter, + definition: JourneyDefinition, + options: UseJourneyProgressOptions = {}, +): JourneyProgress { + const ctx = useJourneyContext(); + // `toRaw` for the same reason the host/outlet do it — a runtime that arrived + // through a reactive prop is a proxy, and the runtime keys on raw identity. + const runtime = toRaw(options.runtime ?? ctx?.runtime ?? undefined) ?? null; + + const instance = useInstanceSnapshot(runtime, instanceId); + + // `definition` / `options` are plain values (not reactive), so the sequence + // is resolved once at setup — the same read the React hook memoizes. + const steps = resolveStepSequence(definition, options.sequence); + + const index = computed(() => (instance.value ? instance.value.history.length : 0)); + const total = computed(() => (steps.length > 0 ? steps.length : null)); + const label = computed(() => { + const current = instance.value?.step; + if (current == null) return null; + return ( + steps.find((s) => s.module === current.moduleId && s.entry === current.entry) + ?.progressLabel ?? null + ); + }); + const stepsRef = computed(() => steps); + + return { index, total, label, steps: stepsRef }; +} From f2dff83056327dd876e492407451e58b3f8ca84b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:29:40 +0000 Subject: [PATCH 2/9] fix(journeys): address review findings on step-sequence & progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the item 3/4 PR, addressing code-review findings: - resolveStepSequence: match the `branch` resolver's return against the fork's declared `targets` by module+entry, so a ref that isn't a real target (or `undefined`) stops the walk instead of being followed blindly. - resolveStepSequence docs: note that `wildcard` transitions are not walked, and that the start step is derived by invoking `initialState`/`start` (which must be safe to call with the provided input). - useJourneyProgress (React + Vue): correct the `total` JSDoc — it is the best-effort statically-resolved spine length (partial on forks/unannotated/ maxSteps), always >= 1 for a derivable start, `null` only for an empty sequence — not the previously-documented "null when no instance / unannotated". Document that `index` (live) can reach/exceed `total` (static), and that `options.sequence` should be referentially stable. - define-module type-test comments: the function-form `to` fix infers `TNavItem` from the `navigation` array; it does not change the default to `NavigationItemBase`. Corrected the misleading comments. - Add a test for the branch-resolver-returns-foreign-ref case. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TqsgpXv75wiufKKkjTNLXz --- .../frontend-core/src/define-module.test-d.ts | 16 ++++++----- .../src/resolve-step-sequence.test.ts | 9 ++++++ .../src/resolve-step-sequence.ts | 28 +++++++++++++++---- packages/journeys/src/use-journey-progress.ts | 21 ++++++++++++-- .../src/define-module.test-d.ts | 6 ++-- .../vue-journeys/src/use-journey-progress.ts | 15 ++++++++-- 6 files changed, 76 insertions(+), 19 deletions(-) diff --git a/packages/frontend-core/src/define-module.test-d.ts b/packages/frontend-core/src/define-module.test-d.ts index 106f7bfd..f6933e44 100644 --- a/packages/frontend-core/src/define-module.test-d.ts +++ b/packages/frontend-core/src/define-module.test-d.ts @@ -1,12 +1,14 @@ // Type-level acceptance tests for `defineModule`'s navigation defaults. // -// Production-feedback item 3 (b): `defineModule`'s default nav-item generic -// used to be `NavigationItem`, which narrows `to` to a plain `string`. A module -// that resolves its href from render-time context (`to: (ctx) => …`) then only -// compiled if the author spelled the fourth generic — so real apps abandoned -// the helper. The default is now the structural `NavigationItemBase` bound, -// which admits function-form `to` with zero generics while still accepting the -// plain-string form and any explicitly-narrowed `TNavItem`. +// Production-feedback item 3 (b): `defineModule`'s nav-item generic `TNavItem` +// defaulted to `NavigationItem`, which narrows `to` to a plain `string`. A +// module that resolves its href from render-time context (`to: (ctx) => …`) +// then only compiled if the author spelled the fourth generic — so real apps +// abandoned the helper. `TNavItem` is now *inferred from the `navigation` array* +// (the parameter is typed `TDescriptor & { navigation?: readonly TNavItem[] }`), +// falling back to `NavigationItem` only when there is no navigation. That +// inference admits function-form `to` with zero generics while still accepting +// the plain-string form and any explicitly-narrowed `TNavItem`. // // Runs through vitest's typecheck pass (see vitest.config.ts). diff --git a/packages/journeys-engine/src/resolve-step-sequence.test.ts b/packages/journeys-engine/src/resolve-step-sequence.test.ts index e46893fb..0e932d3d 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.test.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.test.ts @@ -181,6 +181,15 @@ describe("resolveStepSequence — branching flow", () => { const seq = resolveStepSequence(branching, { branch: () => undefined }); expect(seq.map((s) => `${s.module}/${s.entry}`)).toEqual(["plan/choose"]); }); + + it("stops the sequence when the resolver returns a ref that isn't a declared target", () => { + const seq = resolveStepSequence(branching, { + branch: () => ({ module: "billing", entry: "not-a-target" }), + }); + // The returned ref is matched back against the fork's `targets` by + // module + entry; no match means the walk stops rather than following it. + expect(seq.map((s) => `${s.module}/${s.entry}`)).toEqual(["plan/choose"]); + }); }); // --- Edge cases -------------------------------------------------------------- diff --git a/packages/journeys-engine/src/resolve-step-sequence.ts b/packages/journeys-engine/src/resolve-step-sequence.ts index 2b07412f..b3dbd44a 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.ts @@ -74,7 +74,14 @@ const DEFAULT_MAX_STEPS = 256; * (stamped by `defineTransition`). A step whose transitions are all *bare* * function handlers has no statically-known forward target, so the sequence * stops there. Terminal sentinels (`"complete"` / `"abort"` / `"invoke"`) - * carry no next step and are skipped. + * carry no next step and are skipped. Only the per-step `transitions` map is + * walked — `wildcard` fall-through handlers are not followed, so a step whose + * only forward movement is a wildcard also ends the sequence. + * + * Unless `options.start` is supplied, the first step is computed by invoking + * `definition.initialState(options.input)` then `definition.start(...)`; these + * author-supplied factories must be safe to call with the provided `input` + * (pass `options.start` to skip them entirely). * * Each returned step carries any `path` / `progressLabel` declared under * `definition.steps[module][entry]`. @@ -120,10 +127,21 @@ export function resolveStepSequence< const targets = forwardTargets(definition, current.module, current.entry); if (targets.length === 0) break; - current = - targets.length === 1 - ? targets[0] - : options.branch?.({ module: current.module, entry: current.entry, targets }); + if (targets.length === 1) { + current = targets[0]; + } else { + // Fork — the resolver picks. Its return is matched back against `targets` + // by `module` + `entry` (identity not required), so a `undefined` return + // or a ref that isn't one of the declared targets stops the sequence here. + const picked: StepSequenceRef | undefined = options.branch?.({ + module: current.module, + entry: current.entry, + targets, + }); + current = picked + ? targets.find((t) => t.module === picked.module && t.entry === picked.entry) + : undefined; + } } return sequence; diff --git a/packages/journeys/src/use-journey-progress.ts b/packages/journeys/src/use-journey-progress.ts index 86caa920..063b1a35 100644 --- a/packages/journeys/src/use-journey-progress.ts +++ b/packages/journeys/src/use-journey-progress.ts @@ -20,6 +20,10 @@ export interface UseJourneyProgressOptions { /** * Forwarded to `resolveStepSequence` — most importantly `branch`, to * linearize a forking flow, and `input`, when the start step depends on it. + * + * The sequence is memoized on this object's identity, so pass a stable + * reference (e.g. a module-level constant or a `useMemo`) rather than a fresh + * literal each render if you want to avoid re-walking the graph per render. */ readonly sequence?: ResolveStepSequenceOptions; } @@ -28,12 +32,23 @@ export interface JourneyProgress { /** * 0-based position in the flow — `history.length`, so `0` on the first step. * Matches `useJourneyHost`'s `stepIndex`. Render "Step {index + 1} of {total}". + * + * Note `index` tracks the *live* instance while `total` comes from the + * *statically-resolved* spine, so when the runtime path diverges from the + * resolved one (a fork walked with a different `branch`, or steps past an + * unannotated transition) `index` can reach or exceed `total`. Clamp at the + * call site if you render a bounded stepper. */ readonly index: number; /** - * Total number of steps in the resolved sequence, or `null` when it can't be - * derived — no instance yet, or the flow's transitions aren't annotated with - * `defineTransition` so the graph can't be walked (see `resolveStepSequence`). + * Total number of steps in the resolved sequence — the "N" in "Step X of N". + * + * Best-effort: it counts the statically-walkable spine `resolveStepSequence` + * returns from the start step, which is a *partial* total when the flow forks + * without a `branch` resolver, stops at an unannotated (bare-function) + * transition, or is cut by `maxSteps`. It does not depend on a live instance — + * a definition with a derivable start always yields at least `1`. `null` only + * when no step at all can be resolved (an empty sequence). */ readonly total: number | null; /** diff --git a/packages/tanstack-router-core/src/define-module.test-d.ts b/packages/tanstack-router-core/src/define-module.test-d.ts index 59bfb7fe..3ea40e7a 100644 --- a/packages/tanstack-router-core/src/define-module.test-d.ts +++ b/packages/tanstack-router-core/src/define-module.test-d.ts @@ -10,7 +10,8 @@ // `EntryPointMap` / `ExitPointMap` (which would collapse `EntryNamesOf` to // `string` and break per-entry / per-exit narrowing). // 2. Function-form `to` (`to: (ctx) => "/x/" + ctx.id`) type-checks WITHOUT -// having to spell the `TNavItem` generic — the default admits it. +// having to spell the `TNavItem` generic — `TNavItem` is inferred from the +// `navigation` array rather than defaulting to the string-`to` shape. // // Runs through vitest's typecheck pass (see vitest.config.ts). @@ -32,7 +33,8 @@ const plan = defineModule({ navigation: [ // Function-form `to` — resolves an href from render-time context. Under the // old `TNavItem = NavigationItem` default this was a compile error (`to` - // narrowed to `string`); the `NavigationItemBase` default admits it. + // narrowed to `string`); inferring `TNavItem` from this `navigation` array + // admits it. { label: "Plan", to: (ctx: { workspaceId: string }) => `/plan/${ctx.workspaceId}` }, ], exitPoints: { diff --git a/packages/vue-journeys/src/use-journey-progress.ts b/packages/vue-journeys/src/use-journey-progress.ts index 7da2230f..a5f4d80a 100644 --- a/packages/vue-journeys/src/use-journey-progress.ts +++ b/packages/vue-journeys/src/use-journey-progress.ts @@ -24,9 +24,20 @@ export interface UseJourneyProgressOptions { } export interface JourneyProgress { - /** 0-based position (`history.length`), so `0` on the first step. */ + /** + * 0-based position (`history.length`), so `0` on the first step. Tracks the + * live instance, whereas `total` is the statically-resolved spine, so `index` + * can reach or exceed `total` when the runtime path diverges (a fork walked + * with a different `branch`, or steps past an unannotated transition). + */ readonly index: ComputedRef; - /** Resolved sequence length, or `null` when it can't be derived. */ + /** + * Resolved sequence length — best-effort. Counts the statically-walkable + * spine, so it is a *partial* total when the flow forks without a `branch` + * resolver, stops at an unannotated transition, or is cut by `maxSteps`. A + * definition with a derivable start always yields at least `1`; `null` only + * when no step at all can be resolved. + */ readonly total: ComputedRef; /** `progressLabel` of the current step, or `null`. */ readonly label: ComputedRef; From d62375a708391c0f11155235892aa06de17497fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:02:35 +0000 Subject: [PATCH 3/9] fix(journeys-engine): require input or start for non-void journeys in resolveStepSequence Address CodeRabbit review on PR #83. `resolveStepSequence(definition)` previously type-checked for journeys whose `initialState` needs an input, then called `initialState(undefined)` when `input` was omitted. Model the options so a non-void `TInput` requires either `input` (handed to the factories) or `start` (naming the first step, skipping them), while void-input journeys keep fully-optional options. The trailing options argument becomes required for non-void input via `StepSequenceOptionsArg`, so `resolveStepSequence(def)` is now a compile error precisely when it would have called `initialState(undefined)`. Propagate the same input-awareness through `useJourneyProgress` (React and Vue): `UseJourneyProgressOptions.sequence` is required (with `input`/`start`) for non-void journeys, keeping the forwarding to `resolveStepSequence` sound. Add type-level regression tests proving the bare non-void call, empty options, walk-only options, and wrong-typed `input` are all rejected, while void-input journeys and `input`/`start` forms are accepted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UTynWaEhJz9FYF95seoPvH --- CHANGELOG.md | 4 +- packages/journeys-engine/src/index.ts | 2 + .../src/resolve-step-sequence.test-d.ts | 51 ++++++++++++++ .../src/resolve-step-sequence.ts | 70 +++++++++++++++---- packages/journeys/src/use-journey-progress.ts | 59 ++++++++++++---- .../vue-journeys/src/use-journey-progress.ts | 47 ++++++++++--- 6 files changed, 194 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d16d73..65db1fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,9 @@ Closes item 3 of `docs/consumer-feedback-production-app.md`: the production cons Closes item 4 of `docs/consumer-feedback-production-app.md`: the consumer encoded each journey's flow twice — once as the transition-map graph, and again as a ~170-line hand-maintained file of ordered step arrays (in three branch-variant copies) for URL segments and "Step X of N", kept in sync only by discipline. The ordering and progress primitives now live in the library, derived from the one place the flow is already encoded. -- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires annotated (`defineTransition`) handlers**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceRef`. +- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. Seeding is **required** — not optional — for a journey whose `initialState` consumes a non-void input: the options type demands `input` or `start` there, so `resolveStepSequence(definition)` is a compile error instead of silently calling `initialState(undefined)` (void-input journeys still take no options). It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires annotated (`defineTransition`) handlers**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceWalkOptions`, `StepSequenceOptionsArg`, `StepSequenceRef`. - **`@modular-frontend/core`** — `JourneyDefinition.steps?: JourneyStepMetaMap`. Per-step presentation metadata keyed by `[moduleId][entry]` exactly like `transitions` (entry keys filtered to journey-mountable entries, so a typo is a compile error), each leaf a `JourneyStepMeta` (`{ path?, progressLabel? }`). `path` overrides the URL sync's default `"moduleId/entry"` segment; `progressLabel` feeds progress UIs. One source of truth beside the transitions, not re-encoded at each `next:`. New public types: `JourneyStepMeta`, `JourneyStepMetaMap`. -- **`@modular-react/journeys`**, **`@modular-vue/journeys`** — `useJourneyProgress(instanceId, definition, options?)`. Returns `{ index, total, label, steps }`: `index` from the live instance (`history.length`, so it rewinds when the journey does), `total` / `label` / `steps` from `resolveStepSequence`. This is the `stepCount` that the journey-hosting work (item 2) deferred with "deriving it from the graph is tracked separately" — now derivable because the total comes from the graph rather than a hand-passed number. `total` is `null` when the sequence can't be walked (unannotated transitions). The React hook returns a plain object; the Vue composable returns `ComputedRef`s. `options.sequence` forwards to `resolveStepSequence` (chiefly `branch`, to make the total reflect a chosen path). `resolveStepSequence` and its types are re-exported from both bindings. +- **`@modular-react/journeys`**, **`@modular-vue/journeys`** — `useJourneyProgress(instanceId, definition, options?)`. Returns `{ index, total, label, steps }`: `index` from the live instance (`history.length`, so it rewinds when the journey does), `total` / `label` / `steps` from `resolveStepSequence`. This is the `stepCount` that the journey-hosting work (item 2) deferred with "deriving it from the graph is tracked separately" — now derivable because the total comes from the graph rather than a hand-passed number. `total` is `null` when the sequence can't be walked (unannotated transitions). The React hook returns a plain object; the Vue composable returns `ComputedRef`s. `options.sequence` forwards to `resolveStepSequence` (chiefly `branch`, to make the total reflect a chosen path) and, mirroring it, is optional for void-input journeys but required (carrying `input` / `start`) when the definition needs a non-void input. `resolveStepSequence` and its types are re-exported from both bindings. ### Added — journey runtime additions (EXP-1848 adoption follow-up) diff --git a/packages/journeys-engine/src/index.ts b/packages/journeys-engine/src/index.ts index dab01d8e..32cda23b 100644 --- a/packages/journeys-engine/src/index.ts +++ b/packages/journeys-engine/src/index.ts @@ -69,7 +69,9 @@ export { resolveStepSequence } from "./resolve-step-sequence.js"; export type { ResolvedJourneyStep, ResolveStepSequenceOptions, + StepSequenceOptionsArg, StepSequenceRef, + StepSequenceWalkOptions, } from "./resolve-step-sequence.js"; // Handles — open a journey with typed `input` without importing its runtime. diff --git a/packages/journeys-engine/src/resolve-step-sequence.test-d.ts b/packages/journeys-engine/src/resolve-step-sequence.test-d.ts index e1812525..29706826 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.test-d.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.test-d.ts @@ -6,6 +6,7 @@ import { test } from "vitest"; import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; +import { resolveStepSequence } from "./resolve-step-sequence.js"; const plan = defineModule({ id: "plan", @@ -75,3 +76,53 @@ test("`steps` rejects an unknown key on JourneyStepMeta", () => { transitions: {}, }); }); + +// --- `resolveStepSequence` options: input required for non-void journeys ----- +// A journey whose `initialState` consumes a non-void input must not be walked +// without supplying that input (or an explicit `start`) — otherwise the walk +// calls `initialState(undefined)`. The type must reject the bare call. + +// `initialState` takes no parameter → `TInput` is `void`. +const voidInput = defineJourney()({ + id: "j", + version: "1.0.0", + initialState: () => ({ done: false }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + transitions: {}, +}); + +// `initialState` consumes an input → `TInput` is `{ token: string }`. +const nonVoidInput = defineJourney()({ + id: "j", + version: "1.0.0", + initialState: (input: { readonly token: string }) => ({ done: input.token === "" }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + transitions: {}, +}); + +test("resolveStepSequence: void-input journey needs no options", () => { + resolveStepSequence(voidInput); + resolveStepSequence(voidInput, {}); + resolveStepSequence(voidInput, { maxSteps: 2 }); +}); + +test("resolveStepSequence: non-void-input journey rejects a missing/empty start", () => { + // @ts-expect-error — non-void input: `input` or `start` is required. + resolveStepSequence(nonVoidInput); + // @ts-expect-error — empty options still omit `input`/`start`. + resolveStepSequence(nonVoidInput, {}); + // @ts-expect-error — walk-only options don't satisfy the input requirement. + resolveStepSequence(nonVoidInput, { maxSteps: 2 }); +}); + +test("resolveStepSequence: non-void-input journey accepts `input` or `start`", () => { + resolveStepSequence(nonVoidInput, { input: { token: "t" } }); + resolveStepSequence(nonVoidInput, { start: { module: "plan", entry: "choose" } }); + // `start` skips the factories, so it may stand alone alongside walk options. + resolveStepSequence(nonVoidInput, { start: { module: "plan", entry: "choose" }, maxSteps: 2 }); +}); + +test("resolveStepSequence: non-void-input journey rejects a wrong-typed `input`", () => { + // @ts-expect-error — `input` must match the journey's `TInput`. + resolveStepSequence(nonVoidInput, { input: { token: 1 } }); +}); diff --git a/packages/journeys-engine/src/resolve-step-sequence.ts b/packages/journeys-engine/src/resolve-step-sequence.ts index b3dbd44a..bdec7478 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.ts @@ -22,17 +22,19 @@ export interface StepSequenceRef { readonly entry: string; } -export interface ResolveStepSequenceOptions { - /** - * Input handed to `definition.initialState` / `definition.start` to compute - * the first step. Omit for void-input journeys. Ignored when {@link start} - * is supplied. - */ - readonly input?: TInput; +/** + * Walk-tuning options that never depend on the journey's input type — always + * optional. The input-carrying `input` field lives on + * {@link ResolveStepSequenceOptions}, which requires it (or `start`) for a + * non-void-input journey. + */ +export interface StepSequenceWalkOptions { /** * Explicit first step. Skips calling `initialState` / `start` — useful when * the start step is dynamic on input in a way that does not affect the - * sequence, or when resolving a sub-sequence from a mid-flow step. + * sequence, or when resolving a sub-sequence from a mid-flow step. Supplying + * this satisfies the start requirement for a non-void-input journey, since + * the input-consuming factories are never called. */ readonly start?: StepSequenceRef; /** @@ -56,6 +58,44 @@ export interface ResolveStepSequenceOptions { readonly maxSteps?: number; } +/** + * Options for {@link resolveStepSequence}. + * + * For a **void-input** journey every field is optional — `resolveStepSequence(def)` + * is valid. For a journey whose `initialState` / `start` need a **non-void** + * input, the options must supply the start of the walk explicitly: either + * `input` (handed to the factories to compute the first step, ignored when + * `start` is present) or `start` (naming the first step and skipping the + * factories). This makes `resolveStepSequence(def)` a compile error exactly + * when omitting `input` would otherwise call `initialState(undefined)`. + */ +export type ResolveStepSequenceOptions = StepSequenceWalkOptions & + ([TInput] extends [void] + ? { + /** Input handed to the factories. Optional for void-input journeys. */ + readonly input?: TInput; + } + : + | { + /** Input handed to `initialState` / `start` to compute the first step. */ + readonly input: TInput; + } + | { + /** Explicit first step — skips (and so does not need) `input`. */ + readonly start: StepSequenceRef; + }); + +/** + * Trailing options argument for {@link resolveStepSequence}: optional for a + * void-input journey, required (carrying `input` or `start`) when the journey + * needs a non-void input — so omitting it is a compile error precisely when it + * would call `initialState(undefined)`. Consumers that forward options to + * `resolveStepSequence` (e.g. the `useJourneyProgress` hooks) reuse this tuple. + */ +export type StepSequenceOptionsArg = [TInput] extends [void] + ? [options?: ResolveStepSequenceOptions] + : [options: ResolveStepSequenceOptions]; + const DEFAULT_MAX_STEPS = 256; /** @@ -101,12 +141,16 @@ export function resolveStepSequence< TMeta extends { [K in keyof TMeta]: unknown }, >( definition: JourneyDefinition, - options: ResolveStepSequenceOptions = {}, + ...[options]: StepSequenceOptionsArg ): readonly ResolvedJourneyStep[] { - const maxSteps = normalizeMaxSteps(options.maxSteps); + // Read against the erased shape: `input` sits on a conditional branch of + // `ResolveStepSequenceOptions` that TS can't index while `TInput` is + // still generic, so normalize to a flat readable view (the type guarantees a + // non-void journey supplied `input` or `start` before we get here). + const opts = (options ?? {}) as StepSequenceWalkOptions & { readonly input?: TInput }; + const maxSteps = normalizeMaxSteps(opts.maxSteps); - let current: StepSequenceRef | undefined = - options.start ?? deriveStart(definition, options.input); + let current: StepSequenceRef | undefined = opts.start ?? deriveStart(definition, opts.input); const sequence: ResolvedJourneyStep[] = []; const visited = new Set(); @@ -133,7 +177,7 @@ export function resolveStepSequence< // Fork — the resolver picks. Its return is matched back against `targets` // by `module` + `entry` (identity not required), so a `undefined` return // or a ref that isn't one of the declared targets stops the sequence here. - const picked: StepSequenceRef | undefined = options.branch?.({ + const picked: StepSequenceRef | undefined = opts.branch?.({ module: current.module, entry: current.entry, targets, diff --git a/packages/journeys/src/use-journey-progress.ts b/packages/journeys/src/use-journey-progress.ts index 063b1a35..adcca4b6 100644 --- a/packages/journeys/src/use-journey-progress.ts +++ b/packages/journeys/src/use-journey-progress.ts @@ -6,28 +6,47 @@ import { type ModuleTypeMap, type ResolvedJourneyStep, type ResolveStepSequenceOptions, + type StepSequenceOptionsArg, } from "@modular-frontend/journeys-engine"; import { useInstanceSnapshot } from "./instance-hooks.js"; import { useJourneyContext } from "./provider.js"; -export interface UseJourneyProgressOptions { +interface UseJourneyProgressBase { /** * Runtime the `instanceId` belongs to. Defaults to the one from a * surrounding ``. Pass explicitly to read progress for an * instance on a runtime other than the ambient one. */ readonly runtime?: JourneyRuntime; - /** - * Forwarded to `resolveStepSequence` — most importantly `branch`, to - * linearize a forking flow, and `input`, when the start step depends on it. - * - * The sequence is memoized on this object's identity, so pass a stable - * reference (e.g. a module-level constant or a `useMemo`) rather than a fresh - * literal each render if you want to avoid re-walking the graph per render. - */ - readonly sequence?: ResolveStepSequenceOptions; } +/** + * Options for {@link useJourneyProgress}. + * + * `sequence` is forwarded to `resolveStepSequence` — most importantly `branch`, + * to linearize a forking flow, and `input`, when the start step depends on it. + * The sequence is memoized on this object's identity, so pass a stable + * reference (e.g. a module-level constant or a `useMemo`) rather than a fresh + * literal each render if you want to avoid re-walking the graph per render. + * + * Mirroring `resolveStepSequence`, `sequence` is optional for a void-input + * journey but required (carrying `input` or `start`) when the journey's + * `initialState` / `start` need a non-void input. + */ +export type UseJourneyProgressOptions = UseJourneyProgressBase & + ([TInput] extends [void] + ? { readonly sequence?: ResolveStepSequenceOptions } + : { readonly sequence: ResolveStepSequenceOptions }); + +/** + * Trailing options argument for {@link useJourneyProgress}: optional for a + * void-input journey, required when the journey needs a non-void input so the + * mandatory `sequence.input` / `sequence.start` can't be omitted. + */ +export type UseJourneyProgressArgs = [TInput] extends [void] + ? [options?: UseJourneyProgressOptions] + : [options: UseJourneyProgressOptions]; + export interface JourneyProgress { /** * 0-based position in the flow — `history.length`, so `0` on the first step. @@ -97,16 +116,28 @@ export function useJourneyProgress< >( instanceId: InstanceId | null, definition: JourneyDefinition, - options: UseJourneyProgressOptions = {}, + ...[options]: UseJourneyProgressArgs ): JourneyProgress { + const opts = (options ?? {}) as UseJourneyProgressBase & { + readonly sequence?: ResolveStepSequenceOptions; + }; const context = useJourneyContext(); - const runtime = options.runtime ?? context?.runtime ?? null; + const runtime = opts.runtime ?? context?.runtime ?? null; const instance = useInstanceSnapshot(runtime, instanceId); - const sequenceOptions = options.sequence; + const sequenceOptions = opts.sequence; const steps = useMemo( - () => resolveStepSequence(definition, sequenceOptions), + // The tuple cast localizes the same "TInput is generic here" erasure the + // engine documents: `sequenceOptions` already satisfies the input-or-start + // requirement via `UseJourneyProgressOptions`, so forward it as-is. + () => + resolveStepSequence( + definition, + ...((sequenceOptions === undefined + ? [] + : [sequenceOptions]) as StepSequenceOptionsArg), + ), [definition, sequenceOptions], ); diff --git a/packages/vue-journeys/src/use-journey-progress.ts b/packages/vue-journeys/src/use-journey-progress.ts index a5f4d80a..88a41d58 100644 --- a/packages/vue-journeys/src/use-journey-progress.ts +++ b/packages/vue-journeys/src/use-journey-progress.ts @@ -6,23 +6,42 @@ import { type ModuleTypeMap, type ResolvedJourneyStep, type ResolveStepSequenceOptions, + type StepSequenceOptionsArg, } from "@modular-frontend/journeys-engine"; import { useInstanceSnapshot } from "./instance-hooks.js"; import { useJourneyContext } from "./provider.js"; -export interface UseJourneyProgressOptions { +interface UseJourneyProgressBase { /** * Runtime the `instanceId` belongs to. Defaults to the one provided by a * surrounding ``. */ readonly runtime?: JourneyRuntime; - /** - * Forwarded to `resolveStepSequence` — chiefly `branch`, to linearize a - * forking flow, and `input`, when the start step depends on it. - */ - readonly sequence?: ResolveStepSequenceOptions; } +/** + * Options for {@link useJourneyProgress}. + * + * `sequence` is forwarded to `resolveStepSequence` — chiefly `branch`, to + * linearize a forking flow, and `input`, when the start step depends on it. + * Mirroring `resolveStepSequence`, `sequence` is optional for a void-input + * journey but required (carrying `input` or `start`) when the journey's + * `initialState` / `start` need a non-void input. + */ +export type UseJourneyProgressOptions = UseJourneyProgressBase & + ([TInput] extends [void] + ? { readonly sequence?: ResolveStepSequenceOptions } + : { readonly sequence: ResolveStepSequenceOptions }); + +/** + * Trailing options argument for {@link useJourneyProgress}: optional for a + * void-input journey, required when the journey needs a non-void input so the + * mandatory `sequence.input` / `sequence.start` can't be omitted. + */ +export type UseJourneyProgressArgs = [TInput] extends [void] + ? [options?: UseJourneyProgressOptions] + : [options: UseJourneyProgressOptions]; + export interface JourneyProgress { /** * 0-based position (`history.length`), so `0` on the first step. Tracks the @@ -78,18 +97,26 @@ export function useJourneyProgress< >( instanceId: MaybeRefOrGetter, definition: JourneyDefinition, - options: UseJourneyProgressOptions = {}, + ...[options]: UseJourneyProgressArgs ): JourneyProgress { + const opts = (options ?? {}) as UseJourneyProgressBase & { + readonly sequence?: ResolveStepSequenceOptions; + }; const ctx = useJourneyContext(); // `toRaw` for the same reason the host/outlet do it — a runtime that arrived // through a reactive prop is a proxy, and the runtime keys on raw identity. - const runtime = toRaw(options.runtime ?? ctx?.runtime ?? undefined) ?? null; + const runtime = toRaw(opts.runtime ?? ctx?.runtime ?? undefined) ?? null; const instance = useInstanceSnapshot(runtime, instanceId); // `definition` / `options` are plain values (not reactive), so the sequence - // is resolved once at setup — the same read the React hook memoizes. - const steps = resolveStepSequence(definition, options.sequence); + // is resolved once at setup — the same read the React hook memoizes. The + // tuple cast localizes the engine's documented "TInput is generic here" + // erasure; `opts.sequence` already satisfies the input-or-start requirement. + const steps = resolveStepSequence( + definition, + ...((opts.sequence === undefined ? [] : [opts.sequence]) as StepSequenceOptionsArg), + ); const index = computed(() => (instance.value ? instance.value.history.length : 0)); const total = computed(() => (steps.length > 0 ? steps.length : null)); From 65ef680baa9ec31dc57e427b3b96b950d3555c58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:13:04 +0000 Subject: [PATCH 4/9] docs(changelog): reword "requires annotated handlers" for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit nit on PR #83: "Requires annotated (defineTransition) handlers" → "Requires handlers annotated with defineTransition". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UTynWaEhJz9FYF95seoPvH --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65db1fea..1b07a089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Closes item 3 of `docs/consumer-feedback-production-app.md`: the production cons Closes item 4 of `docs/consumer-feedback-production-app.md`: the consumer encoded each journey's flow twice — once as the transition-map graph, and again as a ~170-line hand-maintained file of ordered step arrays (in three branch-variant copies) for URL segments and "Step X of N", kept in sync only by discipline. The ordering and progress primitives now live in the library, derived from the one place the flow is already encoded. -- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. Seeding is **required** — not optional — for a journey whose `initialState` consumes a non-void input: the options type demands `input` or `start` there, so `resolveStepSequence(definition)` is a compile error instead of silently calling `initialState(undefined)` (void-input journeys still take no options). It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires annotated (`defineTransition`) handlers**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceWalkOptions`, `StepSequenceOptionsArg`, `StepSequenceRef`. +- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. Seeding is **required** — not optional — for a journey whose `initialState` consumes a non-void input: the options type demands `input` or `start` there, so `resolveStepSequence(definition)` is a compile error instead of silently calling `initialState(undefined)` (void-input journeys still take no options). It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires handlers annotated with `defineTransition`**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceWalkOptions`, `StepSequenceOptionsArg`, `StepSequenceRef`. - **`@modular-frontend/core`** — `JourneyDefinition.steps?: JourneyStepMetaMap`. Per-step presentation metadata keyed by `[moduleId][entry]` exactly like `transitions` (entry keys filtered to journey-mountable entries, so a typo is a compile error), each leaf a `JourneyStepMeta` (`{ path?, progressLabel? }`). `path` overrides the URL sync's default `"moduleId/entry"` segment; `progressLabel` feeds progress UIs. One source of truth beside the transitions, not re-encoded at each `next:`. New public types: `JourneyStepMeta`, `JourneyStepMetaMap`. - **`@modular-react/journeys`**, **`@modular-vue/journeys`** — `useJourneyProgress(instanceId, definition, options?)`. Returns `{ index, total, label, steps }`: `index` from the live instance (`history.length`, so it rewinds when the journey does), `total` / `label` / `steps` from `resolveStepSequence`. This is the `stepCount` that the journey-hosting work (item 2) deferred with "deriving it from the graph is tracked separately" — now derivable because the total comes from the graph rather than a hand-passed number. `total` is `null` when the sequence can't be walked (unannotated transitions). The React hook returns a plain object; the Vue composable returns `ComputedRef`s. `options.sequence` forwards to `resolveStepSequence` (chiefly `branch`, to make the total reflect a chosen path) and, mirroring it, is optional for void-input journeys but required (carrying `input` / `start`) when the definition needs a non-void input. `resolveStepSequence` and its types are re-exported from both bindings. From f533b25526efd47fa5e39e1547c60f67fbc794a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 10:35:41 +0000 Subject: [PATCH 5/9] feat(core): add curried defineModule() so nav `to` stays inferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit outside-diff review on PR #83. With partial explicit generics, `defineModule(descriptor)` locked `TNavItem` to its `NavigationItem` default (TypeScript can't partially infer a call's type arguments — spelling some forces the rest to their defaults), so function-form `to: (ctx) => ...` stopped type-checking unless the caller also spelled the full nav-item generic. That partial form is exactly what the scaffolder templates and core READMEs shipped. Add a curried overload — `defineModule()(descriptor)` — to all five cores (frontend-core + the four router cores). The first, empty call pins the app-wide TSharedDependencies / TSlots (and optional TMeta); the second infers TNavItem + TDescriptor from the descriptor, so a typed shell fixes deps/slots while function-form `to` stays inferred. Same idiom as defineJourney. The direct `defineModule(descriptor)` (zero generics) and fully-explicit four-generic forms are unchanged. - Type tests: curried function-form/plain-string `to` in frontend-core; curried literal-preservation in tanstack-router-core; curried `ctx` narrowing in vue-core. - Migrate scaffolder templates (react-router / tanstack / vue), the five core READMEs, the getting-started / shell-patterns / framework-mode / remote- capability docs, and the example modules to the curried form; refresh CLI scaffolding snapshots. Full-repo typecheck (147/147) and the five core + three CLI test suites pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UTynWaEhJz9FYF95seoPvH --- docs/framework-mode-nuxt.md | 2 +- docs/framework-mode-tanstack-router.md | 2 +- docs/getting-started-react-router.md | 4 +- docs/getting-started-tanstack-router.md | 4 +- docs/getting-started-vue-router.md | 6 +- docs/remote-capability-manifests.md | 4 +- docs/shell-patterns-react-router.md | 4 +- docs/shell-patterns-tanstack-router.md | 4 +- docs/shell-patterns-vue-router.md | 2 +- docs/shell-patterns.md | 4 +- docs/sibling-modules-shared-screen.md | 4 +- .../modules/integrations/src/index.ts | 2 +- .../app-shared/src/index.ts | 2 +- .../modules/contentful/src/index.tsx | 2 +- .../modules/github/src/index.tsx | 2 +- .../modules/strapi/src/index.tsx | 2 +- .../modules/integrations/src/index.ts | 2 +- .../app-shared/src/index.ts | 2 +- .../modules/contentful/src/index.tsx | 2 +- .../modules/github/src/index.tsx | 2 +- .../modules/strapi/src/index.tsx | 2 +- .../modules/integration-catalog/src/index.ts | 2 +- .../app-shared/src/index.ts | 2 +- .../modules/contentful/src/index.ts | 2 +- .../modules/github/src/index.ts | 2 +- .../modules/strapi/src/index.ts | 2 +- packages/angular-router-core/README.md | 2 +- .../angular-router-core/src/define-module.ts | 19 +++++- .../frontend-core/src/define-module.test-d.ts | 34 ++++++++++ packages/frontend-core/src/define-module.ts | 65 ++++++++++++++----- .../react-router-cli/src/templates/module.ts | 2 +- .../test/__snapshots__/cli.test.ts.snap | 4 +- packages/react-router-core/README.md | 2 +- .../react-router-core/src/define-module.ts | 19 +++++- .../src/templates/module.ts | 2 +- .../test/__snapshots__/cli.test.ts.snap | 4 +- packages/tanstack-router-core/README.md | 2 +- .../src/define-module.test-d.ts | 35 ++++++++++ .../tanstack-router-core/src/define-module.ts | 19 +++++- packages/vue-cli/src/templates/module.ts | 2 +- .../test/__snapshots__/cli.test.ts.snap | 4 +- packages/vue-core/README.md | 2 +- packages/vue-core/src/define-module.test-d.ts | 26 ++++++++ packages/vue-core/src/define-module.ts | 19 +++++- 44 files changed, 260 insertions(+), 72 deletions(-) diff --git a/docs/framework-mode-nuxt.md b/docs/framework-mode-nuxt.md index c1732b57..648bab04 100644 --- a/docs/framework-mode-nuxt.md +++ b/docs/framework-mode-nuxt.md @@ -78,7 +78,7 @@ import { defineModule } from "@modular-vue/core"; import type { RouteRecordRaw } from "vue-router"; import BillingPage from "./BillingPage.vue"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "1.0.0", createRoutes: (): RouteRecordRaw => ({ diff --git a/docs/framework-mode-tanstack-router.md b/docs/framework-mode-tanstack-router.md index 1dcdc3a1..d099e91a 100644 --- a/docs/framework-mode-tanstack-router.md +++ b/docs/framework-mode-tanstack-router.md @@ -162,7 +162,7 @@ For a module whose route structure is known at build time, wrap its component wi import { defineModule } from "@tanstack-react-modules/core"; import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "1.0.0", createRoutes: (parent) => diff --git a/docs/getting-started-react-router.md b/docs/getting-started-react-router.md index 6eafae54..2673d3ff 100644 --- a/docs/getting-started-react-router.md +++ b/docs/getting-started-react-router.md @@ -31,7 +31,7 @@ Three roles, one contract: - **`AppSlots`**: the static contributions the shell collects across all modules (e.g. a `commands` bar). - **`AppZones`**: per-route layout regions a module can fill (e.g. a detail panel on the right). The active route's contributions are what the shell renders. -Every module signature looks like `defineModule({ … })`. That's how TypeScript catches, at compile time, a module asking for a store the shell doesn't provide. +Every module signature looks like `defineModule()({ … })`. That's how TypeScript catches, at compile time, a module asking for a store the shell doesn't provide. ## 1. Scaffold a project @@ -108,7 +108,7 @@ import type { RouteObject } from "react-router"; import type { AppDependencies, AppSlots, AppZones } from "@myorg/app-shared"; import { DashboardDetailPanel } from "./panels/DetailPanel.js"; -export default defineModule({ +export default defineModule()({ id: "dashboard", version: "0.1.0", diff --git a/docs/getting-started-tanstack-router.md b/docs/getting-started-tanstack-router.md index df1f427f..0ea19a2b 100644 --- a/docs/getting-started-tanstack-router.md +++ b/docs/getting-started-tanstack-router.md @@ -33,7 +33,7 @@ Three roles, one contract: - **`AppSlots`**: the static contributions the shell collects across all modules (e.g. a `commands` bar). - **`AppZones`**: per-route layout regions a module can fill (e.g. a detail panel on the right). The active route's contributions are what the shell renders. On TanStack Router, zones ride on the route's `staticData` field, which `app-shared` tightens via a `declare module` augmentation so the types line up. -Every module signature looks like `defineModule({ … })`. That's how TypeScript catches, at compile time, a module asking for a store the shell doesn't provide. +Every module signature looks like `defineModule()({ … })`. That's how TypeScript catches, at compile time, a module asking for a store the shell doesn't provide. ## 1. Scaffold a project @@ -129,7 +129,7 @@ import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; import type { AppDependencies, AppSlots } from "@myorg/app-shared"; import { DashboardDetailPanel } from "./panels/DetailPanel.js"; -export default defineModule({ +export default defineModule()({ id: "dashboard", version: "0.1.0", diff --git a/docs/getting-started-vue-router.md b/docs/getting-started-vue-router.md index 168850ad..91cedb55 100644 --- a/docs/getting-started-vue-router.md +++ b/docs/getting-started-vue-router.md @@ -60,7 +60,7 @@ Three contract interfaces live in `app-shared`: detail panel on the right). In vue-router these ride on the route's `meta`. Every route-owning module's descriptor is typed as -`defineModule({ … })`. That's how TypeScript catches, +`defineModule()({ … })`. That's how TypeScript catches, at compile time, a module asking for a store the shell doesn't provide. ## 1. Create the workspace @@ -167,7 +167,7 @@ import type { RouteRecordRaw } from "vue-router"; import type { AppDependencies, AppSlots } from "@myorg/app-shared"; import DashboardPage from "./DashboardPage.vue"; -export default defineModule({ +export default defineModule()({ id: "dashboard", version: "0.1.0", @@ -377,7 +377,7 @@ import type { RouteRecordRaw } from "vue-router"; import type { AppDependencies, AppSlots } from "@myorg/app-shared"; import BillingPage from "./BillingPage.vue"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "0.1.0", requires: ["auth"], diff --git a/docs/remote-capability-manifests.md b/docs/remote-capability-manifests.md index e90c5d65..a7e9234d 100644 --- a/docs/remote-capability-manifests.md +++ b/docs/remote-capability-manifests.md @@ -302,7 +302,7 @@ import type { AppDependencies, AppSlots } from "@myorg/app-shared"; import { fetchIntegrationManifests } from "../../services/integrations-client"; import { integrationsStore } from "../../stores/integrations-store"; -export default defineModule({ +export default defineModule()({ id: "integrations", version: "1.0.0", requires: ["httpClient"], @@ -339,7 +339,7 @@ Now: when the fetch completes, the store updates, `recalculateSlots()` fires, `d For the **swap topology**, the same module is shorter: no `onRegister` (fetching is UI-driven), and `dynamicSlots` reads the active manifest directly without a merge helper: ```ts -export default defineModule({ +export default defineModule()({ id: "integrations", version: "1.0.0", requires: ["integrations"], diff --git a/docs/shell-patterns-react-router.md b/docs/shell-patterns-react-router.md index 96187e97..537ff92e 100644 --- a/docs/shell-patterns-react-router.md +++ b/docs/shell-patterns-react-router.md @@ -14,7 +14,7 @@ import { defineModule } from "@react-router-modules/core"; import type { RouteObject } from "react-router"; import type { AppDependencies, AppSlots } from "@myorg/app-shared"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "1.0.0", requires: ["auth", "httpClient"], @@ -253,7 +253,7 @@ For per-module auth or role-based access, put a `loader` directly on the module' import { redirect } from "react-router"; import { authStore } from "@myorg/app-shared/stores"; -export default defineModule({ +export default defineModule()({ id: "admin", createRoutes: () => [ { diff --git a/docs/shell-patterns-tanstack-router.md b/docs/shell-patterns-tanstack-router.md index 06d7c3d0..a922a341 100644 --- a/docs/shell-patterns-tanstack-router.md +++ b/docs/shell-patterns-tanstack-router.md @@ -14,7 +14,7 @@ import { defineModule } from "@tanstack-react-modules/core"; import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; import type { AppDependencies, AppSlots } from "@myorg/app-shared"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "1.0.0", requires: ["auth", "httpClient"], @@ -350,7 +350,7 @@ For per-module auth or role-based access, put `beforeLoad` directly on a module- import { createRoute, redirect } from "@tanstack/react-router"; import { authStore } from "@myorg/app-shared/stores"; -export default defineModule({ +export default defineModule()({ id: "admin", createRoutes: (parentRoute) => { const root = createRoute({ diff --git a/docs/shell-patterns-vue-router.md b/docs/shell-patterns-vue-router.md index 4a67da51..c0b59496 100644 --- a/docs/shell-patterns-vue-router.md +++ b/docs/shell-patterns-vue-router.md @@ -76,7 +76,7 @@ when you pass `parentRouteName`, otherwise at the top level. import { defineModule } from "@modular-vue/core"; import type { RouteRecordRaw } from "vue-router"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "1.0.0", createRoutes: (): RouteRecordRaw => ({ diff --git a/docs/shell-patterns.md b/docs/shell-patterns.md index de649de4..4bed3e8a 100644 --- a/docs/shell-patterns.md +++ b/docs/shell-patterns.md @@ -124,7 +124,7 @@ export interface AppSlots { ```typescript import { defineModule } from "@react-router-modules/core"; // or '@tanstack-react-modules/core' -export default defineModule({ +export default defineModule()({ id: "billing", slots: { commands: [ @@ -432,7 +432,7 @@ This is syntactic sugar: the registry sees a normal `ModuleDescriptor` with `ver Modules can declare dependencies they can function without using `optionalRequires`. Missing optional deps log a warning at resolve time instead of throwing: ```typescript -export default defineModule({ +export default defineModule()({ id: "billing", version: "0.1.0", requires: ["httpClient"], // hard requirement: throws if missing diff --git a/docs/sibling-modules-shared-screen.md b/docs/sibling-modules-shared-screen.md index f531a4c6..368f0b58 100644 --- a/docs/sibling-modules-shared-screen.md +++ b/docs/sibling-modules-shared-screen.md @@ -156,7 +156,7 @@ const handle: AppRouteData = { pageTitle: "Contentful", }; -export default defineModule({ +export default defineModule()({ id: "contentful", version: "1.0.0", requires: ["auth", "httpClient"], @@ -193,7 +193,7 @@ const contentfulConfig: IntegrationConfig = { /* same shape as above */ }; -export default defineModule({ +export default defineModule()({ id: "contentful", version: "1.0.0", requires: ["auth", "httpClient"], diff --git a/examples/react-router/active-project-manifest/modules/integrations/src/index.ts b/examples/react-router/active-project-manifest/modules/integrations/src/index.ts index c1109561..692d4626 100644 --- a/examples/react-router/active-project-manifest/modules/integrations/src/index.ts +++ b/examples/react-router/active-project-manifest/modules/integrations/src/index.ts @@ -14,7 +14,7 @@ import type { AppDependencies, AppSlots } from "@example-active/app-shared"; * No `onRegister` hook: there's nothing to fetch at boot. Fetching happens * on demand when the UI calls `selectProject`. */ -export default defineModule({ +export default defineModule()({ id: "integrations", version: "1.0.0", requires: ["integrations"], diff --git a/examples/react-router/customer-onboarding-journey/app-shared/src/index.ts b/examples/react-router/customer-onboarding-journey/app-shared/src/index.ts index 21a48467..dfeb330b 100644 --- a/examples/react-router/customer-onboarding-journey/app-shared/src/index.ts +++ b/examples/react-router/customer-onboarding-journey/app-shared/src/index.ts @@ -21,7 +21,7 @@ export interface PlanHint { * No module contributes slots here. The workflow is expressed as a journey, * not as slot items. Keeping the slot map empty-but-declared documents that * intent — modules still get the typed dependency/slot surface through the - * same `defineModule` pattern. + * same `defineModule()` pattern. */ export interface AppSlots { // Intentionally empty — this example renders exclusively via journey tabs. diff --git a/examples/react-router/integration-manager/modules/contentful/src/index.tsx b/examples/react-router/integration-manager/modules/contentful/src/index.tsx index 49c9db92..fb123b1d 100644 --- a/examples/react-router/integration-manager/modules/contentful/src/index.tsx +++ b/examples/react-router/integration-manager/modules/contentful/src/index.tsx @@ -31,7 +31,7 @@ const handle = { pageTitle: "Contentful", } satisfies AppRouteData; -export default defineModule({ +export default defineModule()({ id: "contentful", version: "0.0.0", requires: ["auth"], diff --git a/examples/react-router/integration-manager/modules/github/src/index.tsx b/examples/react-router/integration-manager/modules/github/src/index.tsx index 98272ce2..0ff285b8 100644 --- a/examples/react-router/integration-manager/modules/github/src/index.tsx +++ b/examples/react-router/integration-manager/modules/github/src/index.tsx @@ -27,7 +27,7 @@ const handle = { pageTitle: "GitHub", } satisfies AppRouteData; -export default defineModule({ +export default defineModule()({ id: "github", version: "0.0.0", requires: ["auth"], diff --git a/examples/react-router/integration-manager/modules/strapi/src/index.tsx b/examples/react-router/integration-manager/modules/strapi/src/index.tsx index 5704bf0f..7d858d21 100644 --- a/examples/react-router/integration-manager/modules/strapi/src/index.tsx +++ b/examples/react-router/integration-manager/modules/strapi/src/index.tsx @@ -27,7 +27,7 @@ const handle = { pageTitle: "Strapi", } satisfies AppRouteData; -export default defineModule({ +export default defineModule()({ id: "strapi", version: "0.0.0", requires: ["auth"], diff --git a/examples/react-router/remote-capabilities/modules/integrations/src/index.ts b/examples/react-router/remote-capabilities/modules/integrations/src/index.ts index 29c187a9..8f580652 100644 --- a/examples/react-router/remote-capabilities/modules/integrations/src/index.ts +++ b/examples/react-router/remote-capabilities/modules/integrations/src/index.ts @@ -14,7 +14,7 @@ import type { AppDependencies, AppSlots } from "@example/app-shared"; * `manifest.recalculateSlots()`, which re-runs `dynamicSlots(deps)` and the * shell re-renders with the new tiles. No new module registration happens. */ -export default defineModule({ +export default defineModule()({ id: "integrations", version: "1.0.0", requires: ["integrations", "integrationsClient"], diff --git a/examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts b/examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts index fdcfe434..67ba1309 100644 --- a/examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts +++ b/examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts @@ -21,7 +21,7 @@ export interface PlanHint { * No module contributes slots here. The workflow is expressed as a journey, * not as slot items. Keeping the slot map empty-but-declared documents that * intent — modules still get the typed dependency/slot surface through the - * same `defineModule` pattern. + * same `defineModule()` pattern. */ export interface AppSlots { // Intentionally empty — this example renders exclusively via journey tabs. diff --git a/examples/tanstack-router/integration-manager/modules/contentful/src/index.tsx b/examples/tanstack-router/integration-manager/modules/contentful/src/index.tsx index 761a225e..3752528d 100644 --- a/examples/tanstack-router/integration-manager/modules/contentful/src/index.tsx +++ b/examples/tanstack-router/integration-manager/modules/contentful/src/index.tsx @@ -25,7 +25,7 @@ const contentfulConfig: IntegrationConfig = { ], }; -export default defineModule({ +export default defineModule()({ id: "contentful", version: "0.0.0", requires: ["auth"], diff --git a/examples/tanstack-router/integration-manager/modules/github/src/index.tsx b/examples/tanstack-router/integration-manager/modules/github/src/index.tsx index 00abdaf6..58623ffc 100644 --- a/examples/tanstack-router/integration-manager/modules/github/src/index.tsx +++ b/examples/tanstack-router/integration-manager/modules/github/src/index.tsx @@ -21,7 +21,7 @@ const githubConfig: IntegrationConfig = { ], }; -export default defineModule({ +export default defineModule()({ id: "github", version: "0.0.0", requires: ["auth"], diff --git a/examples/tanstack-router/integration-manager/modules/strapi/src/index.tsx b/examples/tanstack-router/integration-manager/modules/strapi/src/index.tsx index b95f679b..d14072c1 100644 --- a/examples/tanstack-router/integration-manager/modules/strapi/src/index.tsx +++ b/examples/tanstack-router/integration-manager/modules/strapi/src/index.tsx @@ -21,7 +21,7 @@ const strapiConfig: IntegrationConfig = { ], }; -export default defineModule({ +export default defineModule()({ id: "strapi", version: "0.0.0", requires: ["auth"], diff --git a/examples/tanstack-router/remote-capabilities/modules/integration-catalog/src/index.ts b/examples/tanstack-router/remote-capabilities/modules/integration-catalog/src/index.ts index fa4807c5..c7d05811 100644 --- a/examples/tanstack-router/remote-capabilities/modules/integration-catalog/src/index.ts +++ b/examples/tanstack-router/remote-capabilities/modules/integration-catalog/src/index.ts @@ -18,7 +18,7 @@ import type { AppDependencies, AppSlots } from "@example-tsr-remote-capabilities * re-runs `dynamicSlots(deps)` and the page re-renders with the new tiles * / connected badges. */ -export default defineModule({ +export default defineModule()({ id: "integration-catalog", version: "1.0.0", requires: ["integrations", "integrationsClient"], diff --git a/examples/vue/customer-onboarding-journey/app-shared/src/index.ts b/examples/vue/customer-onboarding-journey/app-shared/src/index.ts index 7e37528f..1534dd85 100644 --- a/examples/vue/customer-onboarding-journey/app-shared/src/index.ts +++ b/examples/vue/customer-onboarding-journey/app-shared/src/index.ts @@ -21,7 +21,7 @@ export interface PlanHint { * No module contributes slots here. The workflow is expressed as a journey, * not as slot items. Keeping the slot map empty-but-declared documents that * intent — modules still get the typed dependency/slot surface through the - * same `defineModule` pattern. + * same `defineModule()` pattern. */ export interface AppSlots { // Intentionally empty — this example renders exclusively via journey tabs. diff --git a/examples/vue/integration-manager/modules/contentful/src/index.ts b/examples/vue/integration-manager/modules/contentful/src/index.ts index ccf64231..8947244e 100644 --- a/examples/vue/integration-manager/modules/contentful/src/index.ts +++ b/examples/vue/integration-manager/modules/contentful/src/index.ts @@ -4,7 +4,7 @@ import type { AppDependencies, AppSlots } from "@example-vue-integration-manager import ContentfulPage from "./ContentfulPage.vue"; import { contentfulConfig } from "./config.js"; -export default defineModule({ +export default defineModule()({ id: "contentful", version: "0.0.0", requires: ["auth"], diff --git a/examples/vue/integration-manager/modules/github/src/index.ts b/examples/vue/integration-manager/modules/github/src/index.ts index 88cf7f48..9e733b71 100644 --- a/examples/vue/integration-manager/modules/github/src/index.ts +++ b/examples/vue/integration-manager/modules/github/src/index.ts @@ -4,7 +4,7 @@ import type { AppDependencies, AppSlots } from "@example-vue-integration-manager import GithubPage from "./GithubPage.vue"; import { githubConfig } from "./config.js"; -export default defineModule({ +export default defineModule()({ id: "github", version: "0.0.0", requires: ["auth"], diff --git a/examples/vue/integration-manager/modules/strapi/src/index.ts b/examples/vue/integration-manager/modules/strapi/src/index.ts index 6432ae6a..5b6b3feb 100644 --- a/examples/vue/integration-manager/modules/strapi/src/index.ts +++ b/examples/vue/integration-manager/modules/strapi/src/index.ts @@ -4,7 +4,7 @@ import type { AppDependencies, AppSlots } from "@example-vue-integration-manager import StrapiPage from "./StrapiPage.vue"; import { strapiConfig } from "./config.js"; -export default defineModule({ +export default defineModule()({ id: "strapi", version: "0.0.0", requires: ["auth"], diff --git a/packages/angular-router-core/README.md b/packages/angular-router-core/README.md index 6d5d7395..2815f7a0 100644 --- a/packages/angular-router-core/README.md +++ b/packages/angular-router-core/README.md @@ -41,7 +41,7 @@ npm install @angular-router-modules/core ```typescript import { defineModule } from "@angular-router-modules/core"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "0.1.0", createRoutes: () => [ diff --git a/packages/angular-router-core/src/define-module.ts b/packages/angular-router-core/src/define-module.ts index e8ba2a9f..0195f1ce 100644 --- a/packages/angular-router-core/src/define-module.ts +++ b/packages/angular-router-core/src/define-module.ts @@ -14,6 +14,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * type AppNavItem = NavigationItem * * export default defineModule({ ... }) + * + * // Curried — pin app-wide deps/slots while navigation `to` stays inferred: + * export default defineModule()({ ... }) * ``` * * Two inference guarantees matter for journeys built on `typeof someModule`: @@ -41,6 +44,18 @@ export function defineModule< TNavItem extends NavigationItemBase = NavigationItem, TDescriptor extends ModuleDescriptor = ModuleDescriptor, ->(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { - return descriptor; +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor; +export function defineModule< + TSharedDependencies extends Record, + TSlots extends SlotMapOf, + TMeta extends { [K in keyof TMeta]: unknown } = Record, +>(): < + TNavItem extends NavigationItemBase = NavigationItem, + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>( + descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }, +) => TDescriptor; +export function defineModule(descriptor?: unknown): unknown { + return descriptor === undefined ? (inner: unknown) => inner : descriptor; } diff --git a/packages/frontend-core/src/define-module.test-d.ts b/packages/frontend-core/src/define-module.test-d.ts index f6933e44..f96ee69c 100644 --- a/packages/frontend-core/src/define-module.test-d.ts +++ b/packages/frontend-core/src/define-module.test-d.ts @@ -36,6 +36,40 @@ test("plain-string `to` still type-checks with ZERO explicit generics", () => { void m; }); +test("curried form: function-form `to` type-checks with explicit deps/slots", () => { + // The scaffolded shape — a typed shell pins `TSharedDependencies` / `TSlots` + // while `to` resolves from render-time context. Partial generics on a single + // call (`defineModule({...})`) would default `TNavItem` + // back to `NavigationItem` and reject this; the curried call keeps it inferred. + interface AppDeps { + readonly logger: { info(m: string): void }; + } + type AppSlots = Record; + + const m = defineModule()({ + id: "portal", + version: "1.0.0", + navigation: [ + { label: "Requests", to: (ctx: { workspaceId: string }) => `/portal/${ctx.workspaceId}` }, + ], + }); + void m; +}); + +test("curried form: plain-string `to` still type-checks with explicit deps/slots", () => { + interface AppDeps { + readonly logger: { info(m: string): void }; + } + type AppSlots = Record; + + const m = defineModule()({ + id: "settings", + version: "1.0.0", + navigation: [{ label: "Settings", to: "/settings" }], + }); + void m; +}); + test("an explicitly-narrowed TNavItem is still honored", () => { type AppNavItem = NavigationItem<"nav.billing", { orgId: string }, { badge?: "beta" }>; diff --git a/packages/frontend-core/src/define-module.ts b/packages/frontend-core/src/define-module.ts index 5caa1d12..055d6047 100644 --- a/packages/frontend-core/src/define-module.ts +++ b/packages/frontend-core/src/define-module.ts @@ -10,27 +10,41 @@ import type { * Identity function that provides type inference for module descriptors. * Zero runtime overhead — returns its argument unchanged. * - * Use the generics to opt into stricter typing: + * Two call shapes: * - * - `TMeta` — catalog metadata shape ({@link ModuleDescriptor.meta}). + * - **Direct** — `defineModule(descriptor)`. Infers everything from the + * argument. Use the generics to opt into stricter typing: + * - `TMeta` — catalog metadata shape ({@link ModuleDescriptor.meta}). + * - `TNavItem` — app-specific navigation item type. Alias + * `NavigationItem` once in your app and pass it + * through, so typed i18n labels, dynamic hrefs, and typed `meta` are + * enforced. When you don't pass it, it is **inferred from the `navigation` + * array** (the `descriptor & { navigation?: readonly TNavItem[] }` + * parameter shape), defaulting to `NavigationItem` only when there is no + * navigation. That inference is what lets a module use **function-form** + * `to` (`to: (ctx) => "/portal/" + ctx.workspaceId`) with zero generics: + * the old fixed `NavigationItem` default narrowed `to` to a plain `string` + * and rejected the resolver form. The inferred item stays narrow (a + * plain-string `to` infers a `string`-`to` item), so the result is still + * assignable where a `NavigationItem`-typed registry expects it. * - * - `TNavItem` — app-specific navigation item type. Alias - * `NavigationItem` once in your app and pass - * it through, so typed i18n labels, dynamic hrefs, and typed `meta` are - * enforced on every module. When you don't pass it, it is **inferred from the - * `navigation` array** (the `descriptor & { navigation?: readonly TNavItem[] }` - * parameter shape), defaulting to `NavigationItem` only when there is no - * navigation. That inference is what lets a module use **function-form** `to` - * (`to: (ctx) => "/portal/" + ctx.workspaceId`) with zero generics: the old - * fixed `NavigationItem` default narrowed `to` to a plain `string` and - * rejected the resolver form. The inferred item stays narrow (a plain-string - * `to` infers a `string`-`to` item), so the result is still assignable where - * a `NavigationItem`-typed registry expects it. + * - **Curried** — `defineModule()(descriptor)`. Pins the + * app-wide `TSharedDependencies` / `TSlots` (and optionally `TMeta`) in the + * first, empty call, then infers `TNavItem` + `TDescriptor` from the + * descriptor in the second. This is what a typed shell wants: fix the + * dependency/slot types once while function-form `to` still type-checks, + * because `TNavItem` is *inferred*, not defaulted. It exists because + * TypeScript can't partially infer a single call's type arguments — spelling + * some (`defineModule(descriptor)`) forces the rest to + * their defaults, which pins `TNavItem` back to `NavigationItem` and rejects + * function-form `to`. Moving the app-context generics to their own call keeps + * the descriptor-derived generics inferable, exactly like `defineJourney`. * * ```ts * interface JourneyMeta { name: string; category: string } * type AppNavItem = NavigationItem * + * // Direct, fully explicit: * export default defineModule({ * id: "portal", * version: "1.0.0", @@ -43,6 +57,13 @@ import type { * }, * ], * }) + * + * // Curried — typed shell deps/slots, navigation `to` still inferred: + * export default defineModule()({ + * id: "portal", + * version: "1.0.0", + * navigation: [{ label: "Requests", to: ({ workspaceId }) => `/portal/${workspaceId}` }], + * }) * ``` * * The final `TDescriptor` generic is inferred from the argument and lets the @@ -59,6 +80,18 @@ export function defineModule< TNavItem extends NavigationItemBase = NavigationItem, TDescriptor extends ModuleDescriptor = ModuleDescriptor, ->(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { - return descriptor; +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor; +export function defineModule< + TSharedDependencies extends Record, + TSlots extends SlotMapOf, + TMeta extends { [K in keyof TMeta]: unknown } = Record, +>(): < + TNavItem extends NavigationItemBase = NavigationItem, + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>( + descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }, +) => TDescriptor; +export function defineModule(descriptor?: unknown): unknown { + return descriptor === undefined ? (inner: unknown) => inner : descriptor; } diff --git a/packages/react-router-cli/src/templates/module.ts b/packages/react-router-cli/src/templates/module.ts index a1eb41a9..21543003 100644 --- a/packages/react-router-cli/src/templates/module.ts +++ b/packages/react-router-cli/src/templates/module.ts @@ -23,7 +23,7 @@ import type { RouteObject } from 'react-router' import type { AppDependencies, AppSlots, AppZones } from '${params.scope}/app-shared' import { ${label}DetailPanel } from './panels/DetailPanel.js' -export default defineModule({ +export default defineModule()({ id: '${params.name}', version: '0.1.0', diff --git a/packages/react-router-cli/test/__snapshots__/cli.test.ts.snap b/packages/react-router-cli/test/__snapshots__/cli.test.ts.snap index 2b65a065..c72b0a26 100644 --- a/packages/react-router-cli/test/__snapshots__/cli.test.ts.snap +++ b/packages/react-router-cli/test/__snapshots__/cli.test.ts.snap @@ -330,7 +330,7 @@ import type { RouteObject } from 'react-router' import type { AppDependencies, AppSlots, AppZones } from '@acme/app-shared' import { DashboardDetailPanel } from './panels/DetailPanel.js' -export default defineModule({ +export default defineModule()({ id: 'dashboard', version: '0.1.0', @@ -524,7 +524,7 @@ import type { RouteObject } from 'react-router' import type { AppDependencies, AppSlots, AppZones } from '@acme/app-shared' import { OrdersDetailPanel } from './panels/DetailPanel.js' -export default defineModule({ +export default defineModule()({ id: 'orders', version: '0.1.0', diff --git a/packages/react-router-core/README.md b/packages/react-router-core/README.md index a03bb74a..465fb896 100644 --- a/packages/react-router-core/README.md +++ b/packages/react-router-core/README.md @@ -21,7 +21,7 @@ npm install @react-router-modules/core ```typescript import { defineModule } from "@react-router-modules/core"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "0.1.0", createRoutes: () => [ diff --git a/packages/react-router-core/src/define-module.ts b/packages/react-router-core/src/define-module.ts index 6c6fc26f..f8326838 100644 --- a/packages/react-router-core/src/define-module.ts +++ b/packages/react-router-core/src/define-module.ts @@ -14,6 +14,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * type AppNavItem = NavigationItem * * export default defineModule({ ... }) + * + * // Curried — pin app-wide deps/slots while navigation `to` stays inferred: + * export default defineModule()({ ... }) * ``` * * Two inference guarantees matter for journeys built on `typeof someModule`: @@ -41,6 +44,18 @@ export function defineModule< TNavItem extends NavigationItemBase = NavigationItem, TDescriptor extends ModuleDescriptor = ModuleDescriptor, ->(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { - return descriptor; +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor; +export function defineModule< + TSharedDependencies extends Record, + TSlots extends SlotMapOf, + TMeta extends { [K in keyof TMeta]: unknown } = Record, +>(): < + TNavItem extends NavigationItemBase = NavigationItem, + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>( + descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }, +) => TDescriptor; +export function defineModule(descriptor?: unknown): unknown { + return descriptor === undefined ? (inner: unknown) => inner : descriptor; } diff --git a/packages/tanstack-router-cli/src/templates/module.ts b/packages/tanstack-router-cli/src/templates/module.ts index b24c7d35..ec7f7d90 100644 --- a/packages/tanstack-router-cli/src/templates/module.ts +++ b/packages/tanstack-router-cli/src/templates/module.ts @@ -23,7 +23,7 @@ import { createRoute, lazyRouteComponent } from '@tanstack/react-router' import type { AppDependencies, AppSlots } from '${params.scope}/app-shared' import { ${label}DetailPanel } from './panels/DetailPanel.js' -export default defineModule({ +export default defineModule()({ id: '${params.name}', version: '0.1.0', diff --git a/packages/tanstack-router-cli/test/__snapshots__/cli.test.ts.snap b/packages/tanstack-router-cli/test/__snapshots__/cli.test.ts.snap index 8cbf67f7..6c3defaa 100644 --- a/packages/tanstack-router-cli/test/__snapshots__/cli.test.ts.snap +++ b/packages/tanstack-router-cli/test/__snapshots__/cli.test.ts.snap @@ -340,7 +340,7 @@ import { createRoute, lazyRouteComponent } from '@tanstack/react-router' import type { AppDependencies, AppSlots } from '@acme/app-shared' import { DashboardDetailPanel } from './panels/DetailPanel.js' -export default defineModule({ +export default defineModule()({ id: 'dashboard', version: '0.1.0', @@ -544,7 +544,7 @@ import { createRoute, lazyRouteComponent } from '@tanstack/react-router' import type { AppDependencies, AppSlots } from '@acme/app-shared' import { OrdersDetailPanel } from './panels/DetailPanel.js' -export default defineModule({ +export default defineModule()({ id: 'orders', version: '0.1.0', diff --git a/packages/tanstack-router-core/README.md b/packages/tanstack-router-core/README.md index 0258141f..42cc81ae 100644 --- a/packages/tanstack-router-core/README.md +++ b/packages/tanstack-router-core/README.md @@ -22,7 +22,7 @@ npm install @tanstack-react-modules/core import { defineModule } from "@tanstack-react-modules/core"; import { createRoute } from "@tanstack/react-router"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "0.1.0", createRoutes: (parentRoute) => diff --git a/packages/tanstack-router-core/src/define-module.test-d.ts b/packages/tanstack-router-core/src/define-module.test-d.ts index 3ea40e7a..2ca1d8d8 100644 --- a/packages/tanstack-router-core/src/define-module.test-d.ts +++ b/packages/tanstack-router-core/src/define-module.test-d.ts @@ -120,3 +120,38 @@ test("an undeclared entry key in the transition map is a compile error", () => { }; void transitions; }); + +// ----------------------------------------------------------------------------- +// Curried form — a typed shell pins `TSharedDependencies` / `TSlots` explicitly +// while function-form `to` stays inferred. Partial generics on a single call +// (`defineModule({...})`) would default `TNavItem` back to +// the string-`to` shape and reject the resolver form; the curried call keeps it +// inferred (guarantee 2) without losing the literal entry/exit shapes +// (guarantee 1). +// ----------------------------------------------------------------------------- + +test("curried defineModule() keeps function-form `to` inferred", () => { + interface AppDeps { + readonly logger: { info(m: string): void }; + } + type AppSlots = Record; + + const curried = defineModule()({ + id: "plan", + version: "1.0.0", + navigation: [ + { label: "Plan", to: (ctx: { workspaceId: string }) => `/plan/${ctx.workspaceId}` }, + ], + exitPoints: { chosen: defineExit<{ readonly tier: string }>() }, + entryPoints: { + choose: defineEntry({ + component: (() => null) as never, + input: schema<{ readonly recommended: string }>(), + }), + }, + }); + + // Literal entry/exit vocabulary still survives the curried call. + expectTypeOf>().toEqualTypeOf<"choose">(); + expectTypeOf>().toEqualTypeOf<"chosen">(); +}); diff --git a/packages/tanstack-router-core/src/define-module.ts b/packages/tanstack-router-core/src/define-module.ts index eb35a27c..6a5058f5 100644 --- a/packages/tanstack-router-core/src/define-module.ts +++ b/packages/tanstack-router-core/src/define-module.ts @@ -14,6 +14,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * type AppNavItem = NavigationItem * * export default defineModule({ ... }) + * + * // Curried — pin app-wide deps/slots while navigation `to` stays inferred: + * export default defineModule()({ ... }) * ``` * * Two inference guarantees matter for journeys built on `typeof someModule`: @@ -41,6 +44,18 @@ export function defineModule< TNavItem extends NavigationItemBase = NavigationItem, TDescriptor extends ModuleDescriptor = ModuleDescriptor, ->(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { - return descriptor; +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor; +export function defineModule< + TSharedDependencies extends Record, + TSlots extends SlotMapOf, + TMeta extends { [K in keyof TMeta]: unknown } = Record, +>(): < + TNavItem extends NavigationItemBase = NavigationItem, + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>( + descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }, +) => TDescriptor; +export function defineModule(descriptor?: unknown): unknown { + return descriptor === undefined ? (inner: unknown) => inner : descriptor; } diff --git a/packages/vue-cli/src/templates/module.ts b/packages/vue-cli/src/templates/module.ts index 678209f4..f9182ed9 100644 --- a/packages/vue-cli/src/templates/module.ts +++ b/packages/vue-cli/src/templates/module.ts @@ -23,7 +23,7 @@ import type { RouteRecordRaw } from 'vue-router' import type { AppDependencies, AppSlots, AppZones } from '${params.scope}/app-shared' import ${label}DetailPanel from './panels/DetailPanel.vue' -export default defineModule({ +export default defineModule()({ id: '${params.name}', version: '0.1.0', diff --git a/packages/vue-cli/test/__snapshots__/cli.test.ts.snap b/packages/vue-cli/test/__snapshots__/cli.test.ts.snap index e4cf793d..e2644e89 100644 --- a/packages/vue-cli/test/__snapshots__/cli.test.ts.snap +++ b/packages/vue-cli/test/__snapshots__/cli.test.ts.snap @@ -353,7 +353,7 @@ import type { RouteRecordRaw } from 'vue-router' import type { AppDependencies, AppSlots, AppZones } from '@acme/app-shared' import DashboardDetailPanel from './panels/DetailPanel.vue' -export default defineModule({ +export default defineModule()({ id: 'dashboard', version: '0.1.0', @@ -552,7 +552,7 @@ import type { RouteRecordRaw } from 'vue-router' import type { AppDependencies, AppSlots, AppZones } from '@acme/app-shared' import OrdersDetailPanel from './panels/DetailPanel.vue' -export default defineModule({ +export default defineModule()({ id: 'orders', version: '0.1.0', diff --git a/packages/vue-core/README.md b/packages/vue-core/README.md index 2bbd8da5..3a08f59e 100644 --- a/packages/vue-core/README.md +++ b/packages/vue-core/README.md @@ -22,7 +22,7 @@ npm install @modular-vue/core ```typescript import { defineModule } from "@modular-vue/core"; -export default defineModule({ +export default defineModule()({ id: "billing", version: "0.1.0", createRoutes: () => [ diff --git a/packages/vue-core/src/define-module.test-d.ts b/packages/vue-core/src/define-module.test-d.ts index 9f3f5a03..edf91dc9 100644 --- a/packages/vue-core/src/define-module.test-d.ts +++ b/packages/vue-core/src/define-module.test-d.ts @@ -53,6 +53,32 @@ describe("defineModule typing", () => { void asBase; }); + it("curried defineModule() keeps function-form `to` inferred", () => { + interface AppDeps { + auth: { user: string | null }; + } + interface AppSlots { + commands: { id: string }[]; + } + + // A typed shell pins deps/slots explicitly while `to` resolves from + // render-time context. Partial generics on a single call would default + // `TNavItem` to the string-`to` shape and reject this; the curried call + // keeps `TNavItem` inferred from `navigation` (so the resolver form + // type-checks and its `ctx` narrows to the item's shape). + const mod = defineModule()({ + id: "portal", + version: "1.0.0", + navigation: [ + { label: "Portal", to: (ctx: { workspaceId: string }) => `/p/${ctx.workspaceId}` }, + ], + }); + + expectTypeOf(mod.navigation) + .items.toHaveProperty("to") + .toExtend<(ctx: { workspaceId: string }) => string>(); + }); + it("passes typed i18n-label keys through navigation items", () => { type NavKey = "nav.home" | "nav.billing"; type AppNavItem = NavigationItem; diff --git a/packages/vue-core/src/define-module.ts b/packages/vue-core/src/define-module.ts index 59afe6b9..85cced2f 100644 --- a/packages/vue-core/src/define-module.ts +++ b/packages/vue-core/src/define-module.ts @@ -14,6 +14,9 @@ import type { ModuleDescriptor, SlotMap, SlotMapOf } from "./types.js"; * type AppNavItem = NavigationItem * * export default defineModule({ ... }) + * + * // Curried — pin app-wide deps/slots while navigation `to` stays inferred: + * export default defineModule()({ ... }) * ``` * * Two inference guarantees matter for journeys built on `typeof someModule`: @@ -41,6 +44,18 @@ export function defineModule< TNavItem extends NavigationItemBase = NavigationItem, TDescriptor extends ModuleDescriptor = ModuleDescriptor, ->(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor { - return descriptor; +>(descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }): TDescriptor; +export function defineModule< + TSharedDependencies extends Record, + TSlots extends SlotMapOf, + TMeta extends { [K in keyof TMeta]: unknown } = Record, +>(): < + TNavItem extends NavigationItemBase = NavigationItem, + TDescriptor extends ModuleDescriptor = + ModuleDescriptor, +>( + descriptor: TDescriptor & { readonly navigation?: readonly TNavItem[] }, +) => TDescriptor; +export function defineModule(descriptor?: unknown): unknown { + return descriptor === undefined ? (inner: unknown) => inner : descriptor; } From e9c0b109e57ead1f68ff44d7f70d7a1a4ed79dad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 10:41:44 +0000 Subject: [PATCH 6/9] docs: fix staleness across README, docs, examples, and CLI metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation/examples/CLI review pass: - README: correct the per-family version claims in Project status (react-router-modules & tanstack-react-modules: core/runtime v2.x, cli/testing v3.x; @modular-react core/react v2.x, testing v1.x), and drop the stale "some examples declare library deps with semver ranges" clause — every example pins `workspace:*`. - docs/remote-capability-manifests.md: retarget five broken source links from the non-existent `packages/core/src/{types,remote-manifest}.ts` to their real home in `packages/frontend-core/src/`. - docs/navigation.md link fix: promote "Journey-contributed nav" in the journeys README to a heading so the existing `#journey-contributed-nav` anchor resolves. - examples/README: add the undocumented examples to the index tree and descriptions — integration-setup-journey, journey-invoke (RR + TSR), tanstack remote-capabilities, and the catalog demo portal. - CLI: genericize the shared `create store` help text (was hardcoded "Zustand", wrong for the Vue CLI whose store is `createStore`); add @modular-vue/cli to cli-core's consumer list (README + package.json); add the now-required `scaffold` field to cli-core's preset example; document the `serve` subcommand in the catalog README's CLI section; refresh the catalog package status marker (v0.2 → v1.x). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UTynWaEhJz9FYF95seoPvH --- README.md | 8 ++--- docs/remote-capability-manifests.md | 10 +++---- examples/README.md | 30 +++++++++++++++---- packages/catalog/README.md | 13 +++++++- packages/cli-core/README.md | 15 ++++++---- packages/cli-core/package.json | 2 +- .../cli-core/src/commands/create-store.ts | 2 +- packages/journeys/README.md | 4 ++- 8 files changed, 60 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 03f15a4b..4ca84047 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ Once a workspace has accumulated more modules than any one team can keep in thei ## Project status -- `@react-router-modules/*`: **v2.x**, considered stable for the APIs documented in the guides below. -- `@tanstack-react-modules/*`: **v1.x**, considered stable for the APIs documented in the guides below. -- `@modular-react/{core,react,testing}`: the shared foundation, stable at `1.x`. The router-integration packages depend on these and version independently. +- `@react-router-modules/*`: `core` / `runtime` at **v2.x**, `cli` / `testing` at **v3.x** — considered stable for the APIs documented in the guides below. +- `@tanstack-react-modules/*`: the same spread — `core` / `runtime` at **v2.x**, `cli` / `testing` at **v3.x** — considered stable for the APIs documented in the guides below. +- `@modular-react/{core,react,testing}`: the shared foundation — `core` / `react` at **v2.x**, `testing` at **v1.x**. The router-integration packages depend on these and version independently. - `@modular-react/compositions`: **v0.1.x**, the surface and behavior are documented in [its README](packages/compositions/README.md) but breaking changes between 0.x minor versions are still possible. - `@modular-vue/*` (`core`, `runtime`, `vue`, `testing`, `journeys`, `compositions`): the Vue 3 + vue-router family, **v1.0**, at full feature parity with `@react-router-modules/*` (see the [parity audit](docs/vue-support-tracker.md#parity-audit-pr-42)). Start with [Getting started with Vue Router](docs/getting-started-vue-router.md). The `@modular-vue/cli` scaffolder (binary `modular-vue`) ships alongside the family — `modular-vue init` bootstraps a workspace and `modular-vue create module|store|journey` extends it; the getting-started guide also shows the equivalent manual setup. - `@modular-vue/nuxt`: the Nuxt 3 integration, **v0.1.x, experimental**. Grafts module routes onto Nuxt's vue-router and installs the modular contexts on the Nuxt Vue app, either via a Nuxt module or the `installModularApp` helper in your own plugin. See [Framework-mode (Nuxt 3)](docs/framework-mode-nuxt.md). @@ -233,7 +233,7 @@ See [Getting started with Vue Router](docs/getting-started-vue-router.md) and [S ## Examples -Runnable examples live under [`examples/`](examples/), split by router integration. Each is a self-contained pnpm workspace that resolves the library packages from this repo, so changes in `packages/*` are reflected the next time you run the example (some examples pin `workspace:*` on every dep, others declare library deps with semver ranges and rely on the repo's `.npmrc` `link-workspace-packages=true` — either way the local source wins): +Runnable examples live under [`examples/`](examples/), split by router integration. Each is a self-contained pnpm workspace that resolves the library packages from this repo, so changes in `packages/*` are reflected the next time you run the example (examples pin `workspace:*` on their library deps, so the local source always wins): - [`examples/react-router/integration-manager/`](examples/react-router/integration-manager) — sibling modules sharing a screen (React Router) - [`examples/tanstack-router/integration-manager/`](examples/tanstack-router/integration-manager) — sibling modules sharing a screen (TanStack Router) diff --git a/docs/remote-capability-manifests.md b/docs/remote-capability-manifests.md index a7e9234d..7c3b3613 100644 --- a/docs/remote-capability-manifests.md +++ b/docs/remote-capability-manifests.md @@ -67,7 +67,7 @@ Runnable reference: [`remote-capabilities`](../examples/react-router/remote-capa ## What a remote manifest can carry -Remote manifests are a **strict subset** of a [`ModuleDescriptor`](../packages/core/src/types.ts) — only data that survives a round trip through JSON. +Remote manifests are a **strict subset** of a [`ModuleDescriptor`](../packages/frontend-core/src/types.ts) — only data that survives a round trip through JSON. | Contribution | Remote? | Why | | ----------------------------------------- | ------- | ------------------------------------------------------------------------------------ | @@ -85,7 +85,7 @@ The library ships a narrowed type that enforces this subset at compile time: import type { RemoteModuleManifest, RemoteNavigationItem } from "@modular-react/core"; ``` -`RemoteNavigationItem` narrows `to` to `string` and `icon` to `string` — the two fields on a regular [`NavigationItem`](../packages/core/src/types.ts) that aren't JSON-safe. `RemoteModuleManifest` refuses the non-serializable `ModuleDescriptor` fields up front, so the type itself documents the wire contract. +`RemoteNavigationItem` narrows `to` to `string` and `icon` to `string` — the two fields on a regular [`NavigationItem`](../packages/frontend-core/src/types.ts) that aren't JSON-safe. `RemoteModuleManifest` refuses the non-serializable `ModuleDescriptor` fields up front, so the type itself documents the wire contract. ## Architecture @@ -444,6 +444,6 @@ Two complete walkthroughs live under `examples/react-router/`, one per topology. ## Reference -- Type: [`RemoteModuleManifest`](../packages/core/src/remote-manifest.ts) — JSON-safe subset of `ModuleDescriptor`. -- Type: [`RemoteNavigationItem`](../packages/core/src/remote-manifest.ts) — JSON-safe subset of `NavigationItem`. -- Helper: [`mergeRemoteManifests`](../packages/core/src/remote-manifest.ts) — merges an array into `{ slots, navigation, meta }`, throwing on duplicate ids. +- Type: [`RemoteModuleManifest`](../packages/frontend-core/src/remote-manifest.ts) — JSON-safe subset of `ModuleDescriptor`. +- Type: [`RemoteNavigationItem`](../packages/frontend-core/src/remote-manifest.ts) — JSON-safe subset of `NavigationItem`. +- Helper: [`mergeRemoteManifests`](../packages/frontend-core/src/remote-manifest.ts) — merges an array into `{ slots, navigation, meta }`, throwing on duplicate ids. diff --git a/examples/README.md b/examples/README.md index b818ae24..25ccc919 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,18 +8,24 @@ Examples are split by router integration. Pick the directory for the router you examples/ ├── react-router/ │ ├── integration-manager/ Sibling modules sharing a screen (React Router) +│ ├── integration-setup-journey/ State-driven module dispatch via selectModuleOrDefault (React Router) +│ ├── journey-invoke/ Parent journey invokes/resumes a child journey (React Router) │ ├── customer-onboarding-journey/ Multi-module workflow via @modular-react/journeys (React Router) │ ├── editor-composition/ Multi-module screen via @modular-react/compositions (React Router) │ ├── remote-capabilities/ Slots/navigation driven by a backend-served remote manifest │ └── active-project-manifest/ Per-project remote manifests swapped at runtime ├── tanstack-router/ │ ├── integration-manager/ Sibling modules sharing a screen (TanStack Router) +│ ├── integration-setup-journey/ State-driven module dispatch via selectModuleOrDefault (TanStack Router) +│ ├── journey-invoke/ Parent journey invokes/resumes a child journey (TanStack Router) │ ├── customer-onboarding-journey/ Multi-module workflow via @modular-react/journeys (TanStack Router) -│ └── editor-composition/ Multi-module screen via @modular-react/compositions (TanStack Router) -└── vue/ - ├── integration-manager/ Sibling modules sharing a screen (Vue Router) - ├── customer-onboarding-journey/ Multi-module workflow via @modular-vue/journeys (Vue Router) - └── editor-composition/ Multi-module screen via @modular-vue/compositions (Vue Router) +│ ├── editor-composition/ Multi-module screen via @modular-react/compositions (TanStack Router) +│ └── remote-capabilities/ Remote manifests + journey orchestration on one page (TanStack Router) +├── vue/ +│ ├── integration-manager/ Sibling modules sharing a screen (Vue Router) +│ ├── customer-onboarding-journey/ Multi-module workflow via @modular-vue/journeys (Vue Router) +│ └── editor-composition/ Multi-module screen via @modular-vue/compositions (Vue Router) +└── catalog/ Demo discovery portal built from the tanstack-router examples ``` ## Running an example @@ -63,7 +69,19 @@ Slots and navigation are driven by a backend-served `RemoteModuleManifest` JSON ### `active-project-manifest` -Extension of `remote-capabilities` where the active manifest is swapped at runtime when the user switches projects — each project ships a different JSON manifest, rehydrated into the registry. +Extension of `remote-capabilities` where the active manifest is swapped at runtime when the user switches projects — each project ships a different JSON manifest, rehydrated into the registry. (React Router only.) + +### `integration-setup-journey` + +A journey that decides which module to step into next from a value picked earlier in the flow — the state-driven module dispatch pattern via `selectModuleOrDefault` from `@modular-react/journeys`. (React Router and TanStack Router.) + +### `journey-invoke` + +The `invoke` / `resume` primitive in `@modular-react/journeys`: a parent journey suspends mid-flow to run a child journey and picks up its typed output. (React Router and TanStack Router.) + +### `catalog` + +A demo `@modular-react/catalog` discovery portal built from the tanstack-router examples in this repo — the easiest way to see a populated catalog. Lives at [`examples/catalog/`](catalog) and has its own [README](catalog/README.md). ## Adding a new example diff --git a/packages/catalog/README.md b/packages/catalog/README.md index 4d5a934f..d2271f21 100644 --- a/packages/catalog/README.md +++ b/packages/catalog/README.md @@ -2,7 +2,7 @@ Build a deployable, static **discovery portal** for the modules and journeys in your modular-react codebase. Point it at one or more directories, configure how descriptors are exposed, and get back a directory of HTML/JS/CSS/JSON you can host on any static server. -> **Status:** v0.2 — harvester, CLI, and SPA are stable. URL-driven filter state, pivot pages, and the build-time extension API are all in. The catalog also pre-computes a cross-reference graph (entry/exit usage, journey-to-journey invocations, module-to-journey launches) and recovers transition destinations from journey source via static analysis. Catalog `schemaVersion` is `"2"`. +> **Status:** v1.x — harvester, CLI, and SPA are stable. URL-driven filter state, pivot pages, and the build-time extension API are all in. The catalog also pre-computes a cross-reference graph (entry/exit usage, journey-to-journey invocations, module-to-journey launches) and recovers transition destinations from journey source via static analysis. Catalog `schemaVersion` is `"2"`. ## Why @@ -309,6 +309,17 @@ modular-react-catalog build [--config path] [--out path] [--cwd path] | `--out` | `config.out` ?? `dist-catalog` | Override output directory | | `--cwd` | `process.cwd()` | Override the project root used for config / pattern resolution | +```bash +modular-react-catalog serve [dir] [--port 4321] [--host 127.0.0.1] +``` + +Serve a built catalog directory (default `dist-catalog`) over static HTTP — handy for previewing the portal locally after `build`. + +| Flag | Default | Use | +| -------- | ----------- | ---------------------- | +| `--port` | `4321` | Port to listen on | +| `--host` | `127.0.0.1` | Host/interface to bind | + ## Programmatic API ```ts diff --git a/packages/cli-core/README.md b/packages/cli-core/README.md index d596674c..f881d99b 100644 --- a/packages/cli-core/README.md +++ b/packages/cli-core/README.md @@ -1,10 +1,11 @@ # @modular-react/cli-core -Internal foundation for the modular-react CLI binaries. Both -[`@react-router-modules/cli`](../react-router-cli) and -[`@tanstack-react-modules/cli`](../tanstack-router-cli) are thin -preset wrappers around this package: the commands, prompts, project -detection, file transforms, and router-agnostic templates all live here. +Internal foundation for the modular-react CLI binaries. The +[`@react-router-modules/cli`](../react-router-cli), +[`@tanstack-react-modules/cli`](../tanstack-router-cli), and +[`@modular-vue/cli`](../vue-cli) binaries are thin preset wrappers around +this package: the commands, prompts, project detection, file transforms, +and framework-agnostic templates all live here. If you're scaffolding a project, install one of the router-specific binaries — not this package. @@ -56,6 +57,10 @@ const preset: CliPreset = { routerVersion: "^1.0.0", }, docs: { shellPatterns: "shell-patterns-your-router.md" }, + scaffold: { + entryMain: "main.tsx", // shell entry file name + viewExt: "tsx", // extension for generated view/component files + }, templates: { appSharedIndex, // your `app-shared/src/index.ts` template shellMain, // your `shell/src/main.tsx` template diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 20575d70..fcab339d 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -1,7 +1,7 @@ { "name": "@modular-react/cli-core", "version": "1.0.0", - "description": "Shared command implementations and templates for the modular-react CLI binaries (@react-router-modules/cli, @tanstack-react-modules/cli).", + "description": "Shared command implementations and templates for the modular-react CLI binaries (@react-router-modules/cli, @tanstack-react-modules/cli, @modular-vue/cli).", "repository": { "type": "git", "url": "git://github.com/kibertoad/modular-react.git", diff --git a/packages/cli-core/src/commands/create-store.ts b/packages/cli-core/src/commands/create-store.ts index 2c2133e0..7215b0ee 100644 --- a/packages/cli-core/src/commands/create-store.ts +++ b/packages/cli-core/src/commands/create-store.ts @@ -13,7 +13,7 @@ export function createCreateStoreCommand(preset: CliPreset) { return defineCommand({ meta: { name: "store", - description: "Create a new Zustand store and wire it into AppDependencies", + description: "Create a new shared store and wire it into AppDependencies", }, args: { name: { diff --git a/packages/journeys/README.md b/packages/journeys/README.md index 91cc147b..7b64b07c 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -3000,7 +3000,9 @@ interface JourneyNavContribution { } ``` -**Journey-contributed nav.** Set `options.nav` on `registerJourney` when the journey is reachable from a top-level navbar entry without a dedicated launcher module. The journeys plugin collects every `nav` block at manifest time and merges them into `manifest.navigation` alongside module-contributed items. Items the plugin emits carry an `action: { kind: "journey-start", journeyId, buildInput }` - the framework stays agnostic about how the shell dispatches the action; the shell's navbar renderer switches on `action` to start the journey via `runtime.start(journeyId, buildInput?.())`. +#### Journey-contributed nav + +Set `options.nav` on `registerJourney` when the journey is reachable from a top-level navbar entry without a dedicated launcher module. The journeys plugin collects every `nav` block at manifest time and merges them into `manifest.navigation` alongside module-contributed items. Items the plugin emits carry an `action: { kind: "journey-start", journeyId, buildInput }` - the framework stays agnostic about how the shell dispatches the action; the shell's navbar renderer switches on `action` to start the journey via `runtime.start(journeyId, buildInput?.())`. Apps with a narrowed `TNavItem` (typed i18n labels, typed action union, typed meta bag) should supply a `buildNavItem` adapter on `journeysPlugin({ buildNavItem })` to reshape the plugin's default item into the app-narrowed type: From c6ded96d78375e023871f8cbe10a2dd5ae24c19c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 10:49:43 +0000 Subject: [PATCH 7/9] docs: surface the Angular family and newer journey hooks in guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up usefulness pass on the documentation review: - README: add the experimental Angular family (@angular-router-modules/core, @modular-angular/angular) to Project status and a new "Angular (experimental)" Packages subsection — marked v0.1.x / core-only and explicitly not yet a peer of the router integrations, mirroring how the Nuxt integration is noted. - getting-started (React Router, TanStack Router): name the shipped journey features the journeys section previously glossed as "the runtime hooks" — /useJourneyHost, useJourneyProgress, and useJourneySync. - getting-started (Vue Router): note the matching Vue journey composables in the closing further-reading list. Verified every referenced export exists in @modular-react/journeys and @modular-vue/journeys. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UTynWaEhJz9FYF95seoPvH --- README.md | 8 ++++++++ docs/getting-started-react-router.md | 2 +- docs/getting-started-tanstack-router.md | 2 +- docs/getting-started-vue-router.md | 5 ++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4ca84047..f2e2ec69 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Once a workspace has accumulated more modules than any one team can keep in thei - `@modular-react/compositions`: **v0.1.x**, the surface and behavior are documented in [its README](packages/compositions/README.md) but breaking changes between 0.x minor versions are still possible. - `@modular-vue/*` (`core`, `runtime`, `vue`, `testing`, `journeys`, `compositions`): the Vue 3 + vue-router family, **v1.0**, at full feature parity with `@react-router-modules/*` (see the [parity audit](docs/vue-support-tracker.md#parity-audit-pr-42)). Start with [Getting started with Vue Router](docs/getting-started-vue-router.md). The `@modular-vue/cli` scaffolder (binary `modular-vue`) ships alongside the family — `modular-vue init` bootstraps a workspace and `modular-vue create module|store|journey` extends it; the getting-started guide also shows the equivalent manual setup. - `@modular-vue/nuxt`: the Nuxt 3 integration, **v0.1.x, experimental**. Grafts module routes onto Nuxt's vue-router and installs the modular contexts on the Nuxt Vue app, either via a Nuxt module or the `installModularApp` helper in your own plugin. See [Framework-mode (Nuxt 3)](docs/framework-mode-nuxt.md). +- `@angular-router-modules/core` + `@modular-angular/angular`: an Angular Router family, **v0.1.x, experimental / early**. Core-only so far — `defineModule`, shared injectors, and scoped stores that track the React and Vue core packages case-for-case; there is no runtime, CLI, or journeys binding yet, so it is not a peer of the router integrations above. Part of the [Angular support initiative](docs/angular-support-tracker.md). - `@modular-frontend/*` (`core`, `testing`, `journeys-engine`, `compositions-engine`): the framework-neutral shared engine and core the React and Vue families both build on. `journeys-engine` carries the **1.x** version of the package it was extracted from; `core`, `testing`, and `compositions-engine` are **0.1.x**. The binding families peer-depend on these with tight (`^0.1.0`-style) ranges, so any `@modular-frontend/*` bump ships with coordinated peer-range bumps and releases of every dependent binding package in the same batch — see the [versioning policy](docs/vue-support-tracker.md#versioning-and-release). The React families target **React 19**; the Vue family targets **Vue ^3.5** and **vue-router ^4.5**. All target **Node 22+**. The docs and CLI scaffolder assume **pnpm workspaces**, but nothing in the runtime or CLI is pnpm-specific; any local package resolution that understands the `workspace:*` protocol (Yarn Berry, Bun) will work after scaffolding with a few script edits. See each getting-started guide for the full pinned version set. @@ -293,6 +294,13 @@ See [`examples/README.md`](examples/README.md) for how to run them and how to ad | [`@modular-vue/compositions`](packages/vue-compositions) | Vue composition provider, panel/host composables, `` (scoped-slot), registry plugin. | | [`@modular-vue/nuxt`](packages/vue-nuxt) | Nuxt 3 integration (experimental, `0.1.x`): a Nuxt module plus the `installModularApp` runtime installer that grafts module routes onto Nuxt's vue-router and installs the modular contexts. | +### Angular (experimental) + +| Package | Description | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`@angular-router-modules/core`](packages/angular-router-core) | Angular Router module definition (`defineModule`) and shared types — the Angular analog of the React/Vue `core` packages. Experimental, `0.1.x`; core-only (no runtime/CLI yet). | +| [`@modular-angular/angular`](packages/angular) | Angular runtime primitives the core builds on: shared injectors and scoped stores. Experimental, `0.1.x`. | + ### Framework-neutral engine (shared by React and Vue) | Package | Description | diff --git a/docs/getting-started-react-router.md b/docs/getting-started-react-router.md index 2673d3ff..2e4d5d3a 100644 --- a/docs/getting-started-react-router.md +++ b/docs/getting-started-react-router.md @@ -222,7 +222,7 @@ The CLI: The generated definition has TODO markers for the `start` step and the per-module `transitions` map. Fill those in by declaring `entryPoints` / `exitPoints` on each composed module (`defineEntry` / `defineExit` from `@modular-react/core`) and wiring the exit branches to the next step. -See [`@modular-react/journeys`](../packages/journeys/README.md) for the full mental model, the `JourneyOutlet`/`ModuleTab` rendering surfaces, and the runtime hooks. Two end-to-end examples: +See [`@modular-react/journeys`](../packages/journeys/README.md) for the full mental model, the `JourneyOutlet`/`ModuleTab` rendering surfaces, and the runtime hooks — including `` / `useJourneyHost` (mount, run, and clean up a journey in one place), `useJourneyProgress` (derive "Step X of N" and the current step's label from the transition graph, no hand-maintained step array), and `useJourneySync` (bind the active step to the URL so Back/Forward drive the journey). Two end-to-end examples: - [`examples/react-router/customer-onboarding-journey/`](../examples/react-router/customer-onboarding-journey) — three-module branching flow with reload-safe persistence. - [`examples/react-router/integration-setup-journey/`](../examples/react-router/integration-setup-journey) — slot-driven chooser feeding `selectModuleOrDefault` dispatch (some kinds get dedicated modules, the rest funnel through a generic fallback). Useful when the next module depends on a value chosen earlier in the flow. diff --git a/docs/getting-started-tanstack-router.md b/docs/getting-started-tanstack-router.md index 0ea19a2b..9bbc8c2f 100644 --- a/docs/getting-started-tanstack-router.md +++ b/docs/getting-started-tanstack-router.md @@ -252,7 +252,7 @@ The CLI: The generated definition has TODO markers for the `start` step and the per-module `transitions` map. Fill those in by declaring `entryPoints` / `exitPoints` on each composed module (`defineEntry` / `defineExit` from `@modular-react/core`) and wiring the exit branches to the next step. -See [`@modular-react/journeys`](../packages/journeys/README.md) for the full mental model, the `JourneyOutlet`/`ModuleTab` rendering surfaces, and the runtime hooks. Two end-to-end examples: +See [`@modular-react/journeys`](../packages/journeys/README.md) for the full mental model, the `JourneyOutlet`/`ModuleTab` rendering surfaces, and the runtime hooks — including `` / `useJourneyHost` (mount, run, and clean up a journey in one place), `useJourneyProgress` (derive "Step X of N" and the current step's label from the transition graph, no hand-maintained step array), and `useJourneySync` (bind the active step to the URL so Back/Forward drive the journey). Two end-to-end examples: - [`examples/tanstack-router/customer-onboarding-journey/`](../examples/tanstack-router/customer-onboarding-journey) — three-module branching flow with reload-safe persistence. - [`examples/tanstack-router/integration-setup-journey/`](../examples/tanstack-router/integration-setup-journey) — slot-driven chooser feeding `selectModuleOrDefault` dispatch (some kinds get dedicated modules, the rest funnel through a generic fallback). Useful when the next module depends on a value chosen earlier in the flow. diff --git a/docs/getting-started-vue-router.md b/docs/getting-started-vue-router.md index 91cedb55..e1b71e4f 100644 --- a/docs/getting-started-vue-router.md +++ b/docs/getting-started-vue-router.md @@ -597,7 +597,10 @@ per-route and role-based variants. - [Journeys](../packages/journeys/README.md) and [Compositions](../packages/compositions/README.md) — compose several modules into a typed workflow or a shared multi-zone screen (both have Vue bindings: - `@modular-vue/journeys`, `@modular-vue/compositions`). + `@modular-vue/journeys`, `@modular-vue/compositions`). The Vue journey + composables mirror the React hooks — `useJourneyHost` to mount and run a + journey, `useJourneyProgress` for "Step X of N" and the current step's label, + and `useJourneySync` to bind the active step to the URL. - Working examples: [`examples/vue/`](../examples/vue) ships three runnable apps — `integration-manager` (sibling modules sharing a screen), `customer-onboarding-journey` (a persisted journey), and `editor-composition` From 98a53c37eaecea71994a4bfe547af5e4816c1be7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:30:25 +0000 Subject: [PATCH 8/9] fix(journeys): address review feedback on step-sequence + progress Four correctness/typing fixes from PR review: - resolveStepSequence: type `start`/`branch`/targets against the journey's real (module, entry) vocabulary via a `TModules`-generic `StepSequenceRef`, so a typo'd explicit start is a compile error instead of a fake step. - resolveStepSequenceResult: new companion returning `{ steps, complete }`, where `complete` is true only when the walk reaches a genuine end of the flow (not a partial spine cut short by a fork, bare/wildcard step, invoke, cycle, or maxSteps cap). - useJourneyProgress (React + Vue): derive `total` from `complete` so a partial spine yields `null` rather than a misleading "Step 2 of 1"; derive `index` from the resolved-sequence position of the live step so it stays correct under a maxHistory cap that trims `history` (fallback to history.length only when the step is off the spine). - stepPathFromDefinition: new opt-in helper building a `stepToPath` from a definition's `steps[module][entry].path`, so a declared JourneyStepMeta.path can actually drive the URL. Doc on `path` corrected to drop the false "automatic override" claim. Adds unit + type-level regression tests; updates CHANGELOG and READMEs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E8c6U5oHPaC9wWb6FFJxgt --- CHANGELOG.md | 6 +- .../frontend-core/src/journey-contracts.ts | 21 +- packages/journeys-engine/src/index.ts | 4 +- .../journeys-engine/src/journey-sync.test.ts | 56 +++++ packages/journeys-engine/src/journey-sync.ts | 32 +++ .../src/resolve-step-sequence.test-d.ts | 25 +++ .../src/resolve-step-sequence.test.ts | 84 +++++++- .../src/resolve-step-sequence.ts | 200 ++++++++++++++---- packages/journeys/README.md | 1 + packages/journeys/src/index.ts | 1 + .../src/use-journey-progress.test.tsx | 40 +++- packages/journeys/src/use-journey-progress.ts | 83 +++++--- packages/vue-journeys/src/index.ts | 1 + .../vue-journeys/src/use-journey-progress.ts | 68 ++++-- 14 files changed, 511 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eaf6d57..df675024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,9 +83,9 @@ Closes item 3 of `docs/consumer-feedback-production-app.md`: the production cons Closes item 4 of `docs/consumer-feedback-production-app.md`: the consumer encoded each journey's flow twice — once as the transition-map graph, and again as a ~170-line hand-maintained file of ordered step arrays (in three branch-variant copies) for URL segments and "Step X of N", kept in sync only by discipline. The ordering and progress primitives now live in the library, derived from the one place the flow is already encoded. -- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. Seeding is **required** — not optional — for a journey whose `initialState` consumes a non-void input: the options type demands `input` or `start` there, so `resolveStepSequence(definition)` is a compile error instead of silently calling `initialState(undefined)` (void-input journeys still take no options). It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires handlers annotated with `defineTransition`**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceWalkOptions`, `StepSequenceOptionsArg`, `StepSequenceRef`. -- **`@modular-frontend/core`** — `JourneyDefinition.steps?: JourneyStepMetaMap`. Per-step presentation metadata keyed by `[moduleId][entry]` exactly like `transitions` (entry keys filtered to journey-mountable entries, so a typo is a compile error), each leaf a `JourneyStepMeta` (`{ path?, progressLabel? }`). `path` overrides the URL sync's default `"moduleId/entry"` segment; `progressLabel` feeds progress UIs. One source of truth beside the transitions, not re-encoded at each `next:`. New public types: `JourneyStepMeta`, `JourneyStepMetaMap`. -- **`@modular-react/journeys`**, **`@modular-vue/journeys`** — `useJourneyProgress(instanceId, definition, options?)`. Returns `{ index, total, label, steps }`: `index` from the live instance (`history.length`, so it rewinds when the journey does), `total` / `label` / `steps` from `resolveStepSequence`. This is the `stepCount` that the journey-hosting work (item 2) deferred with "deriving it from the graph is tracked separately" — now derivable because the total comes from the graph rather than a hand-passed number. `total` is `null` when the sequence can't be walked (unannotated transitions). The React hook returns a plain object; the Vue composable returns `ComputedRef`s. `options.sequence` forwards to `resolveStepSequence` (chiefly `branch`, to make the total reflect a chosen path) and, mirroring it, is optional for void-input journeys but required (carrying `input` / `start`) when the definition needs a non-void input. `resolveStepSequence` and its types are re-exported from both bindings. +- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. Seeding is **required** — not optional — for a journey whose `initialState` consumes a non-void input: the options type demands `input` or `start` there, so `resolveStepSequence(definition)` is a compile error instead of silently calling `initialState(undefined)` (void-input journeys still take no options). It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires handlers annotated with `defineTransition`**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). `options.start` and `options.branch` are checked against the journey's real `(module, entry)` vocabulary — a typo'd explicit start is a compile error, not a fake step the walk emits. A companion `resolveStepSequenceResult(...)` returns `{ steps, complete }`, where `complete` reports whether the walk reached a genuine end of the flow (vs. a partial spine cut short by a fork, a bare/wildcard step, an invoke, a cycle, or the cap). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceWalkOptions`, `StepSequenceOptionsArg`, `StepSequenceRef` (now generic over `TModules`), `StepSequenceResult`. +- **`@modular-frontend/core`** — `JourneyDefinition.steps?: JourneyStepMetaMap`. Per-step presentation metadata keyed by `[moduleId][entry]` exactly like `transitions` (entry keys filtered to journey-mountable entries, so a typo is a compile error), each leaf a `JourneyStepMeta` (`{ path?, progressLabel? }`). `progressLabel` feeds progress UIs. `path` is an alternative to the URL sync's default `"moduleId/entry"` segment — but the sync is definition-neutral (it never sees this metadata on its own), so a declared `path` drives the URL only when an adapter builds its `stepToPath` from the definition via the new `stepPathFromDefinition(def)` helper (`@modular-frontend/journeys-engine`, re-exported from both bindings). One source of truth beside the transitions, not re-encoded at each `next:`. New public types: `JourneyStepMeta`, `JourneyStepMetaMap`. +- **`@modular-react/journeys`**, **`@modular-vue/journeys`** — `useJourneyProgress(instanceId, definition, options?)`. Returns `{ index, total, label, steps }`: `index` is the live step's position **in the resolved sequence** (so it stays correct under a `maxHistory` cap that trims `history`; it falls back to `history.length` only when the live step is off the resolved spine), and `total` / `label` / `steps` come from `resolveStepSequence`. This is the `stepCount` that the journey-hosting work (item 2) deferred with "deriving it from the graph is tracked separately" — now derivable because the total comes from the graph rather than a hand-passed number. `total` is a number **only when the walk reached a genuine end of the flow**; it is `null` for a partial sequence (an unresolved fork, a bare/wildcard step, an `invoke` hand-off, a cycle, or the `maxSteps` cap), so a partial length is never rendered as a confident total (`steps` still carries the known steps for a breadcrumb). The React hook returns a plain object; the Vue composable returns `ComputedRef`s. `options.sequence` forwards to `resolveStepSequence` (chiefly `branch`, to make the total reflect a chosen path) and, mirroring it, is optional for void-input journeys but required (carrying `input` / `start`) when the definition needs a non-void input. `resolveStepSequence` and its types are re-exported from both bindings. ### Added — journey runtime additions (EXP-1848 adoption follow-up) diff --git a/packages/frontend-core/src/journey-contracts.ts b/packages/frontend-core/src/journey-contracts.ts index 00b7d2e1..7fd42105 100644 --- a/packages/frontend-core/src/journey-contracts.ts +++ b/packages/frontend-core/src/journey-contracts.ts @@ -211,9 +211,9 @@ export type StepSpec = * Declarative per-step presentation metadata a journey attaches to a * `(module, entry)` pair via {@link JourneyStepMetaMap}. Purely descriptive — * the runtime never reads it to make transition decisions. Consumed by - * `resolveStepSequence` (to label a derived step list), a progress hook - * (`useJourneyProgress`), and the journey ↔ URL sync (a per-step `path` - * overrides the default `"moduleId/entry"` segment). + * `resolveStepSequence` (to label a derived step list) and a progress hook + * (`useJourneyProgress`); the `path` field additionally feeds the journey ↔ URL + * sync when an adapter opts in (see `path` below). * * Keeping it on the journey definition — rather than duplicated into every * transition's returned `StepSpec` — is deliberate: the flow's ordering already @@ -222,9 +222,18 @@ export type StepSpec = */ export interface JourneyStepMeta { /** - * URL segment for this step. Overrides the sync's default - * `"moduleId/entry"` path. Should be unique within the journey — two steps - * that share a path are indistinguishable to the URL reconciler. + * URL segment for this step, as an alternative to the sync's default + * `"moduleId/entry"` path. + * + * The journey ↔ URL sync is definition-neutral — it never sees this metadata + * on its own — so declaring a `path` has no effect until the adapter builds + * its `stepToPath` from the definition. Pass `stepPathFromDefinition(def)` + * (from `@modular-frontend/journeys-engine`) as the sync's `stepToPath` to + * make declared paths drive the URL; steps without one fall back to + * `"moduleId/entry"`. + * + * Should be unique within the journey — two steps that share a path are + * indistinguishable to the URL reconciler. */ readonly path?: string; /** diff --git a/packages/journeys-engine/src/index.ts b/packages/journeys-engine/src/index.ts index 32cda23b..b51edd76 100644 --- a/packages/journeys-engine/src/index.ts +++ b/packages/journeys-engine/src/index.ts @@ -41,6 +41,7 @@ export { defaultStepPath, journeyStepPath, resolveJourneySyncAction, + stepPathFromDefinition, } from "./journey-sync.js"; export type { JourneySync, @@ -65,12 +66,13 @@ export type { AnnotatedTransitionHandler, StepRef, TerminalSentinel } from "./de // Derive an ordered step list (URL segments, "Step X of N") from the transition // graph — the runtime companion to the catalog harvester's static extraction. -export { resolveStepSequence } from "./resolve-step-sequence.js"; +export { resolveStepSequence, resolveStepSequenceResult } from "./resolve-step-sequence.js"; export type { ResolvedJourneyStep, ResolveStepSequenceOptions, StepSequenceOptionsArg, StepSequenceRef, + StepSequenceResult, StepSequenceWalkOptions, } from "./resolve-step-sequence.js"; diff --git a/packages/journeys-engine/src/journey-sync.test.ts b/packages/journeys-engine/src/journey-sync.test.ts index 653121cc..e1de9f33 100644 --- a/packages/journeys-engine/src/journey-sync.test.ts +++ b/packages/journeys-engine/src/journey-sync.test.ts @@ -8,6 +8,7 @@ import { defaultStepPath, journeyStepPath, resolveJourneySyncAction, + stepPathFromDefinition, } from "./journey-sync.js"; import type { JourneySyncPort } from "./journey-sync.js"; import { createJourneyRuntime } from "./runtime.js"; @@ -120,12 +121,67 @@ function setup(definition = linear) { return { runtime, id, harness, instance }; } +/** A journey whose start step (`a/show`) declares a custom URL `path`. */ +function setupWithPaths() { + const def = defineJourney>()({ + id: "with-paths", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "show", input: undefined }), + steps: { a: { show: { path: "welcome" } } }, + transitions: { + a: { show: { next: () => ({ next: { module: "b", entry: "show", input: undefined } }) } }, + b: { show: { next: () => ({ complete: undefined }) } }, + }, + }); + const runtime = createJourneyRuntime([{ definition: def, options: undefined }], { modules }); + const id = runtime.start(def.id, undefined); + return { runtime, id, def }; +} + describe("defaultStepPath", () => { it("renders a step as `moduleId/entry`", () => { expect(defaultStepPath({ moduleId: "a", entry: "show", input: undefined })).toBe("a/show"); }); }); +describe("stepPathFromDefinition", () => { + const definition = { + steps: { + profile: { review: { path: "welcome" } }, + plan: { choose: { progressLabel: "Pick a plan" } }, // no path + }, + }; + + it("maps a step to its declared `path`", () => { + const toPath = stepPathFromDefinition(definition); + expect(toPath({ moduleId: "profile", entry: "review", input: undefined })).toBe("welcome"); + }); + + it("falls back to `moduleId/entry` for a step with no declared path", () => { + const toPath = stepPathFromDefinition(definition); + expect(toPath({ moduleId: "plan", entry: "choose", input: undefined })).toBe("plan/choose"); + expect(toPath({ moduleId: "other", entry: "x", input: undefined })).toBe("other/x"); + }); + + it("honours a custom fallback", () => { + const toPath = stepPathFromDefinition(definition, (step) => step.entry); + expect(toPath({ moduleId: "plan", entry: "choose", input: undefined })).toBe("choose"); + }); + + it("drives the URL when passed as the sync's stepToPath", () => { + const port = createMemoryJourneySyncPort("/checkout"); + const { runtime, id, def } = setupWithPaths(); + const sync = createJourneySync(runtime, id, port, { + stepToPath: stepPathFromDefinition(def), + }); + // The start step declares `path: "welcome"`, so the URL reflects it rather + // than the default `profile/review`. + expect(port.read()).toBe("welcome"); + sync.stop(); + }); +}); + describe("journeyStepPath", () => { it("returns the current step's path for an active journey", () => { const { instance } = setup(); diff --git a/packages/journeys-engine/src/journey-sync.ts b/packages/journeys-engine/src/journey-sync.ts index 7a09fced..30f8bb03 100644 --- a/packages/journeys-engine/src/journey-sync.ts +++ b/packages/journeys-engine/src/journey-sync.ts @@ -179,6 +179,38 @@ export function defaultStepPath(step: JourneyStep): string { return `${step.moduleId}/${step.entry}`; } +/** + * Build a {@link JourneySyncOptions.stepToPath} from a journey definition's + * per-step `steps[module][entry].path` metadata — the bridge that makes a + * declared `JourneyStepMeta.path` actually drive the URL. + * + * The sync is definition-neutral: it only ever sees `{ moduleId, entry, input }` + * steps and never the journey definition, so a `path` declared on the + * definition does nothing on its own. An adapter opts in by passing this as its + * `stepToPath`: + * + * ```ts + * createJourneySync(runtime, id, port, { + * stepToPath: stepPathFromDefinition(checkoutDef), + * }); + * ``` + * + * Steps without a declared `path` fall through to `fallback` (default + * {@link defaultStepPath}, `"moduleId/entry"`). The same injectivity caveat + * applies as for any `stepToPath` (see {@link resolveJourneySyncAction}): two + * steps mapped to the same segment are indistinguishable to the reconciler, so + * keep declared paths unique within a journey. + */ +export function stepPathFromDefinition( + definition: { readonly steps?: unknown }, + fallback: (step: JourneyStep) => string = defaultStepPath, +): (step: JourneyStep) => string { + const steps = definition.steps as + | Record | undefined> + | undefined; + return (step) => steps?.[step.moduleId]?.[step.entry]?.path ?? fallback(step); +} + /** * The location a journey's current step should be at, or `null` when the * journey has no step to represent — it is `loading` (async persistence has diff --git a/packages/journeys-engine/src/resolve-step-sequence.test-d.ts b/packages/journeys-engine/src/resolve-step-sequence.test-d.ts index 29706826..f5e8c085 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.test-d.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.test-d.ts @@ -126,3 +126,28 @@ test("resolveStepSequence: non-void-input journey rejects a wrong-typed `input`" // @ts-expect-error — `input` must match the journey's `TInput`. resolveStepSequence(nonVoidInput, { input: { token: 1 } }); }); + +// --- `start` / `branch` refs are checked against the module vocabulary ------- +// An explicit `start` (and a `branch` return) must name a real `(module, entry)` +// pair — a typo should be a compile error, not a fake step the walk emits. + +test("resolveStepSequence: `start` accepts a declared (module, entry) pair", () => { + resolveStepSequence(voidInput, { start: { module: "plan", entry: "choose" } }); +}); + +test("resolveStepSequence: `start` rejects an unknown module id", () => { + // @ts-expect-error — "typo" is not a module in `Modules`. + resolveStepSequence(voidInput, { start: { module: "typo", entry: "choose" } }); +}); + +test("resolveStepSequence: `start` rejects an unknown entry name on a real module", () => { + // @ts-expect-error — `plan` declares `choose`, not `missing`. + resolveStepSequence(voidInput, { start: { module: "plan", entry: "missing" } }); +}); + +test("resolveStepSequence: `branch` must return one of the journey's steps", () => { + resolveStepSequence(voidInput, { + // @ts-expect-error — a fabricated (module, entry) is not a valid branch pick. + branch: () => ({ module: "typo", entry: "missing" }), + }); +}); diff --git a/packages/journeys-engine/src/resolve-step-sequence.test.ts b/packages/journeys-engine/src/resolve-step-sequence.test.ts index 0e932d3d..ce5a0fe3 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.test.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; import { defineJourney } from "./define-journey.js"; import { defineTransition } from "./define-transition.js"; -import { resolveStepSequence } from "./resolve-step-sequence.js"; +import { resolveStepSequence, resolveStepSequenceResult } from "./resolve-step-sequence.js"; // --- Modules ----------------------------------------------------------------- @@ -184,7 +184,9 @@ describe("resolveStepSequence — branching flow", () => { it("stops the sequence when the resolver returns a ref that isn't a declared target", () => { const seq = resolveStepSequence(branching, { - branch: () => ({ module: "billing", entry: "not-a-target" }), + // A real journey step, but not one of this fork's targets (billing/collect + // or plan/upsell) — so it is rejected rather than followed. + branch: () => ({ module: "profile", entry: "review" }), }); // The returned ref is matched back against the fork's `targets` by // module + entry; no match means the walk stops rather than following it. @@ -244,3 +246,81 @@ describe("resolveStepSequence — edge cases", () => { expect(resolveStepSequence(linear, { maxSteps: 2 }).length).toBe(2); }); }); + +// --- resolveStepSequenceResult: `complete` (partial vs. full spine) ---------- + +describe("resolveStepSequenceResult — completeness", () => { + it("is complete when the walk reaches a genuine terminal step", () => { + const result = resolveStepSequenceResult(linear); + expect(result.complete).toBe(true); + expect(result.steps.map((s) => `${s.module}/${s.entry}`)).toEqual([ + "profile/review", + "plan/choose", + "billing/collect", + ]); + }); + + it("is incomplete at an unresolved fork (no branch resolver)", () => { + expect(resolveStepSequenceResult(branching).complete).toBe(false); + }); + + it("is complete again once a branch resolver linearizes the fork to a terminal", () => { + const result = resolveStepSequenceResult(branching, { + branch: ({ targets }) => targets.find((t) => t.module === "billing"), + }); + expect(result.complete).toBe(true); + }); + + it("is incomplete when the branch resolver stops the walk", () => { + expect(resolveStepSequenceResult(branching, { branch: () => undefined }).complete).toBe(false); + }); + + it("is incomplete at a bare (unannotated) handler — the next step is unknown", () => { + const bare = defineJourney()({ + id: "bare-complete", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "profile", entry: "review", input: { customerId: "c1" } }), + transitions: { + profile: { + review: { + done: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }, + }, + }, + }); + const result = resolveStepSequenceResult(bare); + expect(result.complete).toBe(false); + expect(result.steps.map((s) => s.module)).toEqual(["profile"]); + }); + + it("is incomplete when the walk breaks a cycle", () => { + const cyclic = defineJourney()({ + id: "cyclic-complete", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + transitions: { + plan: { + choose: { + chosen: transition({ + targets: [{ module: "plan", entry: "upsell" }], + handle: () => ({ next: { module: "plan", entry: "upsell", input: { x: 1 } } }), + }), + }, + upsell: { + chosen: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }), + }, + }, + }, + }); + expect(resolveStepSequenceResult(cyclic).complete).toBe(false); + }); + + it("is incomplete when cut short by maxSteps", () => { + expect(resolveStepSequenceResult(linear, { maxSteps: 2 }).complete).toBe(false); + }); +}); diff --git a/packages/journeys-engine/src/resolve-step-sequence.ts b/packages/journeys-engine/src/resolve-step-sequence.ts index bdec7478..5aac1d8a 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.ts @@ -1,4 +1,8 @@ -import type { JourneyStepMeta, ModuleTypeMap } from "@modular-frontend/core"; +import type { + EntryNamesByMountKindOf, + JourneyStepMeta, + ModuleTypeMap, +} from "@modular-frontend/core"; import type { JourneyDefinition } from "./types.js"; import { isAnnotatedTransition, isTerminalSentinel } from "./define-transition.js"; @@ -16,11 +20,36 @@ export interface ResolvedJourneyStep { readonly progressLabel?: string; } -/** A bare `(module, entry)` reference — a candidate forward step. */ -export interface StepSequenceRef { - readonly module: string; - readonly entry: string; -} +/** + * Detect `any` at the type level so the generic-erased path (a definition typed + * as `JourneyDefinition`, the registry) keeps a loose + * `{ module: string; entry: string }` ref instead of distributing the mapped + * type over `any`. Mirrors the same gate `StepSpec` uses in core. + */ +type IsAny = 0 extends 1 & T ? true : false; + +/** + * A `(module, entry)` reference into a journey's own module map — a candidate + * forward step, an explicit `start`, or a `branch` pick. + * + * Narrowed to the journey's real modules and their journey-mountable entries — + * exactly the vocabulary a transition `target` is checked against — so a typo + * in an explicit `start` (`{ module: "typo", entry: "missing" }`) or a `branch` + * return is a **compile error** rather than a fake step the walk would happily + * emit. Falls back to the loose string shape on the generic-erased path (where + * `TModules` is `any`). + */ +export type StepSequenceRef = + IsAny extends true + ? { readonly module: string; readonly entry: string } + : { + [M in keyof TModules & string]: { + [E in EntryNamesByMountKindOf & string]: { + readonly module: M; + readonly entry: E; + }; + }[EntryNamesByMountKindOf & string]; + }[keyof TModules & string]; /** * Walk-tuning options that never depend on the journey's input type — always @@ -28,15 +57,18 @@ export interface StepSequenceRef { * {@link ResolveStepSequenceOptions}, which requires it (or `start`) for a * non-void-input journey. */ -export interface StepSequenceWalkOptions { +export interface StepSequenceWalkOptions { /** * Explicit first step. Skips calling `initialState` / `start` — useful when * the start step is dynamic on input in a way that does not affect the * sequence, or when resolving a sub-sequence from a mid-flow step. Supplying * this satisfies the start requirement for a non-void-input journey, since * the input-consuming factories are never called. + * + * Checked against the journey's real `(module, entry)` vocabulary — a step + * the journey does not declare is a compile error. */ - readonly start?: StepSequenceRef; + readonly start?: StepSequenceRef; /** * Fork resolver. When a step's transitions declare more than one distinct * forward `(module, entry)` target, the walk cannot linearize on its own — @@ -48,8 +80,8 @@ export interface StepSequenceWalkOptions { readonly branch?: (ctx: { readonly module: string; readonly entry: string; - readonly targets: readonly StepSequenceRef[]; - }) => StepSequenceRef | undefined; + readonly targets: readonly StepSequenceRef[]; + }) => StepSequenceRef | undefined; /** * Hard cap on sequence length — a backstop against a pathological graph. * Default 256. The walk also stops on its own when it revisits a step @@ -69,7 +101,10 @@ export interface StepSequenceWalkOptions { * factories). This makes `resolveStepSequence(def)` a compile error exactly * when omitting `input` would otherwise call `initialState(undefined)`. */ -export type ResolveStepSequenceOptions = StepSequenceWalkOptions & +export type ResolveStepSequenceOptions< + TInput = unknown, + TModules extends ModuleTypeMap = ModuleTypeMap, +> = StepSequenceWalkOptions & ([TInput] extends [void] ? { /** Input handed to the factories. Optional for void-input journeys. */ @@ -82,7 +117,7 @@ export type ResolveStepSequenceOptions = StepSequenceWalkOptio } | { /** Explicit first step — skips (and so does not need) `input`. */ - readonly start: StepSequenceRef; + readonly start: StepSequenceRef; }); /** @@ -92,9 +127,30 @@ export type ResolveStepSequenceOptions = StepSequenceWalkOptio * would call `initialState(undefined)`. Consumers that forward options to * `resolveStepSequence` (e.g. the `useJourneyProgress` hooks) reuse this tuple. */ -export type StepSequenceOptionsArg = [TInput] extends [void] - ? [options?: ResolveStepSequenceOptions] - : [options: ResolveStepSequenceOptions]; +export type StepSequenceOptionsArg = [ + TInput, +] extends [void] + ? [options?: ResolveStepSequenceOptions] + : [options: ResolveStepSequenceOptions]; + +/** + * Result of walking a journey's transition graph — the ordered step list plus + * whether the walk reached a genuine end of the flow. + * + * `complete` is the fact a "Step X of N" total needs but a bare `steps.length` + * cannot supply: it is `true` only when the walk terminated at a step that can + * *only* end the journey (every transition leads to `complete` / `abort`), and + * `false` when the walk was cut short — an unresolved fork (no/rejecting + * `branch`), a bare (unannotated) or wildcard-only step, a `"invoke"` that + * hands off to a child, a cycle, or the `maxSteps` cap. When `complete` is + * `false`, `steps.length` is only a **lower bound** on the real step count, so + * a progress UI must not present it as a confident total (see + * `useJourneyProgress`, which surfaces `total: null` in that case). + */ +export interface StepSequenceResult { + readonly steps: readonly ResolvedJourneyStep[]; + readonly complete: boolean; +} const DEFAULT_MAX_STEPS = 256; @@ -110,13 +166,14 @@ const DEFAULT_MAX_STEPS = 256; * transitions), deleting the hand-maintained ordered-step arrays that item 4 * of the production-feedback tracker flagged as duplicated, drift-prone glue. * - * **Requires annotated transitions.** The walk reads each handler's `targets` - * (stamped by `defineTransition`). A step whose transitions are all *bare* - * function handlers has no statically-known forward target, so the sequence - * stops there. Terminal sentinels (`"complete"` / `"abort"` / `"invoke"`) - * carry no next step and are skipped. Only the per-step `transitions` map is - * walked — `wildcard` fall-through handlers are not followed, so a step whose - * only forward movement is a wildcard also ends the sequence. + * **Requires handlers annotated with `defineTransition`.** The walk reads each + * handler's `targets` (stamped by `defineTransition`). A step whose transitions + * are all *bare* function handlers has no statically-known forward target, so + * the sequence stops there. Terminal sentinels (`"complete"` / `"abort"` / + * `"invoke"`) carry no next step and are skipped. Only the per-step + * `transitions` map is walked — `wildcard` fall-through handlers are not + * followed, so a step whose only forward movement is a wildcard also ends the + * sequence. * * Unless `options.start` is supplied, the first step is computed by invoking * `definition.initialState(options.input)` then `definition.start(...)`; these @@ -126,6 +183,10 @@ const DEFAULT_MAX_STEPS = 256; * Each returned step carries any `path` / `progressLabel` declared under * `definition.steps[module][entry]`. * + * Returns just the step array. When you also need to know whether the walk + * reached a genuine end of the flow (so a partial spine is not mistaken for the + * true total), call {@link resolveStepSequenceResult}. + * * @example * ```ts * const steps = resolveStepSequence(checkout); @@ -141,19 +202,46 @@ export function resolveStepSequence< TMeta extends { [K in keyof TMeta]: unknown }, >( definition: JourneyDefinition, - ...[options]: StepSequenceOptionsArg + ...args: StepSequenceOptionsArg ): readonly ResolvedJourneyStep[] { - // Read against the erased shape: `input` sits on a conditional branch of - // `ResolveStepSequenceOptions` that TS can't index while `TInput` is - // still generic, so normalize to a flat readable view (the type guarantees a - // non-void journey supplied `input` or `start` before we get here). - const opts = (options ?? {}) as StepSequenceWalkOptions & { readonly input?: TInput }; + return walkStepSequence(definition, args[0]).steps; +} + +/** + * {@link resolveStepSequence} that also reports whether the walk reached a + * genuine terminal step (see {@link StepSequenceResult.complete}). Use this + * when a caller must distinguish a full spine from one cut short by a fork, + * a bare/wildcard step, an invoke, a cycle, or the `maxSteps` cap — the + * progress hooks use it so a partial length is never rendered as a confident + * "Step X of N" total. + */ +export function resolveStepSequenceResult< + TModules extends ModuleTypeMap, + TState, + TInput, + TOutput, + TMeta extends { [K in keyof TMeta]: unknown }, +>( + definition: JourneyDefinition, + ...args: StepSequenceOptionsArg +): StepSequenceResult { + return walkStepSequence(definition, args[0]); +} + +// The public generics guarantee `input`/`start` at the boundary; the walk reads +// the erased shape (module ids / entry names are strings on the wire), so it +// takes the options as `unknown` and normalizes to a flat readable view — +// forwarding the `TModules`-typed `branch` callback through a typed parameter +// would trip callback-variance checks for no benefit here. +function walkStepSequence(definition: AnyDefinition, options: unknown): StepSequenceResult { + const opts = (options ?? {}) as StepSequenceWalkOptions & { readonly input?: unknown }; const maxSteps = normalizeMaxSteps(opts.maxSteps); let current: StepSequenceRef | undefined = opts.start ?? deriveStart(definition, opts.input); const sequence: ResolvedJourneyStep[] = []; const visited = new Set(); + let complete = false; while (current && sequence.length < maxSteps) { const key = stepKey(current.module, current.entry); @@ -168,15 +256,22 @@ export function resolveStepSequence< ...(meta?.progressLabel !== undefined ? { progressLabel: meta.progressLabel } : {}), }); - const targets = forwardTargets(definition, current.module, current.entry); - if (targets.length === 0) break; + const { targets, terminal } = classifyStep(definition, current.module, current.entry); + if (targets.length === 0) { + // No forward target. Either this step can only end the journey (a genuine + // terminal — the spine is complete) or the walk simply cannot see past it + // (bare/wildcard handler, invoke hand-off) — a partial spine. + complete = terminal; + break; + } if (targets.length === 1) { current = targets[0]; } else { // Fork — the resolver picks. Its return is matched back against `targets` // by `module` + `entry` (identity not required), so a `undefined` return - // or a ref that isn't one of the declared targets stops the sequence here. + // or a ref that isn't one of the declared targets stops the sequence here + // (an unresolved fork — the spine is partial). const picked: StepSequenceRef | undefined = opts.branch?.({ module: current.module, entry: current.entry, @@ -188,7 +283,7 @@ export function resolveStepSequence< } } - return sequence; + return { steps: sequence, complete }; } function normalizeMaxSteps(value: number | undefined): number { @@ -225,37 +320,58 @@ function readStepMeta( } /** - * Distinct forward `(module, entry)` targets declared by any annotated exit - * handler on `transitions[module][entry]`. Bare handlers and terminal - * sentinels contribute nothing. Order follows first-declaration order across - * exits, deduped. + * Classify a step for the walk: + * + * - `targets` — the distinct forward `(module, entry)` targets declared by any + * annotated exit handler on `transitions[module][entry]`. Bare handlers and + * terminal sentinels contribute nothing. Order follows first-declaration + * order across exits, deduped. + * - `terminal` — `true` only when this step can *exclusively* end the journey: + * it has at least one annotated transition, no forward step ref, no bare + * handler, no `"invoke"` sentinel, and at least one `"complete"` / `"abort"` + * sentinel. A step that stops the walk for any other reason (bare handler, + * wildcard-only, invoke hand-off, or simply no declared transitions) is + * `terminal: false` — the walk cannot prove it is the flow's real end. */ -function forwardTargets( +function classifyStep( definition: AnyDefinition, module: string, entry: string, -): readonly StepSequenceRef[] { +): { readonly targets: readonly StepSequenceRef[]; readonly terminal: boolean } { const transitions = definition.transitions as | Record | undefined> | undefined> | undefined; const perEntry = transitions?.[module]?.[entry]; - if (!perEntry) return []; + if (!perEntry) return { targets: [], terminal: false }; const refs: StepSequenceRef[] = []; const seen = new Set(); + let hasAnnotated = false; + let hasBare = false; + let hasInvoke = false; + let hasEndSentinel = false; // "complete" | "abort" for (const [exitName, handler] of Object.entries(perEntry)) { // `allowBack` is a sibling boolean flag on the per-entry map, not a handler. if (exitName === "allowBack") continue; - if (!isAnnotatedTransition(handler)) continue; + if (!isAnnotatedTransition(handler)) { + hasBare = true; + continue; + } + hasAnnotated = true; for (const target of handler.targets) { - if (isTerminalSentinel(target)) continue; + if (isTerminalSentinel(target)) { + if (target === "invoke") hasInvoke = true; + else hasEndSentinel = true; + continue; + } const key = stepKey(target.module, target.entry); if (seen.has(key)) continue; seen.add(key); refs.push({ module: target.module, entry: target.entry }); } } - return refs; + const terminal = refs.length === 0 && hasAnnotated && !hasBare && !hasInvoke && hasEndSentinel; + return { targets: refs, terminal }; } /** diff --git a/packages/journeys/README.md b/packages/journeys/README.md index 12b0e0a3..3b6cb6f0 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -2885,6 +2885,7 @@ Every export you're likely to call, grouped by role. | `resolveJourneySyncAction` | `(instance, path, stepToPath?) => JourneySyncAction` — the pure decision table the reconciler acts on: does this location mean `none`, `rewind` at an index, `forward` by N, or `unresolved`? No runtime calls, no navigation. Exported so hosts can pre-compute what a link would do (e.g. to disable a breadcrumb the journey would refuse). | | `journeyStepPath` | `(instance, stepToPath?) => string \| null` — the location an instance's current step should be at, or `null` when there is no step to represent (`loading`, or terminal). `null` means "leave the URL alone", not "clear it". | | `defaultStepPath` | `(step) => "moduleId/entry"` — the default `stepToPath`. Injective for journeys that visit each entry at most once per run. | +| `stepPathFromDefinition` | `(definition, fallback?) => stepToPath` — builds a `stepToPath` from a journey's per-step `steps[module][entry].path` metadata, so a declared `JourneyStepMeta.path` drives the URL. Steps without a `path` fall through to `fallback` (default `defaultStepPath`). Pass the result as the sync's `stepToPath` to opt in. | | `createMemoryJourneySyncPort` | In-memory `JourneySyncPort` over an array of entries, modelling a browser stack (`push` truncates the forward entries, `go` clamps). For tests, and for headless hosts that want journey history semantics without a browser. | | `JourneyValidationError` | Aggregated validation error. `.issues: readonly string[]`. | | `JourneyHydrationError` | Thrown from `hydrate` / async-load when the blob is unusable. | diff --git a/packages/journeys/src/index.ts b/packages/journeys/src/index.ts index 23145e55..42f92056 100644 --- a/packages/journeys/src/index.ts +++ b/packages/journeys/src/index.ts @@ -78,6 +78,7 @@ export { defaultStepPath, journeyStepPath, resolveJourneySyncAction, + stepPathFromDefinition, } from "@modular-frontend/journeys-engine"; export type { JourneySync, diff --git a/packages/journeys/src/use-journey-progress.test.tsx b/packages/journeys/src/use-journey-progress.test.tsx index a1098a0b..0d828d18 100644 --- a/packages/journeys/src/use-journey-progress.test.tsx +++ b/packages/journeys/src/use-journey-progress.test.tsx @@ -120,6 +120,37 @@ describe("useJourneyProgress", () => { expect(seen.at(-1)).toMatchObject({ index: 2, total: 3, label: "Payment" }); }); + it("keeps index correct under a maxHistory cap that trims history", () => { + // maxHistory: 1 trims `history` to a single frame, so `history.length` + // would stall at 1 on the third step. `index` is derived from the resolved + // spine instead, so it still reports the true position. + const runtime = createJourneyRuntime([{ definition: checkout, options: { maxHistory: 1 } }]); + const id = runtime.start(checkout.id, undefined); + const seen: JourneyProgress[] = []; + + function Probe() { + seen.push(useJourneyProgress(id, checkout)); + return null; + } + render( + + + , + ); + + act(() => { + createTestHarness(runtime).fireExit(id, "done"); + }); + act(() => { + createTestHarness(runtime).fireExit(id, "chosen"); + }); + + // History has been trimmed to length 1, but the current step is + // billing/collect — position 2 in the spine. + expect(runtime.getInstance(id)?.history.length).toBe(1); + expect(seen.at(-1)).toMatchObject({ index: 2, total: 3, label: "Payment" }); + }); + it("derives total even before an instance exists (index 0, label null)", () => { let observed: JourneyProgress | undefined; function Probe() { @@ -154,9 +185,10 @@ describe("useJourneyProgress", () => { , ); - // Only the start step is statically knowable, so total is 1 — but the point - // is it never throws and stays finite. (A fully-unresolvable start would be - // null; here `start` yields one step.) - expect(observed?.total).toBe(1); + // The walk stops at the bare-handler start step without reaching a genuine + // terminal, so the spine is partial and `total` is null rather than the + // misleading lower bound of 1. `steps` still carries the one known step. + expect(observed?.total).toBeNull(); + expect(observed?.steps.map((s) => `${s.module}/${s.entry}`)).toEqual(["profile/review"]); }); }); diff --git a/packages/journeys/src/use-journey-progress.ts b/packages/journeys/src/use-journey-progress.ts index adcca4b6..bd82ca20 100644 --- a/packages/journeys/src/use-journey-progress.ts +++ b/packages/journeys/src/use-journey-progress.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import type { InstanceId, JourneyRuntime } from "@modular-frontend/journeys-engine"; import { - resolveStepSequence, + resolveStepSequenceResult, type JourneyDefinition, type ModuleTypeMap, type ResolvedJourneyStep, @@ -33,41 +33,51 @@ interface UseJourneyProgressBase { * journey but required (carrying `input` or `start`) when the journey's * `initialState` / `start` need a non-void input. */ -export type UseJourneyProgressOptions = UseJourneyProgressBase & +export type UseJourneyProgressOptions< + TInput = unknown, + TModules extends ModuleTypeMap = ModuleTypeMap, +> = UseJourneyProgressBase & ([TInput] extends [void] - ? { readonly sequence?: ResolveStepSequenceOptions } - : { readonly sequence: ResolveStepSequenceOptions }); + ? { readonly sequence?: ResolveStepSequenceOptions } + : { readonly sequence: ResolveStepSequenceOptions }); /** * Trailing options argument for {@link useJourneyProgress}: optional for a * void-input journey, required when the journey needs a non-void input so the * mandatory `sequence.input` / `sequence.start` can't be omitted. */ -export type UseJourneyProgressArgs = [TInput] extends [void] - ? [options?: UseJourneyProgressOptions] - : [options: UseJourneyProgressOptions]; +export type UseJourneyProgressArgs = [ + TInput, +] extends [void] + ? [options?: UseJourneyProgressOptions] + : [options: UseJourneyProgressOptions]; export interface JourneyProgress { /** - * 0-based position in the flow — `history.length`, so `0` on the first step. - * Matches `useJourneyHost`'s `stepIndex`. Render "Step {index + 1} of {total}". + * 0-based position in the flow. Render "Step {index + 1} of {total}". * - * Note `index` tracks the *live* instance while `total` comes from the - * *statically-resolved* spine, so when the runtime path diverges from the - * resolved one (a fork walked with a different `branch`, or steps past an - * unannotated transition) `index` can reach or exceed `total`. Clamp at the - * call site if you render a bounded stepper. + * Derived from the *resolved sequence*: the position of the instance's + * current step within `steps`. This is why it is correct under a + * `maxHistory` cap — unlike `history.length`, which the runtime trims and + * which would then under-count on later steps. When the live step is not on + * the resolved spine (a fork walked with a different `branch`, or a step past + * an unannotated transition) it falls back to `history.length`, best-effort, + * and can then reach or exceed `total`; clamp at the call site if you render a + * bounded stepper. `0` before an instance exists. */ readonly index: number; /** - * Total number of steps in the resolved sequence — the "N" in "Step X of N". + * Total number of steps in the resolved sequence — the "N" in "Step X of N", + * or `null` when that total cannot be trusted. * - * Best-effort: it counts the statically-walkable spine `resolveStepSequence` - * returns from the start step, which is a *partial* total when the flow forks - * without a `branch` resolver, stops at an unannotated (bare-function) - * transition, or is cut by `maxSteps`. It does not depend on a live instance — - * a definition with a derivable start always yields at least `1`. `null` only - * when no step at all can be resolved (an empty sequence). + * It is a number only when the walk reached a genuine end of the flow (a + * step that can *only* complete/abort). It is `null` when the sequence is + * *partial* — an unresolved fork (no/rejecting `branch`), a bare + * (unannotated) or wildcard-only step, a `"invoke"` hand-off to a child, a + * cycle, or the `maxSteps` cap — because the statically-walkable length is + * then only a lower bound, and rendering it as the total produces nonsense + * like "Step 2 of 1". Guard your progress UI on `total != null`; use `steps` + * directly if you want the partial list for a breadcrumb. */ readonly total: number | null; /** @@ -116,10 +126,10 @@ export function useJourneyProgress< >( instanceId: InstanceId | null, definition: JourneyDefinition, - ...[options]: UseJourneyProgressArgs + ...[options]: UseJourneyProgressArgs ): JourneyProgress { const opts = (options ?? {}) as UseJourneyProgressBase & { - readonly sequence?: ResolveStepSequenceOptions; + readonly sequence?: ResolveStepSequenceOptions; }; const context = useJourneyContext(); const runtime = opts.runtime ?? context?.runtime ?? null; @@ -127,24 +137,35 @@ export function useJourneyProgress< const instance = useInstanceSnapshot(runtime, instanceId); const sequenceOptions = opts.sequence; - const steps = useMemo( + const resolved = useMemo( // The tuple cast localizes the same "TInput is generic here" erasure the // engine documents: `sequenceOptions` already satisfies the input-or-start // requirement via `UseJourneyProgressOptions`, so forward it as-is. () => - resolveStepSequence( + resolveStepSequenceResult( definition, - ...((sequenceOptions === undefined - ? [] - : [sequenceOptions]) as StepSequenceOptionsArg), + ...((sequenceOptions === undefined ? [] : [sequenceOptions]) as StepSequenceOptionsArg< + TInput, + TModules + >), ), [definition, sequenceOptions], ); - - const index = instance ? instance.history.length : 0; - const total = steps.length > 0 ? steps.length : null; + const steps = resolved.steps; const current = instance?.step; + // Position within the resolved spine — trim-immune, unlike `history.length`. + // Falls back to the live history depth when the current step is off the spine. + const resolvedIndex = + current != null + ? steps.findIndex((s) => s.module === current.moduleId && s.entry === current.entry) + : -1; + const index = resolvedIndex >= 0 ? resolvedIndex : instance ? instance.history.length : 0; + + // Only a completed walk yields a trustworthy total; a partial spine would + // render "Step 2 of 1" once the runtime advances past it. + const total = resolved.complete && steps.length > 0 ? steps.length : null; + const label = current != null ? (steps.find((s) => s.module === current.moduleId && s.entry === current.entry) diff --git a/packages/vue-journeys/src/index.ts b/packages/vue-journeys/src/index.ts index b7f301de..67095557 100644 --- a/packages/vue-journeys/src/index.ts +++ b/packages/vue-journeys/src/index.ts @@ -166,6 +166,7 @@ export { defaultStepPath, journeyStepPath, resolveJourneySyncAction, + stepPathFromDefinition, } from "@modular-frontend/journeys-engine"; export type { JourneySync, diff --git a/packages/vue-journeys/src/use-journey-progress.ts b/packages/vue-journeys/src/use-journey-progress.ts index 88a41d58..951c08c2 100644 --- a/packages/vue-journeys/src/use-journey-progress.ts +++ b/packages/vue-journeys/src/use-journey-progress.ts @@ -1,7 +1,7 @@ import { computed, toRaw, type ComputedRef, type MaybeRefOrGetter } from "vue"; import type { InstanceId, JourneyRuntime } from "@modular-frontend/journeys-engine"; import { - resolveStepSequence, + resolveStepSequenceResult, type JourneyDefinition, type ModuleTypeMap, type ResolvedJourneyStep, @@ -28,34 +28,43 @@ interface UseJourneyProgressBase { * journey but required (carrying `input` or `start`) when the journey's * `initialState` / `start` need a non-void input. */ -export type UseJourneyProgressOptions = UseJourneyProgressBase & +export type UseJourneyProgressOptions< + TInput = unknown, + TModules extends ModuleTypeMap = ModuleTypeMap, +> = UseJourneyProgressBase & ([TInput] extends [void] - ? { readonly sequence?: ResolveStepSequenceOptions } - : { readonly sequence: ResolveStepSequenceOptions }); + ? { readonly sequence?: ResolveStepSequenceOptions } + : { readonly sequence: ResolveStepSequenceOptions }); /** * Trailing options argument for {@link useJourneyProgress}: optional for a * void-input journey, required when the journey needs a non-void input so the * mandatory `sequence.input` / `sequence.start` can't be omitted. */ -export type UseJourneyProgressArgs = [TInput] extends [void] - ? [options?: UseJourneyProgressOptions] - : [options: UseJourneyProgressOptions]; +export type UseJourneyProgressArgs = [ + TInput, +] extends [void] + ? [options?: UseJourneyProgressOptions] + : [options: UseJourneyProgressOptions]; export interface JourneyProgress { /** - * 0-based position (`history.length`), so `0` on the first step. Tracks the - * live instance, whereas `total` is the statically-resolved spine, so `index` - * can reach or exceed `total` when the runtime path diverges (a fork walked - * with a different `branch`, or steps past an unannotated transition). + * 0-based position in the flow, `0` on the first step. Derived from the + * *resolved sequence* (the position of the live step within `steps`), so it + * is correct under a `maxHistory` cap — unlike `history.length`, which the + * runtime trims and which would then under-count later steps. Falls back to + * `history.length` when the live step is off the resolved spine (a fork + * walked with a different `branch`, or a step past an unannotated + * transition), and can then reach or exceed `total`. */ readonly index: ComputedRef; /** - * Resolved sequence length — best-effort. Counts the statically-walkable - * spine, so it is a *partial* total when the flow forks without a `branch` - * resolver, stops at an unannotated transition, or is cut by `maxSteps`. A - * definition with a derivable start always yields at least `1`; `null` only - * when no step at all can be resolved. + * Resolved sequence length — the "N" in "Step X of N" — or `null` when it + * cannot be trusted. A number only when the walk reached a genuine end of the + * flow; `null` when the sequence is *partial* (fork without `branch`, a bare + * or wildcard-only step, an `"invoke"` hand-off, a cycle, or the `maxSteps` + * cap), because the walkable length is then only a lower bound and rendering + * it as the total produces nonsense like "Step 2 of 1". Guard on `total`. */ readonly total: ComputedRef; /** `progressLabel` of the current step, or `null`. */ @@ -97,10 +106,10 @@ export function useJourneyProgress< >( instanceId: MaybeRefOrGetter, definition: JourneyDefinition, - ...[options]: UseJourneyProgressArgs + ...[options]: UseJourneyProgressArgs ): JourneyProgress { const opts = (options ?? {}) as UseJourneyProgressBase & { - readonly sequence?: ResolveStepSequenceOptions; + readonly sequence?: ResolveStepSequenceOptions; }; const ctx = useJourneyContext(); // `toRaw` for the same reason the host/outlet do it — a runtime that arrived @@ -113,13 +122,28 @@ export function useJourneyProgress< // is resolved once at setup — the same read the React hook memoizes. The // tuple cast localizes the engine's documented "TInput is generic here" // erasure; `opts.sequence` already satisfies the input-or-start requirement. - const steps = resolveStepSequence( + const resolved = resolveStepSequenceResult( definition, - ...((opts.sequence === undefined ? [] : [opts.sequence]) as StepSequenceOptionsArg), + ...((opts.sequence === undefined ? [] : [opts.sequence]) as StepSequenceOptionsArg< + TInput, + TModules + >), ); + const steps = resolved.steps; - const index = computed(() => (instance.value ? instance.value.history.length : 0)); - const total = computed(() => (steps.length > 0 ? steps.length : null)); + const index = computed(() => { + const current = instance.value?.step; + // Position within the resolved spine — trim-immune, unlike `history.length`. + const resolvedIndex = + current != null + ? steps.findIndex((s) => s.module === current.moduleId && s.entry === current.entry) + : -1; + if (resolvedIndex >= 0) return resolvedIndex; + return instance.value ? instance.value.history.length : 0; + }); + // Only a completed walk yields a trustworthy total; a partial spine would + // render "Step 2 of 1" once the runtime advances past it. + const total = computed(() => (resolved.complete && steps.length > 0 ? steps.length : null)); const label = computed(() => { const current = instance.value?.step; if (current == null) return null; From 9a08eae85694a0207c09513aadc3261916927177 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 12:36:02 +0000 Subject: [PATCH 9/9] fix(journeys): keep wildcard-advancing steps off the complete total classifyStep only inspected exact transitions[module][entry], so a step with an exact `done -> complete` handler plus a wildcard `retry -> next` fall-through was marked complete: true. The runtime routes an exit with no exact handler through wildcardTransitions.byEntryAndExit then byExit, so firing that exit advances the flow and recreates "Step 2 of 1". Fold applicable wildcard handlers into terminal classification: when any wildcard that could fire from a step may advance (a bare handler, a forward step ref, or "invoke"), the step is no longer terminal, so resolveStepSequenceResult reports complete: false and useJourneyProgress surfaces total: null. Precedence (exact > byEntryAndExit > byExit) is respected, and a wildcard that only targets complete/abort still leaves a genuinely terminal step complete. Also correct the resolveStepSequence example doc to derive a "Step X of N" total from resolveStepSequenceResult gated on `complete`, not steps.length. --- .../src/resolve-step-sequence.test.ts | 137 ++++++++++++++++++ .../src/resolve-step-sequence.ts | 101 ++++++++++++- 2 files changed, 235 insertions(+), 3 deletions(-) diff --git a/packages/journeys-engine/src/resolve-step-sequence.test.ts b/packages/journeys-engine/src/resolve-step-sequence.test.ts index ce5a0fe3..a0044108 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.test.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.test.ts @@ -323,4 +323,141 @@ describe("resolveStepSequenceResult — completeness", () => { it("is incomplete when cut short by maxSteps", () => { expect(resolveStepSequenceResult(linear, { maxSteps: 2 }).complete).toBe(false); }); + + // A step can look terminal from its exact `transitions` alone while a + // wildcard fall-through handler still carries the flow forward — firing that + // exit would recreate "Step 2 of 1". The walk never follows wildcard targets, + // so it keeps such a step off `complete` rather than reporting a false total. + + it("is incomplete when a tier-3 (byExit) wildcard can advance past an exact-terminal step", () => { + const wildcardAdvance = defineJourney()({ + id: "wildcard-advance", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "billing", entry: "collect", input: { x: 1 } }), + transitions: { + billing: { + collect: { + paid: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, + wildcardTransitions: { + byExit: { + chosen: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }), + }, + }, + }); + const result = resolveStepSequenceResult(wildcardAdvance); + expect(result.complete).toBe(false); + // the wildcard target is not walked — only the known spine is returned + expect(result.steps.map((s) => `${s.module}/${s.entry}`)).toEqual(["billing/collect"]); + }); + + it("is incomplete when a tier-2 (byEntryAndExit) wildcard can advance past an exact-terminal step", () => { + const wildcardTier2 = defineJourney()({ + id: "wildcard-tier2", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "plan", entry: "choose", input: { x: 1 } }), + transitions: { + plan: { + choose: { + chosen: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, + wildcardTransitions: { + byEntryAndExit: { + choose: { + premium: transition({ + targets: [{ module: "plan", entry: "upsell" }], + handle: () => ({ next: { module: "plan", entry: "upsell", input: { x: 1 } } }), + }), + }, + }, + }, + }); + expect(resolveStepSequenceResult(wildcardTier2).complete).toBe(false); + }); + + it("is incomplete when a bare (unannotated) wildcard handler could fire", () => { + const wildcardBare = defineJourney()({ + id: "wildcard-bare", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "billing", entry: "collect", input: { x: 1 } }), + transitions: { + billing: { + collect: { + paid: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, + wildcardTransitions: { + byExit: { + // opaque: the walk cannot prove where this leads + chosen: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }, + }, + }); + expect(resolveStepSequenceResult(wildcardBare).complete).toBe(false); + }); + + it("stays complete when the only applicable wildcard just ends the journey", () => { + // A cross-cutting `premium → abort` wildcard must not demote a genuinely + // terminal step — it cannot advance, so the total stays confident. + const wildcardTerminalOnly = defineJourney()({ + id: "wildcard-terminal-only", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "billing", entry: "collect", input: { x: 1 } }), + transitions: { + billing: { + collect: { + paid: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, + wildcardTransitions: { + byExit: { + premium: transition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "cancelled" } }), + }), + }, + }, + }); + expect(resolveStepSequenceResult(wildcardTerminalOnly).complete).toBe(true); + }); + + it("stays complete when an exact handler shadows a would-be-advancing wildcard", () => { + // `paid` has an exact terminal handler, so the `paid` wildcard never fires + // for this step; precedence (exact > wildcard) is respected. + const wildcardShadowed = defineJourney()({ + id: "wildcard-shadowed", + version: "1.0.0", + initialState: () => ({ tier: null }), + start: () => ({ module: "billing", entry: "collect", input: { x: 1 } }), + transitions: { + billing: { + collect: { + paid: transition({ targets: ["complete"], handle: () => ({ complete: undefined }) }), + }, + }, + }, + wildcardTransitions: { + byExit: { + paid: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ next: { module: "plan", entry: "choose", input: { x: 1 } } }), + }), + }, + }, + }); + expect(resolveStepSequenceResult(wildcardShadowed).complete).toBe(true); + }); }); diff --git a/packages/journeys-engine/src/resolve-step-sequence.ts b/packages/journeys-engine/src/resolve-step-sequence.ts index 5aac1d8a..c52ef2b5 100644 --- a/packages/journeys-engine/src/resolve-step-sequence.ts +++ b/packages/journeys-engine/src/resolve-step-sequence.ts @@ -173,7 +173,9 @@ const DEFAULT_MAX_STEPS = 256; * `"invoke"`) carry no next step and are skipped. Only the per-step * `transitions` map is walked — `wildcard` fall-through handlers are not * followed, so a step whose only forward movement is a wildcard also ends the - * sequence. + * sequence. A wildcard that *could* advance still keeps that step off the + * `complete` reckoning (see {@link resolveStepSequenceResult}), so its length is + * never mistaken for a confident total. * * Unless `options.start` is supplied, the first step is computed by invoking * `definition.initialState(options.input)` then `definition.start(...)`; these @@ -189,9 +191,15 @@ const DEFAULT_MAX_STEPS = 256; * * @example * ```ts + * // URL segments from the resolved spine — safe, it just maps the known steps: * const steps = resolveStepSequence(checkout); - * const total = steps.length; // "Step X of N" * const paths = steps.map((s) => s.path ?? `${s.module}/${s.entry}`); + * + * // A "Step X of N" total must come from resolveStepSequenceResult and be + * // trusted only when the walk reached a genuine terminal step — a partial + * // spine's length is a lower bound, not the real total: + * const { steps: seq, complete } = resolveStepSequenceResult(checkout); + * const total = complete ? seq.length : null; * ``` */ export function resolveStepSequence< @@ -332,6 +340,15 @@ function readStepMeta( * sentinel. A step that stops the walk for any other reason (bare handler, * wildcard-only, invoke hand-off, or simply no declared transitions) is * `terminal: false` — the walk cannot prove it is the flow's real end. + * + * Wildcard fall-through handlers count too: the runtime routes an exit with + * no exact `transitions[module][entry][exit]` through + * `wildcardTransitions.byEntryAndExit[entry][exit]` then `byExit[exit]`, so a + * step with an exact `done → complete` handler *and* a wildcard `retry → next` + * handler can still advance. When any applicable wildcard handler may advance + * (a bare handler, a forward step ref, or `"invoke"`), `terminal` is `false` — + * the resolver deliberately does not walk wildcard targets, so it cannot prove + * the step is the flow's real end. */ function classifyStep( definition: AnyDefinition, @@ -346,6 +363,7 @@ function classifyStep( const refs: StepSequenceRef[] = []; const seen = new Set(); + const exactExits = new Set(); let hasAnnotated = false; let hasBare = false; let hasInvoke = false; @@ -353,6 +371,7 @@ function classifyStep( for (const [exitName, handler] of Object.entries(perEntry)) { // `allowBack` is a sibling boolean flag on the per-entry map, not a handler. if (exitName === "allowBack") continue; + exactExits.add(exitName); if (!isAnnotatedTransition(handler)) { hasBare = true; continue; @@ -370,10 +389,86 @@ function classifyStep( refs.push({ module: target.module, entry: target.entry }); } } - const terminal = refs.length === 0 && hasAnnotated && !hasBare && !hasInvoke && hasEndSentinel; + const terminal = + refs.length === 0 && + hasAnnotated && + !hasBare && + !hasInvoke && + hasEndSentinel && + !wildcardMayAdvance(definition, entry, exactExits); return { targets: refs, terminal }; } +/** + * Whether any wildcard fall-through handler that could fire from this step may + * advance the journey (rather than only ending it). Mirrors the runtime's + * resolution precedence — exact → `byEntryAndExit[entry][exit]` → `byExit[exit]` + * — so a wildcard is only considered for an exit that no more-specific tier + * already handles. A handler "may advance" when it is bare (opaque), declares a + * forward step ref, or declares `"invoke"`; a handler that only targets + * `"complete"` / `"abort"` does not. Used to keep a step off `terminal` when a + * wildcard could carry the flow past it (the resolver never walks wildcard + * targets, so it cannot prove such a step is the real end). + */ +function wildcardMayAdvance( + definition: AnyDefinition, + entry: string, + exactExits: ReadonlySet, +): boolean { + const wildcards = (definition as { wildcardTransitions?: unknown }).wildcardTransitions; + if (typeof wildcards !== "object" || wildcards === null) return false; + + const byEntryAndExit = readExitHandlerMap( + (wildcards as { byEntryAndExit?: unknown }).byEntryAndExit, + entry, + ); + const byExit = readExitHandlerMap((wildcards as { byExit?: unknown }).byExit); + + // Tier 2: module unknown, this step's entry + exit known. Fires only for + // exits with no exact handler. + if (byEntryAndExit) { + for (const [exitName, handler] of Object.entries(byEntryAndExit)) { + if (exactExits.has(exitName)) continue; + if (handlerMayAdvance(handler)) return true; + } + } + + // Tier 3: module + entry unknown. Fires only for exits neither an exact + // handler nor a tier-2 handler for this entry already covers. + if (byExit) { + for (const [exitName, handler] of Object.entries(byExit)) { + if (exactExits.has(exitName)) continue; + if (byEntryAndExit && Object.hasOwn(byEntryAndExit, exitName)) continue; + if (handlerMayAdvance(handler)) return true; + } + } + + return false; +} + +/** + * Read an `{ [exit]: handler }` map from a wildcard tier — either `byExit` + * directly, or `byEntryAndExit[entry]` when an `entry` is supplied. Returns + * `undefined` for a missing / malformed tier so callers can skip it. + */ +function readExitHandlerMap(value: unknown, entry?: string): Record | undefined { + if (typeof value !== "object" || value === null) return undefined; + const map = entry === undefined ? value : (value as Record)[entry]; + if (typeof map !== "object" || map === null) return undefined; + return map as Record; +} + +/** + * Whether a single wildcard handler may carry the flow forward. Bare handlers + * are opaque (the walk cannot see past them), and annotated handlers advance + * when they declare a forward step ref or `"invoke"`. A handler that only + * targets `"complete"` / `"abort"` ends the journey and does not advance. + */ +function handlerMayAdvance(handler: unknown): boolean { + if (!isAnnotatedTransition(handler)) return true; // bare — opaque + return handler.targets.some((target) => !isTerminalSentinel(target) || target === "invoke"); +} + /** * Internal alias for the generic-erased definition — `resolveStepSequence` * walks the definition structurally (module ids and entry names are strings on