|
| 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