Skip to content

Commit 2833bfd

Browse files
docs: add plugin-stack-persistence README (FEP-2672) (#747)
1 parent 1568cee commit 2833bfd

1 file changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# @stackflow/plugin-stack-persistence
2+
3+
Applications often need to preserve a user's navigation context across a page
4+
reload or JavaScript runtime replacement.
5+
6+
`@stackflow/plugin-stack-persistence` saves a complete Stackflow snapshot and
7+
restores it when the stack starts again. The package is framework-neutral and
8+
leaves the storage medium, serialization, record lifetime, and reuse policy to
9+
your application.
10+
11+
## Installation
12+
13+
```bash
14+
yarn add @stackflow/plugin-stack-persistence
15+
```
16+
17+
## Setup
18+
19+
Add `stackPersistencePlugin()` to your Stackflow configuration with a storage
20+
and reuse strategy:
21+
22+
```typescript
23+
import { stackPersistencePlugin } from "@stackflow/plugin-stack-persistence";
24+
import { stackflow } from "@stackflow/react";
25+
import { ArticleActivity } from "./ArticleActivity";
26+
import { HomeActivity } from "./HomeActivity";
27+
import { snapshotStorage, snapshotStrategy } from "./persistence";
28+
import { config } from "./stackflow.config";
29+
30+
const { Stack } = stackflow({
31+
config,
32+
components: {
33+
HomeActivity,
34+
ArticleActivity,
35+
},
36+
plugins: [
37+
stackPersistencePlugin({
38+
storage: snapshotStorage,
39+
strategy: snapshotStrategy,
40+
}),
41+
],
42+
});
43+
```
44+
45+
## Usage
46+
47+
The storage must provide a synchronous loader and an asynchronous saver. The
48+
strategy validates stored metadata and decides whether its snapshot can be
49+
reused.
50+
51+
The following example stores snapshots in `localStorage`, rejects records from
52+
another application version, and expires records after seven days:
53+
54+
```typescript
55+
import type {
56+
StackSnapshotRecord,
57+
StackSnapshotStorage,
58+
StackSnapshotStrategy,
59+
} from "@stackflow/plugin-stack-persistence";
60+
61+
const STORAGE_KEY = "stackflow.snapshot";
62+
const APP_VERSION = 1 as const;
63+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
64+
65+
type SnapshotMetadata = {
66+
appVersion: number;
67+
savedAt: number;
68+
};
69+
70+
export const snapshotStorage: StackSnapshotStorage<SnapshotMetadata> = {
71+
load() {
72+
if (typeof window === "undefined") return null;
73+
74+
const serialized = window.localStorage.getItem(STORAGE_KEY);
75+
76+
return serialized === null
77+
? null
78+
: (JSON.parse(serialized) as StackSnapshotRecord<unknown>);
79+
},
80+
async save(record) {
81+
if (typeof window === "undefined") return;
82+
83+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
84+
},
85+
};
86+
87+
export const snapshotStrategy: StackSnapshotStrategy<SnapshotMetadata> = {
88+
metadata: {
89+
create() {
90+
return {
91+
appVersion: APP_VERSION,
92+
savedAt: Date.now(),
93+
};
94+
},
95+
parse(data) {
96+
if (
97+
data === null ||
98+
typeof data !== "object" ||
99+
!("appVersion" in data) ||
100+
typeof data.appVersion !== "number" ||
101+
!("savedAt" in data) ||
102+
typeof data.savedAt !== "number"
103+
) {
104+
return {
105+
ok: false,
106+
detail: "invalid snapshot metadata",
107+
};
108+
}
109+
110+
return {
111+
ok: true,
112+
value: {
113+
appVersion: data.appVersion,
114+
savedAt: data.savedAt,
115+
},
116+
};
117+
},
118+
},
119+
shouldReuse({ record }) {
120+
return (
121+
record.metadata.appVersion === APP_VERSION &&
122+
Date.now() - record.metadata.savedAt < MAX_AGE_MS
123+
);
124+
},
125+
};
126+
```
127+
128+
## Behavior
129+
130+
### Restoring a snapshot
131+
132+
The plugin attempts to restore a record while the stack is being created. It
133+
restores the snapshot only when the record is present, its metadata is valid,
134+
the strategy accepts it for reuse, and Stackflow can load the snapshot with the
135+
current configuration.
136+
137+
### Error handling
138+
139+
| Condition | Result |
140+
| --- | --- |
141+
| No record is available | Stackflow starts with its normal initial stack. |
142+
| The storage cannot load the record or its metadata is invalid | Stackflow starts with its normal initial stack. An optional callback can observe the failure. |
143+
| The strategy rejects the record | Stackflow starts with its normal initial stack without reporting an error. |
144+
| Stackflow cannot load the accepted snapshot | The plugin recovers with the normal initial stack by default. Applications can choose to propagate the error and abort stack creation. |
145+
| Saving the record fails | An optional callback handles the failure; without one, the plugin rethrows the wrapped promise rejection. |
146+
147+
### Storage and strategy requirements
148+
149+
- `storage.load()` must return a complete record or `null` synchronously.
150+
- `storage.save()` must return a `Promise<void>`. Save requests can overlap, so
151+
asynchronous storage must prevent an older request from overwriting a newer
152+
record.
153+
- Storage owns serialization. Its codec must round-trip the snapshot and
154+
metadata values produced by the application.
155+
- `metadata.parse()` must treat loaded metadata as untrusted input and return
156+
`{ ok: false }` for malformed data.
157+
- `shouldReuse()` must return `false` for valid records that should not be used
158+
in the current application context, such as incompatible or expired records.
159+
160+
## API
161+
162+
### `stackPersistencePlugin()`
163+
164+
```typescript
165+
function stackPersistencePlugin<Metadata>(
166+
options: StackPersistencePluginOptions<Metadata>,
167+
): StackflowPlugin;
168+
```
169+
170+
Creates a Stackflow core plugin.
171+
172+
| Option | Description |
173+
| --- | --- |
174+
| `storage` | Required `StackSnapshotStorage<Metadata>` implementation. |
175+
| `strategy` | Required `StackSnapshotStrategy<Metadata>` implementation. |
176+
| `onRecordLoadError` | Receives storage-load and metadata-parse errors. |
177+
| `onRecordSaveError` | Handles storage-save rejections. Without a handler, the wrapped rejection is rethrown. |
178+
| `onLoadError` | Chooses whether to recover from or propagate a core snapshot-load error. Defaults to recovery. |
179+
180+
### Storage and record types
181+
182+
```typescript
183+
interface StackSnapshotStorage<Metadata> {
184+
load(): StackSnapshotRecord<unknown> | null;
185+
save(record: StackSnapshotRecord<Metadata>): Promise<void>;
186+
}
187+
188+
type StackSnapshotRecord<Metadata> = {
189+
snapshot: StackSnapshot;
190+
metadata: Metadata;
191+
};
192+
```
193+
194+
Loaded metadata is deliberately `unknown`; the strategy must validate it before
195+
the plugin can use the record.
196+
197+
### Strategy types
198+
199+
```typescript
200+
interface StackSnapshotMetadataDefinition<Metadata> {
201+
create(args: { stack: Stack; snapshot: StackSnapshot }): Metadata;
202+
parse(data: unknown): Result<Metadata>;
203+
}
204+
205+
interface StackSnapshotStrategy<Metadata> {
206+
metadata: StackSnapshotMetadataDefinition<Metadata>;
207+
shouldReuse(args: {
208+
record: StackSnapshotRecord<Metadata>;
209+
initialContext: unknown;
210+
}): boolean;
211+
}
212+
213+
type Result<Value> =
214+
| { ok: true; value: Value }
215+
| { ok: false; detail?: unknown };
216+
```
217+
218+
`metadata.create()` produces metadata for new records. `metadata.parse()` is
219+
the only boundary that promotes loaded `unknown` data to `Metadata`, and
220+
`shouldReuse()` decides whether a successfully parsed record is compatible with
221+
the current `initialContext`.
222+
223+
### `composeStrategies()`
224+
225+
```typescript
226+
function composeStrategies<
227+
const Strategies extends Record<string, StackSnapshotStrategy<any>>,
228+
>(
229+
strategies: Strategies,
230+
): StackSnapshotStrategy<StrategiesMetadata<Strategies>>;
231+
```
232+
233+
Combines keyed strategies into another `StackSnapshotStrategy`. The composed
234+
strategy can be passed to `stackPersistencePlugin()` without special setup,
235+
and its inferred metadata envelope type is exported as `StrategiesMetadata`.
236+
Every child parser and reuse predicate must succeed. Adding, removing, or
237+
renaming a strategy key makes previously composed metadata invalid.

0 commit comments

Comments
 (0)