Skip to content

Commit 10dcabc

Browse files
docs(plugin-stack-persistence): add README (FEP-2672)
1 parent a90c8e8 commit 10dcabc

1 file changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# @stackflow/plugin-stack-persistence
2+
3+
Persist a Stackflow navigation snapshot beyond the lifetime of the JavaScript
4+
runtime and restore it when the stack starts again. The package is
5+
framework-neutral: it uses the `@stackflow/core` plugin contract and leaves the
6+
storage medium, serialization, record lifetime, and reuse policy to your
7+
application.
8+
9+
## Installation
10+
11+
```bash
12+
yarn add @stackflow/plugin-stack-persistence
13+
```
14+
15+
This package requires `@stackflow/core` 3.x.
16+
17+
## Setup
18+
19+
Create a synchronous loader, an asynchronous saver, and a strategy that
20+
validates stored metadata and decides whether its snapshot can be reused.
21+
22+
The following example stores snapshots in `localStorage`, rejects records from
23+
another application version, and expires records after seven days:
24+
25+
```typescript
26+
import type {
27+
StackSnapshotRecord,
28+
StackSnapshotStorage,
29+
StackSnapshotStrategy,
30+
} from "@stackflow/plugin-stack-persistence";
31+
import { stackPersistencePlugin } from "@stackflow/plugin-stack-persistence";
32+
33+
const STORAGE_KEY = "stackflow.snapshot";
34+
const APP_VERSION = 1 as const;
35+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
36+
37+
type SnapshotMetadata = {
38+
appVersion: typeof APP_VERSION;
39+
savedAt: number;
40+
};
41+
42+
const storage: StackSnapshotStorage<SnapshotMetadata> = {
43+
load() {
44+
if (typeof window === "undefined") return null;
45+
46+
const serialized = window.localStorage.getItem(STORAGE_KEY);
47+
48+
return serialized === null
49+
? null
50+
: (JSON.parse(serialized) as StackSnapshotRecord<unknown>);
51+
},
52+
async save(record) {
53+
if (typeof window === "undefined") return;
54+
55+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
56+
},
57+
};
58+
59+
const strategy: StackSnapshotStrategy<SnapshotMetadata> = {
60+
metadata: {
61+
create() {
62+
return {
63+
appVersion: APP_VERSION,
64+
savedAt: Date.now(),
65+
};
66+
},
67+
parse(data) {
68+
if (
69+
data === null ||
70+
typeof data !== "object" ||
71+
!("appVersion" in data) ||
72+
data.appVersion !== APP_VERSION ||
73+
!("savedAt" in data) ||
74+
typeof data.savedAt !== "number"
75+
) {
76+
return {
77+
ok: false,
78+
detail: "invalid snapshot metadata",
79+
};
80+
}
81+
82+
return {
83+
ok: true,
84+
value: {
85+
appVersion: APP_VERSION,
86+
savedAt: data.savedAt,
87+
},
88+
};
89+
},
90+
},
91+
shouldReuse({ record }) {
92+
return Date.now() - record.metadata.savedAt < MAX_AGE_MS;
93+
},
94+
};
95+
96+
export const persistencePlugin = stackPersistencePlugin({
97+
storage,
98+
strategy,
99+
onRecordLoadError(error) {
100+
console.warn("Could not read the saved Stackflow snapshot", error);
101+
},
102+
onRecordSaveError(error) {
103+
console.error("Could not save the Stackflow snapshot", error);
104+
},
105+
});
106+
```
107+
108+
Add the plugin to an existing Stackflow configuration:
109+
110+
```typescript
111+
import { stackflow } from "@stackflow/react";
112+
import { ArticleActivity } from "./ArticleActivity";
113+
import { HomeActivity } from "./HomeActivity";
114+
import { persistencePlugin } from "./persistence";
115+
import { config } from "./stackflow.config";
116+
117+
const { Stack } = stackflow({
118+
config,
119+
components: {
120+
HomeActivity,
121+
ArticleActivity,
122+
},
123+
plugins: [persistencePlugin],
124+
});
125+
```
126+
127+
## Behavior
128+
129+
### Restoring a snapshot
130+
131+
Stackflow calls `storage.load()` synchronously while creating the stack. When a
132+
record is present, the plugin:
133+
134+
1. passes its untrusted `metadata` through `strategy.metadata.parse()`;
135+
2. passes the parsed record and Stackflow's `initialContext` to
136+
`strategy.shouldReuse()`; and
137+
3. provides the snapshot to Stackflow when the strategy returns `true`.
138+
139+
Returning `null` from `storage.load()`, returning `false` from `shouldReuse()`,
140+
or returning `{ ok: false }` from `metadata.parse()` causes Stackflow to use its
141+
normal initial stack. A thrown `storage.load()` error has the same fallback and
142+
is reported as `StackSnapshotRecordLoadError` through `onRecordLoadError`.
143+
Metadata parse failures are reported as `StackSnapshotMetadataParseError`.
144+
145+
After the plugin accepts a record, core still validates and replays its
146+
snapshot against the current Stackflow configuration. `onLoadError` controls
147+
what happens when that step fails:
148+
149+
```typescript
150+
stackPersistencePlugin({
151+
storage,
152+
strategy,
153+
onLoadError({ error, initialContext }) {
154+
reportSnapshotError(error, initialContext);
155+
156+
return { policy: "propagate" };
157+
},
158+
});
159+
```
160+
161+
The default policy is `{ policy: "recover" }`, which discards the unusable
162+
snapshot and creates the normal initial stack. Return `{ policy: "propagate" }`
163+
to let the core `SnapshotLoadError` abort stack creation.
164+
165+
`storage.load()`, metadata parsing, the reuse decision, and snapshot loading
166+
are all synchronous. Prepare data before creating the stack when the backing
167+
store has an asynchronous read API. In server environments, return `null` when
168+
the chosen storage is unavailable, as in the example above.
169+
170+
### Saving snapshots
171+
172+
The plugin captures a record during stack initialization and after stack
173+
changes, but calls `storage.save()` only when `globalTransitionState` is
174+
`"idle"`. Each record contains the complete core snapshot and metadata created
175+
from the same current `Stack` and `StackSnapshot`. The `metadata.create()`
176+
callback receives both values.
177+
178+
`storage.save()` runs asynchronously and does not block navigation. The plugin
179+
does not wait for an earlier save before starting a later one, so storage backed
180+
by asynchronous I/O must prevent an older request from overwriting a newer
181+
record. A rejected save is wrapped in `StackSnapshotRecordSaveError` and sent
182+
to `onRecordSaveError`. Without a handler, the wrapped error is rethrown from
183+
the promise rejection.
184+
185+
The storage owns serialization. Ensure that the selected codec can represent
186+
the values carried by your application's snapshot events and metadata.
187+
188+
### Composing reuse policies
189+
190+
Use `composeStrategies()` when a record must satisfy several independent reuse
191+
policies:
192+
193+
```typescript
194+
import { composeStrategies } from "@stackflow/plugin-stack-persistence";
195+
196+
const strategy = composeStrategies({
197+
appVersion: appVersionStrategy,
198+
session: sessionStrategy,
199+
});
200+
```
201+
202+
The composed strategy stores a versioned metadata envelope. On load, it
203+
requires exactly the same strategy keys, parses each strategy's metadata, and
204+
reuses the snapshot only when every `shouldReuse()` call returns `true`.
205+
206+
## Error handling
207+
208+
- `onRecordLoadError` receives `StackSnapshotRecordLoadError` when
209+
`storage.load()` throws and `StackSnapshotMetadataParseError` when
210+
`metadata.parse()` returns `{ ok: false }`. In both cases, startup falls back
211+
to the normal initial stack.
212+
- `onLoadError` receives core `SnapshotLoadError` values for snapshots that
213+
cannot be loaded with the current configuration. It recovers by default.
214+
- `onRecordSaveError` receives `StackSnapshotRecordSaveError` when the promise
215+
returned by `storage.save()` rejects.
216+
217+
The error wrappers expose the original value as `cause` for record load/save
218+
errors and as `detail` for metadata parse errors. Exceptions thrown directly by
219+
`metadata.parse()` or `shouldReuse()` are outside these recovery callbacks and
220+
propagate during stack creation. Return `{ ok: false, detail }` or `false` for
221+
expected rejection paths.
222+
223+
## Public API
224+
225+
### `stackPersistencePlugin(options)`
226+
227+
Creates a Stackflow core plugin. `options` contains:
228+
229+
- `storage` — required `StackSnapshotStorage<Metadata>` implementation;
230+
- `strategy` — required `StackSnapshotStrategy<Metadata>` implementation;
231+
- `onRecordLoadError` — optional storage-load and metadata-parse error handler;
232+
- `onRecordSaveError` — optional save-rejection handler; and
233+
- `onLoadError` — optional core snapshot-load policy handler.
234+
235+
Only one Stackflow plugin can provide a non-null snapshot during stack
236+
creation. If this plugin accepts a record while another plugin also provides a
237+
snapshot, core rejects the conflicting configuration.
238+
239+
### Storage and record types
240+
241+
```typescript
242+
interface StackSnapshotStorage<Metadata> {
243+
load(): StackSnapshotRecord<unknown> | null;
244+
save(record: StackSnapshotRecord<Metadata>): Promise<void>;
245+
}
246+
247+
type StackSnapshotRecord<Metadata> = {
248+
snapshot: StackSnapshot;
249+
metadata: Metadata;
250+
};
251+
```
252+
253+
Loaded metadata is deliberately `unknown`; the strategy must validate it before
254+
the plugin can use the record.
255+
256+
### Strategy types
257+
258+
```typescript
259+
interface StackSnapshotMetadataDefinition<Metadata> {
260+
create(args: { stack: Stack; snapshot: StackSnapshot }): Metadata;
261+
parse(data: unknown): Result<Metadata>;
262+
}
263+
264+
interface StackSnapshotStrategy<Metadata> {
265+
metadata: StackSnapshotMetadataDefinition<Metadata>;
266+
shouldReuse(args: {
267+
record: StackSnapshotRecord<Metadata>;
268+
initialContext: unknown;
269+
}): boolean;
270+
}
271+
272+
type Result<Value> =
273+
| { ok: true; value: Value }
274+
| { ok: false; detail?: unknown };
275+
```
276+
277+
`composeStrategies()` returns another `StackSnapshotStrategy`, so composed
278+
strategies can be passed to `stackPersistencePlugin()` without special setup.
279+
The inferred envelope type is exported as `StrategiesMetadata`.
280+
281+
### Error classes
282+
283+
- `StackSnapshotRecordLoadError` — exposes the thrown storage value as `cause`.
284+
- `StackSnapshotMetadataParseError` — exposes the parser failure detail as
285+
`detail`.
286+
- `StackSnapshotRecordSaveError` — exposes the rejected storage value as
287+
`cause`.

0 commit comments

Comments
 (0)