Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Per-package detail lives in the GitHub release tagged `<npm-name>@<version>`.

## Unreleased

### Added — journey runtime additions (EXP-1848 adoption follow-up)

- **`@modular-react/core`** — `ModuleEntryPoint.buildInput?: (state) => TInput`. When declared on an entry, the journey runtime calls it on every step entry (initial start, forward push, `goBack` pop, resume-into-step) AND on any same-step state change (an `{ invoke }` carrying `state`, or a resume that bumps state without advancing) and uses the result as the step's `input`. Lets a back-navigated form re-render against the journey state accumulated by earlier exits instead of the input frozen at first push. Opt-in — entries without `buildInput` keep the current cache-on-push behaviour. Throws abort the instance with a typed `JourneySystemAbortReason` (`"build-input-threw"`); in `debug` mode a one-time warning fires when the handler's `next.input` would have been overridden by a differing `buildInput` result. Authors annotate the `state` parameter with the hosting journey's `TState` (the module surface stays journey-agnostic).
- **`@modular-react/core`** — `JourneyRuntime.goBack(id: InstanceId): void` on the public surface. Equivalent to the `goBack` prop the runtime hands the active step's component, but addressable by instance id so a shell that owns its own back button (browser `popstate`, hardware back, breadcrumb) can wire `popstate → runtime.goBack(id)` directly instead of capturing the active step's callback through a React context. No-op under the same conditions as the closure form (unknown id, terminal / loading, child in flight, no `allowBack`, empty history).
- **`@modular-react/journeys`** — `useJourneyInstance(id)`, `useJourneyState<T>(id)`, `useActiveLeafJourneyInstance(rootId)`, and `useActiveLeafJourneyState<T>(rootId)`. React hooks that subscribe to a runtime instance via `useSyncExternalStore`. The `*Instance` variants return the full `JourneyInstance | null`; the state variants are sugar over `instance?.state`. The leaf variants walk `activeChildId` and re-subscribe as the chain grows / shrinks. Prefer the `*Instance` variants when the host needs `step` / `status` / `terminalPayload`, or when the leaf may be any of several journeys (`inst.journeyId` is a natural discriminator).
- **`@modular-react/core`** — new public type `JourneyStepFor<TModules>`. Discriminated union of every concrete `JourneyStep` reachable in a journey's module map; narrowing on `moduleId` + `entry` surfaces the entry's typed `input` without a cast. `JourneyStep` itself becomes `JourneyStep<TInput = unknown>` (backwards-compatible — existing usages default to the wide form). The `simulateJourney` `JourneySimulator<TModules, TState>`'s `step` / `currentStep` / `history` now use the typed union, so tests can assert on per-entry input shapes without `Record<string, unknown>` casts.
- **`@modular-react/journeys`** — `simulateJourney` gains a fourth generic, `TOutput = unknown`. Journeys with a concrete terminal payload type are now assignable to the simulator's parameter without the `as unknown as Parameters<typeof simulateJourney>[0]` cast required by the previous `unknown`-output signature. `SimulateJourneyOptions.modules` lets headless tests bind module descriptors so `buildInput` re-runs at every step entry (without descriptors, the runtime falls back to the cached handler-supplied input — identical to pre-`buildInput` behaviour).

### Behavior changes

- **`@modular-react/core`** — `buildNavigationManifest` (and therefore `useNavigation`) now breaks ties on `order` by preserving insertion order instead of label string comparison. Items declared first render first when `order` is unset or equal: modules in registration order, items in the order declared in each module's `navigation` array, plugin-contributed items last. Labels are no longer a tiebreaker — the previous fallback sorted by i18n-key name (e.g. `appShell.nav.assets` < `appShell.nav.projects`), which produced surprising orderings unrelated to translated text. Apps that relied (intentionally or not) on alphabetical-by-key fallback should set explicit `order` values to lock in the desired sequence.
Expand Down
53 changes: 52 additions & 1 deletion packages/core/src/entry-exit.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { expectTypeOf, test } from "vitest";
import type { ComponentType, ReactNode } from "react";

import { defineEntry, schema } from "./entry-exit.js";
import { buildInputFor, defineEntry, schema } from "./entry-exit.js";
import type {
EagerModuleEntryPoint,
LazyEntryComponent,
Expand Down Expand Up @@ -100,3 +100,54 @@ test("ModuleEntryPoint<TInput> is the union of Eager and Lazy variants", () => {
type Expected = EagerModuleEntryPoint<MyInput> | LazyModuleEntryPoint<MyInput>;
expectTypeOf<ModuleEntryPoint<MyInput>>().toEqualTypeOf<Expected>();
});

// -----------------------------------------------------------------------------
// `buildInputFor` — typed wrapper for `buildInput` that bakes TState into the
// factory's `state` parameter. The returned function matches the entry's
// declared `(state: unknown) => TInput` shape regardless of consumer strictness.
// -----------------------------------------------------------------------------

interface ProjectState {
readonly draftName: string;
readonly draftEmail: string;
}

test("buildInputFor narrows state to TState inside the factory and preserves TInput", () => {
const factory = buildInputFor<ProjectState>()((state) => {
expectTypeOf(state).toEqualTypeOf<ProjectState>();
return { previousName: state.draftName };
});
expectTypeOf(factory).toEqualTypeOf<(state: unknown) => { previousName: string }>();
});

test("buildInputFor wraps cleanly into defineEntry({ buildInput })", () => {
interface NameInput {
readonly previousName: string;
}
const NameComponent = ((_props: ModuleEntryProps<NameInput, any>) => null) as ComponentType<
ModuleEntryProps<NameInput, any>
>;
const entry = defineEntry({
component: NameComponent,
input: schema<NameInput>(),
buildInput: buildInputFor<ProjectState>()((state) => ({
previousName: state.draftName,
})),
});
expectTypeOf(entry).toMatchTypeOf<EagerModuleEntryPoint<NameInput>>();
});

test("buildInputFor's wrapper enforces the contextually-expected TInput when one is supplied", () => {
// When assigned to a position with an expected `(state: unknown) => TInput`
// shape, the inner factory's return is checked against TInput.
const ok: (state: unknown) => { previousName: string } = buildInputFor<ProjectState>()(
(state) => ({ previousName: state.draftName }),
);
expectTypeOf(ok).toEqualTypeOf<(state: unknown) => { previousName: string }>();

// @ts-expect-error — factory returns `{ wrong: number }`, not assignable to `{ previousName: string }`.
const bad: (state: unknown) => { previousName: string } = buildInputFor<ProjectState>()(() => ({
wrong: 1,
}));
void bad;
});
42 changes: 42 additions & 0 deletions packages/core/src/entry-exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,48 @@ export function defineEntry<TInput>(entry: ModuleEntryPoint<TInput>): ModuleEntr
return entry;
}

/**
* Typed wrapper for `defineEntry({ buildInput })` that bakes the
* hosting journey's `TState` into the factory's `state` parameter.
* Curried so callers spell `TState` explicitly while `TInput` infers
* from the function body. The visual shape matches `defineJourney`'s
* curry — explicit generics in the outer call, inferred ones in the
* inner — though the motivation differs: `defineJourney` uses two
* calls to work around TypeScript's lack of partial inference, while
* `buildInputFor` uses them so `TInput` infers cleanly from the
* function body alone.
*
* ```ts
* defineEntry({
* component: NameForm,
* input: schema<{ previousName: string }>(),
* buildInput: buildInputFor<ProjectState>()((state) => ({
* previousName: state.draftName,
* })),
* });
* ```
*
* **What this catches**: the inline-annotation alternative —
* `buildInput: (state: ProjectState) => …` — relies on TypeScript
* allowing a narrower-parameter function to assign to the entry's
* declared `(state: unknown) => TInput`. Under `strictFunctionTypes` (or
* stricter consumer configs) that assignment can fail; this helper
* does the unknown→TState cast inside its body, so the *outer* signature
* always matches the entry's declared shape regardless of consumer
* strictness.
*
* **What this does NOT catch**: a mismatch between the spelled `TState`
* and the host journey's actual state type. Modules are
* journey-agnostic — nothing at the module-declaration site knows which
* journey will run them. Annotate carefully; integration / harness
* tests are the safety net for the cross-cut.
*/
export const buildInputFor =
<TState>() =>
<TInput>(fn: (state: TState) => TInput): ((state: unknown) => TInput) =>
(state: unknown) =>
fn(state as TState);

/**
* Type-only brand that preserves inference of `TOutput` on a single
* {@link ExitPointSchema}. Called with no arguments and no runtime cost —
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type {

// Entry / exit helpers
export {
buildInputFor,
defineEntry,
defineExit,
defineExitContract,
Expand Down Expand Up @@ -120,6 +121,7 @@ export type {
ExitOutputOf,
StepSpec,
JourneyStep,
JourneyStepFor,
ExitCtx,
TransitionResult,
EntryTransitions,
Expand Down
72 changes: 68 additions & 4 deletions packages/core/src/journey-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import type {
EntryPointMap,
ExitContract,

Check warning on line 16 in packages/core/src/journey-contracts.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Type 'ExitContract' is imported but never used.
ExitPointMap,
ExitPointSchema,
ModuleDescriptor,
Expand Down Expand Up @@ -87,13 +87,36 @@
}[EntryNamesOf<TModules[M]> & string];
}[keyof TModules & string];

/** Snapshot of a single step in a journey's history / current position. */
export interface JourneyStep {
/**
* Snapshot of a single step in a journey's history / current position.
* The runtime stores history as the wide `JourneyStep<unknown>` form;
* typed simulator / harness surfaces narrow at their boundary via
* {@link JourneyStepFor}.
*
* @see {@link JourneyStepFor} — discriminated-union counterpart that
* narrows `input` by `moduleId` + `entry`.
*/
export interface JourneyStep<TInput = unknown> {
Comment thread
diogomiguel marked this conversation as resolved.
readonly moduleId: string;
readonly entry: string;
readonly input: unknown;
readonly input: TInput;
}

/**
* Discriminated union of every concrete `JourneyStep` reachable in a
* journey's module map. Narrowing on `moduleId` + `entry` picks the
* correct `input` type.
*/
export type JourneyStepFor<TModules extends ModuleTypeMap> = {
[M in keyof TModules & string]: {
[E in EntryNamesOf<TModules[M]> & string]: {
readonly moduleId: M;
readonly entry: E;
readonly input: EntryInputOf<TModules[M], E>;
};
}[EntryNamesOf<TModules[M]> & string];
}[keyof TModules & string];

/** Context passed to a transition handler. */
export interface ExitCtx<TState, TOutput, TEntryInput> {
readonly state: TState;
Expand Down Expand Up @@ -664,6 +687,22 @@
isRegistered(journeyId: string): boolean;
/** Subscribe to changes for one instance. Returns unsubscribe. */
subscribe(id: InstanceId, listener: () => void): () => void;
/**
* Pop the current step and re-enter the previous one — equivalent to the
* `goBack` callback the host hands the active step's component via
* {@link ModuleEntryProps.goBack}, but addressable by `instanceId` so a
* shell that owns its own back button (browser `popstate`, hardware back
* key, breadcrumb navigation) doesn't have to thread the active step's
* callback through a React context.
*
* No-op when the id is unknown, the instance is terminal / loading,
* a child journey is in flight (parent steps are paused while a child
* runs), the journey's transition does not opt in via `allowBack: true`,
* or history is empty. Matches the closure form: the `goBack` prop is
* `undefined` under the same conditions, and shells should treat both
* forms as a hint that the call is presently a no-op.
*/
goBack(id: InstanceId): void;
/**
* Force-terminate an instance. Fires `onAbandon` if still active; no-op if
* the instance is already terminal or unknown.
Expand Down Expand Up @@ -707,6 +746,13 @@
* shells can narrow against this union when interpreting an abort
* outcome — see {@link JourneySystemAbortReason} for the per-code
* payload shape and {@link isJourneySystemAbort} for the type guard.
*
* **These string codes are reserved by the runtime.** An author-supplied
* `{ abort: { reason: "<one-of-these>" } }` payload will be
* misidentified as a system abort by {@link isJourneySystemAbort}. Pick
* a distinct `reason` for author aborts (e.g. namespace with your
* app slug: `"acme.user-cancelled"`) to keep the narrowing predicate
* accurate.
*/
export type JourneySystemAbortReasonCode =
| "invoke-cycle"
Expand All @@ -727,7 +773,8 @@
| "transition-error"
| "transition-returned-promise"
| "exit-payload-invalid"
| "exit-payload-invalid-async";
| "exit-payload-invalid-async"
| "build-input-threw";

/**
* Discriminated union of every abort payload the runtime emits. Each arm
Expand Down Expand Up @@ -854,6 +901,22 @@
*/
readonly reason: "exit-payload-invalid-async";
readonly exit: string;
}
| {
/**
* An entry's `buildInput(state)` factory threw while the runtime
* was deriving the step's input at entry time (initial start,
* forward push, `goBack` pop, or rebuild after a state-changing
* resume / invoke). The instance is aborted rather than entered
* with a half-built / stale input — leaving the form mounted with
* the pre-throw cached input would silently mis-render against
* accumulated state, which is exactly the bug `buildInput` exists
* to fix.
*/
readonly reason: "build-input-threw";
readonly moduleId: string;
readonly entry: string;
readonly error: unknown;
};

const JOURNEY_SYSTEM_ABORT_REASON_CODES: ReadonlySet<string> =
Expand All @@ -877,6 +940,7 @@
"transition-returned-promise",
"exit-payload-invalid",
"exit-payload-invalid-async",
"build-input-threw",
]);

/**
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,26 @@ interface ModuleEntryPointBase<TInput> {
* false (default) — no `goBack` prop is supplied to the component.
*/
readonly allowBack?: "preserve-state" | "rollback" | false;
/**
* Optional factory that derives this entry's input from the current
* journey state every time the step is entered (push, pop / `goBack`,
* invoke return, initial start). When present, the runtime ignores the
* `input` value a transition handler placed on `next` and replaces it
* with `buildInput(state)`.
*
* Use this for steps that present data accumulated by earlier exits —
* back-navigating to a previous step then sees the up-to-date values
* the user already entered, instead of the stale snapshot the step was
* first pushed with.
*
* The `state` parameter is typed `unknown` at the module surface —
* modules don't know which journey hosts them, so authors annotate
* explicitly: `buildInput: (state) => { const s = state as MyState; … }`,
* or narrow via a parameter annotation when TS allows it. Pure,
* synchronous, called on the hot path; do not allocate work here that
* should run inside the component instead.
*/
readonly buildInput?: (state: unknown) => TInput;
Comment thread
diogomiguel marked this conversation as resolved.
}

/**
Expand Down
Loading
Loading