@plq/use-persisted-state 1.4.1 → 1.4.2

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.
Files changed (63) hide show
  1. package/lib/create-async-persisted-state.d.ts.map +1 -1
  2. package/lib/create-async-persisted-state.js +10 -50
  3. package/lib/create-async-persisted-state.js.map +1 -1
  4. package/lib/create-persisted-state.d.ts.map +1 -1
  5. package/lib/create-persisted-state.js +4 -16
  6. package/lib/create-persisted-state.js.map +1 -1
  7. package/lib/index.d.ts.map +1 -1
  8. package/lib/storages/chrome-storage.d.ts.map +1 -1
  9. package/lib/storages/chrome-storage.js +6 -15
  10. package/lib/storages/chrome-storage.js.map +1 -1
  11. package/lib/storages/local-storage.d.ts.map +1 -1
  12. package/lib/storages/local-storage.js +1 -2
  13. package/lib/storages/local-storage.js.map +1 -1
  14. package/lib/storages/session-storage.d.ts.map +1 -1
  15. package/lib/storages/session-storage.js +1 -2
  16. package/lib/storages/session-storage.js.map +1 -1
  17. package/lib/utils/change-notifier.d.ts +1 -8
  18. package/lib/utils/change-notifier.d.ts.map +1 -1
  19. package/lib/utils/change-notifier.js +1 -10
  20. package/lib/utils/change-notifier.js.map +1 -1
  21. package/lib/utils/create-web-storage.d.ts +1 -8
  22. package/lib/utils/create-web-storage.d.ts.map +1 -1
  23. package/lib/utils/create-web-storage.js +5 -17
  24. package/lib/utils/create-web-storage.js.map +1 -1
  25. package/lib/utils/extension-storage.d.ts +2 -12
  26. package/lib/utils/extension-storage.d.ts.map +1 -1
  27. package/lib/utils/extension-storage.js +3 -14
  28. package/lib/utils/extension-storage.js.map +1 -1
  29. package/lib/utils/get-new-item.d.ts +1 -12
  30. package/lib/utils/get-new-item.d.ts.map +1 -1
  31. package/lib/utils/get-new-item.js +2 -16
  32. package/lib/utils/get-new-item.js.map +1 -1
  33. package/lib/utils/get-persisted-value.d.ts +1 -7
  34. package/lib/utils/get-persisted-value.d.ts.map +1 -1
  35. package/lib/utils/get-persisted-value.js +3 -13
  36. package/lib/utils/get-persisted-value.js.map +1 -1
  37. package/lib/utils/is-async-storage.d.ts +1 -7
  38. package/lib/utils/is-async-storage.d.ts.map +1 -1
  39. package/lib/utils/is-async-storage.js +6 -25
  40. package/lib/utils/is-async-storage.js.map +1 -1
  41. package/lib/utils/storage-event-router.d.ts +1 -5
  42. package/lib/utils/storage-event-router.d.ts.map +1 -1
  43. package/lib/utils/storage-event-router.js +3 -14
  44. package/lib/utils/storage-event-router.js.map +1 -1
  45. package/lib/utils/use-storage-handler.d.ts +1 -10
  46. package/lib/utils/use-storage-handler.d.ts.map +1 -1
  47. package/lib/utils/use-storage-handler.js +10 -57
  48. package/lib/utils/use-storage-handler.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/create-async-persisted-state.ts +10 -50
  51. package/src/create-persisted-state.ts +4 -16
  52. package/src/index.ts +1 -4
  53. package/src/storages/chrome-storage.ts +6 -18
  54. package/src/storages/local-storage.ts +1 -2
  55. package/src/storages/session-storage.ts +1 -2
  56. package/src/utils/change-notifier.ts +1 -10
  57. package/src/utils/create-web-storage.ts +5 -17
  58. package/src/utils/extension-storage.ts +3 -14
  59. package/src/utils/get-new-item.ts +2 -16
  60. package/src/utils/get-persisted-value.ts +3 -13
  61. package/src/utils/is-async-storage.ts +6 -26
  62. package/src/utils/storage-event-router.ts +3 -14
  63. package/src/utils/use-storage-handler.ts +11 -68
@@ -6,14 +6,7 @@ export interface ChangeNotifier {
6
6
  onChanged: StorageChangeEvent
7
7
  }
8
8
 
9
- /**
10
- * Holds the subscribers of a single change source. Each adapter — and, for the
11
- * extension backends, each storage area — owns one, so a change never reaches
12
- * listeners that subscribed elsewhere.
13
- *
14
- * Routing a backend's events to the right notifier stays with the adapter: the
15
- * extensions demultiplex one event by area name, the DOM one by storage object.
16
- */
9
+ /** Holds the subscribers of a single change source, so a change never reaches listeners that subscribed elsewhere. */
17
10
  export function createChangeNotifier(): ChangeNotifier {
18
11
  const listeners = new Set<StorageChangeListener>()
19
12
 
@@ -23,8 +16,6 @@ export function createChangeNotifier(): ChangeNotifier {
23
16
  listener(changes)
24
17
  }
25
18
  },
26
- // Lets an adapter release a shared resource once nobody is listening; the
27
- // set behind onChanged stays the only count, so the two cannot drift.
28
19
  hasListeners() {
29
20
  return listeners.size > 0
30
21
  },
@@ -6,21 +6,12 @@ function toKeyList(keys: string | string[]): string[] {
6
6
  return Array.isArray(keys) ? keys : [keys]
7
7
  }
8
8
 
9
- /**
10
- * Adapts a Web Storage area to the library's `Storage` contract.
11
- *
12
- * Pass `undefined` where the global is missing, as it is on a server: the
13
- * adapter then reads back nothing and **silently discards every write**, so a
14
- * consumer keeps its initial value instead of the import throwing. Listeners
15
- * are still accepted and reported, they simply never fire.
16
- */
9
+ /** Adapts a Web Storage area. With `undefined`, as on a server, it reads back nothing and discards every write. */
17
10
  export default (storage: globalThis.Storage | undefined): Storage => {
18
- // Per-instance notifier: the localStorage and sessionStorage adapters must not
19
- // share listeners, or a write to one area notifies subscribers of the other.
11
+ // Per instance: the localStorage and sessionStorage adapters must not share listeners.
20
12
  const notifier = createChangeNotifier()
21
13
 
22
- // Only a missing global is inert. Anything else a caller passes has to fail
23
- // on first use instead of swallowing their writes.
14
+ // Only a missing global is inert; anything else a caller passes has to fail on first use.
24
15
  if (storage === undefined) {
25
16
  return {
26
17
  get: () => ({}),
@@ -32,9 +23,7 @@ export default (storage: globalThis.Storage | undefined): Storage => {
32
23
 
33
24
  const route: StorageEventRoute = { storage, fire: notifier.fire }
34
25
 
35
- // The DOM subscription is shared and reference counted: an adapter joins it
36
- // with its first listener and lets go with its last, so factory calls nobody
37
- // listens to leave nothing attached to the global object.
26
+ // The DOM subscription is shared and reference counted, so adapters nobody listens to attach nothing.
38
27
  const onChanged: StorageChangeEvent = {
39
28
  addListener(listener) {
40
29
  notifier.onChanged.addListener(listener)
@@ -58,8 +47,7 @@ export default (storage: globalThis.Storage | undefined): Storage => {
58
47
  for (const key of toKeyList(keys)) {
59
48
  const item = storage.getItem(key)
60
49
 
61
- // `null` is the only answer that means absence: an empty string is a
62
- // value the caller stored, and reporting it as a missing key loses it.
50
+ // `null` is the only answer meaning absence; an empty string is a value the caller stored.
63
51
  if (item !== null) result[key] = item
64
52
  }
65
53
 
@@ -5,18 +5,12 @@ const TRACKED_AREAS = ['local', 'sync', 'managed'] as const
5
5
 
6
6
  export type Area = (typeof TRACKED_AREAS)[number]
7
7
 
8
- // Both browsers also report `session` changes, an area this library does not
9
- // expose. Dispatching one would read an undefined notifier and throw.
8
+ // Both browsers also report `session`, an area this library does not expose; dispatching one would throw.
10
9
  function isTrackedArea(area: string): area is Area {
11
10
  return (TRACKED_AREAS as readonly string[]).includes(area)
12
11
  }
13
12
 
14
- /**
15
- * Extension storage holds arbitrary JSON, while this library only ever writes
16
- * serialized strings. Anything else under a key belongs to other code and is
17
- * reported as absent — which is what effectively happened before, once such a
18
- * value reached `JSON.parse` and the hook fell back to its initial value.
19
- */
13
+ /** Extension storage holds arbitrary JSON; this library writes only strings, so anything else reads as absent. */
20
14
  export function toStoredValue(value: unknown): string | null | undefined {
21
15
  if (value === undefined) return
22
16
 
@@ -53,12 +47,7 @@ export interface ListenerRegistry {
53
47
  createOnChanged(area: Area): StorageChangeEvent
54
48
  }
55
49
 
56
- /**
57
- * Demultiplexes an extension's single `onChanged` event, which names the area
58
- * that changed, to one notifier per area. Each adapter owns its own registry,
59
- * so a change in one browser's storage never reaches listeners registered on
60
- * another's.
61
- */
50
+ /** Demultiplexes an extension's single `onChanged` event to one notifier per area, one registry per adapter. */
62
51
  export function createListenerRegistry(): ListenerRegistry {
63
52
  const notifiers: { [area in Area]: ChangeNotifier } = {
64
53
  local: createChangeNotifier(),
@@ -1,17 +1,6 @@
1
1
  import { isObject } from '@plq/is'
2
2
 
3
- /**
4
- * Merges `newValue` under `key` into the serialized entry a factory shares between all of its
5
- * hooks, and returns the entry to store.
6
- *
7
- * Reports and throws when the entry cannot be merged into, rather than answering with one built
8
- * from nothing. What this returns is written over the whole entry, so an unreadable entry is the
9
- * one case with nothing safe to return: every other hook's key would be replaced with nothing,
10
- * and the bytes a repair still needs would go with them.
11
- *
12
- * @throws {SyntaxError} when the entry is not valid JSON.
13
- * @throws {TypeError} when the entry is valid JSON but not an object of keys.
14
- */
3
+ /** Merges `newValue` under `key` into the shared entry, throwing rather than rebuilding one it cannot read. */
15
4
  export default function getNewItem<T>(key: string, persistedItem: string, newValue: T): string {
16
5
  let persist: unknown
17
6
 
@@ -24,10 +13,7 @@ export default function getNewItem<T>(key: string, persistedItem: string, newVal
24
13
  }
25
14
 
26
15
  if (!isObject(persist)) {
27
- // A shared backend can leave any JSON under the key, and neither a primitive nor an array
28
- // survives being merged into: `JSON.stringify` unwraps the first back to itself and keeps the
29
- // second an array, so the value being set disappears with the property added to it. A stored
30
- // `null` did not even get that far - it threw out of `Object.assign` and into the caller.
16
+ // Neither a primitive nor an array survives being merged into: the value being set would disappear.
31
17
  const err = new TypeError('the stored entry is not an object of keys')
32
18
 
33
19
  console.error("use-persisted-state: Can't write value to storage", err)
@@ -1,21 +1,13 @@
1
1
  import { isFunction, isObject } from '@plq/is'
2
2
 
3
- /**
4
- * Resolves the value a hook starts from: the persisted entry for `key` when the stored payload
5
- * carries one, the initial value otherwise.
6
- *
7
- * Presence is decided by the key rather than by the value, so a persisted `null` comes back as
8
- * the value the user set instead of being mistaken for an absence and replaced.
9
- */
3
+ /** Resolves a hook's starting value by key presence, so a persisted `null` is not mistaken for an absence. */
10
4
  export default function getPersistedValue<T>(key: string, initialValue: T | (() => T), persist?: string): T {
11
5
  let initialPersist: unknown
12
6
 
13
7
  try {
14
8
  initialPersist = persist ? JSON.parse(persist) : {}
15
9
  } catch (err) {
16
- // A shared backend can hold a foreign or truncated entry, so a parse failure is expected here
17
- // and must not stop the component mounting. It is reported rather than swallowed because the
18
- // fallback discards whatever was persisted, and because the change path logs the same failure.
10
+ // A shared backend can hold a foreign or truncated entry, so a parse failure must not stop the mount.
19
11
  console.error("use-persisted-state: Can't parse value from storage", err)
20
12
 
21
13
  initialPersist = {}
@@ -23,9 +15,7 @@ export default function getPersistedValue<T>(key: string, initialValue: T | (()
23
15
 
24
16
  let initialOrPersistedValue = isFunction(initialValue) ? initialValue() : initialValue
25
17
 
26
- // `JSON.parse` yields any JSON value, so a foreign entry can be a primitive,
27
- // and `in` throws on one. This runs in the `useState` initializer, where a
28
- // throw stops the component mounting instead of falling back.
18
+ // `in` throws on a primitive, and this runs in the `useState` initializer, where a throw stops the mount.
29
19
  if (isObject(initialPersist) && key in initialPersist) {
30
20
  initialOrPersistedValue = initialPersist[key] as T
31
21
  }
@@ -1,25 +1,16 @@
1
1
  import { isAsyncFunction, isFunction, isPromise } from '@plq/is'
2
2
  import type { AsyncStorage } from '../@types/storage'
3
3
 
4
- // An unvalidated candidate: its members are unknown until each one is checked.
5
4
  type StorageCandidate = {
6
5
  get?: unknown
7
6
  set?: unknown
8
7
  remove?: unknown
9
8
  }
10
9
 
11
- // `AsyncStorage` is three methods, so every member has to be callable: a promise standing in for
12
- // one satisfies nothing the hook then does with it. `@plq/is` classifies async functions as their
13
- // own type, so `isFunction` rejects them and both checks are needed to admit a callable member.
10
+ // `@plq/is` classifies async functions as their own type, so `isFunction` alone rejects them.
14
11
  const isCallableMember = (member: unknown): boolean => isFunction(member) || isAsyncFunction(member)
15
12
 
16
- /**
17
- * Narrows a storage to {@link AsyncStorage}, deciding which hook the entry point builds.
18
- *
19
- * Inspection never writes: `set` and `remove` are only checked for shape, never invoked, so
20
- * `get` alone decides. A candidate pairing an async `get` with a sync `set` satisfies neither
21
- * storage interface, which is why proving the other two members is not worth a write.
22
- */
13
+ /** Narrows a storage to {@link AsyncStorage}. Inspecting never writes: `set` and `remove` are never called. */
23
14
  export default function isAsyncStorage(storage: unknown): storage is AsyncStorage {
24
15
  const candidate = storage as StorageCandidate | null | undefined
25
16
 
@@ -42,23 +33,14 @@ export default function isAsyncStorage(storage: unknown): storage is AsyncStorag
42
33
  return false
43
34
  }
44
35
 
45
- // `AsyncStorage` admits a plain function returning a promise, and nothing short of a call tells
46
- // that apart from a sync get. Any adapter written that way can only be recognised here, and a
47
- // third-party one is free to be written that way, so this line stays necessary however the
48
- // shipped adapters happen to declare themselves. A read is the one probe that leaves the
49
- // inspected storage as it found it.
36
+ // A plain function returning a promise is a valid `AsyncStorage`, and only a call tells it from a sync get.
50
37
  let probe: unknown
51
38
 
52
39
  try {
53
- // Called as a method of the storage it belongs to: an adapter written as an
54
- // object of methods reads its own state through `this`, and probing the
55
- // extracted function would throw on a storage that is perfectly valid.
40
+ // Called as a method: an adapter reading its own state through `this` would throw on a torn-off function.
56
41
  probe = get.call(candidate, '')
57
42
  } catch {
58
- // Not swallowed, deferred: consumers build the factory at module scope, where
59
- // a throw takes the import down instead of reaching a component. A storage
60
- // whose read fails is not provably async, and the sync path raises the same
61
- // failure at the first read, where it can be handled.
43
+ // Deferred, not swallowed: factories are built at module scope, where a throw takes the import down.
62
44
  return false
63
45
  }
64
46
 
@@ -66,9 +48,7 @@ export default function isAsyncStorage(storage: unknown): storage is AsyncStorag
66
48
  return false
67
49
  }
68
50
 
69
- // Only the probe's shape answers the question, so its outcome is discarded by construction.
70
- // The rejection still has to be claimed: unclaimed, it terminates the process on Node 15+,
71
- // letting a storage that merely fails a read take the consuming application down with it.
51
+ // An unclaimed rejection terminates the process on Node 15+, so the discarded probe still has to be claimed.
72
52
  probe.then(undefined, () => undefined)
73
53
 
74
54
  return true
@@ -21,30 +21,19 @@ function routeStorageEvent(event: StorageEvent): void {
21
21
  }
22
22
 
23
23
  for (const route of routes) {
24
- // A browser always names the area it changed, so real cross-tab events
25
- // reach only the adapter of that area. An event a consumer synthesized —
26
- // the sole way to test cross-tab sync under jsdom, and a common way to
27
- // notify the writing tab in production — often cannot name one: the
28
- // StorageEvent constructor rejects a mocked storage there. Those still go
29
- // everywhere, exactly as they did before areas were told apart.
24
+ // An event a consumer synthesized often cannot name an area, so those still reach every route.
30
25
  if (event.storageArea == null || event.storageArea === route.storage) {
31
26
  route.fire(changes)
32
27
  }
33
28
  }
34
29
  }
35
30
 
36
- // A runtime can provide storage without the DOM event API, and can provide half
37
- // of that API: subscribing where nothing can unsubscribe strands a listener and
38
- // makes releasing it throw, so both halves are required before taking one.
31
+ // A runtime can provide half the DOM event API, and subscribing where nothing can unsubscribe strands a listener.
39
32
  function canSubscribe(): boolean {
40
33
  return typeof globalThis.addEventListener === 'function' && typeof globalThis.removeEventListener === 'function'
41
34
  }
42
35
 
43
- /**
44
- * Starts routing DOM storage events to `route`. Every adapter shares one
45
- * subscription on the global object, so creating adapters nobody listens to
46
- * costs nothing.
47
- */
36
+ /** Starts routing DOM storage events to `route`. Every adapter shares one subscription on the global object. */
48
37
  export function addRoute(route: StorageEventRoute): void {
49
38
  routes.add(route)
50
39
 
@@ -3,17 +3,7 @@ import { useEffect, useRef } from 'react'
3
3
  import type { AsyncStorage, Storage, StorageChange } from '../@types/storage'
4
4
  import { isFunction, isObject } from '@plq/is'
5
5
 
6
- /**
7
- * One key read out of a serialized entry. `null` is a value a caller can store,
8
- * so a stored `null` has to stay distinguishable from no value at all —
9
- * collapsing the two is what dropped a persisted `null` on its way from a change
10
- * event into state.
11
- *
12
- * A key that is not in the entry and an entry that will not parse are both
13
- * `unavailable`. They differ in why, not in what a caller does next, and the
14
- * difference that matters is reported where it happens: only a parse failure is
15
- * a defect worth a diagnostic, an entry holding other keys is routine.
16
- */
6
+ // A stored `null` is a value, so absence needs a status of its own rather than a `null` value.
17
7
  type StoredValue<T> = { status: 'stored'; value: T } | { status: 'unavailable' }
18
8
 
19
9
  function readStoredValue<T>(key: string, entry: string): StoredValue<T> {
@@ -27,15 +17,12 @@ function readStoredValue<T>(key: string, entry: string): StoredValue<T> {
27
17
  return { status: 'unavailable' }
28
18
  }
29
19
 
30
- // A foreign entry can be any JSON value, and `in` throws on a primitive. This
31
- // runs inside the adapter's notify loop, where a throw also cuts off every
32
- // listener queued behind this one.
20
+ // `in` throws on a primitive, and a throw here cuts off every listener queued behind this one.
33
21
  if (!isObject(parsed) || !(key in parsed)) return { status: 'unavailable' }
34
22
 
35
23
  return { status: 'stored', value: parsed[key] as T }
36
24
  }
37
25
 
38
- // Restores the initial value when the whole entry is removed from the storage.
39
26
  function applyRemoval<T>(
40
27
  change: StorageChange,
41
28
  itemKey: string,
@@ -46,36 +33,20 @@ function applyRemoval<T>(
46
33
 
47
34
  const oldValue = readStoredValue<T>(itemKey, change.oldValue)
48
35
  const declaredInitialValue = latestInitialValue.current
49
- // Compared against the resolved value: a factory is never equal to what it
50
- // produces, so comparing the declaration re-applies the initial value on every
51
- // removal.
36
+ // Resolved before comparing: a factory is never equal to what it produces.
52
37
  const initialValue = isFunction(declaredInitialValue) ? declaredInitialValue() : declaredInitialValue
53
38
 
54
- // An entry with no readable value for the key cannot be shown to have held the
55
- // initial value, so the fallback is applied rather than skipped. The entry is
56
- // gone either way, and stale state left in place is worse than a redundant
57
- // restore.
39
+ // Restore redundantly rather than leave stale state when the entry's old value cannot be read.
58
40
  if (oldValue.status === 'stored' && oldValue.value === initialValue) return
59
41
 
60
42
  applyValue(initialValue)
61
43
  }
62
44
 
63
- // A backend that reports nothing back leaves every record unmatched, so the list
64
- // needs a ceiling or it grows by one string per write for as long as the hook is
65
- // mounted. A bound, not a tuning: a backend that does report drains the list on
66
- // every echo, and one that reports late is what the list exists for.
45
+ // A list, not one slot: a write may still be awaiting its echo when the next one starts.
46
+ // Capped, because a backend that never reports back leaves every record unmatched.
67
47
  const MAX_PENDING_OWN_WRITES = 8
68
48
 
69
- /**
70
- * Remembers an entry the hook is about to write, so the change the backend
71
- * reports for it can be told apart from someone else's write.
72
- *
73
- * Recorded before the write, because a backend may report it before the call
74
- * settles. One slot was not enough: a backend free to report after its write
75
- * settles - chrome and browser storage both are - lets the next write overwrite
76
- * the record of a write still waiting to be reported, and that unsuppressed echo
77
- * then puts the earlier value back over the later one.
78
- */
49
+ /** Records an entry before it is written, because a backend may report the change before the write settles. */
79
50
  export function recordOwnWrite(pendingOwnWrites: React.RefObject<string[]>, item: string): void {
80
51
  const pending = pendingOwnWrites.current
81
52
 
@@ -84,39 +55,18 @@ export function recordOwnWrite(pendingOwnWrites: React.RefObject<string[]>, item
84
55
  pending.push(item)
85
56
  }
86
57
 
87
- /**
88
- * Reports whether this change is a write the hook itself made, forgetting the
89
- * record when it is. A backend reports a write to every listener, the one that
90
- * made it included, and applying that echo is the storage-to-state re-sync this
91
- * hook was rebuilt without, arriving by another road: it decodes the entry again
92
- * and hands the caller an equal value with a new identity, plus a render for a
93
- * value it already holds.
94
- *
95
- * The price, accepted knowingly: for a value JSON cannot carry, the writer keeps
96
- * what it set - NaN - while every other component on the key decodes the null
97
- * that reached storage, so the two disagree until one of them remounts. Applying
98
- * the echo would settle that disagreement and bring back both the second
99
- * storage-to-state path and the render per write, so it is not the repair it
100
- * looks like.
101
- *
102
- * Forgetting on the first match keeps a later identical entry a change. Another
103
- * hook writing the same bytes is suppressed along with it, and nothing is lost:
104
- * it would have replaced a value with an equal one.
105
- */
58
+ // Applying a hook's own echo would re-decode the entry and re-render for a value it already holds.
106
59
  function consumeOwnWriteEcho(entry: string, pendingOwnWrites: React.RefObject<string[]>): boolean {
107
60
  const matched = pendingOwnWrites.current.indexOf(entry)
108
61
 
109
62
  if (matched === -1) return false
110
63
 
111
- // Everything recorded before the reported entry goes with it: a backend applies
112
- // writes in the order it took them and reports them in that order too, so a
113
- // record still unmatched by now has no echo left to wait for.
64
+ // A backend reports writes in the order it took them, so a record still unmatched has no echo left.
114
65
  pendingOwnWrites.current.splice(0, matched + 1)
115
66
 
116
67
  return true
117
68
  }
118
69
 
119
- // Builds the change handler. Not a hook, despite living next to one.
120
70
  function createStorageHandler<T>(
121
71
  itemKey: string,
122
72
  storageKey: string,
@@ -128,9 +78,7 @@ function createStorageHandler<T>(
128
78
  for (const [key, change] of Object.entries(changes)) {
129
79
  if (key !== storageKey) continue
130
80
 
131
- // Ahead of the echo check, which needs an entry to match and a removal has
132
- // none. A removal is never one of this hook's own writes anyway: only the
133
- // entries it stores are recorded.
81
+ // Ahead of the echo check, which needs an entry to match and a removal has none.
134
82
  if (change.newValue === null || change.newValue === undefined) {
135
83
  applyRemoval<T>(change, itemKey, applyValue, latestInitialValue)
136
84
  continue
@@ -153,12 +101,7 @@ export default function useStorageHandler<T>(
153
101
  initialValue: T | (() => T),
154
102
  pendingOwnWrites: React.RefObject<string[]>,
155
103
  ): void {
156
- // A removal restores the initial value the hook holds now, the same one the
157
- // key-change path reads, so a caller whose default travels with its data gets
158
- // that record's default back rather than the mounted one. Tracked through a ref
159
- // rather than a dependency because an inline object or factory has a new
160
- // identity every render, which would tear the subscription down and rebuild it;
161
- // written during render, as the key the hook is rendering for is.
104
+ // A ref, not a dependency: an inline initial value changes identity every render and would churn it.
162
105
  const latestInitialValue = useRef(initialValue)
163
106
 
164
107
  latestInitialValue.current = initialValue