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