feat(plugin-stack-persistence): navigation context persistence plugin (FEP-2546)#727
feat(plugin-stack-persistence): navigation context persistence plugin (FEP-2546)#727ENvironmentSet wants to merge 12 commits into
Conversation
…-surface stub (FEP-2546) Scaffold @stackflow/plugin-stack-persistence and pin its confirmed public contract as an executable harness ahead of the implementation: - Public surface stub: stackPersistencePlugin, StackSnapshotStorage/Record/ Strategy, StackPersistenceLoadError/SaveError with complete signatures; the plugin body throws until implemented, so the runtime suites fail red at runtime while everything compiles. - Runtime suite (vitest, node): restore-on-start, reuse strategy, load error policies, idle auto-save, save error handling, plugin/analytics observation, SSR neutrality, and responsibility boundaries — driven only through the package entrypoint and @stackflow/core public API, with deterministic fake-timer clocks and per-call deferred storage doubles. Unhandled-rejection contracts run in isolated node subprocesses. - Type suite: strict tsc fixtures for the public names, record/storage shape, metadata inference, strategy and error contracts, plus a control run that strips every @ts-expect-error and asserts the compile failures land exactly on the flagged lines. - Package-boundary suite: manifest/dist audit and a PnP subprocess that requires the built package by name in a React-free, browser-global-free process and connects it to a core store. The options type keeps the strategy branch as the sole Metadata inference site (NoInfer-equivalent alias) so mismatched storage/strategy metadata fails compilation instead of widening to a union. Current state: type suite 14/14 green, typecheck clean; runtime suite 68/68 red and boundary connection red via the unimplemented stub, which is the intended baseline for the implementation to turn green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stack fields (FEP-2546) Three harness-layer corrections, none weakening the contracts under test: - Activity order: core sorts Stack.activities by activity id, so raw array order carries no navigation meaning. Assertions now compare navigation depth through zIndex (logicalStackView sorts by it and carries zIndex and enteredBy; multi-activity assertions use the navigation-order helpers). While correcting this, the round-trip case also pins the real params semantics: an activity's params are its current step's params, and the entry params stay distinguishable on the entry step and enteredBy event. - Paused fixture: current core replays only queued events on resume — an empty pause resumes to an unchanged stack. The paused snapshot now carries a navigation event recorded after Paused, so the restored stack has queued work and pause → resume → idle → save is observable: the queued entry stays unapplied while paused (no save), and resume applies it before the single unpaused-idle save. - Reuse call order: the load-phase exclusivity case now shares its call log with the strategy spy as intended; the log previously recorded only storage calls, failing even a correct implementation. Verified both ways: 68/68 pass against the implementation, and 68/68 fail via the unimplemented-stub throw when the plugin body is swapped back to the pre-implementation stub. Type suite 14/14, boundary suite 4/4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… its own fixture (FEP-2546) The queued-pause fixture introduced for the resume scenario had replaced the terminal-Paused snapshot everywhere, so no test fed the plugin a record whose last event is Paused — an implementation rejecting exactly those records passed the whole suite while violating the "no plugin-added Idle/tail-event load precondition" contract. Split the two semantic cases: pausedSnapshot() is terminal-Paused again and drives the envelope/paused pass-through case (with a fixture self-check pinning the Paused tail), while the queued variant lives on as resumablePausedSnapshot() for the pause → resume → idle → save case. Verified with cleared transform caches: 68/68 green on the implementation, 68/68 red on the pre-implementation stub, and a terminal-Paused-rejection mutant now fails exactly the pass-through test (67/68). Type suite 14/14, boundary suite 4/4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The run plan is a process document for the implementation run, not a shipped artifact. Repo discipline keeps process docs out of the tree (rationale lives in code comments and commits, transient process notes in the PR / issue tracker), so it should not land on main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: a9dac73 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds ChangesStack Persistence Plugin
Estimated code review effort: 5 (Critical) | ~120 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ 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 |
commit: |
Deploying stackflow-demo with
|
| Latest commit: |
a9dac73
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8ddf1325.stackflow-demo.pages.dev |
| Branch Preview URL: | https://feature-fep-2546.stackflow-demo.pages.dev |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
stackflow-docs | a9dac73 | Commit Preview URL | Jul 15 2026, 06:41 AM |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
extensions/plugin-stack-persistence/src/__fixtures__/isolatedProcess.ts (1)
10-11: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueShared static
OUT_DIR+ whole-directory cleanup is a latent race if invoked concurrently.
outfileis keyed only bychildFileNameunder a fixedOUT_DIR, andfinallyremoves the entire directory recursively. Currently the two call sites run sequentially in one spec file, so it's inert, but any futuretest.concurrent/parallel invocation ofrunIsolatedChild(same or different fixtures) could have one call'srmSyncdelete another's in-flight build/output. Consider a per-invocation unique subdirectory (e.g.fs.mkdtempSync) instead of a shared static path.Also applies to: 28-31, 83-85
🤖 Prompt for 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. In `@extensions/plugin-stack-persistence/src/__fixtures__/isolatedProcess.ts` around lines 10 - 11, Update runIsolatedChild to create a unique temporary output directory for each invocation, using the existing filesystem utilities such as fs.mkdtempSync under PACKAGE_ROOT or the harness output root. Build outfile paths from that per-invocation directory and keep cleanup scoped to only the directory created by that invocation, removing reliance on the shared static OUT_DIR.
🤖 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 `@extensions/plugin-stack-persistence/package.json`:
- Line 30: Update the package’s dev script to start the JavaScript build
watcher, declaration build watcher, and test watcher concurrently instead of
chaining them with &&. Ensure the test command invokes Vitest’s watch mode
directly rather than appending --watch to a vitest run command, using the
existing script symbols where possible.
In `@extensions/plugin-stack-persistence/src/__fixtures__/assertions.ts`:
- Around line 110-139: Update installBrowserGlobalTraps to validate every name
in BANNED_BROWSER_GLOBALS for pre-existing globalThis properties in a complete
first pass, throwing before any defineProperty calls when validation fails; then
perform the existing trap-installation pass and return the uninstall handler.
In `@extensions/plugin-stack-persistence/src/__fixtures__/isolatedProcess.ts`:
- Around line 52-60: Update the env configuration in the spawnSync call to
preserve any inherited process.env.NODE_OPTIONS while appending the required
.pnp.cjs --require option. Keep the existing PnP requirement and ensure the
resulting value remains valid when NODE_OPTIONS is unset.
In `@extensions/plugin-stack-persistence/src/preservationScope.spec.ts`:
- Around line 194-197: Update the assertion in preservationScope.spec.ts to
locate the Home activity by its contractual identity rather than assuming it is
at activities[0]. Preserve the existing checks for the activity name and
restored params, while avoiding reliance on raw activities-array ordering.
In `@extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts`:
- Around line 136-140: Update the save flow around storage.save in the stack
persistence plugin so synchronous exceptions are caught and routed through the
same save-error policy as rejected promises, producing StackPersistenceSaveError
without synchronously unwinding initialization or navigation. Preserve the
existing asynchronous rejection handling and policy behavior for both failure
paths.
---
Nitpick comments:
In `@extensions/plugin-stack-persistence/src/__fixtures__/isolatedProcess.ts`:
- Around line 10-11: Update runIsolatedChild to create a unique temporary output
directory for each invocation, using the existing filesystem utilities such as
fs.mkdtempSync under PACKAGE_ROOT or the harness output root. Build outfile
paths from that per-invocation directory and keep cleanup scoped to only the
directory created by that invocation, removing reliance on the shared static
OUT_DIR.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 264afffb-23fa-4d23-847f-81a406d255a1
⛔ Files ignored due to path filters (55)
.yarn/cache/@esbuild-darwin-arm64-npm-0.28.1-b71e3b7721-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-darwin-x64-npm-0.28.1-4318a1bdc6-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-linux-arm64-npm-0.28.1-33ca543ef9-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-linux-ia32-npm-0.28.1-1ab1ff7c72-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@esbuild-linux-x64-npm-0.28.1-f7b93afd3d-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@jridgewell-sourcemap-codec-npm-1.5.5-5189d9fc79-5d9d207b46.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@rollup-rollup-darwin-arm64-npm-4.62.2-23ccb466e4-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@rollup-rollup-darwin-x64-npm-4.62.2-02ad04466d-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@rollup-rollup-linux-arm64-gnu-npm-4.62.2-22a54d9321-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@rollup-rollup-linux-arm64-musl-npm-4.62.2-123c27c34b-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@rollup-rollup-linux-x64-gnu-npm-4.62.2-1774ccb0dc-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@rollup-rollup-linux-x64-musl-npm-4.62.2-bf1e2b555e-10.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@types-chai-npm-5.2.3-5f61dbddda-e79947307d.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@types-deep-eql-npm-4.0.2-e6bc68cc92-249a27b0bb.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@types-estree-npm-1.0.9-63428f58ff-16aabfa703.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@vitest-expect-npm-3.2.7-287b64f32d-2621cf1e90.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@vitest-mocker-npm-3.2.7-6daa3361b9-fccb77857a.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@vitest-pretty-format-npm-3.2.7-fb6e627f7c-81286f667f.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@vitest-runner-npm-3.2.7-101792a6a3-ff7c14726c.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@vitest-snapshot-npm-3.2.7-59081b8193-8d1bc49c1d.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@vitest-spy-npm-3.2.7-407a36bb46-a125bff0ec.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/@vitest-utils-npm-3.2.7-c519404dea-d17fda64b6.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/assertion-error-npm-2.0.1-8169d136f2-a0789dd882.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/chai-npm-5.3.3-ebef71cdac-0d0ef63106.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/check-error-npm-2.1.3-e17bcf3ed8-f1868d3db6.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/debug-npm-4.4.3-0105c6123a-9ada3434ea.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/deep-eql-npm-5.0.2-3bce58289f-a529b81e2e.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/es-module-lexer-npm-1.7.0-456b47a08a-b6f3e576a3.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/esbuild-npm-0.28.1-23da8d1b66-aaa4a92264.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/estree-walker-npm-3.0.3-0372979673-a65728d572.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/expect-type-npm-1.4.0-aeee227f9a-bad91f4b7e.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/fdir-npm-6.5.0-8814a0dec7-14ca1c9f0a.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/js-tokens-npm-9.0.1-3ed793c0c1-3288ba73bb.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/loupe-npm-3.2.1-a8f491982f-a4d78ec758.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/magic-string-npm-0.30.21-9a226cb21e-57d5691f41.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/nanoid-npm-3.3.15-2658de05f8-13c74a5208.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/pathval-npm-2.0.1-7fb9ae82ba-f5e8b82f6b.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/picomatch-npm-4.0.5-bb8e0de0f7-8bad770af9.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/postcss-npm-8.5.16-086b209555-b39408900a.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/rollup-npm-4.62.2-b7a75098ad-40a8551c1b.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/siginfo-npm-2.0.0-9bbac931f8-e93ff66c65.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/stackback-npm-0.0.2-73273dc92e-2d4dc4e64e.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/strip-literal-npm-3.1.0-b0340463b3-6eb00906a1.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tinybench-npm-2.9.0-2861a048db-cfa1e1418e.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tinyexec-npm-0.3.2-381b1e349c-b9d5fed316.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tinyglobby-npm-0.2.17-f2c3ddb917-f85e8a217d.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tinypool-npm-1.1.1-6772421283-0d54139e9d.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tinyrainbow-npm-2.0.0-b4ba575b93-94d4e16246.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/tinyspy-npm-4.0.4-94a3f61e82-858a99e3de.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/vite-node-npm-3.2.4-cb1d79df3b-343244ecab.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/vite-npm-7.3.6-7e42869881-6fcbadb1a4.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/vitest-npm-3.2.7-232be925e6-b54cba8bbb.zipis excluded by!**/.yarn/**,!**/*.zip.yarn/cache/why-is-node-running-npm-2.3.0-011cf61a18-0de6e6cd8f.zipis excluded by!**/.yarn/**,!**/*.zipyarn.lockis excluded by!**/yarn.lock,!**/*.lock
📒 Files selected for processing (57)
.changeset/calm-stacks-persist.md.pnp.cjsCONTEXT.mddocs/adr/0001-recover-from-unusable-navigation-snapshots.mddocs/adr/0002-require-a-ready-snapshot-at-stack-creation.mddocs/adr/0003-use-one-asynchronous-snapshot-save-contract.mddocs/adr/0004-capture-only-idle-stacks.mddocs/adr/0005-inject-snapshot-metadata-and-a-reuse-gate.mddocs/design/fep-2546-navigation-context-persistence.mdextensions/plugin-stack-persistence/.gitignoreextensions/plugin-stack-persistence/README.mdextensions/plugin-stack-persistence/boundary-tests/consumer/consumer.tsextensions/plugin-stack-persistence/boundary-tests/consumer/consumerRuntime.cjsextensions/plugin-stack-persistence/boundary-tests/consumer/tsconfig.jsonextensions/plugin-stack-persistence/boundary-tests/packageBoundary.spec.tsextensions/plugin-stack-persistence/boundary-tests/tsconfig.jsonextensions/plugin-stack-persistence/esbuild.config.jsextensions/plugin-stack-persistence/package.jsonextensions/plugin-stack-persistence/src/StackSnapshotRecord.tsextensions/plugin-stack-persistence/src/StackSnapshotStorage.tsextensions/plugin-stack-persistence/src/StackSnapshotStrategy.tsextensions/plugin-stack-persistence/src/__fixtures__/assertions.tsextensions/plugin-stack-persistence/src/__fixtures__/clock.tsextensions/plugin-stack-persistence/src/__fixtures__/controlledStorage.tsextensions/plugin-stack-persistence/src/__fixtures__/isolated/childReport.tsextensions/plugin-stack-persistence/src/__fixtures__/isolated/handledSaveRejection.child.tsextensions/plugin-stack-persistence/src/__fixtures__/isolated/unhandledSaveRejection.child.tsextensions/plugin-stack-persistence/src/__fixtures__/isolatedProcess.tsextensions/plugin-stack-persistence/src/__fixtures__/observerPlugin.tsextensions/plugin-stack-persistence/src/__fixtures__/stackFixtures.tsextensions/plugin-stack-persistence/src/__fixtures__/strategySpy.tsextensions/plugin-stack-persistence/src/autoSave.spec.tsextensions/plugin-stack-persistence/src/errors.tsextensions/plugin-stack-persistence/src/index.tsextensions/plugin-stack-persistence/src/loadErrors.spec.tsextensions/plugin-stack-persistence/src/pluginObservability.spec.tsextensions/plugin-stack-persistence/src/preservationScope.spec.tsextensions/plugin-stack-persistence/src/responsibilityBoundaries.spec.tsextensions/plugin-stack-persistence/src/restoreOnStart.spec.tsextensions/plugin-stack-persistence/src/reuseStrategy.spec.tsextensions/plugin-stack-persistence/src/saveErrorPropagation.spec.tsextensions/plugin-stack-persistence/src/saveErrors.spec.tsextensions/plugin-stack-persistence/src/saveMetadata.spec.tsextensions/plugin-stack-persistence/src/ssr.spec.tsextensions/plugin-stack-persistence/src/stackPersistencePlugin.tsextensions/plugin-stack-persistence/tsconfig.jsonextensions/plugin-stack-persistence/type-tests/fixtures/errorContract.tsextensions/plugin-stack-persistence/type-tests/fixtures/helpers.tsextensions/plugin-stack-persistence/type-tests/fixtures/optionsMetadataInference.tsextensions/plugin-stack-persistence/type-tests/fixtures/optionsWithoutStrategy.tsextensions/plugin-stack-persistence/type-tests/fixtures/publicSurface.tsextensions/plugin-stack-persistence/type-tests/fixtures/recordAndStorage.tsextensions/plugin-stack-persistence/type-tests/fixtures/singleInjectionSurface.tsextensions/plugin-stack-persistence/type-tests/fixtures/strategyContract.tsextensions/plugin-stack-persistence/type-tests/tsconfig.jsonextensions/plugin-stack-persistence/type-tests/typeContracts.spec.tsextensions/plugin-stack-persistence/vitest.config.mts
Summary
Adds
@stackflow/plugin-stack-persistence— a framework-neutral core plugin that persists complete Stackflow navigation snapshots and restores them on startup, so an app can reopen exactly where the user left off.Built against the confirmed design doc (
docs/design/fep-2546-navigation-context-persistence.md) and the FEP-2548 create/load distinction mechanism in@stackflow/core(provideSnapshot,initInfo.kind,SnapshotLoadError,captureSnapshot). No React/URL/browser surface is added — the plugin depends only on the core plugin contract and injectedstorage/strategyinterfaces.What it does
StackSnapshotStrategy,shouldReuseruns first and only the accepted record reaches core validation;createMetadataattaches typed metadata on save. Without a strategy,Metadataisundefinedand the record is always offered to core.StackPersistenceLoadError/StackPersistenceSaveErrorcarry{ kind, detail }(never the failing record). Load errors follow arecover(default) /propagatepolicy; a coreSnapshotLoadErroris passed through unwrapped and, when propagated, preserves object identity. WhenonSaveErroris omitted, save rejections propagate asynchronously rather than being swallowed. IfcreateMetadatathrows, the whole record save is abandoned atomically.Non-goals from the design (History/URL integration, migration, React surface, success-observation callbacks, manual save/flush APIs) are intentionally not implemented.
Package
New package under
extensions/plugin-stack-persistencefollowing sibling-extension conventions (yarn Berry, Biome, vitest). Public surface:stackPersistencePlugin,StackSnapshotStorage,StackSnapshotRecord,StackSnapshotStrategy,StackPersistenceLoadError/StackPersistenceSaveError,onLoadError/onSaveError. Changeset included.Testing
A contract-first test harness was built and independently reviewed before implementation, then the implementation was driven to green against it:
Metadatainference (strategy vs no-strategy) and options-union contracts are verified withtsc --noEmitplus@ts-expect-errornegative controls that are checked to actually fail when the directive is removed.All suites green; type inference and error/concurrency semantics verified by empirical measurement (not inspection).