react-mnemonic 0.1.1-alpha.0 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,16 +7,13 @@ Persistent, type-safe state management for React.
7
7
  [![license](https://img.shields.io/npm/l/react-mnemonic.svg)](./LICENSE.md)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)](https://www.typescriptlang.org/)
9
9
 
10
- > **⚠️ Alpha Software** — This library is in active development. APIs may
11
- > change between releases without prior notice. Use in production at your own
12
- > risk.
13
-
14
- ---
15
-
16
10
  **react-mnemonic** gives your React components persistent memory. Values survive
17
11
  page refreshes, synchronize across tabs, and stay type-safe end-to-end -- all
18
12
  through a single hook that works like `useState`.
19
13
 
14
+ `1.0.0` is currently being shipped as a beta on the npm `beta` dist-tag while
15
+ the release candidate hardening work finishes.
16
+
20
17
  ## Features
21
18
 
22
19
  - **`useState`-like API** -- `useMnemonicKey` returns `{ value, set, reset, remove }`
@@ -25,6 +22,9 @@ through a single hook that works like `useState`.
25
22
  - **Cross-tab sync** -- opt-in `listenCrossTab` uses the browser `storage` event
26
23
  - **Pluggable storage** -- bring your own backend via the `StorageLike` interface (IndexedDB, sessionStorage, etc.)
27
24
  - **Schema versioning and migration** -- upgrade stored data with versioned schemas and migration rules
25
+ - **Structural migration helpers** -- optional tree utilities for idempotent insert/rename/dedupe migration steps
26
+ - **Read-time reconciliation** -- selectively enforce new defaults on persisted values without clearing the whole key
27
+ - **Recovery helpers** -- build user-facing soft reset and hard reset flows with namespace-scoped clear helpers
28
28
  - **Write-time normalization** -- migrations where `fromVersion === toVersion` run on every write
29
29
  - **Lifecycle callbacks** -- `onMount` and `onChange` hooks
30
30
  - **DevTools** -- inspect and mutate state from the browser console
@@ -34,20 +34,21 @@ through a single hook that works like `useState`.
34
34
  ## Installation
35
35
 
36
36
  ```bash
37
- npm install react-mnemonic
37
+ npm install react-mnemonic@beta
38
38
  ```
39
39
 
40
40
  ```bash
41
- yarn add react-mnemonic
41
+ yarn add react-mnemonic@beta
42
42
  ```
43
43
 
44
44
  ```bash
45
- pnpm add react-mnemonic
45
+ pnpm add react-mnemonic@beta
46
46
  ```
47
47
 
48
48
  ### Peer dependencies
49
49
 
50
- React 18 or later is required.
50
+ React 18 or later is required. CI verifies packaged-consumer installs against
51
+ React 18 and React 19.
51
52
 
52
53
  ```json
53
54
  {
@@ -90,6 +91,25 @@ export default function App() {
90
91
  The counter value persists in `localStorage` under the key `my-app.count` and
91
92
  survives full page reloads.
92
93
 
94
+ Persist only the durable slice of your app state. `useMnemonicKey` stores
95
+ whatever you pass to `set`, so keep transient UI state like loading flags,
96
+ hover state, and draft search text in plain React state unless you explicitly
97
+ want them to rehydrate after reload. See the
98
+ [Persisted vs Ephemeral State guide](https://thirtytwobits.github.io/react-mnemonic/docs/guides/persisted-vs-ephemeral-state)
99
+ for patterns and an interactive example.
100
+
101
+ For self-service recovery UX, pair your per-key hooks with
102
+ `useMnemonicRecovery` so users can clear stale filters, reset broken settings,
103
+ or fully wipe a namespace without opening DevTools. See the
104
+ [Reset and Recovery guide](https://thirtytwobits.github.io/react-mnemonic/docs/guides/reset-and-recovery)
105
+ for soft-reset and hard-reset recipes.
106
+
107
+ If a field must stay cleared across reloads, model it as nullable and persist
108
+ `null` explicitly. `remove()` deletes the key and falls back to `defaultValue`,
109
+ while `reset()` writes the default again. See the
110
+ [Clearable Persisted Values guide](https://thirtytwobits.github.io/react-mnemonic/docs/guides/clearable-persisted-values)
111
+ for the canonical nullable pattern.
112
+
93
113
  ## API
94
114
 
95
115
  ### `<MnemonicProvider>`
@@ -125,16 +145,48 @@ const { value, set, reset, remove } = useMnemonicKey<T>(key, options);
125
145
  | `reset` | `() => void` | Reset to `defaultValue` and persist it |
126
146
  | `remove` | `() => void` | Delete the key from storage entirely |
127
147
 
148
+ For clearable fields, remember the semantic split:
149
+
150
+ - `set(null)` persists a cleared value and stays cleared after reload
151
+ - `remove()` deletes the key, so the next read falls back to `defaultValue`
152
+ - `reset()` persists `defaultValue`
153
+
128
154
  #### Options
129
155
 
130
- | Option | Type | Default | Description |
131
- | ---------------- | ------------------------------------------------- | ----------- | --------------------------------------------------- |
132
- | `defaultValue` | `T \| ((error?: CodecError \| SchemaError) => T)` | _required_ | Fallback value or error-aware factory |
133
- | `codec` | `Codec<T>` | `JSONCodec` | Encode/decode strategy (bypasses schema validation) |
134
- | `onMount` | `(value: T) => void` | -- | Called once with the initial value |
135
- | `onChange` | `(value: T, prev: T) => void` | -- | Called on every value change |
136
- | `listenCrossTab` | `boolean` | `false` | Sync via the browser `storage` event |
137
- | `schema` | `{ version?: number }` | -- | Pin writes to a specific schema version |
156
+ | Option | Type | Default | Description |
157
+ | ---------------- | ------------------------------------------------- | ----------- | ------------------------------------------------------------- |
158
+ | `defaultValue` | `T \| ((error?: CodecError \| SchemaError) => T)` | _required_ | Fallback value or error-aware factory |
159
+ | `codec` | `Codec<T>` | `JSONCodec` | Encode/decode strategy (bypasses schema validation) |
160
+ | `reconcile` | `(value: T, context: ReconcileContext) => T` | -- | Adjust a persisted value after read and persist if it changes |
161
+ | `onMount` | `(value: T) => void` | -- | Called once with the initial value |
162
+ | `onChange` | `(value: T, prev: T) => void` | -- | Called on every value change |
163
+ | `listenCrossTab` | `boolean` | `false` | Sync via the browser `storage` event |
164
+ | `schema` | `{ version?: number }` | -- | Pin writes to a specific schema version |
165
+
166
+ ### `useMnemonicRecovery(options)`
167
+
168
+ Hook for namespace-scoped recovery actions such as "clear saved filters" or
169
+ "reset all persisted app data".
170
+
171
+ ```ts
172
+ const { namespace, canEnumerateKeys, listKeys, clearAll, clearKeys, clearMatching } = useMnemonicRecovery({
173
+ onRecover: (event) => console.log(event.action, event.clearedKeys),
174
+ });
175
+ ```
176
+
177
+ | Return | Type | Description |
178
+ | ------------------ | --------------------------------------------------- | ----------------------------------------------------- |
179
+ | `namespace` | `string` | Current provider namespace |
180
+ | `canEnumerateKeys` | `boolean` | Whether the storage backend can list namespace keys |
181
+ | `listKeys` | `() => string[]` | List visible unprefixed keys in the current namespace |
182
+ | `clearAll` | `() => string[]` | Clear every key in the namespace |
183
+ | `clearKeys` | `(keys: readonly string[]) => string[]` | Clear an explicit set of unprefixed keys |
184
+ | `clearMatching` | `(predicate: (key: string) => boolean) => string[]` | Clear keys whose names match a predicate |
185
+
186
+ `clearAll()` and `clearMatching()` require an enumerable storage backend such
187
+ as `localStorage` or `sessionStorage`. If your custom storage does not support
188
+ `length` and `key(index)`, use `clearKeys([...])` with the explicit durable-key
189
+ list your app owns.
138
190
 
139
191
  ### Codecs
140
192
 
@@ -172,6 +224,8 @@ interface StorageLike {
172
224
  `onExternalChange` enables cross-tab sync for non-localStorage backends (e.g.
173
225
  IndexedDB over `BroadcastChannel`). The library handles all error cases
174
226
  internally -- see the `StorageLike` JSDoc for the full error-handling contract.
227
+ Namespace-wide recovery helpers can only enumerate keys when the backend also
228
+ implements `length` and `key(index)`.
175
229
 
176
230
  ### `validateJsonSchema(schema, value)`
177
231
 
@@ -318,84 +372,109 @@ const normalizer: MigrationRule = {
318
372
  };
319
373
  ```
320
374
 
375
+ ### Reconciliation
376
+
377
+ Use `reconcile` when you want to keep persisted data but selectively enforce
378
+ new application defaults after the value has been decoded and any read-time
379
+ migrations have already run.
380
+
381
+ ```ts
382
+ const { value } = useMnemonicKey("preferences", {
383
+ defaultValue: { theme: "dark", density: "comfortable", accents: true },
384
+ reconcile: (persisted, { persistedVersion }) => ({
385
+ ...persisted,
386
+ accents: persistedVersion === 0 ? true : persisted.accents,
387
+ }),
388
+ });
389
+ ```
390
+
391
+ Use a schema migration when the stored shape must move from one explicit version
392
+ to another. Use `reconcile` for conditional, field-level policy changes such as
393
+ rolling out a new default while preserving the rest of a user's stored data.
394
+
395
+ ### Structural migration helpers
396
+
397
+ For layout-like data that already uses `id` and `children`, Mnemonic ships
398
+ optional pure helpers for common idempotent migration steps:
399
+
400
+ ```ts
401
+ import { insertChildIfMissing, renameNode, dedupeChildrenBy } from "react-mnemonic";
402
+
403
+ const migrated = dedupeChildrenBy(
404
+ renameNode(insertChildIfMissing(layout, "sidebar", { id: "search", title: "Search" }), "prefs", "preferences"),
405
+ (node) => node.id,
406
+ );
407
+ ```
408
+
409
+ Use these inside your `MigrationRule.migrate` functions when you want repeatable
410
+ tree edits without hand-writing the same traversal logic each time. See the
411
+ [Schema Migration guide](https://thirtytwobits.github.io/react-mnemonic/docs/guides/schema-migration)
412
+ for a cookbook example and custom adapter usage.
413
+
321
414
  ### Example schema registry
322
415
 
323
416
  A schema registry stores versioned schemas for each key, and resolves migration
324
- paths to upgrade stored data. Schemas are plain JSON (serializable); migrations
325
- are procedural functions.
417
+ paths to upgrade stored data. For the common immutable case, use
418
+ `createSchemaRegistry(...)` instead of hand-rolling the indexing boilerplate.
326
419
 
327
420
  ```tsx
328
421
  import {
422
+ createSchemaRegistry,
329
423
  MnemonicProvider,
330
424
  useMnemonicKey,
331
- type SchemaRegistry,
332
425
  type KeySchema,
333
426
  type MigrationRule,
334
427
  } from "react-mnemonic";
335
428
 
336
- const schemas = new Map<string, KeySchema>();
337
- const migrations: MigrationRule[] = [];
338
-
339
- const registry: SchemaRegistry = {
340
- getSchema: (key, version) => schemas.get(`${key}:${version}`),
341
- getLatestSchema: (key) =>
342
- Array.from(schemas.values())
343
- .filter((schema) => schema.key === key)
344
- .sort((a, b) => b.version - a.version)[0],
345
- getMigrationPath: (key, fromVersion, toVersion) => {
346
- const byKey = migrations.filter((rule) => rule.key === key);
347
- const path: MigrationRule[] = [];
348
- let cur = fromVersion;
349
- while (cur < toVersion) {
350
- const next = byKey.find((rule) => rule.fromVersion === cur);
351
- if (!next) return null;
352
- path.push(next);
353
- cur = next.toVersion;
354
- }
355
- return path;
356
- },
357
- getWriteMigration: (key, version) => {
358
- return migrations.find((r) => r.key === key && r.fromVersion === version && r.toVersion === version);
359
- },
360
- registerSchema: (schema) => {
361
- const id = `${schema.key}:${schema.version}`;
362
- if (schemas.has(id)) throw new Error(`Schema already registered for ${id}`);
363
- schemas.set(id, schema);
429
+ const schemas: KeySchema[] = [
430
+ {
431
+ key: "profile",
432
+ version: 1,
433
+ schema: {
434
+ type: "object",
435
+ properties: { name: { type: "string" }, email: { type: "string" } },
436
+ required: ["name", "email"],
437
+ },
364
438
  },
365
- };
366
-
367
- registry.registerSchema({
368
- key: "profile",
369
- version: 1,
370
- schema: {
371
- type: "object",
372
- properties: { name: { type: "string" }, email: { type: "string" } },
373
- required: ["name", "email"],
439
+ {
440
+ key: "profile",
441
+ version: 2,
442
+ schema: {
443
+ type: "object",
444
+ properties: {
445
+ name: { type: "string" },
446
+ email: { type: "string" },
447
+ migratedAt: { type: "string" },
448
+ },
449
+ required: ["name", "email", "migratedAt"],
450
+ },
374
451
  },
375
- });
376
-
377
- migrations.push({
378
- key: "profile",
379
- fromVersion: 1,
380
- toVersion: 2,
381
- migrate: (value) => {
382
- const v1 = value as { name: string; email: string };
383
- return { ...v1, migratedAt: new Date().toISOString() };
452
+ ];
453
+
454
+ const migrations: MigrationRule[] = [
455
+ {
456
+ key: "profile",
457
+ fromVersion: 1,
458
+ toVersion: 2,
459
+ migrate: (value) => {
460
+ const v1 = value as { name: string; email: string };
461
+ return { ...v1, migratedAt: new Date().toISOString() };
462
+ },
384
463
  },
385
- });
386
-
387
- registry.registerSchema({
388
- key: "profile",
389
- version: 2,
390
- schema: {
391
- type: "object",
392
- properties: {
393
- name: { type: "string" },
394
- email: { type: "string" },
395
- migratedAt: { type: "string" },
464
+ {
465
+ key: "profile",
466
+ fromVersion: 2,
467
+ toVersion: 2,
468
+ migrate: (value) => {
469
+ const profile = value as { name: string; email: string; migratedAt: string };
470
+ return { ...profile, email: profile.email.trim().toLowerCase() };
396
471
  },
397
- required: ["name", "email", "migratedAt"],
398
472
  },
473
+ ];
474
+
475
+ const registry = createSchemaRegistry({
476
+ schemas,
477
+ migrations,
399
478
  });
400
479
 
401
480
  function ProfileEditor() {
@@ -410,6 +489,11 @@ function ProfileEditor() {
410
489
  </MnemonicProvider>;
411
490
  ```
412
491
 
492
+ `createSchemaRegistry` validates duplicate schemas and ambiguous migration
493
+ graphs up front. If you need runtime schema registration for
494
+ `schemaMode="autoschema"`, keep a custom mutable `SchemaRegistry`
495
+ implementation.
496
+
413
497
  ### Registry immutability
414
498
 
415
499
  In `default` and `strict` modes, the schema registry is treated as immutable for
@@ -452,12 +536,14 @@ Enable the console inspector in development:
452
536
  Then in the browser console:
453
537
 
454
538
  ```js
455
- __REACT_MNEMONIC_DEVTOOLS__.app.dump(); // table of all keys
456
- __REACT_MNEMONIC_DEVTOOLS__.app.get("theme"); // read a decoded value
457
- __REACT_MNEMONIC_DEVTOOLS__.app.set("theme", "dark"); // write
458
- __REACT_MNEMONIC_DEVTOOLS__.app.remove("theme"); // delete
459
- __REACT_MNEMONIC_DEVTOOLS__.app.keys(); // list all keys
460
- __REACT_MNEMONIC_DEVTOOLS__.app.clear(); // remove all keys
539
+ const app = window.__REACT_MNEMONIC_DEVTOOLS__?.resolve("app");
540
+
541
+ app?.dump(); // table of all keys
542
+ app?.get("theme"); // read a decoded value
543
+ app?.set("theme", "dark"); // write
544
+ app?.remove("theme"); // delete
545
+ app?.keys(); // list all keys
546
+ app?.clear(); // remove all keys
461
547
  ```
462
548
 
463
549
  ## TypeScript