Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
96c1861
docs: design stack persistence plugin
ENvironmentSet Jul 12, 2026
1a0bf65
add run plan
ENvironmentSet Jul 12, 2026
0cdc252
test(plugin-stack-persistence): add contract test harness with public…
ENvironmentSet Jul 12, 2026
7072ce4
feat(plugin-stack-persistence): implement snapshot persistence (FEP-2…
ENvironmentSet Jul 12, 2026
8217093
test(plugin-stack-persistence): assert navigation meaning via public …
ENvironmentSet Jul 12, 2026
ff95e5d
test(plugin-stack-persistence): keep terminal-paused load boundary as…
ENvironmentSet Jul 12, 2026
2ed5564
chore: drop orchestration run-plan doc from repo (FEP-2546)
ENvironmentSet Jul 12, 2026
5274482
fix(plugin-stack-persistence): expose reuse predicate failures (T1-2 …
ENvironmentSet Jul 15, 2026
2a068a9
refactor(plugin-stack-persistence): simplify load error cause (T1-3 l…
ENvironmentSet Jul 15, 2026
f542051
fix(plugin-stack-persistence): isolate metadata failures (T2-1 create…
ENvironmentSet Jul 15, 2026
0a2c748
refactor(plugin-stack-persistence): simplify save error cause (T2-2 s…
ENvironmentSet Jul 15, 2026
a039563
feat(plugin-stack-persistence): add storage load recovery (T1-1 stora…
ENvironmentSet Jul 15, 2026
0cb1e66
chore(plugin-stack-persistence): remove tests and docs
ENvironmentSet Jul 15, 2026
7111507
update tsconfig
ENvironmentSet Jul 16, 2026
a4a3a64
update package.json
ENvironmentSet Jul 16, 2026
3f41b31
delete gitignore
ENvironmentSet Jul 16, 2026
b089ca7
fix tsconfig
ENvironmentSet Jul 16, 2026
e4ad37d
simpl
ENvironmentSet Jul 16, 2026
2d23c31
refactor
ENvironmentSet Jul 16, 2026
97e4a61
cs
ENvironmentSet Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-stacks-persist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackflow/plugin-stack-persistence": major
---

Package initial release; Persist and restore complete Stackflow navigation snapshots with optional metadata reuse strategies and explicit load/save error handling.
20 changes: 20 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions extensions/plugin-stack-persistence/esbuild.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const { context } = require("esbuild");
const config = require("@stackflow/esbuild-config");
const pkg = require("./package.json");

const watch = process.argv.includes("--watch");
const external = Object.keys({
...pkg.dependencies,
...pkg.peerDependencies,
});

Promise.all([
context({
...config({}),
format: "cjs",
external,
}).then((ctx) =>
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
),
context({
...config({}),
format: "esm",
outExtension: {
".js": ".mjs",
},
external,
}).then((ctx) =>
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
),
]).catch(() => process.exit(1));
52 changes: 52 additions & 0 deletions extensions/plugin-stack-persistence/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@stackflow/plugin-stack-persistence",
"version": "0.0.0",
"repository": {
"type": "git",
"url": "https://github.com/daangn/stackflow.git",
"directory": "extensions/plugin-stack-persistence"
},
"license": "MIT",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist",
"src"
],
"scripts": {
"build": "yarn build:js && yarn build:dts",
"build:dts": "tsc --emitDeclarationOnly",
"build:js": "node ./esbuild.config.js",
"clean": "rimraf dist",
"dev": "yarn build:js --watch && yarn build:dts --watch",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

dev script blocks on first --watch command.

The && operator runs commands sequentially. Since yarn build:js --watch is a long-running process, yarn build:dts --watch will never execute. Please run them concurrently in the background.

🔧 Proposed fix: use background `&` for parallel watch processes
-    "dev": "yarn build:js --watch && yarn build:dts --watch",
+    "dev": "yarn build:js --watch & yarn build:dts --watch",

If cross-platform support is needed, consider using concurrently (if installed) instead.

🤖 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/package.json` at line 29, Update the
package.json dev script so the watch processes for build:js and build:dts start
concurrently instead of chaining with &&. Use background execution with & as
requested, or the existing concurrently utility if the project already provides
one.

"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@stackflow/core": "^3.0.0",
"@stackflow/esbuild-config": "^1.0.3",
"@types/node": "^20.14.9",
"esbuild": "^0.23.0",
"rimraf": "^3.0.2",
"typescript": "^5.5.3"
},
"peerDependencies": {
"@stackflow/core": "^3.0.0"
},
"publishConfig": {
"access": "public"
},
"ultra": {
"concurrent": [
"dev",
"build"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { StackSnapshot } from "@stackflow/core";

export type StackSnapshotRecord<Metadata> = {
snapshot: StackSnapshot;
metadata: Metadata;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { StackSnapshotRecord } from "./StackSnapshotRecord";

export interface StackSnapshotStorage<Metadata> {
load(): StackSnapshotRecord<Metadata> | null;
save(record: StackSnapshotRecord<Metadata>): Promise<void>;
}
13 changes: 13 additions & 0 deletions extensions/plugin-stack-persistence/src/StackSnapshotStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { StackSnapshot } from "@stackflow/core";
import type { StackSnapshotRecord } from "./StackSnapshotRecord";

export interface StackSnapshotStrategy<Metadata> {
createMetadata(args: {
snapshot: StackSnapshot;
}): Metadata;

shouldReuse(args: {
record: StackSnapshotRecord<Metadata>;
initialContext: unknown;
}): boolean;
}
19 changes: 19 additions & 0 deletions extensions/plugin-stack-persistence/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export class StackSnapshotRecordSaveError extends Error {
cause: unknown;

constructor(cause: unknown, message?: string) {
super(message ?? "failed to save stack snapshot record");
this.name = "StackSnapshotRecordSaveError";
this.cause = cause;
}
}

export class StackSnapshotRecordLoadError extends Error {
cause: unknown;

constructor(cause: unknown, message?: string) {
super(message ?? "failed to load stack snapshot record");
this.name = "StackSnapshotRecordLoadError";
this.cause = cause;
}
}
11 changes: 11 additions & 0 deletions extensions/plugin-stack-persistence/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export {
StackSnapshotRecordSaveError,
StackSnapshotRecordLoadError
} from "./errors";
export { StackSnapshotRecord } from "./StackSnapshotRecord";
export { StackSnapshotStorage } from "./StackSnapshotStorage";
export { StackSnapshotStrategy } from "./StackSnapshotStrategy";
export {
StackPersistencePluginOptions,
stackPersistencePlugin,
} from "./stackPersistencePlugin";
67 changes: 67 additions & 0 deletions extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type {
StackflowActions,
StackflowPlugin,
} from "@stackflow/core";
import { StackSnapshotRecordSaveError, StackSnapshotRecordLoadError } from "./errors";
import type { StackSnapshotStorage } from "./StackSnapshotStorage";
import type { StackSnapshotStrategy } from "./StackSnapshotStrategy";

export type StackPersistencePluginOptions<Metadata> = {
storage: StackSnapshotStorage<Metadata>;
strategy: StackSnapshotStrategy<Metadata>;
onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void;
onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void;
onLoadError?: NonNullable<ReturnType<StackflowPlugin>['onLoadError']>;
}

export function stackPersistencePlugin<Metadata>(
{ storage, strategy, onRecordLoadError, onRecordSaveError, onLoadError }: StackPersistencePluginOptions<Metadata>,
): StackflowPlugin {
return () => {
const saveIfIdle = (actions: StackflowActions) => {
const stack = actions.getStack();

if (stack.globalTransitionState !== "idle") return;

const snapshot = actions.captureSnapshot();
const metadata = strategy.createMetadata({ snapshot });

storage.save({
snapshot,
metadata
}).catch(error => {
if (onRecordSaveError) return onRecordSaveError(error);
else throw error;
});
}
Comment on lines +21 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Fix type error and route synchronous exceptions through the save-error policy.

onRecordSaveError expects a StackSnapshotRecordSaveError but is currently receiving the raw error from the .catch block.
Additionally, strategy.createMetadata() and storage.save() can throw synchronously. Because they are not wrapped in a try/catch, any synchronous exceptions will bypass the .catch block and synchronously unwind onInit or onChanged, potentially crashing the navigation lifecycle.

Wrap the synchronous execution in a try/catch that converts errors to a rejected Promise, and ensure the error is properly wrapped in StackSnapshotRecordSaveError.

🔧 Proposed fix
-    const saveIfIdle = (actions: StackflowActions) => {
-      const stack = actions.getStack();
-
-      if (stack.globalTransitionState !== "idle") return;
-
-      const snapshot = actions.captureSnapshot();
-      const metadata = strategy.createMetadata({ snapshot });
-
-      storage.save({
-        snapshot,
-        metadata
-      }).catch(error => {
-        if (onRecordSaveError) return onRecordSaveError(error);
-        else throw error;
-      });
-    }
+    const saveIfIdle = (actions: StackflowActions) => {
+      const stack = actions.getStack();
+
+      if (stack.globalTransitionState !== "idle") return;
+
+      let savePromise: Promise<void>;
+      try {
+        const snapshot = actions.captureSnapshot();
+        const metadata = strategy.createMetadata({ snapshot });
+
+        savePromise = storage.save({
+          snapshot,
+          metadata
+        });
+      } catch (error) {
+        savePromise = Promise.reject(error);
+      }
+
+      savePromise.catch(error => {
+        if (onRecordSaveError) {
+          onRecordSaveError(new StackSnapshotRecordSaveError(error));
+        } else {
+          throw error;
+        }
+      });
+    }
🤖 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/stackPersistencePlugin.ts` around
lines 21 - 36, Update saveIfIdle to wrap snapshot capture,
strategy.createMetadata, and storage.save in a try/catch so synchronous failures
enter the same save-error handling path. Convert caught errors to
StackSnapshotRecordSaveError before invoking onRecordSaveError, and preserve the
rejected-Promise behavior when no handler is configured.


return {
key: "@stackflow/plugin-stack-persistence",
provideSnapshot({ initialContext }) {
try {
const record = storage.load();

if (!record)
return null;
if (strategy?.shouldReuse({ record, initialContext }) === false)
return null;

return record.snapshot;
} catch (error) {
onRecordLoadError?.(new StackSnapshotRecordLoadError(error));

return null;
}
},
onLoadError(...args) {
return onLoadError?.(...args) ?? { policy: "recover" }
},
onInit({ actions }) {
saveIfIdle(actions);
},
onChanged({ actions }) {
saveIfIdle(actions);
},
};
};
}
13 changes: 13 additions & 0 deletions extensions/plugin-stack-persistence/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"module": "preserve",
"declaration": true,
"declarationMap": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["./src/**/*"]
}
15 changes: 15 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6000,6 +6000,21 @@ __metadata:
languageName: unknown
linkType: soft

"@stackflow/plugin-stack-persistence@workspace:extensions/plugin-stack-persistence":
version: 0.0.0-use.local
resolution: "@stackflow/plugin-stack-persistence@workspace:extensions/plugin-stack-persistence"
dependencies:
"@stackflow/core": "npm:^3.0.0"
"@stackflow/esbuild-config": "npm:^1.0.3"
"@types/node": "npm:^20.14.9"
esbuild: "npm:^0.23.0"
rimraf: "npm:^3.0.2"
typescript: "npm:^5.5.3"
peerDependencies:
"@stackflow/core": ^3.0.0
languageName: unknown
linkType: soft

"@stackflow/react-ui-core@npm:^1.3.6, @stackflow/react-ui-core@workspace:extensions/react-ui-core":
version: 0.0.0-use.local
resolution: "@stackflow/react-ui-core@workspace:extensions/react-ui-core"
Expand Down
Loading