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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
declare -A PATH_TO_NAME=(
["packages/core"]="@modular-react/core"
["packages/react"]="@modular-react/react"
["packages/journeys"]="@modular-react/journeys"
["packages/testing"]="@modular-react/testing"
["packages/catalog"]="@modular-react/catalog"
["packages/react-router-cli"]="@react-router-modules/cli"
Expand Down Expand Up @@ -66,6 +67,13 @@ jobs:
build:
needs: [changed-files-job]
if: needs.changed-files-job.outputs.packages != '[]'
# The reusable workflow's job declares `permissions: contents: read`,
# which the caller must be able to grant. The workflow-level
# `permissions: {}` strips defaults, so the caller job has to opt in
# explicitly — without this the workflow fails at startup before any
# job runs.
permissions:
contents: read
strategy:
matrix:
node-version: [22.x, 24.x]
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ on:
- main
pull_request:
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.github/workflows/zizmor.yml'
- ".github/workflows/**"
- ".github/actions/**"
- ".github/workflows/zizmor.yml"

permissions: {}

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Per-package detail lives in the GitHub release tagged `<npm-name>@<version>`.

- **`@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/core`** — `JourneyRuntime.canGoBack(id: InstanceId): boolean` and `JourneyRuntime.canGoForward(id: InstanceId): boolean`. Read-only predicates that mirror the guards in `goBack` / `goForward` exactly — same `status` / `activeChildId` / history-or-future-length / `journeyAllowsBack` / `entryAllowBackMode` checks, including the documented module-map fallback (no descriptor + journey opt-in → treat entry as `preserve-state`). Lets a shell-owned Back / Forward toolbar gate enable-state authoritatively from a single instance id instead of duplicating the runtime's transition/entry opt-in logic in shell code. Pure reads — no mutation, no notifies.
- **`@modular-react/core`** — `JourneyRuntime.goForward(id: InstanceId): void` and `JourneyInstance.future: readonly JourneyStep[]`. Inverse of `goBack`: each `goBack` pushes the step it rewinds from (plus the post-transition state and the popped rollback snapshot) onto a per-instance future stack; `goForward` pops it and restores all three so the runtime lands back at the step the user just rewound from. Lets shells wire browser Forward (or a redo button) to journey navigation instead of having the URL drift ahead of the runtime. Does NOT re-run the transition handler (side effects don't double-fire) or `buildInput` (the captured `step.input` was already built against the same state being restored — asymmetric with `goBack`, which re-runs `buildInput` on re-entry). For a `rollback`-mode entry, any edits the user made between the rewind and the redo are discarded. Cleared by any fresh exit-driven transition (matches browser back/forward semantics — `invoke` leaves the stack intact since the parent's step doesn't advance). Mirrored as `ModuleEntryProps.goForward?: () => void` on the per-step props (present only when the future stack is non-empty — most shells will wire Forward at the shell level via the runtime method), and as `simulator.goForward()` / `harness.goForward(id)` for headless testing. Transient: not persisted, reset on hydrate and on `start` recycle. The exposed `instance.future` is bare-step (no internal state / snapshot) so consumers can gate UI on `instance.future.length > 0` without reaching into runtime internals.
- **`@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.
Expand Down
36 changes: 33 additions & 3 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 @@ -704,9 +704,11 @@
* 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.
* the registered module entry declares `allowBack: false`, 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. Use {@link canGoBack} to
* gate UI affordances without duplicating these checks.
*/
goBack(id: InstanceId): void;
/**
Expand All @@ -726,6 +728,34 @@
* back/forward).
*/
goForward(id: InstanceId): void;
/**
* Predicate that mirrors the guards in {@link goBack}. Returns `true`
* iff calling `goBack(id)` right now would actually rewind a step.
* Lets shells gate the visual state of a Back button without having to
* peek into module-entry `allowBack` declarations or duplicate the
* runtime's transition opt-in logic.
*
* Returns `false` when the id is unknown, the instance is terminal /
* loading, a child journey is in flight, history is empty, the
* current step's transition does not declare `allowBack: true`, or
* the registered module entry declares `allowBack: false`. Entries
* without a registered module descriptor (headless simulator, tests
* that don't pass `modules`) inherit the journey's transition opt-in
* — mirrors the documented fallback on
* {@link JourneyRuntimeOptions.modules}.
*/
canGoBack(id: InstanceId): boolean;
/**
* Predicate that mirrors the guards in {@link goForward}. Returns
* `true` iff calling `goForward(id)` right now would actually restore
* a redo target. Useful for the symmetric Forward / redo affordance
* in a shell-owned toolbar.
*
* Returns `false` when the id is unknown, the instance is terminal /
* loading, a child journey is in flight, or the future stack is
* empty (any fresh exit-driven transition clears it).
*/
canGoForward(id: InstanceId): boolean;
/**
* Force-terminate an instance. Fires `onAbandon` if still active; no-op if
* the instance is already terminal or unknown.
Expand Down
Loading
Loading