@plq/use-persisted-state 1.4.1 → 1.4.3

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 (64) hide show
  1. package/README.md +3 -3
  2. package/lib/create-async-persisted-state.d.ts.map +1 -1
  3. package/lib/create-async-persisted-state.js +14 -89
  4. package/lib/create-async-persisted-state.js.map +1 -1
  5. package/lib/create-persisted-state.d.ts.map +1 -1
  6. package/lib/create-persisted-state.js +6 -53
  7. package/lib/create-persisted-state.js.map +1 -1
  8. package/lib/index.d.ts.map +1 -1
  9. package/lib/storages/chrome-storage.d.ts.map +1 -1
  10. package/lib/storages/chrome-storage.js +6 -15
  11. package/lib/storages/chrome-storage.js.map +1 -1
  12. package/lib/storages/local-storage.d.ts.map +1 -1
  13. package/lib/storages/local-storage.js +1 -2
  14. package/lib/storages/local-storage.js.map +1 -1
  15. package/lib/storages/session-storage.d.ts.map +1 -1
  16. package/lib/storages/session-storage.js +1 -2
  17. package/lib/storages/session-storage.js.map +1 -1
  18. package/lib/utils/change-notifier.d.ts +1 -8
  19. package/lib/utils/change-notifier.d.ts.map +1 -1
  20. package/lib/utils/change-notifier.js +1 -10
  21. package/lib/utils/change-notifier.js.map +1 -1
  22. package/lib/utils/create-web-storage.d.ts +1 -8
  23. package/lib/utils/create-web-storage.d.ts.map +1 -1
  24. package/lib/utils/create-web-storage.js +5 -17
  25. package/lib/utils/create-web-storage.js.map +1 -1
  26. package/lib/utils/extension-storage.d.ts +2 -12
  27. package/lib/utils/extension-storage.d.ts.map +1 -1
  28. package/lib/utils/extension-storage.js +3 -14
  29. package/lib/utils/extension-storage.js.map +1 -1
  30. package/lib/utils/get-new-item.d.ts +1 -12
  31. package/lib/utils/get-new-item.d.ts.map +1 -1
  32. package/lib/utils/get-new-item.js +2 -16
  33. package/lib/utils/get-new-item.js.map +1 -1
  34. package/lib/utils/get-persisted-value.d.ts +1 -7
  35. package/lib/utils/get-persisted-value.d.ts.map +1 -1
  36. package/lib/utils/get-persisted-value.js +3 -13
  37. package/lib/utils/get-persisted-value.js.map +1 -1
  38. package/lib/utils/is-async-storage.d.ts +1 -7
  39. package/lib/utils/is-async-storage.d.ts.map +1 -1
  40. package/lib/utils/is-async-storage.js +6 -25
  41. package/lib/utils/is-async-storage.js.map +1 -1
  42. package/lib/utils/storage-event-router.d.ts +1 -5
  43. package/lib/utils/storage-event-router.d.ts.map +1 -1
  44. package/lib/utils/storage-event-router.js +3 -14
  45. package/lib/utils/storage-event-router.js.map +1 -1
  46. package/lib/utils/use-storage-handler.d.ts +1 -13
  47. package/lib/utils/use-storage-handler.d.ts.map +1 -1
  48. package/lib/utils/use-storage-handler.js +8 -78
  49. package/lib/utils/use-storage-handler.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/create-async-persisted-state.ts +14 -58
  52. package/src/create-persisted-state.ts +6 -22
  53. package/src/index.ts +1 -4
  54. package/src/storages/chrome-storage.ts +6 -18
  55. package/src/storages/local-storage.ts +1 -2
  56. package/src/storages/session-storage.ts +1 -2
  57. package/src/utils/change-notifier.ts +1 -10
  58. package/src/utils/create-web-storage.ts +5 -17
  59. package/src/utils/extension-storage.ts +3 -14
  60. package/src/utils/get-new-item.ts +2 -16
  61. package/src/utils/get-persisted-value.ts +3 -13
  62. package/src/utils/is-async-storage.ts +6 -26
  63. package/src/utils/storage-event-router.ts +3 -14
  64. package/src/utils/use-storage-handler.ts +7 -94
@@ -1,7 +1,7 @@
1
1
  import type React from 'react'
2
2
  import { useCallback, useEffect, useRef, useState } from 'react'
3
3
 
4
- import useStorageHandler, { recordOwnWrite } from './utils/use-storage-handler'
4
+ import useStorageHandler from './utils/use-storage-handler'
5
5
  import getNewValue from './utils/get-new-value'
6
6
  import getNewItem from './utils/get-new-item'
7
7
  import getPersistedValue from './utils/get-persisted-value'
@@ -15,20 +15,11 @@ export default function createAsyncPersistedState<S extends AsyncStorage>(
15
15
  ): [PersistedState, () => Promise<void>] {
16
16
  const safeStorageKey = `persisted_state_hook:${storageKey}`
17
17
 
18
- // Every hook this factory makes lives in one entry, and storing a value means
19
- // reading that entry, merging one key into it and writing all of it back, with
20
- // a suspension point on either side of the merge. Left to overlap, a second
21
- // writer merges into a snapshot taken before the first one landed and stores
22
- // it: the first writer's key is gone from storage while its value is still on
23
- // screen, and nothing reports the disagreement. It is the entry, not the hook,
24
- // that has to be taken one at a time, so the chain belongs to the factory.
18
+ // Storing is read-merge-write on one shared entry, so overlapping writers would drop each other's keys.
25
19
  let entryWrites: Promise<unknown> = Promise.resolve()
26
20
 
27
21
  const clear = (): Promise<void> => {
28
- // Removing the entry changes it as much as storing does, so it takes its turn
29
- // in the same chain. Outside it, a write already queued lands after the
30
- // removal and brings back what was cleared - and "clear this data" is the one
31
- // request that cannot be allowed to half happen.
22
+ // In the same chain as writes, or a queued write lands after the removal and restores what was cleared.
32
23
  const removal = entryWrites.then(() => storage.remove(safeStorageKey))
33
24
 
34
25
  entryWrites = removal.catch(() => undefined)
@@ -36,7 +27,7 @@ export default function createAsyncPersistedState<S extends AsyncStorage>(
36
27
  return removal
37
28
  }
38
29
 
39
- const commitEntry = <T>(key: string, newValue: T, pendingOwnWrites: React.RefObject<string[]>): Promise<void> => {
30
+ const commitEntry = <T>(key: string, newValue: T): Promise<void> => {
40
31
  const write = entryWrites.then(async () => {
41
32
  const persistedItem = await storage.get(safeStorageKey)
42
33
  let newItem: string
@@ -44,23 +35,14 @@ export default function createAsyncPersistedState<S extends AsyncStorage>(
44
35
  try {
45
36
  newItem = getNewItem<T>(key, persistedItem[safeStorageKey], newValue)
46
37
  } catch {
47
- // Refused, and reported where it was refused. A write replaces the whole
48
- // entry, so an entry that cannot be read is one no write can be built on
49
- // without dropping every other hook's key; skipping leaves the bytes for
50
- // a repair to reach. The caller keeps what it set, unpersisted.
38
+ // A write replaces the whole entry, so an unreadable one is skipped rather than rebuilt without other keys.
51
39
  return
52
40
  }
53
41
 
54
- // Recorded before the write, because the backend may report it before the
55
- // promise settles.
56
- recordOwnWrite(pendingOwnWrites, newItem)
57
-
58
42
  await storage.set({ [safeStorageKey]: newItem })
59
43
  })
60
44
 
61
- // The chain has to outlive a failed write, or one backend rejection stops
62
- // every later write on this entry. The rejection itself is not swallowed: it
63
- // stays on the promise handed back to the caller that asked for the write.
45
+ // The chain must outlive a failed write; the rejection still reaches the caller through `write`.
64
46
  entryWrites = write.catch(() => undefined)
65
47
 
66
48
  return write
@@ -69,14 +51,10 @@ export default function createAsyncPersistedState<S extends AsyncStorage>(
69
51
  const usePersistedState = <T>(key: string, initialValue: T | (() => T)): UsePersistedState<T> => {
70
52
  const [state, setState] = useState<T>(initialValue)
71
53
 
72
- // The setter must resolve updater functions against the last value applied,
73
- // not against the one captured by the render that created it. Reading the
74
- // render closure collapses updates batched into a single event.
54
+ // Updater functions must resolve against the last applied value, not the render closure's.
75
55
  const latestValue = useRef(state)
76
56
 
77
- // Whether anything has already put a value in place. A load that settles after
78
- // the caller set one must not revert it, and no signal local to the load can
79
- // answer this: the write it loses to happens outside the load entirely.
57
+ // A load that settles after the caller set a value must not revert it.
80
58
  const hasAppliedValue = useRef(false)
81
59
 
82
60
  const applyValue = useCallback((value: T): void => {
@@ -86,42 +64,25 @@ export default function createAsyncPersistedState<S extends AsyncStorage>(
86
64
  setState(value)
87
65
  }, [])
88
66
 
89
- // The entries this hook has written and not yet seen reported back.
90
- const pendingOwnWrites = useRef<string[]>([])
91
-
92
67
  const setPersistedState = useCallback(
93
68
  async (newState: React.SetStateAction<T>): Promise<void> => {
94
69
  const newValue = getNewValue<T>(newState, latestValue.current)
95
70
 
96
- // Applied before the write is even queued: the caller sees its value at
97
- // once, and only the trip to storage waits its turn.
98
71
  applyValue(newValue)
99
72
 
100
- await commitEntry<T>(key, newValue, pendingOwnWrites)
73
+ await commitEntry<T>(key, newValue)
101
74
  },
102
75
  [key, applyValue],
103
76
  )
104
77
 
105
- // As in `useState`, the value the load falls back to is the one given on the
106
- // first render. Later identities of an inline object must not reload, or the
107
- // effect re-runs on every render and never settles.
78
+ // The first render's initial value: reloading on a later identity would re-run the effect forever.
108
79
  const mountInitialValue = useRef(initialValue)
109
80
 
110
- // Subscribed before the load below, and the order is load-bearing: React runs
111
- // effects in declaration order, so reading first would leave an interval
112
- // between the read and the subscription in which a write belongs to neither
113
- // and is lost. Reading last covers everything written before the subscription
114
- // began, and the listener covers everything after.
115
- useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue, pendingOwnWrites)
81
+ // Subscribed before the load: effects run in declaration order, and reading first would lose writes in between.
82
+ useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue)
116
83
 
117
84
  useEffect(() => {
118
- // Two separate questions, and one cell cannot hold both: whether this load
119
- // is still the current one, which only the closure it belongs to can answer,
120
- // and whether a value has been applied meanwhile, which outlives it. A load
121
- // abandoned by a key change would otherwise read the flag its successor had
122
- // just set, apply the previous key's value and shut the successor out. The
123
- // second question is also what makes subscribing first safe: a value the
124
- // listener delivers while the load is in flight is not overwritten by it.
85
+ // Separate from `hasAppliedValue`: only this closure knows whether its own load is still current.
125
86
  let isCancelled = false
126
87
 
127
88
  hasAppliedValue.current = false
@@ -134,12 +95,7 @@ export default function createAsyncPersistedState<S extends AsyncStorage>(
134
95
 
135
96
  applyValue(getPersistedValue<T>(key, mountInitialValue.current, persist[safeStorageKey]))
136
97
  } catch (err) {
137
- // An extension backend rejects on a quota error or an invalidated
138
- // extension context. Nothing awaits this load, so an unclaimed
139
- // rejection terminates the process on Node 15+ and surfaces as an
140
- // uncaught error in the browser. The initial value is already in
141
- // state, so the mount stands on it and the failure is reported rather
142
- // than swallowed, as the synchronous path reports its own.
98
+ // Nothing awaits this load, so an unclaimed rejection would surface as an uncaught error.
143
99
  console.error("use-persisted-state: Can't read value from storage", err)
144
100
  }
145
101
  }
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from 'react'
4
4
  import type { Storage } from './@types/storage'
5
5
  import type { PersistedState, UsePersistedState } from './@types/hook'
6
6
 
7
- import useStorageHandler, { recordOwnWrite } from './utils/use-storage-handler'
7
+ import useStorageHandler from './utils/use-storage-handler'
8
8
  import getNewValue from './utils/get-new-value'
9
9
  import getNewItem from './utils/get-new-item'
10
10
  import getPersistedValue from './utils/get-persisted-value'
@@ -19,14 +19,10 @@ export default function createPersistedState(storageKey: string, storage: Storag
19
19
  getPersistedValue<T>(key, initialValue, storage.get(safeStorageKey)[safeStorageKey])
20
20
 
21
21
  const usePersistedState = <T>(key: string, initialValue: T): UsePersistedState<T> => {
22
- // Reading through the initializer keeps the storage out of the render path:
23
- // the hook runs on every render of every consuming component, so a re-read is
24
- // only warranted when the key it is asked for actually changes.
22
+ // Read through the initializer: the hook runs on every render, so storage stays out of the render path.
25
23
  const [state, setState] = useState<T>(() => readPersisted(key, initialValue))
26
24
 
27
- // The setter must resolve updater functions against the last value applied,
28
- // not against the one captured by the render that created it. Reading the
29
- // render closure collapses updates batched into a single event.
25
+ // Updater functions must resolve against the last applied value, not the render closure's.
30
26
  const latestValue = useRef(state)
31
27
 
32
28
  const applyValue = useCallback((value: T): void => {
@@ -35,11 +31,7 @@ export default function createPersistedState(storageKey: string, storage: Storag
35
31
  setState(value)
36
32
  }, [])
37
33
 
38
- // A mounted hook handed a different key has to show that key's value before
39
- // anything can be written, or the next update stores the previous key's value
40
- // under the new key and destroys what was there. Adjusting during render ties
41
- // the re-read to the change itself, where an effect would cost a read and a
42
- // second render on every mount as well.
34
+ // A new key must be read before anything is written, or the next update stores the old key's value under it.
43
35
  const renderedKey = useRef(key)
44
36
 
45
37
  if (renderedKey.current !== key) {
@@ -48,9 +40,6 @@ export default function createPersistedState(storageKey: string, storage: Storag
48
40
  applyValue(readPersisted(key, initialValue))
49
41
  }
50
42
 
51
- // The entries this hook has written and not yet seen reported back.
52
- const pendingOwnWrites = useRef<string[]>([])
53
-
54
43
  const setPersistedState = useCallback(
55
44
  (newState: React.SetStateAction<T>): void => {
56
45
  const newValue = getNewValue<T>(newState, latestValue.current)
@@ -63,21 +52,16 @@ export default function createPersistedState(storageKey: string, storage: Storag
63
52
  try {
64
53
  newItem = getNewItem<T>(key, persistedItem, newValue)
65
54
  } catch {
66
- // Refused, and reported where it was refused. A write replaces the whole
67
- // entry, so an entry that cannot be read is one no write can be built on
68
- // without dropping every other hook's key; skipping leaves the bytes for
69
- // a repair to reach. The caller keeps what it set, unpersisted.
55
+ // A write replaces the whole entry, so an unreadable one is skipped rather than rebuilt without other keys.
70
56
  return
71
57
  }
72
58
 
73
- recordOwnWrite(pendingOwnWrites, newItem)
74
-
75
59
  storage.set({ [safeStorageKey]: newItem })
76
60
  },
77
61
  [key, applyValue],
78
62
  )
79
63
 
80
- useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue, pendingOwnWrites)
64
+ useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue)
81
65
 
82
66
  return [state, setPersistedState]
83
67
  }
package/src/index.ts CHANGED
@@ -20,9 +20,6 @@ export default function createPersistedState<S extends Storage | AsyncStorage>(
20
20
  export { default as createPersistedState } from './create-persisted-state'
21
21
  export { default as createAsyncPersistedState } from './create-async-persisted-state'
22
22
 
23
- // The contract a backend implements, and the shape the hook hands back. Both appear in the
24
- // signatures above, so a consumer writing an adapter or typing a wrapper has to be able to name
25
- // them from the supported entry point rather than from `lib/`, which the exports map keeps open
26
- // only as a frozen legacy path.
23
+ // Both appear in the signatures above, so a consumer writing an adapter can name them from the entry point.
27
24
  export type { AsyncStorage, Storage, StorageChange, StorageChangeEvent, StorageChangeListener } from './@types/storage'
28
25
  export type { PersistedState, UsePersistedState } from './@types/hook'
@@ -7,25 +7,13 @@ chrome.storage.onChanged.addListener((changes, area) => {
7
7
  registry.fire(toStorageChanges(changes), area)
8
8
  })
9
9
 
10
- // chrome.storage is callback-based, unlike its promise-based firefox counterpart.
10
+ // Passing no callback selects the promise form; the callback form leaves failures in `runtime.lastError`.
11
+ // Called through `storage`: a torn-off `StorageArea` method throws `Illegal invocation`, unlike firefox's.
12
+ // `get` stays `async` so asking whether this storage is asynchronous costs no round trip to the extension.
11
13
  const createStorage = (storage: chrome.storage.StorageArea, area: Area): AsyncStorage => ({
12
- // Declared async, like the firefox adapter, so that asking whether this
13
- // storage is asynchronous can be answered from the function itself instead of
14
- // by calling it — a call here costs a round trip to the extension process.
15
- get: async keys =>
16
- new Promise(resolve => {
17
- storage.get(keys, items => {
18
- resolve(toStoredItems(items))
19
- })
20
- }),
21
- set: items =>
22
- new Promise(resolve => {
23
- storage.set(items, resolve)
24
- }),
25
- remove: keys =>
26
- new Promise(resolve => {
27
- storage.remove(keys, resolve)
28
- }),
14
+ get: async keys => toStoredItems(await storage.get(keys)),
15
+ set: items => storage.set(items),
16
+ remove: keys => storage.remove(keys),
29
17
  onChanged: registry.createOnChanged(area),
30
18
  })
31
19
 
@@ -1,5 +1,4 @@
1
1
  import createStorage from '../utils/create-web-storage'
2
2
 
3
- // The typeof guard keeps this import from throwing on server runtimes (SSR),
4
- // where the browser storage globals are not defined.
3
+ // The typeof guard keeps this import from throwing on server runtimes, where the global is not defined.
5
4
  export default createStorage(typeof localStorage !== 'undefined' ? localStorage : undefined)
@@ -1,5 +1,4 @@
1
1
  import createStorage from '../utils/create-web-storage'
2
2
 
3
- // The typeof guard keeps this import from throwing on server runtimes (SSR),
4
- // where the browser storage globals are not defined.
3
+ // The typeof guard keeps this import from throwing on server runtimes, where the global is not defined.
5
4
  export default createStorage(typeof sessionStorage !== 'undefined' ? sessionStorage : undefined)
@@ -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