diff --git a/examples/catalog/tests/catalog.spec.ts b/examples/catalog/tests/catalog.spec.ts index 97d942a..c43bd98 100644 --- a/examples/catalog/tests/catalog.spec.ts +++ b/examples/catalog/tests/catalog.spec.ts @@ -175,6 +175,11 @@ test.describe("catalog SPA", () => { .first(); await profileCompleteRow.locator("summary").click(); await expect(profileCompleteRow.getByText("plan")).toBeVisible(); + // The example journey wraps `profileComplete` with `defineTransition({ targets })`, + // so the harvester reports `targetsDeclared: true` and the UI shows a + // "declared" badge. Reassures the reader that the destination set is + // authoritative (not AST best-effort). + await expect(profileCompleteRow.getByTestId("declared-targets-badge")).toBeVisible(); // The destination chip's module name is itself a link to the module page. await profileCompleteRow.getByRole("link", { name: "plan" }).click(); await expect(page).toHaveURL(/\/modules\/plan$/); diff --git a/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/package.json b/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/package.json index f0793f0..6c5687f 100644 --- a/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/package.json +++ b/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/package.json @@ -18,7 +18,7 @@ "@example-onboarding/billing-module": "workspace:*", "@example-onboarding/plan-module": "workspace:*", "@example-onboarding/profile-module": "workspace:*", - "@modular-react/journeys": "^0.1.0" + "@modular-react/journeys": "^1.0.0" }, "devDependencies": { "typescript": "^6.0.2" diff --git a/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/customer-onboarding.ts b/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/customer-onboarding.ts index fa00f21..bd31104 100644 --- a/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/customer-onboarding.ts +++ b/examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/customer-onboarding.ts @@ -1,4 +1,4 @@ -import { defineJourney, defineJourneyHandle } from "@modular-react/journeys"; +import { defineJourney, defineJourneyHandle, defineTransition } from "@modular-react/journeys"; import type { PlanHint, SubscriptionPlan } from "@example-onboarding/app-shared"; import type profileModule from "@example-onboarding/profile-module"; import type planModule from "@example-onboarding/plan-module"; @@ -27,6 +27,14 @@ export interface OnboardingState { | null; } +// Bind `defineTransition` to the journey's modules + state once so every +// wrapped handler below gets contextual narrowing on `next.module` / +// `next.entry` and autocomplete on `targets`. Bare-function handlers stay +// fully supported — only the handlers that fan out to lazy steps need to +// migrate. Naming mirrors `selectModule`: a descriptive verb, not an +// abbreviation. +const transition = defineTransition(); + export const customerOnboardingJourney = defineJourney()({ id: "customer-onboarding", version: "1.0.0", @@ -63,45 +71,69 @@ export const customerOnboardingJourney = defineJourney ({ - state: { ...state, hint: output.hint }, - next: { - module: "plan", - entry: "choose", - input: { customerId: state.customerId, hint: output.hint }, - }, + // `transition({ targets })` lets `` + // (the default) speculatively warm the chunks for the steps this exit + // can advance into. With billing/collect now lazy-loaded, declaring + // the targets here ensures the chunk is hot before the rep clicks + // into it. Bare-function handlers below stay unchanged. + profileComplete: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: ({ output, state }) => ({ + state: { ...state, hint: output.hint }, + next: { + module: "plan", + entry: "choose", + input: { customerId: state.customerId, hint: output.hint }, + }, + }), }), - readyToBuy: ({ output }) => ({ - next: { - module: "billing", - entry: "collect", - input: { customerId: output.customerId, amount: output.amount }, - }, + readyToBuy: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: ({ output }) => ({ + next: { + module: "billing", + entry: "collect", + input: { customerId: output.customerId, amount: output.amount }, + }, + }), }), needsMoreDetails: ({ output }) => ({ abort: { reason: "profile-incomplete", missing: output.missing }, }), - cancelled: () => ({ abort: { reason: "rep-cancelled" } }), + // Demonstrates a terminal-only annotation: `targets: ["abort"]` + // tells the catalog harvester this handler may abort (no AST walk + // needed) and constrains the handler return to just the abort arm + // at compile time. + cancelled: transition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "rep-cancelled" } }), + }), }, }, plan: { choose: { allowBack: true, - choseStandard: ({ output, state }) => ({ - state: { ...state, selectedPlan: output.plan }, - next: { - module: "billing", - entry: "collect", - input: { customerId: state.customerId, amount: output.plan.monthly }, - }, + choseStandard: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: ({ output, state }) => ({ + state: { ...state, selectedPlan: output.plan }, + next: { + module: "billing", + entry: "collect", + input: { customerId: state.customerId, amount: output.plan.monthly }, + }, + }), }), - choseWithTrial: ({ output, state }) => ({ - state: { ...state, selectedPlan: output.plan }, - next: { - module: "billing", - entry: "startTrial", - input: { customerId: state.customerId, plan: output.plan }, - }, + choseWithTrial: transition({ + targets: [{ module: "billing", entry: "startTrial" }], + handle: ({ output, state }) => ({ + state: { ...state, selectedPlan: output.plan }, + next: { + module: "billing", + entry: "startTrial", + input: { customerId: state.customerId, plan: output.plan }, + }, + }), }), noFit: ({ output }) => ({ abort: { reason: "plan-no-fit", detail: output.reason }, diff --git a/examples/react-router/customer-onboarding-journey/modules/billing/src/index.ts b/examples/react-router/customer-onboarding-journey/modules/billing/src/index.ts index e63156f..c2f6a8f 100644 --- a/examples/react-router/customer-onboarding-journey/modules/billing/src/index.ts +++ b/examples/react-router/customer-onboarding-journey/modules/billing/src/index.ts @@ -1,10 +1,11 @@ import { defineEntry, defineModule, schema } from "@modular-react/core"; import { billingExits } from "./exits.js"; -import { CollectPayment, type CollectPaymentInput } from "./CollectPayment.js"; +import type { CollectPaymentInput } from "./CollectPayment.js"; import { StartTrial, type StartTrialInput } from "./StartTrial.js"; export { billingExits }; export type { BillingExits } from "./exits.js"; +export type { CollectPaymentInput } from "./CollectPayment.js"; export default defineModule({ id: "billing", @@ -15,8 +16,13 @@ export default defineModule({ }, exitPoints: billingExits, entryPoints: { + // Lazy-loaded — `CollectPayment` is only fetched when a journey actually + // reaches the `collect` step (or when an outlet preloads it during idle + // time, see the journey definition's `defineTransition({ targets })` + // annotations). Eliminates the bundle cost of the payment-collection + // surface for journeys that branch into `startTrial` instead. collect: defineEntry({ - component: CollectPayment, + lazy: () => import("./CollectPayment.js").then((m) => ({ default: m.CollectPayment })), input: schema(), // Rollback: if the rep steps back from `collect`, the journey state // reverts to the snapshot taken before entering it — any "paid" diff --git a/examples/react-router/customer-onboarding-journey/shell/package.json b/examples/react-router/customer-onboarding-journey/shell/package.json index 32f270d..932fac1 100644 --- a/examples/react-router/customer-onboarding-journey/shell/package.json +++ b/examples/react-router/customer-onboarding-journey/shell/package.json @@ -17,7 +17,7 @@ "@example-onboarding/plan-module": "workspace:*", "@example-onboarding/profile-module": "workspace:*", "@modular-react/core": "^1.0.0", - "@modular-react/journeys": "^0.1.0", + "@modular-react/journeys": "^1.0.0", "@modular-react/react": "^1.0.0", "@react-router-modules/core": "^2.0.0", "@react-router-modules/runtime": "^2.0.0", diff --git a/examples/react-router/journey-invoke/journeys/checkout/package.json b/examples/react-router/journey-invoke/journeys/checkout/package.json index 41b5be3..e90b7db 100644 --- a/examples/react-router/journey-invoke/journeys/checkout/package.json +++ b/examples/react-router/journey-invoke/journeys/checkout/package.json @@ -18,7 +18,7 @@ "@example-rr-invoke/checkout-confirm-module": "workspace:*", "@example-rr-invoke/checkout-review-module": "workspace:*", "@example-rr-invoke/verify-identity-journey": "workspace:*", - "@modular-react/journeys": "^0.1.0" + "@modular-react/journeys": "^1.0.0" }, "devDependencies": { "typescript": "^6.0.2" diff --git a/examples/react-router/journey-invoke/journeys/verify-identity/package.json b/examples/react-router/journey-invoke/journeys/verify-identity/package.json index af5ef65..91836ae 100644 --- a/examples/react-router/journey-invoke/journeys/verify-identity/package.json +++ b/examples/react-router/journey-invoke/journeys/verify-identity/package.json @@ -16,7 +16,7 @@ "dependencies": { "@example-rr-invoke/age-verify-module": "workspace:*", "@example-rr-invoke/app-shared": "workspace:*", - "@modular-react/journeys": "^0.1.0" + "@modular-react/journeys": "^1.0.0" }, "devDependencies": { "typescript": "^6.0.2" diff --git a/examples/react-router/journey-invoke/shell/package.json b/examples/react-router/journey-invoke/shell/package.json index a5d5da0..1c8d76b 100644 --- a/examples/react-router/journey-invoke/shell/package.json +++ b/examples/react-router/journey-invoke/shell/package.json @@ -17,7 +17,7 @@ "@example-rr-invoke/checkout-review-module": "workspace:*", "@example-rr-invoke/verify-identity-journey": "workspace:*", "@modular-react/core": "^1.0.0", - "@modular-react/journeys": "^0.1.0", + "@modular-react/journeys": "^1.0.0", "@modular-react/react": "^1.0.0", "@react-router-modules/core": "^2.0.0", "@react-router-modules/runtime": "^2.0.0", diff --git a/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/package.json b/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/package.json index 40d8257..5ae9697 100644 --- a/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/package.json +++ b/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/package.json @@ -18,7 +18,7 @@ "@example-tsr-onboarding/billing-module": "workspace:*", "@example-tsr-onboarding/plan-module": "workspace:*", "@example-tsr-onboarding/profile-module": "workspace:*", - "@modular-react/journeys": "^0.1.0" + "@modular-react/journeys": "^1.0.0" }, "devDependencies": { "typescript": "^6.0.2" diff --git a/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts b/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts index d36a20d..9f560bb 100644 --- a/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts +++ b/examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts @@ -1,4 +1,4 @@ -import { defineJourney, defineJourneyHandle } from "@modular-react/journeys"; +import { defineJourney, defineJourneyHandle, defineTransition } from "@modular-react/journeys"; import type { PlanHint, SubscriptionPlan } from "@example-tsr-onboarding/app-shared"; import type profileModule from "@example-tsr-onboarding/profile-module"; import type planModule from "@example-tsr-onboarding/plan-module"; @@ -27,6 +27,13 @@ export interface OnboardingState { | null; } +// Bind `defineTransition` to the journey's modules + state once so every +// wrapped handler below gets contextual narrowing on `next.module` / +// `next.entry` and autocomplete on `targets`. Bare-function handlers stay +// fully supported — only the handlers that fan out to lazy steps need to +// migrate. +const transition = defineTransition(); + export const customerOnboardingJourney = defineJourney()({ id: "customer-onboarding", version: "1.0.0", @@ -67,45 +74,69 @@ export const customerOnboardingJourney = defineJourney ({ - state: { ...state, hint: output.hint }, - next: { - module: "plan", - entry: "choose", - input: { customerId: state.customerId, hint: output.hint }, - }, + // `transition({ targets })` lets `` + // (the default) speculatively warm the chunks for the steps this exit + // can advance into. With billing/collect now lazy-loaded, declaring + // the targets here ensures the chunk is hot before the rep clicks + // into it. Bare-function handlers below stay unchanged. + profileComplete: transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: ({ output, state }) => ({ + state: { ...state, hint: output.hint }, + next: { + module: "plan", + entry: "choose", + input: { customerId: state.customerId, hint: output.hint }, + }, + }), }), - readyToBuy: ({ output }) => ({ - next: { - module: "billing", - entry: "collect", - input: { customerId: output.customerId, amount: output.amount }, - }, + readyToBuy: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: ({ output }) => ({ + next: { + module: "billing", + entry: "collect", + input: { customerId: output.customerId, amount: output.amount }, + }, + }), }), needsMoreDetails: ({ output }) => ({ abort: { reason: "profile-incomplete", missing: output.missing }, }), - cancelled: () => ({ abort: { reason: "rep-cancelled" } }), + // Demonstrates a terminal-only annotation: `targets: ["abort"]` + // tells the catalog harvester this handler may abort (no AST walk + // needed) and constrains the handler return to just the abort arm + // at compile time. + cancelled: transition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "rep-cancelled" } }), + }), }, }, plan: { choose: { allowBack: true, - choseStandard: ({ output, state }) => ({ - state: { ...state, selectedPlan: output.plan }, - next: { - module: "billing", - entry: "collect", - input: { customerId: state.customerId, amount: output.plan.monthly }, - }, + choseStandard: transition({ + targets: [{ module: "billing", entry: "collect" }], + handle: ({ output, state }) => ({ + state: { ...state, selectedPlan: output.plan }, + next: { + module: "billing", + entry: "collect", + input: { customerId: state.customerId, amount: output.plan.monthly }, + }, + }), }), - choseWithTrial: ({ output, state }) => ({ - state: { ...state, selectedPlan: output.plan }, - next: { - module: "billing", - entry: "startTrial", - input: { customerId: state.customerId, plan: output.plan }, - }, + choseWithTrial: transition({ + targets: [{ module: "billing", entry: "startTrial" }], + handle: ({ output, state }) => ({ + state: { ...state, selectedPlan: output.plan }, + next: { + module: "billing", + entry: "startTrial", + input: { customerId: state.customerId, plan: output.plan }, + }, + }), }), noFit: ({ output }) => ({ abort: { reason: "plan-no-fit", detail: output.reason }, diff --git a/examples/tanstack-router/customer-onboarding-journey/modules/billing/src/index.ts b/examples/tanstack-router/customer-onboarding-journey/modules/billing/src/index.ts index f054bc8..8637b46 100644 --- a/examples/tanstack-router/customer-onboarding-journey/modules/billing/src/index.ts +++ b/examples/tanstack-router/customer-onboarding-journey/modules/billing/src/index.ts @@ -1,10 +1,11 @@ import { defineEntry, defineModule, schema } from "@modular-react/core"; import { billingExits } from "./exits.js"; -import { CollectPayment, type CollectPaymentInput } from "./CollectPayment.js"; +import type { CollectPaymentInput } from "./CollectPayment.js"; import { StartTrial, type StartTrialInput } from "./StartTrial.js"; export { billingExits }; export type { BillingExits } from "./exits.js"; +export type { CollectPaymentInput } from "./CollectPayment.js"; export default defineModule({ id: "billing", @@ -19,8 +20,13 @@ export default defineModule({ }, exitPoints: billingExits, entryPoints: { + // Lazy-loaded — `CollectPayment` is only fetched when a journey actually + // reaches the `collect` step (or when an outlet preloads it during idle + // time, see the journey definition's `defineTransition({ targets })` + // annotations). Eliminates the bundle cost of the payment-collection + // surface for journeys that branch into `startTrial` instead. collect: defineEntry({ - component: CollectPayment, + lazy: () => import("./CollectPayment.js").then((m) => ({ default: m.CollectPayment })), input: schema(), // Rollback: if the rep steps back from `collect`, the journey state // reverts to the snapshot taken before entering it — any "paid" diff --git a/examples/tanstack-router/customer-onboarding-journey/shell/package.json b/examples/tanstack-router/customer-onboarding-journey/shell/package.json index 4360cd6..bcbfe11 100644 --- a/examples/tanstack-router/customer-onboarding-journey/shell/package.json +++ b/examples/tanstack-router/customer-onboarding-journey/shell/package.json @@ -17,7 +17,7 @@ "@example-tsr-onboarding/plan-module": "workspace:*", "@example-tsr-onboarding/profile-module": "workspace:*", "@modular-react/core": "^1.0.0", - "@modular-react/journeys": "^0.1.0", + "@modular-react/journeys": "^1.0.0", "@modular-react/react": "^1.0.0", "@tanstack-react-modules/core": "^2.0.0", "@tanstack-react-modules/runtime": "^2.0.0", diff --git a/examples/tanstack-router/journey-invoke/journeys/checkout/package.json b/examples/tanstack-router/journey-invoke/journeys/checkout/package.json index c414806..425810c 100644 --- a/examples/tanstack-router/journey-invoke/journeys/checkout/package.json +++ b/examples/tanstack-router/journey-invoke/journeys/checkout/package.json @@ -18,7 +18,7 @@ "@example-tsr-invoke/checkout-confirm-module": "workspace:*", "@example-tsr-invoke/checkout-review-module": "workspace:*", "@example-tsr-invoke/verify-identity-journey": "workspace:*", - "@modular-react/journeys": "^0.1.0" + "@modular-react/journeys": "^1.0.0" }, "devDependencies": { "typescript": "^6.0.2" diff --git a/examples/tanstack-router/journey-invoke/journeys/verify-identity/package.json b/examples/tanstack-router/journey-invoke/journeys/verify-identity/package.json index c8f6e83..8fb3968 100644 --- a/examples/tanstack-router/journey-invoke/journeys/verify-identity/package.json +++ b/examples/tanstack-router/journey-invoke/journeys/verify-identity/package.json @@ -16,7 +16,7 @@ "dependencies": { "@example-tsr-invoke/age-verify-module": "workspace:*", "@example-tsr-invoke/app-shared": "workspace:*", - "@modular-react/journeys": "^0.1.0" + "@modular-react/journeys": "^1.0.0" }, "devDependencies": { "typescript": "^6.0.2" diff --git a/examples/tanstack-router/journey-invoke/shell/package.json b/examples/tanstack-router/journey-invoke/shell/package.json index 787fef3..2e7e0f2 100644 --- a/examples/tanstack-router/journey-invoke/shell/package.json +++ b/examples/tanstack-router/journey-invoke/shell/package.json @@ -17,7 +17,7 @@ "@example-tsr-invoke/checkout-review-module": "workspace:*", "@example-tsr-invoke/verify-identity-journey": "workspace:*", "@modular-react/core": "^1.0.0", - "@modular-react/journeys": "^0.1.0", + "@modular-react/journeys": "^1.0.0", "@modular-react/react": "^1.0.0", "@tanstack-react-modules/core": "^2.0.0", "@tanstack-react-modules/runtime": "^2.0.0", diff --git a/packages/catalog/spa-src/src/types.ts b/packages/catalog/spa-src/src/types.ts index cc4121a..6caa10a 100644 --- a/packages/catalog/spa-src/src/types.ts +++ b/packages/catalog/spa-src/src/types.ts @@ -99,6 +99,12 @@ export interface ModuleExitUsage { readonly destinations?: readonly TransitionDestination[]; readonly aborts?: boolean; readonly completes?: boolean; + /** + * True when `destinations` came from a `defineTransition({ targets })` + * declaration — the destination set is statically authoritative. False / + * absent means destinations were AST-inferred and may miss branches. + */ + readonly targetsDeclared?: boolean; } export interface CatalogModel { diff --git a/packages/catalog/spa-src/src/views/ModuleDetailView.tsx b/packages/catalog/spa-src/src/views/ModuleDetailView.tsx index af34e29..7b269b0 100644 --- a/packages/catalog/spa-src/src/views/ModuleDetailView.tsx +++ b/packages/catalog/spa-src/src/views/ModuleDetailView.tsx @@ -366,6 +366,25 @@ function ExitOutcomes({ usage }: { usage: ModuleExitUsage }) { {tags} + {usage.targetsDeclared && } + + ); +} + +/** + * Marks an exit's destinations as authoritative — sourced from + * `defineTransition({ targets })` rather than AST-inferred. Reassures the + * reader that the listing is complete (vs the best-effort AST walk, which + * can miss branches behind dynamic `output`-driven returns). + */ +function DeclaredBadge() { + return ( + + declared ); } diff --git a/packages/catalog/src/config/types.ts b/packages/catalog/src/config/types.ts index c98a45f..77885a0 100644 --- a/packages/catalog/src/config/types.ts +++ b/packages/catalog/src/config/types.ts @@ -243,6 +243,14 @@ export interface ExitOutcome { readonly nexts: readonly { readonly module: string; readonly entry?: string }[]; readonly aborts: boolean; readonly completes: boolean; + /** + * True when `nexts` was sourced from a `defineTransition({ targets })` + * declaration on the handler — the destination set is statically + * authoritative, including branches a pure AST walk could not resolve. + * False / absent when destinations were AST-inferred from `next` literals + * inside the handler body. + */ + readonly targetsDeclared?: boolean; } /** Discriminated union of every harvestable entry kind. */ diff --git a/packages/catalog/src/harvester/ast-destinations.test.ts b/packages/catalog/src/harvester/ast-destinations.test.ts index 09e4c31..1ec076e 100644 --- a/packages/catalog/src/harvester/ast-destinations.test.ts +++ b/packages/catalog/src/harvester/ast-destinations.test.ts @@ -188,6 +188,239 @@ describe("extractTransitionDestinations", () => { expect(Object.keys(map.a!.x!)).toEqual(["ok"]); }); + it("unwraps a `defineTransition({ targets, handle })`-wrapped handler", async () => { + // The runtime supports a wrapped form that attaches `targets` metadata + // for the auto-preloader. The harvester must descend into the inner + // `handle:` function to recover the destination — the wrapper is + // otherwise transparent. + const path = writeTmp( + "wrapped-define-transition.ts", + `export default { + id: "wrapped", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "x" }), + transitions: { + a: { + x: { + ok: defineTransition({ + targets: [{ module: "b", entry: "y" }], + handle: () => ({ next: { module: "b", entry: "y", input: {} } }), + }), + cancel: defineTransition({ + // Terminal-only handler MUST declare the sentinel — the + // harvester no longer walks the handler body when targets + // is present (declaration is the source of truth). + targets: ["abort"], + handle: () => ({ abort: { reason: "user" } }), + }), + }, + }, + }, + };`, + ); + + const map = await extractTransitionDestinations(path, "wrapped"); + expect(map.a?.x?.ok).toEqual({ + nexts: [{ module: "b", entry: "y" }], + aborts: false, + completes: false, + // `targets:` was present, so `nexts` is authoritative (declared). + targetsDeclared: true, + }); + expect(map.a?.x?.cancel).toEqual({ + nexts: [], + // `aborts: true` comes from the declared `"abort"` sentinel — + // not from walking the handler body. + aborts: true, + completes: false, + targetsDeclared: true, + }); + }); + + it("unwraps a curried-binder call (`const t = defineTransition<...>(); t({...})`)", async () => { + // Same as above but the binder is a local variable — the harvester + // doesn't care about the callee identifier, only the spec object shape. + const path = writeTmp( + "curried.ts", + `const transition = defineTransition(); + export default { + id: "curried", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "x" }), + transitions: { + a: { + x: { + ok: transition({ + targets: [{ module: "b", entry: "y" }], + handle: () => ({ next: { module: "b", entry: "y", input: {} } }), + }), + }, + }, + }, + };`, + ); + + const map = await extractTransitionDestinations(path, "curried"); + expect(map.a?.x?.ok).toEqual({ + nexts: [{ module: "b", entry: "y" }], + aborts: false, + completes: false, + targetsDeclared: true, + }); + }); + + it("prefers declared `targets` over AST inference (catches dynamic-return branches)", async () => { + // The inner handler returns `next` from a ternary the AST can't reduce + // — without `targets:` we would extract zero destinations. With + // declared targets the catalog gets the FULL static destination set. + const path = writeTmp( + "dynamic-with-targets.ts", + `export default { + id: "dyn-decl", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "x" }), + transitions: { + a: { + x: { + branch: defineTransition({ + targets: [{ module: "b", entry: "y" }, { module: "c", entry: "z" }], + handle: ({ output }) => ({ + next: + output.kind === "y" + ? { module: "b", entry: "y", input: {} } + : { module: "c", entry: "z", input: {} }, + }), + }), + }, + }, + }, + };`, + ); + + const map = await extractTransitionDestinations(path, "dyn-decl"); + expect(map.a?.x?.branch).toEqual({ + nexts: [ + { module: "b", entry: "y" }, + { module: "c", entry: "z" }, + ], + aborts: false, + completes: false, + targetsDeclared: true, + }); + }); + + it("treats a CallExpression without `targets:` as opaque", async () => { + // `targets` is mandatory on every `defineTransition` call, so its + // absence here means this is some unrelated helper (or a malformed + // call) the harvester has no contract with. Don't recurse into the + // inner `handle:` function — that would falsely surface destinations + // for a wrapper that may not even forward the handler verbatim. + const path = writeTmp( + "no-targets.ts", + `export default { + id: "no-decl", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "x" }), + transitions: { + a: { + x: { + ok: someOtherWrapper({ + handle: () => ({ next: { module: "b", entry: "y", input: {} } }), + }), + }, + }, + }, + };`, + ); + const map = await extractTransitionDestinations(path, "no-decl"); + expect(map.a?.x?.ok).toBeUndefined(); + }); + + it("derives `aborts` / `completes` flags from terminal sentinels in `targets`", async () => { + // With `defineTransition` the targets array is the source of truth for + // ALL outcomes — both next refs and the terminal arms. The harvester + // should set `aborts` / `completes` from the sentinels rather than + // walking the handler body. + const path = writeTmp( + "sentinels.ts", + `export default { + id: "sentinels", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "x" }), + transitions: { + a: { + x: { + cancel: defineTransition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "user" } }), + }), + finish: defineTransition({ + targets: ["complete"], + handle: () => ({ complete: { ok: true } }), + }), + proceed: defineTransition({ + targets: [{ module: "b", entry: "y" }, "abort"], + handle: ({ output }) => + output.ok + ? { next: { module: "b", entry: "y", input: {} } } + : { abort: { reason: "rejected" } }, + }), + }, + }, + }, + };`, + ); + + const map = await extractTransitionDestinations(path, "sentinels"); + expect(map.a?.x?.cancel).toEqual({ + nexts: [], + aborts: true, + completes: false, + targetsDeclared: true, + }); + expect(map.a?.x?.finish).toEqual({ + nexts: [], + aborts: false, + completes: true, + targetsDeclared: true, + }); + expect(map.a?.x?.proceed).toEqual({ + nexts: [{ module: "b", entry: "y" }], + aborts: true, + completes: false, + targetsDeclared: true, + }); + }); + + it('accepts `"invoke"` sentinel without crashing (no schema slot today)', async () => { + // The catalog schema doesn't have an `invokes` flag yet; the parser + // accepts the sentinel so handlers that may invoke aren't rejected. + const path = writeTmp( + "invoke-sentinel.ts", + `export default { + id: "invoke-sent", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "a", entry: "x" }), + transitions: { + a: { x: { fanout: defineTransition({ targets: ["invoke"], handle: () => ({ invoke: {} }) }) } }, + }, + };`, + ); + const map = await extractTransitionDestinations(path, "invoke-sent"); + expect(map.a?.x?.fanout).toEqual({ + nexts: [], + aborts: false, + completes: false, + targetsDeclared: true, + }); + }); + it("returns empty when no journey object matches the id", async () => { const path = writeTmp( "wrongid.ts", diff --git a/packages/catalog/src/harvester/ast-destinations.ts b/packages/catalog/src/harvester/ast-destinations.ts index d417726..e6ec225 100644 --- a/packages/catalog/src/harvester/ast-destinations.ts +++ b/packages/catalog/src/harvester/ast-destinations.ts @@ -140,11 +140,103 @@ interface HandlerOutcome { nexts: { module: string; entry?: string }[]; aborts: boolean; completes: boolean; + /** + * True when `nexts` came from a `defineTransition({ targets })` declaration + * — i.e. the destination set is authoritative, not inferred from handler + * branches. The catalog UI can surface this as a "declared" badge so + * authors know the listing is complete (vs the AST best-effort, which can + * miss branches behind dynamic returns). + */ + targetsDeclared?: boolean; +} + +/** + * Outcome of parsing a `targets:` literal — both the next-step object refs + * and the terminal-arm sentinel flags. Returned as a single struct so the + * caller can stamp `nexts` / `aborts` / `completes` from one declaration. + */ +interface DeclaredTargets { + nexts: { module: string; entry: string }[]; + aborts: boolean; + completes: boolean; +} + +/** + * Parse a `targets` literal — `[{ module: "m", entry: "e" }, "abort", ...]` + * — into the next-step refs and terminal-arm flags. The shape mirrors the + * runtime `StepRef` union: object refs go to `nexts`, string sentinels + * (`"complete"` / `"abort"` / `"invoke"`) flip the corresponding flag. + * + * Non-object elements / objects missing either a literal `module` or `entry` + * string are skipped (defensive: a hand-rolled call site might pass garbage). + * Returns `null` when the value is not an array literal at all so callers + * can distinguish "no targets declared" from "empty targets declared". + */ +function readDeclaredTargets(node: AstNode): DeclaredTargets | null { + if (!node || node.type !== "ArrayExpression" || !Array.isArray(node.elements)) return null; + const out: DeclaredTargets = { nexts: [], aborts: false, completes: false }; + for (const el of node.elements) { + if (!el) continue; + if (el.type === "Literal" && typeof el.value === "string") { + // Sentinel — flip the corresponding flag. `"invoke"` doesn't fan + // through to a separate flag today (the catalog's existing schema + // tracks only `aborts` / `completes`); it's accepted at parse time + // so handlers that may invoke a child journey aren't rejected. + if (el.value === "abort") out.aborts = true; + else if (el.value === "complete") out.completes = true; + continue; + } + if (el.type !== "ObjectExpression") continue; + let module: string | null = null; + let entry: string | null = null; + for (const prop of objectProperties(el)) { + const key = staticPropertyKey(prop); + if ( + key === "module" && + prop.value?.type === "Literal" && + typeof prop.value.value === "string" + ) { + module = prop.value.value; + } else if ( + key === "entry" && + prop.value?.type === "Literal" && + typeof prop.value.value === "string" + ) { + entry = prop.value.value; + } + } + if (module !== null && entry !== null) out.nexts.push({ module, entry }); + } + return out; } function analyzeHandler(value: AstNode): HandlerOutcome | null { // Only function expressions / arrows count; literal `{}` etc. are inert. if (!value) return null; + + // Unwrap a `defineTransition({ targets, handle })` call (or its curried + // sibling, e.g. `const transition = defineTransition<...>(); transition({...})`). + // `targets` is mandatory on every `defineTransition` invocation in the + // runtime, so its absence here means this is some other CallExpression + // we can't classify — leave it opaque rather than guessing at the inner + // function. Declared targets are the authoritative source for all + // outcomes (next refs + terminal `aborts` / `completes` from sentinels); + // the runtime narrows the handler return to those arms, so an AST walk + // over the body can only confirm what `targets` already says. + if (value.type === "CallExpression") { + const spec = value.arguments?.[0]; + if (!spec || spec.type !== "ObjectExpression") return null; + const targetsProp = findProperty(spec, "targets"); + const declared = targetsProp ? readDeclaredTargets(targetsProp.value) : null; + if (declared === null) return null; + return { + nexts: declared.nexts, + aborts: declared.aborts, + completes: declared.completes, + targetsDeclared: true, + }; + } + const isFunction = value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression"; if (!isFunction) return null; diff --git a/packages/catalog/src/schema/build-model.ts b/packages/catalog/src/schema/build-model.ts index 7fa1505..8497751 100644 --- a/packages/catalog/src/schema/build-model.ts +++ b/packages/catalog/src/schema/build-model.ts @@ -391,6 +391,7 @@ function buildExitUsage( readonly nexts: readonly { readonly module: string; readonly entry?: string }[]; readonly aborts: boolean; readonly completes: boolean; + readonly targetsDeclared?: boolean; } | undefined, ): ModuleExitUsage { @@ -401,6 +402,7 @@ function buildExitUsage( destinations?: readonly TransitionDestination[]; aborts?: boolean; completes?: boolean; + targetsDeclared?: boolean; } = { journeyId, fromEntry }; if (outcome.nexts.length > 0) { out.destinations = outcome.nexts.map((n) => @@ -409,5 +411,6 @@ function buildExitUsage( } if (outcome.aborts) out.aborts = true; if (outcome.completes) out.completes = true; + if (outcome.targetsDeclared) out.targetsDeclared = true; return out; } diff --git a/packages/catalog/src/schema/types.ts b/packages/catalog/src/schema/types.ts index e8708f5..9eede41 100644 --- a/packages/catalog/src/schema/types.ts +++ b/packages/catalog/src/schema/types.ts @@ -169,6 +169,14 @@ export interface ModuleExitUsage { readonly aborts?: boolean; /** True when the handler returns `{ complete }` on at least one branch. */ readonly completes?: boolean; + /** + * True when the handler is wrapped with `defineTransition({ targets, handle })` + * and `destinations` was sourced from the declared `targets` array — i.e. + * the destination set is statically authoritative. False / absent means + * `destinations` is the AST best-effort, which can miss branches behind + * dynamic `output`-driven returns. UIs can show this as a "declared" badge. + */ + readonly targetsDeclared?: boolean; } /** diff --git a/packages/core/src/entry-exit.test-d.ts b/packages/core/src/entry-exit.test-d.ts new file mode 100644 index 0000000..0d6e338 --- /dev/null +++ b/packages/core/src/entry-exit.test-d.ts @@ -0,0 +1,102 @@ +// Type-level regression tests for the entry-point shape: +// - `defineEntry` overloads (eager vs lazy, mutually exclusive) +// - `EagerModuleEntryPoint` / `LazyModuleEntryPoint` discrimination via the +// `?: never` idiom that makes `component`+`lazy` co-occurrence an error +// - `LazyEntryComponent` importer signature (default-export and direct-export +// module shapes are both accepted) +// - `fallback` is allowed only on lazy entries (never on eager — eager +// entries don't suspend, so the field would be silently ignored) + +import { expectTypeOf, test } from "vitest"; +import type { ComponentType, ReactNode } from "react"; + +import { defineEntry, schema } from "./entry-exit.js"; +import type { + EagerModuleEntryPoint, + LazyEntryComponent, + LazyModuleEntryPoint, + ModuleEntryPoint, + ModuleEntryProps, +} from "./types.js"; + +interface MyInput { + readonly id: string; +} +const Component = ((_props: ModuleEntryProps) => null) as ComponentType< + ModuleEntryProps +>; + +// ----------------------------------------------------------------------------- +// `defineEntry` — overload selection preserves the narrow union member. +// ----------------------------------------------------------------------------- + +test("defineEntry({ component }) returns EagerModuleEntryPoint, not the wider union", () => { + const entry = defineEntry({ component: Component, input: schema() }); + expectTypeOf(entry).toMatchTypeOf>(); + expectTypeOf(entry).not.toMatchTypeOf>(); +}); + +test("defineEntry({ lazy }) returns LazyModuleEntryPoint, not the wider union", () => { + const entry = defineEntry({ + lazy: () => Promise.resolve({ default: Component }), + input: schema(), + fallback: null, + }); + expectTypeOf(entry).toMatchTypeOf>(); + expectTypeOf(entry).not.toMatchTypeOf>(); +}); + +test("defineEntry rejects an entry that declares BOTH component and lazy", () => { + // @ts-expect-error — eager branch sets `lazy: never`, lazy branch sets + // `component: never`; declaring both fails both overloads. + defineEntry({ + component: Component, + lazy: () => Promise.resolve({ default: Component }), + }); +}); + +test("defineEntry rejects an entry that declares NEITHER component nor lazy", () => { + // @ts-expect-error — neither overload matches an empty entry. + defineEntry({}); +}); + +test("`fallback` is allowed only on lazy entries", () => { + // @ts-expect-error — fallback is `never` on EagerModuleEntryPoint. + defineEntry({ component: Component, fallback: null }); + + // OK on lazy entries. + defineEntry({ + lazy: () => Promise.resolve({ default: Component }), + fallback: null as ReactNode, + }); +}); + +// ----------------------------------------------------------------------------- +// `LazyEntryComponent` — importer signature normalizes default-export and +// direct-export module shapes. +// ----------------------------------------------------------------------------- + +test("LazyEntryComponent accepts a `() => Promise<{ default: ComponentType<...> }>`", () => { + const importer: LazyEntryComponent = () => Promise.resolve({ default: Component }); + expectTypeOf(importer).returns.resolves.toMatchTypeOf< + | { default: ComponentType> } + | ComponentType> + >(); +}); + +test("LazyEntryComponent also accepts a `() => Promise>` (direct export)", () => { + const importer: LazyEntryComponent = () => Promise.resolve(Component); + expectTypeOf(importer).returns.resolves.toMatchTypeOf< + | { default: ComponentType> } + | ComponentType> + >(); +}); + +// ----------------------------------------------------------------------------- +// `ModuleEntryPoint` — the union both render sites accept. +// ----------------------------------------------------------------------------- + +test("ModuleEntryPoint is the union of Eager and Lazy variants", () => { + type Expected = EagerModuleEntryPoint | LazyModuleEntryPoint; + expectTypeOf>().toEqualTypeOf(); +}); diff --git a/packages/core/src/entry-exit.test.ts b/packages/core/src/entry-exit.test.ts index 1941352..e451db0 100644 --- a/packages/core/src/entry-exit.test.ts +++ b/packages/core/src/entry-exit.test.ts @@ -26,6 +26,17 @@ describe("defineEntry", () => { }); expect(entry.allowBack).toBe("rollback"); }); + + it("accepts a lazy importer in place of a component", () => { + const importer = (): Promise<{ default: typeof DummyComponent }> => + Promise.resolve({ default: DummyComponent }); + const entry = defineEntry({ + lazy: importer, + input: schema<{ id: string }>(), + }); + expect(entry.lazy).toBe(importer); + expect((entry as { component?: unknown }).component).toBeUndefined(); + }); }); describe("defineExit", () => { @@ -58,13 +69,35 @@ describe("validateModuleEntryExit", () => { expect(validateModuleEntryExit(m)).toEqual([]); }); - it("flags a non-function component", () => { + it("flags an entry that has neither component nor lazy", () => { const m = mod({ entryPoints: { broken: { component: "not-a-component" as any } }, }); const issues = validateModuleEntryExit(m); expect(issues).toHaveLength(1); - expect(issues[0]).toMatch(/broken.*React component/); + expect(issues[0]).toMatch(/broken.*React component or a lazy importer/); + }); + + it("accepts an entry with only a lazy importer", () => { + const importer = (): Promise<{ default: typeof DummyComponent }> => + Promise.resolve({ default: DummyComponent }); + const m = mod({ + entryPoints: { ok: { lazy: importer } as any }, + }); + expect(validateModuleEntryExit(m)).toEqual([]); + }); + + it("flags an entry that declares both component and lazy", () => { + const importer = (): Promise<{ default: typeof DummyComponent }> => + Promise.resolve({ default: DummyComponent }); + const m = mod({ + entryPoints: { + both: { component: DummyComponent, lazy: importer } as any, + }, + }); + const issues = validateModuleEntryExit(m); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatch(/both.*mutually exclusive/); }); it("flags an invalid allowBack value", () => { diff --git a/packages/core/src/entry-exit.ts b/packages/core/src/entry-exit.ts index b560c74..f74df19 100644 --- a/packages/core/src/entry-exit.ts +++ b/packages/core/src/entry-exit.ts @@ -1,8 +1,10 @@ import type { + EagerModuleEntryPoint, EntryPointMap, ExitPointMap, ExitPointSchema, InputSchema, + LazyModuleEntryPoint, ModuleDescriptor, ModuleEntryPoint, } from "./types.js"; @@ -17,10 +19,18 @@ export const schema = (): InputSchema => ({}) as InputSchema; /** * Identity helper used to preserve inference of `TInput` on a single * {@link ModuleEntryPoint}. The descriptor types only flow through correctly - * when the entry is a typed const value. + * when the entry is a typed const value. Overloaded so eager and lazy entries + * keep their narrow union member through the call. */ -export const defineEntry = (entry: ModuleEntryPoint): ModuleEntryPoint => - entry; +export function defineEntry( + entry: EagerModuleEntryPoint, +): EagerModuleEntryPoint; +export function defineEntry( + entry: LazyModuleEntryPoint, +): LazyModuleEntryPoint; +export function defineEntry(entry: ModuleEntryPoint): ModuleEntryPoint { + return entry; +} /** * Type-only brand that preserves inference of `TOutput` on a single @@ -59,9 +69,15 @@ export function validateModuleEntryExit( issues.push(`entry "${name}" is not an object`); continue; } - if (typeof entry.component !== "function") { + const hasComponent = typeof (entry as { component?: unknown }).component === "function"; + const hasLazy = typeof (entry as { lazy?: unknown }).lazy === "function"; + if (!hasComponent && !hasLazy) { + issues.push( + `entry "${name}" must declare a React component or a lazy importer (got component: ${typeof (entry as { component?: unknown }).component}, lazy: ${typeof (entry as { lazy?: unknown }).lazy})`, + ); + } else if (hasComponent && hasLazy) { issues.push( - `entry "${name}" must declare a React component (got ${typeof entry.component})`, + `entry "${name}" declares both \`component\` and \`lazy\` — these are mutually exclusive`, ); } const allowBack = entry.allowBack; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f4f2e33..875fe82 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,9 @@ export type { ZoneMap, ZoneMapOf, ModuleEntryPoint, + EagerModuleEntryPoint, + LazyModuleEntryPoint, + LazyEntryComponent, ModuleEntryProps, ExitPointSchema, EntryPointMap, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 321740b..3dd3547 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -231,13 +231,17 @@ export interface InputSchema { } /** - * A single typed entry point on a module — the combination of a React - * component, the input type it accepts, and an optional opt-in to "go back" - * navigation. + * Lazy-importer signature for an entry-point component. Mirrors the shape + * `React.lazy` accepts (default-exported component) but is normalized at the + * runtime to also accept a module that exports the component directly. */ -export interface ModuleEntryPoint { - /** Component to render when this entry is opened. Receives `ModuleEntryProps`. */ - readonly component: React.ComponentType>; +export type LazyEntryComponent = () => Promise< + | { default: React.ComponentType> } + | React.ComponentType> +>; + +/** Fields shared by both eager and lazy entry-point variants. */ +interface ModuleEntryPointBase { /** Type-level declaration of the input shape. Pure inference aid. */ readonly input?: InputSchema; /** @@ -250,6 +254,41 @@ export interface ModuleEntryPoint { readonly allowBack?: "preserve-state" | "rollback" | false; } +/** + * Eager entry — a directly-bound React component. The historic shape; works + * unchanged for every existing consumer. + */ +export interface EagerModuleEntryPoint extends ModuleEntryPointBase { + /** Component to render when this entry is opened. Receives `ModuleEntryProps`. */ + readonly component: React.ComponentType>; + readonly lazy?: never; + readonly fallback?: never; +} + +/** + * Lazy entry — a dynamic-import factory. Hosts wrap the resolved component + * in `React.lazy` + `` and expose an idempotent `preload()` so + * speculative prefetching is one call, not a hand-written wrapper component. + */ +export interface LazyModuleEntryPoint extends ModuleEntryPointBase { + readonly component?: never; + /** Dynamic import of the entry's component. Called at most once per descriptor. */ + readonly lazy: LazyEntryComponent; + /** + * Suspense fallback rendered while the lazy chunk is loading. Hosts wrap + * the resolved component in ``. Only + * meaningful for lazy entries — eager entries don't suspend. + */ + readonly fallback?: React.ReactNode; +} + +/** + * A single typed entry point on a module — either eager (`component`) or + * lazy (`lazy`). The `?: never` idiom on each branch makes the two forms + * mutually exclusive at the type level. + */ +export type ModuleEntryPoint = EagerModuleEntryPoint | LazyModuleEntryPoint; + /** * Typed declaration of a single exit point — describes the output payload * type the exit can emit. Can be omitted for void exits. diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..c951b3f --- /dev/null +++ b/packages/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/journeys/README.md b/packages/journeys/README.md index 16d06b2..d344389 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -22,8 +22,8 @@ Routes, slots, navigation, workspaces - none of that changes. Journeys sit **on - [Quickstart shortcut: scaffold the journey package](#quickstart-shortcut-scaffold-the-journey-package) - `create journey` if you bootstrapped with the modular-react CLI - [Quickstart](#quickstart) - the 5-step path from zero to a running journey - [Core concepts](#core-concepts) - entries, exits, `allowBack`, lifecycle, statuses, keys -- [Authoring patterns](#authoring-patterns) - module entries, exits, loading flows, `goBack` opt-in -- [Journey definition patterns](#journey-definition-patterns) - branching, `selectModule` dispatch, terminals, state rewrites, bounded history, module compatibility +- [Authoring patterns](#authoring-patterns) - module entries, exits, loading flows, `goBack` opt-in, **lazy entry-points (code-splitting)** +- [Journey definition patterns](#journey-definition-patterns) - branching, `selectModule` dispatch, terminals, state rewrites, bounded history, module compatibility, **`defineTransition` (auto-preload + narrowed handler return)** - [Composing journeys (invoke / resume)](#composing-journeys-invoke--resume) - call out to a child journey mid-flow and resume on its outcome - [Cycle and recursion safety](#cycle-and-recursion-safety) - cycle / depth / undeclared-child / bounce-limit guards and how to tune them - [Runtime surface](#runtime-surface) - the `JourneyRuntime` you get back from `manifest.journeys` @@ -360,10 +360,10 @@ See the [customer-onboarding-journey example](../../examples/react-router/custom Two additive (optional) fields on `ModuleDescriptor`: -| Field | Shape | Purpose | -| ------------- | ----------------------------------------------- | ----------------------------------------------------------- | -| `entryPoints` | `{ [name]: { component, input?, allowBack? } }` | Typed ways to open the module. A module can expose several. | -| `exitPoints` | `{ [name]: { output? } }` | The module's full outcome vocabulary. | +| Field | Shape | Purpose | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `entryPoints` | `{ [name]: { component, input?, allowBack? } }`
or `{ [name]: { lazy: () => import("./X"), fallback?, input?, allowBack? } }` | Typed ways to open the module. A module can expose several. Each entry is either eager (a directly-bound component) or lazy (a dynamic-import factory — see [Pattern - lazy entry-points](#pattern---lazy-entry-points-code-splitting-per-step)). | +| `exitPoints` | `{ [name]: { output? } }` | The module's full outcome vocabulary. | `ModuleEntryProps` typed props for the component - `{ input, exit, goBack? }`, with `exit(name, output)` cross-checked against `TExits` at compile time. @@ -494,6 +494,39 @@ export default defineModule({ The journey's transition map targets `{ module: 'billing', entry: 'collect' }` or `'startTrial'` - the discriminated `StepSpec` enforces that `input` matches the chosen entry. +### Pattern - lazy entry-points (code-splitting per step) + +For heavy steps (rich editors, charting libraries, large vendor bundles) declare `lazy: () => import('./HeavyStep')` instead of `component:`. The runtime wraps the resolved component in `React.lazy` + `` for you and exposes an idempotent `preload()` that the outlet calls during idle time (see [auto-preload](#pattern---declared-targets-with-definetransition-auto-preload--narrowed-return-type)). This eliminates the per-entry `LazyXxxStep.tsx` wrapper consumers used to write to get past the descriptor's "must be a function" validation. + +```tsx +// modules/billing/src/index.ts +import { defineEntry, defineModule, schema } from "@modular-react/core"; +import { billingExits } from "./exits.js"; + +export default defineModule({ + id: "billing", + version: "1.0.0", + exitPoints: billingExits, + entryPoints: { + collect: defineEntry({ + lazy: () => import("./CollectPayment.js"), + fallback: , // optional + input: schema<{ customerId: string; amount: number }>(), + }), + }, +}); +``` + +Rules: + +- **Eager and lazy are mutually exclusive at the type level.** Declaring both `component` and `lazy` on the same entry is a TypeScript error (and a `validateModuleEntryExit` issue — defense in depth). Declaring neither is also flagged. +- **`fallback` only on lazy entries.** Eager entries don't suspend, so the field is typed `never` on `EagerModuleEntryPoint`. Trying to pass it would be confusing — make the trap visible at the type level. +- **Importer signature matches `React.lazy`.** Standard `() => import("./X")` works (default export). The runtime also normalizes a module that exports the component directly, so `() => Promise.resolve(MyComponent)` works in tests. +- **The lazy import is memoized per entry-object identity** via a process-local `WeakMap` in `@modular-react/react`. A descriptor is fetched at most once across all renders, hot reloads producing fresh descriptor objects get fresh wrappers, and StrictMode's double-mount is safe. +- **Manual prefetch** is exposed via `preloadEntry(entry)` from `@modular-react/react` — useful for hover-prefetch UIs (`onMouseEnter={() => preloadEntry(entry)}`), navigation gestures, or warming a chunk from a `useEffect` that knows the user is about to advance. +- **Errors from the import** propagate through Suspense and are caught by the outlet's existing `StepErrorBoundary`, going through `onStepError` (`abort | retry | ignore`) just like a component throw. A permanently-failing import re-throws the cached rejection on retry; the existing `retryLimit` budget applies. +- **SSR**: `React.lazy` resolution is server-renderable in React 19+; the auto-preload effect is browser-only (no `useEffect` on the server). + ### Pattern - a loading entry point for async work Transitions are pure and synchronous. When a step needs to fetch data between user actions, put the fetch inside a **loading entry** on the next module; that module fires an exit with the loaded data, and the journey transitions from that exit as usual. @@ -594,6 +627,95 @@ profile: { // module id }, ``` +### Pattern - declared targets with `defineTransition` (auto-preload + narrowed return type) + +Wrap a handler with `defineTransition` to declare every outcome it may take — both next-step destinations and terminal arms. Two effects from one declaration: + +1. **Runtime — preload precision.** ``'s default `preload="precise"` mode reads `targets` and warms exactly the declared next-step chunks during idle time, so navigating Next finds the chunk already cached. +2. **Type-level — the handler's return is constrained to the declared arms.** Returning an arm that wasn't declared (e.g. `abort` when only `next` was declared) is a compile error. + +`targets` accepts a mixed array of: + +- `{ module, entry }` — same shape as `next:` minus the runtime-computed `input`. One per next-step candidate. +- `"complete"` / `"abort"` / `"invoke"` — string sentinels for the terminal arms. Declaring `"complete"` permits `{ complete: ... }` returns; `"abort"` permits `{ abort: ... }`; `"invoke"` permits `{ invoke: ... }` (the journey's `invokes:` field remains the closed-set declaration the runtime cycle-guards check against — the sentinel is just "this handler may invoke something"). + +The helper has two call shapes: + +- **Curried (recommended)** — `defineTransition()` binds the journey's generics once. Handlers wrapped with the returned binder get `targets` autocompleted to valid step refs + sentinels and the handler's return narrowed to the declared arms. +- **Bare** — `defineTransition({ targets, handle })` for one-off use. `targets` accepts any well-formed step-ref / sentinel without TModules-level checking; the handler's return is not contextually narrowed. + +`targets` is **mandatory on every `defineTransition` call** — the wrapper's whole point is to enumerate the possible outcomes, and an empty/missing array would silently sit out of precise-mode preload while looking annotated. If you don't want to declare outcomes, use a bare function — the runtime invocation path is identical. + +```ts +import { defineJourney, defineTransition } from "@modular-react/journeys"; + +// Bind the journey's generics once — every `transition({ ... })` call below +// gets autocomplete on `targets` and contextual narrowing on `next`. Naming +// mirrors `selectModule`: a descriptive verb for the binder, not an +// abbreviation (`tx` reads as "transaction" in most codebases). +const transition = defineTransition(); + +export const onboardingJourney = defineJourney()({ + // ... + transitions: { + profile: { + review: { + // Multi-target next: outlet preloads BOTH chunks during the idle + // window after profile/review mounts. Handler return is narrowed + // to `{ next: planChoose | billingCollect }` — returning `abort` + // here would be a compile error. + profileComplete: transition({ + targets: [ + { module: "plan", entry: "choose" }, + { module: "billing", entry: "collect" }, + ], + handle: ({ output, state }) => ({ + state: { ...state, hint: output.hint }, + next: + output.hint === "cheap" + ? { + module: "plan", + entry: "choose", + input: { customerId: state.customerId, hint: output.hint }, + } + : { + module: "billing", + entry: "collect", + input: { customerId: state.customerId, amount: 0 }, + }, + }), + }), + // Mixed: handler may advance OR abort. Both arms are declared. + review: transition({ + targets: [{ module: "plan", entry: "choose" }, "abort"], + handle: ({ output }) => + output.ok + ? { + next: { + module: "plan", + entry: "choose", + input: { customerId: "c", hint: "cheap" }, + }, + } + : { abort: { reason: "rejected" } }, + }), + // Terminal-only: declaring `"abort"` lets the catalog harvester + // surface the abort flag without an AST walk over the handler body, + // and constrains the return to just the abort arm at compile time. + cancelled: transition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "user-cancelled" } }), + }), + }, + }, + }, +}); +``` + +Why explicit declarations rather than inferring from the handler body? Handler bodies are dynamic (`next: cond ? A : B`) and may have side effects, so the runtime can't safely run them speculatively to enumerate destinations. One declarative line per wrapped transition is the trade-off — and it doubles as the catalog's authoritative outcome map (no AST walking for `aborts` / `completes` flags either). + +**Bare-function handlers still work.** The runtime invocation path is identical, and they sit out of precise-mode preload (`preload="aggressive"` is the fallback). Migrate handlers that fan out to heavy steps first; everything else stays as-is. + ### Pattern - branching on state/output inside a handler Handlers are plain functions - branch with `if` / `switch` on output or state. Return whichever `TransitionResult` makes sense. @@ -1464,6 +1586,23 @@ interface JourneyOutletProps { * error-reporting pipeline. */ errorComponent?: ComponentType; + /** + * Speculatively prefetch chunks for entries reachable from the current + * step during idle time after mount, so navigating Next finds the + * bundle hot. + * + * "precise" (default, alias `true`) — read declared `targets` from + * `defineTransition({ targets, handle })`-annotated handlers on + * the current step's transitions. Preload exactly those entries. + * Bare handlers contribute nothing. + * "aggressive" — preload every entry referenced anywhere in the + * journey's `transitions` map. Useful for unmigrated journeys. + * false — opt out entirely. + * + * No effect for eager (`component:`) entries — their import is + * already resolved. SSR is a no-op (the preload effect is browser-only). + */ + preload?: boolean | "precise" | "aggressive"; } interface JourneyOutletNotFoundProps { @@ -1516,10 +1655,11 @@ What it does: 1. Subscribes to the instance via `useSyncExternalStore`. 2. Renders `loadingFallback` while the async persistence `load` is in flight. -3. Resolves `step.module` + `step.entry` against the module map (prop, or the one the runtime was built with) and renders its component with a freshly bound `{ input, exit, goBack? }`. -4. Wraps the step in an error boundary and applies `onStepError` policy. Retries count against `retryLimit` globally per instance (the counter does **not** reset when a retry advances the step), so a throwing component can't bypass the cap by bumping the step token. +3. Resolves `step.module` + `step.entry` against the module map (prop, or the one the runtime was built with) and renders its component with a freshly bound `{ input, exit, goBack? }`. Lazy entries are wrapped in `React.lazy` + `` automatically — see [Pattern - lazy entry-points](#pattern---lazy-entry-points-code-splitting-per-step). +4. Wraps the step in an error boundary and applies `onStepError` policy. Retries count against `retryLimit` globally per instance (the counter does **not** reset when a retry advances the step), so a throwing component can't bypass the cap by bumping the step token. Lazy import failures surface through this same boundary. 5. Fires `onFinished` exactly once when the instance terminates; the outcome carries `{ status, payload, instanceId, journeyId }` so analytics can correlate without re-reading props. 6. On unmount while still `active` **or** `loading`, abandons the instance via `runtime.end({ reason: 'unmounted' })`. Two defenses keep the instance alive when it should stay: StrictMode's simulated mount/unmount/remount cycle (same component, same `mountedRef`) and back-to-back independent outlets that hand off to each other (checked via `record.listeners.size`). +7. After each step mounts, schedules a `requestIdleCallback` (with a `setTimeout(_, 0)` fallback) to call `preload()` on every entry reachable from the current step (per `preload` mode — see the prop docs above). Effect cancels on step change so a fast advance doesn't race with the previous step's preload set. ### Error policies in depth @@ -1942,31 +2082,36 @@ Every export you're likely to call, grouped by role. ### From `@modular-react/core` (module authors) -| Export | Signature | Purpose | -| -------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `defineEntry` | `(e: ModuleEntryPoint) => ModuleEntryPoint` | Identity helper for an entry-point literal. Zero runtime cost. | -| `defineExit` | `(s?: ExitPointSchema) => ExitPointSchema` | Identity helper for an exit-point literal. Zero runtime cost. | -| `schema` | `() => InputSchema` | Type-only brand used to carry an input/output shape. Zero runtime cost. | -| `ModuleEntryProps` | `` | Typed props for an entry component: `{ input, exit, goBack? }`. | -| `ModuleEntryPoint` | `{ component, input?, allowBack? }` | Entry-point descriptor shape. | -| `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. | -| `ExitFn` | `(name, output?) => void` | The function signature `exit` gets on an entry component. | -| `EntryPointMap` / `ExitPointMap` | `Record>` / `Record>` | Map shapes on `ModuleDescriptor`. | +| Export | Signature | Purpose | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defineEntry` | overloaded — `(e: EagerModuleEntryPoint \| LazyModuleEntryPoint) => same` | Identity helper. Two forms: eager (`{ component, input?, allowBack? }`) or lazy (`{ lazy: () => import(…), fallback?, input?, allowBack? }`). Mutually exclusive at the type level. | +| `defineExit` | `() => ExitPointSchema` | Identity helper for an exit-point literal. Zero runtime cost. | +| `schema` | `() => InputSchema` | Type-only brand used to carry an input/output shape. Zero runtime cost. | +| `ModuleEntryProps` | `` | Typed props for an entry component: `{ input, exit, goBack? }`. | +| `ModuleEntryPoint` | `EagerModuleEntryPoint \| LazyModuleEntryPoint` | Discriminated union — eager (`component`) or lazy (`lazy`). | +| `EagerModuleEntryPoint` / `LazyModuleEntryPoint` | `{ component, input?, allowBack?, lazy?: never }` / `{ lazy, fallback?, input?, allowBack?, component?: never }` | The two branches of the union, exported for callers that want to type a single variant explicitly. | +| `LazyEntryComponent` | `() => Promise<{ default: ComponentType<…> } \| ComponentType<…>>` | Importer signature accepted by `defineEntry({ lazy })`. Both default-export and direct-export module shapes are normalized at runtime. | +| `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. | +| `ExitFn` | `(name, output?) => void` | The function signature `exit` gets on an entry component. | +| `EntryPointMap` / `ExitPointMap` | `Record>` / `Record>` | Map shapes on `ModuleDescriptor`. | ### Authoring (`@modular-react/journeys`) -| Export | Signature | Purpose | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `defineJourney` | `() => (def: JourneyDefinition) => def` | Identity helper with full inference on transitions and state. Curried so `TInput` infers from `initialState`. The optional third generic `TOutput` narrows `complete` payloads (and a parent's resume `outcome.payload` when invoked). | -| `defineJourneyHandle` | `(def) => JourneyHandle` | Builds a typed token from a journey definition so modules and shells can call `runtime.start(handle, input)` without importing the journey's runtime code. Carries `TOutput` so a parent's resume sees `outcome.payload` typed end-to-end. | -| `invoke` | `({ handle, input, resume }) => { invoke: InvokeSpec }` | Typed builder for the `{ invoke }` arm of `TransitionResult`. Cross-checks `input` against the handle's `TInput` — a bare object literal won't. See [Composing journeys](#composing-journeys-invoke--resume). | -| `validateJourneyGraph` | `(journeys: readonly RegisteredJourney[]) => void` | Static cycle check over the directed graph derived from each journey's `invokes` field. Run automatically by `validateJourneyContracts`; exported separately for shells that compose registrations across plugin boundaries. See [Cycle and recursion safety](#cycle-and-recursion-safety). | -| `isJourneySystemAbort` | `(payload: unknown) => payload is JourneySystemAbortReason` | Type guard that narrows an `unknown` abort payload to the runtime's discriminated `JourneySystemAbortReason` union. Returns `false` for author-supplied aborts so a `{ abort: { reason: "user-cancelled" } }` does not collide with the system codes. See [Cycle and recursion safety - Failure surface](#cycle-and-recursion-safety). | -| `selectModule` | `() => (key, cases) => StepSpec` | Exhaustive state-driven dispatch helper for transition handlers - see [the pattern](#pattern---exhaustive-state-driven-module-dispatch-selectmodule). Missing branches are a compile error. | -| `selectModuleOrDefault` | `() => (key, cases, fallback) => StepSpec` | Sibling of `selectModule` accepting a partial cases map plus an explicit fallback `StepSpec` - see [the pattern](#pattern---fallback-dispatch-selectmoduleordefault). Use when most discriminator values funnel through a generic module. | -| `defineJourneyPersistence` | `(adapter) => JourneyPersistence` | Types `keyFor`'s `input` against `TInput`, `load`/`save` against `TState`. | -| `createWebStoragePersistence` | `({ keyFor, storage? }) => JourneyPersistence` | Stock `localStorage` / `sessionStorage` adapter. SSR-safe, auto-clears corrupt JSON entries. Pass `storage` to override the backing store. | -| `createMemoryPersistence` | `({ keyFor, initial?, clone? }) => MemoryPersistence` | `Map`-backed adapter for tests/SSR. Exposes `size()` / `entries()` / `clear()`. Deep-clones on `save` and `load` by default. | +| Export | Signature | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defineJourney` | `() => (def: JourneyDefinition) => def` | Identity helper with full inference on transitions and state. Curried so `TInput` infers from `initialState`. The optional third generic `TOutput` narrows `complete` payloads (and a parent's resume `outcome.payload` when invoked). | +| `defineJourneyHandle` | `(def) => JourneyHandle` | Builds a typed token from a journey definition so modules and shells can call `runtime.start(handle, input)` without importing the journey's runtime code. Carries `TOutput` so a parent's resume sees `outcome.payload` typed end-to-end. | +| `invoke` | `({ handle, input, resume }) => { invoke: InvokeSpec }` | Typed builder for the `{ invoke }` arm of `TransitionResult`. Cross-checks `input` against the handle's `TInput` — a bare object literal won't. See [Composing journeys](#composing-journeys-invoke--resume). | +| `validateJourneyGraph` | `(journeys: readonly RegisteredJourney[]) => void` | Static cycle check over the directed graph derived from each journey's `invokes` field. Run automatically by `validateJourneyContracts`; exported separately for shells that compose registrations across plugin boundaries. See [Cycle and recursion safety](#cycle-and-recursion-safety). | +| `isJourneySystemAbort` | `(payload: unknown) => payload is JourneySystemAbortReason` | Type guard that narrows an `unknown` abort payload to the runtime's discriminated `JourneySystemAbortReason` union. Returns `false` for author-supplied aborts so a `{ abort: { reason: "user-cancelled" } }` does not collide with the system codes. See [Cycle and recursion safety - Failure surface](#cycle-and-recursion-safety). | +| `selectModule` | `() => (key, cases) => StepSpec` | Exhaustive state-driven dispatch helper for transition handlers - see [the pattern](#pattern---exhaustive-state-driven-module-dispatch-selectmodule). Missing branches are a compile error. | +| `selectModuleOrDefault` | `() => (key, cases, fallback) => StepSpec` | Sibling of `selectModule` accepting a partial cases map plus an explicit fallback `StepSpec` - see [the pattern](#pattern---fallback-dispatch-selectmoduleordefault). Use when most discriminator values funnel through a generic module. | +| `defineTransition` | curried: `() => (spec) => handle & { targets }`
bare: `(spec) => handle & { targets }` | Wraps a transition handler with declared `targets` — a mixed array of `{ module, entry }` step refs and `"complete"` / `"abort"` / `"invoke"` sentinels. Required on every wrapped handler; narrows the handler's return to the declared arms (returning an undeclared arm is a compile error). The outlet's default `preload="precise"` mode reads the step refs to warm chunks during idle time; sentinels are skipped. See [Pattern - declared targets](#pattern---declared-targets-with-definetransition-auto-preload--narrowed-return-type). | +| `isTerminalSentinel` | `(value: unknown) => value is "complete" \| "abort" \| "invoke"` | Type guard for the terminal sentinels accepted in `targets`. Exposed for hosts that introspect a wrapped handler's targets and want to separate step refs from terminal arms. | +| `isAnnotatedTransition` | `(value: unknown) => boolean` | Type guard for `defineTransition`-wrapped handlers. The outlet's preloader uses it; exported for custom hosts that walk a journey's `transitions` map. | +| `defineJourneyPersistence` | `(adapter) => JourneyPersistence` | Types `keyFor`'s `input` against `TInput`, `load`/`save` against `TState`. | +| `createWebStoragePersistence` | `({ keyFor, storage? }) => JourneyPersistence` | Stock `localStorage` / `sessionStorage` adapter. SSR-safe, auto-clears corrupt JSON entries. Pass `storage` to override the backing store. | +| `createMemoryPersistence` | `({ keyFor, initial?, clone? }) => MemoryPersistence` | `Map`-backed adapter for tests/SSR. Exposes `size()` / `entries()` / `clear()`. Deep-clones on `save` and `load` by default. | ### Rendering + context (`@modular-react/journeys`) diff --git a/packages/journeys/package.json b/packages/journeys/package.json index cf1626c..63d978e 100644 --- a/packages/journeys/package.json +++ b/packages/journeys/package.json @@ -1,6 +1,6 @@ { "name": "@modular-react/journeys", - "version": "0.1.0", + "version": "1.0.0", "description": "Typed, serializable workflows that compose multiple modules. A journey declares entry/exit transitions between modules and owns shared state; modules stay journey-unaware.", "repository": { "type": "git", diff --git a/packages/journeys/src/define-transition.test-d.ts b/packages/journeys/src/define-transition.test-d.ts new file mode 100644 index 0000000..bc6e1cd --- /dev/null +++ b/packages/journeys/src/define-transition.test-d.ts @@ -0,0 +1,341 @@ +// Type-level regression tests for `defineTransition`. Runs through vitest's +// typecheck pass — the assertions fail the test suite if `targets` autocomplete, +// `next` narrowing, or the curried/bare overloads drift. +// +// Covered via `@ts-expect-error` directives plus `expectTypeOf` on the +// returned handler shape. + +import { expectTypeOf, test } from "vitest"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; + +import { + type AnnotatedTransitionHandler, + type StepRef, + defineTransition, +} from "./define-transition.js"; + +// ----------------------------------------------------------------------------- +// Module fixtures with deliberately divergent input shapes per entry — that +// way using the wrong target / module / input is a real type error, not just +// an indistinguishable structural duplicate. +// ----------------------------------------------------------------------------- + +const profile = defineModule({ + id: "profile", + version: "1.0.0", + exitPoints: { + profileComplete: defineExit<{ hint: "cheap" | "premium" }>(), + cancelled: defineExit(), + } as const, + entryPoints: { + review: defineEntry({ + component: (() => null) as never, + input: schema<{ customerId: string }>(), + }), + }, +}); + +const plan = defineModule({ + id: "plan", + version: "1.0.0", + exitPoints: { + choseStandard: defineExit<{ planId: string }>(), + } as const, + entryPoints: { + choose: defineEntry({ + component: (() => null) as never, + input: schema<{ customerId: string; hint: "cheap" | "premium" }>(), + }), + }, +}); + +const billing = defineModule({ + id: "billing", + version: "1.0.0", + exitPoints: { + paid: defineExit<{ reference: string }>(), + } as const, + entryPoints: { + collect: defineEntry({ + component: (() => null) as never, + input: schema<{ customerId: string; amount: number }>(), + }), + }, +}); + +type Modules = { + readonly profile: typeof profile; + readonly plan: typeof plan; + readonly billing: typeof billing; +}; + +interface State { + readonly customerId: string; +} + +const transition = defineTransition(); + +// ----------------------------------------------------------------------------- +// `StepRef` — same `{ module, entry }` shape `next:` uses, minus +// the runtime-computed `input`. Sharing the structure with `next:` keeps +// the API consistent: authors don't flip between an object and a slash-string. +// ----------------------------------------------------------------------------- + +test("StepRef resolves to the union of `{ module, entry }` literals plus terminal sentinels", () => { + expectTypeOf>().toEqualTypeOf< + | { readonly module: "profile"; readonly entry: "review" } + | { readonly module: "plan"; readonly entry: "choose" } + | { readonly module: "billing"; readonly entry: "collect" } + | "complete" + | "abort" + | "invoke" + >(); +}); + +// ----------------------------------------------------------------------------- +// Curried form — the recommended path. +// ----------------------------------------------------------------------------- + +test("curried form: targets infer as a literal tuple without `as const`", () => { + const handler = transition({ + targets: [{ module: "plan", entry: "choose" }], + // Handler must return `{ next: ... }` because that's the only declared + // arm — the new return-narrowing rejects an undeclared `abort` here. + handle: () => ({ + next: { module: "plan", entry: "choose", input: { customerId: "c", hint: "cheap" } }, + }), + }); + // The `const TTargets` modifier on the binder preserves the literal + // tuple AND each target's literal property values, so callers never + // need `as const` on the targets array. + expectTypeOf(handler.targets).toEqualTypeOf< + readonly [{ readonly module: "plan"; readonly entry: "choose" }] + >(); +}); + +test("curried form: targets accept any subset of valid step refs", () => { + // Multi-target — autocomplete-checked against the journey's StepRef union. + const handler = transition({ + targets: [ + { module: "plan", entry: "choose" }, + { module: "billing", entry: "collect" }, + ], + handle: ({ output }) => ({ + next: + (output as { hint: "cheap" | "premium" }).hint === "cheap" + ? { module: "plan", entry: "choose", input: { customerId: "c", hint: "cheap" } } + : { module: "billing", entry: "collect", input: { customerId: "c", amount: 0 } }, + }), + }); + expectTypeOf(handler.targets).toEqualTypeOf< + readonly [ + { readonly module: "plan"; readonly entry: "choose" }, + { readonly module: "billing"; readonly entry: "collect" }, + ] + >(); +}); + +test("curried form: typo on `entry` is a compile error", () => { + transition({ + // @ts-expect-error — `entry: "chooze"` is not a valid entry of `plan`; + // TS reports an excess-property / mismatch error against StepRef. + targets: [{ module: "plan", entry: "chooze" }], + handle: () => ({ abort: { reason: "noop" } }), + }); +}); + +test("curried form: unknown `module` is a compile error", () => { + transition({ + // @ts-expect-error — `module: "ghost"` is not a key of TModules. + targets: [{ module: "ghost", entry: "x" }], + handle: () => ({ abort: { reason: "noop" } }), + }); +}); + +test("curried form: missing `entry` field is a compile error", () => { + transition({ + // @ts-expect-error — every target must specify both `module` and `entry`. + targets: [{ module: "plan" }], + handle: () => ({ abort: { reason: "noop" } }), + }); +}); + +test("curried form: cross-pair (entry from a different module) is a compile error", () => { + transition({ + // @ts-expect-error — `plan` has no entry called `collect` (that's billing's). + targets: [{ module: "plan", entry: "collect" }], + handle: () => ({ abort: { reason: "noop" } }), + }); +}); + +test("curried form: handler `next.module` narrows to keys of TModules", () => { + transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ + // @ts-expect-error — '"ghost"' is not assignable to keyof Modules. + next: { module: "ghost", entry: "choose", input: {} }, + }), + }); +}); + +test("curried form: handler `next.entry` narrows against the chosen module's entries", () => { + transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ + // @ts-expect-error — entry "wrong" is not on plan.entryPoints. + next: { module: "plan", entry: "wrong", input: { customerId: "c", hint: "cheap" } }, + }), + }); +}); + +test("curried form: handler `next.input` narrows against the entry's input schema", () => { + transition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ + // @ts-expect-error — plan.choose's input requires `customerId` AND `hint`; + // omitting `hint` makes the whole `next` literal incompatible with StepSpec. + next: { module: "plan", entry: "choose", input: { customerId: "c" } }, + }), + }); +}); + +test('curried form: terminal-only handler declares `"complete"` sentinel', () => { + const handler = transition({ + targets: ["complete"], + handle: () => ({ complete: undefined }), + }); + expectTypeOf(handler.targets).toEqualTypeOf(); +}); + +test('curried form: terminal-only handler declares `"abort"` sentinel', () => { + const handler = transition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "x" } }), + }); + expectTypeOf(handler.targets).toEqualTypeOf(); +}); + +test("curried form: handler can mix step refs and sentinels", () => { + const handler = transition({ + targets: [{ module: "plan", entry: "choose" }, "abort"], + handle: ({ output }) => + (output as { kind: "ok" | "no" }).kind === "ok" + ? { + next: { module: "plan", entry: "choose", input: { customerId: "c", hint: "cheap" } }, + } + : { abort: { reason: "rejected" } }, + }); + expectTypeOf(handler.targets).toEqualTypeOf< + readonly [{ readonly module: "plan"; readonly entry: "choose" }, "abort"] + >(); +}); + +test("curried form: typo on a sentinel is a compile error", () => { + transition({ + // @ts-expect-error — `"complte"` is not a valid TerminalSentinel. + targets: ["complte"], + handle: () => ({ abort: { reason: "x" } }), + }); +}); + +test("curried form: returning an arm not declared in targets is a compile error (next-only)", () => { + // Declares only the `next` arm — handler may NOT return `abort` / `complete`. + // The directive sits on the `handle:` arrow expression where the error + // actually fires (the return literal doesn't satisfy the narrowed contract). + transition({ + targets: [{ module: "plan", entry: "choose" }], + // @ts-expect-error — `abort` is not in the declared targets. + handle: () => ({ abort: { reason: "x" } }), + }); +}); + +test("curried form: returning an arm not declared in targets is a compile error (terminal-only)", () => { + transition({ + targets: ["abort"], + // @ts-expect-error — `complete` is not in the declared targets. + handle: () => ({ complete: undefined }), + }); +}); + +// ----------------------------------------------------------------------------- +// Bare form — no contextual narrowing on `next`, but `targets` is still +// inferred as a literal tuple via the `const TTargets` modifier. Runtime +// validates each target's `{ module, entry }` shape via `isAnnotatedTransition`. +// ----------------------------------------------------------------------------- + +test("bare form: targets infer as a literal tuple without `as const`", () => { + const handler = defineTransition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ abort: { reason: "noop" } }), + }); + expectTypeOf(handler.targets).toEqualTypeOf< + readonly [{ readonly module: "plan"; readonly entry: "choose" }] + >(); +}); + +test("bare form: targets accept any `{ module, entry }` pair (no StepRef constraint)", () => { + // Any string-keyed object passes — the bare form trades autocomplete for the + // simpler signature. The runtime preloader's lookup against + // `modules[m]?.entryPoints` is the safety net. + const handler = defineTransition({ + targets: [{ module: "anything", entry: "atall" }], + handle: () => ({ abort: { reason: "noop" } }), + }); + expectTypeOf(handler.targets).toEqualTypeOf< + readonly [{ readonly module: "anything"; readonly entry: "atall" }] + >(); +}); + +test("bare form: returns AnnotatedTransitionHandler with intersection of handler + targets", () => { + const handler = defineTransition({ + targets: [{ module: "plan", entry: "choose" }], + handle: (ctx: { state: number; input: string; output: boolean }) => ({ + complete: ctx.input, + }), + }); + // The wrapper preserves the handler's call signature verbatim. + expectTypeOf(handler).parameter(0).toMatchTypeOf<{ + state: number; + input: string; + output: boolean; + }>(); +}); + +// ----------------------------------------------------------------------------- +// Both forms produce a handler that drops into a `transitions` slot — +// runtime invocation at runtime.ts:1338-1359 reads the function's call +// signature and ignores the metadata. +// ----------------------------------------------------------------------------- + +test("both forms produce values assignable to AnnotatedTransitionHandler", () => { + const curried = transition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "x" } }), + }); + const bare = defineTransition({ + targets: ["abort"], + handle: () => ({ abort: { reason: "x" } }), + }); + expectTypeOf(curried).toMatchTypeOf< + AnnotatedTransitionHandler< + (ctx: any) => any, + readonly ( + | { readonly module: string; readonly entry: string } + | "complete" + | "abort" + | "invoke" + )[] + > + >(); + expectTypeOf(bare).toMatchTypeOf< + AnnotatedTransitionHandler< + (ctx: any) => any, + readonly ( + | { readonly module: string; readonly entry: string } + | "complete" + | "abort" + | "invoke" + )[] + > + >(); +}); diff --git a/packages/journeys/src/define-transition.test.ts b/packages/journeys/src/define-transition.test.ts new file mode 100644 index 0000000..f1be710 --- /dev/null +++ b/packages/journeys/src/define-transition.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { + defineTransition, + isAnnotatedTransition, + isTerminalSentinel, +} from "./define-transition.js"; + +describe("defineTransition", () => { + it("returns a function callable with the same signature as the bare handler", () => { + const handler = defineTransition({ + targets: [{ module: "plan", entry: "choose" }], + handle: ({ output }: { output: { hint: string } }) => ({ + next: { module: "plan", entry: "choose", input: { hint: output.hint } }, + }), + }); + expect(typeof handler).toBe("function"); + const result = handler({ + state: undefined, + input: undefined, + output: { hint: "cheap" }, + }); + expect(result).toEqual({ + next: { module: "plan", entry: "choose", input: { hint: "cheap" } }, + }); + }); + + it("attaches `targets` as a non-enumerable readonly property", () => { + const handler = defineTransition({ + targets: [ + { module: "plan", entry: "choose" }, + { module: "billing", entry: "collect" }, + ], + handle: () => ({ abort: { reason: "noop" } }), + }); + expect(handler.targets).toEqual([ + { module: "plan", entry: "choose" }, + { module: "billing", entry: "collect" }, + ]); + // Non-enumerable: structural iteration over the transitions map should + // not surface `targets` as a phantom exit name. + expect(Object.keys(handler)).not.toContain("targets"); + // Frozen: cannot be mutated by accident — neither the array nor the + // individual target objects. + expect(() => { + (handler.targets as unknown as { module: string }[])[0] = { module: "x" } as never; + }).toThrow(); + expect(() => { + (handler.targets[0] as unknown as { module: string }).module = "x"; + }).toThrow(); + }); + + it("freezes a copy of the targets array (caller mutations don't leak in)", () => { + const targets: { module: string; entry: string }[] = [{ module: "plan", entry: "choose" }]; + const handler = defineTransition({ + targets, + handle: () => ({ abort: { reason: "noop" } }), + }); + targets.push({ module: "billing", entry: "collect" }); + expect(handler.targets).toEqual([{ module: "plan", entry: "choose" }]); + }); + + it('supports handlers that return `complete` via the `"complete"` sentinel', () => { + const handler = defineTransition({ + targets: ["complete"] as const, + handle: ({ output }: { output: { id: string } }) => ({ + complete: { result: output.id }, + }), + }); + expect(handler.targets).toEqual(["complete"]); + expect(handler({ state: undefined, input: undefined, output: { id: "x" } })).toEqual({ + complete: { result: "x" }, + }); + }); + + it('supports handlers that return `abort` via the `"abort"` sentinel', () => { + const handler = defineTransition({ + targets: ["abort"] as const, + handle: () => ({ abort: { reason: "user-cancelled" } }), + }); + expect(handler.targets).toEqual(["abort"]); + expect(handler({ state: undefined, input: undefined, output: undefined })).toEqual({ + abort: { reason: "user-cancelled" }, + }); + }); + + it("mixes step refs and terminal sentinels in the same `targets` array", () => { + const handler = defineTransition({ + targets: [{ module: "plan", entry: "choose" }, "abort"] as const, + handle: ({ output }: { output: { kind: "ok" | "no" } }) => + output.kind === "ok" + ? { next: { module: "plan", entry: "choose", input: {} } } + : { abort: { reason: "rejected" } }, + }); + expect(handler.targets).toEqual([{ module: "plan", entry: "choose" }, "abort"]); + expect(handler({ state: undefined, input: undefined, output: { kind: "ok" } })).toEqual({ + next: { module: "plan", entry: "choose", input: {} }, + }); + expect(handler({ state: undefined, input: undefined, output: { kind: "no" } })).toEqual({ + abort: { reason: "rejected" }, + }); + }); + + it("freezes string sentinels in place (they're already immutable, but the array stays frozen)", () => { + const handler = defineTransition({ + targets: ["complete"] as const, + handle: () => ({ complete: undefined }), + }); + expect(() => { + (handler.targets as unknown as string[]).push("abort"); + }).toThrow(); + }); + + it("throws an actionable error when the same handler is passed twice", () => { + // Reusing the handler reference would crash on the second + // `Object.defineProperty` call (the property is non-configurable so the + // first stamp can't be silently overwritten). The guard surfaces a + // clear message instead of the cryptic `Cannot redefine property` engine + // error — protects authors who accidentally factor a shared handler. + const shared = () => ({ abort: { reason: "x" } }); + defineTransition({ targets: ["abort"] as const, handle: shared }); + expect(() => defineTransition({ targets: ["abort"] as const, handle: shared })).toThrow( + /passed to defineTransition twice/, + ); + }); + + it("curried form binds the journey's generics and stamps targets identically", () => { + // No-arg call returns the binder. The binder behaves like the bare form + // at runtime — the journey's generics flow only through the type system. + const tx = defineTransition(); + const handler = tx({ + targets: [{ module: "plan", entry: "choose" }], + handle: ({ output }) => ({ + next: { + module: "plan", + entry: "choose", + input: { hint: (output as { hint: string }).hint }, + }, + }), + }); + expect(handler.targets).toEqual([{ module: "plan", entry: "choose" }]); + expect(typeof handler).toBe("function"); + // Same metadata stamping behavior as the bare form. + expect(Object.keys(handler)).not.toContain("targets"); + }); +}); + +describe("isAnnotatedTransition", () => { + it("returns true for a defineTransition-wrapped handler", () => { + const handler = defineTransition({ + targets: [{ module: "plan", entry: "choose" }], + handle: () => ({ abort: { reason: "noop" } }), + }); + expect(isAnnotatedTransition(handler)).toBe(true); + }); + + it("returns false for a bare function handler", () => { + const bare = () => ({ abort: { reason: "noop" } }); + expect(isAnnotatedTransition(bare)).toBe(false); + }); + + it("returns false for a handler with a non-{module,entry} `targets`", () => { + const fake = Object.assign(() => ({ abort: { reason: "noop" } }), { + // Old slash-string form should NOT pass — both keys must be present + // as separate properties on each target object. + targets: ["plan/choose"] as unknown as readonly { module: string; entry: string }[], + }); + expect(isAnnotatedTransition(fake)).toBe(false); + }); + + it("returns false when target objects miss the `entry` field", () => { + const fake = Object.assign(() => ({ abort: { reason: "noop" } }), { + targets: [{ module: "plan" }] as unknown as readonly { module: string; entry: string }[], + }); + expect(isAnnotatedTransition(fake)).toBe(false); + }); + + it("returns true when targets contain only terminal sentinels", () => { + const handler = defineTransition({ + targets: ["abort"] as const, + handle: () => ({ abort: { reason: "x" } }), + }); + expect(isAnnotatedTransition(handler)).toBe(true); + }); + + it("returns true for a mixed array of step refs and sentinels", () => { + const handler = defineTransition({ + targets: [{ module: "plan", entry: "choose" }, "complete"] as const, + handle: () => ({ complete: undefined }), + }); + expect(isAnnotatedTransition(handler)).toBe(true); + }); + + it("returns false for unknown sentinel strings", () => { + const fake = Object.assign(() => ({ abort: { reason: "noop" } }), { + targets: ["maybe"] as unknown as readonly ("complete" | "abort" | "invoke")[], + }); + expect(isAnnotatedTransition(fake)).toBe(false); + }); + + it("returns false for non-function values", () => { + expect(isAnnotatedTransition({})).toBe(false); + expect(isAnnotatedTransition(null)).toBe(false); + expect(isAnnotatedTransition("plan/choose")).toBe(false); + }); +}); + +describe("isTerminalSentinel", () => { + it("recognizes the three documented sentinels", () => { + expect(isTerminalSentinel("complete")).toBe(true); + expect(isTerminalSentinel("abort")).toBe(true); + expect(isTerminalSentinel("invoke")).toBe(true); + }); + + it("rejects unknown strings and non-strings", () => { + expect(isTerminalSentinel("done")).toBe(false); + expect(isTerminalSentinel("")).toBe(false); + expect(isTerminalSentinel({ module: "plan", entry: "choose" })).toBe(false); + expect(isTerminalSentinel(undefined)).toBe(false); + expect(isTerminalSentinel(null)).toBe(false); + }); +}); diff --git a/packages/journeys/src/define-transition.ts b/packages/journeys/src/define-transition.ts new file mode 100644 index 0000000..46ea832 --- /dev/null +++ b/packages/journeys/src/define-transition.ts @@ -0,0 +1,306 @@ +import type { + EntryInputOf, + EntryNamesOf, + ExitCtx, + InvokeSpec, + ModuleTypeMap, + TransitionResult, +} from "@modular-react/core"; + +/** + * Sentinel value declaring a non-`next` outcome on a wrapped transition + * handler. Mixed into the `targets:` array alongside `{ module, entry }` + * step refs so a single declaration captures every branch the handler + * may take. + * + * - `"complete"` — handler may return `{ complete: ... }` (terminates). + * - `"abort"` — handler may return `{ abort: ... }` (terminates with abort). + * - `"invoke"` — handler may return `{ invoke: { handle, input, resume } }` + * (suspends the parent, runs a child journey). The specific handle is + * not type-narrowed here — the journey definition's `invokes` field + * remains the closed-set declaration the runtime cycle / undeclared- + * child guards check against. + */ +export type TerminalSentinel = "complete" | "abort" | "invoke"; + +/** + * Reference to one possible outcome of a transition handler. Used by + * {@link defineTransition} to declare every branch the handler may take; + * the host's auto-preloader reads the `{ module, entry }` entries to warm + * chunks for next-step candidates from the current step, and the catalog + * harvester reads the sentinels to derive `aborts` / `completes` flags + * without an AST walk over the handler body. + * + * The `{ module, entry }` shape mirrors the `next:` field handlers return — + * a step ref is just `StepSpec` without the runtime-computed `input` — + * so authors don't flip between two notations for the same idea. + * + * When `TModules` is bound (via the curried `defineTransition()` + * binder), `module` narrows to `keyof TModules` and `entry` narrows to that + * module's `entryPoints` keys. + */ +export type StepRef = + | { + [M in keyof TModules & string]: { + [E in EntryNamesOf & string]: { + readonly module: M; + readonly entry: E; + }; + }[EntryNamesOf & string]; + }[keyof TModules & string] + | TerminalSentinel; + +/** + * Type predicate that splits a `StepRef` into its step-ref vs sentinel arms. + * Object refs land in the `next:` arm; sentinels gate the terminal arms. + */ +type StepObjectRef = { readonly module: string; readonly entry: string }; + +/** + * Build the `next.{ module, entry, input }` shape for one declared step ref. + * Distributes over a union of refs so multiple targets produce a union of + * step specs under a single `next:` key (rather than separate `{ next: A }` + * vs `{ next: B }` arms — the latter would reject conditional handler + * returns like `next: cond ? planRef : billingRef`). + */ +type StepSpecFromRef = TRef extends { + readonly module: infer M; + readonly entry: infer E; +} + ? M extends keyof TModules & string + ? E extends EntryNamesOf & string + ? { + readonly module: M; + readonly entry: E; + readonly input: EntryInputOf; + } + : never + : never + : never; + +/** + * Narrow the handler return type to only the arms whose targets are declared. + * - `{ module, entry }` in targets → `next:` arm allowed (with `input` typed + * against the chosen entry). Multiple refs collapse into one `next:` key + * whose value is the union of step specs. + * - `"complete"` in targets → `complete:` arm allowed. + * - `"abort"` in targets → `abort:` arm allowed. + * - `"invoke"` in targets → `invoke:` arm allowed. + * + * Declaring an arm in targets but never returning it is fine (over-declaring + * is conservative for preload). Returning an arm that wasn't declared is a + * compile error — the wrapped handler can't drift past the declaration. + */ +type NarrowedTransitionResult< + TModules extends ModuleTypeMap, + TState, + TOutput, + TTargets extends readonly StepRef[], +> = + | (Extract extends never + ? never + : { + readonly next: StepSpecFromRef>; + readonly state?: TState; + }) + | (Extract extends never + ? never + : { readonly complete: TOutput; readonly state?: TState }) + | (Extract extends never + ? never + : { readonly abort: unknown; readonly state?: TState }) + | (Extract extends never + ? never + : { readonly invoke: InvokeSpec; readonly state?: TState }); + +/** + * A transition handler with declared `targets` metadata attached. Functionally + * identical to the bare handler the journey runtime expects — the call + * signature is preserved verbatim, so the value drops directly into a + * `transitions[mod][entry][exit]` slot. The host's preloader walks + * `Object.values(perEntry)` and reads each handler's `.targets` to schedule + * speculative imports. + */ +export type AnnotatedTransitionHandler< + THandler extends (ctx: any) => any, + TTargets extends readonly (StepObjectRef | TerminalSentinel)[], +> = THandler & { readonly targets: TTargets }; + +interface DefineTransitionSpec< + THandler extends (ctx: any) => any, + TTargets extends readonly (StepObjectRef | TerminalSentinel)[], +> { + readonly targets: TTargets; + readonly handle: THandler; +} + +function attach< + THandler extends (ctx: any) => any, + TTargets extends readonly (StepObjectRef | TerminalSentinel)[], +>(spec: DefineTransitionSpec): AnnotatedTransitionHandler { + const handler = spec.handle as AnnotatedTransitionHandler; + // Reusing the same function reference across two `defineTransition` calls + // would crash on the second `Object.defineProperty` with a cryptic + // `TypeError: Cannot redefine property: targets` (we set the property + // non-configurable so frozen targets can't be silently replaced). Detect + // the reuse explicitly and surface an actionable message instead. + if (Object.getOwnPropertyDescriptor(handler, "targets") !== undefined) { + throw new TypeError( + "[@modular-react/journeys] defineTransition: the same handler function was passed to defineTransition twice. " + + "Each transition needs its own handler — pass an inline arrow / function literal per `defineTransition({ ... })` call.", + ); + } + // Non-enumerable so structural iteration (Object.entries on the transitions + // map, JSON serialization of journey snapshots) does not surface this field; + // the preloader reads it via direct property access. + Object.defineProperty(handler, "targets", { + value: Object.freeze( + spec.targets.map((t) => (typeof t === "string" ? t : Object.freeze({ ...t }))), + ) as TTargets, + enumerable: false, + writable: false, + configurable: false, + }); + return handler; +} + +/** + * Curried binder used by {@link defineTransition} to thread the journey's + * `TModules` / `TState` / `TOutput` into the handler's contextual return + * type — `next.module` / `next.entry` and the choice of arms (`next` vs + * `complete` vs `abort` vs `invoke`) check against the bound generics + * instead of widening to plain `string` / accepting any arm. + */ +export interface TypedTransitionBinder { + < + const TTargets extends readonly StepRef[], + TEntryInput = unknown, + TExitOutput = unknown, + >(spec: { + readonly targets: TTargets; + readonly handle: ( + ctx: ExitCtx, + ) => NarrowedTransitionResult; + }): AnnotatedTransitionHandler< + (ctx: ExitCtx) => TransitionResult, + TTargets + >; +} + +/** + * Wrap a transition handler with a static declaration of every outcome it may + * take. Two effects: + * + * 1. **Runtime — preload precision.** ``'s default + * `preload="precise"` mode reads `targets` and warms exactly those + * entries' chunks during idle time, so navigating Next finds the + * chunk already cached. Bare-function handlers contribute nothing + * to precise mode (they fall back to `preload="aggressive"` if set). + * Sentinel targets (`"complete"`, `"abort"`, `"invoke"`) carry no + * chunk to preload — they are skipped. + * + * 2. **Type-level — the handler's return is constrained to the declared + * arms.** Declaring `targets: [{ module: "plan", entry: "choose" }]` + * means the handler may only return `{ next: ... }`; declaring + * `targets: ["abort"]` means only `{ abort: ... }`; mixing both + * allows either. Returning an undeclared arm is a compile error. + * + * **`targets` is mandatory.** A wrapped handler must enumerate every + * outcome it may take. If you don't want a declaration, use a bare + * function — the runtime invocation path is identical, and bare handlers + * sit out of precise-mode preload. + * + * Two call shapes: + * + * ```ts + * // Curried (recommended): bind the journey's generics once, get full + * // contextual typing on every wrapped handler. Naming convention mirrors + * // `selectModule` (a descriptive verb for the binder, not an + * // abbreviation — `tx` reads as "transaction" in most codebases). + * const transition = defineTransition(); + * + * profileComplete: transition({ + * targets: [{ module: "plan", entry: "choose" }], + * handle: ({ output, state }) => ({ + * state: { ...state, hint: output.hint }, + * next: { module: "plan", entry: "choose", input: ... }, + * }), + * }), + * + * // Mix step refs with sentinels for handlers that branch between + * // next and a terminal arm: + * checkout: transition({ + * targets: [{ module: "plan", entry: "choose" }, "abort"], + * handle: ({ output }) => + * output.kind === "ok" + * ? { next: { module: "plan", entry: "choose", input: ... } } + * : { abort: { reason: "user-cancelled" } }, + * }), + * + * // Bare: zero-config, no contextual narrowing. Targets accept any + * // `{ module: string; entry: string } | "complete" | "abort" | "invoke"`; + * // useful for one-off handlers or for journeys whose return literals + * // are already typed by an outer annotation. + * cancelled: defineTransition({ + * targets: ["abort"], + * handle: () => ({ abort: { reason: "user-cancelled" } }), + * }), + * ``` + */ +export function defineTransition< + TModules extends ModuleTypeMap, + TState = unknown, + TOutput = unknown, +>(): TypedTransitionBinder; +export function defineTransition< + THandler extends (ctx: any) => any, + const TTargets extends readonly (StepObjectRef | TerminalSentinel)[], +>(spec: DefineTransitionSpec): AnnotatedTransitionHandler; +export function defineTransition(specOrNothing?: DefineTransitionSpec): unknown { + if (specOrNothing === undefined) { + // Curried form — return the binder. The binder reuses `attach` so the + // metadata-stamping logic stays in one place. + return ((spec: DefineTransitionSpec) => attach(spec)) as TypedTransitionBinder< + ModuleTypeMap, + unknown, + unknown + >; + } + return attach(specOrNothing); +} + +/** + * Narrow a value to the annotated form. Used by the auto-preloader to read + * `targets` from a handler without trusting structural lookups on `unknown`. + * Each target must be either a `{ module, entry }` string-pair or one of + * the recognized sentinel strings. + */ +export function isAnnotatedTransition( + value: unknown, +): value is AnnotatedTransitionHandler< + (ctx: any) => any, + readonly (StepObjectRef | TerminalSentinel)[] +> { + if (typeof value !== "function") return false; + const targets = (value as { targets?: unknown }).targets; + if (!Array.isArray(targets)) return false; + return targets.every( + (t) => + isTerminalSentinel(t) || + (typeof t === "object" && + t !== null && + typeof (t as { module?: unknown }).module === "string" && + typeof (t as { entry?: unknown }).entry === "string"), + ); +} + +const TERMINAL_SENTINELS = new Set(["complete", "abort", "invoke"]); + +/** + * Narrow a value to one of the recognized {@link TerminalSentinel} strings. + * Exposed for hosts that introspect a wrapped handler's targets and want to + * separate step refs from terminal arms without a string-equality dance. + */ +export function isTerminalSentinel(value: unknown): value is TerminalSentinel { + return typeof value === "string" && TERMINAL_SENTINELS.has(value as TerminalSentinel); +} diff --git a/packages/journeys/src/index.ts b/packages/journeys/src/index.ts index b2806fb..6f9072f 100644 --- a/packages/journeys/src/index.ts +++ b/packages/journeys/src/index.ts @@ -65,6 +65,16 @@ export type { JourneyHandle } from "./handle.js"; export { selectModule, selectModuleOrDefault } from "./select-module.js"; export type { SelectModuleCases, SelectModuleCasesPartial } from "./select-module.js"; +// Authoring helpers — annotate a transition handler with the entry points it +// can advance into. Read by `` (the default) +// to warm exactly those chunks during idle time. +export { + defineTransition, + isAnnotatedTransition, + isTerminalSentinel, +} from "./define-transition.js"; +export type { AnnotatedTransitionHandler, StepRef, TerminalSentinel } from "./define-transition.js"; + export type { AbandonCtx, AnyJourneyDefinition, diff --git a/packages/journeys/src/module-tab.test.tsx b/packages/journeys/src/module-tab.test.tsx index 49677ae..8d0450e 100644 --- a/packages/journeys/src/module-tab.test.tsx +++ b/packages/journeys/src/module-tab.test.tsx @@ -165,4 +165,36 @@ describe("ModuleTab", () => { const { getByTestId } = render(); expect(getByTestId("void-marker")).toBeTruthy(); }); + + it("renders a lazy entry — fallback first, resolved component after the chunk loads", async () => { + let resolveImport!: (mod: { default: typeof Review }) => void; + const lazyImporter = vi.fn( + () => + new Promise<{ default: typeof Review }>((res) => { + resolveImport = res; + }), + ); + const lazyMod = defineModule({ + id: "review-lazy", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + review: defineEntry({ + lazy: lazyImporter, + fallback: loading…, + input: schema<{ customerId: string }>(), + }), + }, + }); + const { getByTestId } = render( + , + ); + expect(getByTestId("lazy-fallback")).toBeTruthy(); + expect(lazyImporter).toHaveBeenCalledTimes(1); + await act(async () => { + resolveImport({ default: Review }); + await Promise.resolve(); + }); + expect(getByTestId("cid").textContent).toBe("C-9"); + }); }); diff --git a/packages/journeys/src/module-tab.tsx b/packages/journeys/src/module-tab.tsx index 3ab7752..a53954f 100644 --- a/packages/journeys/src/module-tab.tsx +++ b/packages/journeys/src/module-tab.tsx @@ -1,7 +1,12 @@ -import { createElement } from "react"; +import { Suspense, createElement } from "react"; import type { ComponentType, ReactNode } from "react"; -import type { ExitPointMap, ModuleDescriptor, ModuleEntryProps } from "@modular-react/core"; -import { ModuleErrorBoundary, useModuleExit, type ModuleExitEvent } from "@modular-react/react"; +import type { ModuleDescriptor } from "@modular-react/core"; +import { + ModuleErrorBoundary, + resolveEntryComponent, + useModuleExit, + type ModuleExitEvent, +} from "@modular-react/react"; /** * Exit event fired by a module rendered inside a ``. @@ -98,10 +103,15 @@ export function ModuleTab(props: ModuleTabProps): Reac `Pass \`input={undefined}\` explicitly if the entry accepts no input.`, ); } else { - const Component = entryPoint.component as ComponentType< - ModuleEntryProps - >; - content = createElement(Component, { input: input as TInput, exit }); + const { Component } = resolveEntryComponent(entryPoint); + // `fallback` is typed `never` on eager entries (always `undefined` at + // runtime), `ReactNode | undefined` on lazy entries. + const fallback = entryPoint.fallback ?? null; + content = createElement( + Suspense, + { fallback }, + createElement(Component, { input: input as TInput, exit }), + ); } } else if (mod.component) { // Back-compat: render the legacy workspace component when the module diff --git a/packages/journeys/src/outlet-preload.test.tsx b/packages/journeys/src/outlet-preload.test.tsx new file mode 100644 index 0000000..f57f760 --- /dev/null +++ b/packages/journeys/src/outlet-preload.test.tsx @@ -0,0 +1,547 @@ +import { Suspense } from "react"; +import type { ReactElement } from "react"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core"; +import type { ModuleEntryProps } from "@modular-react/core"; + +import { defineJourney } from "./define-journey.js"; +import { defineTransition } from "./define-transition.js"; +import { createJourneyRuntime } from "./runtime.js"; +import { JourneyOutlet } from "./outlet.js"; + +// Capture once at import time so afterEach can restore whatever the host +// environment set (most likely `undefined` under happy-dom, but a downstream +// test runner could shim it). Without restoration, deleting the global here +// would leak across test files on shared workers. +const globalWithRic = globalThis as typeof globalThis & { requestIdleCallback?: unknown }; +const originalRequestIdleCallback = globalWithRic.requestIdleCallback; + +afterEach(() => { + cleanup(); + if (originalRequestIdleCallback === undefined) { + delete globalWithRic.requestIdleCallback; + } else { + globalWithRic.requestIdleCallback = originalRequestIdleCallback; + } +}); + +beforeEach(() => { + // happy-dom doesn't ship requestIdleCallback. Force the setTimeout(_, 0) + // fallback explicitly so the assertion is unambiguous. + delete globalWithRic.requestIdleCallback; +}); + +// --- Per-test factory --------------------------------------------------------- + +const startExits = { + toCheap: defineExit(), + toExpensive: defineExit(), +} as const; + +function StartScreen({ exit }: ModuleEntryProps): ReactElement { + return ( +
+ start + + +
+ ); +} + +const cheapExits = { done: defineExit() } as const; +function CheapStep(_props: ModuleEntryProps): ReactElement { + return cheap step; +} + +const expensiveExits = { done: defineExit() } as const; +function ExpensiveStep(_props: ModuleEntryProps): ReactElement { + return expensive step; +} + +const unrelatedExits = { done: defineExit() } as const; +function UnrelatedStep(_props: ModuleEntryProps): ReactElement { + return unrelated step; +} + +interface PreloadFixtures { + readonly cheapImporter: Mock; + readonly expensiveImporter: Mock; + readonly unrelatedImporter: Mock; + readonly modules: Readonly<{ + start: ReturnType; + cheap: ReturnType; + expensive: ReturnType; + unrelated: ReturnType; + }>; +} + +// Each test gets fresh mock importers + fresh entry objects, so the +// process-wide `WeakMap` inside `resolveEntryComponent` doesn't bleed +// state across tests. +function makeFixtures(): PreloadFixtures { + const cheapImporter = vi.fn(() => Promise.resolve({ default: CheapStep })); + const expensiveImporter = vi.fn(() => Promise.resolve({ default: ExpensiveStep })); + const unrelatedImporter = vi.fn(() => Promise.resolve({ default: UnrelatedStep })); + + const startModule = defineModule({ + id: "start", + version: "1.0.0", + exitPoints: startExits, + entryPoints: { + pick: defineEntry({ component: StartScreen, input: schema() }), + }, + }); + const cheapModule = defineModule({ + id: "cheap", + version: "1.0.0", + exitPoints: cheapExits, + entryPoints: { + show: defineEntry({ lazy: cheapImporter, input: schema() }), + }, + }); + const expensiveModule = defineModule({ + id: "expensive", + version: "1.0.0", + exitPoints: expensiveExits, + entryPoints: { + show: defineEntry({ lazy: expensiveImporter, input: schema() }), + }, + }); + const unrelatedModule = defineModule({ + id: "unrelated", + version: "1.0.0", + exitPoints: unrelatedExits, + entryPoints: { + show: defineEntry({ lazy: unrelatedImporter, input: schema() }), + }, + }); + + return { + cheapImporter, + expensiveImporter, + unrelatedImporter, + modules: { + start: startModule, + cheap: cheapModule, + expensive: expensiveModule, + unrelated: unrelatedModule, + }, + }; +} + +function makeAnnotatedJourney(modules: PreloadFixtures["modules"]) { + type M = typeof modules; + return defineJourney()({ + id: "annotated", + version: "1.0.0", + initialState: () => ({ _: true }) as const, + start: () => ({ module: "start", entry: "pick", input: undefined as never }), + transitions: { + start: { + pick: { + toCheap: defineTransition({ + targets: [{ module: "cheap", entry: "show" }], + handle: () => ({ + next: { module: "cheap", entry: "show", input: undefined as never }, + }), + }), + toExpensive: defineTransition({ + targets: [{ module: "expensive", entry: "show" }], + handle: () => ({ + next: { module: "expensive", entry: "show", input: undefined as never }, + }), + }), + }, + }, + }, + }); +} + +function makeBareJourney(modules: PreloadFixtures["modules"]) { + type M = typeof modules; + return defineJourney()({ + id: "bare", + version: "1.0.0", + initialState: () => ({ _: true }) as const, + start: () => ({ module: "start", entry: "pick", input: undefined as never }), + transitions: { + start: { + pick: { + toCheap: () => ({ + next: { module: "cheap", entry: "show", input: undefined as never }, + }), + toExpensive: () => ({ + next: { module: "expensive", entry: "show", input: undefined as never }, + }), + }, + }, + cheap: { show: { done: () => ({ complete: undefined }) } }, + expensive: { show: { done: () => ({ complete: undefined }) } }, + }, + }); +} + +async function flushIdle() { + await act(async () => { + await new Promise((res) => setTimeout(res, 0)); + }); +} + +// --- Tests -------------------------------------------------------------------- + +describe("JourneyOutlet — auto-preload (precise, default)", () => { + it("preloads only the entries declared by annotated transition handlers", async () => { + const fx = makeFixtures(); + const journey = makeAnnotatedJourney(fx.modules); + const rt = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: fx.modules, + debug: false, + }); + const id = rt.start("annotated", undefined as never); + render( + + + , + ); + await flushIdle(); + expect(fx.cheapImporter).toHaveBeenCalledTimes(1); + expect(fx.expensiveImporter).toHaveBeenCalledTimes(1); + expect(fx.unrelatedImporter).not.toHaveBeenCalled(); + }); + + it("skips terminal sentinels when collecting preload candidates", async () => { + // A handler whose only declared outcome is a sentinel ("abort") has + // nothing to preload — the outlet must not interpret the string as a + // module/entry pair. + const fx = makeFixtures(); + type M = typeof fx.modules; + const sentinelJourney = defineJourney()({ + id: "sentinel-only", + version: "1.0.0", + initialState: () => ({ _: true }) as const, + start: () => ({ module: "start", entry: "pick", input: undefined as never }), + transitions: { + start: { + pick: { + toCheap: defineTransition({ + targets: ["abort"] as const, + handle: () => ({ abort: { reason: "user-cancelled" } }), + }), + toExpensive: defineTransition({ + targets: [{ module: "expensive", entry: "show" }, "complete"] as const, + handle: () => ({ complete: undefined }), + }), + }, + }, + }, + }); + const rt = createJourneyRuntime([{ definition: sentinelJourney, options: undefined }], { + modules: fx.modules, + debug: false, + }); + const id = rt.start("sentinel-only", undefined as never); + render( + + + , + ); + await flushIdle(); + // `cheap` is never preloaded — `toCheap`'s only target is the abort sentinel. + expect(fx.cheapImporter).not.toHaveBeenCalled(); + // `expensive/show` IS preloaded (the object ref); the `"complete"` + // sentinel sitting next to it is silently skipped, not crashed on. + expect(fx.expensiveImporter).toHaveBeenCalledTimes(1); + }); + + it("does not preload anything when handlers are bare functions (no `targets`)", async () => { + const fx = makeFixtures(); + const journey = makeBareJourney(fx.modules); + const rt = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: fx.modules, + debug: false, + }); + const id = rt.start("bare", undefined as never); + render( + + + , + ); + await flushIdle(); + expect(fx.cheapImporter).not.toHaveBeenCalled(); + expect(fx.expensiveImporter).not.toHaveBeenCalled(); + }); + + it("re-runs the preload set when the step advances", async () => { + // Targets vary by exit (`toCheap` → cheap/show; `toExpensive` → expensive/show). + // Pick `cheap` first; only that chunk is preloaded. Then advance to it and + // verify nothing new is scheduled for the now-current step (its own + // transitions in this fixture are empty, so no further preload). + const fx = makeFixtures(); + type M = typeof fx.modules; + const stepThroughJourney = defineJourney()({ + id: "step-through", + version: "1.0.0", + initialState: () => ({ _: true }) as const, + start: () => ({ module: "start", entry: "pick", input: undefined as never }), + transitions: { + start: { + pick: { + toCheap: defineTransition({ + targets: [{ module: "cheap", entry: "show" }], + handle: () => ({ + next: { module: "cheap", entry: "show", input: undefined as never }, + }), + }), + toExpensive: defineTransition({ + targets: [{ module: "expensive", entry: "show" }], + handle: () => ({ + next: { module: "expensive", entry: "show", input: undefined as never }, + }), + }), + }, + }, + cheap: { + show: { + done: defineTransition({ + targets: [{ module: "unrelated", entry: "show" }], + handle: () => ({ + next: { module: "unrelated", entry: "show", input: undefined as never }, + }), + }), + }, + }, + }, + }); + const rt = createJourneyRuntime([{ definition: stepThroughJourney, options: undefined }], { + modules: fx.modules, + debug: false, + }); + const id = rt.start("step-through", undefined as never); + const { getByText } = render( + + + , + ); + await flushIdle(); + // First step's targets: cheap/show + expensive/show. + expect(fx.cheapImporter).toHaveBeenCalledTimes(1); + expect(fx.expensiveImporter).toHaveBeenCalledTimes(1); + expect(fx.unrelatedImporter).not.toHaveBeenCalled(); + + // Advance to cheap/show — the next step's targets reference unrelated/show, + // which should now be preloaded. + await act(async () => { + getByText("cheap").click(); + }); + await flushIdle(); + expect(fx.unrelatedImporter).toHaveBeenCalledTimes(1); + // Already-preloaded chunks aren't re-fetched (idempotent). + expect(fx.cheapImporter).toHaveBeenCalledTimes(1); + expect(fx.expensiveImporter).toHaveBeenCalledTimes(1); + }); +}); + +describe("JourneyOutlet — auto-preload (precise) with scoped module ids", () => { + it("splits `${module}/${entry}` on the LAST slash so scoped ids round-trip", async () => { + // Module id contains a slash (npm-style scope). The preloader must + // split on the LAST `/` or it would look up `@scope` instead of + // `@scope/billing`. + const importer = vi.fn(() => Promise.resolve({ default: CheapStep })); + const scopedExits = { done: defineExit() } as const; + const scopedModule = defineModule({ + id: "@scope/billing", + version: "1.0.0", + exitPoints: scopedExits, + entryPoints: { + review: defineEntry({ lazy: importer, input: schema() }), + }, + }); + const startMod = defineModule({ + id: "start", + version: "1.0.0", + exitPoints: { go: defineExit() } as const, + entryPoints: { + pick: defineEntry({ component: StartScreen, input: schema() }), + }, + }); + const localModules = { start: startMod, "@scope/billing": scopedModule }; + type LocalModules = typeof localModules; + const journey = defineJourney()({ + id: "scoped", + version: "1.0.0", + initialState: () => ({ _: true }) as const, + start: () => ({ module: "start", entry: "pick", input: undefined as never }), + transitions: { + start: { + pick: { + go: defineTransition({ + targets: [{ module: "@scope/billing", entry: "review" }], + handle: () => ({ + next: { + module: "@scope/billing", + entry: "review", + input: undefined as never, + }, + }), + }), + }, + }, + }, + }); + const rt = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: localModules, + debug: false, + }); + const id = rt.start("scoped", undefined as never); + render( + + + , + ); + await flushIdle(); + expect(importer).toHaveBeenCalledTimes(1); + }); +}); + +describe("JourneyOutlet — auto-preload (aggressive)", () => { + it('preloads every entry referenced as a transition source when `preload="aggressive"`', async () => { + const fx = makeFixtures(); + const journey = makeBareJourney(fx.modules); + const rt = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: fx.modules, + debug: false, + }); + const id = rt.start("bare", undefined as never); + render( + + + , + ); + await flushIdle(); + // `start/pick` is the current step → skipped. `cheap/show` and + // `expensive/show` are both transition sources → preloaded. + expect(fx.cheapImporter).toHaveBeenCalledTimes(1); + expect(fx.expensiveImporter).toHaveBeenCalledTimes(1); + // `unrelated` is registered as a module but is not a transition source + // anywhere in this journey → not preloaded even in aggressive mode. + expect(fx.unrelatedImporter).not.toHaveBeenCalled(); + }); + + it("preloads destination-only entries reached via annotated `targets:` even when they have no outbound transitions", async () => { + // The shape this test guards against: an entry that's a destination of + // some annotated handler but is itself terminal (no outbound transitions + // wired). Source-keys-only enumeration would miss it; the + // destinations-side pass through annotated targets must close the gap. + const fx = makeFixtures(); + type M = typeof fx.modules; + const destOnlyJourney = defineJourney()({ + id: "dest-only", + version: "1.0.0", + initialState: () => ({ _: true }) as const, + start: () => ({ module: "start", entry: "pick", input: undefined as never }), + transitions: { + start: { + pick: { + // `unrelated/show` is the destination — it has NO outbound + // transitions of its own, so source-keys enumeration would + // skip it. The annotated target must surface it. + toUnrelated: defineTransition({ + targets: [{ module: "unrelated", entry: "show" }], + handle: () => ({ + next: { module: "unrelated", entry: "show", input: undefined as never }, + }), + }), + }, + }, + }, + }); + const rt = createJourneyRuntime([{ definition: destOnlyJourney, options: undefined }], { + modules: fx.modules, + debug: false, + }); + const id = rt.start("dest-only", undefined as never); + render( + + + , + ); + await flushIdle(); + expect(fx.unrelatedImporter).toHaveBeenCalledTimes(1); + }); +}); + +describe("JourneyOutlet — auto-preload (off)", () => { + it("does not preload when `preload={false}`", async () => { + const fx = makeFixtures(); + const journey = makeAnnotatedJourney(fx.modules); + const rt = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: fx.modules, + debug: false, + }); + const id = rt.start("annotated", undefined as never); + render( + + + , + ); + await flushIdle(); + expect(fx.cheapImporter).not.toHaveBeenCalled(); + expect(fx.expensiveImporter).not.toHaveBeenCalled(); + }); +}); + +describe("JourneyOutlet — lazy step rendering", () => { + it("renders the entry's `fallback` while the lazy chunk loads, then the resolved component", async () => { + let resolveCheap!: (mod: { default: typeof CheapStep }) => void; + const cheapDeferredImporter = vi.fn( + () => + new Promise<{ default: typeof CheapStep }>((res) => { + resolveCheap = res; + }), + ); + const cheapDeferredModule = defineModule({ + id: "cheap-deferred", + version: "1.0.0", + exitPoints: cheapExits, + entryPoints: { + show: defineEntry({ + lazy: cheapDeferredImporter, + fallback: loading…, + input: schema(), + }), + }, + }); + const localModules = { "cheap-deferred": cheapDeferredModule }; + type LocalModules = typeof localModules; + const localJourney = defineJourney()({ + id: "deferred", + version: "1.0.0", + initialState: () => ({ _: true }) as const, + start: () => ({ + module: "cheap-deferred", + entry: "show", + input: undefined as never, + }), + transitions: {}, + }); + const rt = createJourneyRuntime([{ definition: localJourney, options: undefined }], { + modules: localModules, + debug: false, + }); + const id = rt.start("deferred", undefined as never); + const { getByTestId } = render( + , + ); + expect(getByTestId("cheap-fallback")).toBeTruthy(); + expect(cheapDeferredImporter).toHaveBeenCalledTimes(1); + await act(async () => { + resolveCheap({ default: CheapStep }); + await Promise.resolve(); + }); + await waitFor(() => { + expect(getByTestId("cheap")).toBeTruthy(); + }); + }); +}); diff --git a/packages/journeys/src/outlet.tsx b/packages/journeys/src/outlet.tsx index 3fc72cc..13783b7 100644 --- a/packages/journeys/src/outlet.tsx +++ b/packages/journeys/src/outlet.tsx @@ -1,5 +1,6 @@ import { Component, + Suspense, createElement, useEffect, useMemo, @@ -8,11 +9,19 @@ import { useSyncExternalStore, } from "react"; import type { ComponentType, ReactNode } from "react"; -import type { ExitPointMap, ModuleDescriptor, ModuleEntryProps } from "@modular-react/core"; +import type { ModuleDescriptor, ModuleEntryPoint } from "@modular-react/core"; +import { resolveEntryComponent } from "@modular-react/react"; import { getInternals } from "./runtime.js"; import { useJourneyContext } from "./provider.js"; -import type { InstanceId, JourneyRuntime, JourneyStep, TerminalOutcome } from "./types.js"; +import { isAnnotatedTransition } from "./define-transition.js"; +import type { + AnyJourneyDefinition, + InstanceId, + JourneyRuntime, + JourneyStep, + TerminalOutcome, +} from "./types.js"; export type JourneyStepErrorPolicy = "abort" | "retry" | "ignore"; @@ -78,6 +87,36 @@ export interface JourneyOutletProps { * through their own reporting. */ readonly errorComponent?: ComponentType; + /** + * Speculatively prefetch the chunks for entries reachable from the + * current step during idle time after mount, so the next click finds + * its bundle hot. + * + * `"precise"` (default, alias `true`) — read declared `targets` from + * `defineTransition({ targets, handle })`-annotated handlers on the + * current step's transitions. Preload exactly those entries. + * Bare-function handlers contribute nothing (this is the precise + * mode's whole point — no guessing). + * + * `"aggressive"` — preload every entry that appears as a transition + * source OR as a declared `target` of any annotated handler in the + * journey's `transitions` map. The destination-side pass catches + * terminal-only steps that have no outbound transitions of their + * own (e.g. a freshly-added receipt screen reachable from `next:` + * but not yet wired with its own exits). A step reachable only + * via `definition.start` AND with no outbound transitions of its + * own is the one remaining static gap — but such a step can only + * be the current step on first mount (no exits → no advance), and + * the skip-current logic already excludes it. Useful when handlers + * are not annotated and the journey is small enough that warming + * all candidates is cheap. + * + * `false` — opt out entirely. + * + * Has no effect for eager (`component:`) entries — their import is + * already resolved. Effects only fire in the browser; SSR is a no-op. + */ + readonly preload?: boolean | "precise" | "aggressive"; } /** @@ -100,6 +139,7 @@ export function JourneyOutlet(props: JourneyOutletProps): ReactNode { notFoundComponent, errorComponent, leafOnly = true, + preload = "precise", } = props; const runtime = runtimeProp ?? context?.runtime; @@ -174,6 +214,65 @@ export function JourneyOutlet(props: JourneyOutletProps): ReactNode { }); }, [rootInstance, onFinished]); + // Speculative preload of reachable entries' chunks. Runs after the current + // step is settled in-DOM; cancels on step change so a fast advance does + // not race with the previous step's preload set. Deps are deliberately + // narrow — `instance` itself changes reference on every snapshot bump + // (timestamps, child-id shifts), and re-running preload on those is wasted + // work. We re-key the effect on (status, module, entry, journey) instead. + const isActive = instance?.status === "active"; + const stepModuleId = instance?.step?.moduleId; + const stepEntryName = instance?.step?.entry; + const journeyId = instance?.journeyId; + useEffect(() => { + if (preload === false || !isActive) return; + if (!stepModuleId || !stepEntryName || !journeyId) return; + const reg = internals.__getRegistered(journeyId); + if (!reg) return; + const mode = preload === "aggressive" ? "aggressive" : "precise"; + const targets = collectPreloadTargets( + reg.definition, + modules, + stepModuleId, + stepEntryName, + mode, + ); + if (targets.length === 0) return; + + let cancelled = false; + const run = (): void => { + if (cancelled) return; + for (const entry of targets) { + try { + resolveEntryComponent(entry).preload(); + } catch { + // Best-effort: a malformed entry would have failed validation + // upstream. Swallow here so one bad entry never hides the rest. + } + } + }; + + const ricFn = ( + globalThis as { + requestIdleCallback?: (cb: () => void, opts?: { timeout?: number }) => number; + } + ).requestIdleCallback; + const cicFn = (globalThis as { cancelIdleCallback?: (handle: number) => void }) + .cancelIdleCallback; + let idleHandle: number | undefined; + let timeoutHandle: ReturnType | undefined; + if (typeof ricFn === "function") { + idleHandle = ricFn(run, { timeout: 2000 }); + } else { + timeoutHandle = setTimeout(run, 0); + } + return () => { + cancelled = true; + if (idleHandle !== undefined && typeof cicFn === "function") cicFn(idleHandle); + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + }; + }, [preload, isActive, stepModuleId, stepEntryName, journeyId, internals, modules]); + if (!instance) return null; if (instance.status === "loading") return loadingFallback ?? null; if (instance.status === "completed" || instance.status === "aborted") return null; @@ -229,13 +328,17 @@ export function JourneyOutlet(props: JourneyOutletProps): ReactNode { // 'ignore' — leave the boundary UI in place until the user navigates away }; - // The step's declared input/exit contract is erased at the module-map - // boundary (the outlet holds ModuleDescriptor). - // Narrow to the structural shape every entry component satisfies — - // `ModuleEntryProps` — instead of `any`, so the - // cast site at least documents the prop bag the outlet hands in. - const StepComponent = entry.component as ComponentType>; + // Resolve eager (`component:`) and lazy (`lazy:`) entries through the + // shared helper. Lazy entries get a memoized `React.lazy` wrapper plus an + // idempotent `preload()`; eager entries pass through as-is. Both render + // sites (here and `ModuleTab`) call this so the per-descriptor cache + // is shared. + const { Component: StepComponent } = resolveEntryComponent(entry); const stepKey = `${record.stepToken}:${retryKey}`; + // For eager entries `entry.fallback` is typed `never` (and is always + // `undefined` at runtime); for lazy entries it's the optional Suspense + // fallback. Either way, fall through to the outlet-level `loadingFallback`. + const suspenseFallback = entry.fallback ?? loadingFallback ?? null; return createElement( StepErrorBoundary, @@ -246,14 +349,98 @@ export function JourneyOutlet(props: JourneyOutletProps): ReactNode { key: stepKey, children: null, }, - createElement(StepComponent, { - input: step.input, - exit, - goBack, - }), + createElement( + Suspense, + { fallback: suspenseFallback }, + createElement(StepComponent, { + input: step.input, + exit, + goBack, + }), + ), ); } +/** + * Walk `definition.transitions` to assemble the set of entry-point + * descriptors to preload. In `"precise"` mode we look at the current + * step's transitions only and read each handler's declared `targets`; + * in `"aggressive"` mode we walk every entry referenced anywhere in the + * map. Both skip the current step (it's already mounted). + */ +function collectPreloadTargets( + definition: AnyJourneyDefinition, + modules: Readonly>>, + currentModuleId: string, + currentEntry: string, + mode: "precise" | "aggressive", +): readonly ModuleEntryPoint[] { + const seen = new Set(); + const out: ModuleEntryPoint[] = []; + const transitions = definition.transitions as + | Record> | undefined> + | undefined; + if (!transitions) return out; + + const collectPair = (moduleId: string, entryName: string): void => { + if (!moduleId || !entryName) return; + if (moduleId === currentModuleId && entryName === currentEntry) return; + // Composite key only used to dedupe. Using `` as the separator + // sidesteps any collision risk with module ids that legitimately + // contain `/` (npm-style scopes) or other punctuation. + const seenKey = `${moduleId}${entryName}`; + if (seen.has(seenKey)) return; + seen.add(seenKey); + const entry = modules[moduleId]?.entryPoints?.[entryName]; + if (entry) out.push(entry); + }; + + if (mode === "precise") { + const perEntry = transitions[currentModuleId]?.[currentEntry]; + if (!perEntry) return out; + for (const value of Object.values(perEntry)) { + if (!isAnnotatedTransition(value)) continue; + for (const target of value.targets) { + // Sentinel targets (`"complete"` / `"abort"` / `"invoke"`) carry + // no chunk to preload — they're terminal-arm declarations for the + // type system and the catalog harvester. Skip them here. + if (typeof target === "string") continue; + collectPair(target.module, target.entry); + } + } + return out; + } + + // Aggressive — every (module, entry) the journey could plausibly navigate + // to: source-side keys (covers bare-function handlers and every step that + // has outbound transitions wired) UNIONED with the destinations declared + // by every annotated handler (covers terminal-only destination steps — + // entries reachable from a `next:` arm that themselves have no outbound + // transitions yet, e.g. a freshly-added receipt screen). + // + // The remaining static gap — a step reachable only via `definition.start` + // AND with no outbound transitions of its own — is left uncovered. Such a + // step can only be the current step on first mount (you can't advance + // away from a step with no exits), in which case the skip-current logic + // excludes it anyway. `definition.start` is a function and we + // deliberately don't run it speculatively. + for (const [moduleId, perModule] of Object.entries(transitions)) { + if (!perModule) continue; + for (const [entryName, perExit] of Object.entries(perModule)) { + collectPair(moduleId, entryName); + if (!perExit) continue; + for (const value of Object.values(perExit)) { + if (!isAnnotatedTransition(value)) continue; + for (const target of value.targets) { + if (typeof target === "string") continue; + collectPair(target.module, target.entry); + } + } + } + } + return out; +} + function DefaultNotFound({ moduleId, entry }: JourneyOutletNotFoundProps): ReactNode { return createElement( "div", diff --git a/packages/react-router-testing/package.json b/packages/react-router-testing/package.json index 75fc0e6..b29b2f9 100644 --- a/packages/react-router-testing/package.json +++ b/packages/react-router-testing/package.json @@ -48,7 +48,7 @@ }, "peerDependencies": { "@modular-react/core": "^1.2.0", - "@modular-react/journeys": "^0.1.0", + "@modular-react/journeys": "^1.0.0", "@modular-react/react": "^1.2.0", "@react-router-modules/core": "^2.3.0", "@react-router-modules/runtime": "^2.3.0", diff --git a/packages/react/README.md b/packages/react/README.md index 8bbd214..dfca356 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -18,6 +18,7 @@ npm install @modular-react/react - **Error boundary**: `ModuleErrorBoundary` - **Module-exit plumbing**: `ModuleExitProvider`, `useModuleExit`, `useModuleExitDispatcher`, `ModuleEvent`. The "step 0" pattern — a module entry fires an exit from outside any journey, the composition root decides what it means. - **Standalone hosts**: `ModuleRoute` renders a module entry as a route element (router-mode step 0). Pairs with `ModuleTab` from `@modular-react/journeys` for the workspace-mode variant. +- **Lazy entry resolution**: `resolveEntryComponent(entry)` returns `{ Component, preload }` for either an eager (`{ component }`) or a lazy (`{ lazy: () => import(…) }`) `ModuleEntryPoint`. Memoized per entry-object identity via `WeakMap`. Used by both `JourneyOutlet` and `ModuleTab` so the lazy wrapper / import promise is shared across renders, hot reloads, and StrictMode double-mount. `preloadEntry(entry)` is the convenience wrapper for hover-prefetch UIs and other manual warm-up paths. - **Re-exported from `@modular-react/core`**: all types, `createStore`, `isStore`, `isStoreApi`, `isReactiveService`, `separateDeps`, `defineModule`, `defineSlots`, slot/navigation/validation functions, and runtime helpers ## Usage @@ -51,4 +52,23 @@ function LaunchPage() { `ModuleExitProvider` automatically — apps that use the journeys plugin do not need to mount both. +### Manual prefetch with `preloadEntry` + +`preloadEntry(entry)` triggers a lazy entry's dynamic import without rendering the component. Call it from a hover handler, an analytics-driven prediction, or a `useEffect` that knows the user is about to advance: + +```tsx +import { preloadEntry } from "@modular-react/react"; +import { billingModule } from "./billing-module.js"; + +function PlanCard({ onClick }: { onClick: () => void }) { + return ( + + ); +} +``` + +Calls are idempotent — the underlying `WeakMap` cache returns the same in-flight or resolved promise across hover, click, and any `` that picked the same entry. + See the [main documentation](https://github.com/kibertoad/modular-react#readme) for the full guide. diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 83c2b0f..564eaf9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -73,3 +73,10 @@ export type { ModuleExitEvent, ModuleExitHandler, ModuleExitProviderProps } from // React-specific: router-mode module host (step 0 outside a workspace tab). export { ModuleRoute } from "./module-route.js"; export type { ModuleRouteProps, ModuleRouteExitEvent } from "./module-route.js"; + +// React-specific: lazy entry-point resolution. Hosts (JourneyOutlet, +// ModuleTab) call `resolveEntryComponent` to obtain a renderable component +// + idempotent `preload()` for both eager (`component:`) and lazy (`lazy:`) +// entries. `preloadEntry` is the convenience prefetch helper. +export { resolveEntryComponent, preloadEntry } from "./resolve-entry.js"; +export type { ResolvedEntry } from "./resolve-entry.js"; diff --git a/packages/react/src/resolve-entry.test-d.ts b/packages/react/src/resolve-entry.test-d.ts new file mode 100644 index 0000000..d2543f6 --- /dev/null +++ b/packages/react/src/resolve-entry.test-d.ts @@ -0,0 +1,49 @@ +// Type-level regression tests for `resolveEntryComponent` / `preloadEntry`. +// Runs through vitest's typecheck pass — assertions fail the suite if the +// resolved render surface (the `{ Component, preload }` pair) drifts from +// the documented contract. + +import { expectTypeOf, test } from "vitest"; +import type { ComponentType } from "react"; +import { defineEntry, schema, type ModuleEntryProps } from "@modular-react/core"; + +import { type ResolvedEntry, preloadEntry, resolveEntryComponent } from "./resolve-entry.js"; + +interface MyInput { + readonly id: string; +} +const Component = ((_props: ModuleEntryProps) => null) as ComponentType< + ModuleEntryProps +>; + +// ----------------------------------------------------------------------------- +// `resolveEntryComponent` — uniform return shape across both variants. +// ----------------------------------------------------------------------------- + +test("resolveEntryComponent returns ResolvedEntry for an eager entry", () => { + const entry = defineEntry({ component: Component, input: schema() }); + expectTypeOf(resolveEntryComponent(entry)).toEqualTypeOf(); +}); + +test("resolveEntryComponent returns ResolvedEntry for a lazy entry", () => { + const entry = defineEntry({ + lazy: () => Promise.resolve({ default: Component }), + input: schema(), + }); + expectTypeOf(resolveEntryComponent(entry)).toEqualTypeOf(); +}); + +test("ResolvedEntry exposes Component (renderable) and preload (Promise)", () => { + expectTypeOf().toMatchTypeOf>(); + expectTypeOf().toMatchTypeOf<() => Promise>(); +}); + +test("preloadEntry is the convenience wrapper — same return type as resolveEntryComponent(...).preload()", () => { + const entry = defineEntry({ lazy: () => Promise.resolve({ default: Component }) }); + expectTypeOf(preloadEntry(entry)).toEqualTypeOf>(); +}); + +test("preloadEntry accepts an eager entry too (returns a resolved promise)", () => { + const entry = defineEntry({ component: Component }); + expectTypeOf(preloadEntry(entry)).toEqualTypeOf>(); +}); diff --git a/packages/react/src/resolve-entry.test.tsx b/packages/react/src/resolve-entry.test.tsx new file mode 100644 index 0000000..2f2f04b --- /dev/null +++ b/packages/react/src/resolve-entry.test.tsx @@ -0,0 +1,199 @@ +import { Suspense } from "react"; +import type { ReactElement } from "react"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + EagerModuleEntryPoint, + ExitPointMap, + LazyModuleEntryPoint, + ModuleEntryProps, +} from "@modular-react/core"; + +import { preloadEntry, resolveEntryComponent } from "./resolve-entry.js"; + +const REACT_LAZY_TYPE = Symbol.for("react.lazy"); + +function Eager(_props: ModuleEntryProps<{ value: string }, ExitPointMap>): ReactElement { + return eager; +} + +function Lazy(_props: ModuleEntryProps<{ value: string }, ExitPointMap>): ReactElement { + return lazy; +} + +afterEach(() => { + cleanup(); +}); + +describe("resolveEntryComponent — eager", () => { + it("returns the original component verbatim", () => { + const entry: EagerModuleEntryPoint<{ value: string }> = { component: Eager }; + const { Component } = resolveEntryComponent(entry); + expect(Component).toBe(Eager); + }); + + it("preload() is a resolved promise (no-op)", async () => { + const entry: EagerModuleEntryPoint<{ value: string }> = { component: Eager }; + await expect(resolveEntryComponent(entry).preload()).resolves.toBeUndefined(); + }); + + it("memoizes per entry-object identity", () => { + const entry: EagerModuleEntryPoint<{ value: string }> = { component: Eager }; + expect(resolveEntryComponent(entry)).toBe(resolveEntryComponent(entry)); + }); +}); + +describe("resolveEntryComponent — lazy", () => { + it("Component is a React.lazy exotic", () => { + const entry: LazyModuleEntryPoint<{ value: string }> = { + lazy: () => Promise.resolve({ default: Lazy }), + }; + const { Component } = resolveEntryComponent(entry); + expect((Component as unknown as { $$typeof: symbol }).$$typeof).toBe(REACT_LAZY_TYPE); + }); + + it("preload() invokes the importer exactly once across N calls", async () => { + const importer = vi.fn(() => Promise.resolve({ default: Lazy })); + const entry: LazyModuleEntryPoint<{ value: string }> = { lazy: importer }; + const { preload } = resolveEntryComponent(entry); + await Promise.all([preload(), preload(), preload()]); + expect(importer).toHaveBeenCalledTimes(1); + }); + + it("preloadEntry() is equivalent to resolveEntryComponent(...).preload()", async () => { + const importer = vi.fn(() => Promise.resolve({ default: Lazy })); + const entry: LazyModuleEntryPoint<{ value: string }> = { lazy: importer }; + await preloadEntry(entry); + await preloadEntry(entry); + expect(importer).toHaveBeenCalledTimes(1); + }); + + it("normalizes a module that exports the component directly (no `default`)", async () => { + const entry: LazyModuleEntryPoint<{ value: string }> = { + // Importer resolves to the component itself, not `{ default: ... }`. + lazy: () => Promise.resolve(Lazy as unknown as { default: typeof Lazy }), + }; + const { Component } = resolveEntryComponent(entry); + let result: ReturnType | undefined; + await act(async () => { + result = render( + loading}> + undefined) as never} goBack={undefined} /> + , + ); + }); + await waitFor(() => { + expect(result?.getByTestId("lazy")).toBeTruthy(); + }); + }); + + it("renders the fallback then the resolved component", async () => { + let resolveImport!: (mod: { default: typeof Lazy }) => void; + const entry: LazyModuleEntryPoint<{ value: string }> = { + lazy: () => + new Promise<{ default: typeof Lazy }>((res) => { + resolveImport = res; + }), + }; + const { Component } = resolveEntryComponent(entry); + const { getByTestId } = render( + loading}> + undefined) as never} goBack={undefined} /> + , + ); + // Suspense renders the fallback while the import is pending. + expect(getByTestId("fallback")).toBeTruthy(); + await act(async () => { + resolveImport({ default: Lazy }); + // Allow the lazy promise + React commit to settle. + await Promise.resolve(); + }); + await waitFor(() => { + expect(getByTestId("lazy")).toBeTruthy(); + }); + }); + + it("memoizes per entry-object identity (cached lazy wrapper)", () => { + const entry: LazyModuleEntryPoint<{ value: string }> = { + lazy: () => Promise.resolve({ default: Lazy }), + }; + expect(resolveEntryComponent(entry)).toBe(resolveEntryComponent(entry)); + }); + + it("does not flash the Suspense fallback after `preload()` has settled", async () => { + // After `preload()` resolves, the cached path returns a synchronous + // thenable — React.lazy's `_init` flips `_status` to `Resolved` inside + // the `.then(...)` call (no microtask deferral), so the first render + // skips the suspending throw entirely. Asserted by counting fallback + // mounts: zero means the component went straight to `Resolved`. + const entry: LazyModuleEntryPoint<{ value: string }> = { + lazy: () => Promise.resolve({ default: Lazy }), + }; + const { Component, preload } = resolveEntryComponent(entry); + await preload(); // import resolves; cached slot is populated. + + let fallbackMounts = 0; + const Fallback = () => { + fallbackMounts += 1; + return loading; + }; + const { getByTestId } = render( + }> + undefined) as never} goBack={undefined} /> + , + ); + // The component is the resolved `Lazy` from the very first commit — + // `getByTestId("lazy")` finds it without any waitFor, and the + // fallback was never mounted at all. + expect(getByTestId("lazy")).toBeTruthy(); + expect(fallbackMounts).toBe(0); + }); + + it("dedupes concurrent loader calls so `preload()` and a render share one import", async () => { + // The cached/inflight pair guarantees `importer` is invoked once even + // when `preload()` and the component's render fire back-to-back. This + // matters for hover-prefetch UX: hovering primes preload and clicking + // mounts the component a moment later; both paths funnel through the + // same fetch. + const importer = vi.fn(() => Promise.resolve({ default: Lazy })); + const entry: LazyModuleEntryPoint<{ value: string }> = { lazy: importer }; + const { Component, preload } = resolveEntryComponent(entry); + // Kick preload AND render in the same tick. + const preloading = preload(); + let result: ReturnType | undefined; + await act(async () => { + result = render( + loading}> + undefined) as never} goBack={undefined} /> + , + ); + await preloading; + }); + await waitFor(() => { + expect(result?.getByTestId("lazy")).toBeTruthy(); + }); + expect(importer).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveEntryComponent — invalid input", () => { + it("throws when the entry declares neither component nor lazy", () => { + expect(() => resolveEntryComponent({} as unknown as EagerModuleEntryPoint)).toThrow( + /neither `component` nor `lazy`/, + ); + }); + + it("traps a sync-throwing importer as a cached rejected promise", async () => { + const failure = new Error("module is broken"); + const importer = vi.fn(() => { + throw failure; + }); + const entry: LazyModuleEntryPoint<{ value: string }> = { lazy: importer }; + const { preload } = resolveEntryComponent(entry); + // First call traps the throw and stores a rejected promise. + await expect(preload()).rejects.toBe(failure); + // Second call replays the cached rejection — importer is NOT re-invoked. + await expect(preload()).rejects.toBe(failure); + expect(importer).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/resolve-entry.ts b/packages/react/src/resolve-entry.ts new file mode 100644 index 0000000..c3f809e --- /dev/null +++ b/packages/react/src/resolve-entry.ts @@ -0,0 +1,141 @@ +import { lazy } from "react"; +import type { ComponentType } from "react"; +import type { + EagerModuleEntryPoint, + ExitPointMap, + LazyModuleEntryPoint, + ModuleEntryPoint, + ModuleEntryProps, +} from "@modular-react/core"; + +/** Per-entry resolved render surface used by hosts (JourneyOutlet, ModuleTab). */ +export interface ResolvedEntry { + /** + * Renderable component for the entry. For lazy entries this is a + * `React.lazy(...)` wrapper — hosts must render it inside a `` + * boundary. The host's existing fallback path applies. + */ + readonly Component: ComponentType>; + /** + * Idempotent prefetch. Eager entries resolve immediately; lazy entries + * trigger the dynamic import once and return the cached promise on every + * subsequent call. Safe to call from idle callbacks, hover handlers, etc. + */ + readonly preload: () => Promise; +} + +const cache = new WeakMap, ResolvedEntry>(); + +const normalize = ( + mod: unknown, +): { default: ComponentType> } => { + if (mod && typeof mod === "object" && "default" in (mod as Record)) { + return mod as { default: ComponentType> }; + } + return { + default: mod as ComponentType>, + }; +}; + +/** + * Normalize a module entry point into a `{ Component, preload }` pair. Both + * the `component` (eager) and `lazy` (dynamic-import) shapes are supported; + * the lazy form is wrapped with `React.lazy` and exposes an idempotent + * `preload()` so hosts can speculatively warm the chunk during idle time. + * + * Memoized by entry-object identity via a process-local `WeakMap` — repeated + * calls return the same pair (so the lazy wrapper and import promise are + * stable across re-renders and StrictMode double-mount). + */ +export function resolveEntryComponent(entry: ModuleEntryPoint): ResolvedEntry { + const existing = cache.get(entry); + if (existing) return existing; + + let resolved: ResolvedEntry; + const eager = (entry as EagerModuleEntryPoint).component; + const importer = (entry as LazyModuleEntryPoint).lazy; + + if (typeof eager === "function") { + resolved = { + Component: eager as ComponentType>, + preload: () => Promise.resolve(), + }; + } else if (typeof importer === "function") { + type Resolved = { default: ComponentType> }; + let cached: Resolved | undefined; + let inflight: Promise | undefined; + + // The cached path returns a SYNCHRONOUS thenable instead of a real + // promise once the import has settled. React.lazy's `_init` flow does: + // + // const thenable = ctor() + // thenable.then(setResolved, setRejected) + // if (payload._status === Uninitialized) payload._status = Pending + // if (payload._status === Resolved) return moduleObject.default + // throw payload._result + // + // With a real (already-resolved) promise, `.then(setResolved)` schedules + // its callback on the microtask queue, so the status check below + // still sees `Uninitialized` → flips to `Pending` → throws → Suspense + // shows the fallback for one microtask. With a synchronous thenable, + // `setResolved` runs inside the `.then(...)` call, the status flips to + // `Resolved` before the check runs, and the component renders without + // crossing a microtask boundary — eliminating the post-preload + // fallback flash. Promise A+ only requires `.then` to *schedule* its + // callback; React.lazy doesn't depend on the deferral. + // + // Both `Component` (via `React.lazy`) and `preload` go through the + // same `cachedImport` closure so the `cached` slot populated by an + // explicit `preload()` is visible to the subsequent lazy-render. + const cachedImport = (): Promise => { + if (cached !== undefined) { + const value = cached; + return { + // oxlint-disable-next-line no-thenable -- synchronous thenable for React.lazy fast-path; see block comment above + then(onFulfilled?: (m: Resolved) => unknown) { + return onFulfilled ? onFulfilled(value) : value; + }, + } as unknown as Promise; + } + if (inflight) return inflight; + // try/catch converts a sync-throwing importer into a cached rejected + // promise — without it, a sync throw would skip the assignment and + // the next call would re-invoke the broken importer instead of + // replaying the failure (and consumers would never see the error + // via Suspense). + try { + inflight = Promise.resolve(importer()) + .then(normalize) + .then((m) => { + cached = m; + return m; + }); + } catch (err) { + inflight = Promise.reject(err); + } + return inflight; + }; + const Component = lazy(cachedImport) as unknown as ComponentType< + ModuleEntryProps + >; + resolved = { Component, preload: cachedImport }; + } else { + throw new Error( + "[@modular-react/react] resolveEntryComponent: entry has neither `component` nor `lazy`. " + + "Validate modules with `validateModuleEntryExit` before rendering.", + ); + } + + cache.set(entry, resolved); + return resolved; +} + +/** + * Convenience wrapper that triggers a lazy entry's dynamic import without + * materializing its component. Equivalent to + * `resolveEntryComponent(entry).preload()` and intended for hover-prefetch + * UIs and other manual warm-up paths. Idempotent across repeated calls. + */ +export function preloadEntry(entry: ModuleEntryPoint): Promise { + return resolveEntryComponent(entry).preload(); +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index d22e699..98bbf38 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -3,5 +3,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "happy-dom", + // Pick up type-level assertions (`expectTypeOf(...)`, `assertType(...)`) + // from `*.test-d.ts` files. Runtime behavior tests continue to live in + // `*.test.tsx`. Both are run by `pnpm test`. + typecheck: { + enabled: true, + include: ["src/**/*.test-d.ts"], + tsconfig: "./tsconfig.json", + }, }, }); diff --git a/packages/tanstack-router-testing/package.json b/packages/tanstack-router-testing/package.json index 9924a17..59b7edf 100644 --- a/packages/tanstack-router-testing/package.json +++ b/packages/tanstack-router-testing/package.json @@ -48,7 +48,7 @@ }, "peerDependencies": { "@modular-react/core": "^1.2.0", - "@modular-react/journeys": "^0.1.0", + "@modular-react/journeys": "^1.0.0", "@modular-react/react": "^1.2.0", "@tanstack-react-modules/core": "^2.3.0", "@tanstack-react-modules/runtime": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4ffb88..c93376f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,7 +179,7 @@ importers: specifier: workspace:* version: link:../../modules/profile '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../../packages/journeys devDependencies: typescript: @@ -264,7 +264,7 @@ importers: specifier: ^1.0.0 version: link:../../../../packages/core '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../packages/journeys '@modular-react/react': specifier: ^1.0.0 @@ -681,7 +681,7 @@ importers: specifier: workspace:* version: link:../verify-identity '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../../packages/journeys devDependencies: typescript: @@ -697,7 +697,7 @@ importers: specifier: workspace:* version: link:../../app-shared '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../../packages/journeys devDependencies: typescript: @@ -785,7 +785,7 @@ importers: specifier: ^1.0.0 version: link:../../../../packages/core '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../packages/journeys '@modular-react/react': specifier: ^1.0.0 @@ -964,7 +964,7 @@ importers: specifier: workspace:* version: link:../../modules/profile '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../../packages/journeys devDependencies: typescript: @@ -1049,7 +1049,7 @@ importers: specifier: ^1.0.0 version: link:../../../../packages/core '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../packages/journeys '@modular-react/react': specifier: ^1.0.0 @@ -1469,7 +1469,7 @@ importers: specifier: workspace:* version: link:../verify-identity '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../../packages/journeys devDependencies: typescript: @@ -1485,7 +1485,7 @@ importers: specifier: workspace:* version: link:../../app-shared '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../../packages/journeys devDependencies: typescript: @@ -1573,7 +1573,7 @@ importers: specifier: ^1.0.0 version: link:../../../../packages/core '@modular-react/journeys': - specifier: ^0.1.0 + specifier: ^1.0.0 version: link:../../../../packages/journeys '@modular-react/react': specifier: ^1.0.0