@plq/use-persisted-state 1.3.0 → 1.4.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.
Files changed (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +165 -50
  3. package/esm/index.d.mts +13 -0
  4. package/esm/index.mjs +10 -0
  5. package/esm/storages/browser-storage.d.mts +1 -0
  6. package/esm/storages/browser-storage.mjs +5 -0
  7. package/esm/storages/chrome-storage.d.mts +1 -0
  8. package/esm/storages/chrome-storage.mjs +5 -0
  9. package/esm/storages/local-storage.d.mts +1 -0
  10. package/esm/storages/local-storage.mjs +3 -0
  11. package/esm/storages/session-storage.d.mts +1 -0
  12. package/esm/storages/session-storage.mjs +3 -0
  13. package/lib/@types/storage.d.ts.map +1 -1
  14. package/lib/create-async-persisted-state.d.ts +2 -2
  15. package/lib/create-async-persisted-state.d.ts.map +1 -1
  16. package/lib/create-async-persisted-state.js +62 -12
  17. package/lib/create-async-persisted-state.js.map +1 -1
  18. package/lib/create-persisted-state.d.ts +2 -2
  19. package/lib/create-persisted-state.d.ts.map +1 -1
  20. package/lib/create-persisted-state.js +31 -13
  21. package/lib/create-persisted-state.js.map +1 -1
  22. package/lib/index.d.ts +6 -5
  23. package/lib/index.d.ts.map +1 -1
  24. package/lib/index.js +4 -2
  25. package/lib/index.js.map +1 -1
  26. package/lib/storages/browser-storage.d.ts +2 -2
  27. package/lib/storages/browser-storage.d.ts.map +1 -1
  28. package/lib/storages/browser-storage.js +6 -26
  29. package/lib/storages/browser-storage.js.map +1 -1
  30. package/lib/storages/chrome-storage.d.ts +2 -2
  31. package/lib/storages/chrome-storage.d.ts.map +1 -1
  32. package/lib/storages/chrome-storage.js +10 -27
  33. package/lib/storages/chrome-storage.js.map +1 -1
  34. package/lib/storages/local-storage.d.ts +1 -1
  35. package/lib/storages/local-storage.d.ts.map +1 -1
  36. package/lib/storages/local-storage.js +3 -1
  37. package/lib/storages/local-storage.js.map +1 -1
  38. package/lib/storages/session-storage.d.ts +1 -1
  39. package/lib/storages/session-storage.d.ts.map +1 -1
  40. package/lib/storages/session-storage.js +3 -1
  41. package/lib/storages/session-storage.js.map +1 -1
  42. package/lib/utils/change-notifier.d.ts +18 -0
  43. package/lib/utils/change-notifier.d.ts.map +1 -0
  44. package/lib/utils/change-notifier.js +38 -0
  45. package/lib/utils/change-notifier.js.map +1 -0
  46. package/lib/utils/create-web-storage.d.ts +10 -2
  47. package/lib/utils/create-web-storage.d.ts.map +1 -1
  48. package/lib/utils/create-web-storage.js +72 -75
  49. package/lib/utils/create-web-storage.js.map +1 -1
  50. package/lib/utils/extension-storage.d.ts +38 -0
  51. package/lib/utils/extension-storage.d.ts.map +1 -0
  52. package/lib/utils/extension-storage.js +66 -0
  53. package/lib/utils/extension-storage.js.map +1 -0
  54. package/lib/utils/get-new-item.d.ts.map +1 -1
  55. package/lib/utils/get-new-item.js.map +1 -1
  56. package/lib/utils/get-new-value.d.ts +1 -1
  57. package/lib/utils/get-new-value.d.ts.map +1 -1
  58. package/lib/utils/get-persisted-value.d.ts +8 -1
  59. package/lib/utils/get-persisted-value.d.ts.map +1 -1
  60. package/lib/utils/get-persisted-value.js +19 -5
  61. package/lib/utils/get-persisted-value.js.map +1 -1
  62. package/lib/utils/is-async-storage.d.ts +7 -0
  63. package/lib/utils/is-async-storage.d.ts.map +1 -1
  64. package/lib/utils/is-async-storage.js +51 -20
  65. package/lib/utils/is-async-storage.js.map +1 -1
  66. package/lib/utils/storage-event-router.d.ts +17 -0
  67. package/lib/utils/storage-event-router.d.ts.map +1 -0
  68. package/lib/utils/storage-event-router.js +54 -0
  69. package/lib/utils/storage-event-router.js.map +1 -0
  70. package/lib/utils/use-storage-handler.d.ts +3 -3
  71. package/lib/utils/use-storage-handler.d.ts.map +1 -1
  72. package/lib/utils/use-storage-handler.js +80 -26
  73. package/lib/utils/use-storage-handler.js.map +1 -1
  74. package/package.json +97 -41
  75. package/src/@types/storage.ts +13 -13
  76. package/src/create-async-persisted-state.ts +81 -18
  77. package/src/create-persisted-state.ts +48 -22
  78. package/src/index.ts +12 -7
  79. package/src/storages/browser-storage.ts +8 -36
  80. package/src/storages/chrome-storage.ts +24 -46
  81. package/src/storages/local-storage.ts +3 -1
  82. package/src/storages/session-storage.ts +3 -1
  83. package/src/utils/change-notifier.ts +43 -0
  84. package/src/utils/create-web-storage.ts +74 -77
  85. package/src/utils/extension-storage.ts +79 -0
  86. package/src/utils/get-new-item.ts +1 -1
  87. package/src/utils/get-new-value.ts +1 -1
  88. package/src/utils/get-persisted-value.ts +22 -7
  89. package/src/utils/is-async-storage.ts +72 -27
  90. package/src/utils/storage-event-router.ts +67 -0
  91. package/src/utils/use-storage-handler.ts +112 -36
  92. package/storages/browser-storage.d.ts +1 -0
  93. package/storages/browser-storage.js +11 -0
  94. package/storages/chrome-storage.d.ts +1 -0
  95. package/storages/chrome-storage.js +11 -0
  96. package/storages/local-storage.d.ts +1 -0
  97. package/storages/local-storage.js +9 -0
  98. package/storages/session-storage.d.ts +1 -0
  99. package/storages/session-storage.js +9 -0
@@ -1,63 +1,76 @@
1
- import { Storage, StorageChange, StorageChangeEvent, StorageChangeListener } from '../@types/storage'
1
+ import type { Storage, StorageChange, StorageChangeEvent } from '../@types/storage'
2
+ import { createChangeNotifier } from './change-notifier'
3
+ import { addRoute, removeRoute, type StorageEventRoute } from './storage-event-router'
2
4
 
3
- const listeners = new Set<StorageChangeListener>()
4
-
5
- function fireStorageEvent(changes: { [key: string]: StorageChange }) {
6
- listeners.forEach(listener => {
7
- listener(changes)
8
- })
5
+ function toKeyList(keys: string | string[]): string[] {
6
+ return Array.isArray(keys) ? keys : [keys]
9
7
  }
10
8
 
11
- window.addEventListener('storage', event => {
12
- if (event.key) {
13
- const changes = {
14
- [event.key]: {
15
- newValue: event.newValue,
16
- oldValue: event.oldValue,
17
- },
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
+ */
17
+ 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.
20
+ const notifier = createChangeNotifier()
21
+
22
+ // Only a missing global is inert. Anything else a caller passes has to fail
23
+ // on first use instead of swallowing their writes.
24
+ if (storage === undefined) {
25
+ return {
26
+ get: () => ({}),
27
+ set: () => {},
28
+ remove: () => {},
29
+ onChanged: notifier.onChanged,
18
30
  }
19
-
20
- fireStorageEvent(changes)
21
31
  }
22
- })
23
-
24
- const onChanged: StorageChangeEvent = {
25
- addListener(listener) {
26
- listeners.add(listener)
27
- },
28
- removeListener(listener) {
29
- listeners.delete(listener)
30
- },
31
- hasListener(listener) {
32
- return listeners.has(listener)
33
- },
34
- }
35
32
 
36
- export default (storage: globalThis.Storage): Storage => ({
37
- get: keys => {
38
- const result: { [key: string]: string } = {}
33
+ const route: StorageEventRoute = { storage, fire: notifier.fire }
34
+
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.
38
+ const onChanged: StorageChangeEvent = {
39
+ addListener(listener) {
40
+ notifier.onChanged.addListener(listener)
41
+
42
+ addRoute(route)
43
+ },
44
+ removeListener(listener) {
45
+ notifier.onChanged.removeListener(listener)
46
+
47
+ if (!notifier.hasListeners()) removeRoute(route)
48
+ },
49
+ hasListener(listener) {
50
+ return notifier.onChanged.hasListener(listener)
51
+ },
52
+ }
39
53
 
40
- if (Array.isArray(keys)) {
41
- keys.forEach(key => {
42
- const item = typeof storage !== 'undefined' ? storage.getItem(key) : undefined
54
+ return {
55
+ get: keys => {
56
+ const result: { [key: string]: string } = {}
43
57
 
44
- if (item) result[key] = item
45
- })
46
- } else {
47
- const item = typeof storage !== 'undefined' ? storage.getItem(keys) : undefined
58
+ for (const key of toKeyList(keys)) {
59
+ const item = storage.getItem(key)
48
60
 
49
- if (item) result[keys] = item
50
- }
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.
63
+ if (item !== null) result[key] = item
64
+ }
51
65
 
52
- return result
53
- },
54
- set: items => {
55
- const changes: { [key: string]: StorageChange } = {}
66
+ return result
67
+ },
68
+ set: items => {
69
+ const changes: { [key: string]: StorageChange } = {}
56
70
 
57
- Object.entries(items).forEach(([key, value]) => {
58
- const oldValue = typeof storage !== 'undefined' ? storage.getItem(key) : undefined
71
+ for (const [key, value] of Object.entries(items)) {
72
+ const oldValue = storage.getItem(key)
59
73
 
60
- if (typeof storage !== 'undefined') {
61
74
  storage.setItem(key, value)
62
75
 
63
76
  changes[key] = {
@@ -65,41 +78,25 @@ export default (storage: globalThis.Storage): Storage => ({
65
78
  newValue: value,
66
79
  }
67
80
  }
68
- })
69
-
70
- if (Object.keys(changes).length > 0) fireStorageEvent(changes)
71
- },
72
- remove: keys => {
73
- const changes: { [key: string]: StorageChange } = {}
74
81
 
75
- if (Array.isArray(keys)) {
76
- keys.forEach(key => {
77
- const oldValue = typeof storage !== 'undefined' ? storage.getItem(key) : undefined
82
+ if (Object.keys(changes).length > 0) notifier.fire(changes)
83
+ },
84
+ remove: keys => {
85
+ const changes: { [key: string]: StorageChange } = {}
78
86
 
79
- if (typeof storage !== 'undefined') {
80
- storage.removeItem(key)
87
+ for (const key of toKeyList(keys)) {
88
+ const oldValue = storage.getItem(key)
81
89
 
82
- changes[key] = {
83
- oldValue,
84
- newValue: null,
85
- }
86
- }
87
- })
88
- } else {
89
- const oldValue = typeof storage !== 'undefined' ? storage.getItem(keys) : undefined
90
-
91
- if (typeof storage !== 'undefined') {
92
- storage.removeItem(keys)
90
+ storage.removeItem(key)
93
91
 
94
- changes[keys] = {
92
+ changes[key] = {
95
93
  oldValue,
96
94
  newValue: null,
97
95
  }
98
96
  }
99
- }
100
-
101
- if (Object.keys(changes).length > 0) fireStorageEvent(changes)
102
- },
103
- onChanged,
104
- })
105
97
 
98
+ if (Object.keys(changes).length > 0) notifier.fire(changes)
99
+ },
100
+ onChanged,
101
+ }
102
+ }
@@ -0,0 +1,79 @@
1
+ import type { StorageChange, StorageChangeEvent } from '../@types/storage'
2
+ import { type ChangeNotifier, createChangeNotifier } from './change-notifier'
3
+
4
+ const TRACKED_AREAS = ['local', 'sync', 'managed'] as const
5
+
6
+ export type Area = (typeof TRACKED_AREAS)[number]
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.
10
+ function isTrackedArea(area: string): area is Area {
11
+ return (TRACKED_AREAS as readonly string[]).includes(area)
12
+ }
13
+
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
+ */
20
+ export function toStoredValue(value: unknown): string | null | undefined {
21
+ if (value === undefined) return
22
+
23
+ return typeof value === 'string' ? value : null
24
+ }
25
+
26
+ export function toStorageChanges(changes: { [key: string]: { oldValue?: unknown; newValue?: unknown } }): {
27
+ [key: string]: StorageChange
28
+ } {
29
+ const result: { [key: string]: StorageChange } = {}
30
+
31
+ for (const [key, change] of Object.entries(changes)) {
32
+ result[key] = {
33
+ oldValue: toStoredValue(change.oldValue),
34
+ newValue: toStoredValue(change.newValue),
35
+ }
36
+ }
37
+
38
+ return result
39
+ }
40
+
41
+ export function toStoredItems(items: { [key: string]: unknown }): { [key: string]: string } {
42
+ const result: { [key: string]: string } = {}
43
+
44
+ for (const [key, value] of Object.entries(items)) {
45
+ if (typeof value === 'string') result[key] = value
46
+ }
47
+
48
+ return result
49
+ }
50
+
51
+ export interface ListenerRegistry {
52
+ fire(changes: { [key: string]: StorageChange }, area: string): void
53
+ createOnChanged(area: Area): StorageChangeEvent
54
+ }
55
+
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
+ */
62
+ export function createListenerRegistry(): ListenerRegistry {
63
+ const notifiers: { [area in Area]: ChangeNotifier } = {
64
+ local: createChangeNotifier(),
65
+ sync: createChangeNotifier(),
66
+ managed: createChangeNotifier(),
67
+ }
68
+
69
+ return {
70
+ fire(changes, area) {
71
+ if (!isTrackedArea(area)) return
72
+
73
+ notifiers[area].fire(changes)
74
+ },
75
+ createOnChanged(area) {
76
+ return notifiers[area].onChanged
77
+ },
78
+ }
79
+ }
@@ -1,4 +1,4 @@
1
- export default function<T>(key: string, persistedItem: string, newValue: T): string {
1
+ export default function <T>(key: string, persistedItem: string, newValue: T): string {
2
2
  let persist: { [x: string]: unknown }
3
3
 
4
4
  try {
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import type React from 'react'
2
2
  import { isFunction } from '@plq/is'
3
3
 
4
4
  export default function <T>(newState: React.SetStateAction<T>, state: T): T {
@@ -1,18 +1,33 @@
1
- import { isFunction } from '@plq/is'
1
+ import { isFunction, isObject } from '@plq/is'
2
2
 
3
- export default function<T>(key:string, initialValue: T | (() => T), persist?: string): T {
4
- let initialPersist: { [x: string]: unknown }
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
+ */
10
+ export default function getPersistedValue<T>(key: string, initialValue: T | (() => T), persist?: string): T {
11
+ let initialPersist: unknown
5
12
 
6
13
  try {
7
14
  initialPersist = persist ? JSON.parse(persist) : {}
8
- } catch {
15
+ } 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.
19
+ console.error("use-persisted-state: Can't parse value from storage", err)
20
+
9
21
  initialPersist = {}
10
22
  }
11
23
 
12
- let initialOrPersistedValue = isFunction(initialValue) ? initialValue() : initialValue
24
+ let initialOrPersistedValue = isFunction(initialValue) ? initialValue() : initialValue
13
25
 
14
- if (initialPersist && key in initialPersist) {
15
- initialOrPersistedValue = (initialPersist[key] as T) ?? initialOrPersistedValue
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.
29
+ if (isObject(initialPersist) && key in initialPersist) {
30
+ initialOrPersistedValue = initialPersist[key] as T
16
31
  }
17
32
 
18
33
  return initialOrPersistedValue
@@ -1,30 +1,75 @@
1
- import {isFunction, isAsyncFunction, isPromise} from '@plq/is'
2
- import type {AsyncStorage} from '../@types/storage'
1
+ import { isAsyncFunction, isFunction, isPromise } from '@plq/is'
2
+ import type { AsyncStorage } from '../@types/storage'
3
3
 
4
+ // An unvalidated candidate: its members are unknown until each one is checked.
5
+ type StorageCandidate = {
6
+ get?: unknown
7
+ set?: unknown
8
+ remove?: unknown
9
+ }
10
+
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.
14
+ const isCallableMember = (member: unknown): boolean => isFunction(member) || isAsyncFunction(member)
15
+
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
+ */
4
23
  export default function isAsyncStorage(storage: unknown): storage is AsyncStorage {
5
- const hasGet = Boolean(storage) && typeof (storage as any)?.get !== 'undefined'
6
- const hasSet = Boolean(storage) && typeof (storage as any)?.set !== 'undefined'
7
- const hasRemove = Boolean(storage) && typeof (storage as any)?.remove !== 'undefined'
8
-
9
- if (!hasGet || !hasSet || !hasRemove) {
10
- return false
11
- }
12
-
13
- const hasGetPromise = isPromise((storage as any).get)
14
- || isFunction((storage as any).get) && isPromise((storage as any).get(''))
15
- || isAsyncFunction((storage as any).get)
16
- const hasSetPromise = isPromise((storage as any).set)
17
- || isFunction((storage as any).set) && isPromise((storage as any).set({}))
18
- || isAsyncFunction((storage as any).set)
19
- const hasRemovePromise = isPromise((storage as any).remove)
20
- || isFunction((storage as any).remove) && isPromise((storage as any).remove(''))
21
- || isAsyncFunction((storage as any).remove)
22
-
23
- return Boolean(storage)
24
- && hasGet
25
- && hasSet
26
- && hasRemove
27
- && hasGetPromise
28
- && hasSetPromise
29
- && hasRemovePromise
24
+ const candidate = storage as StorageCandidate | null | undefined
25
+
26
+ if (!candidate) {
27
+ return false
28
+ }
29
+
30
+ const { get, set, remove } = candidate
31
+
32
+ if (!isCallableMember(set) || !isCallableMember(remove)) {
33
+ return false
34
+ }
35
+
36
+ // An `async` declaration is the one form of asyncness visible without a call.
37
+ if (isAsyncFunction(get)) {
38
+ return true
39
+ }
40
+
41
+ if (!isFunction(get)) {
42
+ return false
43
+ }
44
+
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.
50
+ let probe: unknown
51
+
52
+ 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.
56
+ probe = get.call(candidate, '')
57
+ } 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.
62
+ return false
63
+ }
64
+
65
+ if (!isPromise(probe)) {
66
+ return false
67
+ }
68
+
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.
72
+ probe.then(undefined, () => undefined)
73
+
74
+ return true
30
75
  }
@@ -0,0 +1,67 @@
1
+ import type { StorageChange } from '../@types/storage'
2
+
3
+ /** An adapter waiting for DOM storage events about the area it wraps. */
4
+ export interface StorageEventRoute {
5
+ storage: globalThis.Storage
6
+ fire(changes: { [key: string]: StorageChange }): void
7
+ }
8
+
9
+ const routes = new Set<StorageEventRoute>()
10
+
11
+ let subscription: ((event: StorageEvent) => void) | null = null
12
+
13
+ function routeStorageEvent(event: StorageEvent): void {
14
+ if (!event.key) return
15
+
16
+ const changes = {
17
+ [event.key]: {
18
+ newValue: event.newValue,
19
+ oldValue: event.oldValue,
20
+ },
21
+ }
22
+
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.
30
+ if (event.storageArea == null || event.storageArea === route.storage) {
31
+ route.fire(changes)
32
+ }
33
+ }
34
+ }
35
+
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.
39
+ function canSubscribe(): boolean {
40
+ return typeof globalThis.addEventListener === 'function' && typeof globalThis.removeEventListener === 'function'
41
+ }
42
+
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
+ */
48
+ export function addRoute(route: StorageEventRoute): void {
49
+ routes.add(route)
50
+
51
+ if (!subscription && canSubscribe()) {
52
+ subscription = routeStorageEvent
53
+
54
+ globalThis.addEventListener('storage', subscription)
55
+ }
56
+ }
57
+
58
+ /** Stops routing to `route`, releasing the shared subscription with the last one. */
59
+ export function removeRoute(route: StorageEventRoute): void {
60
+ routes.delete(route)
61
+
62
+ if (routes.size === 0 && subscription) {
63
+ globalThis.removeEventListener('storage', subscription)
64
+
65
+ subscription = null
66
+ }
67
+ }
@@ -1,62 +1,138 @@
1
- import React, {useEffect} from 'react'
2
- import { AsyncStorage, Storage, StorageChange } from '../@types/storage'
3
- import { isFunction } from '@plq/is'
1
+ import type React from 'react'
2
+ import { useEffect, useRef } from 'react'
3
+ import type { AsyncStorage, Storage, StorageChange } from '../@types/storage'
4
+ import { isFunction, isObject } from '@plq/is'
4
5
 
5
- function getValue<T>(key: string, value: string) {
6
- let newState = null
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
+ */
17
+ type StoredValue<T> = { status: 'stored'; value: T } | { status: 'unavailable' }
18
+
19
+ function readStoredValue<T>(key: string, entry: string): StoredValue<T> {
20
+ let parsed: unknown
7
21
 
8
22
  try {
9
- newState = JSON.parse(value)
23
+ parsed = JSON.parse(entry)
10
24
  } catch (err) {
11
- console.error('use-persisted-state: Can\'t parse value from storage', err)
25
+ console.error("use-persisted-state: Can't parse value from storage", err)
26
+
27
+ return { status: 'unavailable' }
12
28
  }
13
29
 
14
- return newState && key in newState ? newState[key] as T : null
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.
33
+ if (!isObject(parsed) || !(key in parsed)) return { status: 'unavailable' }
34
+
35
+ return { status: 'stored', value: parsed[key] as T }
15
36
  }
16
37
 
17
- function useStorageHandler<T>(
38
+ // Restores the initial value when the whole entry is removed from the storage.
39
+ function applyRemoval<T>(
40
+ change: StorageChange,
41
+ itemKey: string,
42
+ applyValue: (value: T) => void,
43
+ latestInitialValue: React.RefObject<T | (() => T)>,
44
+ ): void {
45
+ if (change.oldValue === null || change.oldValue === undefined) return
46
+
47
+ const oldValue = readStoredValue<T>(itemKey, change.oldValue)
48
+ 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.
52
+ const initialValue = isFunction(declaredInitialValue) ? declaredInitialValue() : declaredInitialValue
53
+
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.
58
+ if (oldValue.status === 'stored' && oldValue.value === initialValue) return
59
+
60
+ applyValue(initialValue)
61
+ }
62
+
63
+ /**
64
+ * Reports whether this change is the write the hook itself just made, forgetting
65
+ * the record when it is. A backend reports a write to every listener, the one
66
+ * that made it included, and applying that echo is the storage-to-state re-sync
67
+ * this hook was rebuilt without, arriving by another road: it decodes the entry
68
+ * again and hands the caller an equal value with a new identity, plus a render
69
+ * for a value it already holds.
70
+ *
71
+ * The price, accepted knowingly: for a value JSON cannot carry, the writer keeps
72
+ * what it set - NaN - while every other component on the key decodes the null
73
+ * that reached storage, so the two disagree until one of them remounts. Applying
74
+ * the echo would settle that disagreement and bring back both the second
75
+ * storage-to-state path and the render per write, so it is not the repair it
76
+ * looks like.
77
+ *
78
+ * Forgetting on the first match keeps a later identical entry a change. Another
79
+ * hook writing the same bytes is suppressed along with it, and nothing is lost:
80
+ * it would have replaced a value with an equal one.
81
+ */
82
+ function consumeOwnWriteEcho(change: StorageChange, pendingOwnWrite: React.RefObject<string | null>): boolean {
83
+ if (pendingOwnWrite.current === null || change.newValue !== pendingOwnWrite.current) return false
84
+
85
+ pendingOwnWrite.current = null
86
+
87
+ return true
88
+ }
89
+
90
+ // Builds the change handler. Not a hook, despite living next to one.
91
+ function createStorageHandler<T>(
18
92
  itemKey: string,
19
93
  storageKey: string,
20
- setState: React.Dispatch<React.SetStateAction<T>>,
21
- initialValue: T | (() => T),
94
+ applyValue: (value: T) => void,
95
+ latestInitialValue: React.RefObject<T | (() => T)>,
96
+ pendingOwnWrite: React.RefObject<string | null>,
22
97
  ) {
23
98
  return (changes: { [key: string]: StorageChange }): void => {
24
- Object.entries(changes).forEach(([key, change]) => {
25
- if (
26
- key === storageKey
27
- && (
28
- change.newValue === null || change.newValue === undefined
29
- )
30
- && change.oldValue !== null
31
- && change.oldValue !== undefined
32
- ) {
33
- const oldValue = getValue<T>(itemKey, change.oldValue)
34
-
35
- if (oldValue !== initialValue) setState(isFunction(initialValue) ? initialValue() : initialValue)
36
- }
99
+ for (const [key, change] of Object.entries(changes)) {
100
+ if (key !== storageKey) continue
37
101
 
38
- if (
39
- key === storageKey
40
- && change.newValue !== null
41
- && change.newValue !== undefined
42
- ) {
43
- const newValue = getValue<T>(itemKey, change.newValue)
102
+ if (consumeOwnWriteEcho(change, pendingOwnWrite)) continue
44
103
 
45
- if (newValue !== null) setState(newValue)
104
+ if (change.newValue === null || change.newValue === undefined) {
105
+ applyRemoval<T>(change, itemKey, applyValue, latestInitialValue)
106
+ continue
46
107
  }
47
- })
108
+
109
+ const newValue = readStoredValue<T>(itemKey, change.newValue)
110
+
111
+ if (newValue.status === 'stored') applyValue(newValue.value)
112
+ }
48
113
  }
49
114
  }
50
115
 
51
- export default function<T>(
116
+ export default function useStorageHandler<T>(
52
117
  key: string,
53
118
  storageKey: string,
54
- setState: React.Dispatch<React.SetStateAction<T>>,
119
+ applyValue: (value: T) => void,
55
120
  storage: AsyncStorage | Storage,
56
121
  initialValue: T | (() => T),
122
+ pendingOwnWrite: React.RefObject<string | null>,
57
123
  ): void {
124
+ // A removal restores the initial value the hook holds now, the same one the
125
+ // key-change path reads, so a caller whose default travels with its data gets
126
+ // that record's default back rather than the mounted one. Tracked through a ref
127
+ // rather than a dependency because an inline object or factory has a new
128
+ // identity every render, which would tear the subscription down and rebuild it;
129
+ // written during render, as the key the hook is rendering for is.
130
+ const latestInitialValue = useRef(initialValue)
131
+
132
+ latestInitialValue.current = initialValue
133
+
58
134
  useEffect(() => {
59
- const handleStorage = useStorageHandler<T>(key, storageKey, setState, initialValue)
135
+ const handleStorage = createStorageHandler<T>(key, storageKey, applyValue, latestInitialValue, pendingOwnWrite)
60
136
 
61
137
  storage.onChanged.addListener(handleStorage)
62
138
 
@@ -65,5 +141,5 @@ export default function<T>(
65
141
  storage.onChanged.removeListener(handleStorage)
66
142
  }
67
143
  }
68
- }, [initialValue, key, storage.onChanged, storageKey, setState])
144
+ }, [key, storage.onChanged, storageKey, applyValue, pendingOwnWrite])
69
145
  }
@@ -0,0 +1 @@
1
+ export { local, sync, managed } from '../lib/storages/browser-storage'