Skip to content

feat: Implement support for journeys to sidetrack into other journeys#24

Merged
kibertoad merged 15 commits intomainfrom
feat/journey-invoke
Apr 30, 2026
Merged

feat: Implement support for journeys to sidetrack into other journeys#24
kibertoad merged 15 commits intomainfrom
feat/journey-invoke

Conversation

@kibertoad
Copy link
Copy Markdown
Owner

@kibertoad kibertoad commented Apr 30, 2026

Summary by CodeRabbit

  • New Features

    • Parent journeys can invoke child journeys and resume with typed outputs; call-stack visibility via a new hook.
    • Persistence round-trips in-flight invokes so parent↔child links rehydrate after reloads.
    • Transition events include a kind field (step/invoke/resume) for clearer telemetry.
    • Added Router examples (React Router & TanStack) demonstrating invoke/resume flows.
  • Documentation

    • Expanded docs and READMEs explaining invoke/resume model and persistence behavior.
  • Tests

    • Extensive runtime and UI tests covering invoke/resume, persistence, and cycle-safety.

kibertoad and others added 9 commits April 30, 2026 14:20
Threads TOutput through JourneyHandleRef, JourneyDefinition, defineJourney,
defineJourneyHandle, TransitionResult.complete, and TerminalOutcome so a
journey's terminal payload can be type-checked end-to-end. Existing call
sites keep compiling — TOutput defaults to unknown.

Adds the new authoring vocabulary without runtime behaviour: ChildOutcome
(discriminated union for child terminal outcomes), InvokeSpec (the
parent-side return shape for invoking a child journey by handle + named
resume), and ResumeMap (sibling of TransitionMap keyed by
[moduleId][entryName][resumeName]).

ResumeMap lives at the JourneyDefinition level, not inside EntryTransitions:
nesting an index-signature value in EntryTransitions' intersection
collapses the mapped-type variance and breaks assignability to
AnyJourneyDefinition. Keeping resumes separate sidesteps that and leaves
resume names in their own keyspace, so a step can declare an exit and a
resume that share a label without conflict.

AnyJourneyDefinition now uses any for TModules instead of ModuleTypeMap so
the structural variance check on ResumeMap admits any concrete TModules.

No runtime work yet — runtime/outlet/persistence wiring lands in
subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core runtime change implementing the subroutine model: a parent journey's
transition can return `{ invoke: { handle, input, resume } }` to suspend
at the current step, run a child journey, and resume on a named handler
when the child terminates.

Runtime additions:
- InstanceRecord gains `parent` (back-link from child to parent) and
  `activeChildId` (forward-link from parent to child). A reverse map
  `childToParent` keeps the child-terminal hook O(1).
- `applyTransition` learns an invoke branch that validates the spec
  (unknown journey, missing resume name, runtime.start failures) and
  drives the parent into a discoverable abort on any failure path —
  surfaced through the existing onError hook.
- After every transition, `applyResumeIfChild` checks whether the just-
  transitioned record was a child whose terminal we just applied; if so
  it looks up the parent's resume handler in `def.resumes[mod][entry]`,
  fires it with `ChildOutcome` (typed payload on completed, reason on
  aborted), and applies the result as the parent's next transition.
  Recursion-safe: a parent that itself completes will trigger the
  grandparent's resume, and so on up the call chain.
- `dispatchExit` blocks exits from a parent step that has a child in
  flight — the parent advances only via the resume.
- `runtime.end` cascades: a parent that gets force-terminated takes its
  in-flight child with it, with a propagated `parent-ended` reason so
  child telemetry can distinguish the two paths.

Persistence (round-trips invoke state across reload):
- `SerializedJourney.pendingInvoke` (parent side) and `parentLink` (child
  side) are emitted from `serialize()` when the parent has an in-flight
  child whose record is in memory.
- `hydrateInto` restores both sides' link state. After every hydrate
  path (sync start, async start, explicit hydrate), `relinkInvocations`
  rebuilds `childToParent` from the in-memory pairs. Out-of-order hydrate
  (child before parent or vice versa) ends up with a consistent map.
- A parent whose `activeChildId` references a not-yet-loaded child
  stays in `active`; its step exits stay blocked. The shell decides
  whether to load the missing child later or `runtime.end` to surrender
  — auto-aborting would race with multi-step hydrates that legitimately
  load parent and child in separate calls.
- `forget` / `forgetTerminal` clean up the reverse-map entry alongside
  the keyIndex entry.

Public surface:
- `JourneyInstance` now exposes `activeChildId` and `parent` so the
  outlet's call-stack walk and shells layering presentations can read
  link state through the stable snapshot.

Tests cover: happy path (typed resume payload flows into parent state),
parent exits blocked while child is in flight, child abort surfaces as
ChildOutcome.aborted on the parent's resume, cascade-end on parent
termination, three-level nesting (outer→middle→leaf with bubbling
resumes), all four validation failure modes (unknown child journey,
unknown resume name, resume handler that throws, resume returns Promise),
and persistence round-trip in both forward and reverse hydrate order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JourneyOutlet now walks the active call chain (root → activeChildId →
activeChildId → …) by default and renders the leaf step. A child journey
invoked from a parent step takes over the same outlet for the duration
of its run, matching the subroutine intuition.

leafOnly={false} opts out for layered presentations: the outlet stays on
the supplied instance's step even when a child is in flight, and a sibling
outlet keyed off `activeChildId` (or `useJourneyCallStack`) renders the
child elsewhere (modal, side-panel, etc.).

Internals:
- Subscribes to every instance along the chain so a link change anywhere
  triggers a re-render. Bounded by MAX_CHAIN_DEPTH (64) with cycle
  detection — defensive even though the runtime should not produce cycles.
- Step callbacks (`exit`, `goBack`) bind to the LEAF's record + reg so
  exits dispatched by the rendered module hit the right journey. The
  abandon-on-unmount cleanup targets the ROOT — `runtime.end` cascades
  to the active child, so a single call cleans the whole chain.
- onFinished fires for the ROOT only. A child terminal triggers the
  parent's resume; observers that want every termination subscribe via
  registration-level onComplete/onAbort, not the outlet.
- onStepError's "abort" policy ends the LEAF (so the parent's resume sees
  `outcome.aborted` and decides recovery), not the root.

Hook: useJourneyCallStack(runtime, rootId) returns the chain root-to-leaf.
Useful for shells that render parent and child outlets simultaneously
(parent under a modal, child inside it).

Tests cover: leaf-walk default rendering across invoke→resume hops,
leafOnly={false} stays on parent, onFinished does not fire on child
terminations but does fire on root completion, useJourneyCallStack
shifts as the chain grows and shrinks, abandon-on-unmount cascades to
the active child.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend simulateJourney with two test modes for invoke/resume:

1. End-to-end: pass `children` in options to register reachable child
   journey definitions; `sim.activeChild` returns a sub-simulator wrapped
   around the runtime-registered child instance and drives it via the
   same harness. Child terminations resume the parent automatically.

2. Mock the outcome: `sim.completeChild(payload)` synthesizes a
   `{ status: "completed" }` outcome on the active child without
   enumerating its transitions; `sim.abortChild(reason)` calls
   `runtime.end` on the child. Useful when the child's path is
   irrelevant to the parent-resume unit test.

Implementation:
- `JourneyRuntimeInternals.__synthesizeCompletion` is the runtime hook
  the simulator calls to terminate a child via the standard
  `applyTransition({ complete })` machinery — onComplete, onTransition,
  persistence, and the parent's resume hook all fire as they would
  for a real `{ complete }` transition.
- `wrapInstanceAsSim` is the shared constructor for both the primary
  sim returned by simulateJourney and any child sim surfaced via
  sim.activeChild. Same surface; recursive simulators for grandchildren
  fall out naturally.
- A simulator built without `children` aborts on `invoke` with
  `invoke-unknown-journey` — surfaces the missing registration loudly
  rather than silently letting the parent stall.

Tests cover all four code paths (end-to-end success, end-to-end abort,
synthesized completion, runtime.end-driven abort) plus the throw-on-
no-child guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds discriminator and correlation fields to TransitionEvent so telemetry
consumers can filter to ordinary step transitions and skip the (often
noisy) invoke-bookkeeping events, or — when interested — read the child
identity on invoke and the typed outcome on resume.

- `kind: "step"` — ordinary `{ next | complete | abort }` (the default).
- `kind: "invoke"` — parent has just started a child journey. `child:
  { instanceId, journeyId }` is populated; `from` and `to` are equal
  (parent's step doesn't change).
- `kind: "resume"` — parent's named resume handler has been applied with
  the child's outcome. `outcome: ChildOutcome<unknown>` and `resume:
  "<name>"` are populated; `from` / `to` reflect the actual transition
  the resume handler returned.

Wiring threaded through `applyTransition`'s new optional `eventExtras`
parameter, defaulting to `kind: "step"` so existing call sites stay
on the step kind without modification. Kind events all flow through the
existing `onTransition` hook — no new subscription surface.

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

Walks through the subroutine model end-to-end: the shape of an `invoke`
transition + `resumes` map, why resume names are strings (persistence
round-trip), the lifecycle/error matrix in table form, outlet leaf-walk
behavior plus the layered-presentation pattern with useJourneyCallStack,
the persistence blob shape, observability via TransitionEvent.kind, and
the simulator's two test modes (real child sub-sim vs synthesized
outcome).

Adds a "What this is not" footer that explicitly calls out the design
boundaries (no shared state, no concurrent spawn, no goBack across the
boundary) so future authors don't try to bend the primitive into shapes
it doesn't fit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compact React Router example demonstrating the parent → invoke → child →
resume flow end-to-end with persistence round-trip.

Layout:
- modules/checkout-review, modules/age-verify, modules/checkout-confirm —
  three plain modules that know nothing about journeys.
- journeys/verify-identity — child journey, single-step age check that
  completes with a typed AgeVerificationToken (third generic on
  defineJourney pins TOutput).
- journeys/checkout — parent journey that invokes the child mid-flow
  and reads `outcome.payload.token` typed end-to-end via the resume
  handler. The child's terminal aborts surface as
  `outcome.status === "aborted"` so the parent decides recovery.
- shell — minimal RR app with one JourneyOutlet that follows the
  active leaf, plus useJourneyCallStack for a "Call stack" banner that
  visualizes the parent ↔ child relationship.

Persistence: both journeys use createWebStoragePersistence. Reload
mid-verification rehydrates parent + child from localStorage; the
runtime relinks them via the in-memory `parent` / `activeChildId`
fields. The verify modal pops right back up.

Plugin signature update:
- registerJourney now takes a fourth optional generic `TOutput =
  unknown` so journeys with a typed terminal payload (like the new
  verify-identity journey) keep typechecking through the plugin's
  registration surface. Default keeps existing call sites compiling.

Lockfile regenerated (was corrupted by an earlier stash interaction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same shape as the React Router variant — one parent journey (checkout)
that invokes a child journey (verify-identity) mid-flow, picks up the
typed terminal payload, and continues. Persistence round-trips both
blobs so reload-during-verify relinks them automatically.

Differences from the RR variant are scoped strictly to the routing
surround: registry comes from @tanstack-react-modules/runtime, the
shell uses TanStack's <Outlet>, the dev server runs on port 5176.
Modules, journeys, persistence shape, and the demo flow itself are
identical — same `<JourneyOutlet>` follows the leaf of the call chain
by default; same `useJourneyCallStack` shows the parent → child relation
in a banner.

The two examples side-by-side are a useful regression check that the
invoke / resume primitive is router-agnostic — the same journey
definition exports work under both routing surfaces.

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

coderabbitai Bot commented Apr 30, 2026

Warning

Rate limit exceeded

@kibertoad has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 10 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66077220-de65-443d-9cd6-3c08827c48aa

📥 Commits

Reviewing files that changed from the base of the PR and between 45d2c50 and 282c8f7.

📒 Files selected for processing (14)
  • examples/react-router/journey-invoke/journeys/verify-identity/src/verify-identity.ts
  • examples/react-router/journey-invoke/modules/age-verify/src/Verify.tsx
  • examples/tanstack-router/journey-invoke/journeys/verify-identity/src/verify-identity.ts
  • examples/tanstack-router/journey-invoke/modules/age-verify/src/Verify.tsx
  • packages/core/src/index.ts
  • packages/core/src/journey-contracts.ts
  • packages/journeys/README.md
  • packages/journeys/src/index.ts
  • packages/journeys/src/invoke-cycle-safety.test.ts
  • packages/journeys/src/invoke.test.ts
  • packages/journeys/src/outlet-invoke.test.tsx
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/types.ts
  • packages/journeys/src/validation.ts
📝 Walkthrough

Walkthrough

Adds invoke/resume support for journeys: parents can invoke typed child journeys, persist in-flight links, and resume with child terminal payloads. Core types, runtime, outlet, validation, simulator, tests, docs, and two complete examples (React Router and TanStack Router) are implemented or updated.

Changes

Cohort / File(s) Summary
Core contracts
packages/core/src/index.ts, packages/core/src/journey-contracts.ts
Introduce invoke/resume types: InvokeSpec, ChildOutcome, ResumeHandler, ResumeMap, PendingInvoke, ParentLink, ResumeBounceCounter; extend TransitionResult/Instance/Serialized models for parent↔child linking and persisted invoke fields.
Journeys public API & typing
packages/journeys/src/index.ts, packages/journeys/src/types.ts, packages/journeys/src/define-journey.ts, packages/journeys/src/handle.ts, packages/journeys/src/plugin.tsx
Add TOutput generic across definitions/handles/register API; add invoke helper; expose validateJourneyGraph, useJourneyCallStack; thread terminal-output typing end-to-end and add resumes/invokes shape and register-time options.
Runtime core
packages/journeys/src/runtime.ts
Implement parent/child lifecycle: invoke transition handling (validation, start child, link parent↔child, persist), resume application when child terminates, relink/hydration helpers, bounce/stack safety guards, cascade termination, and simulator/testing internals.
Outlet & hooks
packages/journeys/src/outlet.tsx
JourneyOutlet now supports leafOnly (default true), subscribes to root and leaf separately, re-scopes step handling to leaf, centralizes retry budget, and exports useJourneyCallStack to observe root→leaf instance chain.
Validation & graph tools
packages/journeys/src/validation.ts
Add invoke-graph static cycle detection, full resumes shape validation, and new validateJourneyGraph helper for cycle checks.
Simulator & tests
packages/journeys/src/simulate-journey.ts, packages/journeys/src/invoke.test.ts, packages/journeys/src/outlet-invoke.test.tsx, packages/journeys/src/simulate-journey-invoke.test.ts, packages/journeys/src/invoke-cycle-safety.test.ts, packages/journeys/src/simulate-*.ts
Extend simulator API to register child journeys, expose activeChildId/activeChild, add completeChild/abortChild, and add extensive unit/React/simulation tests covering invoke/resume lifecycles, persistence, cycle-safety, bounce limits, and error cases.
Documentation
packages/journeys/README.md
Document invoke/resume model, persistence round-tripping (pendingInvoke/parentLink), telemetry TransitionEvent.kind, useJourneyCallStack, authoring/validation rules, and safety/limits.
Examples — React Router
examples/react-router/journey-invoke/**
New complete example: checkout parent invokes verify-identity child; modules (checkout-review, checkout-confirm, age-verify), shared types/hooks, persistence adapters, Vite shells, README, and runtime wiring demonstrating invoke/resume and reload re-linking.
Examples — TanStack Router
examples/tanstack-router/journey-invoke/**
Parallel TanStack Router example mirroring React Router flow, modules, persistence, Vite shell, and docs.
Module packages & small infra
examples/**/package.json, tsconfig.*, vite.config.ts, index.html files across new example packages
Add package manifests, tsconfig bases, and Vite configs for examples and modules to support the new example projects.

Sequence Diagram

sequenceDiagram
    participant User
    participant JourneyOutlet
    participant JourneyRuntime
    participant ParentJourney
    participant ChildJourney

    User->>JourneyOutlet: interact (e.g. "Proceed")
    JourneyOutlet->>ParentJourney: dispatch exit
    ParentJourney->>JourneyRuntime: evaluate transition → { invoke }
    JourneyRuntime->>JourneyRuntime: validate & start child
    JourneyRuntime->>ChildJourney: start(childHandle, input)
    JourneyRuntime->>JourneyRuntime: set parent.activeChildId, child.parentLink, persist
    JourneyOutlet->>JourneyRuntime: observe activeChildId
    JourneyOutlet->>ChildJourney: render child leaf
    User->>ChildJourney: complete (verified)
    ChildJourney->>JourneyRuntime: child terminal (complete)
    JourneyRuntime->>JourneyRuntime: apply parent's resume handler with child outcome
    JourneyRuntime->>ParentJourney: perform resume transition
    JourneyRuntime->>JourneyRuntime: clear links, persist
    JourneyOutlet->>ParentJourney: render resumed parent step
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • feat: add persistence adapters #21: Related — persistence adapters (createWebStoragePersistence/createMemoryPersistence) used by the new examples and runtime rehydration logic.
  • implement journeys #20: Related — foundational journeys/handles/outlet runtime and types that this PR extends with invoke/resume semantics.

Suggested reviewers

  • diogomiguel
  • casamitjana

Poem

🐰
I hopped in, ears alert and bright,
A parent called a child mid-flight.
Tokens returned, links rejoined,
Then onward went the journeyed coin.
LocalStorage hummed — the flow felt right. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the core feature: enabling journeys to invoke and resume other journeys as subroutines. The changeset demonstrates comprehensive invoke/resume implementation across core contracts, runtime, outlets, and two complete example applications.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 10 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

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

🧹 Nitpick comments (5)
examples/react-router/journey-invoke/modules/age-verify/src/Verify.tsx (1)

36-46: 💤 Low value

Missing timeout cleanup on unmount.

If the component unmounts during the 250ms delay (e.g., user navigates away), the callback will still fire. While the journey runtime's step-token mechanism drops stale exits, cleaning up the timeout avoids unnecessary work and potential React warnings about state updates on unmounted components.

♻️ Proposed fix to clean up timeout
+import { useState, useRef, useEffect } from "react";
-import { useState } from "react";
 import type { ModuleEntryProps } from "@modular-react/core";
 import type { AgeVerifyExits } from "./exits.js";

 export function Verify({ input, exit }: ModuleEntryProps<VerifyInput, AgeVerifyExits>) {
   const [confirming, setConfirming] = useState(false);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    };
+  }, []);
   // ...
           onClick={() => {
             setConfirming(true);
-            window.setTimeout(() => {
+            timeoutRef.current = window.setTimeout(() => {
               exit("verified", {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/react-router/journey-invoke/modules/age-verify/src/Verify.tsx`
around lines 36 - 46, The onClick uses window.setTimeout without clearing it, so
store the timeout id (from window.setTimeout) in a ref (e.g., timeoutRef) when
you call it in the onClick handler and add a useEffect cleanup that clears that
timeout on unmount (clearTimeout(timeoutRef.current)). Update references to the
timeout id with the appropriate TypeScript type (number | null) if needed and
ensure no further state updates occur from the timed callback after the timeout
is cleared; keep using setConfirming and exit as-is inside the callback.
packages/journeys/src/outlet-invoke.test.tsx (1)

306-324: ⚡ Quick win

Consider adding a timeout to the Promise-based test.

The test wraps in a Promise and resolves via queueMicrotask, which is correct for testing the deferred abandon behavior. However, if the microtask never fires (due to a bug), the test would hang indefinitely.

🛡️ Add a timeout to prevent hanging on failure
-describe("JourneyOutlet — abandon-on-unmount cascades to the active child", () => {
-  it("ends both the parent and the child when the outlet unmounts mid-invoke", () =>
-    new Promise<void>((resolve) => {
+describe("JourneyOutlet — abandon-on-unmount cascades to the active child", () => {
+  it("ends both the parent and the child when the outlet unmounts mid-invoke", { timeout: 1000 }, () =>
+    new Promise<void>((resolve, reject) => {
       const rt = buildRuntime();
       const id = rt.start(parentHandle, { orderId: "O-UM" });
       const ui = render(createElement(JourneyOutlet, { runtime: rt, instanceId: id }));
       act(() => {
         ui.getByTestId("review-pick").click();
       });
       const childId = rt.getInstance(id)!.activeChildId!;
       ui.unmount();
       // Abandon is deferred by a microtask to survive StrictMode mount/unmount.
+      const timeout = setTimeout(() => reject(new Error("Microtask did not fire")), 500);
       queueMicrotask(() => {
+        clearTimeout(timeout);
         expect(rt.getInstance(id)!.status).toBe("aborted");
         expect(rt.getInstance(childId)!.status).toBe("aborted");
         resolve();
       });
     }));
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/journeys/src/outlet-invoke.test.tsx` around lines 306 - 324, The
test "JourneyOutlet — abandon-on-unmount cascades to the active child" can hang
if the microtask never runs; update the Promise in the test to include a timeout
fallback: when creating the Promise in the it block, start a timer (e.g.,
setTimeout for ~1000ms) that fails the test (call reject or call
expect(false).toBeTruthy() and resolve) and clear that timer when the
queueMicrotask callback runs and calls resolve; reference the test's Promise,
the rt/start pattern (rt.start(parentHandle,...)), the queueMicrotask callback,
and the resolve call so you add the timer before mounting and clear it inside
the microtask success path to ensure the test never hangs indefinitely.
examples/react-router/journey-invoke/journeys/checkout/src/checkout.ts (1)

68-77: 💤 Low value

Inline new Date().toISOString() creates non-deterministic state.

When requiresAgeCheck is false, the verifiedAt timestamp is generated at transition time. This makes the journey state non-reproducible and could complicate testing or debugging. For a demo this is acceptable, but production code might prefer passing a clock or using a sentinel value.

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

In `@examples/react-router/journey-invoke/journeys/checkout/src/checkout.ts`
around lines 68 - 77, The inline new Date().toISOString() in the transition to
module "checkout-confirm" (entry "confirm") makes journey state
non-deterministic; instead, pass a deterministic timestamp or sentinel from the
journey context or state (e.g., a provided clock value like state.now / ctx.now)
or use a stable sentinel/null for verifiedAt when requiresAgeCheck is false.
Update the transition that builds verification ({ token: "skipped", verifiedAt:
... }) to consume an injected clock value or a precomputed state property rather
than calling new Date() inline so the checkout flow (requiresAgeCheck,
state.order, verification) remains reproducible and testable.
examples/tanstack-router/journey-invoke/journeys/verify-identity/src/verify-identity.ts (1)

13-16: 💤 Low value

The attempts field is initialized but never used.

The VerifyIdentityState includes an attempts counter initialized to 0, but no transition increments or reads this value. This appears to be dead state that could be removed, or the retry logic may be missing.

🧹 If retry logic is not needed, remove the unused field
 export interface VerifyIdentityState {
   readonly customerId: string;
-  readonly attempts: number;
 }
 
 // ...
 
-  initialState: ({ customerId }: VerifyIdentityInput) => ({ customerId, attempts: 0 }),
+  initialState: ({ customerId }: VerifyIdentityInput) => ({ customerId }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/tanstack-router/journey-invoke/journeys/verify-identity/src/verify-identity.ts`
around lines 13 - 16, The VerifyIdentityState declares an unused attempts field;
either remove attempts from the VerifyIdentityState interface and any initial
state construction/usages if no retry logic is needed, or implement retry
handling: add a MAX_ATTEMPTS constant and update the attempts counter immutably
in the verification flow (e.g., inside the
verify/handleVerificationFailure/transition function that processes failed
verification attempts) by incrementing state.attempts and gating retries when
attempts >= MAX_ATTEMPTS; ensure you update all places that construct
VerifyIdentityState (initializers) and any type references.
packages/journeys/src/outlet.tsx (1)

413-421: 💤 Low value

Cache key collision is theoretically possible but practically safe.

The cache key uses fresh.join(">") which could collide if instance IDs contain the > character. For UUID-based IDs this is not a concern, but worth noting if custom ID schemes are ever supported.

🤖 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 413 - 421, The cache key in
getStableSnapshot currently uses fresh.join(">") which can collide if InstanceId
values include ">", so update getStableSnapshot to derive a collision-proof key
(e.g., use a delimiter-safe encoding or serialize the array) before
comparing/storing in cacheRef; update the logic around cacheRef,
getStableSnapshot, and getSnapshot to use that new key so equality checks remain
correct and avoid accidental collisions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/react-router/journey-invoke/shell/src/persistence.ts`:
- Around line 27-31: The comment describing the child namespacing is out of sync
with the key generation in keyFor: ({ input }) => `verify:${input.customerId}`;
update one of them to match the other — either change the comment to describe
that the key is `verify:${customerId}` or modify the keyFor implementation to
include the parent order id (for example
`verify:${input.orderId}:${input.customerId}` or
`verify:${parentOrderId}:${input.customerId}` depending on available fields) so
the code and comment are consistent; adjust any consumers or tests that expect
the previous format if you change the key format.

In `@examples/tanstack-router/journey-invoke/modules/age-verify/src/Verify.tsx`:
- Around line 33-55: The decline path can race with the scheduled
exit("verified", …): store the timeout id (e.g., timeoutRef via useRef) when
calling window.setTimeout in the onClick that calls setConfirming(true), and in
the decline onClick first guard against confirming (if (confirming) return),
disable the decline button by setting disabled={confirming}, and clear the
pending timeout via clearTimeout(timeoutRef.current) before calling
exit("declined", …); this ensures setConfirming, confirming, the scheduled
window.setTimeout callback, and exit are coordinated and only one exit is
emitted.

In `@packages/journeys/README.md`:
- Around line 1784-1785: The table's anchor links are broken because the
"Pattern - ..." headings generate anchors with three dashes (---) but the table
uses two (--); update the reference links for `selectModule` and
`selectModuleOrDefault` (and any other pattern links in the same table) to use
the triple-dash form matching the generated heading anchors (e.g., change
pattern--exhaustive-state-driven-module-dispatch-selectmodule to
pattern---exhaustive-state-driven-module-dispatch-selectmodule and
pattern--fallback-dispatch-selectmoduleordefault to
pattern---fallback-dispatch-selectmoduleordefault) so the fragments resolve
correctly. Ensure you update both table cells and run the markdown linter to
catch any remaining mismatches.

---

Nitpick comments:
In `@examples/react-router/journey-invoke/journeys/checkout/src/checkout.ts`:
- Around line 68-77: The inline new Date().toISOString() in the transition to
module "checkout-confirm" (entry "confirm") makes journey state
non-deterministic; instead, pass a deterministic timestamp or sentinel from the
journey context or state (e.g., a provided clock value like state.now / ctx.now)
or use a stable sentinel/null for verifiedAt when requiresAgeCheck is false.
Update the transition that builds verification ({ token: "skipped", verifiedAt:
... }) to consume an injected clock value or a precomputed state property rather
than calling new Date() inline so the checkout flow (requiresAgeCheck,
state.order, verification) remains reproducible and testable.

In `@examples/react-router/journey-invoke/modules/age-verify/src/Verify.tsx`:
- Around line 36-46: The onClick uses window.setTimeout without clearing it, so
store the timeout id (from window.setTimeout) in a ref (e.g., timeoutRef) when
you call it in the onClick handler and add a useEffect cleanup that clears that
timeout on unmount (clearTimeout(timeoutRef.current)). Update references to the
timeout id with the appropriate TypeScript type (number | null) if needed and
ensure no further state updates occur from the timed callback after the timeout
is cleared; keep using setConfirming and exit as-is inside the callback.

In
`@examples/tanstack-router/journey-invoke/journeys/verify-identity/src/verify-identity.ts`:
- Around line 13-16: The VerifyIdentityState declares an unused attempts field;
either remove attempts from the VerifyIdentityState interface and any initial
state construction/usages if no retry logic is needed, or implement retry
handling: add a MAX_ATTEMPTS constant and update the attempts counter immutably
in the verification flow (e.g., inside the
verify/handleVerificationFailure/transition function that processes failed
verification attempts) by incrementing state.attempts and gating retries when
attempts >= MAX_ATTEMPTS; ensure you update all places that construct
VerifyIdentityState (initializers) and any type references.

In `@packages/journeys/src/outlet-invoke.test.tsx`:
- Around line 306-324: The test "JourneyOutlet — abandon-on-unmount cascades to
the active child" can hang if the microtask never runs; update the Promise in
the test to include a timeout fallback: when creating the Promise in the it
block, start a timer (e.g., setTimeout for ~1000ms) that fails the test (call
reject or call expect(false).toBeTruthy() and resolve) and clear that timer when
the queueMicrotask callback runs and calls resolve; reference the test's
Promise, the rt/start pattern (rt.start(parentHandle,...)), the queueMicrotask
callback, and the resolve call so you add the timer before mounting and clear it
inside the microtask success path to ensure the test never hangs indefinitely.

In `@packages/journeys/src/outlet.tsx`:
- Around line 413-421: The cache key in getStableSnapshot currently uses
fresh.join(">") which can collide if InstanceId values include ">", so update
getStableSnapshot to derive a collision-proof key (e.g., use a delimiter-safe
encoding or serialize the array) before comparing/storing in cacheRef; update
the logic around cacheRef, getStableSnapshot, and getSnapshot to use that new
key so equality checks remain correct and avoid accidental collisions.
🪄 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: 5161d722-0413-43a1-b859-57a9f0ff92f9

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd760f and c013f81.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (86)
  • examples/react-router/journey-invoke/README.md
  • examples/react-router/journey-invoke/app-shared/package.json
  • examples/react-router/journey-invoke/app-shared/src/index.ts
  • examples/react-router/journey-invoke/app-shared/tsconfig.json
  • examples/react-router/journey-invoke/journeys/checkout/package.json
  • examples/react-router/journey-invoke/journeys/checkout/src/checkout.ts
  • examples/react-router/journey-invoke/journeys/checkout/src/index.ts
  • examples/react-router/journey-invoke/journeys/checkout/tsconfig.json
  • examples/react-router/journey-invoke/journeys/verify-identity/package.json
  • examples/react-router/journey-invoke/journeys/verify-identity/src/index.ts
  • examples/react-router/journey-invoke/journeys/verify-identity/src/verify-identity.ts
  • examples/react-router/journey-invoke/journeys/verify-identity/tsconfig.json
  • examples/react-router/journey-invoke/modules/age-verify/package.json
  • examples/react-router/journey-invoke/modules/age-verify/src/Verify.tsx
  • examples/react-router/journey-invoke/modules/age-verify/src/exits.ts
  • examples/react-router/journey-invoke/modules/age-verify/src/index.ts
  • examples/react-router/journey-invoke/modules/age-verify/tsconfig.json
  • examples/react-router/journey-invoke/modules/checkout-confirm/package.json
  • examples/react-router/journey-invoke/modules/checkout-confirm/src/Confirm.tsx
  • examples/react-router/journey-invoke/modules/checkout-confirm/src/exits.ts
  • examples/react-router/journey-invoke/modules/checkout-confirm/src/index.ts
  • examples/react-router/journey-invoke/modules/checkout-confirm/tsconfig.json
  • examples/react-router/journey-invoke/modules/checkout-review/package.json
  • examples/react-router/journey-invoke/modules/checkout-review/src/Review.tsx
  • examples/react-router/journey-invoke/modules/checkout-review/src/exits.ts
  • examples/react-router/journey-invoke/modules/checkout-review/src/index.ts
  • examples/react-router/journey-invoke/modules/checkout-review/tsconfig.json
  • examples/react-router/journey-invoke/shell/index.html
  • examples/react-router/journey-invoke/shell/package.json
  • examples/react-router/journey-invoke/shell/src/components/Home.tsx
  • examples/react-router/journey-invoke/shell/src/components/RootLayout.tsx
  • examples/react-router/journey-invoke/shell/src/main.tsx
  • examples/react-router/journey-invoke/shell/src/persistence.ts
  • examples/react-router/journey-invoke/shell/tsconfig.json
  • examples/react-router/journey-invoke/shell/vite.config.ts
  • examples/react-router/journey-invoke/tsconfig.base.json
  • examples/tanstack-router/journey-invoke/README.md
  • examples/tanstack-router/journey-invoke/app-shared/package.json
  • examples/tanstack-router/journey-invoke/app-shared/src/index.ts
  • examples/tanstack-router/journey-invoke/app-shared/tsconfig.json
  • examples/tanstack-router/journey-invoke/journeys/checkout/package.json
  • examples/tanstack-router/journey-invoke/journeys/checkout/src/checkout.ts
  • examples/tanstack-router/journey-invoke/journeys/checkout/src/index.ts
  • examples/tanstack-router/journey-invoke/journeys/checkout/tsconfig.json
  • examples/tanstack-router/journey-invoke/journeys/verify-identity/package.json
  • examples/tanstack-router/journey-invoke/journeys/verify-identity/src/index.ts
  • examples/tanstack-router/journey-invoke/journeys/verify-identity/src/verify-identity.ts
  • examples/tanstack-router/journey-invoke/journeys/verify-identity/tsconfig.json
  • examples/tanstack-router/journey-invoke/modules/age-verify/package.json
  • examples/tanstack-router/journey-invoke/modules/age-verify/src/Verify.tsx
  • examples/tanstack-router/journey-invoke/modules/age-verify/src/exits.ts
  • examples/tanstack-router/journey-invoke/modules/age-verify/src/index.ts
  • examples/tanstack-router/journey-invoke/modules/age-verify/tsconfig.json
  • examples/tanstack-router/journey-invoke/modules/checkout-confirm/package.json
  • examples/tanstack-router/journey-invoke/modules/checkout-confirm/src/Confirm.tsx
  • examples/tanstack-router/journey-invoke/modules/checkout-confirm/src/exits.ts
  • examples/tanstack-router/journey-invoke/modules/checkout-confirm/src/index.ts
  • examples/tanstack-router/journey-invoke/modules/checkout-confirm/tsconfig.json
  • examples/tanstack-router/journey-invoke/modules/checkout-review/package.json
  • examples/tanstack-router/journey-invoke/modules/checkout-review/src/Review.tsx
  • examples/tanstack-router/journey-invoke/modules/checkout-review/src/exits.ts
  • examples/tanstack-router/journey-invoke/modules/checkout-review/src/index.ts
  • examples/tanstack-router/journey-invoke/modules/checkout-review/tsconfig.json
  • examples/tanstack-router/journey-invoke/shell/index.html
  • examples/tanstack-router/journey-invoke/shell/package.json
  • examples/tanstack-router/journey-invoke/shell/src/components/Home.tsx
  • examples/tanstack-router/journey-invoke/shell/src/main.tsx
  • examples/tanstack-router/journey-invoke/shell/src/persistence.ts
  • examples/tanstack-router/journey-invoke/shell/tsconfig.json
  • examples/tanstack-router/journey-invoke/shell/vite.config.ts
  • examples/tanstack-router/journey-invoke/tsconfig.base.json
  • packages/core/src/index.ts
  • packages/core/src/journey-contracts.ts
  • packages/journeys/README.md
  • packages/journeys/src/define-journey.ts
  • packages/journeys/src/handle.ts
  • packages/journeys/src/index.ts
  • packages/journeys/src/invoke.test.ts
  • packages/journeys/src/outlet-invoke.test.tsx
  • packages/journeys/src/outlet.tsx
  • packages/journeys/src/plugin.tsx
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/simulate-journey-invoke.test.ts
  • packages/journeys/src/simulate-journey.ts
  • packages/journeys/src/types.ts
  • packages/journeys/src/validation.ts

Comment thread examples/react-router/journey-invoke/shell/src/persistence.ts Outdated
Comment thread packages/journeys/README.md Outdated
kibertoad and others added 2 commits April 30, 2026 17:53
Fix the comment in the RR shell's verify-identity persistence to match
the implementation (it was promising a parent-orderId-prefixed key but
the keyFor only included customerId), and disable the decline button
on both the RR and TSR age-verify modules while a verification timeout
is pending — without it, a user could click "decline" during the 250ms
window and emit two exits in a row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Composing journeys via invoke creates a directed call graph: A invokes
B, which may invoke C, possibly back into A. Without guards a cycle
becomes either an infinite chain that exhausts memory or a same-step
bouncing resume that pegs the CPU. This commit lands four layered
guards plus an opt-in static check at registration time.

* invokes[] declaration on JourneyDefinition — closed set of child
  handles a journey may dispatch to. Strongly recommended; enables
  the static cycle check and the runtime invoke-undeclared-child
  guard for that journey.

* validateJourneyGraph (DFS over the invokes graph, deduping cycles
  via canonical-rotation form) — runs as part of validateJourneyContracts
  and rejects registrations that close a cycle, listing the path. Also
  exported for shells composing registrations across plugin boundaries.

* invoke-cycle runtime guard — walks the active parent chain on every
  invoke; if the target id is already on the stack the parent aborts
  with the chain in the terminal payload.

* invoke-stack-overflow runtime guard — depth-cap with default 16,
  resolved as min across non-undefined maxCallStackDepth on every
  registration in the chain. Strictest journey wins; setting it to 1
  blocks invoke from the journey outright.

* invoke-undeclared-child runtime guard — fires only when the parent
  declared invokes[]; rejects dynamic dispatch to a handle missing
  from the closed set.

* resume-bounce-limit runtime guard — caps consecutive same-step
  resumes that return { invoke } instead of advancing. Counter resets
  on any genuine step change and is persisted on the parent's blob
  (resumeBouncesAtStep) so a reload-bounce sequence cannot reset the
  budget through storage. Per-parent only.

All four runtime aborts surface through the existing onError channel
(phase: "invoke" or "resume") and produce structured terminalPayload
shapes that are safe to log. Tests cover each guard, mixed
configurations, the persistence round-trip, and the omitted-invokes[]
opt-out path.

Also fixes the broken anchor links in the API reference table flagged
by CodeRabbit (Pattern - … headings slug to three dashes, not two).

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: 2

Caution

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

⚠️ Outside diff range comments (1)
packages/journeys/src/runtime.ts (1)

1335-1359: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

goBack() bypasses the paused-parent guard and carries the old bounce counter onto a new step.

This path should enforce the same activeChildId pause semantics as dispatchExit(). Right now a stale goBack closure can rewind the parent while a child is still in flight. It also never clears resumeBouncesAtStep, so a later serialize/hydrate cycle can restamp an old bounce budget onto the new step and abort incorrectly.

Suggested fix
   function dispatchGoBack(record: InstanceRecord, reg: RegisteredJourney, stepToken: number) {
     if (record.status !== "active") return;
+    if (record.activeChildId) return;
     if (record.stepToken !== stepToken) return;
     if (record.history.length === 0) return;

     const step = record.step;
     if (!step) return;
@@
     record.hasRollbackSnapshot = record.rollbackSnapshots.some((s) => s !== undefined);
     record.step = previousStep;
+    record.resumeBouncesAtStep = null;
     record.stepToken += 1;
     record.updatedAt = nowIso();
     record.cachedCallbacks = null;
🤖 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 1335 - 1359, dispatchGoBack
currently allows rewinding a parent while a child is paused and leaves resume
bounce state intact; modify dispatchGoBack to mirror the paused-parent guard
used in dispatchExit by returning early if record.activeChildId is set (or
otherwise if the journey is paused by a child), and after performing the
rollback set record.resumeBouncesAtStep = undefined and reset
record.resumeBounces = 0 (or the equivalent fields used to track bounce budget)
so the old bounce budget isn't carried onto the new step; keep the existing
updates (step, stepToken, updatedAt, cachedCallbacks, persistence schedule,
notify) but add the active-child guard near the top and clear the resume bounce
fields right after applying the snapshot/setting the new step.
🤖 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 856-885: runtime.start can return an existing child; before
reassigning links, check if the returned child (from instances.get(childId))
already has child.parent set to a different parent id and if so treat it as "in
use": call applyTransitionLocal to abort the invoke (similar to the existing
"invoke-start-no-record" handling), optionally log via debug and call
fireOnError(parentReg, parent, /* err or message */, parentStep, "invoke"), and
then return false — otherwise proceed to set parent.activeChildId, child.parent
and childToParent as currently implemented.
- Around line 939-947: The code mutates linkage fields (child.parent = null and
parent.activeChildId = null) outside the standard transition/notify flow, which
leaves public snapshots returned by buildInstance()/getInstance() stale; change
the logic so clearance of invoke links is applied through the existing
transition/notify mechanism (or invoke the same notification step) instead of
direct mutation: move the parent/child unlinking into the same transition
function that emits updates (or call the instance update/notify function after
mutating) so that listeners get a new snapshot whenever child.parent or
parent.activeChildId is cleared; reference the child.parent and
parent.activeChildId mutations, the buildInstance()/getInstance() snapshot path,
and the transition/notify routine when implementing the fix.

---

Outside diff comments:
In `@packages/journeys/src/runtime.ts`:
- Around line 1335-1359: dispatchGoBack currently allows rewinding a parent
while a child is paused and leaves resume bounce state intact; modify
dispatchGoBack to mirror the paused-parent guard used in dispatchExit by
returning early if record.activeChildId is set (or otherwise if the journey is
paused by a child), and after performing the rollback set
record.resumeBouncesAtStep = undefined and reset record.resumeBounces = 0 (or
the equivalent fields used to track bounce budget) so the old bounce budget
isn't carried onto the new step; keep the existing updates (step, stepToken,
updatedAt, cachedCallbacks, persistence schedule, notify) but add the
active-child guard near the top and clear the resume bounce fields right after
applying the snapshot/setting the new step.
🪄 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: d395d700-60b5-4c14-86cc-ec7ad4e1048e

📥 Commits

Reviewing files that changed from the base of the PR and between c013f81 and 45d2c50.

📒 Files selected for processing (11)
  • examples/react-router/journey-invoke/modules/age-verify/src/Verify.tsx
  • examples/react-router/journey-invoke/shell/src/persistence.ts
  • examples/tanstack-router/journey-invoke/modules/age-verify/src/Verify.tsx
  • packages/core/src/index.ts
  • packages/core/src/journey-contracts.ts
  • packages/journeys/README.md
  • packages/journeys/src/index.ts
  • packages/journeys/src/invoke-cycle-safety.test.ts
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/types.ts
  • packages/journeys/src/validation.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/core/src/index.ts
  • examples/react-router/journey-invoke/shell/src/persistence.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/react-router/journey-invoke/modules/age-verify/src/Verify.tsx

Comment thread packages/journeys/src/runtime.ts
Comment thread packages/journeys/src/runtime.ts Outdated
kibertoad and others added 4 commits April 30, 2026 18:34
…broader test coverage

Addresses the post-implementation review of the cycle/recursion safety
work:

* `chain` payload on `invoke-cycle` aborts now matches the printed
  warning — cycle portion only, pre-cycle prefix dropped. Telemetry
  consumers parsing the payload and humans reading the log no longer
  see different chains.

* `JourneySystemAbortReason` discriminated union and `isJourneySystemAbort`
  predicate are exported from `@modular-react/core` (re-exported through
  `@modular-react/journeys`). Authors and shells can narrow an `unknown`
  abort payload to the runtime-emitted shapes via a single type guard;
  author-supplied aborts whose `reason` field collides with a system
  code are correctly excluded by checking against the closed code set.

* `resolveMaxCallStackDepth` no longer re-looks up the parent's
  registration — call sites pass it directly.

* Documentation: README explicitly states that the cycle check is part
  of the standard `validateJourneyContracts` pipeline; `0` / negative /
  non-finite values for both new options are documented as "no opinion"
  on the JSDoc and in the README; the abort-reason API table gains
  `isJourneySystemAbort` with a worked narrowing example.

* New tests:
  - `invokes: []` (empty set) rejects every dispatch.
  - 3-level runtime cycle (A→B→C closing back to A) — the basic
    case the original suite did not cover.
  - 4-level runtime cycle where the cycle starts mid-chain (A→B→C→D
    re-invoking B) — exercises the prefix-drop behavior in the
    `chain` payload.
  - Bounce-limit firing at the leaf of a multi-level chain bubbles
    the abort up via the standard resume cascade; outer's terminal
    payload reaches back to the original `resume-bounce-limit` reason.
  - Depth cap can be lowered by the *child* journey, not only the
    parent — verifies the documented "any journey in the chain can
    lower" semantic.
  - `0` / negative `maxCallStackDepth` is treated as "no opinion".
  - `isJourneySystemAbort` narrows runtime-emitted payloads, rejects
    author-supplied payloads with colliding-but-out-of-set `reason`
    fields, and rejects null / undefined / non-objects.

* The previously-borderline "ignores edges to journeys not in the
  registration set" test is paired with a runtime assertion proving
  the missing-id failure mode (`invoke-unknown-journey`) still fires
  even when `invokes` declared the missing handle.

203 tests pass (up from 195). Typecheck clean across the journey
package graph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the lint check on the PR — `oxfmt --check` was failing on the five
files touched by the cycle-safety follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups on the cycle-safety PR:

1. Reject child theft on idempotent start. `runtime.start` returns the
   in-flight child id when a second invoker resolves to the same
   persistence key. The original code overwrote `child.parent`
   unconditionally, stealing the child from the first parent and
   stranding it behind a stale `activeChildId`. The second invoke now
   aborts with a typed `invoke-child-already-linked` reason carrying
   `childInstanceId` + `existingParentId`, and the original parent's
   link is preserved.

2. Refresh public snapshots on link clearance. `applyResumeIfChild`
   mutated `child.parent` and `parent.activeChildId` outside the normal
   transition/notify flow. Subscribers had already cached the pre-clear
   snapshot, so `getInstance(id)` kept returning the stale linkage.
   Notify the child after clearing its parent link, and notify the
   parent on the early-return arms (parent terminal, missing reg/step)
   that bail before `applyTransition` would have flushed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- runtime.ts: dispatchGoBack now mirrors dispatchExit's paused-parent
  guard (drops when activeChildId is set) and clears
  resumeBouncesAtStep on rollback so a serialize/hydrate cycle can't
  restamp an old per-step bounce budget onto the new step.
- outlet-invoke.test.tsx: add a 1s timeout fallback to the
  abandon-on-unmount Promise so a missing microtask fails fast
  instead of hanging the suite.
- examples (RR + TSR) age-verify Verify.tsx: track the verification
  setTimeout in a ref and clear it on unmount.
- examples (RR + TSR) verify-identity.ts: drop the unused
  \`attempts\` field from VerifyIdentityState/initialState.

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

@coderabbitai — addressed the remaining items from the two review summaries in 282c8f7.

Fixed

  • dispatchGoBack paused-parent + bounce reset (packages/journeys/src/runtime.ts, outside-diff comment from the second review): added the if (record.activeChildId) return; guard mirroring dispatchExit, and cleared record.resumeBouncesAtStep = null after the rollback so a serialize/hydrate cycle can't restamp an old per-step bounce budget onto the new step.
  • Abandon-on-unmount test timeout (packages/journeys/src/outlet-invoke.test.tsx): wrapped the queueMicrotask Promise with a 1s setTimeout reject fallback, and put the assertions in a try/catch so a thrown expect rejects instead of swallowing.
  • Timeout cleanup on unmount (RR + TSR modules/age-verify/src/Verify.tsx): tracked the verification setTimeout id in a ref and cleared it from a useEffect cleanup. Applied to both the RR and TSR mirrors so they stay in sync.
  • Unused attempts field (RR + TSR journeys/verify-identity/src/verify-identity.ts): the journey never increments or reads it — dropped from VerifyIdentityState and initialState. No retry logic was planned.

Skipped (intentionally)

  • Inline new Date().toISOString() in checkout.ts: the demo's whole point is that the parent's verifiedAt reflects when the skip-token was minted; threading a clock dependency through a single example file would be more noise than the 💤 nitpick is worth. The runtime already exposes deterministic clocks for tests.
  • fresh.join(">") cache key collision in outlet.tsx: instance ids in this codebase are runtime-generated and never contain >. Adding a delimiter-safe encoding for a hypothetical custom ID scheme that doesn't exist felt like premature defensiveness.

Tests + types still green (pnpm --filter @modular-react/journeys test, 204 passed).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

Tip

For best results, initiate chat on the files or code changes.

@kibertoad — thanks for the thorough follow-up! All four fixes look solid:

  • The dispatchGoBack guard + bounce-counter reset is the right symmetry with dispatchExit — prevents both the stale-budget-restamp problem and the paused-parent-advances-on-back edge case.
  • Wrapping the queueMicrotask promise with a setTimeout reject fallback is the correct pattern for async test reliability in Vitest.
  • Tracking the setTimeout id in a ref and clearing it on unmount is the proper React lifecycle fix — good that it was applied consistently to both the RR and TSR mirrors.
  • Dropping the never-incremented attempts field keeps the state shape honest and removes a misleading hint of retry logic that wasn't there.

The rationale for the two intentional skips is also sound — the clock in the example communicates real intent to readers, and the > collision is genuinely theoretical given runtime-generated opaque IDs.

204 tests green is a great sign. The PR looks good to go! 🎉


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@kibertoad kibertoad merged commit 0069937 into main Apr 30, 2026
10 checks passed
@kibertoad kibertoad deleted the feat/journey-invoke branch April 30, 2026 15:58
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.

1 participant