-
Notifications
You must be signed in to change notification settings - Fork 115
feat(plugin-stack-persistence): navigation context persistence plugin #727
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
96c1861
1a0bf65
0cdc252
7072ce4
8217093
ff95e5d
2ed5564
5274482
2a068a9
f542051
0a2c748
a039563
0cb1e66
7111507
a4a3a64
3f41b31
b089ca7
e4ad37d
2d23c31
97e4a61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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)); |
| 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", | ||
| "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>; | ||
| } |
| 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; | ||
| } |
| 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; | ||
| } | ||
| } |
| 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"; |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Wrap the synchronous execution in a 🔧 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 |
||
|
|
||
| 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); | ||
| }, | ||
| }; | ||
| }; | ||
| } | ||
| 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/**/*"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
devscript blocks on first--watchcommand.The
&&operator runs commands sequentially. Sinceyarn build:js --watchis a long-running process,yarn build:dts --watchwill never execute. Please run them concurrently in the background.🔧 Proposed fix: use background `&` for parallel watch processes
If cross-platform support is needed, consider using
concurrently(if installed) instead.🤖 Prompt for AI Agents