feat(journeys,core): add runtime.goForward + JourneyInstance.future redo stack#40
Conversation
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughAdds a transient in-memory redo stack and a runtime API ChangesRedo (goForward) Navigation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/journeys/src/simulate-journey.ts`:
- Around line 310-312: The simulator's goForward method calls
harness.goForward(instanceId) which throws when redo is unavailable, but the
simulator interface (and runtime.goForward) should no-op instead; modify
goForward to catch or check for redo availability and return without throwing
when no redo exists (e.g., wrap the harness.goForward(instanceId) call in a
conditional or try/catch and silently return when the harness indicates no-op),
preserving existing behavior when redo is available and still delegating to
harness.goForward for the actual redo.
🪄 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: 6194d04f-edc3-48ce-a62b-99acca48bef8
📒 Files selected for processing (7)
packages/core/src/journey-contracts.tspackages/core/src/types.tspackages/journeys/src/outlet.tsxpackages/journeys/src/runtime-go-forward.test.tspackages/journeys/src/runtime.tspackages/journeys/src/simulate-journey.tspackages/journeys/src/testing.ts
8c882d3 to
2c98804
Compare
kibertoad
left a comment
There was a problem hiding this comment.
Code review — runtime.goForward + JourneyInstance.future
Verified locally: pnpm install && pnpm -w build && pnpm --filter @modular-react/journeys test → 413/413 passing, no type errors.
The change is clean, symmetric with dispatchGoBack, and the test suite is one of the most thorough I've seen for a feature this size. State/step/history/rollbackSnapshots/hasRollbackSnapshot are all restored consistently — I traced rollback-mode and preserve-state flows step by step and the invariants hold (the captured state is the post-transition value, step.input was already built against that state, and the popped snapshot lines back up with the right history index on redo).
Nothing blocking. A few things worth considering:
1. Asymmetric simulator behavior — goBack throws, goForward no-ops
packages/journeys/src/simulate-journey.ts:307-316
simulator.goBack() delegates to harness.goBack() (throws on empty history). simulator.goForward() deliberately bypasses the harness wrapper and calls runtime.goForward() directly so it's a silent no-op. The inline comment explains the intent, but the public-facing JSDoc on the interface (goForward(): void) doesn't surface this asymmetry to authors writing simulator-driven tests — a misordered goBack in a test will fail loudly, a misordered goForward will silently mis-assert downstream. Consider either:
- Routing
simulator.goForwardthrough the harness too (consistent loud failure), or - Adding a sentence to the JSDoc making the no-op-vs-throw asymmetry explicit so test authors understand they need to assert state, not rely on the throw.
2. TransitionEvent can't distinguish back/forward from forward navigation
packages/journeys/src/runtime.ts:1862 (and existing dispatchGoBack at 1827)
Both fire fireOnTransition(..., null) with default kind: "step". Telemetry consumers can only infer back/forward indirectly (exit === null + comparing to against history). Worth considering extending TransitionEvent["kind"] from "step" | "invoke" | "resume" to also include "back" | "forward" so analytics rules can filter cleanly. Out of scope for this PR — but if you ever land it, this is the natural place. (Filing the existing gap on goBack here too since the asymmetry would otherwise compound.)
3. hasRollbackSnapshot O(n) rescan on goForward
packages/journeys/src/runtime.ts:1851
record.rollbackSnapshots.push(next.snapshot);
record.hasRollbackSnapshot = record.rollbackSnapshots.some((s) => s !== undefined);Pushing a single entry can only ever flip the flag to true, never to false. The scan is unnecessary on the goForward path:
record.rollbackSnapshots.push(next.snapshot);
if (next.snapshot !== undefined) record.hasRollbackSnapshot = true;Minor — only matters under deep rollback stacks — but trivially correct.
4. activeChildId guard in dispatchGoForward is unreachable
packages/journeys/src/runtime.ts:1837 + test at runtime-go-forward.test.ts:280-307
The test author already notes this: goBack is itself a no-op while a child is in flight, so the future stack cannot be non-empty while activeChildId is set (and exit-driven invokes don't populate future). The guard is defensive — good — but the corresponding test really just exercises the future.length === 0 guard. If you want real coverage of the activeChildId branch you'd have to reach into the record directly. Fine to leave; just calling out that the test claim ("is a no-op while a child journey is in flight") is slightly stronger than what's actually being checked.
5. Stray empty // comment line
packages/journeys/src/runtime.ts:1287
Looks like a diff artifact — the trailing // on its own line above the if ("next" in result || …) block adds nothing. Easy cleanup.
6. Missing low-cost test cases
goForwardon an instance instatus: "loading"— thestatus !== "active"guard would short-circuit, but it's untested directly (terminal covers the same branch).- No explicit assertion that the persistence adapter sees the post-redo blob. The "restores history + rollbackSnapshots" test implicitly verifies record state, but a mock-adapter assertion (the same pattern your other persistence tests use) would lock the contract.
Neither is required, just nice-to-have given the otherwise-exhaustive suite.
What's good
futurecorrectly excluded fromserialize(), reset on hydrate and on recycle inruntime.start. Transient semantics match the JSDoc.cachedSnapshotinvalidation vianotify→revision++is correct;cachedCallbackscleared inside both dispatch paths.applyTransitionclearsfutureonnext | complete | abortonly —invokecorrectly leaves it intact, matching the browser model.- Decision to not re-run the transition handler on redo is the right call (side-effect doubling would be a footgun) and documented prominently.
- Decision to not re-run
buildInputon redo (trusting capturedstep.input) is internally consistent becausestep.inputwas originally built against the same state being restored. The asymmetry withdispatchGoBack(which does re-runbuildInput) is explained in the inline comment — worth keeping that comment. - Future stack is bounded by history depth at
goBacktime, somaxHistoryis never violated bygoForwardpushing back into history.
LGTM — happy to see this ship once the simulator asymmetry doc nit (#1) and the stray comment (#5) are addressed.
Generated by Claude Code
2c98804 to
9abc542
Compare
|
Thanks for the thorough review — addressed in 9abc542:
Not addressing #2 (extending Validated: 415/415 tests passing, typecheck + build clean. |
…edo stack
Inverse of `runtime.goBack`. `goBack` was destructive — it popped the
current step + rollback snapshot off the instance record and threw the
data away, so a shell wiring browser Forward to journey navigation had
no way to redo the rewind. The URL would drift ahead of the runtime
with no recovery path beyond re-firing the original exit (which
requires the step component's form data + side effects, neither of
which the shell has).
`InstanceRecord` now carries a `future` stack of `{ step, state,
snapshot }` entries. `dispatchGoBack` pushes the step it rewinds from
(plus the state captured at that moment and the rollback snapshot it
just popped) onto `future` before mutating; the new `dispatchGoForward`
inverts the operation — pops the top, pushes the current step back
onto `history`, restores `state` verbatim, re-attaches the snapshot to
`rollbackSnapshots` so a subsequent `goBack` rewinds correctly.
`goForward` does NOT re-run the original transition handler. The
future entry holds the post-transition state directly; redo restores
it without invoking side effects (API calls, telemetry, persistence
in the transition body would double-fire on every Forward press). If
a shell needs the business logic to re-execute, it fires the exit
itself instead of calling this method.
Forward-stack invalidation mirrors the browser. `applyTransition`
clears `future` on any next / complete / abort arm — a fresh
navigation supersedes whatever the user could have redone. `invoke`
leaves it intact (parent step doesn't advance while a child runs).
`JourneyInstance.future` exposes the stack as a read-only
`readonly JourneyStep[]` so shells can gate a Forward button on
`instance.future.length > 0` and compare `future[future.length - 1]`
against an incoming URL before calling `runtime.goForward`. The full
`FutureEntry` (with state + snapshot) stays internal — consumers only
need to know what step a redo would land on.
The stack is transient: not persisted, reset on hydrate, reset when a
record is recycled by `startFresh`. Reload starts with an empty
forward stack, same as opening a new browser tab.
Test coverage: eight scenarios in `runtime-go-forward.test.ts` —
single goBack→goForward, state restoration, multi-step rewind+redo,
no-op when stack empty, no-op for unknown ids, no-op on terminal
instance, future invalidation on fresh exit, history symmetry so
subsequent goBack still works after a redo. Existing 399 tests stay
green.
9abc542 to
7903c10
Compare
Summary
Adds a redo / forward primitive to the journey runtime:
runtime.goForward(id)andJourneyInstance.future. Inverse ofgoBack, so shells can wire browser Forward (or an in-page redo button) to actual journey navigation instead of having the URL drift ahead of the runtime.Motivation
goBackwas destructive — it poppedhistory+rollbackSnapshotsand threw the data away. A shell wiring browser Forward had no way to redo the rewind because the original exit name + output had flowed from the step component into the runtime, not back out. Cases like this:…left the URL out of sync with the rendered step until the user re-submitted the form. Browser semantics expect Forward to replay the rewind; the runtime now supports that.
What changed
InstanceRecord.future: a stack of{ step, state, snapshot }entries.dispatchGoBackpushes the step it rewinds from before mutating;dispatchGoForwardinverts the operation — pops the top, pushes the current step back ontohistory, restoresstateverbatim, re-attaches the snapshot torollbackSnapshotsso a subsequentgoBackrewinds correctly.goForwarddoes NOT re-run the original transition handler. The future entry holds the post-transition state directly; redo restores it without invoking side effects (API calls, telemetry, persistence in the transition body would double-fire on every Forward press). If a shell needs business logic to re-execute, it fires the exit itself instead.Forward-stack invalidation mirrors the browser:
applyTransitionclearsfutureon any next / complete / abort arm.invokeleaves it intact (parent step doesn't advance while a child runs).Public surface:
runtime.goForward(id): voidonJourneyRuntimeinstance.future: readonly JourneyStep[]onJourneyInstance(lets shells gate a Forward button oninstance.future.length > 0)ModuleEntryProps.goForward?: () => void— closure form for steps that surface an in-page redo control (most shells will wire Forward at the shell level instead)simulator.goForward()andharness.goForward(id)for authoring + testingJourneyOutletpassesgoForwardthrough alongsidegoBackTransient: not persisted, reset on hydrate, reset when a record is recycled. Reload starts empty, same as opening a new browser tab.
Test plan
pnpm test— 411 tests passing (399 prior + 12 newruntime-go-forwardtests covering single goBack→goForward, state restoration, multi-step rewind+redo, no-op when empty / unknown / terminal, future invalidation on fresh exit, history symmetry,instance.futuresnapshot exposure, active-child no-op, rollback-mode redo discarding intermediate edits, subscriber notify)pnpm typecheckcleanpnpm buildclean across all packages<JourneyHost>callinguseJourneyUrlwired toruntime.goForward) — browser Back/Forward round-trips work for a real wizard flowNotes
goBackkeeps its destructive semantics for callers that don't care about redo (justrecord.future.push(...)is added before the existing pop).bindStepCallbacksadds acanGoForwardgate based onrecord.future.length > 0— the new closure isundefinedwhen there's no redo target, matching thegoBackpattern.JourneyRuntimeInternals.__bindStepCallbacksreturn type now includesgoForward?: () => void(test harness uses this).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests