From 8a3a199d01f5f430e88768d922fc3eed72a0187e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 18:56:41 +0000 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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`