Skip to content

implement journeys#20

Merged
kibertoad merged 31 commits intomainfrom
feat/journeys
Apr 24, 2026
Merged

implement journeys#20
kibertoad merged 31 commits intomainfrom
feat/journeys

Conversation

@kibertoad
Copy link
Copy Markdown
Owner

@kibertoad kibertoad commented Apr 23, 2026

Summary

Introduces Journeys - an optional layer that composes existing modules into typed, serializable workflows with shared state. Modules stay journey-unaware; a journey owns transitions and state; the shell just mounts inside whatever container it already uses (tab, route, modal).

Zero impact on apps that don't use it. Everything is additive.

What's in the branch

New package

  • packages/journeys/ - @modular-react/journeys
    • defineJourney + full type graph (JourneyDefinition, TransitionMap, StepSpec, TransitionResult, …)
    • Pure, headless runtime (runtime.ts) - state machine, step tokens for stale-exit protection,
      serialized-per-instance save queue with coalescing, pluggable persistence (keyFor / load / save / remove), idempotent
      start()
    • React component - useSyncExternalStore subscription, ModuleErrorBoundary + configurable onStepError (abort / retry / ignore), loadingFallback, onFinished, unmount → onAbandon
    • host + onModuleExit manifest option - standalone (non-journey) exit path
    • simulateJourney(def, input) headless test helper
    • validateJourneyContracts - aggregated validation at resolveManifest time (unknown module/entry/exit ids, allowBack mismatches, duplicate journey ids)

Core additions (@modular-react/core)

  • Optional entryPoints / exitPoints on ModuleDescriptor
  • Types: ModuleEntryPoint, ExitPointSchema, ModuleEntryProps, ExitFn, EntryPointMap, ExitPointMap, InputSchema
  • Helpers: defineEntry, defineExit, schema, validateModuleEntryExit
  • defineModule signature now preserves the literal descriptor type so downstream journey types can derive entry/exit
    vocabulary from typeof someModule

Router-runtime integration (both React Router and TanStack Router)

  • registry.registerJourney(def, options) with optional persistence and maxHistory
  • manifest.journeys: JourneyRuntime exposed from resolveManifest
  • Matching renderJourney helper in both testing packages

Docs

  • packages/journeys/README.md - package-level usage guide
  • docs/journeys.md - canonical spec (problem, design, edge cases, implementation plan, worked example)
  • Cross-links added to README.md, docs/shell-patterns.md, docs/workspace-patterns.md
  • WorkspaceActions.openTab({ kind: 'module' | 'journey', … }); openModuleTab marked @deprecated

Example project

  • examples/react-router/customer-onboarding-journey/ - full runnable example (profile → plan → billing) with localStorage-backed persistence and reload recovery. The existing integration-manager example is untouched.

Notes for reviewers

  • Transitions are intentionally synchronous and pure - async fetching belongs in a dedicated loading entry on a module. This keeps the transition graph unit-testable without React.
  • Persistence is pluggable, not bundled - no localStorage/IndexedDB/backend adapter ships in the package. The example
    wires localStorage.
  • start(journeyId, input) is idempotent per persistence key — deliberate, so mid-flow reloads don't duplicate
    instances. Edge cases documented in docs/journeys.md §12.

Summary by CodeRabbit

  • New Features

    • Introduced a journeys system for typed, serializable multi-module workflows with pluggable persistence, journey handles, and runtime/outlet integration.
    • Workspace API unified to open module or journey tabs and support journey instance bookkeeping.
  • Documentation

    • Added comprehensive Journeys README and updated shell/workspace guides with patterns and migration notes.
  • Examples

    • Added end-to-end customer-onboarding journey examples for React Router and TanStack Router.
  • Tests / CI

    • Added unit tests, journey testing utilities, and Playwright E2E smoke tests plus CI job.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new @modular-react/journeys package and integrates journey concepts across core, runtimes, registry plugins, React UI, testing utilities, examples (React Router and TanStack Router), persistence adapters, and CI/E2E tests to support typed, serializable multi-module workflows with hosted lifecycle and persistence.

Changes

Cohort / File(s) Summary
Documentation & Guides
README.md, docs/shell-patterns.md, docs/workspace-patterns.md, packages/journeys/README.md, examples/.../README.md
Introduce Journeys concept, update guides/tables, and document workspace/tab contracts and persistence notes.
Core Type & API Additions
packages/core/src/types.ts, packages/core/src/entry-exit.ts, packages/core/src/define-module.ts, packages/core/src/journey-contracts.ts, packages/core/src/plugin.ts
Add entry/exit typing helpers, input schemas, refined defineModule inference, journey runtime contract types, and a plugin contract for registry extension.
Journeys Package (API, Runtime, Plugin, Tests)
packages/journeys/src/index.ts, packages/journeys/src/define-journey.ts, packages/journeys/src/handle.ts, packages/journeys/src/types.ts, packages/journeys/src/persistence.ts, packages/journeys/src/runtime.ts, packages/journeys/src/validation.ts, packages/journeys/src/plugin.tsx, packages/journeys/src/outlet.tsx, packages/journeys/src/module-tab.tsx, packages/journeys/src/provider.tsx, packages/journeys/.../*.test.tsx, packages/journeys/src/simulate-journey.ts, packages/journeys/src/testing.ts
New journeys package: definition helpers, typed persistence adapter, runtime implementation with hydration/rollback/persistence, outlet/module-tab/provider UI primitives, plugin integration, validation, simulators, and broad test coverage.
Registry Plugin Integration (React & TanStack runtimes)
packages/react-router-runtime/src/registry.ts, packages/react-router-runtime/src/types.ts, packages/tanstack-router-runtime/src/registry.ts, packages/tanstack-router-runtime/src/types.ts
Add plugin support to registries, expose moduleDescriptors/extensions/journeys in manifests, and wire onModuleExit forwarding.
Testing Utilities (render/simulate)
packages/react-router-testing/src/render-journey.tsx, packages/tanstack-router-testing/src/render-journey.tsx, packages/*-testing/src/render-module.tsx, packages/*-testing/src/index.ts
Add renderJourney helpers and extend renderModule to support entry-based rendering and typed input/exit for tests; re-export simulate/testing utilities.
Examples: customer-onboarding (React Router & TanStack)
examples/*/customer-onboarding-journey/...
Add two parallel end-to-end examples (React Router and TanStack) implementing profile → plan → billing journey: new packages, modules, journeys, shells, persistence adapters, Vite configs, and E2E tests.
Shell Workspace & UI (examples)
examples/*/customer-onboarding-journey/shell/src/stores/*, .../shell/src/services/*, .../shell/src/components/*, .../shell/src/main.tsx, .../shell/src/persistence.ts
Add workspace tabs store, workspace actions factory, TabStrip/TabContent/Home components, Tab lifecycle wiring, journey rehydration logic, and localStorage persistence helpers.
E2E & CI
examples/*/shell/e2e/*.spec.ts, examples/*/shell/playwright.config.ts, .github/workflows/ci.yml, .gitignore
Add Playwright smoke tests for both example shells, Playwright configs, CI job orchestration, and ignore rules for Playwright artifacts.
Build & Config Updates
packages/journeys/package.json, packages/journeys/tsconfig.json, packages/journeys/vite.config.ts, packages/journeys/vitest.config.ts, pnpm-workspace.yaml, package.json
Add journeys package manifest and build/test configs, update pnpm workspace globs, and bump pnpm version.

Sequence Diagram

sequenceDiagram
    participant User
    participant Shell
    participant JourneyRuntime
    participant Persistence
    participant JourneyOutlet
    participant ModuleTab

    User->>Shell: Click "Start Onboarding"
    Shell->>JourneyRuntime: start(journeyHandle, input)
    JourneyRuntime->>Persistence: load(key)
    alt persisted active blob
        Persistence-->>JourneyRuntime: blob (status: active)
        JourneyRuntime->>JourneyRuntime: hydrate(blob)
    else no blob
        JourneyRuntime->>JourneyRuntime: create instance (status: active)
        JourneyRuntime->>Persistence: save(serialized)
    end
    JourneyRuntime-->>Shell: { instanceId }
    Shell->>JourneyOutlet: mount(instanceId)
    JourneyOutlet->>JourneyRuntime: subscribe(instanceId)
    JourneyRuntime-->>JourneyOutlet: currentStep (moduleId, entry, input)
    JourneyOutlet->>ModuleTab: render module entry with input
    User->>ModuleTab: interact -> exit(name, payload)
    ModuleTab->>JourneyRuntime: exit(instanceId, name, payload)
    JourneyRuntime->>JourneyRuntime: apply transition, update state/history
    JourneyRuntime->>Persistence: save(updated)
    JourneyRuntime-->>JourneyOutlet: notify subscribers (new step / terminal)
    alt terminal
        JourneyRuntime->>Persistence: remove(key)
        JourneyOutlet->>Shell: onFinished(terminalOutcome)
    end
Loading

Estimated Code Review Effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • diogomiguel
  • mzpkdev

"🐇
I hopped through modules, typed each little part,
Saved states in burrows, wrapped transitions with heart.
From profile to billing, step by careful step,
I left carrot crumbs of types so journeys won't misstep."

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/journeys

@kibertoad kibertoad requested a review from diogomiguel April 23, 2026 17:36
@kibertoad kibertoad requested a review from casamitjana April 23, 2026 18:49
@kibertoad kibertoad marked this pull request as ready for review April 23, 2026 18:52
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/workspace-patterns.md (1)

489-500: ⚠️ Potential issue | 🟡 Minor

Example still calls the newly-deprecated openModuleTab.

You just annotated openModuleTab with @deprecated (line 467) and introduced openTab({ kind: 'module', ... }) as the replacement, but the InvoiceActions example below still demonstrates workspace.openModuleTab('payments'). Readers hitting this section after reading the interface change will get mixed signals. Update the example (and keep one short aside noting openModuleTab is still accepted for back-compat) so the doc's recommended call path is consistent.

📝 Suggested doc fix
 function InvoiceActions({ invoiceId }: { invoiceId: string }) {
   const workspace = useService('workspace')

   return (
-    <button onClick={() => workspace.openModuleTab('payments')}>
+    <button onClick={() => workspace.openTab({ kind: 'module', id: 'payments' })}>
       Pay Invoice
     </button>
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/workspace-patterns.md` around lines 489 - 500, Update the InvoiceActions
example to use the new API: replace the deprecated
workspace.openModuleTab('payments') call with workspace.openTab({ kind:
'module', id: 'payments' }) in the InvoiceActions component that uses
useService('workspace'); also add one short aside sentence below the example
stating that openModuleTab is still accepted for back-compat but deprecated,
referencing the openModuleTab and openTab symbols so readers see the recommended
call path.
🧹 Nitpick comments (39)
packages/tanstack-router-runtime/package.json (1)

49-58: Consider marking @modular-react/journeys as an optional peer dependency.

The PR states journeys is an additive, opt-in layer with no impact on apps that don't use it, but adding it as a required peerDependency will emit install warnings/errors for all existing consumers of @tanstack-react-modules/runtime who don't adopt journeys. Consider declaring it optional via peerDependenciesMeta.

♻️ Proposed change
   "peerDependencies": {
     "@modular-react/core": "^1.2.0",
     "@modular-react/journeys": "^0.1.0",
     "@modular-react/react": "^1.2.0",
     ...
+  },
+  "peerDependenciesMeta": {
+    "@modular-react/journeys": { "optional": true }
   }

Also note: @modular-react/journeys at ^0.1.0 is a 0.x range — per semver, any minor bump (0.2.0) will break this constraint. Confirm this is the intended floor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-runtime/package.json` around lines 49 - 58, Update
package metadata to make `@modular-react/journeys` optional: in
packages/tanstack-router-runtime/package.json, add a peerDependenciesMeta entry
for "@modular-react/journeys" with {"optional": true} and keep it listed under
peerDependencies so consumers that use it still get the peer check; also verify
the intended minimum semver for "@modular-react/journeys" (currently "^0.1.0" is
a 0.x range) and adjust the version spec if you want a less restrictive floor.
packages/react-router-testing/vite.config.ts (1)

13-27: Externalize subpaths with a regex to avoid missing future entry points.

Listing @modular-react/journeys and @modular-react/journeys/testing explicitly works, but if the package adds another subpath export later (e.g., /react) it will silently get bundled. A pattern like /^@modular-react\/journeys(\/.*)?$/ is more future-proof. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-testing/vite.config.ts` around lines 13 - 27, Replace
the literal entries for "@modular-react/journeys" and
"@modular-react/journeys/testing" in the Vite config's external array with a
regex that matches the package and any subpaths (e.g.,
/^@modular-react\/journeys(\/.*)?$/) so future subpath exports aren't
accidentally bundled; update the external array where those strings appear in
vite.config.ts (the external: [...] declaration) to include this regex instead
of the two explicit entries.
examples/tanstack-router/customer-onboarding-journey/shell/index.html (1)

7-41: Consider extracting shared shell CSS to avoid drift.

This style block appears duplicated across example shells. Moving it to a shared stylesheet would reduce maintenance overhead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/tanstack-router/customer-onboarding-journey/shell/index.html` around
lines 7 - 41, Extract the repeated shell CSS (the <style> block defining
selectors *, *::before, *::after, body, button, button:hover, button:disabled,
and code) into a shared stylesheet and replace the inline block in this file
with a <link rel="stylesheet"> reference to that shared file; ensure the shared
stylesheet preserves the exact rules and that any example shells update their
HTML to import the same shared CSS so styles don't drift.
examples/react-router/customer-onboarding-journey/shell/src/stores/workspace-tabs.ts (1)

30-30: Narrow the return type of findJourneyTabByInstance to JourneyTab | null.

The implementation filters by t.kind === "journey", so the result is always a JourneyTab (or null), never a ModuleTab. Typing it as Tab | null forces callers to re-narrow. Since this is the public contract of the store, downstream consumers (e.g. workspace-actions.ts) benefit from the precise type.

Note: the same fix applies to the duplicated file at examples/tanstack-router/customer-onboarding-journey/shell/src/stores/workspace-tabs.ts.

♻️ Proposed type narrowing
-  readonly findJourneyTabByInstance: (instanceId: string) => Tab | null;
+  readonly findJourneyTabByInstance: (instanceId: string) => JourneyTab | null;
     findJourneyTabByInstance: (instanceId) => {
       const { tabs } = get();
-      return tabs.find((t) => t.kind === "journey" && t.instanceId === instanceId) ?? null;
+      return (
+        tabs.find(
+          (t): t is JourneyTab => t.kind === "journey" && t.instanceId === instanceId,
+        ) ?? null
+      );
     },

Also applies to: 91-94

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/react-router/customer-onboarding-journey/shell/src/stores/workspace-tabs.ts`
at line 30, The method findJourneyTabByInstance currently returns Tab | null but
its implementation filters t.kind === "journey", so change its signature to
return JourneyTab | null; update the declaration of readonly
findJourneyTabByInstance to use JourneyTab | null (in workspace-tabs.ts) and
mirror the same change in the duplicate file in the tanstack-router example so
callers no longer need to re-narrow the result.
examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts (2)

26-29: Nit: readonly commands: readonly never[] is an awkward way to express "no slots".

never[] is fine but the intent ("modules contribute no slot items") reads more naturally as an empty tuple literal type, which also prevents any accidental future push of a non-never item from a structurally-compatible value.

-export interface AppSlots {
-  // Intentionally empty — this example renders exclusively via journey tabs.
-  readonly commands: readonly never[];
-}
+export interface AppSlots {
+  // Intentionally empty — this example renders exclusively via journey tabs.
+  readonly commands: readonly [];
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts`
around lines 26 - 29, Replace the awkward "readonly commands: readonly never[]"
in the AppSlots interface with an empty tuple literal type to express “no slots”
more clearly: update the AppSlots interface property commands to use a readonly
empty tuple (i.e., the empty tuple literal type) so it communicates intent and
prevents accidental pushes; adjust any callers if they relied on the previous
never[] shape.

58-63: Make the deprecated openModuleTab optional to allow shells to stop providing it.

openModuleTab is JSDoc-deprecated in favor of openTab({ kind: 'module', ... }) but remains a required member of WorkspaceActions. No active callers of openModuleTab exist in the codebase—both shell implementations already delegate it to openTab. Making it optional would allow shells to drop the legacy method at their own pace while maintaining backward compatibility.

♻️ Suggested change
 export interface WorkspaceActions {
   /** `@deprecated` Use `openTab({ kind: 'module', id, input })` instead. */
-  readonly openModuleTab: (moduleId: string, input?: unknown) => OpenTabResult;
+  readonly openModuleTab?: (moduleId: string, input?: unknown) => OpenTabResult;
   readonly openTab: (spec: OpenTabSpec) => OpenTabResult;
   readonly closeTab: (tabId: string) => void;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts`
around lines 58 - 63, The WorkspaceActions interface currently requires the
deprecated method openModuleTab; change its signature to be optional so shells
can omit it: update the interface definition for WorkspaceActions to make
openModuleTab?: (moduleId: string, input?: unknown) => OpenTabResult (keeping
the JSDoc deprecation comment), and ensure no other call sites rely on it by
preferring openTab(spec) where spec.kind === 'module'—leave openTab and closeTab
unchanged.
examples/tanstack-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts (1)

18-21: Module-level nextTabId is shared across all createWorkspaceActions instances.

Minor: if the shell ever instantiates multiple workspaces (e.g., tests, multi-root), they share a monotonic counter. Fine for a single-shell example, but consider moving the counter inside createWorkspaceActions for isolation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/tanstack-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts`
around lines 18 - 21, The module-level counter nextTabId and mintTabId create a
shared monotonic ID across all createWorkspaceActions instances; make the
counter instance-scoped by moving nextTabId into createWorkspaceActions and
either recreate mintTabId as a closure inside createWorkspaceActions or change
mintTabId to accept the instance counter, so each createWorkspaceActions call
gets its own counter (refer to nextTabId, mintTabId, and createWorkspaceActions
to locate the code).
packages/tanstack-router-testing/src/render-journey.test.tsx (1)

46-58: Optional: assert the initial entry renders before acting.

Currently the test jumps straight from status === "active" to clicking confirm-it. Adding expect(getByText("confirm-it")).toBeTruthy() (or similar) before the act() would catch regressions where the outlet mounts but fails to render the initial module entry, which is otherwise masked by the later getByText throwing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-testing/src/render-journey.test.tsx` around lines 46
- 58, Add an assertion that the initial entry is rendered before interacting:
after calling renderJourney(journey, ...) and after confirming
runtime.getInstance(instanceId)?.status is "active", assert that
getByText("confirm-it") (or an equivalent query for the initial outlet content)
is present/truthy before calling act() and clicking it; this ensures
renderJourney, the outlet mount, and the initial module entry (referenced by
getByText("confirm-it")) are actually rendered prior to driving the transition
with act() and clicking.
packages/journeys/src/module-tab.test.tsx (2)

102-107: Brittle: assertion depends on entry iteration order.

getByText(/review, other/) will fail if the notice ever lists keys alphabetically or in a different order. If the implementation guarantees insertion order, consider asserting both names independently to decouple from formatting:

♻️ Proposed tweak
-    expect(getByText(/exposes multiple entries/)).toBeTruthy();
-    expect(getByText(/review, other/)).toBeTruthy();
+    const notice = getByText(/exposes multiple entries/);
+    expect(notice.textContent).toMatch(/\breview\b/);
+    expect(notice.textContent).toMatch(/\bother\b/);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/module-tab.test.tsx` around lines 102 - 107, The test
for ModuleTab is brittle because it asserts a specific entry order using
getByText(/review, other/); change the assertion to check for each entry name
independently (e.g., assert that both "review" and "other" are present via
separate getByText calls or use a regex that accepts either order) so it doesn't
rely on insertion ordering of the module entries referenced by multiMod and
ModuleTab.

73-84: Nit: test title vs. scenario.

The title says "when no entry matches," but the test exercises a module that has no entryPoints at all (and passes no entry prop). Consider renaming to e.g. "falls back to module.component when the module declares no entries" to avoid implying the lookup-miss path (which is actually covered by the entry="ghost" test below).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/module-tab.test.tsx` around lines 73 - 84, Rename the
test title to accurately reflect the scenario: update the it(...) description
for the test that renders Legacy/legacyMod with ModuleTab to something like
"falls back to module.component when the module declares no entries"
(references: ModuleTab, defineModule, legacyMod, Legacy) so it doesn't imply an
entry lookup miss.
packages/journeys/vite.config.ts (1)

14-22: Optional: make the @modular-react/* external match future-proof.

The current list only externalizes exact package ids. If any source later imports a subpath (e.g. @modular-react/core/testing), Rollup would try to bundle it. A function form avoids that surprise.

♻️ Proposed refactor
     rollupOptions: {
-      external: [
-        "react",
-        "react/jsx-runtime",
-        "react-dom",
-        "@modular-react/core",
-        "@modular-react/react",
-      ],
+      external: (id) =>
+        id === "react" ||
+        id === "react-dom" ||
+        id.startsWith("react/") ||
+        id.startsWith("@modular-react/"),
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/vite.config.ts` around lines 14 - 22, The external array in
rollupOptions only matches exact package IDs so subpath imports like
`@modular-react/core/testing` would be bundled; update rollupOptions.external to a
function (or use a regex) that returns true for exact matches ("react",
"react/jsx-runtime", "react-dom") and also for any id that startsWith or matches
the prefix "@modular-react/" so all subpaths (e.g.
"@modular-react/core/testing") are treated external; locate rollupOptions and
replace the external array with a function that checks these conditions.
packages/journeys/src/simulate-journey.test.ts (1)

10-10: Nit: avoid shadowing module as a local identifier.

module is a well-known CJS global and reads awkwardly in ESM files; some lint configs (no-shadow-restricted-names, no-restricted-globals) will flag it. Consider renaming to e.g. menuModule.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/simulate-journey.test.ts` at line 10, The test declares
a local variable named "module" via the call to defineModule which shadows the
CommonJS global and may trigger lint rules; rename that local identifier (e.g.,
to "menuModule" or "testModule") wherever it is declared and used in this file
(the defineModule call and any references to its properties/methods) to avoid
shadowing and satisfy rules like
no-shadow-restricted-names/no-restricted-globals.
packages/react-router-testing/src/render-journey.test.tsx (1)

46-58: Consider using fireEvent.click or userEvent instead of native .click().

Calling .click() directly on the DOM node works but bypasses Testing Library's event system. fireEvent.click(getByText("confirm-it")) (or userEvent.click) is the idiomatic pattern and auto-wraps in act, making the explicit act() here unnecessary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-testing/src/render-journey.test.tsx` around lines 46 -
58, Replace the direct DOM .click() inside act with Testing Library's event
helper: in the test that calls renderJourney (symbols: renderJourney, runtime,
instanceId, getByText), remove the explicit act wrapper and invoke
fireEvent.click(getByText("confirm-it")) (or userEvent.click) so the click is
dispatched via Testing Library's event system and auto-wrapped; keep the rest of
the assertions unchanged.
examples/react-router/customer-onboarding-journey/shell/src/persistence.ts (1)

23-33: Consider validating shape, not just JSON-parse-ability.

load only guards against JSON parse errors; a JSON-valid-but-wrong-shape blob (e.g. from an older schema) still reaches the runtime and can blow up on hydration. For a demo this is probably fine, but a light typeof blob === "object" && blob && "status" in blob check (and remove on failure, matching your JSON-parse branch) would make reload-recovery more robust across schema drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react-router/customer-onboarding-journey/shell/src/persistence.ts`
around lines 23 - 33, In the load function (load: (key: string):
SerializedJourney<OnboardingState> | null), after JSON.parse succeeds, validate
the parsed blob’s shape before returning: ensure the value is a non-null object
and contains expected keys (e.g., check typeof parsed === "object" && parsed !==
null && "status" in parsed or other required props from
SerializedJourney/OnboardingState); if the shape check fails, call
localStorage.removeItem(key) and return null to match the parse-error branch so
malformed or old-schema data won’t be returned.
examples/tanstack-router/customer-onboarding-journey/shell/src/components/Home.tsx (1)

6-17: Unused tabsStore prop.

HomeProps declares tabsStore, but Home only destructures workspace. Either drop the prop from the interface or consume it (and remove the prop from callers).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/tanstack-router/customer-onboarding-journey/shell/src/components/Home.tsx`
around lines 6 - 17, HomeProps declares tabsStore but the Home component never
uses it; either remove tabsStore from the HomeProps interface or actually
consume it in the Home component. To fix, update the HomeProps type (remove the
readonly tabsStore: StoreApi<WorkspaceTabsState> field) and remove that prop
from any Home call sites/JSX where passed, or alternatively change the Home
signature to accept and destructure tabsStore (function Home({ workspace,
tabsStore }: HomeProps)) and use tabsStore inside the component; reference the
HomeProps interface, the tabsStore symbol, and the Home component when making
the change.
examples/tanstack-router/customer-onboarding-journey/shell/src/persistence.ts (1)

56-61: Consider cleaning up corrupt entries here too.

load removes invalid JSON from storage on parse failure, but hasPersistedJourney silently returns false and leaves the corrupt entry behind. Minor inconsistency — optional to mirror the cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/tanstack-router/customer-onboarding-journey/shell/src/persistence.ts`
around lines 56 - 61, The hasPersistedJourney function currently swallows JSON
parse errors and returns false without removing the corrupt blob; update its
catch block to delete the stored entry (using the same storage API used by load)
when JSON.parse throws, then return false. Locate hasPersistedJourney in
persistence.ts and mirror the cleanup logic from load: on parse failure call the
storage removal method (e.g., removeItem or delete for the same key) to clear
the corrupt entry before returning false.
examples/react-router/customer-onboarding-journey/shell/src/components/TabStrip.tsx (1)

27-38: Minor a11y: consider aria-current="page" on the active tab/Home button.

The active tab is indicated only via color. Adding aria-current communicates selection to assistive tech.

Also applies to: 57-70

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/react-router/customer-onboarding-journey/shell/src/components/TabStrip.tsx`
around lines 27 - 38, Add aria-current="page" to the Home button and to each tab
button when they are the active tab so assistive tech knows which tab is
selected; specifically update the JSX in TabStrip.tsx (the Home button that
calls tabsStore.getState().activateTab(null) and the other tab button render
block around lines 57-70) to conditionally include aria-current="page" when
state.activeTabId === null for Home or when state.activeTabId === tab.id for
each tab.
examples/react-router/customer-onboarding-journey/app-shared/package.json (1)

16-19: Consider workspace:* for intra-repo dependencies.

In a pnpm monorepo, referencing @modular-react/core and @react-router-modules/core by semver range may resolve to a registry version (or fail to resolve) instead of the local workspace package. Example packages typically use "workspace:*".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react-router/customer-onboarding-journey/app-shared/package.json`
around lines 16 - 19, The dependency entries in package.json currently use
semver ranges which can pull from the registry; change the intra-repo dependency
declarations for "@modular-react/core" and "@react-router-modules/core" in the
"dependencies" section to use "workspace:*" so pnpm resolves the local workspace
packages (update the two dependency values in package.json accordingly).
packages/journeys/src/validation.test.ts (1)

125-134: Consider asserting on specific issues.

expect(issues.length).toBeGreaterThanOrEqual(1) would still pass if validation surfaced an unrelated issue. Asserting on the issue codes/messages for the missing id and initialState fields would better pin the behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/validation.test.ts` around lines 125 - 134, The test
"reports missing required fields" should assert specific validation issues
instead of only checking length; update the test that calls
validateJourneyDefinition(bad) to look for issue objects that indicate the
missing id and missing initialState (e.g., check issues contains entries whose
path/name equals "id" and "initialState" or whose message/code references those
fields) so the test fails if unrelated issues are reported; locate the test in
validation.test.ts and replace the
expect(issues.length).toBeGreaterThanOrEqual(1) with assertions that inspect
issues[].path or issues[].message (or issues[].code) for "id" and
"initialState".
packages/journeys/src/provider.tsx (1)

41-45: Memoize the context value.

value is a fresh object on every render, so every descendant that subscribes via useJourneyContext re-renders whenever JourneyProvider's parent re-renders — even when runtime and onModuleExit are stable. Wrap in useMemo.

Proposed fix
-import { createContext, createElement, useContext } from "react";
+import { createContext, createElement, useContext, useMemo } from "react";
@@
 export function JourneyProvider(props: JourneyProviderProps): ReactNode {
   const { runtime, onModuleExit, children } = props;
-  const value: JourneyProviderValue = { runtime, onModuleExit };
+  const value = useMemo<JourneyProviderValue>(
+    () => ({ runtime, onModuleExit }),
+    [runtime, onModuleExit],
+  );
   return createElement(JourneyContext.Provider, { value }, children);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/provider.tsx` around lines 41 - 45, The JourneyProvider
creates a new value object each render causing unnecessary re-renders; memoize
the context value by wrapping the { runtime, onModuleExit } object in
React.useMemo and pass that memoized value to JourneyContext.Provider inside the
JourneyProvider function (keep references to JourneyProvider, value, runtime,
onModuleExit, and JourneyContext.Provider) so the context only updates when
runtime or onModuleExit change.
examples/react-router/customer-onboarding-journey/modules/billing/src/CollectPayment.tsx (1)

9-11: Nit: call out that Math.random() is illustrative only.

This is obviously an example component, but since "PAY-…" references are the kind of thing that gets copy-pasted into real billing code, it may be worth a one-line comment noting that a real integration should source the reference from the payment processor (or at minimum crypto.randomUUID()), not Math.random().toString(36). No functional change needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/react-router/customer-onboarding-journey/modules/billing/src/CollectPayment.tsx`
around lines 9 - 11, Add a one-line comment above the makeReference function
explaining that using Math.random() is only illustrative and should not be used
in production billing code; suggest sourcing a reference from the payment
processor or using a stronger identifier like crypto.randomUUID() instead, and
reference the function name makeReference so reviewers can locate it quickly.
packages/journeys/src/outlet.test.tsx (2)

1-11: Nit: afterEach hook is interleaved with imports.

The afterEach(cleanup) sits between the testing-library import and the module-under-test imports. Works fine because ESM imports are hoisted, but it reads oddly — consider moving it below all imports for clarity.

Proposed reorder
 import { act, cleanup, render } from "@testing-library/react";
 import { afterEach, describe, expect, it, vi } from "vitest";
-
-afterEach(() => {
-  cleanup();
-});
 import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core";
 import type { ModuleEntryProps } from "@modular-react/core";
 import { defineJourney } from "./define-journey.js";
 import { createJourneyRuntime } from "./runtime.js";
 import { JourneyOutlet } from "./outlet.js";
+
+afterEach(() => {
+  cleanup();
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet.test.tsx` around lines 1 - 11, Move the
afterEach(() => { cleanup(); }) hook so it appears after all imports (e.g.,
after imports of defineEntry/defineExit/defineModule/schema, ModuleEntryProps,
defineJourney, createJourneyRuntime, and JourneyOutlet) to avoid interleaving
test setup with module imports; locate the current afterEach usage and relocate
it below the import block so all imports are grouped together and then test
lifecycle hooks follow.

216-250: Loading-fallback test settles the promise but never asserts the post-load UI.

After resolveLoad(null) the test awaits two microtasks but never re-queries the DOM to confirm the loading fallback is removed or the real step rendered. That leaves the "transitions out of loading" path completely uncovered — a regression that keeps loadingFallback displayed forever would still pass this test.

Suggested follow-up assertion
     expect(getByText("please wait")).toBeTruthy();
     resolveLoad(null);
     await Promise.resolve();
     await Promise.resolve();
+    // After the persistence load resolves, the loading fallback should be gone
+    // and the real step should render.
+    expect(rt.getInstance(id)!.status).toBe("active");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet.test.tsx` around lines 216 - 250, The test
currently resolves the persistence load promise (resolveLoad(null)) but never
asserts the post-load UI; update the test around the JourneyOutlet render to
wait for the UI after resolving the load: after resolveLoad(null) await the DOM
update using an async matcher (e.g. await findByText or waitFor) to assert that
the loadingFallback text "please wait" is removed and the expected step content
from the journey is present; locate this change in the test that uses
createJourneyRuntime, JourneyOutlet, resolveLoad and getByText and add the
appropriate post-resolution assertions.
packages/react-router-runtime/src/registry.ts (1)

319-349: Consider making moduleDescriptors immutable before handing it to the runtime.

moduleDescriptors is a plain mutable record that gets passed into createJourneyRuntime and also surfaced on the resolved manifest (lines 338, 452, 521). If the runtime (or a consumer) later adds or swaps descriptors at runtime, you'd silently mutate the assembly record shared with the manifest. A shallow Object.freeze here would surface such misuse immediately without any real cost.

Optional hardening
     const moduleDescriptors: Record<string, ModuleDescriptor<any, any, any, any>> = {};
     for (const mod of modules)
       moduleDescriptors[mod.id] = mod as ModuleDescriptor<any, any, any, any>;
+    Object.freeze(moduleDescriptors);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-runtime/src/registry.ts` around lines 319 - 349,
moduleDescriptors is a mutable plain object that gets passed into
createJourneyRuntime and returned on the manifest; make it immutable to prevent
later accidental mutation by calling Object.freeze(moduleDescriptors) (and
optionally Object.freeze(...) each descriptor value) immediately after
populating it and before passing it to createJourneyRuntime and returning it, so
the runtime or consumers will get a frozen/shallow-immutable moduleDescriptors
and mutations will throw rather than silently corrupt the manifest.
packages/core/src/entry-exit.ts (1)

81-87: Minor: simplify the exit-shape predicate.

exit !== undefined && (exit === null || typeof exit !== "object") is correct but reads awkwardly. Since typeof null === "object", the condition can fold into a single expression.

Optional simplification
   if (exitPoints) {
     for (const [name, exit] of Object.entries(exitPoints)) {
-      if (exit !== undefined && (exit === null || typeof exit !== "object")) {
+      if (exit !== undefined && (exit === null || typeof exit !== "object")) {
+        issues.push(`exit "${name}" must be an object or undefined`);
+      }
+      // Equivalent, slightly tighter:
+      // if (exit !== undefined && (typeof exit !== "object" || exit === null)) { ... }
-        issues.push(`exit "${name}" must be an object or undefined`);
-      }
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/entry-exit.ts` around lines 81 - 87, The predicate for
validating exit shapes is awkward; replace the complex condition in the loop
over exitPoints with a clearer expression such as testing for explicit null or
non-object values: use exit === null || (exit !== undefined && typeof exit !==
"object") where the current code checks exit !== undefined && (exit === null ||
typeof exit !== "object"); update the check inside the for (const [name, exit]
of Object.entries(exitPoints)) loop so issues.push still runs when exit is null
or any non-object (but not when undefined).
packages/tanstack-router-runtime/src/registry.ts (1)

366-377: Consider rejecting duplicate journey registrations.

registerJourney validates the structural shape but doesn't check for a duplicate (id, version) already present in journeys[]. A host that accidentally imports/registers the same journey twice will end up with both entries, and downstream validateJourneyContracts / createJourneyRuntime will see a list with duplicates. The module path guards this via validateNoDuplicateIds; a symmetric check here would surface the mistake loudly at registration time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-runtime/src/registry.ts` around lines 366 - 377,
registerJourney currently validates structure but allows duplicate
registrations; update registerJourney to check journeys[] for an existing entry
with the same definition.id and definition.version before pushing. If a
duplicate (same id and version) is found, throw a descriptive error (e.g.,
JourneyValidationError or a new DuplicateJourneyError) identifying the
id/version so registration fails fast; keep the existing
validateJourneyDefinition check and only push when no duplicate is present. Use
the symbols registerJourney, journeys, and
AnyJourneyDefinition.definition.id/version to locate and implement the check.
examples/react-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts (1)

14-17: Module-scoped nextTabId persists across factory calls.

nextTabId lives at module scope, so every call to createWorkspaceActions() shares the same counter. In an example app this is fine, but if this pattern gets copy-pasted into a real host (or used from tests that instantiate multiple registries), IDs can collide with another workspace's assumptions or leak across test cases. Moving it inside createWorkspaceActions would make each workspace self-contained.

♻️ Proposed scope change
-let nextTabId = 1;
-function mintTabId(kind: "module" | "journey", key: string): string {
-  return `${kind}:${key}:${nextTabId++}`;
-}
-
 /**
  * Wire the workspace actions. The journey runtime reference is filled in
  * after `registry.resolve()` (which is what creates the runtime), so we
  * accept a mutable box instead of a concrete runtime.
  */
 export function createWorkspaceActions(
   tabsStore: StoreApi<WorkspaceTabsState>,
   runtimeRef: RuntimeRef,
 ): WorkspaceActions {
+  let nextTabId = 1;
+  const mintTabId = (kind: "module" | "journey", key: string): string =>
+    `${kind}:${key}:${nextTabId++}`;
+
   function openTab(spec: OpenTabSpec): OpenTabResult {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/react-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts`
around lines 14 - 17, The module-level counter nextTabId and mintTabId should be
moved inside createWorkspaceActions so each workspace gets its own counter;
update createWorkspaceActions to declare let nextTabId = 1 and define
mintTabId(kind: "module" | "journey", key: string): string within that function
so mintTabId closes over the per-instance counter, and replace external uses of
the top-level mintTabId/nextTabId with the new instance-scoped mintTabId
returned or used by functions like createWorkspaceActions.
packages/core/src/types.ts (1)

203-291: LGTM — entry/exit type graph is cohesive and well-documented.

ExitFn + ExitOutputArg correctly make the payload argument optional for void exits while keeping it required for schemas with a declared output, and the allowBack tri-state on ModuleEntryPoint lines up with the rollback behavior tested in the runtime suite.

Optional nit: TExits extends ExitPointMap = {} works but reads a bit oddly — the {} default is valid only because ExitPointMap uses Record<string, …>. Using ExitPointMap as the default would be marginally clearer:

Optional polish
-export interface ModuleEntryProps<TInput, TExits extends ExitPointMap = {}> {
+export interface ModuleEntryProps<TInput, TExits extends ExitPointMap = ExitPointMap> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/types.ts` around lines 203 - 291, The default generic for
TExits is currently an empty object (TExits extends ExitPointMap = {}) which
relies on ExitPointMap being a Record and is slightly unclear; change the
default to use ExitPointMap directly so it reads TExits extends ExitPointMap =
ExitPointMap. Update the generic default in ModuleEntryProps<TInput, TExits
extends ExitPointMap = ExitPointMap> and the related ExitFn<TExits extends
ExitPointMap = ...> signature(s) to use ExitPointMap as the default to make
intent clearer.
packages/journeys/src/runtime.test.ts (2)

439-443: Comment mismatches the test.

The comment describes "three transitions rapid-fire," but the test only fires a single cancelled exit (plus the implicit start). Tightening this avoids future confusion when someone tries to reconcile the comment with the assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.test.ts` around lines 439 - 443, The test
comment incorrectly says "three transitions rapid-fire" while the code only
triggers an implicit "start" and a single explicit exit("cancelled") via
internals.__bindStepCallbacks(internals.__getRecord(id)!,
reg).exit("cancelled"); update the comment to describe that the test fires a
single cancelled transition (after the implicit start) and asserts the runtime
coalesces saves so there is at most one in-flight save, or remove the
three-transitions wording so the comment matches the actual behavior being
tested.

550-550: Use modern vi.fn generic syntax for vitest 4.1.0.

Line 550 uses the legacy pre-1.0 vi.fn<[Args], Return> generic form. Vitest 4.1.0 and later expect vi.fn<FunctionType>() syntax for full type safety:

♻️ Proposed fix
-    const remove = vi.fn<[string], void>();
+    const remove = vi.fn<(k: string) => void>();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.test.ts` at line 550, The test mock uses the
old vi.fn generic form; update the declaration for the mock named remove to use
the modern vitest 4.1+ generic signature (a function type) instead of
vi.fn<[string], void>(); locate the remove constant in runtime.test.ts and
replace its type parameter with a function type (e.g., a single-argument
function taking string and returning void) by using vi.fn< (arg: string) => void
>() so the mock is fully type-safe under the new API.
packages/react-router-testing/src/render-journey.tsx (1)

38-92: Extract shared renderJourney implementation into a router-agnostic factory.

The function implementations in this file and packages/tanstack-router-testing/src/render-journey.tsx are identical except for the @react-router-modules/core vs @tanstack-react-modules/core import. The interface definitions, the moduleDescriptors/moduleEntries loop, runtime creation, and render tree wiring are duplicated verbatim.

Consider creating a factory in @modular-react/journeys/testing (or internal utilities) that accepts { separateDeps, SharedDependenciesContext } and returns the configured renderer, letting each router package provide only the package-specific wiring. This eliminates drift and makes future changes safer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-testing/src/render-journey.tsx` around lines 38 - 92,
The renderJourney implementation is duplicated across router packages; extract
the shared logic (the dependency separation using separateDeps, the
moduleDescriptors/moduleEntries loop, createJourneyRuntime call, runtime.start,
and the render tree wiring around JourneyOutlet) into a router-agnostic factory
in the testing utilities (e.g., a function that accepts { separateDeps,
SharedDependenciesContext } and returns a configured renderJourney). Replace the
local renderJourney in both packages to call that factory and only keep
router-specific bits (the router-specific SharedDependenciesContext import and
any package-specific types), ensuring the factory exposes a generic function
signature compatible with renderJourney’s generics and returns { runtime,
instanceId } merged with the testing-library result; update references to
moduleDescriptors, moduleEntries, createJourneyRuntime, and render usage to use
the centralized implementation.
packages/journeys/src/types.ts (1)

196-214: SerializedJourney.status drops "loading" — confirm intent and document.

JourneyStatus includes "loading" but SerializedJourney.status narrows to "active" | "completed" | "aborted". The runtime's serialize() coerces loading → active at line 315 of runtime.ts. Worth a short doc comment here pointing to that coercion so future hydrate/migration authors don't assume a loading blob is possible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/types.ts` around lines 196 - 214,
SerializedJourney.status narrows possible values to "active" | "completed" |
"aborted" but JourneyStatus includes "loading", and runtime.serialize() coerces
"loading" → "active"; add a short doc comment on the SerializedJourney.status
property explaining that "loading" is intentionally omitted because
runtime.serialize() converts loading into active (see serialize() in runtime.ts)
so serialized blobs will never contain "loading", to help future
hydrate/migration authors avoid assuming a "loading" serialized state.
packages/journeys/src/runtime.ts (3)

508-512: entryAllowBackModeForStep is a single-line re-export of entryAllowBackMode.

Only referenced once (line 454). Consider inlining or removing the wrapper.

♻️ Proposed cleanup
-        const nextMode = entryAllowBackModeForStep(nextStep);
+        const nextMode = entryAllowBackMode(nextStep);
         ...
-  function entryAllowBackModeForStep(
-    step: JourneyStep | null,
-  ): "preserve-state" | "rollback" | false {
-    return entryAllowBackMode(step);
-  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.ts` around lines 508 - 512, The function
entryAllowBackModeForStep is a trivial one-line wrapper around
entryAllowBackMode and is only used once; remove entryAllowBackModeForStep and
update its sole call site to call entryAllowBackMode(step) directly (or if you
prefer to keep the alias, export entryAllowBackMode under the desired name and
delete the wrapper), ensuring imports/exports are adjusted accordingly and any
TypeScript types remain compatible.

125-135: Listener iteration is not safe against listeners that unsubscribe during notify.

record.listeners.forEach-style iteration over a Set while a listener's callback calls subscribe's returned unsubscribe (which does listeners.delete) is permitted by spec, but a listener that both unsubscribes itself and the last-visited sibling can skip a notification depending on insertion order. For a useSyncExternalStore-driven world this rarely bites, but snapshotting the listener set before iteration is a cheap safety net.

♻️ Proposed hardening
-    for (const listener of record.listeners) {
+    for (const listener of [...record.listeners]) {
       try {
         listener();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.ts` around lines 125 - 135, The notify function
mutates record.listeners while iterating, which can cause some listeners to be
skipped if callbacks unsubscribe others; modify notify (operating on
InstanceRecord and its record.listeners) to take a stable snapshot of the
listeners (e.g., Array.from(record.listeners) or spread into a new array) before
the for-loop, then iterate that snapshot and call each listener inside the
try/catch so subscriptions/unsubscriptions during notification won’t affect
which callbacks are invoked.

779-802: probeLoad's reg parameter is unused.

The function body only uses persistence and key. Dropping the unused parameter tightens the API a bit and quiets future lint tightening.

♻️ Proposed cleanup
-  function probeLoad(
-    reg: RegisteredJourney,
-    persistence: JourneyPersistence<unknown>,
-    key: string,
-  ):
+  function probeLoad(
+    persistence: JourneyPersistence<unknown>,
+    key: string,
+  ):

(and update the two call sites at lines 846 / around the async branch).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.ts` around lines 779 - 802, The probeLoad
function currently declares an unused first parameter reg (type
RegisteredJourney); remove that parameter from the probeLoad signature and any
related type mention so the function becomes probeLoad(persistence:
JourneyPersistence<unknown>, key: string) with the same return union, and update
both call sites that currently pass a RegisteredJourney to call
probeLoad(persistence, key) instead of probeLoad(reg, persistence, key); also
remove any now-unused RegisteredJourney imports and ensure the existing
persistence.load usage and error handling are kept unchanged.
examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts (1)

147-152: onHydrate throwing is handled, but consider returning null-equivalent for smoother migration story.

migrateBlob catches the thrown error and falls through to startFresh (silently, unless debug is on) — so the user's onboarding progress is lost on version bumps with no telemetry. If that's the intent for this example, fine; otherwise, log or emit a hydration event so the shell can surface "your session expired due to an upgrade" rather than silently restarting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts`
around lines 147 - 152, The onHydrate handler currently throws for unknown
blob.version which makes migrateBlob fall back to startFresh silently; change
onHydrate (in this file) to return a null-equivalent (e.g., null) instead of
throwing when blob.version !== "1.0.0", and also emit or log a
hydration/fallback event (or call the existing telemetry/log function) so the
shell can surface "session expired due to upgrade" rather than silently losing
progress; adjust callers that expect a blob to handle a null return and invoke
startFresh only after logging/emitting the event.
packages/tanstack-router-runtime/src/types.ts (1)

121-134: Consider extracting the onModuleExit event shape into a named type.

The same { moduleId, entry, exit, output, tabId? } event is inlined in both ResolveManifestOptions and ResolvedManifest (and implicitly in ApplicationManifest callers). Extracting a ModuleExitEvent type would keep the two signatures in lockstep and also give consumers a name to import for typing their callbacks.

♻️ Proposed refactor
+export interface ModuleExitEvent {
+  readonly moduleId: string;
+  readonly entry: string;
+  readonly exit: string;
+  readonly output: unknown;
+  readonly tabId?: string;
+}
+
 export interface ResolveManifestOptions<...> {
   ...
-  onModuleExit?: (event: {
-    readonly moduleId: string;
-    readonly entry: string;
-    readonly exit: string;
-    readonly output: unknown;
-    readonly tabId?: string;
-  }) => void;
+  onModuleExit?: (event: ModuleExitEvent) => void;
 }

Also applies to: 221-228

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-runtime/src/types.ts` around lines 121 - 134,
Extract the inline event shape used by onModuleExit into a named type (e.g.,
ModuleExitEvent) and replace the three inline occurrences with that type so
signatures stay consistent: create export type ModuleExitEvent = { readonly
moduleId: string; readonly entry: string; readonly exit: string; readonly
output: unknown; readonly tabId?: string }; then update the onModuleExit
property signature in the definitions that reference it (onModuleExit?: (event:
ModuleExitEvent) => void) inside ResolveManifestOptions, ResolvedManifest, and
any ApplicationManifest callers so they all share the same named type.
packages/journeys/src/outlet.tsx (2)

170-178: Nit: children: null in props alongside a third-arg child is redundant.

createElement's third argument overrides props.children, so children: null exists only to satisfy the StepErrorBoundaryProps.children: ReactNode required type. Marking children optional on the internal interface would drop the noise here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet.tsx` around lines 170 - 178, The props object
passed to createElement includes a redundant children: null because the third
argument provides the actual child; update the StepErrorBoundaryProps interface
to make children optional (e.g., children?: ReactNode) so callers like the
createElement(StepErrorBoundary, { moduleId: step.moduleId, onError:
handleError, key: stepKey }, ...) no longer need to pass children: null; change
the StepErrorBoundaryProps type definition and any consumers that relied on
children being required (referencing StepErrorBoundaryProps and the
StepErrorBoundary component) accordingly.

56-73: Explicit runtime can still be undefined without a provider.

If a caller passes runtime={someMaybeUndefined} plus no provider is mounted, both runtimeProp and context?.runtime can be undefined, and the thrown error says "either pass runtime or mount a provider" — which the author did attempt. Message is fine, but consider also narrowing the prop type to JourneyRuntime (required) when the caller opts into explicit mode. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet.tsx` around lines 56 - 73, The runtime can still
be undefined when a caller passes runtime={maybeUndefined} without a provider;
to fix this either make the prop required at the type level or add an explicit
runtime-prop presence check: update the JourneyOutletProps runtime type to be a
non-optional JourneyRuntime (remove undefined from the type) so callers cannot
compile with runtime={undefined}, or (if you prefer runtime remain optional) in
JourneyOutlet (use runtimeProp and context via useJourneyContext) detect when
props has an explicit runtime prop but it is undefined (e.g., check
Object.prototype.hasOwnProperty.call(props, 'runtime') && runtimeProp ===
undefined) and throw a clearer error message indicating the caller passed an
explicit undefined runtime; refer to JourneyOutlet, runtimeProp,
JourneyOutletProps and useJourneyContext when making the change.

Comment thread packages/journeys/src/provider.test.tsx Outdated
Comment thread packages/journeys/src/runtime.ts
Comment thread packages/journeys/src/runtime.ts
Comment thread packages/journeys/src/simulate-journey.ts Outdated
Comment thread packages/journeys/src/types.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/journeys/src/outlet.tsx (2)

174-182: Nit: children: null in the props object is dead — the explicit child overrides it.

React.createElement(type, config, ...children) ignores config.children when ...children is also supplied, so the children: null entry never takes effect. Minor, but it could mislead a reader into thinking both are passed or that the boundary intentionally nulls children. Drop the children: null key from the props object.

Proposed cleanup
   return createElement(
     StepErrorBoundary,
-    { moduleId: step.moduleId, onError: handleError, key: stepKey, children: null },
+    { moduleId: step.moduleId, onError: handleError, key: stepKey },
     createElement(StepComponent, {
       input: step.input,
       exit,
       goBack,
     }),
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet.tsx` around lines 174 - 182, The props object
passed to React.createElement for StepErrorBoundary includes a redundant
"children: null" that is ignored because an explicit child element is supplied;
update the createElement call that constructs StepErrorBoundary (the one using
StepErrorBoundary, { moduleId: step.moduleId, onError: handleError, key:
stepKey, children: null }, createElement(StepComponent, {...})) by removing the
children: null entry so the props only contain moduleId, onError, and key (leave
the inner createElement(StepComponent, { input: step.input, exit, goBack }) as
the explicit child).

148-169: Consider exposing incrementRetry() and getRetryCount() accessors on JourneyRuntimeInternals for explicit retry budget management.

The outlet directly mutates record.retryCount alongside the runtime's internal writes (initialization at line 706 in runtime.ts, reset at line 739). While retryCount is mutable on InstanceRecord, this creates hidden coupling between the React layer and the runtime's internal state. The comment explaining the design (survives stepToken bumps mid-retry) is good, but the pattern of direct field access across modules makes it harder to evolve this contract later. Exposing dedicated accessor methods would make the coupling explicit and discoverable, improving maintainability without changing functionality.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet.tsx` around lines 148 - 169, Replace direct
reads/writes of record.retryCount from the React boundary with explicit runtime
accessors: add getRetryCount(instanceId) and incrementRetry(instanceId) (or
similarly named methods) to the JourneyRuntimeInternals API and implement them
to operate on the InstanceRecord.retryCount; then update handleError in
outlet.tsx to call runtime.getRetryCount(instanceId) to compare with retryLimit
and runtime.incrementRetry(instanceId) instead of mutating record.retryCount,
and update any runtime initialization/reset code that currently touches
retryCount directly to use the new accessors so the retry budget is only managed
via the runtime API.
packages/journeys/src/runtime.test.ts (1)

434-467: Test name slightly oversells what it verifies.

The name says "coalesces rapid saves so there is at most one in flight", but the body fires only a single cancelled exit on top of the start-time save. Since cancelled is terminal, the subsequent scheduling hits removePersisted (deferred by pendingRemove), not the non-terminal "pending save coalesces" branch in schedulePersist. Consider either renaming to reflect what's actually asserted (e.g. "terminal transition while a save is in-flight doesn't spawn a second save") or adding a non-terminal intermediate transition to exercise pendingSave overwrite semantics. Coverage of the rejected-save coalesce path is already handled at lines 804-844.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.test.ts` around lines 434 - 467, The test title
overstates behavior: it only fires a terminal exit("cancelled") on top of the
start-time save, so the runtime takes the removePersisted/pendingRemove path
instead of exercising schedulePersist's non-terminal pendingSave
overwrite/coalesce logic; update the test to either rename it to something like
"terminal transition while a save is in-flight doesn't spawn a second save" or
change the sequence of transitions invoked via
internals.__bindStepCallbacks(...).exit(...) to include a non-terminal
transition (e.g., trigger a non-terminal state transition before the final
terminal one) so schedulePersist's pendingSave overwrite path (referenced by
schedulePersist, pendingSave, pendingRemove, and removePersisted) is actually
executed and asserted.
packages/journeys/src/types.ts (1)

244-262: Minor: JourneyRegisterOptions.onTransition drops the generics that the definition-level hook carries.

JourneyDefinition.onTransition is (ev: TransitionEvent<TModules, TState>) => void, but the registration-level hook here is (ev: TransitionEvent) => void (defaults). Shell-level analytics lose the typed state/history/step narrowing that journey authors get for free. Consider parameterizing:

Proposed typing
 export interface JourneyRegisterOptions<TState = unknown> {
-  onTransition?: (ev: TransitionEvent) => void;
+  onTransition?: (ev: TransitionEvent<ModuleTypeMap, TState>) => void;
   persistence?: JourneyPersistence<TState>;

Not a blocker — shells typically treat registration hooks as type-erased — but it's cheap to recover.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/types.ts` around lines 244 - 262,
JourneyRegisterOptions currently loses generic typing for the onTransition hook;
update the type parameters and hook signature so registration preserves
module+state types: add a TModules generic (e.g. JourneyRegisterOptions<TState =
unknown, TModules = unknown>) and change onTransition to accept a typed
TransitionEvent (onTransition?: (ev: TransitionEvent<TModules, TState>) => void)
so it matches JourneyDefinition.onTransition and preserves state/history/step
narrowing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/journeys/src/runtime.test.ts`:
- Around line 567-591: Replace the deprecated Vitest 1 signature vi.fn<[string],
void>() with the new single function-type form; in this test change the mock
declaration to vi.fn<(k: string) => void>() (or vi.fn<(arg: string) => void>())
so the remove mock has the correct typed function signature referenced in the
test (the remove constant and its expectation remain unchanged).

---

Nitpick comments:
In `@packages/journeys/src/outlet.tsx`:
- Around line 174-182: The props object passed to React.createElement for
StepErrorBoundary includes a redundant "children: null" that is ignored because
an explicit child element is supplied; update the createElement call that
constructs StepErrorBoundary (the one using StepErrorBoundary, { moduleId:
step.moduleId, onError: handleError, key: stepKey, children: null },
createElement(StepComponent, {...})) by removing the children: null entry so the
props only contain moduleId, onError, and key (leave the inner
createElement(StepComponent, { input: step.input, exit, goBack }) as the
explicit child).
- Around line 148-169: Replace direct reads/writes of record.retryCount from the
React boundary with explicit runtime accessors: add getRetryCount(instanceId)
and incrementRetry(instanceId) (or similarly named methods) to the
JourneyRuntimeInternals API and implement them to operate on the
InstanceRecord.retryCount; then update handleError in outlet.tsx to call
runtime.getRetryCount(instanceId) to compare with retryLimit and
runtime.incrementRetry(instanceId) instead of mutating record.retryCount, and
update any runtime initialization/reset code that currently touches retryCount
directly to use the new accessors so the retry budget is only managed via the
runtime API.

In `@packages/journeys/src/runtime.test.ts`:
- Around line 434-467: The test title overstates behavior: it only fires a
terminal exit("cancelled") on top of the start-time save, so the runtime takes
the removePersisted/pendingRemove path instead of exercising schedulePersist's
non-terminal pendingSave overwrite/coalesce logic; update the test to either
rename it to something like "terminal transition while a save is in-flight
doesn't spawn a second save" or change the sequence of transitions invoked via
internals.__bindStepCallbacks(...).exit(...) to include a non-terminal
transition (e.g., trigger a non-terminal state transition before the final
terminal one) so schedulePersist's pendingSave overwrite path (referenced by
schedulePersist, pendingSave, pendingRemove, and removePersisted) is actually
executed and asserted.

In `@packages/journeys/src/types.ts`:
- Around line 244-262: JourneyRegisterOptions currently loses generic typing for
the onTransition hook; update the type parameters and hook signature so
registration preserves module+state types: add a TModules generic (e.g.
JourneyRegisterOptions<TState = unknown, TModules = unknown>) and change
onTransition to accept a typed TransitionEvent (onTransition?: (ev:
TransitionEvent<TModules, TState>) => void) so it matches
JourneyDefinition.onTransition and preserves state/history/step narrowing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 193da361-b4a1-4485-aaa3-f78f15018e29

📥 Commits

Reviewing files that changed from the base of the PR and between fc5eb2f and 1f1cc7e.

📒 Files selected for processing (19)
  • examples/react-router/customer-onboarding-journey/modules/billing/src/StartTrial.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/main.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/stores/workspace-tabs.ts
  • examples/tanstack-router/customer-onboarding-journey/README.md
  • examples/tanstack-router/customer-onboarding-journey/modules/billing/src/StartTrial.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/package.json
  • examples/tanstack-router/customer-onboarding-journey/shell/src/components/Shell.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/main.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/stores/workspace-tabs.ts
  • packages/journeys/README.md
  • packages/journeys/src/module-tab.test.tsx
  • packages/journeys/src/module-tab.tsx
  • packages/journeys/src/outlet.test.tsx
  • packages/journeys/src/outlet.tsx
  • packages/journeys/src/provider.test.tsx
  • packages/journeys/src/runtime.test.ts
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/simulate-journey.ts
  • packages/journeys/src/types.ts
✅ Files skipped from review due to trivial changes (3)
  • examples/tanstack-router/customer-onboarding-journey/shell/package.json
  • examples/tanstack-router/customer-onboarding-journey/README.md
  • examples/tanstack-router/customer-onboarding-journey/shell/src/stores/workspace-tabs.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • examples/react-router/customer-onboarding-journey/shell/src/stores/workspace-tabs.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/src/components/Shell.tsx
  • examples/tanstack-router/customer-onboarding-journey/modules/billing/src/StartTrial.tsx
  • packages/journeys/src/module-tab.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/main.tsx
  • packages/journeys/src/outlet.test.tsx
  • packages/journeys/src/runtime.ts

Comment thread packages/journeys/src/runtime.test.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/journeys/src/runtime.test.ts (3)

434-467: Test gap: coalesced save is never observed to actually flush.

The test only asserts saves.length === 1 while the first save is paused, then releases it and awaits microtasks without re-checking. If the coalescing logic regresses such that the second save is dropped entirely (e.g., pending flag cleared prematurely), this test would still pass. Add a post-release assertion that the coalesced save ran to lock in the intended behavior.

♻️ Proposed tightening
     resolveFirst();
     await Promise.resolve();
     await Promise.resolve();
+    // Coalesced terminal save must flush once the first save settles.
+    expect(saves.length).toBe(2);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.test.ts` around lines 434 - 467, The test
currently only asserts saves.length === 1 while the first save is paused and
never verifies the coalesced save actually flushed; after calling resolveFirst()
and awaiting microtasks (the existing await Promise.resolve() lines), add a
follow-up assertion that the coalesced save ran (e.g.,
expect(saves.length).toBe(2) or assert the final saved timestamp changed) so the
test ensures the second/coalesced save was executed; update the test around the
resolveFirst()/await Promise.resolve() section in the "coalesces rapid saves so
there is at most one in flight" case to perform this post-release check using
the existing saves array and resolveFirst/firstDone helpers.

668-692: Unnecessary async on a fully synchronous test.

The test body contains no await. Drop async to avoid implying an async contract.

✏️ Proposed edit
-  it("keyFor collisions across journeys do not alias onto the same instance", async () => {
+  it("keyFor collisions across journeys do not alias onto the same instance", () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.test.ts` around lines 668 - 692, The test
"keyFor collisions across journeys do not alias onto the same instance" is
declared async but contains no await; remove the unnecessary async keyword from
the it(...) callback to avoid implying an async contract—edit the test function
where it's defined (the it(...) block for that description in runtime.test.ts)
and change the callback from async () => { ... } to () => { ... } so the test
runs synchronously without an unused async wrapper.

457-461: Comment claims "three transitions" but only two occur.

The sequence is start + cancelled (two saves), not three. Small wording cleanup.

✏️ Proposed edit
-    // Fire three transitions rapid-fire — cancelled completes. Sequence:
-    // start (save queued), cancelled (save queued & coalesces on top of the
-    // first in-flight save). The runtime must have at most one save in
-    // flight at a time.
+    // Sequence: start (save queued), cancelled (save queued & coalesces
+    // on top of the first in-flight save). The runtime must have at most
+    // one save in flight at a time.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.test.ts` around lines 457 - 461, The comment
incorrectly says "Fire three transitions rapid-fire" but the code only triggers
two transitions (start then cancelled); update the comment near
internals.__bindStepCallbacks(internals.__getRecord(id)!, reg).exit("cancelled")
to describe two transitions (e.g., "Fire two transitions rapid‑fire — cancelled
completes" or similar), and adjust the following sentence to reflect "two saves"
instead of "three" so the description matches the sequence produced by
__bindStepCallbacks and __getRecord exit("cancelled").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/journeys/src/runtime.test.ts`:
- Around line 716-739: The current assertion in the test for hydrate() is too
broad (matching /version mismatch/) and masks the real cause when onHydrate
throws; update the expectation to assert the wrapped error more precisely—for
example call rt.hydrate("collect", blob) inside a throws assertion that either
checks the thrown value is an instance of JourneyHydrationError or that the
error message (or error.cause message) contains the original "migration bailed"
string coming from onHydrate; locate the test block using onHydrate, rt.hydrate,
and migrateBlob and replace the expect(...).toThrow(/version mismatch/) with an
assertion matching the specific "migration bailed" substring or asserting
JourneyHydrationError + cause contains that message.

---

Nitpick comments:
In `@packages/journeys/src/runtime.test.ts`:
- Around line 434-467: The test currently only asserts saves.length === 1 while
the first save is paused and never verifies the coalesced save actually flushed;
after calling resolveFirst() and awaiting microtasks (the existing await
Promise.resolve() lines), add a follow-up assertion that the coalesced save ran
(e.g., expect(saves.length).toBe(2) or assert the final saved timestamp changed)
so the test ensures the second/coalesced save was executed; update the test
around the resolveFirst()/await Promise.resolve() section in the "coalesces
rapid saves so there is at most one in flight" case to perform this post-release
check using the existing saves array and resolveFirst/firstDone helpers.
- Around line 668-692: The test "keyFor collisions across journeys do not alias
onto the same instance" is declared async but contains no await; remove the
unnecessary async keyword from the it(...) callback to avoid implying an async
contract—edit the test function where it's defined (the it(...) block for that
description in runtime.test.ts) and change the callback from async () => { ... }
to () => { ... } so the test runs synchronously without an unused async wrapper.
- Around line 457-461: The comment incorrectly says "Fire three transitions
rapid-fire" but the code only triggers two transitions (start then cancelled);
update the comment near
internals.__bindStepCallbacks(internals.__getRecord(id)!, reg).exit("cancelled")
to describe two transitions (e.g., "Fire two transitions rapid‑fire — cancelled
completes" or similar), and adjust the following sentence to reflect "two saves"
instead of "three" so the description matches the sequence produced by
__bindStepCallbacks and __getRecord exit("cancelled").
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5129c09b-4022-4d28-a970-8c8b65829414

📥 Commits

Reviewing files that changed from the base of the PR and between 1f1cc7e and 74a4796.

📒 Files selected for processing (2)
  • packages/journeys/src/outlet.test.tsx
  • packages/journeys/src/runtime.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/journeys/src/outlet.test.tsx

Comment thread packages/journeys/src/runtime.test.ts Outdated
@mmurakaru
Copy link
Copy Markdown

@kibertoad ran into a runtime error:

  1. pnpm build
  2. pnpm --filter customer-onboarding-shell dev

Renders the app unusable:
CleanShot 2026-04-24 at 10 39 14@2x

Fix is after:

&

...
"@modular-react/core",
"@modular-react/react",
"@modular-react/journeys", <-- to add
...

kibertoad and others added 5 commits April 24, 2026 13:04
Add a lightweight handle token (`JourneyHandle`, `defineJourneyHandle`)
so modules and shells open journeys with typed `input` without importing
the journey's runtime code. `JourneyRuntime.start` gains a handle-first
overload; the string-id form still works.

- core: `JourneyHandleRef` + the handle overload on `JourneyRuntime.start`
- journeys: `handle.ts` / `handle.test.ts`, `start` impl accepts either
  form, re-exports the handle from the package index
- examples: journeys export `customerOnboardingHandle`; Home screens
  use the handle at `runtime.start(...)` call sites

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend `JourneyRegisterOptions` with shell-level hooks so analytics and
telemetry live at registration rather than in journey definitions:

- `onComplete` / `onAbort` fire sequentially after the definition hook
  (both run). Matches the existing `onTransition` layering.
- `onAbandon` overrides the definition's handler when set — only one
  can drive the transition; fallback to definition when absent.
- `onHydrate` layers on top of the definition's. Either hook counts as
  explicit migration (skips the version-mismatch check).
- `onError` observes every step-component throw and every transition
  handler throw. Observation-only; the outlet's `onStepError` policy
  still decides retry / ignore / abort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce `createTestHarness(runtime)` on `@modular-react/journeys/testing`
as the public way to drive a runtime in tests — `fireExit`, `goBack`,
`inspect`. Replaces direct use of the `__`-prefixed runtime internals by
external callers.

- `testing.ts` rewritten from stub to harness implementation.
- `simulate-journey.ts` migrated onto the harness; public `JourneySimulator`
  contract unchanged.
- `getInternals` dropped from the package's public index. The symbol still
  lives on `./runtime.js` for in-package callers (the outlet, the harness
  itself) until the in-package tests migrate; that's the next incremental
  step, not blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the hardcoded red divs in `<JourneyOutlet>` with two optional
component props. Omitting them preserves today's visuals.

- `notFoundComponent`: rendered when the current step points at an
  unregistered module/entry. Receives `{ moduleId, entry }`.
- `errorComponent`: rendered when a step component throws. Receives
  `{ moduleId, error }`, giving shells the raw error for their own
  reporting. Threaded through `StepErrorBoundary`.

Defaults extracted as `DefaultNotFound` / `DefaultError`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kibertoad and others added 2 commits April 24, 2026 14:18
Export `isTerminal(instance)` from `@modular-react/core` for the one
predicate that captures a non-trivial disjunction over `JourneyStatus`
(`completed` or `aborted`). `isActive` and `isLoading` would wrap a
single string comparison on a typed union and are intentionally not
shipped — the JSDoc says so.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-router-runtime/src/registry.ts (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Pipeline: oxfmt formatting failure reported in CI.

Please rerun the formatter and commit the result.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-runtime/src/registry.ts` at line 1, The file
packages/react-router-runtime/src/registry.ts failed the oxfmt CI check; re-run
the oxfmt formatter locally and commit the updated file so the import line
(import { createBrowserRouter, createMemoryRouter } from "react-router";) and
the rest of registry.ts are properly formatted; ensure you run the same oxfmt
command used by CI and add/commit the formatted changes before pushing.
♻️ Duplicate comments (1)
packages/journeys/src/runtime.ts (1)

1044-1048: ⚠️ Potential issue | 🟡 Minor

hydrate() leaks a half-built instance when hydrateInto throws.

instances.set(instanceId, record) happens before hydrateInto(...), which can throw JourneyHydrationError for rollbackSnapshots/history length mismatches (line 774‑777). When it throws, the error propagates to the caller, but a status: "loading" record with no listeners is left behind in the map — it surfaces in listInstances()/getInstance(id) forever, and forget(id) refuses to drop it because it's not terminal. The start() paths already handle this (sync path at 980‑988 rolls back, async path at 937‑945 via the if (record.status !== "loading") return guard + startFresh fallback), so hydrate() is the odd one out.

🛡️ Proposed fix
       const record = createRecord(reg, instanceId, null, migrated.blob.state);
       instances.set(instanceId, record);
-      hydrateInto(record, migrated.blob);
-      notify(record);
-      return instanceId;
+      try {
+        hydrateInto(record, migrated.blob);
+      } catch (err) {
+        instances.delete(instanceId);
+        throw err;
+      }
+      notify(record);
+      return instanceId;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.ts` around lines 1044 - 1048, hydrate()
currently inserts a partially-built record into the instances map before calling
hydrateInto, which can throw (e.g., JourneyHydrationError on
rollbackSnapshots/history mismatch) and leaves a non-terminal "loading" record
behind; fix by ensuring the instance is not retained on failure — either move
instances.set(instanceId, record) until after hydrateInto/notify complete or
wrap hydrateInto(...) and notify(...) in a try/catch that calls
instances.delete(instanceId) before rethrowing the error (referencing hydrate(),
hydrateInto, createRecord, instances, notify, and the
JourneyHydrationError/rollbackSnapshots failure path).
🧹 Nitpick comments (22)
examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts (1)

64-64: Consider replacing the hard wait with an event-based settle.

waitForTimeout(250) is flaky-by-design: if a runtime error surfaces at 260ms (e.g. a lazy module import resolving late) the test passes despite the regression. await page.waitForLoadState("networkidle") (or an explicit expect(...) on the first step's content) gives a deterministic flush point while still letting microtask errors propagate. Same note applies to the tanstack sibling spec.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts` at
line 64, Replace the flaky hard wait (the call to page.waitForTimeout(250)) with
an event-based settle so microtask/runtime errors reliably surface;
specifically, use an explicit load/network/DOM assertion such as awaiting
page.waitForLoadState("networkidle") or asserting the first step's content with
an expect(...) after navigation in the smoke.spec.ts test (locate the
page.waitForTimeout call) to provide a deterministic flush point instead of the
fixed 250ms pause.
.github/workflows/ci.yml (1)

124-200: Consider lifting the any_changed guard to job level to avoid per-step repetition.

Seven steps each carry if: steps.changes.outputs.any_changed == 'true'. A new step added without the guard will run on every PR, including ones that shouldn't trigger e2e at all — a silent regression of the gating logic. Two cleaner shapes:

  1. Move the change-detection to a dedicated detect-e2e job and gate examples-e2e with a single job-level if:.
  2. Keep detection inline but make it a hard exit, e.g. an early step that sets a job-level output and subsequent steps check needs.

Option (1) also avoids running tj-actions/changed-files once per matrix leg. Non-blocking, but worth doing while the blast radius is small.

Sketch of option 1
  examples-e2e-detect:
    runs-on: ubuntu-latest
    outputs:
      rr_changed: ${{ steps.rr.outputs.any_changed }}
      tsr_changed: ${{ steps.tsr.outputs.any_changed }}
    steps:
      - uses: actions/checkout@v6
      - id: rr
        uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a
        with:
          files: |
            examples/react-router/customer-onboarding-journey/**
            packages/react-router-runtime/**
            packages/react-router-core/**
            packages/journeys/**
            packages/react/**
            packages/core/**
            .github/workflows/ci.yml
          files_ignore: |
            **/*.md
            **/*.test.ts
            **/*.test.tsx
      # ...similar `tsr` step...

  examples-e2e:
    needs: [examples-e2e-detect]
    if: needs.examples-e2e-detect.outputs.rr_changed == 'true' || needs.examples-e2e-detect.outputs.tsr_changed == 'true'
    strategy:
      fail-fast: false
      matrix:
        include: [...]
    steps:
      # no per-step `if:` needed
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 124 - 200, The job currently repeats
per-step guards using steps.changes.outputs.any_changed inside the examples-e2e
job; instead create a dedicated detect job (e.g., examples-e2e-detect) that runs
tj-actions/changed-files once and exposes outputs (like rr_changed/tsr_changed),
then change the examples-e2e job to depend on that job (needs:
examples-e2e-detect) and add a single job-level if that checks the detect
outputs (e.g., if: needs.examples-e2e-detect.outputs.rr_changed == 'true' ||
needs.examples-e2e-detect.outputs.tsr_changed == 'true'), removing the per-step
if: steps.changes.outputs.any_changed == 'true' guards so steps run only when
the detection indicates changes.
examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts (1)

30-32: Extract the journey version to a single constant to prevent hydrate drift.

The literal "1.0.0" is declared at line 32 (journey version) and independently checked at line 148 (blob.version !== "1.0.0"). When the journey version is bumped, it's easy to update one and forget the other — the result is either a silent acceptance of stale blobs under a new version or a spurious hydrate failure after a coordinated bump. Since referencing customerOnboardingJourney.version inside its own definition would be circular, extract a const and use it in both places.

♻️ Proposed refactor
+const CUSTOMER_ONBOARDING_VERSION = "1.0.0";
+
 export const customerOnboardingJourney = defineJourney<OnboardingModules, OnboardingState>()({
   id: "customer-onboarding",
-  version: "1.0.0",
+  version: CUSTOMER_ONBOARDING_VERSION,
   meta: {
@@
   onHydrate: (blob) => {
-    if (blob.version !== "1.0.0") {
+    if (blob.version !== CUSTOMER_ONBOARDING_VERSION) {
       throw new Error(`Unknown customer-onboarding journey version: ${blob.version}`);
     }
     return blob;
   },
 });

Also applies to: 147-152

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts`
around lines 30 - 32, Extract the journey version string into a single constant
(e.g., const CUSTOMER_ONBOARDING_JOURNEY_VERSION = "1.0.0") and use that
constant both when constructing the journey with defineJourney (replace the
literal in customerOnboardingJourney's version) and when checking blobs (replace
the literal in the blob.version !== ... comparison); do not reference
customerOnboardingJourney.version inside its own definition to avoid a circular
reference.
packages/journeys/src/handle.test.ts (2)

67-77: Minor: as never casts in the persistence adapter.

Using as never on the return of load and on the whole persistence object bypasses type checking — if the adapter shape drifts from JourneyPersistence<TState>, this test won't notice. Annotating the adapter with the real type costs nothing and gives the test the same coverage the production code gets.

♻️ Proposed fix
-    const store = new Map<string, unknown>();
-    const persistence = {
-      keyFor: ({ input }: { input: unknown }) => `demo:${(input as Input).id}`,
-      load: (key: string) => (store.get(key) ?? null) as never,
-      save: (key: string, blob: unknown) => void store.set(key, blob),
-      remove: (key: string) => void store.delete(key),
-    };
-    const rt = createJourneyRuntime(
-      [{ definition: journey, options: { persistence: persistence as never } }],
-      { modules: { m: mod }, debug: false },
-    );
+    const store = new Map<string, SerializedJourney<{ id: string }>>();
+    const persistence: JourneyPersistence<{ id: string }> = {
+      keyFor: ({ input }) => `demo:${(input as Input).id}`,
+      load: (key) => store.get(key) ?? null,
+      save: (key, blob) => void store.set(key, blob),
+      remove: (key) => void store.delete(key),
+    };
+    const rt = createJourneyRuntime(
+      [{ definition: journey, options: { persistence } }],
+      { modules: { m: mod }, debug: false },
+    );

(Requires importing JourneyPersistence and SerializedJourney from @modular-react/core or ./types.js.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/handle.test.ts` around lines 67 - 77, The test uses
unsafe casts "as never" on the persistence adapter (properties load/save/remove
and the whole persistence object) which hides type mismatches; replace the casts
by typing the adapter with the proper interface (e.g.,
JourneyPersistence<SerializedJourney> or the correct JourneyPersistence<TState>
type) so the persistence constant and its load/save/remove signatures are
type-checked; import JourneyPersistence and SerializedJourney (or the
appropriate types) and annotate the persistence variable accordingly before
passing it into createJourneyRuntime.

84-96: Test relies on getInternals, which the PR is migrating off the public API.

Per the PR's commit summary, createTestHarness(runtime) is the new public test harness and getInternals was removed from the public API. This third test asserts an implementation detail (internals.__getRegistered("demo")) that the public surface no longer exposes, so it will become brittle if getInternals moves further behind a wall. Consider asserting behavior through rt.listDefinitions() (or the new harness) instead — the observable claim ("handle resolves the registered definition the same way a string id does") is already fully exercised by the persistence idempotency test above.

♻️ Proposed replacement
-  it("surfaces the handle's id in the step that hydrates the journey", () => {
-    // Smoke test: the runtime's internals resolve the registered definition
-    // via the same path whether we pass a string or a handle.
-    const rt = createJourneyRuntime([{ definition: journey, options: undefined }], {
-      modules: { m: mod },
-      debug: false,
-    });
-    const handle = defineJourneyHandle(journey);
-    const id = rt.start(handle, { id: "x" });
-    const internals = getInternals(rt);
-    expect(internals.__getRegistered("demo")).toBeDefined();
-    expect(rt.getInstance(id)!.journeyId).toBe("demo");
-  });
+  it("resolves to the same registered definition as the string-id form", () => {
+    const rt = createJourneyRuntime([{ definition: journey, options: undefined }], {
+      modules: { m: mod },
+      debug: false,
+    });
+    const handle = defineJourneyHandle(journey);
+    const id = rt.start(handle, { id: "x" });
+    expect(rt.listDefinitions().some((d) => d.id === "demo")).toBe(true);
+    expect(rt.getInstance(id)!.journeyId).toBe("demo");
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/handle.test.ts` around lines 84 - 96, The test
currently depends on the removed public helper getInternals; update it to assert
via the public test harness/API instead: stop calling getInternals and replace
the internal assertion with a check using
createTestHarness(createJourneyRuntime(...)) or rt.listDefinitions() and the
existing public methods (e.g., defineJourneyHandle, rt.start, rt.getInstance) to
verify that the handle resolves to the same journey id ("demo"); ensure the test
still creates the same runtime (createJourneyRuntime) and handle
(defineJourneyHandle) but uses the public listing/harness API rather than
internals.__getRegistered.
packages/journeys/src/handle.ts (1)

23-29: defineJourneyHandle returns JourneyHandle<string, TInput> rather than narrowing TId to the literal id.

As written, consumers lose the literal-type information about which journey the handle refers to — handle.id types as plain string. Threading a TId extends string generic through JourneyDefinition and defineJourney would let callers get JourneyHandle<"customer-onboarding", Input> back, enabling typed lookups / registries. Not blocking for the current example, but worth considering before the API surface is fully public.

♻️ Sketch (requires corresponding widening of `JourneyDefinition` and `defineJourney`)
 export function defineJourneyHandle<
+  TId extends string,
   TModules extends ModuleTypeMap,
   TState,
   TInput,
->(def: JourneyDefinition<TModules, TState, TInput>): JourneyHandle<string, TInput> {
-  return { id: def.id };
+>(def: JourneyDefinition<TModules, TState, TInput> & { readonly id: TId }): JourneyHandle<TId, TInput> {
+  return { id: def.id };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/handle.ts` around lines 23 - 29, defineJourneyHandle
currently widens the id to string; change its generic to carry a literal id type
(e.g. add TId extends string) and return JourneyHandle<TId, TInput> instead of
JourneyHandle<string, TInput>, and adjust the JourneyDefinition type
parameterization (and any defineJourney helper) to expose that TId so the
compiler can infer the literal type from def.id; specifically update
defineJourneyHandle signature to accept JourneyDefinition<TModules, TState,
TInput, TId> (or equivalent) and return { id: def.id } typed as
JourneyHandle<TId, TInput> so callers preserve the literal id type.
packages/core/src/journey-contracts.ts (2)

115-121: Potential naming collision: allowBack lives in the same object as exit-name keys.

EntryTransitions is built as a mapped type over ExitNamesOf<TMod> intersected with { readonly allowBack?: boolean }. If a module ever declares an exit named allowBack, the type of that key becomes boolean & ((ctx) => TransitionResult) — effectively unusable and confusing at the call site. Consider either reserving the name explicitly in documentation, renaming the metadata key (e.g. $allowBack), or nesting transitions under a dedicated key ({ transitions: {...}, allowBack?: boolean }) to keep the namespaces fully separate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/journey-contracts.ts` around lines 115 - 121,
EntryTransitions currently merges mapped exit keys (ExitNamesOf<TMod>) with a
metadata key allowBack, causing a type collision if an exit name is "allowBack";
update the type to separate metadata from transitions to avoid name clashes by
replacing EntryTransitions with a shape like { transitions: { [X in
ExitNamesOf<TMod>]?: (ctx: ExitCtx<TState, ExitOutputOf<TMod, X>,
EntryInputOf<TMod, TEntry>>) => TransitionResult<TModules, TState> },
allowBack?: boolean } so the mapped keys live under transitions and allowBack
remains a distinct optional property; update any code that consumes
EntryTransitions/EntryTransitions.* to access the nested transitions and the
allowBack flag accordingly.

259-290: Overload ordering for start looks correct, but verify handle/string disambiguation at call sites.

The handle-first overload takes JourneyHandleRef<TId, TInput> (an object); the string-id overload takes string. TS overload resolution is top-down, so a string argument will correctly skip the handle overload and bind to the second. However, if anyone calls rt.start({ id: "demo" }, input) with a plain literal that matches JourneyHandleRef structurally but was intended as a string-id path, TInput on that call site will be inferred as unknown (since the handle has no phantom marker) — silently widening the input check. Consider branding JourneyHandleRef with a unique symbol or tag to prevent accidental structural matches from erasing input typing.

♻️ Proposed brand
-export interface JourneyHandleRef<TId extends string = string, TInput = unknown> {
-  readonly id: TId;
-  readonly __input?: TInput;
-}
+declare const journeyHandleBrand: unique symbol;
+export interface JourneyHandleRef<TId extends string = string, TInput = unknown> {
+  readonly id: TId;
+  readonly __input?: TInput;
+  readonly [journeyHandleBrand]?: true;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/journey-contracts.ts` around lines 259 - 290, The overload
ambiguity comes from JourneyHandleRef being structurally compatible with plain
objects, which can cause accidental matches to the handle-first
start<TId,TInput>(handle, input) overload and widen TInput; fix this by branding
JourneyHandleRef<TId, TInput> with a unique nominal tag (e.g. a private/unique
symbol property or a readonly phantom field) so plain object literals won't
structurally match it, update the JourneyHandleRef type declaration to include
that brand, and update any handle factory/constructor functions to produce
values with the brand (and adjust places that construct handles manually) so
runtime call sites using rt.start(...) keep correct overload resolution and
input typing.
packages/journeys/src/types.ts (1)

94-101: onAbandon at registration level cannot return a type-checked next step.

JourneyRegisterOptions.onAbandon is parameterized only by TState, so TransitionResult<ModuleTypeMap, TState> is evaluated against the base ModuleTypeMap rather than the specific journey's module map. A handler that returns { next } will effectively have an untyped step spec. In practice, shell-level abandon handlers almost always return { abort } or { complete }, so this is likely fine — but worth documenting the limitation so authors don't reach for { next } here expecting typed entry inputs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/types.ts` around lines 94 - 101, The onAbandon handler
is currently typed against the global ModuleTypeMap which prevents returning a
type-checked `{ next }` for a specific journey; update the
JourneyRegisterOptions generic so it carries the journey-specific module map
(e.g., add a generic parameter like M extends ModuleTypeMap) and change the
onAbandon signature to use AbandonCtx<M, TState> => TransitionResult<M, TState>
(ensure any uses of AbandonCtx, TransitionResult, and the JourneyRegisterOptions
type are updated to propagate the same M generic) so onAbandon can return
journey-typed next steps instead of the base ModuleTypeMap.
packages/tanstack-router-runtime/src/registry.ts (2)

443-447: Manifest's journeys field is implicitly coupled to a plugin named exactly "journeys".

Both resolve() and resolveManifest() read assembly.extensions.journeys to populate manifest.journeys. If the journeys plugin is ever registered under a different name (or a user swaps it for an alternate implementation with a different name), manifest.journeys will silently be undefined even though manifest.extensions["other-name"] holds the runtime. Consider either documenting "journeys" as a reserved plugin name or exposing a well-known symbol/constant that the plugin and registry both import from core.

Also applies to: 507-511

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-runtime/src/registry.ts` around lines 443 - 447, The
code currently hardcodes the "journeys" extension key (e.g. reading
assembly.extensions.journeys in resolve() and resolveManifest() to set
manifest.journeys), which couples the feature to a literal plugin name; export a
single well-known constant/symbol (e.g. JOURNEYS_EXTENSION or a
Symbol('journeys')) from core and use that symbol when registering and when
reading extensions so both plugin implementations and the registry reference the
same identifier; update all occurrences (assembly.extensions.journeys,
manifest.journeys assignment sites, and the other spots mentioned around lines
507–511) to use that exported constant/symbol so the manifest correctly picks up
the runtime regardless of plugin.name.

246-248: plugin.validate?. runs inside buildAssembly, so resolveManifest() retries will call it again.

resolveManifest() is documented as "commit to first-call semantics but allow retry after a failed buildAssembly" (see the comments around firstCallCompleted). If a plugin's validate has observable side-effects (logging, registering, throwing non-idempotently), a first-call failure followed by a retry will double-invoke it. The onRegister hook above is guarded by onRegisterRan for exactly this reason — consider the same guard for validate, or document that validate must be a pure predicate with no side-effects.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-runtime/src/registry.ts` around lines 246 - 248, The
plugin.validate call inside buildAssembly can be invoked twice when
resolveManifest retries; add a guard similar to onRegister/onRegisterRan to
ensure validate is only run once per plugin (or once globally) on first
successful invocation: introduce a boolean like validateRan (or a per-plugin
flag) and wrap plugin.validate?.({ modules }) with if (!validateRan) {
validateRan = true; plugin.validate?.({ modules }) } so retries won't re-run
non-idempotent validate logic; reference buildAssembly, resolveManifest,
plugin.validate, firstCallCompleted, onRegister and onRegisterRan when locating
where to add the guard.
examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts (1)

26-29: The readonly never[] pattern is valid but unusually explicit.

readonly never[] satisfies the framework's SlotMapOf<T> constraint (which requires each slot to be a readonly unknown[]), and the intent is already documented in the comment. However, this pattern is unique in the codebase — all other AppSlots declarations assign concrete array types. For consistency and simplicity, consider using an empty interface instead:

export interface AppSlots {
  // Intentionally empty — this example renders exclusively via journey tabs.
}

An empty interface is equally valid, more idiomatic, and communicates the same intent without the unusual type syntax.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts`
around lines 26 - 29, The AppSlots interface uses the unusual readonly never[]
pattern; update the declaration of AppSlots (the interface named AppSlots) to be
an empty interface (remove the readonly never[] property and keep the comment)
so it remains compatible with SlotMapOf<T> while matching the project's
idiomatic style and other AppSlots declarations.
packages/journeys/src/outlet.test.tsx (1)

337-341: Tighten the retry-count assertion.

retryLimit={1} with an initial render + one retry before abort means onStepError fires exactly twice (initial throw + retry throw). The loose >= 2 assertion will mask regressions that burst retries beyond the limit (e.g. off-by-one in record.retryCount accounting). Prefer an exact count so the retry-budget invariant stays locked down.

♻️ Proposed fix
-    expect(onStepError.mock.calls.length).toBeGreaterThanOrEqual(2);
+    expect(onStepError).toHaveBeenCalledTimes(2);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet.test.tsx` around lines 337 - 341, The test
currently asserts
expect(onStepError.mock.calls.length).toBeGreaterThanOrEqual(2) which is too
loose for retryLimit={1}; change this to assert the exact number of expected
error callbacks (expect(onStepError.mock.calls.length).toBe(2)) so that the
sequence (initial throw + one retry throw -> aborted) is enforced; update the
assertion near the block that checks rt.getInstance(id)!.status === "aborted"
and after restoreError.mockRestore() to validate exactly two onStepError
invocations.
examples/tanstack-router/customer-onboarding-journey/shell/src/components/Home.tsx (1)

52-52: Use customerOnboardingHandle.id for consistency.

Line 35 keys the tab by customerOnboardingHandle.id, but Line 52 hardcodes "customer-onboarding" for the persistence lookup. If the handle's id is ever renamed, the "Resume" label will silently desync from the actual tab/persistence key.

♻️ Proposed fix
-          const resuming = hasPersistedJourney("customer-onboarding", customer.id);
+          const resuming = hasPersistedJourney(customerOnboardingHandle.id, customer.id);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/tanstack-router/customer-onboarding-journey/shell/src/components/Home.tsx`
at line 52, The persistence lookup uses a hardcoded key "customer-onboarding"
causing a desync; update the call to hasPersistedJourney to use the same dynamic
key as the tab by passing customerOnboardingHandle.id (i.e., change
hasPersistedJourney("customer-onboarding", customer.id) to
hasPersistedJourney(customerOnboardingHandle.id, customer.id)) so the resume
label and persisted state remain consistent with the tab key.
packages/journeys/src/register-options.test.ts (1)

4-4: Prefer createTestHarness over raw getInternals in tests.

The PR introduces createTestHarness as the public test harness and migrates simulateJourney off internals. This file still reaches into __getRecord, __getRegistered, and __bindStepCallbacks via getInternals, which duplicates what the harness already exposes via fireExit (and makes these tests sensitive to internal-shape changes the harness is supposed to insulate callers from).

♻️ Proposed refactor using the harness
-import { createJourneyRuntime, getInternals } from "./runtime.js";
+import { createJourneyRuntime } from "./runtime.js";
+import { createTestHarness } from "./testing.js";
@@
-function driveExit(rt: ReturnType<typeof createJourneyRuntime>, id: string, exit: string, output?: unknown) {
-  const internals = getInternals(rt);
-  const rec = internals.__getRecord(id)!;
-  const reg = internals.__getRegistered("j")!;
-  internals.__bindStepCallbacks(rec, reg).exit(exit, output);
-}
+function driveExit(rt: ReturnType<typeof createJourneyRuntime>, id: string, exit: string, output?: unknown) {
+  createTestHarness(rt).fireExit(id, exit, output);
+}

Also applies to: 57-62

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/register-options.test.ts` at line 4, Tests are reaching
into internals via getInternals to call __getRecord, __getRegistered, and
__bindStepCallbacks; replace those calls with the public createTestHarness API
(import createTestHarness from runtime.js) and use the harness methods (e.g.,
fireExit and whatever harness exposes for inspecting records/registered steps
and binding callbacks) so tests no longer depend on internal shapes—update all
usages in this file (including the block around lines 57-62) to construct a
harness and call its public helpers instead of getInternals/__* methods.
packages/journeys/README.md (1)

1327-1331: createTestHarness is missing from the testing exports table.

The PR objectives and index.ts callouts both highlight createTestHarness(runtime) as the public test harness, yet the @modular-react/journeys/testing table only lists simulateJourney / JourneySimulator. Add it so the "migrate off getInternals" guidance in index.ts has a landing spot in the docs.

Proposed addition
 | Export             | Purpose                                                                                                                                                                                       |
 | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | `simulateJourney`  | Headless simulator: fires exits / goBack, exposes `step` / `currentStep` (throws if terminal) / `state` / `history` / `status` / `transitions` / `terminalPayload` / `serialize()`, no React. |
 | `JourneySimulator` | Type for the object returned by `simulateJourney`.                                                                                                                                            |
+| `createTestHarness` | Public test harness (`fireExit`, `goBack`, `inspect`) bound to a runtime — replacement for the former `getInternals` access path.                                                              |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/README.md` around lines 1327 - 1331, Add the missing
testing export entry for createTestHarness to the
`@modular-react/journeys/testing` exports table in README.md: update the table row
list that currently documents simulateJourney and JourneySimulator to also
include a `createTestHarness` entry and a short Purpose description linking it
to the public test harness (e.g., "Public test harness for runtime-based
testing; migration target for getInternals"), ensuring the symbol name
`createTestHarness` is used exactly and that it aligns with the index.ts
callouts referencing createTestHarness(runtime).
packages/react-router-runtime/src/registry.ts (1)

389-409: Consider validating that use() isn't re-entered during extend().

use() pushes to plugins (line 397) and then calls plugin.extend(...) (line 399). If a plugin's extend were ever written to call registry.use(anotherPlugin) (legitimate composition), the duplicate-name guard would work but the nested plugin's extend would run first and its methods could be overwritten or lost depending on order. Today extend contexts only expose markDirty, so this is theoretical — but if you plan to broaden PluginExtendCtx later, adding a re-entrancy guard (or making plugins.push happen after extend) would save a future debugging session.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-runtime/src/registry.ts` around lines 389 - 409, The
use() flow can be re-entered during plugin.extend which pushes plugin into
plugins before calling plugin.extend; to prevent nested registration from
running in an inconsistent state either (A) move
seenPluginNames.add(plugin.name) and plugins.push(plugin) to after calling
plugin.extend({ markDirty: () => {} }) so the plugin is only considered
registered once its extension completes, or (B) add a reentrancy guard flag
around use() (e.g., a local inExtend boolean checked/set before calling
plugin.extend and cleared after) to reject or queue nested registry.use calls;
update checks that reference seenPluginNames, plugins, plugin.extend,
PluginExtendCtx/markDirty and registry accordingly.
packages/tanstack-router-runtime/src/types.ts (1)

140-249: Extract the onModuleExit event payload to a shared type.

The same { moduleId; entry; exit; output; tabId? } shape is inlined in ResolveManifestOptions.onModuleExit (140‑146), ResolvedManifest.onModuleExit (243‑249), and — per the plugin file — JourneysPluginOptions.onModuleExit in packages/journeys/src/plugin.tsx. Any future addition (e.g. timestamp, reason) has to be synchronized across three spots without TypeScript catching the drift. Lifting this into a single exported alias (ideally reused from @modular-react/journeys' ModuleTabExitEvent) keeps them in lockstep.

Sketch
+import type { ModuleTabExitEvent } from "@modular-react/journeys";-  onModuleExit?: (event: {
-    readonly moduleId: string;
-    readonly entry: string;
-    readonly exit: string;
-    readonly output: unknown;
-    readonly tabId?: string;
-  }) => void;
+  onModuleExit?: (event: ModuleTabExitEvent) => void;

If @modular-react/journeys is a peer/optional dep, hoist the alias to @modular-react/core instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-runtime/src/types.ts` around lines 140 - 249, Create
and export a single shared type for the module-exit payload (e.g.
ModuleExitEvent or reuse ModuleTabExitEvent) from a common place (preferably
`@modular-react/core` or the existing shared types file) and replace the inline
object types currently used in ResolveManifestOptions.onModuleExit,
ResolvedManifest.onModuleExit, and JourneysPluginOptions.onModuleExit with that
exported alias; preserve all fields (moduleId, entry, exit, output, optional
tabId) and update imports where those interfaces reference the inline shape so
all three call sites use the single shared type.
examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts (2)

147-152: onHydrate is brittle against any future version bump.

Exact-string matching against "1.0.0" means the first time you ship 1.0.1 (a no-op patch, a typo fix, anything), every persisted blob will be discarded via the runtime's discardBlob + startFresh path — customers mid-onboarding on reload will silently lose state. Even if that's the desired behaviour today, the example is what new adopters copy-paste, so it's worth teaching the right pattern up front: either whitelist known older versions with migrations, or compare by major.minor and let patches pass through.

Suggested pattern
   onHydrate: (blob) => {
-    if (blob.version !== "1.0.0") {
-      throw new Error(`Unknown customer-onboarding journey version: ${blob.version}`);
-    }
-    return blob;
+    const [major, minor] = blob.version.split(".");
+    if (major !== "1" || minor !== "0") {
+      throw new Error(`Unsupported customer-onboarding journey version: ${blob.version}`);
+    }
+    return blob;
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts`
around lines 147 - 152, The onHydrate handler currently rejects any blob whose
blob.version !== "1.0.0", which will discard persisted state on patch-level
version bumps; update onHydrate (the function named onHydrate that reads
blob.version) to accept compatible patch versions instead of exact-string
matching — e.g., parse blob.version and compare only major and minor (or
implement a whitelist/migrations table keyed by version) and return the blob or
run migrations for supported versions rather than throwing, ensuring you still
throw for truly unknown/unsupported major versions.

143-145: onAbandon embeds the entire journey state in the abort payload.

state includes customerId (and any future PII you stuff into OnboardingState). Since the abort payload is serialized into terminalPayload and typically flows through analytics / onAbort hooks / persisted terminal blobs, this can unintentionally expand the PII surface for every abandoned journey. Consider scoping what's captured:

-  onAbandon: ({ step, state }) => ({
-    abort: { reason: "abandoned", at: step?.moduleId, state },
-  }),
+  onAbandon: ({ step, state }) => ({
+    abort: {
+      reason: "abandoned",
+      at: step?.moduleId,
+      customerId: state.customerId,     // whatever you actually need downstream
+      selectedPlanId: state.selectedPlan?.id ?? null,
+    },
+  }),

Same caveat applies to the React Router copy of this example — worth aligning both.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts`
around lines 143 - 145, The current onAbandon handler embeds the full journey
state (including customerId/PII) into the abort payload; update the onAbandon
implementation so abort contains only a minimal, non-PII subset (e.g., reason
and at: step?.moduleId or other non-identifying metadata) or a sanitized summary
produced by a helper like buildSafeAbortPayload(state), ensuring you do not
include customerId or any PII from OnboardingState in the terminalPayload/abort
object.
packages/react-router-runtime/src/types.ts (1)

141-147: Consider extracting the onModuleExit event shape to a named type.

The event payload { moduleId, entry, exit, output, tabId? } is declared inline both here and at Lines 245-251, and the plugin wiring in packages/journeys/src/plugin.tsx (JourneyProvider / onModuleExit) has to match it by hand. A shared ModuleExitEvent type would keep the three sites from drifting.

Also applies to: 245-251

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-runtime/src/types.ts` around lines 141 - 147, Extract
the inline event payload into a named exported type (e.g. ModuleExitEvent) and
use it for the onModuleExit callback signature instead of the inline object;
specifically, declare export type ModuleExitEvent = { readonly moduleId: string;
readonly entry: string; readonly exit: string; readonly output: unknown;
readonly tabId?: string } in the same module as the current inline declaration,
update the onModuleExit?: (event: ModuleExitEvent) => void signature where it is
currently inline, and update the places that manually recreate the shape
(including the JourneyProvider / onModuleExit wiring) to import and reference
ModuleExitEvent so all three sites share the same type.
packages/journeys/src/runtime.test.ts (1)

458-468: Comment says "three transitions" but the test only drives two saves; strengthen the post-release assertion.

The inline comment describes a "three transitions rapid-fire" sequence, but the body only fires start (implicit) + cancelled — two saves, not three. More importantly, after resolveFirst() the test drains the microtask queue but never re-asserts saves.length, so it doesn't actually prove the coalesced terminal save eventually flushed (it only proves the second save was queued and not started while the first was paused). Consider pinning saves.length === 2 after the drain to lock in the coalesce-then-flush behavior.

♻️ Proposed tweak
-    // Fire three transitions rapid-fire — cancelled completes. Sequence:
-    // start (save queued), cancelled (save queued & coalesces on top of the
+    // Sequence: start (save queued), cancelled (save queued & coalesces on top of the
     // first in-flight save). The runtime must have at most one save in
     // flight at a time.
     internals.__bindStepCallbacks(internals.__getRecord(id)!, reg).exit("cancelled");
     // First save is paused; the terminal save must have coalesced.
     expect(saves.length).toBe(1);
     resolveFirst();
     await Promise.resolve();
     await Promise.resolve();
+    // The coalesced terminal save flushes once the first one settles.
+    expect(saves.length).toBe(2);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.test.ts` around lines 458 - 468, The test's
comment and assertions are inconsistent: it claims "three transitions
rapid-fire" but only triggers start + cancelled via
internals.__bindStepCallbacks(internals.__getRecord(id)!,
reg).exit("cancelled"), and after resolveFirst() the test never re-checks saves
to confirm the terminal save flushed. Fix by either actually firing the third
transition (call exit("...") a third time on the same record/registration) or,
more simply, update the assertions after resolveFirst() and the microtask drains
to assert saves.length === 2 (referencing saves and resolveFirst) and update the
inline comment to match the two-transition scenario so the test proves the
coalesced terminal save eventually flushed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad43eda8-f428-457e-a4ad-cd6d79eb20db

📥 Commits

Reviewing files that changed from the base of the PR and between 74a4796 and 0f3068b.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (47)
  • .github/workflows/ci.yml
  • .gitignore
  • examples/react-router/customer-onboarding-journey/README.md
  • examples/react-router/customer-onboarding-journey/app-shared/src/index.ts
  • examples/react-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts
  • examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts
  • examples/react-router/customer-onboarding-journey/shell/package.json
  • examples/react-router/customer-onboarding-journey/shell/playwright.config.ts
  • examples/react-router/customer-onboarding-journey/shell/src/components/Home.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/main.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts
  • examples/tanstack-router/customer-onboarding-journey/README.md
  • examples/tanstack-router/customer-onboarding-journey/app-shared/src/index.ts
  • examples/tanstack-router/customer-onboarding-journey/journeys/customer-onboarding/src/index.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/package.json
  • examples/tanstack-router/customer-onboarding-journey/shell/playwright.config.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/src/components/Home.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/components/Shell.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/main.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts
  • packages/core/src/index.ts
  • packages/core/src/journey-contracts.ts
  • packages/core/src/plugin.ts
  • packages/journeys/README.md
  • packages/journeys/src/handle.test.ts
  • packages/journeys/src/handle.ts
  • packages/journeys/src/index.ts
  • packages/journeys/src/outlet.test.tsx
  • packages/journeys/src/outlet.tsx
  • packages/journeys/src/plugin.tsx
  • packages/journeys/src/register-options.test.ts
  • packages/journeys/src/runtime.test.ts
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/simulate-journey.test.ts
  • packages/journeys/src/simulate-journey.ts
  • packages/journeys/src/testing.test.ts
  • packages/journeys/src/testing.ts
  • packages/journeys/src/types.ts
  • packages/react-router-runtime/src/registry-journeys.test.ts
  • packages/react-router-runtime/src/registry.ts
  • packages/react-router-runtime/src/types.ts
  • packages/react-router-runtime/vite.config.ts
  • packages/tanstack-router-runtime/src/registry-journeys.test.ts
  • packages/tanstack-router-runtime/src/registry.ts
  • packages/tanstack-router-runtime/src/types.ts
  • packages/tanstack-router-runtime/vite.config.ts
✅ Files skipped from review due to trivial changes (7)
  • .gitignore
  • packages/react-router-runtime/vite.config.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/package.json
  • examples/react-router/customer-onboarding-journey/shell/package.json
  • examples/tanstack-router/customer-onboarding-journey/shell/playwright.config.ts
  • examples/tanstack-router/customer-onboarding-journey/README.md
  • examples/react-router/customer-onboarding-journey/app-shared/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • examples/tanstack-router/customer-onboarding-journey/shell/src/components/Shell.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/main.tsx
  • packages/journeys/src/simulate-journey.test.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts
  • examples/react-router/customer-onboarding-journey/shell/src/components/Home.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/main.tsx
  • packages/core/src/index.ts

Comment thread examples/react-router/customer-onboarding-journey/README.md Outdated
Comment on lines +20 to +26
page.on("console", (msg: ConsoleMessage) => {
if (msg.type() !== "error") return;
const text = msg.text();
// React's dev build yells loudly about DOM hydration warnings in StrictMode;
// keep only real failures. Extend if the example adds known-noisy logs.
consoleErrors.push(text);
});
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Comment doesn't match the code — nothing is actually filtered.

The "keep only real failures" / "Extend if the example adds known-noisy logs" wording implies a denylist that isn't there. Either drop the comment or push the filtering into the handler so future noise has an obvious hook.

Proposed fix
   page.on("console", (msg: ConsoleMessage) => {
     if (msg.type() !== "error") return;
-    const text = msg.text();
-    // React's dev build yells loudly about DOM hydration warnings in StrictMode;
-    // keep only real failures. Extend if the example adds known-noisy logs.
-    consoleErrors.push(text);
+    consoleErrors.push(msg.text());
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
page.on("console", (msg: ConsoleMessage) => {
if (msg.type() !== "error") return;
const text = msg.text();
// React's dev build yells loudly about DOM hydration warnings in StrictMode;
// keep only real failures. Extend if the example adds known-noisy logs.
consoleErrors.push(text);
});
page.on("console", (msg: ConsoleMessage) => {
if (msg.type() !== "error") return;
consoleErrors.push(msg.text());
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts`
around lines 20 - 26, The comment claims noisy console messages are filtered but
the handler for page.on("console", (msg: ConsoleMessage) => { ... }) currently
pushes every error into consoleErrors; update the handler to actually filter
known-noisy messages (or remove the misleading comment). For a fix, implement a
denylist predicate inside the page.on callback (e.g., check msg.text() for
hydration/StrictMode patterns like /hydrate|Warning.*Did not expect/ or any
example-specific noisy strings) and only push to consoleErrors when the message
does not match the denylist; reference the page.on("console", (msg:
ConsoleMessage) => { ... }), the text variable, and the consoleErrors array when
making the change.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed - the misleading comment was removed; the handler now just pushes every console.error without pretending to filter.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@kibertoad have exceeded the limit for the number of chat messages per hour. Please wait 57 minutes and 13 seconds before sending another message.

Comment thread examples/react-router/customer-onboarding-journey/shell/playwright.config.ts Outdated
@@ -0,0 +1,63 @@
import type { StoreApi } from "zustand/vanilla";
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix oxfmt formatting violation flagged by CI.

The CI pipeline reports an oxfmt formatting issue on this line. Please run the repo's formatter (e.g. pnpm format / oxfmt) to normalize the imports block before merge — the likely culprit is the ordering of the zustand/vanilla type import relative to the @example-onboarding/app-shared import group.

🧰 Tools
🪛 GitHub Actions: CI

[error] 1-1: Formatting issue detected by oxfmt in this file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/react-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts`
at line 1, The import ordering in workspace-actions.ts is violating oxfmt rules
due to the type import "StoreApi" from "zustand/vanilla" being out of the
expected group order; reorder the imports so external/shared package groups
(e.g. `@example-onboarding/app-shared`) come in the correct group before or after
the zustand/vanilla type import per repo convention, then run the project
formatter (pnpm format or oxfmt) to normalize the file and commit the formatted
change; locate the import for StoreApi in workspace-actions.ts to adjust
ordering and reformat.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed - file now passes oxfmt (type imports grouped correctly).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@kibertoad have exceeded the limit for the number of chat messages per hour. Please wait 57 minutes and 13 seconds before sending another message.

Comment thread packages/journeys/src/simulate-journey.ts
Comment thread packages/journeys/src/testing.ts
Comment thread packages/react-router-runtime/src/registry.ts
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { defineEntry, defineExit, defineModule, schema } from "@modular-react/core";
import { defineJourney, journeysPlugin } from "@modular-react/journeys";
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect vite.config.ts in both runtime packages and confirm whether
# "@modular-react/journeys" is declared alongside the other `@modular-react/`* deps.
fd -H -t f 'vite.config.ts' packages/tanstack-router-runtime packages/react-router-runtime --exec sh -c 'echo "=== $0 ==="; cat "$0"'
# Also confirm package.json dependencies for each runtime package.
fd -H -t f 'package.json' packages/tanstack-router-runtime packages/react-router-runtime --exec sh -c 'echo "=== $0 ==="; jq "{dependencies, peerDependencies, devDependencies}" "$0"'

Repository: kibertoad/modular-react

Length of output: 3101


Test cannot run — @modular-react/journeys is not resolvable from this package.

CI fails with Failed to resolve import "@modular-react/journeys". The vite.config.ts files are already correct (both packages include @modular-react/journeys in rollupOptions.external), but the actual issue is that @modular-react/journeys is missing from package.json in both packages/tanstack-router-runtime and packages/react-router-runtime. Add it to devDependencies (or peerDependencies) in both package.json files so Vitest can resolve the import at test time.

🧰 Tools
🪛 GitHub Actions: CI

[error] 3-3: Vitest failed to resolve import "@modular-react/journeys" from "src/registry-journeys.test.ts" (vite:import-analysis). Error: Failed to resolve import "@modular-react/journeys" from "src/registry-journeys.test.ts". Does the file exist?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tanstack-router-runtime/src/registry-journeys.test.ts` at line 3,
The test imports defineJourney and journeysPlugin from "@modular-react/journeys"
but that module is not declared in the package manifests, so Vitest can't
resolve it; add "@modular-react/journeys" to the package.json deps for the
tanstack-router-runtime and react-router-runtime packages (prefer
devDependencies or peerDependencies per repo policy) so the import used by
registry-journeys.test.ts (defineJourney, journeysPlugin) can be resolved at
test time, then reinstall node modules and re-run the tests.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed - @modular-react/journeys is in devDependencies (workspace:*) in both packages/react-router-runtime/package.json and packages/tanstack-router-runtime/package.json.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@kibertoad have exceeded the limit for the number of chat messages per hour. Please wait 57 minutes and 11 seconds before sending another message.

Comment thread packages/tanstack-router-runtime/src/registry.ts
@kibertoad
Copy link
Copy Markdown
Owner Author

@mmurakaru thank you, fixed and added e2e test to capture such issues in the future

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-router-runtime/src/registry.ts (1)

495-519: ⚠️ Potential issue | 🟡 Minor

Minor: plugin onResolve is re-invoked on every retry, leaking the previous runtime.

If the first resolveManifest() call throws inside buildAssembly (e.g., a plugin validate hook failure on a later plugin after earlier plugins already ran onResolve in the previous iteration, or a retry after a transient authoring error), cachedManifest stays null, firstCallCompleted is true, and the next call falls through to buildAssembly again. That re-runs plugin.onResolve and constructs a fresh JourneyRuntime; any listeners/timers installed by the earlier runtime (if a plugin eagerly sets anything up on construction) are silently orphaned.

For the journeys plugin today this is benign because the runtime is passive until start() is called, but other future plugins may not be. Worth either memoizing the extensions map across retries or documenting in RegistryPlugin.onResolve that it may be invoked more than once per registry on recovery paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-runtime/src/registry.ts` around lines 495 - 519, The
buildAssembly/plugins onResolve path can run multiple times on retries and leak
runtimes; to fix, memoize the produced extensions/runtime across the first
attempt and reuse them on subsequent retries: introduce a capturedExtensions (or
capturedRuntime) variable alongside capturedOptions/firstCallCompleted and, when
buildAssembly() is first invoked, store its extensions/runtime result there
before any later steps that may throw; on subsequent calls (when
firstCallCompleted is true and cachedManifest is still null) reuse
capturedExtensions instead of re-invoking plugin.onResolve or reconstructing a
new JourneyRuntime; update resolveManifest() to consult capturedExtensions
before calling buildAssembly() and keep existing behavior for cachedManifest and
capturedOptions.
🧹 Nitpick comments (7)
examples/tanstack-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts (1)

49-53: Replace the fixed waitForTimeout(250) with a deterministic wait.

Hard waits are flaky and discouraged by Playwright best practices; they can mask late-arriving errors on slow CI and add needless latency on fast runs. Since the intent is to let <JourneyOutlet> finish mounting so any delayed pageerror/console.error surfaces, prefer waiting on a concrete post-mount signal (e.g., the first step heading already asserted in the later test, or page.waitForLoadState("networkidle")).

♻️ Proposed refactor
   await expect(page.getByRole("button", { name: /^Onboard · Alice Martin/ })).toBeVisible();
-  await page.waitForTimeout(250);
+  await expect(page.getByRole("heading", { name: /Profile · /i })).toBeVisible();
 
   assertNoErrors(errors);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/tanstack-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts`
around lines 49 - 53, The hard-coded waitForTimeout(250) should be replaced with
a deterministic wait: remove the waitForTimeout call and instead wait for a
concrete post-mount signal (for example use page.waitForLoadState("networkidle")
or wait for the JourneyOutlet's first-step heading / the same element asserted
later) so any delayed pageerror/console.error surfaces before
assertNoErrors(errors) runs; update the test around the existing
getByRole("button", { name: /^Onboard · Alice Martin/ }) and
assertNoErrors(errors) to wait on that reliable signal (e.g.,
page.waitForLoadState or the first step heading selector) rather than a fixed
timeout.
examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts (1)

64-64: Prefer a deterministic wait over waitForTimeout(250).

Fixed sleeps are flaky on slow CI — 250 ms may be too short under load, and too long locally. Since the intent is "let queued errors flush," an explicit wait on a post-mount signal (e.g., the step content you already assert, or page.waitForLoadState('networkidle')) gives the same guarantee without the magic number.

♻️ Possible replacement
-  await expect(page.getByRole("button", { name: /^Onboard · Alice Martin/ })).toBeVisible();
-  await page.waitForTimeout(250); // let microtask-queued errors flush
+  await expect(page.getByRole("button", { name: /^Onboard · Alice Martin/ })).toBeVisible();
+  // Any error thrown during mount surfaces synchronously with the outlet's
+  // first paint; once the tab button is visible the outlet has rendered.
+  await page.waitForLoadState("networkidle");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts` at
line 64, Replace the brittle fixed sleep call page.waitForTimeout(250) with a
deterministic wait that waits for the post-mount signal you already assert
(e.g., wait for the step content selector) or use
page.waitForLoadState('networkidle'); update the test around the same location
where page.waitForTimeout(250) is used in smoke.spec.ts so the test explicitly
waits for the element or load state that indicates the app has finished mounting
instead of sleeping for 250ms.
packages/core/src/journey-contracts.ts (1)

115-121: Minor: allowBack key could collide with an exit literally named "allowBack".

EntryTransitions intersects a mapped type over ExitNamesOf<TMod> with { readonly allowBack?: boolean }. A module that (perversely) defines an exit called allowBack would end up with two differently-typed fields at the same key, which TS collapses to never. Not worth guarding in code, but worth a sentence in the JSDoc — "allowBack is reserved on the transition map and must not be a declared exit name" — so a future author gets a docs hit rather than a confusing never in tooltips.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/journey-contracts.ts` around lines 115 - 121, Add a short
JSDoc note on the EntryTransitions type explaining that the key "allowBack" is
reserved and must not be used as an exit name; specifically, update the JSDoc
for the EntryTransitions<TModules, TState, TMod, TEntry> type to say that
allowBack is a reserved key on the transition map and declaring an exit named
"allowBack" will collide with this field and produce confusing types. This
references the EntryTransitions type and the allowBack property so future
authors see the reservation in tooltips/docs.
packages/journeys/src/runtime.ts (2)

81-82: Dead code: ASYNC_LOAD_PENDING symbol is declared but never returned.

probeLoad's return type unions in AsyncLoadPending, but the function body (lines 810-822) only ever returns null, a resolved blob, or a Promise. The sentinel is never produced or consumed anywhere. Drop both the symbol and the type — probeLoad's return simplifies to SerializedJourney<unknown> | null | Promise<SerializedJourney<unknown> | null>.

🧹 Proposed cleanup
-const ASYNC_LOAD_PENDING = Symbol("asyncLoadPending");
-type AsyncLoadPending = typeof ASYNC_LOAD_PENDING;
-
 /**
   function probeLoad(
     reg: RegisteredJourney,
     persistence: JourneyPersistence<unknown>,
     key: string,
   ):
     | SerializedJourney<unknown>
     | null
-    | AsyncLoadPending
     | Promise<SerializedJourney<unknown> | null> {

Also applies to: 800-823

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.ts` around lines 81 - 82, Remove the unused
sentinel by deleting the ASYNC_LOAD_PENDING symbol and the AsyncLoadPending
type, and update probeLoad's declared return type to SerializedJourney<unknown>
| null | Promise<SerializedJourney<unknown> | null>; then simplify any internal
logic or type annotations in probeLoad to stop producing/handling
ASYNC_LOAD_PENDING (search for ASYNC_LOAD_PENDING, AsyncLoadPending, and
probeLoad to find all occurrences) so the function only returns null, a
SerializedJourney, or a Promise thereof.

429-526: Edge case: transition handler returning a shape without next/complete/abort silently no-ops.

If a handler (via as any escape or JS caller) returns {} or { state: … } only, lines 441-443 may mutate state but none of the three in result branches fires — yet lines 519-525 still call schedulePersist/removePersisted and notify. The instance stays on the same step with no history entry, listeners get woken up, and persistence captures the new state.

The TS type precludes this in well-typed code, but a defensive warning in dev mode (symmetric to the "returned a Promise" guard at lines 590-603) would surface accidental { state }-only returns during refactors rather than leaving them as invisible state mutations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.ts` around lines 429 - 526, applyTransition
currently allows a transition handler to return an object that mutates state but
has no "next", "complete", or "abort" keys (e.g. {} or {state:...}) which
silently no-ops; add a dev-only warning after the existing branch handling that
checks if none of "next"/"complete"/"abort" are in result and, when debug is
true, console.warn with context (include previousStep?.moduleId and
previousStep?.entry and any identifying info from reg) describing that the
transition returned an unexpected shape and no navigation occurred; keep
behavior unchanged otherwise (just warn) so callers can spot accidental returns
similar to the existing Promise-return guard.
packages/journeys/README.md (1)

1336-1342: JourneyRegisterOptions documented shape is incomplete.

The runtime in packages/journeys/src/runtime.ts reads reg.options?.onAbandon, onComplete, onAbort, onError, and onHydrate in addition to the three listed here (onTransition, persistence, maxHistory). The README's surrounding prose in "Observation hooks" actually describes most of these, but the formal interface block undersells the option surface — a reader copy/pasting it as "the" type will miss half the registration-level hooks that the rest of the document references.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/README.md` around lines 1336 - 1342, The documented
JourneyRegisterOptions interface is missing several optional hook properties
that the runtime reads (reg.options?.onAbandon, onComplete, onAbort, onError,
onHydrate); update the README's JourneyRegisterOptions declaration to include
these optional callbacks (preserving the existing generic TState and the
existing onTransition, persistence, maxHistory) so the documented type matches
the runtime behavior and the "Observation hooks" prose.
packages/journeys/src/validation.ts (1)

107-115: Nit: inconsistent handling of missing def.id in issue messages.

Line 109 uses def.id ?? "(unknown)" but lines 111, 113, and 115 interpolate def.id directly, producing journey "undefined" ... messages when id is missing. Minor cosmetic inconsistency.

✂️ Proposed tweak
-  if (typeof def.initialState !== "function")
-    issues.push(`journey "${def.id}" must declare initialState as a function`);
-  if (typeof def.start !== "function")
-    issues.push(`journey "${def.id}" must declare start as a function`);
-  if (!def.transitions || typeof def.transitions !== "object")
-    issues.push(`journey "${def.id}" must declare transitions`);
+  const idLabel = def.id ?? "(unknown)";
+  if (typeof def.initialState !== "function")
+    issues.push(`journey "${idLabel}" must declare initialState as a function`);
+  if (typeof def.start !== "function")
+    issues.push(`journey "${idLabel}" must declare start as a function`);
+  if (!def.transitions || typeof def.transitions !== "object")
+    issues.push(`journey "${idLabel}" must declare transitions`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/validation.ts` around lines 107 - 115, The issue is
inconsistent handling of a missing def.id in validation messages causing
"journey \"undefined\"" to appear; fix by normalizing the id used in
messages—compute a local identifier like const jid = def.id ?? "(unknown)" (or
use def.id ?? "(unknown)" inline) and replace occurrences in the initialState,
start, and transitions error pushes so all messages use jid instead of def.id
directly; update the checks around typeof def.initialState, typeof def.start,
and the def.transitions check to use this normalized jid when constructing the
strings pushed to issues.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/journeys/src/runtime.ts`:
- Around line 1042-1046: hydrate() currently sets instances.set(instanceId,
record) before calling hydrateInto(record, migrated.blob), which leaks the
half-built record if hydrateInto throws; wrap the hydrateInto call in a
try/catch and on error remove the placeholder exactly as the sync-start path
does: instances.delete(instanceId), remove any keyIndex entry that was added for
this instance (e.g., keyIndex.delete(record.key || instanceKey)), and
delete/cleanup the migrated.blob entry that was stored for this id (mirror the
blob cleanup used in the sync-start error path), then rethrow the original error
so callers still receive the JourneyHydrationError. Ensure notify(record) and
the return only happen after successful hydration.

In `@packages/journeys/src/validation.ts`:
- Around line 62-95: The transitions traversal in
validateJourneyContracts/validateJourneyDefinition assumes nested objects and
will throw on null/non-object entries; add defensive guards before iterating:
when reading transitions, check each perModule is an object (non-null) and push
an issue like `journey "${def.id}" has malformed transitions for "${moduleId}"`
if not, skip iterating; likewise check each perEntry is an object before calling
Object.keys or accessing perEntry.allowBack and push a similar issue for
`moduleId.entryName`; update the loops around Object.entries(perModule),
Object.keys(perEntry), and perEntry.allowBack checks (referencing transitions,
perModule, perEntry, entry.allowBack) to skip/record malformed entries instead
of letting a TypeError propagate so all issues accumulate.

In `@packages/react-router-runtime/src/registry.ts`:
- Around line 475-481: The current assertions cast the manifest's extensions to
Record<string, unknown> causing the conditional type for journeys to resolve to
unknown; update the two incorrect casts so that where you currently assert
ApplicationManifest<..., Record<string, unknown>> and ResolvedManifest<...,
Record<string, unknown>> you instead use the actual plugin runtimes type
(PluginRuntimesOf<TPlugins> or the corresponding generic
TExtensions/PluginRuntimes type parameter) so that the conditional type for
journeys evaluates correctly to the inferred type or never; specifically change
the assertions that build the returned object (the properties moduleDescriptors,
extensions, journeys) inside resolve() and resolveManifest() to use
PluginRuntimesOf<TPlugins> (or the generic runtime type) for the extensions
generic to restore proper compile-time guarding when the journeys plugin is
absent.

---

Outside diff comments:
In `@packages/react-router-runtime/src/registry.ts`:
- Around line 495-519: The buildAssembly/plugins onResolve path can run multiple
times on retries and leak runtimes; to fix, memoize the produced
extensions/runtime across the first attempt and reuse them on subsequent
retries: introduce a capturedExtensions (or capturedRuntime) variable alongside
capturedOptions/firstCallCompleted and, when buildAssembly() is first invoked,
store its extensions/runtime result there before any later steps that may throw;
on subsequent calls (when firstCallCompleted is true and cachedManifest is still
null) reuse capturedExtensions instead of re-invoking plugin.onResolve or
reconstructing a new JourneyRuntime; update resolveManifest() to consult
capturedExtensions before calling buildAssembly() and keep existing behavior for
cachedManifest and capturedOptions.

---

Nitpick comments:
In `@examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts`:
- Line 64: Replace the brittle fixed sleep call page.waitForTimeout(250) with a
deterministic wait that waits for the post-mount signal you already assert
(e.g., wait for the step content selector) or use
page.waitForLoadState('networkidle'); update the test around the same location
where page.waitForTimeout(250) is used in smoke.spec.ts so the test explicitly
waits for the element or load state that indicates the app has finished mounting
instead of sleeping for 250ms.

In
`@examples/tanstack-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts`:
- Around line 49-53: The hard-coded waitForTimeout(250) should be replaced with
a deterministic wait: remove the waitForTimeout call and instead wait for a
concrete post-mount signal (for example use page.waitForLoadState("networkidle")
or wait for the JourneyOutlet's first-step heading / the same element asserted
later) so any delayed pageerror/console.error surfaces before
assertNoErrors(errors) runs; update the test around the existing
getByRole("button", { name: /^Onboard · Alice Martin/ }) and
assertNoErrors(errors) to wait on that reliable signal (e.g.,
page.waitForLoadState or the first step heading selector) rather than a fixed
timeout.

In `@packages/core/src/journey-contracts.ts`:
- Around line 115-121: Add a short JSDoc note on the EntryTransitions type
explaining that the key "allowBack" is reserved and must not be used as an exit
name; specifically, update the JSDoc for the EntryTransitions<TModules, TState,
TMod, TEntry> type to say that allowBack is a reserved key on the transition map
and declaring an exit named "allowBack" will collide with this field and produce
confusing types. This references the EntryTransitions type and the allowBack
property so future authors see the reservation in tooltips/docs.

In `@packages/journeys/README.md`:
- Around line 1336-1342: The documented JourneyRegisterOptions interface is
missing several optional hook properties that the runtime reads
(reg.options?.onAbandon, onComplete, onAbort, onError, onHydrate); update the
README's JourneyRegisterOptions declaration to include these optional callbacks
(preserving the existing generic TState and the existing onTransition,
persistence, maxHistory) so the documented type matches the runtime behavior and
the "Observation hooks" prose.

In `@packages/journeys/src/runtime.ts`:
- Around line 81-82: Remove the unused sentinel by deleting the
ASYNC_LOAD_PENDING symbol and the AsyncLoadPending type, and update probeLoad's
declared return type to SerializedJourney<unknown> | null |
Promise<SerializedJourney<unknown> | null>; then simplify any internal logic or
type annotations in probeLoad to stop producing/handling ASYNC_LOAD_PENDING
(search for ASYNC_LOAD_PENDING, AsyncLoadPending, and probeLoad to find all
occurrences) so the function only returns null, a SerializedJourney, or a
Promise thereof.
- Around line 429-526: applyTransition currently allows a transition handler to
return an object that mutates state but has no "next", "complete", or "abort"
keys (e.g. {} or {state:...}) which silently no-ops; add a dev-only warning
after the existing branch handling that checks if none of
"next"/"complete"/"abort" are in result and, when debug is true, console.warn
with context (include previousStep?.moduleId and previousStep?.entry and any
identifying info from reg) describing that the transition returned an unexpected
shape and no navigation occurred; keep behavior unchanged otherwise (just warn)
so callers can spot accidental returns similar to the existing Promise-return
guard.

In `@packages/journeys/src/validation.ts`:
- Around line 107-115: The issue is inconsistent handling of a missing def.id in
validation messages causing "journey \"undefined\"" to appear; fix by
normalizing the id used in messages—compute a local identifier like const jid =
def.id ?? "(unknown)" (or use def.id ?? "(unknown)" inline) and replace
occurrences in the initialState, start, and transitions error pushes so all
messages use jid instead of def.id directly; update the checks around typeof
def.initialState, typeof def.start, and the def.transitions check to use this
normalized jid when constructing the strings pushed to issues.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2311000b-2d9b-4946-bf77-c6f2d9a94ac1

📥 Commits

Reviewing files that changed from the base of the PR and between 0f3068b and a665d03.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (25)
  • examples/react-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts
  • examples/react-router/customer-onboarding-journey/shell/src/components/Home.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/main.tsx
  • examples/react-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/e2e/smoke.spec.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/src/components/Home.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/main.tsx
  • examples/tanstack-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts
  • packages/core/src/journey-contracts.ts
  • packages/journeys/README.md
  • packages/journeys/src/handle.test-d.ts
  • packages/journeys/src/handle.ts
  • packages/journeys/src/index.ts
  • packages/journeys/src/outlet.test.tsx
  • packages/journeys/src/plugin.tsx
  • packages/journeys/src/register-options.test.ts
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/testing.test.ts
  • packages/journeys/src/testing.ts
  • packages/journeys/src/types.ts
  • packages/journeys/src/validation.ts
  • packages/react-router-runtime/package.json
  • packages/react-router-runtime/src/registry.ts
  • packages/tanstack-router-runtime/package.json
  • packages/tanstack-router-runtime/src/registry.ts
✅ Files skipped from review due to trivial changes (5)
  • packages/react-router-runtime/package.json
  • packages/tanstack-router-runtime/package.json
  • packages/journeys/src/handle.ts
  • examples/tanstack-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts
  • packages/journeys/src/types.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • examples/tanstack-router/customer-onboarding-journey/shell/src/components/Home.tsx
  • packages/journeys/src/register-options.test.ts
  • examples/react-router/customer-onboarding-journey/shell/src/main.tsx
  • packages/journeys/src/outlet.test.tsx
  • packages/journeys/src/testing.ts
  • examples/react-router/customer-onboarding-journey/shell/src/services/workspace-actions.ts

Comment on lines +1042 to +1046
const record = createRecord(reg, instanceId, null, migrated.blob.state);
instances.set(instanceId, record);
hydrateInto(record, migrated.blob);
notify(record);
return instanceId;
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

hydrate() leaks a half-built record if hydrateInto throws.

instances.set(instanceId, record) happens before hydrateInto(record, migrated.blob). If hydrateInto throws (e.g., the rollbackSnapshots/history length mismatch check at lines 772-776), the caller gets the JourneyHydrationError but the just-createRecord-ed placeholder (status "loading", init state, no history) is already parked in instances. Subsequent getInstance(id) returns this partial record, hydrate() with the same id throws the "already in memory" guard, and forget(id) is a no-op (status ≠ terminal) — the record is effectively leaked until the caller knows to call end(id) first.

The sync-start path at lines 971-986 already handles this with a try/catch that cleans up instances/keyIndex/blob. Mirror that here.

🛡️ Proposed fix
       const record = createRecord(reg, instanceId, null, migrated.blob.state);
       instances.set(instanceId, record);
-      hydrateInto(record, migrated.blob);
-      notify(record);
-      return instanceId;
+      try {
+        hydrateInto(record, migrated.blob);
+      } catch (err) {
+        instances.delete(instanceId);
+        throw err;
+      }
+      notify(record);
+      return instanceId;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const record = createRecord(reg, instanceId, null, migrated.blob.state);
instances.set(instanceId, record);
hydrateInto(record, migrated.blob);
notify(record);
return instanceId;
const record = createRecord(reg, instanceId, null, migrated.blob.state);
instances.set(instanceId, record);
try {
hydrateInto(record, migrated.blob);
} catch (err) {
instances.delete(instanceId);
throw err;
}
notify(record);
return instanceId;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/runtime.ts` around lines 1042 - 1046, hydrate()
currently sets instances.set(instanceId, record) before calling
hydrateInto(record, migrated.blob), which leaks the half-built record if
hydrateInto throws; wrap the hydrateInto call in a try/catch and on error remove
the placeholder exactly as the sync-start path does:
instances.delete(instanceId), remove any keyIndex entry that was added for this
instance (e.g., keyIndex.delete(record.key || instanceKey)), and delete/cleanup
the migrated.blob entry that was stored for this id (mirror the blob cleanup
used in the sync-start error path), then rethrow the original error so callers
still receive the JourneyHydrationError. Ensure notify(record) and the return
only happen after successful hydration.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed - runtime.ts:1044 now wraps hydrateInto in try/catch and calls instances.delete(instanceId) on failure before rethrowing, mirroring the sync-start cleanup path. keyIndex / blob don't need cleanup here because explicit hydrate() is intentionally persistence-unlinked (no key is set at createRecord).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@kibertoad have exceeded the limit for the number of chat messages per hour. Please wait 57 minutes and 10 seconds before sending another message.

Comment thread packages/journeys/src/validation.ts Outdated
Comment on lines +475 to +481
moduleDescriptors: assembly.moduleDescriptors,
extensions: assembly.extensions,
journeys: assembly.extensions.journeys as ApplicationManifest<
TSlots,
TNavItem,
Record<string, unknown>
>["journeys"],
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how manifest.journeys and manifest.extensions.journeys are typed
fd 'types.ts' packages/react-router-runtime packages/tanstack-router-runtime --exec rg -n -C3 'journeys|extensions' {}

Repository: kibertoad/modular-react

Length of output: 3414


🏁 Script executed:

# First, let's look at the exact code in registry.ts at the mentioned lines
cat -n packages/react-router-runtime/src/registry.ts | sed -n '470,485p'

Repository: kibertoad/modular-react

Length of output: 647


🏁 Script executed:

# Also check lines 549-555
cat -n packages/react-router-runtime/src/registry.ts | sed -n '545,560p'

Repository: kibertoad/modular-react

Length of output: 706


🏁 Script executed:

# Get the full type definitions from types.ts for both ApplicationManifest and ResolvedApplicationManifest
cat -n packages/react-router-runtime/src/types.ts | sed -n '75,95p'

Repository: kibertoad/modular-react

Length of output: 1105


🏁 Script executed:

# Check the second occurrence in types.ts
cat -n packages/react-router-runtime/src/types.ts | sed -n '225,245p'

Repository: kibertoad/modular-react

Length of output: 958


🏁 Script executed:

# Find where assembly is defined and what its type is
rg -n "assembly\s*=" packages/react-router-runtime/src/registry.ts | head -20

Repository: kibertoad/modular-react

Length of output: 153


🏁 Script executed:

# Look for the function signature containing these type parameters
sed -n '450,485p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 1468


🏁 Script executed:

# Check what TExtensions is and how it flows through the function
rg -B 20 "journeys: assembly.extensions.journeys as ApplicationManifest" packages/react-router-runtime/src/registry.ts | head -40

Repository: kibertoad/modular-react

Length of output: 840


🏁 Script executed:

# Let's see the full function signature for context
sed -n '430,485p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 2321


🏁 Script executed:

# Find the function signatures - look for the start of the functions containing these return statements
sed -n '400,485p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 3676


🏁 Script executed:

# Look even further back for function declarations
sed -n '350,450p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 4404


🏁 Script executed:

# Search for buildAssembly function definition
rg -n "function buildAssembly|const buildAssembly" packages/react-router-runtime/src/registry.ts

Repository: kibertoad/modular-react

Length of output: 107


🏁 Script executed:

# Check the function around line 350-400 more carefully with wider context
sed -n '340,400p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 2538


🏁 Script executed:

# Look at the buildAssembly function definition and return type
sed -n '249,330p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 4040


🏁 Script executed:

# Check what the extensions variable is in buildAssembly
rg -n "extensions" packages/react-router-runtime/src/registry.ts | grep -E "249:|330:|extensions ="

Repository: kibertoad/modular-react

Length of output: 49


🏁 Script executed:

# Let's see how extensions is built - look for where it's assigned
sed -n '249,350p' packages/react-router-runtime/src/registry.ts | cat -n | tail -50

Repository: kibertoad/modular-react

Length of output: 2158


🏁 Script executed:

# Let's see the complete function signatures and return types for both resolve functions
rg -n "resolve\(" packages/react-router-runtime/src/registry.ts | head -5

Repository: kibertoad/modular-react

Length of output: 381


🏁 Script executed:

# Check the first resolve function signature more completely
sed -n '405,430p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 1219


🏁 Script executed:

# Check the resolveManifest function signature
sed -n '480,510p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 1654


🏁 Script executed:

# Let's check what CommonAssembly type is and if it has the extensions property typed
rg -n "type CommonAssembly|interface CommonAssembly" packages/react-router-runtime/src/

Repository: kibertoad/modular-react

Length of output: 143


🏁 Script executed:

# Get the CommonAssembly interface definition
sed -n '173,220p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 2678


🏁 Script executed:

# Now let's understand the real issue - check if extensions is actually typed as Record<string, unknown> in CommonAssembly
rg -A 20 "interface CommonAssembly" packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 1035


🏁 Script executed:

# Let me verify the actual type behavior - check what happens when the journeys plugin is used vs not used
rg -n "journeysPlugin" packages/react-router-runtime/src/ --type ts

Repository: kibertoad/modular-react

Length of output: 956


🏁 Script executed:

# Look for how journeys plugin extends the extensions
rg -n "journeys" packages/react-router-runtime/src/ -A 2 -B 2 | head -50

Repository: kibertoad/modular-react

Length of output: 3906


🏁 Script executed:

# Check if there's a way to understand how TExtensions should flow through the type system
rg -n "TExtensions" packages/react-router-runtime/src/types.ts

Repository: kibertoad/modular-react

Length of output: 615


🏁 Script executed:

# Let me check what the actual TExtensions type is in the resolve and resolveManifest functions
# Look at the full function signature including generics
sed -n '405,425p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 992


🏁 Script executed:

# Check the resolveManifest function signature completely
sed -n '485,510p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 1493


🏁 Script executed:

# Find where these functions are defined in relation to the plugin tracking
sed -n '40,100p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 3160


🏁 Script executed:

# This is the key! Let me find PluginRuntimesOf which should track the plugin types
rg -n "PluginRuntimesOf" packages/react-router-runtime/src/

Repository: kibertoad/modular-react

Length of output: 376


🏁 Script executed:

# Now I understand the issue - the resolve function signature promises PluginRuntimesOf<TPlugins>
# But the implementation hard-codes Record<string, unknown>
# Let me verify by looking at how resolve is actually typed vs implemented
sed -n '56,59p' packages/react-router-runtime/src/registry.ts

Repository: kibertoad/modular-react

Length of output: 298


🏁 Script executed:

# And check the actual implementation return type annotation
sed -n '418,420p' packages/react-router-runtime/src/registry.ts

Repository: kibertoad/modular-react

Length of output: 213


🏁 Script executed:

# Find PluginRuntimesOf definition
rg -n "PluginRuntimesOf|type PluginRuntimesOf" packages/react-router-runtime/src/

Repository: kibertoad/modular-react

Length of output: 376


🏁 Script executed:

# Check the exact line numbers for both the interface declaration and implementation
echo "=== Interface declaration (should use PluginRuntimesOf<TPlugins>) ===" && \
sed -n '97p' packages/react-router-runtime/src/registry.ts && \
echo -e "\n=== Implementation return type (currently uses Record<string, unknown>) ===" && \
sed -n '418,420p' packages/react-router-runtime/src/registry.ts && \
echo -e "\n=== Same for resolveManifest interface ===" && \
sed -n '111p' packages/react-router-runtime/src/registry.ts && \
echo -e "\n=== resolveManifest implementation ===" && \
sed -n '493,495p' packages/react-router-runtime/src/registry.ts

Repository: kibertoad/modular-react

Length of output: 626


🏁 Script executed:

# Let me get the resolveManifest implementation return type correctly
sed -n '493,495p' packages/react-router-runtime/src/registry.ts

Repository: kibertoad/modular-react

Length of output: 108


🏁 Script executed:

# Find where resolveManifest is implemented in the registry object
rg -n "resolveManifest\(" packages/react-router-runtime/src/registry.ts | grep -v "//"

Repository: kibertoad/modular-react

Length of output: 968


🏁 Script executed:

# Get the exact resolveManifest implementation return type
sed -n '528,532p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 310


🏁 Script executed:

# Get the resolveManifest function signature (line 486-488)
sed -n '486,490p' packages/react-router-runtime/src/registry.ts | cat -n

Repository: kibertoad/modular-react

Length of output: 318


Fix type assertion to use actual plugin runtimes instead of Record<string, unknown>.

The interface declarations promise PluginRuntimesOf<TPlugins> for both resolve() and resolveManifest(), but the implementations incorrectly assert the type as ApplicationManifest<..., Record<string, unknown>> and ResolvedManifest<..., Record<string, unknown>>. This breaks the conditional type safety on journeys.

When TExtensions = Record<string, unknown>, the conditional type TExtensions extends { journeys: infer R } ? R : never always evaluates true, returning unknown instead of never. This means the convenience alias silently accepts manifest.journeys access regardless of whether the journeys plugin was registered, causing runtime crashes when accessing undefined.

Change the type assertions at lines 477–481 and 551–555 to use PluginRuntimesOf<TPlugins> (or the corresponding generic parameter) so the conditional type properly returns never when the plugin isn't loaded, forcing compile-time guards at call sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router-runtime/src/registry.ts` around lines 475 - 481, The
current assertions cast the manifest's extensions to Record<string, unknown>
causing the conditional type for journeys to resolve to unknown; update the two
incorrect casts so that where you currently assert ApplicationManifest<...,
Record<string, unknown>> and ResolvedManifest<..., Record<string, unknown>> you
instead use the actual plugin runtimes type (PluginRuntimesOf<TPlugins> or the
corresponding generic TExtensions/PluginRuntimes type parameter) so that the
conditional type for journeys evaluates correctly to the inferred type or never;
specifically change the assertions that build the returned object (the
properties moduleDescriptors, extensions, journeys) inside resolve() and
resolveManifest() to use PluginRuntimesOf<TPlugins> (or the generic runtime
type) for the extensions generic to restore proper compile-time guarding when
the journeys plugin is absent.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional design - the private createRegistry implementation doesn't thread TPlugins through, so the internal return types use Record<string, unknown> and the journeys field is as never. The public surface's correct typing comes from the outer as unknown as ModuleRegistry<..., readonly []> cast at registry.ts:587 - consumers see PluginRuntimesOf<TPlugins> from ModuleRegistry, so the conditional TExtensions extends { journeys: infer R } ? R : never resolves correctly at call sites. Inline comments at registry.ts:500-506 and :576-577 describe this split.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate Limit Exceeded

@kibertoad have exceeded the limit for the number of chat messages per hour. Please wait 57 minutes and 8 seconds before sending another message.

kibertoad and others added 7 commits April 24, 2026 17:04
- redact customerId from onAbandon abort payload (RR + TSR examples) —
  full state contained user-identifying fields that would leak into
  analytics sinks by default
- give persisted tabs collision-free ids via crypto.randomUUID — the
  old module-level counter reset to 1 on reload and clobbered persisted
  tab ids
- invoke vite through pnpm exec in playwright configs and fix the
  misleading "after pnpm build" comment (it's actually the dev server)
- drop a stale filtering comment in the RR smoke spec that described
  a denylist that didn't exist
- add a `text` language hint to the RR example README code fence

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runtime
- runtime.hydrate(): clean up the half-built placeholder if hydrateInto
  throws, mirroring the sync-start path. Without this, a retry hit the
  "already in memory" guard and forget() was a no-op (non-terminal
  status), effectively leaking the record.
- bindStepCallbacks: implement the JSDoc-documented fallback on
  `JourneyRuntimeOptions.modules` — when a runtime is built without a
  module descriptor but the journey transition declares allowBack: true,
  treat the mode as 'preserve-state' so goBack stays wired. Makes the
  headless simulator's goBack work out of the box (README example) and
  matches what the public docs already promise.
- outlet: replace `React.ComponentType<any>` with the already-imported
  `ComponentType<any>` — React namespace isn't imported in this file.

Validation
- validateJourneyContracts now guards null/non-object inner values in
  the transitions map so malformed `{ transitions: { foo: null } }`
  produces an issue instead of a TypeError that short-circuits the
  accumulate-all-issues contract.

Testing
- createTestHarness.goBack now throws a readable error when back is
  unavailable instead of silently no-oping. The previous optional chain
  let `goBack walks back…` style tests "pass" when wiring was broken.
- README: drop the stale getInternals entry (not exported) and add
  `createTestHarness` / `JourneyTestHarness` to the testing table.

RR + TSR runtime registries
- `plugin.onResolve({ debug })` now mirrors the journeys runtime's own
  environment-based default (NODE_ENV !== "production") instead of
  hardcoded false, so the journeys plugin emits verbose dev logs in
  development without `journeysPlugin({ debug: true })` per site.
- `journeys: assembly.extensions.journeys as never` at the extension
  cast point, with a comment explaining that the public `.journeys`
  type comes from `PluginRuntimesOf<TPlugins>` via the outer registry
  cast — the previous `as Record<string, unknown>` shape was misleading
  and (incorrectly) implied a concrete shape at the impl site.
- Add a comment on `plugin.extend({ markDirty: () => {} })` clarifying
  that markDirty is reserved by the plugin contract for future
  reactivity support; today it's an intentional no-op, not missed
  wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants