From e7845bb6d79151f877686d308b206c7b1bef39d8 Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Fri, 15 May 2026 17:53:00 +0300 Subject: [PATCH 1/3] feat(journeys,core): add runtime.canGoBack / canGoForward predicates Read-only predicates that mirror the guards in `goBack` / `goForward` exactly, so a shell-owned Back / Forward toolbar can gate enable-state authoritatively from a single instance id without duplicating the runtime's transition / entry opt-in logic. Factored a single `canGoBackFor(record, reg)` helper that's now the sole authority on "would goBack(id) rewind?". Used by: - `bindStepCallbacks` (gates the step-prop `goBack` closure) - `dispatchGoBack` (rejects calls that should be no-ops) - `runtime.canGoBack(id)` (new predicate) This collapses three inline copies of the rule and fixes a latent contradiction with the documented "Matches the closure form" promise: previously `dispatchGoBack` skipped the `entryAllowBackMode` check, so `runtime.goBack(id)` could rewind out of a step the module had explicitly declared non-rewindable. `validateJourneyContracts` already flagged this as a config error, but the runtime is now defensive when validation is skipped. Same pattern for `canGoForwardFor` / `dispatchGoForward` (no behavior change there). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + packages/core/src/journey-contracts.ts | 36 ++++- packages/journeys/README.md | 29 +++- packages/journeys/src/runtime-go-back.test.ts | 125 ++++++++++++++++++ .../journeys/src/runtime-go-forward.test.ts | 28 ++++ packages/journeys/src/runtime.ts | 103 +++++++++------ 6 files changed, 277 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d567239..aa013d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Per-package detail lives in the GitHub release tagged `@`. - **`@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(id)`, `useActiveLeafJourneyInstance(rootId)`, and `useActiveLeafJourneyState(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`. 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` (backwards-compatible — existing usages default to the wide form). The `simulateJourney` `JourneySimulator`'s `step` / `currentStep` / `history` now use the typed union, so tests can assert on per-entry input shapes without `Record` casts. diff --git a/packages/core/src/journey-contracts.ts b/packages/core/src/journey-contracts.ts index a75a347..09bafb9 100644 --- a/packages/core/src/journey-contracts.ts +++ b/packages/core/src/journey-contracts.ts @@ -704,9 +704,11 @@ export interface JourneyRuntime { * 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; /** @@ -726,6 +728,34 @@ export interface JourneyRuntime { * 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. diff --git a/packages/journeys/README.md b/packages/journeys/README.md index 24bf9a7..fdc06d6 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -1464,7 +1464,9 @@ interface JourneyRuntime { * (browser `popstate`, hardware back, breadcrumb) can wire it * directly. No-op when the id is unknown, the instance is terminal * / loading, a child journey is in flight, the transition didn't - * opt into `allowBack: true`, or history is empty. + * opt into `allowBack: true`, the registered module entry declares + * `allowBack: false`, or history is empty. Matches the closure form + * on `ModuleEntryProps.goBack`. */ goBack(id: InstanceId): void; @@ -1481,6 +1483,27 @@ interface JourneyRuntime { */ goForward(id: InstanceId): void; + /** + * Predicate that mirrors the guards in `goBack`. Returns `true` iff + * calling `goBack(id)` right now would actually rewind a step. Lets + * shells gate the visual state of a Back button authoritatively + * without duplicating the runtime's transition/entry opt-in logic. + * Returns `false` for unknown / terminal / loading instances, when a + * child journey is in flight, when history is empty, when the + * journey transition does not declare `allowBack: true`, or when the + * registered module entry declares `allowBack: false` (an entry + * without a registered descriptor inherits the journey's opt-in — + * matches the simulator fallback on `JourneyRuntimeOptions.modules`). + */ + canGoBack(id: InstanceId): boolean; + + /** + * Predicate that mirrors the guards in `goForward`. Returns `true` + * iff calling `goForward(id)` right now would actually restore a + * redo target. + */ + canGoForward(id: InstanceId): boolean; + /** * Force-terminate an instance. Fires `onAbandon` if still active; * no-op if already terminal or unknown. @@ -2379,7 +2402,9 @@ Every export you're likely to call, grouped by role. | `listInstances()` / `listDefinitions()` | Enumerate. Useful for admin tooling. | | `isRegistered(journeyId)` | Cheap "is this id known?" predicate. Use to filter persisted shell state before calling `start()` - keeps the expected-drop path out of the exception channel. | | `subscribe(id, listener)` | Subscribe to change notifications for one instance. Returns unsubscribe. | -| `goBack(id)` | Pop the active step back to the previous one. Equivalent to the `goBack` prop on `ModuleEntryProps`, but addressable by id so a shell wiring browser-Back / `popstate` / breadcrumb navigation doesn't have to capture the step component's closure through a context. No-op when the id is unknown, the instance is terminal / loading, a child is in flight, the transition didn't opt into `allowBack: true`, or history is empty. | +| `goBack(id)` | Pop the active step back to the previous one. Equivalent to the `goBack` prop on `ModuleEntryProps`, but addressable by id so a shell wiring browser-Back / `popstate` / breadcrumb navigation doesn't have to capture the step component's closure through a context. No-op when the id is unknown, the instance is terminal / loading, a child is in flight, the transition didn't opt into `allowBack: true`, the registered module entry declares `allowBack: false`, or history is empty. | +| `goForward(id)` | Inverse of `goBack` — re-applies the most recent rewind by restoring the captured post-transition state + step. No-op when the id is unknown, terminal / loading, a child is in flight, or the future stack is empty. Does NOT re-run transition handlers; the captured state wins. | +| `canGoBack(id)` / `canGoForward(id)` | Read-only predicates that mirror the guards in `goBack` / `goForward` — returns `true` iff the corresponding call would actually move. Lets a shell-owned Back / Forward toolbar gate enable-state authoritatively without duplicating the runtime's transition/entry opt-in logic. | | `end(id, reason?)` | Force-terminate. Fires `onAbandon` if active; treats `loading` as a direct abort without firing `onAbandon`. | | `forget(id)` / `forgetTerminal()` | Drop terminal instances from memory. `forget` is a no-op on active/loading; `forgetTerminal` batches them all. | diff --git a/packages/journeys/src/runtime-go-back.test.ts b/packages/journeys/src/runtime-go-back.test.ts index 57eeca7..0f32f63 100644 --- a/packages/journeys/src/runtime-go-back.test.ts +++ b/packages/journeys/src/runtime-go-back.test.ts @@ -93,3 +93,128 @@ describe("runtime.goBack(id)", () => { expect(runtime.getInstance(id)?.history).toHaveLength(0); }); }); + +describe("runtime.canGoBack(id)", () => { + it("returns false for unknown ids", () => { + const { runtime } = setup(); + expect(runtime.canGoBack("does-not-exist")).toBe(false); + }); + + it("returns false at the start of a journey (history empty)", () => { + const { runtime, id } = setup(); + expect(runtime.getInstance(id)?.history).toHaveLength(0); + expect(runtime.canGoBack(id)).toBe(false); + }); + + it("returns false when the active transition does not declare allowBack", () => { + // Step `a` has `allowBack: "preserve-state"` on the entry but the + // journey transition out of `a` does NOT set `allowBack: true`. The + // predicate must reject this case — otherwise a shell would render + // an enabled Back button that does nothing. + const { runtime, id } = setup(); + expect(runtime.canGoBack(id)).toBe(false); + }); + + it("returns true once the journey has advanced into a step with allowBack", () => { + const { runtime, id, harness } = setup(); + harness.fireExit(id, "next"); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("b"); + // `b`'s transition declares `allowBack: true` and its entry + // declares `allowBack: "preserve-state"` — both opt-ins satisfied. + expect(runtime.canGoBack(id)).toBe(true); + }); + + it("flips back to false after the back actually fires", () => { + const { runtime, id, harness } = setup(); + harness.fireExit(id, "next"); + expect(runtime.canGoBack(id)).toBe(true); + runtime.goBack(id); + // We popped back to `a`, whose transition has no allowBack opt-in. + expect(runtime.canGoBack(id)).toBe(false); + }); + + it("returns false for terminal instances", () => { + const { runtime, id, harness } = setup(); + harness.fireExit(id, "next"); + harness.fireExit(id, "next"); // completes + expect(runtime.getInstance(id)?.status).toBe("completed"); + expect(runtime.canGoBack(id)).toBe(false); + }); +}); + +describe("entry opt-out (allowBack: false on the active entry)", () => { + // Lock in that the id-based `runtime.goBack` and the `canGoBack` + // predicate honour an entry-level opt-out the same way the step prop + // (`ModuleEntryProps.goBack`) does. Without this both paths could + // disagree — and a shell calling `runtime.goBack(id)` would rewind + // out of a step the module had explicitly declared non-rewindable. + // Note: `validateJourney` flags this combination as a config issue, + // but the runtime should still be defensive if validation is skipped. + function setupWithEntryOptOut() { + const exits = { next: defineExit() } as const; + const stepIn = defineModule({ + id: "in", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: "preserve-state", + }), + }, + }); + const stepOut = defineModule({ + id: "out", + version: "1.0.0", + exitPoints: exits, + entryPoints: { + show: defineEntry({ + component: (() => null) as never, + input: schema(), + allowBack: false, + }), + }, + }); + type Mods = { readonly in: typeof stepIn; readonly out: typeof stepOut }; + const journey = defineJourney>()({ + id: "entry-opt-out", + version: "1.0.0", + initialState: () => ({}), + start: () => ({ module: "in", entry: "show", input: undefined }), + transitions: { + in: { + show: { + next: () => ({ next: { module: "out", entry: "show", input: undefined } }), + }, + }, + out: { + show: { + allowBack: true, + next: () => ({ complete: undefined }), + }, + }, + }, + }); + const runtime = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: { in: stepIn, out: stepOut }, + }); + const id = runtime.start(journey.id, undefined); + return { runtime, id, harness: createTestHarness(runtime) }; + } + + it("canGoBack returns false even when the journey transition opts in", () => { + const { runtime, id, harness } = setupWithEntryOptOut(); + harness.fireExit(id, "next"); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("out"); + expect(runtime.canGoBack(id)).toBe(false); + }); + + it("runtime.goBack(id) no-ops to match the canGoBack contract", () => { + const { runtime, id, harness } = setupWithEntryOptOut(); + harness.fireExit(id, "next"); + runtime.goBack(id); + expect(runtime.getInstance(id)?.step?.moduleId).toBe("out"); + expect(runtime.getInstance(id)?.history).toHaveLength(1); + }); +}); diff --git a/packages/journeys/src/runtime-go-forward.test.ts b/packages/journeys/src/runtime-go-forward.test.ts index 67098b4..e8855c6 100644 --- a/packages/journeys/src/runtime-go-forward.test.ts +++ b/packages/journeys/src/runtime-go-forward.test.ts @@ -485,6 +485,34 @@ describe("runtime.goForward(id)", () => { expect(notified).toBeGreaterThan(0); }); + it("canGoForward mirrors the guards: empty future stack → false", () => { + const { runtime, id, harness } = setup(); + expect(runtime.canGoForward(id)).toBe(false); + harness.fireExit(id, "next"); // a → b; future still empty (no rewind) + expect(runtime.canGoForward(id)).toBe(false); + }); + + it("canGoForward becomes true after a rewind, false again after the redo fires", () => { + const { runtime, id, harness } = setup(); + harness.fireExit(id, "next"); // a → b + runtime.goBack(id); // b → a, future has [b] + expect(runtime.canGoForward(id)).toBe(true); + runtime.goForward(id); + expect(runtime.canGoForward(id)).toBe(false); + }); + + it("canGoForward returns false for unknown ids and terminal instances", () => { + const { runtime, id, harness } = setup(); + expect(runtime.canGoForward("does-not-exist")).toBe(false); + + harness.fireExit(id, "next"); + runtime.goBack(id); + expect(runtime.canGoForward(id)).toBe(true); + runtime.end(id); + expect(runtime.getInstance(id)?.status).toBe("aborted"); + expect(runtime.canGoForward(id)).toBe(false); + }); + it("persists the post-redo blob so a reload would restore the redone step", async () => { // The redo path isn't persistence-aware on its own — it relies on // the same `schedulePersist` call every other transition path diff --git a/packages/journeys/src/runtime.ts b/packages/journeys/src/runtime.ts index 9f45318..41d90fc 100644 --- a/packages/journeys/src/runtime.ts +++ b/packages/journeys/src/runtime.ts @@ -406,6 +406,43 @@ export function createJourneyRuntime( return perEntry?.allowBack === true; } + // Single source of truth for "would goBack(id) actually rewind?". Used + // by `bindStepCallbacks` (gates the step-prop closure), `dispatchGoBack` + // (rejects calls that should be no-ops), and the `runtime.canGoBack` + // predicate. Keeping these three paths reading the same function + // prevents the id-based runtime call from drifting away from the + // step-prop form — the documented "Matches the closure form" promise + // on `JourneyRuntime.goBack`. + function canGoBackFor(record: InstanceRecord, reg: RegisteredJourney): boolean { + if (record.status !== "active") return false; + if (record.activeChildId) return false; + if (record.history.length === 0) return false; + const step = record.step; + if (!step) return false; + if (!journeyAllowsBack(reg.definition, step)) return false; + // When the runtime has a module descriptor for this step and the + // descriptor's entry explicitly opts out, the opt-out wins. When no + // descriptor is registered (headless simulator, tests that don't + // pass `modules`) we trust the journey's transition opt-in and + // treat the missing descriptor as 'preserve-state' — matches the + // documented fallback in `JourneyRuntimeOptions.modules`. + const mode = entryAllowBackMode(step); + if (mode === false && moduleMap[step.moduleId]) return false; + return true; + } + + // Inverse predicate for `goForward`. The future stack is only + // populated by a `goBack` that already passed `canGoBackFor`, so no + // re-check of the journey transition / entry mode is needed here — + // a stale future would have been cleared by any exit-driven + // transition between the rewind and now. + function canGoForwardFor(record: InstanceRecord): boolean { + if (record.status !== "active") return false; + if (record.activeChildId) return false; + if (record.future.length === 0) return false; + return true; + } + function cloneSnapshot(state: unknown): unknown { if (state === null || typeof state !== "object") return state; const cloned: unknown = Array.isArray(state) ? [...state] : { ...(state as object) }; @@ -1777,19 +1814,13 @@ export function createJourneyRuntime( } function dispatchGoBack(record: InstanceRecord, reg: RegisteredJourney, stepToken: number) { - if (record.status !== "active") return; - // Mirror dispatchExit: while a child journey is in flight the parent - // step is paused, so a stale `goBack` closure must not rewind the - // parent out from under it. - if (record.activeChildId) return; + // Stale closure: a step callback that survived a transition must + // not rewind the new step. Checked first because it's the only + // guard that depends on the call site, not on record state. if (record.stepToken !== stepToken) return; - if (record.history.length === 0) return; - - const step = record.step; - if (!step) return; - // Journey-side opt-in - if (!journeyAllowsBack(reg.definition, step)) return; + if (!canGoBackFor(record, reg)) return; + const step = record.step!; const previousStep = record.history.pop()!; const snapshot = record.rollbackSnapshots.pop(); // Capture the redo target before we mutate `step` / `state`. @@ -1832,10 +1863,8 @@ export function createJourneyRuntime( // Inverse of `dispatchGoBack`. See `JourneyRuntime.goForward` JSDoc // for restoration semantics. function dispatchGoForward(record: InstanceRecord, reg: RegisteredJourney, stepToken: number) { - if (record.status !== "active") return; - if (record.activeChildId) return; if (record.stepToken !== stepToken) return; - if (record.future.length === 0) return; + if (!canGoForwardFor(record)) return; const fromStep = record.step; if (!fromStep) return; @@ -1875,36 +1904,16 @@ export function createJourneyRuntime( const exit = (name: string, output?: unknown) => { dispatchExit(record, reg, token, name, output); }; - let mode = entryAllowBackMode(record.step); - // Documented fallback (see `JourneyRuntimeOptions.modules`): when the - // runtime is built without a module descriptor for this step but the - // journey's transition opts in via `allowBack: true`, treat the mode as - // 'preserve-state' so `goBack` stays wired. Without this fallback the - // headless simulator (which never passes a moduleMap) and any runtime - // created without module descriptors would see `goBack` silently - // disappear, contradicting the documented behavior. - if ( - mode === false && - record.step && - journeyAllowsBack(reg.definition, record.step) && - !moduleMap[record.step.moduleId] - ) { - mode = "preserve-state"; - } - const canGoBack = - mode !== false && journeyAllowsBack(reg.definition, record.step) && record.history.length > 0; - const goBack = canGoBack + // Read both gates from the same helpers `dispatchGoBack` / + // `dispatchGoForward` use, so the step-prop closure form and the + // id-based `runtime.goBack` / `runtime.goForward` calls can never + // disagree about whether navigation is currently allowed. + const goBack = canGoBackFor(record, reg) ? () => { dispatchGoBack(record, reg, token); } : undefined; - // Forward / redo closure — gated on the future stack having an - // entry to restore. Unlike `goBack` this does not check - // `journeyAllowsBack` / `entryAllowBackMode`: the future was only - // populated by a `goBack` that already passed those guards, so - // redoing the inverse is always valid by construction. - const canGoForward = record.future.length > 0; - const goForward = canGoForward + const goForward = canGoForwardFor(record) ? () => { dispatchGoForward(record, reg, token); } @@ -2548,6 +2557,20 @@ export function createJourneyRuntime( dispatchGoForward(record, reg, record.stepToken); }, + canGoBack(id) { + const record = instances.get(id); + if (!record) return false; + const reg = definitions.get(record.journeyId); + if (!reg) return false; + return canGoBackFor(record, reg); + }, + + canGoForward(id) { + const record = instances.get(id); + if (!record) return false; + return canGoForwardFor(record); + }, + end(id, reason) { const record = instances.get(id); if (!record) return; From 351e8d06ee068bb0c0e78f686ee36f6c2a14225b Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Fri, 15 May 2026 17:58:42 +0300 Subject: [PATCH 2/3] ci: grant build job contents:read and detect packages/journeys changes Two issues with the CI workflow surfaced on this PR: 1. The reusable `ci.common.yml` declares `permissions: contents: read` on its build job, but the calling `build` job in `ci.yml` had no `permissions:` block. Combined with the workflow-level `permissions: {}` added in #39, the caller could not grant the permissions the reusable workflow needs, so every CI run since #39 has been a `startup_failure` with zero jobs scheduled. Grant `contents: read` on the caller job so the reusable workflow can run. 2. `PATH_TO_NAME` in `changed-files-job` enumerated every workspace package except `packages/journeys`, so PRs touching only journeys produced an empty `packages` output and skipped the build matrix entirely. Add the missing entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 275b59b..f4a77b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" @@ -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] From 50968cbfa66828fe6af7d754c1b293f4100c04d3 Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Fri, 15 May 2026 18:02:31 +0300 Subject: [PATCH 3/3] Fix linting --- .github/workflows/zizmor.yml | 6 ++-- packages/journeys/README.md | 28 +++++++++---------- .../journeys/src/runtime-go-forward.test.ts | 6 ++-- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index e9568e8..9e6c9f6 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -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: {} diff --git a/packages/journeys/README.md b/packages/journeys/README.md index fdc06d6..a8d8fa7 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -396,7 +396,7 @@ A `resolveManifest()` error surfaces if the two sides disagree. ### `goForward` — redoing a `goBack` -Each `goBack` pushes the step it rewinds from (plus state + rollback snapshot) onto a per-instance **future stack**. `runtime.goForward(id)` (or `ModuleEntryProps.goForward?.()`) pops the top of that stack and restores the runtime to the rewound step. The captured *post-transition* state wins — for a `rollback`-mode entry, edits the user made between the rewind and the redo are discarded. +Each `goBack` pushes the step it rewinds from (plus state + rollback snapshot) onto a per-instance **future stack**. `runtime.goForward(id)` (or `ModuleEntryProps.goForward?.()`) pops the top of that stack and restores the runtime to the rewound step. The captured _post-transition_ state wins — for a `rollback`-mode entry, edits the user made between the rewind and the redo are discarded. Key points: @@ -2393,20 +2393,20 @@ Every export you're likely to call, grouped by role. ### Runtime methods (the `JourneyRuntime` returned as `manifest.journeys`) -| Method | Description | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `start(handle, input)` | **Preferred.** Start or resume an instance via a handle (`defineJourneyHandle`); `input` is type-checked end-to-end. Idempotent per persistence key. | -| `start(journeyId, input)` | String-id form for dynamic dispatch (e.g. navbar `{ kind: "journey-start", journeyId }`). Accepts any `input`. | -| `hydrate(journeyId, blob)` | Explicit read-only hydrate. Persistence-unlinked. Returns `InstanceId`. | -| `getInstance(id)` | Current snapshot of an instance, or `null`. Stable-identity between changes (for `useSyncExternalStore`). | -| `listInstances()` / `listDefinitions()` | Enumerate. Useful for admin tooling. | -| `isRegistered(journeyId)` | Cheap "is this id known?" predicate. Use to filter persisted shell state before calling `start()` - keeps the expected-drop path out of the exception channel. | -| `subscribe(id, listener)` | Subscribe to change notifications for one instance. Returns unsubscribe. | +| Method | Description | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `start(handle, input)` | **Preferred.** Start or resume an instance via a handle (`defineJourneyHandle`); `input` is type-checked end-to-end. Idempotent per persistence key. | +| `start(journeyId, input)` | String-id form for dynamic dispatch (e.g. navbar `{ kind: "journey-start", journeyId }`). Accepts any `input`. | +| `hydrate(journeyId, blob)` | Explicit read-only hydrate. Persistence-unlinked. Returns `InstanceId`. | +| `getInstance(id)` | Current snapshot of an instance, or `null`. Stable-identity between changes (for `useSyncExternalStore`). | +| `listInstances()` / `listDefinitions()` | Enumerate. Useful for admin tooling. | +| `isRegistered(journeyId)` | Cheap "is this id known?" predicate. Use to filter persisted shell state before calling `start()` - keeps the expected-drop path out of the exception channel. | +| `subscribe(id, listener)` | Subscribe to change notifications for one instance. Returns unsubscribe. | | `goBack(id)` | Pop the active step back to the previous one. Equivalent to the `goBack` prop on `ModuleEntryProps`, but addressable by id so a shell wiring browser-Back / `popstate` / breadcrumb navigation doesn't have to capture the step component's closure through a context. No-op when the id is unknown, the instance is terminal / loading, a child is in flight, the transition didn't opt into `allowBack: true`, the registered module entry declares `allowBack: false`, or history is empty. | -| `goForward(id)` | Inverse of `goBack` — re-applies the most recent rewind by restoring the captured post-transition state + step. No-op when the id is unknown, terminal / loading, a child is in flight, or the future stack is empty. Does NOT re-run transition handlers; the captured state wins. | -| `canGoBack(id)` / `canGoForward(id)` | Read-only predicates that mirror the guards in `goBack` / `goForward` — returns `true` iff the corresponding call would actually move. Lets a shell-owned Back / Forward toolbar gate enable-state authoritatively without duplicating the runtime's transition/entry opt-in logic. | -| `end(id, reason?)` | Force-terminate. Fires `onAbandon` if active; treats `loading` as a direct abort without firing `onAbandon`. | -| `forget(id)` / `forgetTerminal()` | Drop terminal instances from memory. `forget` is a no-op on active/loading; `forgetTerminal` batches them all. | +| `goForward(id)` | Inverse of `goBack` — re-applies the most recent rewind by restoring the captured post-transition state + step. No-op when the id is unknown, terminal / loading, a child is in flight, or the future stack is empty. Does NOT re-run transition handlers; the captured state wins. | +| `canGoBack(id)` / `canGoForward(id)` | Read-only predicates that mirror the guards in `goBack` / `goForward` — returns `true` iff the corresponding call would actually move. Lets a shell-owned Back / Forward toolbar gate enable-state authoritatively without duplicating the runtime's transition/entry opt-in logic. | +| `end(id, reason?)` | Force-terminate. Fires `onAbandon` if active; treats `loading` as a direct abort without firing `onAbandon`. | +| `forget(id)` / `forgetTerminal()` | Drop terminal instances from memory. `forget` is a no-op on active/loading; `forgetTerminal` batches them all. | ### Registration options (passed to `registry.registerJourney`) diff --git a/packages/journeys/src/runtime-go-forward.test.ts b/packages/journeys/src/runtime-go-forward.test.ts index e8855c6..1d9a2ba 100644 --- a/packages/journeys/src/runtime-go-forward.test.ts +++ b/packages/journeys/src/runtime-go-forward.test.ts @@ -495,7 +495,7 @@ describe("runtime.goForward(id)", () => { it("canGoForward becomes true after a rewind, false again after the redo fires", () => { const { runtime, id, harness } = setup(); harness.fireExit(id, "next"); // a → b - runtime.goBack(id); // b → a, future has [b] + runtime.goBack(id); // b → a, future has [b] expect(runtime.canGoForward(id)).toBe(true); runtime.goForward(id); expect(runtime.canGoForward(id)).toBe(false); @@ -538,10 +538,10 @@ describe("runtime.goForward(id)", () => { const harness = createTestHarness(rt); harness.fireExit(id, "next"); // a → b - rt.goBack(id); // b → a, future has [b] + rt.goBack(id); // b → a, future has [b] persistence.save.mockClear(); - rt.goForward(id); // a → b again + rt.goForward(id); // a → b again // Let the scheduled persist micro-task flush. await Promise.resolve(); await Promise.resolve();