Skip to content

Commit 0072c62

Browse files
feat(plugin-stack-persistence): navigation context persistence plugin (#727)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent aaf2d03 commit 0072c62

12 files changed

Lines changed: 256 additions & 0 deletions

File tree

.changeset/calm-stacks-persist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackflow/plugin-stack-persistence": major
3+
---
4+
5+
Package initial release; Persist and restore complete Stackflow navigation snapshots with optional metadata reuse strategies and explicit load/save error handling.

.pnp.cjs

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const { context } = require("esbuild");
2+
const config = require("@stackflow/esbuild-config");
3+
const pkg = require("./package.json");
4+
5+
const watch = process.argv.includes("--watch");
6+
const external = Object.keys({
7+
...pkg.dependencies,
8+
...pkg.peerDependencies,
9+
});
10+
11+
Promise.all([
12+
context({
13+
...config({}),
14+
format: "cjs",
15+
external,
16+
}).then((ctx) =>
17+
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
18+
),
19+
context({
20+
...config({}),
21+
format: "esm",
22+
outExtension: {
23+
".js": ".mjs",
24+
},
25+
external,
26+
}).then((ctx) =>
27+
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
28+
),
29+
]).catch(() => process.exit(1));
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "@stackflow/plugin-stack-persistence",
3+
"version": "0.0.0",
4+
"repository": {
5+
"type": "git",
6+
"url": "https://github.com/daangn/stackflow.git",
7+
"directory": "extensions/plugin-stack-persistence"
8+
},
9+
"license": "MIT",
10+
"exports": {
11+
".": {
12+
"types": "./dist/index.d.ts",
13+
"require": "./dist/index.js",
14+
"import": "./dist/index.mjs"
15+
}
16+
},
17+
"main": "./dist/index.js",
18+
"module": "./dist/index.mjs",
19+
"types": "./dist/index.d.ts",
20+
"files": [
21+
"dist",
22+
"src"
23+
],
24+
"scripts": {
25+
"build": "yarn build:js && yarn build:dts",
26+
"build:dts": "tsc --emitDeclarationOnly",
27+
"build:js": "node ./esbuild.config.js",
28+
"clean": "rimraf dist",
29+
"dev": "yarn build:js --watch && yarn build:dts --watch",
30+
"typecheck": "tsc --noEmit"
31+
},
32+
"devDependencies": {
33+
"@stackflow/core": "^3.0.0",
34+
"@stackflow/esbuild-config": "^1.0.3",
35+
"@types/node": "^20.14.9",
36+
"esbuild": "^0.23.0",
37+
"rimraf": "^3.0.2",
38+
"typescript": "^5.5.3"
39+
},
40+
"peerDependencies": {
41+
"@stackflow/core": "^3.0.0"
42+
},
43+
"publishConfig": {
44+
"access": "public"
45+
},
46+
"ultra": {
47+
"concurrent": [
48+
"dev",
49+
"build"
50+
]
51+
}
52+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { StackSnapshot } from "@stackflow/core";
2+
3+
export type StackSnapshotRecord<Metadata> = {
4+
snapshot: StackSnapshot;
5+
metadata: Metadata;
6+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { StackSnapshotRecord } from "./StackSnapshotRecord";
2+
3+
export interface StackSnapshotStorage<Metadata> {
4+
load(): StackSnapshotRecord<Metadata> | null;
5+
save(record: StackSnapshotRecord<Metadata>): Promise<void>;
6+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { StackSnapshot } from "@stackflow/core";
2+
import type { StackSnapshotRecord } from "./StackSnapshotRecord";
3+
4+
export interface StackSnapshotStrategy<Metadata> {
5+
createMetadata(args: {
6+
snapshot: StackSnapshot;
7+
}): Metadata;
8+
9+
shouldReuse(args: {
10+
record: StackSnapshotRecord<Metadata>;
11+
initialContext: unknown;
12+
}): boolean;
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export class StackSnapshotRecordSaveError extends Error {
2+
cause: unknown;
3+
4+
constructor(cause: unknown, message?: string) {
5+
super(message ?? "failed to save stack snapshot record");
6+
this.name = "StackSnapshotRecordSaveError";
7+
this.cause = cause;
8+
}
9+
}
10+
11+
export class StackSnapshotRecordLoadError extends Error {
12+
cause: unknown;
13+
14+
constructor(cause: unknown, message?: string) {
15+
super(message ?? "failed to load stack snapshot record");
16+
this.name = "StackSnapshotRecordLoadError";
17+
this.cause = cause;
18+
}
19+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export {
2+
StackSnapshotRecordSaveError,
3+
StackSnapshotRecordLoadError
4+
} from "./errors";
5+
export { StackSnapshotRecord } from "./StackSnapshotRecord";
6+
export { StackSnapshotStorage } from "./StackSnapshotStorage";
7+
export { StackSnapshotStrategy } from "./StackSnapshotStrategy";
8+
export {
9+
StackPersistencePluginOptions,
10+
stackPersistencePlugin,
11+
} from "./stackPersistencePlugin";
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type {
2+
StackflowActions,
3+
StackflowPlugin,
4+
} from "@stackflow/core";
5+
import { StackSnapshotRecordSaveError, StackSnapshotRecordLoadError } from "./errors";
6+
import type { StackSnapshotStorage } from "./StackSnapshotStorage";
7+
import type { StackSnapshotStrategy } from "./StackSnapshotStrategy";
8+
9+
export type StackPersistencePluginOptions<Metadata> = {
10+
storage: StackSnapshotStorage<Metadata>;
11+
strategy: StackSnapshotStrategy<Metadata>;
12+
onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void;
13+
onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void;
14+
onLoadError?: NonNullable<ReturnType<StackflowPlugin>['onLoadError']>;
15+
}
16+
17+
export function stackPersistencePlugin<Metadata>(
18+
{ storage, strategy, onRecordLoadError, onRecordSaveError, onLoadError }: StackPersistencePluginOptions<Metadata>,
19+
): StackflowPlugin {
20+
return () => {
21+
const saveIfIdle = (actions: StackflowActions) => {
22+
const stack = actions.getStack();
23+
24+
if (stack.globalTransitionState !== "idle") return;
25+
26+
const snapshot = actions.captureSnapshot();
27+
const metadata = strategy.createMetadata({ snapshot });
28+
29+
storage.save({
30+
snapshot,
31+
metadata
32+
}).catch(error => {
33+
if (onRecordSaveError) return onRecordSaveError(error);
34+
else throw error;
35+
});
36+
}
37+
38+
return {
39+
key: "@stackflow/plugin-stack-persistence",
40+
provideSnapshot({ initialContext }) {
41+
try {
42+
const record = storage.load();
43+
44+
if (!record)
45+
return null;
46+
if (strategy?.shouldReuse({ record, initialContext }) === false)
47+
return null;
48+
49+
return record.snapshot;
50+
} catch (error) {
51+
onRecordLoadError?.(new StackSnapshotRecordLoadError(error));
52+
53+
return null;
54+
}
55+
},
56+
onLoadError(...args) {
57+
return onLoadError?.(...args) ?? { policy: "recover" }
58+
},
59+
onInit({ actions }) {
60+
saveIfIdle(actions);
61+
},
62+
onChanged({ actions }) {
63+
saveIfIdle(actions);
64+
},
65+
};
66+
};
67+
}

0 commit comments

Comments
 (0)