@modular-react/journeys 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @modular-react/journeys
2
-
2
+
3
3
  Typed, serializable workflows that compose several modules. A journey declares how one module's exit feeds the next module's entry; the modules themselves stay journey-unaware - they just declare what input they accept and what outcomes they can emit.
4
4
 
5
5
  Use this package when a domain flow spans multiple modules with **shared state** (e.g. "confirm the customer's profile → branch into plan selection → collect a payment or activate a free trial"), and you want:
@@ -23,7 +23,7 @@ Routes, slots, navigation, workspaces - none of that changes. Journeys sit **on
23
23
  - [Quickstart](#quickstart) - the 5-step path from zero to a running journey
24
24
  - [Core concepts](#core-concepts) - entries, exits, `allowBack`, lifecycle, statuses, keys
25
25
  - [Authoring patterns](#authoring-patterns) - module entries, exits, loading flows, `goBack` opt-in, **lazy entry-points (code-splitting)**
26
- - [Journey definition patterns](#journey-definition-patterns) - branching, `selectModule` dispatch, terminals, state rewrites, bounded history, module compatibility, **`defineTransition` (auto-preload + narrowed handler return)**
26
+ - [Journey definition patterns](#journey-definition-patterns) - branching, `selectModule` dispatch, terminals, state rewrites, bounded history, module compatibility, **`defineTransition` (auto-preload + narrowed handler return)**, **wildcard transitions + shared exit contracts**
27
27
  - [Composing journeys (invoke / resume)](#composing-journeys-invoke--resume) - call out to a child journey mid-flow and resume on its outcome
28
28
  - [Cycle and recursion safety](#cycle-and-recursion-safety) - cycle / depth / undeclared-child / bounce-limit guards and how to tune them
29
29
  - [Runtime surface](#runtime-surface) - the `JourneyRuntime` you get back from `manifest.journeys`
@@ -800,6 +800,108 @@ It's a separate function, not a third argument on `selectModule`, so the _exhaus
800
800
 
801
801
  Slots drive presentation (dynamic, discoverable); the journey owns dispatch (typed, statically declared). See [`examples/react-router/integration-setup-journey/`](../../examples/react-router/integration-setup-journey/) for an end-to-end example with both forms exercised by Playwright.
802
802
 
803
+ ### Pattern - wildcard transitions (`wildcardTransitions`)
804
+
805
+ Cross-cutting outcomes - `cancelled`, `error`, `back`, `signedOut` - tend to be emitted by many modules and handled the same way. Rather than copy the same handler under every `transitions[mod][entry]` block, declare it once on `wildcardTransitions`. Two precision tiers:
806
+
807
+ ```ts
808
+ defineJourney<Modules, State>()({
809
+ // …id, version, initialState, start…
810
+ transitions: {
811
+ // exact handlers — one per [module][entry][exit] triple.
812
+ profile: { review: { approved: ({ output }) => ({ next: ... }) } },
813
+ billing: { confirm: { approved: ({ output }) => ({ complete: ... }) } },
814
+ },
815
+ wildcardTransitions: {
816
+ // tier 2 — module unknown, entry + exit known.
817
+ // Fires when no exact handler matches AND the active step's entry name is "review".
818
+ byEntryAndExit: {
819
+ review: {
820
+ cancelled: ({ output }) => ({ abort: { reason: "user-cancelled" } }),
821
+ },
822
+ },
823
+ // tier 3 — module + entry both unknown.
824
+ // Fires when no exact handler and no byEntryAndExit handler matches.
825
+ byExit: {
826
+ error: ({ output, state }) => ({
827
+ state: { ...state, lastError: output.code },
828
+ next: { module: "errorScreen", entry: "show", input: { code: output.code } },
829
+ }),
830
+ },
831
+ },
832
+ });
833
+ ```
834
+
835
+ **Resolution precedence.** The runtime tries three locations in this order, first hit wins:
836
+
837
+ 1. `transitions[currentMod][currentEntry][exit]` - exact, all three known.
838
+ 2. `wildcardTransitions.byEntryAndExit[currentEntry][exit]` - module wildcarded.
839
+ 3. `wildcardTransitions.byExit[exit]` - module + entry both wildcarded.
840
+
841
+ So a more specific handler always preempts a less specific one. No flag, no merge step - just lookup order.
842
+
843
+ **Type narrowing on the wildcard's `output`.** Because the source module is unknown at the wildcard call site, the handler's `output` type is the **intersection** of `ExitOutputOf` across the matching modules. If those modules' exits emit different shapes, `output` collapses to fields every variant guarantees (or `never`, if the shapes are incompatible). The fix is to share a single `defineExitContract` value across modules - see the next pattern.
844
+
845
+ **`input` typing.** A `byEntryAndExit` handler's `input` is the intersection of `EntryInputOf<TModules[M], E>` across modules that declare entry `E` (the entry name is known, so this is sharper than tier 3). A `byExit` handler's `input` is `unknown` (entry is also unknown) - read step context via `onTransition` if you need it.
846
+
847
+ **Validation at registration time.** Every wildcard slot is checked. The checks have deliberately different scopes:
848
+
849
+ | Check | Scope | Effect |
850
+ | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
851
+ | **Live-key.** `byExit["nope"]` no module emits, or `byEntryAndExit["review"]["typo"]` no module pairs. | App-wide (every registered module). The journey may navigate to modules via `next` transitions whose targets aren't statically introspectable, so narrowing this would false-reject legitimate wildcards. The check's job is typo detection, which a loose scope serves better. | `JourneyValidationError` |
852
+ | **Contract consistency.** Modules sharing a wildcard slot must use the same `ExitContract` instance for the exit. | Journey-scoped (modules keyed under `def.transitions`). The wildcard's typed `output` is determined by the modules the journey actually navigates to; an unrelated registry module that happens to use a different contract for the same exit name must not produce a spurious mismatch. | `JourneyValidationError` |
853
+ | **Tier-overlap.** Same exit declared under both `byEntryAndExit[E][X]` and `byExit[X]`; `byExit` is unreachable from entry `E`. | n/a — only inspects the journey's own `wildcardTransitions`. | `console.warn` (sometimes deliberate). |
854
+ | **Shape.** `wildcardTransitions`, `byEntryAndExit`, `byExit`, and inner entry maps must be plain objects (rejects `null` and arrays). | n/a — structural. | `JourneyValidationError` with the actual rejected kind in the message (e.g. `"got array"`). |
855
+
856
+ ### Pattern - shared exit contracts (`defineExitContract`)
857
+
858
+ Wildcard transitions read across modules - so when several modules emit the same kind of exit (`cancelled`, `error`, ...), the journey wants a _single_ output shape regardless of which module fired. A **shared exit contract** locks that shape in: define it once, reference the same value as the schema for every module's exit. Two effects:
859
+
860
+ 1. **Wildcard `output` narrows to a single typed shape** - the contract's `T`, not the intersection.
861
+ 2. **Cross-module shape consistency is enforced at registration** - if two modules emit `cancelled` with different contract instances, `validateJourneyContracts` throws.
862
+
863
+ ```ts
864
+ // shared/exits.ts — shared across the modules that emit these
865
+ import { defineExitContract } from "@modular-react/core";
866
+ import { z } from "zod";
867
+
868
+ // Type-only contract — declared TOutput, no runtime validation.
869
+ export const errorContract = defineExitContract<{ code: string }>("error");
870
+
871
+ // Schema-backed contract — TOutput inferred from the schema, runtime
872
+ // validates payloads at every emit. Works with any StandardSchemaV1
873
+ // implementation: Zod, Valibot, ArkType, ...
874
+ export const cancelledContract = defineExitContract("cancelled", z.object({ reason: z.string() })); // ExitContract<{ reason: string }>
875
+ ```
876
+
877
+ ```ts
878
+ // modules/profile/exits.ts and modules/billing/exits.ts — both reference
879
+ // the same contract values. Identity is what makes consistency cheap.
880
+ import { defineExit } from "@modular-react/core";
881
+ import { cancelledContract, errorContract } from "../../shared/exits.js";
882
+
883
+ export const profileExits = {
884
+ approved: defineExit<{ profileId: string }>(),
885
+ cancelled: cancelledContract, // shared
886
+ error: errorContract, // shared
887
+ } as const;
888
+ ```
889
+
890
+ **Runtime validation.** When the contract carries a schema, the runtime validates every payload at `exit()` emit - _before_ any handler runs - and aborts the journey with a typed system reason on issues:
891
+
892
+ | Outcome | System abort reason | Payload |
893
+ | ------------------------------------------- | ---------------------------- | --------------------------------------------------------- |
894
+ | Payload fails schema | `exit-payload-invalid` | `{ exit, issues: ReadonlyArray<StandardSchemaV1.Issue> }` |
895
+ | Schema returns a Promise (async refinement) | `exit-payload-invalid-async` | `{ exit }` |
896
+
897
+ Both reasons flow through the existing `isJourneySystemAbort()` predicate. Async schemas are rejected because the journey runtime is intentionally synchronous - put async refinement work inside a loading entry point on a module instead. Schemas that throw during validation surface as a normal `transition-error` abort and fire `onError`.
898
+
899
+ **Why Standard Schema, not Zod-direct.** `@standard-schema/spec` is a type-only package (~0 bytes runtime, single interface) that Zod, Valibot, ArkType, and others all implement. Consumers pick the schema lib that fits; this package stays library-agnostic. Same approach TanStack Router uses for search-param validation.
900
+
901
+ **Validation lifecycle.** `validateJourneyContracts` is a **registration-time snapshot** — it sees the modules registered with the registry at the moment `resolveManifest()` / `resolve()` runs, and the runtime is rebuilt with the same snapshot in the same tick. So in normal lifecycles (plugins, route-driven entry loading, HMR re-bundle, fresh test setup), every module change re-triggers `resolve()` which re-runs validation — coverage stays current.
902
+
903
+ **Runtime drift spot-check.** For bypass cases (direct `createJourneyRuntime` use, plugin composition that adds modules after validation, test mocks that swap descriptors between resolves), the runtime adds a lazy belt-and-suspenders check that mirrors the validator's contract-consistency scope: at the first dispatch of each contract-based exit fired by a journey, it walks the dispatching journey's _own_ modules (those keyed under `def.transitions`, intersected with the runtime's module map) and emits a warning if two of them declare the same exit via _different_ `ExitContract` instances. The result is cached per `[journeyId, exitName]` pair, so the per-pair cost is paid once and the hot path is amortized free. Gated on the runtime's `debug` flag at the call site — production builds pay nothing (no function call, no Set lookup, no walk).
904
+
803
905
  ### Pattern - terminal with structured payload
804
906
 
805
907
  `complete` and `abort` both take `unknown` - pass any shape you want. Consumers read it via `instance.terminalPayload` or the `outcome.payload` arg to `onFinished`.
@@ -2082,18 +2184,21 @@ Every export you're likely to call, grouped by role.
2082
2184
 
2083
2185
  ### From `@modular-react/core` (module authors)
2084
2186
 
2085
- | Export | Signature | Purpose |
2086
- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2087
- | `defineEntry` | overloaded — `<T>(e: EagerModuleEntryPoint<T> \| LazyModuleEntryPoint<T>) => same` | Identity helper. Two forms: eager (`{ component, input?, allowBack? }`) or lazy (`{ lazy: () => import(…), fallback?, input?, allowBack? }`). Mutually exclusive at the type level. |
2088
- | `defineExit` | `<T = void>() => ExitPointSchema<T>` | Identity helper for an exit-point literal. Zero runtime cost. |
2089
- | `schema` | `<T>() => InputSchema<T>` | Type-only brand used to carry an input/output shape. Zero runtime cost. |
2090
- | `ModuleEntryProps` | `<TInput, TExits extends ExitPointMap = {}>` | Typed props for an entry component: `{ input, exit, goBack? }`. |
2091
- | `ModuleEntryPoint` | `EagerModuleEntryPoint<T> \| LazyModuleEntryPoint<T>` | Discriminated union eager (`component`) or lazy (`lazy`). |
2092
- | `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. |
2093
- | `LazyEntryComponent` | `() => Promise<{ default: ComponentType<…> } \| ComponentType<…>>` | Importer signature accepted by `defineEntry({ lazy })`. Both default-export and direct-export module shapes are normalized at runtime. |
2094
- | `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. |
2095
- | `ExitFn` | `<TExits>(name, output?) => void` | The function signature `exit` gets on an entry component. |
2096
- | `EntryPointMap` / `ExitPointMap` | `Record<string, ModuleEntryPoint<any>>` / `Record<string, ExitPointSchema<any>>` | Map shapes on `ModuleDescriptor`. |
2187
+ | Export | Signature | Purpose |
2188
+ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2189
+ | `defineEntry` | overloaded — `<T>(e: EagerModuleEntryPoint<T> \| LazyModuleEntryPoint<T>) => same` | Identity helper. Two forms: eager (`{ component, input?, allowBack? }`) or lazy (`{ lazy: () => import(…), fallback?, input?, allowBack? }`). Mutually exclusive at the type level. |
2190
+ | `defineExit` | `<T = void>() => ExitPointSchema<T>` | Identity helper for an exit-point literal. Zero runtime cost. |
2191
+ | `defineExitContract` | overloaded — `<T>(kind) => ExitContract<T>` or `<S extends StandardSchemaV1>(kind, schema) => ExitContract<InferOutput<S>>` | Shared exit contract identity. Use the same returned value as the schema for an exit on every module that emits it - the journey's wildcard transitions then narrow `output` to the contract's `T` and (when a schema is provided) the runtime validates payloads at every emit. See [Pattern - shared exit contracts](#pattern---shared-exit-contracts-defineexitcontract). |
2192
+ | `isExitContract` | `(schema: unknown) => schema is ExitContract<unknown>` | Type predicate distinguishing a contract from a plain `ExitPointSchema`. Used by the journey runtime and validators; exported for custom hosts. |
2193
+ | `ExitContract` | `{ kind: string; schema?: StandardSchemaV1; output? }` | Shape of a shared contract. Extends `ExitPointSchema`; carries an identity (`kind`) and an optional Standard Schema for runtime validation. |
2194
+ | `schema` | `<T>() => InputSchema<T>` | Type-only brand used to carry an input/output shape. Zero runtime cost. |
2195
+ | `ModuleEntryProps` | `<TInput, TExits extends ExitPointMap = {}>` | Typed props for an entry component: `{ input, exit, goBack? }`. |
2196
+ | `ModuleEntryPoint` | `EagerModuleEntryPoint<T> \| LazyModuleEntryPoint<T>` | Discriminated union — eager (`component`) or lazy (`lazy`). |
2197
+ | `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. |
2198
+ | `LazyEntryComponent` | `() => Promise<{ default: ComponentType<…> } \| ComponentType<…>>` | Importer signature accepted by `defineEntry({ lazy })`. Both default-export and direct-export module shapes are normalized at runtime. |
2199
+ | `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. |
2200
+ | `ExitFn` | `<TExits>(name, output?) => void` | The function signature `exit` gets on an entry component. |
2201
+ | `EntryPointMap` / `ExitPointMap` | `Record<string, ModuleEntryPoint<any>>` / `Record<string, ExitPointSchema<any>>` | Map shapes on `ModuleDescriptor`. |
2097
2202
 
2098
2203
  ### Authoring (`@modular-react/journeys`)
2099
2204
 
package/dist/index.d.ts CHANGED
@@ -2,11 +2,14 @@ import { AbandonCtx } from '@modular-react/core';
2
2
  import { CatalogMeta } from '@modular-react/core';
3
3
  import { ChildOutcome } from '@modular-react/core';
4
4
  import { ComponentType } from 'react';
5
+ import { EntryExitWildcardMap } from '@modular-react/core';
5
6
  import { EntryInputOf } from '@modular-react/core';
6
7
  import { EntryNamesOf } from '@modular-react/core';
7
8
  import { EntryTransitions } from '@modular-react/core';
8
9
  import { ExitCtx } from '@modular-react/core';
9
10
  import { ExitNamesOf } from '@modular-react/core';
11
+ import { ExitNamesPairedWithEntry } from '@modular-react/core';
12
+ import { ExitOnlyWildcardMap } from '@modular-react/core';
10
13
  import { ExitOutputOf } from '@modular-react/core';
11
14
  import { InstanceId } from '@modular-react/core';
12
15
  import { InvokeSpec } from '@modular-react/core';
@@ -39,6 +42,12 @@ import { TerminalOutcome } from '@modular-react/core';
39
42
  import { TransitionEvent as TransitionEvent_2 } from '@modular-react/core';
40
43
  import { TransitionMap } from '@modular-react/core';
41
44
  import { TransitionResult } from '@modular-react/core';
45
+ import { WildcardEntryInputOf } from '@modular-react/core';
46
+ import { WildcardEntryNamesOf } from '@modular-react/core';
47
+ import { WildcardExitNamesOf } from '@modular-react/core';
48
+ import { WildcardExitOutputForEntry } from '@modular-react/core';
49
+ import { WildcardExitOutputOf } from '@modular-react/core';
50
+ import { WildcardTransitionMap } from '@modular-react/core';
42
51
 
43
52
  export { AbandonCtx }
44
53
 
@@ -187,7 +196,7 @@ export declare function createWebStoragePersistence<TInput, TState>(options: Web
187
196
  *
188
197
  * Zero runtime cost — the definition is returned unchanged.
189
198
  */
190
- export declare const defineJourney: <TModules extends ModuleTypeMap, TState, TOutput = unknown, TMeta extends { [K in keyof TMeta]: unknown; } = Record<string, unknown>>() => <TInput = void>(definition: JourneyDefinition<TModules, TState, TInput, TOutput, CatalogMeta & TMeta>) => JourneyDefinition<TModules, TState, TInput, TOutput, any>;
199
+ export declare const defineJourney: <TModules extends ModuleTypeMap, TState, TOutput = unknown, TMeta extends { [K in keyof TMeta]: unknown; } = Record<string, unknown>>() => <TInput = void>(definition: JourneyDefinition<TModules, TState, TInput, TOutput, CatalogMeta & TMeta>) => JourneyDefinition<TModules, TState, TInput, TOutput, CatalogMeta & TMeta>;
191
200
 
192
201
  /**
193
202
  * Build a handle from a journey definition. Runtime identity is just
@@ -295,6 +304,8 @@ declare interface DefineTransitionSpec<THandler extends (ctx: any) => any, TTarg
295
304
  readonly handle: THandler;
296
305
  }
297
306
 
307
+ export { EntryExitWildcardMap }
308
+
298
309
  export { EntryInputOf }
299
310
 
300
311
  export { EntryNamesOf }
@@ -305,6 +316,10 @@ export { ExitCtx }
305
316
 
306
317
  export { ExitNamesOf }
307
318
 
319
+ export { ExitNamesPairedWithEntry }
320
+
321
+ export { ExitOnlyWildcardMap }
322
+
308
323
  export { ExitOutputOf }
309
324
 
310
325
  export { InstanceId }
@@ -395,6 +410,28 @@ export declare interface JourneyDefinition<TModules extends ModuleTypeMap, TStat
395
410
  readonly initialState: (input: TInput) => TState;
396
411
  readonly start: (state: TState, input: TInput) => StepSpec<TModules>;
397
412
  readonly transitions: TransitionMap<TModules, TState, TOutput>;
413
+ /**
414
+ * Wildcard transitions — fall-through handlers matched by exit name
415
+ * (and optionally entry name) rather than by full `[mod][entry][exit]`
416
+ * triple. Two precision tiers:
417
+ *
418
+ * - `byEntryAndExit[entry][exit]` — module unknown, entry + exit known.
419
+ * Fires when no exact `transitions[mod][entry][exit]` matches but
420
+ * the active step's `entry` and `exit` are both declared here.
421
+ * - `byExit[exit]` — module + entry both unknown. Fires when neither
422
+ * of the more specific tiers match.
423
+ *
424
+ * Resolution precedence at runtime is exact → byEntryAndExit → byExit;
425
+ * the more precise handler always wins because the lookup checks them
426
+ * in order and the first hit fires.
427
+ *
428
+ * Use cases: cross-cutting outcomes like `cancelled`, `error`, `back`,
429
+ * or `signedOut` that any module can emit — declare the handler once
430
+ * here instead of repeating it under every step. Pair with
431
+ * `defineExitContract` to enforce a uniform output shape across the
432
+ * modules that emit the exit.
433
+ */
434
+ readonly wildcardTransitions?: WildcardTransitionMap<TModules, TState, TOutput>;
398
435
  /**
399
436
  * Resume handlers fired when a child journey `invoke`d from a parent
400
437
  * step terminates. Keyed by `[moduleId][entryName][resumeName]` — the
@@ -1409,4 +1446,16 @@ export declare interface WebStoragePersistenceOptions<TInput> {
1409
1446
  readonly storage?: Storage | null | (() => Storage | null);
1410
1447
  }
1411
1448
 
1449
+ export { WildcardEntryInputOf }
1450
+
1451
+ export { WildcardEntryNamesOf }
1452
+
1453
+ export { WildcardExitNamesOf }
1454
+
1455
+ export { WildcardExitOutputForEntry }
1456
+
1457
+ export { WildcardExitOutputOf }
1458
+
1459
+ export { WildcardTransitionMap }
1460
+
1412
1461
  export { }
package/dist/index.js CHANGED
Binary file