Skip to content

feat(plugin-stack-persistence): navigation context persistence plugin (FEP-2546)#727

Draft
ENvironmentSet wants to merge 7 commits into
mainfrom
feature/fep-2546
Draft

feat(plugin-stack-persistence): navigation context persistence plugin (FEP-2546)#727
ENvironmentSet wants to merge 7 commits into
mainfrom
feature/fep-2546

Conversation

@ENvironmentSet

Copy link
Copy Markdown
Collaborator

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 injected storage / strategy interfaces.

What it does

  • Restore on start — provides the stored snapshot to core at startup; core validates it and, on failure, routes to the plugin's error policy. No provisional fresh stack is ever exposed before the decision.
  • Optional reuse strategy — with a StackSnapshotStrategy, shouldReuse runs first and only the accepted record reaches core validation; createMetadata attaches typed metadata on save. Without a strategy, Metadata is undefined and the record is always offered to core.
  • Automatic save while running — captures and saves on each completed navigation while the stack is at an unpaused Idle; saves are fire-and-forget so a failing or slow write never blocks or cancels navigation, and the next Idle simply retries.
  • Explicit error handlingStackPersistenceLoadError / StackPersistenceSaveError carry { kind, detail } (never the failing record). Load errors follow a recover (default) / propagate policy; a core SnapshotLoadError is passed through unwrapped and, when propagated, preserves object identity. When onSaveError is omitted, save rejections propagate asynchronously rather than being swallowed. If createMetadata throws, the whole record save is abandoned atomically.
  • SSR-neutral — no reliance on browser globals; storage is the consumer's responsibility.

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-persistence following 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:

  • Runtime suite — 68 behavior tests over restore-on-start, reuse strategy, preservation scope, auto-save, save/load error handling and identity, async save-error propagation (across a real process boundary), responsibility boundaries, and SSR neutrality. Concurrency scenarios (pending-write re-navigation, interleaved save failures, terminal-vs-resumable pause) use controllable deferred-promise storage and fake timers for determinism.
  • Type-level suite — the Metadata inference (strategy vs no-strategy) and options-union contracts are verified with tsc --noEmit plus @ts-expect-error negative controls that are checked to actually fail when the directive is removed.
  • Package-boundary suite — validates the built package's public entrypoint.

All suites green; type inference and error/concurrency semantics verified by empirical measurement (not inspection).

ENvironmentSet and others added 7 commits July 12, 2026 15:34
…-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-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7506e1d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@stackflow/plugin-stack-persistence Patch

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

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added the Stackflow persistence plugin to save and restore navigation snapshots across runtime sessions.
    • Added optional metadata and reuse strategies for deciding when saved navigation should be restored.
    • Added configurable load and save error handling, including recovery and propagation policies.
    • Preserved completed navigation changes automatically while avoiding persistence of in-progress transitions.
  • Documentation

    • Added usage guidance, terminology, design decisions, and recovery behavior documentation.
  • Tests

    • Added comprehensive runtime, SSR, package-boundary, and TypeScript contract coverage.

Walkthrough

Adds @stackflow/plugin-stack-persistence, providing snapshot storage, optional metadata strategies, startup restoration, idle-state auto-save, and explicit load/save error handling with comprehensive runtime, type, boundary, and SSR tests.

Changes

Stack Persistence Plugin

Layer / File(s) Summary
Persistence contracts and public API
CONTEXT.md, docs/adr/*, docs/design/..., extensions/plugin-stack-persistence/src/{StackSnapshotRecord,StackSnapshotStorage,StackSnapshotStrategy,errors,index}.ts
Defines snapshot records, storage and strategy contracts, error types, lifecycle semantics, and public exports.
Package wiring and distribution boundary
.pnp.cjs, extensions/plugin-stack-persistence/{package.json,esbuild.config.js,tsconfig.json,vitest.config.mts,README.md}, extensions/plugin-stack-persistence/boundary-tests/*
Adds package build metadata, workspace resolution, published entrypoints, framework-neutral boundary checks, and release metadata.
Load, reuse, and save orchestration
extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts
Implements snapshot loading, synchronous reuse decisions, idle-only saving, metadata creation, and load/save error policies.
Deterministic persistence test fixtures
extensions/plugin-stack-persistence/src/__fixtures__/*
Adds controlled storage, snapshot builders, observers, deterministic clocks, browser-global guards, and isolated subprocess helpers.
Runtime behavior and error validation
extensions/plugin-stack-persistence/src/*.{spec.ts}
Tests restoration, reuse, auto-save timing, metadata, error propagation, observability, SSR behavior, and responsibility boundaries.
Type and consumer contract validation
extensions/plugin-stack-persistence/type-tests/*
Validates public type inference, option restrictions, error narrowing, storage and strategy contracts, and compile-time diagnostics.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested reviewers: orionmiz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.53% 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
Title check ✅ Passed The title clearly matches the main change: adding the plugin-stack-persistence feature for FEP-2546.
Description check ✅ Passed The description is directly aligned with the implemented persistence plugin, APIs, behavior, and tests.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fep-2546

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

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 12, 2026

Copy link
Copy Markdown
  • @stackflow/demo

    yarn add https://pkg.pr.new/daangn/stackflow/@stackflow/plugin-stack-persistence@727.tgz
    

commit: 7506e1d

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying stackflow-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7506e1d
Status: ✅  Deploy successful!
Preview URL: https://e86cca6c.stackflow-demo.pages.dev
Branch Preview URL: https://feature-fep-2546.stackflow-demo.pages.dev

View logs

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
stackflow-docs 7506e1d Commit Preview URL Jul 12 2026, 11:30 AM

@ENvironmentSet ENvironmentSet marked this pull request as draft July 12, 2026 11:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
extensions/plugin-stack-persistence/src/__fixtures__/isolatedProcess.ts (1)

10-11: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Shared static OUT_DIR + whole-directory cleanup is a latent race if invoked concurrently.

outfile is keyed only by childFileName under a fixed OUT_DIR, and finally removes the entire directory recursively. Currently the two call sites run sequentially in one spec file, so it's inert, but any future test.concurrent/parallel invocation of runIsolatedChild (same or different fixtures) could have one call's rmSync delete 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

📥 Commits

Reviewing files that changed from the base of the PR and between f0fc1fb and 7506e1d.

⛔ Files ignored due to path filters (55)
  • .yarn/cache/@esbuild-darwin-arm64-npm-0.28.1-b71e3b7721-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@esbuild-darwin-x64-npm-0.28.1-4318a1bdc6-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@esbuild-linux-arm64-npm-0.28.1-33ca543ef9-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@esbuild-linux-ia32-npm-0.28.1-1ab1ff7c72-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@esbuild-linux-x64-npm-0.28.1-f7b93afd3d-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@jridgewell-sourcemap-codec-npm-1.5.5-5189d9fc79-5d9d207b46.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@rollup-rollup-darwin-arm64-npm-4.62.2-23ccb466e4-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@rollup-rollup-darwin-x64-npm-4.62.2-02ad04466d-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@rollup-rollup-linux-arm64-gnu-npm-4.62.2-22a54d9321-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@rollup-rollup-linux-arm64-musl-npm-4.62.2-123c27c34b-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@rollup-rollup-linux-x64-gnu-npm-4.62.2-1774ccb0dc-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@rollup-rollup-linux-x64-musl-npm-4.62.2-bf1e2b555e-10.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@types-chai-npm-5.2.3-5f61dbddda-e79947307d.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@types-deep-eql-npm-4.0.2-e6bc68cc92-249a27b0bb.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@types-estree-npm-1.0.9-63428f58ff-16aabfa703.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@vitest-expect-npm-3.2.7-287b64f32d-2621cf1e90.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@vitest-mocker-npm-3.2.7-6daa3361b9-fccb77857a.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@vitest-pretty-format-npm-3.2.7-fb6e627f7c-81286f667f.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@vitest-runner-npm-3.2.7-101792a6a3-ff7c14726c.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@vitest-snapshot-npm-3.2.7-59081b8193-8d1bc49c1d.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@vitest-spy-npm-3.2.7-407a36bb46-a125bff0ec.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/@vitest-utils-npm-3.2.7-c519404dea-d17fda64b6.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/assertion-error-npm-2.0.1-8169d136f2-a0789dd882.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/chai-npm-5.3.3-ebef71cdac-0d0ef63106.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/check-error-npm-2.1.3-e17bcf3ed8-f1868d3db6.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/debug-npm-4.4.3-0105c6123a-9ada3434ea.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/deep-eql-npm-5.0.2-3bce58289f-a529b81e2e.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/es-module-lexer-npm-1.7.0-456b47a08a-b6f3e576a3.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/esbuild-npm-0.28.1-23da8d1b66-aaa4a92264.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/estree-walker-npm-3.0.3-0372979673-a65728d572.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/expect-type-npm-1.4.0-aeee227f9a-bad91f4b7e.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/fdir-npm-6.5.0-8814a0dec7-14ca1c9f0a.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/js-tokens-npm-9.0.1-3ed793c0c1-3288ba73bb.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/loupe-npm-3.2.1-a8f491982f-a4d78ec758.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/magic-string-npm-0.30.21-9a226cb21e-57d5691f41.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/nanoid-npm-3.3.15-2658de05f8-13c74a5208.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/pathval-npm-2.0.1-7fb9ae82ba-f5e8b82f6b.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/picomatch-npm-4.0.5-bb8e0de0f7-8bad770af9.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/postcss-npm-8.5.16-086b209555-b39408900a.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/rollup-npm-4.62.2-b7a75098ad-40a8551c1b.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/siginfo-npm-2.0.0-9bbac931f8-e93ff66c65.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/stackback-npm-0.0.2-73273dc92e-2d4dc4e64e.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/strip-literal-npm-3.1.0-b0340463b3-6eb00906a1.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/tinybench-npm-2.9.0-2861a048db-cfa1e1418e.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/tinyexec-npm-0.3.2-381b1e349c-b9d5fed316.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/tinyglobby-npm-0.2.17-f2c3ddb917-f85e8a217d.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/tinypool-npm-1.1.1-6772421283-0d54139e9d.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/tinyrainbow-npm-2.0.0-b4ba575b93-94d4e16246.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/tinyspy-npm-4.0.4-94a3f61e82-858a99e3de.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/vite-node-npm-3.2.4-cb1d79df3b-343244ecab.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/vite-npm-7.3.6-7e42869881-6fcbadb1a4.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/vitest-npm-3.2.7-232be925e6-b54cba8bbb.zip is excluded by !**/.yarn/**, !**/*.zip
  • .yarn/cache/why-is-node-running-npm-2.3.0-011cf61a18-0de6e6cd8f.zip is excluded by !**/.yarn/**, !**/*.zip
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (57)
  • .changeset/calm-stacks-persist.md
  • .pnp.cjs
  • CONTEXT.md
  • docs/adr/0001-recover-from-unusable-navigation-snapshots.md
  • docs/adr/0002-require-a-ready-snapshot-at-stack-creation.md
  • docs/adr/0003-use-one-asynchronous-snapshot-save-contract.md
  • docs/adr/0004-capture-only-idle-stacks.md
  • docs/adr/0005-inject-snapshot-metadata-and-a-reuse-gate.md
  • docs/design/fep-2546-navigation-context-persistence.md
  • extensions/plugin-stack-persistence/.gitignore
  • extensions/plugin-stack-persistence/README.md
  • extensions/plugin-stack-persistence/boundary-tests/consumer/consumer.ts
  • extensions/plugin-stack-persistence/boundary-tests/consumer/consumerRuntime.cjs
  • extensions/plugin-stack-persistence/boundary-tests/consumer/tsconfig.json
  • extensions/plugin-stack-persistence/boundary-tests/packageBoundary.spec.ts
  • extensions/plugin-stack-persistence/boundary-tests/tsconfig.json
  • extensions/plugin-stack-persistence/esbuild.config.js
  • extensions/plugin-stack-persistence/package.json
  • extensions/plugin-stack-persistence/src/StackSnapshotRecord.ts
  • extensions/plugin-stack-persistence/src/StackSnapshotStorage.ts
  • extensions/plugin-stack-persistence/src/StackSnapshotStrategy.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/assertions.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/clock.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/controlledStorage.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/isolated/childReport.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/isolated/handledSaveRejection.child.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/isolated/unhandledSaveRejection.child.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/isolatedProcess.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/observerPlugin.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/stackFixtures.ts
  • extensions/plugin-stack-persistence/src/__fixtures__/strategySpy.ts
  • extensions/plugin-stack-persistence/src/autoSave.spec.ts
  • extensions/plugin-stack-persistence/src/errors.ts
  • extensions/plugin-stack-persistence/src/index.ts
  • extensions/plugin-stack-persistence/src/loadErrors.spec.ts
  • extensions/plugin-stack-persistence/src/pluginObservability.spec.ts
  • extensions/plugin-stack-persistence/src/preservationScope.spec.ts
  • extensions/plugin-stack-persistence/src/responsibilityBoundaries.spec.ts
  • extensions/plugin-stack-persistence/src/restoreOnStart.spec.ts
  • extensions/plugin-stack-persistence/src/reuseStrategy.spec.ts
  • extensions/plugin-stack-persistence/src/saveErrorPropagation.spec.ts
  • extensions/plugin-stack-persistence/src/saveErrors.spec.ts
  • extensions/plugin-stack-persistence/src/saveMetadata.spec.ts
  • extensions/plugin-stack-persistence/src/ssr.spec.ts
  • extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts
  • extensions/plugin-stack-persistence/tsconfig.json
  • extensions/plugin-stack-persistence/type-tests/fixtures/errorContract.ts
  • extensions/plugin-stack-persistence/type-tests/fixtures/helpers.ts
  • extensions/plugin-stack-persistence/type-tests/fixtures/optionsMetadataInference.ts
  • extensions/plugin-stack-persistence/type-tests/fixtures/optionsWithoutStrategy.ts
  • extensions/plugin-stack-persistence/type-tests/fixtures/publicSurface.ts
  • extensions/plugin-stack-persistence/type-tests/fixtures/recordAndStorage.ts
  • extensions/plugin-stack-persistence/type-tests/fixtures/singleInjectionSurface.ts
  • extensions/plugin-stack-persistence/type-tests/fixtures/strategyContract.ts
  • extensions/plugin-stack-persistence/type-tests/tsconfig.json
  • extensions/plugin-stack-persistence/type-tests/typeContracts.spec.ts
  • extensions/plugin-stack-persistence/vitest.config.mts

Comment thread extensions/plugin-stack-persistence/package.json
Comment thread extensions/plugin-stack-persistence/src/__fixtures__/assertions.ts
Comment thread extensions/plugin-stack-persistence/src/preservationScope.spec.ts
Comment thread extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts
Comment on lines +154 to +179
try {
record = storage.load();
} catch (detail) {
return recoverFromPersistenceLoadError(
new StackPersistenceLoadError({ kind: "storage", detail }),
context,
);
}

if (record === null || !strategy) {
return record?.snapshot ?? null;
}

try {
return strategy.shouldReuse({
record: record as StackSnapshotRecord<Metadata>,
initialContext: context,
})
? record.snapshot
: null;
} catch (detail) {
return recoverFromPersistenceLoadError(
new StackPersistenceLoadError({ kind: "strategy", detail }),
context,
);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

load 경로 오류 처리 재정의 요청

현재 provideSnapshotstorage.load()strategy.shouldReuse()의 throw를 모두 StackPersistenceLoadError로 정규화해 load 정책(recover/propagate)에 회부합니다. 이 경로를 아래 세 갈래로 재정의해 주세요.

원칙: 외부 I/O 경계(load)의 실패는 조용히 fresh stack으로 강등하고, 순수 동기 정책 계약(shouldReuse)의 throw는 계약 위반이므로 정규화하지 않고 그대로 노출하며, core가 검증한 스냅샷 실패만 onLoadError 정책 대상으로 둔다.

1. storage.load() throw → 스냅샷 제공 포기, null 반환

StackPersistenceLoadError로 감싸지 말고, onLoadError도 호출하지 말고, null을 반환해 fresh stack으로 강등합니다. (맨 앞의 initialContext = context 대입은 유지 — 이후 save 기준점에 필요합니다.)

load는 외부 저장 매체 읽기 경계입니다. 읽기가 깨졌을 때 탐색을 아예 못 여는 것보다 새 stack으로 정상 기동하는 편이 안전한 기본값이고, 읽기 실패 자체의 세부 처리는 소비자가 자기 저장소 구현 안에서 담당할 수 있습니다.

2. strategy.shouldReuse() throw → 그대로 전파, 추가 처리 없음

catch를 제거합니다. shouldReuse는 동기 순수 판정 함수이고 "throw하지 않는다"가 계약입니다. 따라서 throw는 strategy 결함(unexpected exception)이며, 정규화하지 않고 raw로 전파합니다.

이 throw는 makeCoreStoreprovideSnapshot 폴링 지점(try/catch로 감싸지 않음)을 지나 stack 생성에서 그대로 터집니다 — 계약 위반을 조용히 삼키지 않고 크게 드러내는 의도된 동작입니다. core가 overrideInitialEvents 체인의 throw를 "plugin bug"로 raw 전파하는 것과 동일한 입장입니다.

3. core Snapshot 유효성 검증 실패 → SnapshotLoadErroronLoadError 보고 (현행 유지)

이 경로만 onLoadError + recover/propagate 정책을 유지합니다. core가 만든 SnapshotLoadError를 감싸지 않고 정체성을 보존한 채 전달하는 현재 동작 그대로입니다.


반영 시 함께 처리(ripple):

  • 위 1·2로 StackPersistenceLoadError를 생성하는 코드가 모두 사라져 런타임에서 완전히 미사용이 됩니다. recoverFromPersistenceLoadError 헬퍼도 제거 대상이고, errors.ts·index.ts 공개 표면에서도 제거를 제안합니다(미배포 0.1.0이라 지금이 적기).
  • 그 결과 onLoadError 핸들러 인자 타입이 StackPersistenceLoadError | SnapshotLoadErrorSnapshotLoadError 단독으로 좁혀집니다.
  • 동작 변화 명시: storage 읽기 실패에 대한 propagate 선택지와 onLoadError 관찰 통지가 사라집니다(무조건 fresh stack). 탐색 연속성을 필수로 두는 소비자용 propagate는 이제 core 검증 실패에만 적용됩니다.
  • 스펙 문서 docs/design/fep-2546-navigation-context-persistence.md가 이 변경을 수반합니다 — 「오류 API」 절("저장소와 strategy에서 발생한 예상 가능한 load 실패는 StackPersistenceLoadError로 표현한다")과 「시작 시 복원」 절(저장소 읽기 실패·shouldReuse throw를 onLoadError로 알린다는 서술, 대략 128~130줄)을 새 시맨틱에 맞게 갱신해 주세요.
  • 테스트: loadErrors.spec.ts의 storage/strategy load-error → 정책 케이스와 타입 테스트(errorContract.ts, publicSurface.ts)를 새 계약으로 재작성해야 합니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

제안해 주신 세 갈래의 처리, 즉 storage.load() 실패는 항상 null로 강등하고, strategy.shouldReuse() 예외는 원본 그대로 전파하며, core의 SnapshotLoadErroronLoadError 정책 대상으로 남기는 방안을 검토했습니다. 다만 현재 오류 계약이 탐색 가용성과 오류 관찰을 함께 제공하고 있어 이 변경은 반영하지 않겠습니다.

현재 storage.load() 또는 shouldReuse()가 실패해도 onLoadError를 생략한 기본 정책은 recover이므로 스냅샷을 포기하고 fresh stack으로 정상 기동합니다. 동시에 핸들러를 둔 소비자는 실패 단계와 원본 detail을 관찰하고, 탐색 연속성이 필수라면 propagate를 선택할 수 있습니다. storage.load() 실패를 무조건 null로 바꾸면 “저장된 record가 없음”과 “저장소/codec 읽기 실패”를 플러그인 경계에서 구분할 수 없고 이 관찰 및 전파 선택도 사라집니다. 조용한 복구가 필요한 저장소는 현행 기본 recover를 사용하거나 저장소 구현에서 실패를 null로 변환할 수 있습니다.

또한 StackSnapshotStrategy의 공개 인터페이스는 shouldReuse()를 동기 계약으로 정의할 뿐 예외를 금지하는 non-throwing 계약은 두지 않습니다. 설계 문서도 shouldReuse() 예외를 strategy 평가 단계의 load 오류로 명시하고 있습니다. 따라서 동기 함수라는 이유만으로 해당 예외를 계약 위반으로 새로 분류해 raw 전파할 근거는 현재 계약에 없습니다.

말씀하신 대로 core는 provideSnapshot을 별도 try/catch 없이 호출하므로 catch를 제거하면 오류가 stack 생성 밖으로 그대로 전파됩니다. 다만 overrideInitialEvents의 raw throw는 core가 명시적으로 plugin bug로 정한 별도 hook 계약이고, persistence strategy의 오류 분류까지 동일해야 한다는 근거는 아닙니다. core 검증이 만든 SnapshotLoadError는 현재도 감싸지 않고 같은 객체를 onLoadError에 전달하며 recover/propagate 정책을 적용하므로, 그 경로의 정체성 보존에는 수정할 부분이 없습니다.

Comment on lines +118 to +143
if (strategy) {
try {
metadata = strategy.createMetadata({ snapshot, initialContext });
} catch (detail) {
reportSaveError(
new StackPersistenceSaveError({ kind: "strategy", detail }),
);
return;
}
} else {
metadata = undefined;
}

const record: StackSnapshotRecord<Metadata | undefined> = {
snapshot,
metadata,
};

// A synchronous throw violates StackSnapshotStorage's contract and is
// intentionally left as an unexpected exception. Rejected promises are
// the expected storage failure channel.
const savePromise = storage.save(record);
void savePromise.catch((detail: unknown) => {
reportSaveError(
new StackPersistenceSaveError({ kind: "storage", detail }),
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

save 경로 오류 처리 재정의 요청

load 경로 변경(#discussion_r3568437333)과 같은 원칙을 save 경로에도 적용해 주세요: 순수 동기 strategy 함수(createMetadata)의 throw는 계약 위반이므로 정규화하지 않고 그대로 노출하고, StackPersistenceSaveError는 실제 비동기 실패 채널인 storage.save 거부에만 남깁니다.

1. strategy.createMetadata() throw → 그대로 전파, 추가 조치 없음

saveIfIdle에서 createMetadata를 감싸던 try/catch를 제거합니다. createMetadata는 동기 순수 함수이고 "throw하지 않는다"가 규약이므로, throw는 strategy 결함(unexpected exception)입니다. StackPersistenceSaveError{ kind: "strategy" }로 정규화하거나 reportSaveError로 보내지 않고 raw로 전파합니다.

착지점: onInit 경로(생성/복원 직후 stack이 이미 idle)면 init()에서 동기적으로 터져 초기화를 unwind합니다. 실행 중 onChanged 경로면 idle이 dispatchEventsetInterval 콜백에서 도달하므로 uncaught async 예외로 표면화합니다. 어느 쪽이든 계약 위반을 조용히 삼키지 않고 크게 드러내는 의도된 동작이며, load 경로의 shouldReuse throw 처리와 대칭입니다.

2. StackPersistenceSaveErrorkind 제거

1의 결과로 StackPersistenceSaveError의 유일한 생산자가 storage.save 거부(아래 .catch)뿐이 됩니다. kind 판별자가 무의미해지므로 제거해 주세요:

  • StackPersistenceSaveErrorCause: { kind: "strategy" | "storage"; detail }{ detail: unknown }
  • .catch 생성 인자: { kind: "storage", detail }{ detail }
  • errors.ts 메시지 문자열의 : ${cause.kind} 제거

반영 시 함께 처리(ripple):

  • reportSaveError 자체는 유지되지만 호출 지점은 storage.save 거부 경로 하나만 남습니다.
  • 스펙 문서 docs/design/fep-2546-navigation-context-persistence.md 「실행 중 보존」 절(대략 198~199줄: "createMetadata가 예외를 던지면 … strategy 단계의 오류로 onSaveError에 전달한다", "metadata 없는 record를 저장하거나 이전 metadata를 재사용하지 않는다")을 새 시맨틱(raw 전파, onSaveError 미경유)으로 갱신해 주세요.
  • 타입 테스트: errorContract.tscategorizeSaveCause(switch (error.cause.kind))와 save cause 관련 @ts-expect-error(strategy/storage 단계 한정 서술), publicSurface.ts의 save-error 계약을 새 cause shape로 재작성해야 합니다.
  • index.tsStackPersistenceSaveErrorCause export는 유지되나 shape가 { detail }로 바뀝니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

제안해 주신 대로 strategy.createMetadata()try/catch를 제거해 예외를 원본 그대로 전파하고, 그 결과 StackPersistenceSaveErrorCause.kind도 제거하는 방안을 검토했습니다. 다만 현재 strategy 계약과 저장 실패의 런타임 경계를 고려해 이 변경은 반영하지 않겠습니다.

StackSnapshotStrategycreateMetadata()를 동기 메서드로 정의하지만 non-throwing 또는 pure 규약은 두지 않습니다. 설계 문서는 이 예외를 해당 save 요청의 strategy 단계 실패로 명시하며, metadata 없는 record를 저장하거나 이전 metadata를 재사용하지 않고 요청 전체를 중단한 뒤 onSaveError로 알리도록 정하고 있습니다. 현재 구현도 storage.save()를 호출하기 전에 이 원자성을 보장합니다.

raw 전파로 바꾸면 같은 createMetadata() 결함이 발생 시점에 따라 서로 다른 경계로 노출됩니다. 최초 stack이 이미 Idle이면 무보호 onInit 호출을 통해 init()을 동기적으로 unwind하지만, 실행 중 navigation이 Idle에 도달할 때는 core의 timer 갱신에서 onChanged가 호출되므로 timer callback의 uncaught exception이 됩니다. 이처럼 저장 시점에 따라 오류 표면이 달라지면 예측 가능성이 낮아지고, “저장 실패가 이미 확정된 탐색을 취소하거나 앱 사용을 막지 않는다”는 현재 계약도 훼손됩니다.

현재 처리는 strategy 예외를 조용히 삼키지 않습니다. 핸들러가 있으면 원본 detail을 보존한 StackPersistenceSaveErroronSaveError에 전달하고, 핸들러가 없으면 비동기 오류 경계로 전파합니다. 더 강한 fail-fast가 필요한 소비자는 핸들러에서 명시적으로 오류를 throw할 수 있습니다. 또한 metadata 생성 실패와 storage.save() Promise rejection은 실제 실패 단계와 채널이 다르므로, 이를 구분하는 kind: "strategy" | "storage"는 여전히 유효한 진단 정보입니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant