@plq/use-persisted-state 1.3.0 → 1.4.1

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 (101) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +179 -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 +136 -17
  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 +76 -15
  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 +13 -1
  55. package/lib/utils/get-new-item.d.ts.map +1 -1
  56. package/lib/utils/get-new-item.js +27 -7
  57. package/lib/utils/get-new-item.js.map +1 -1
  58. package/lib/utils/get-new-value.d.ts +1 -1
  59. package/lib/utils/get-new-value.d.ts.map +1 -1
  60. package/lib/utils/get-persisted-value.d.ts +8 -1
  61. package/lib/utils/get-persisted-value.d.ts.map +1 -1
  62. package/lib/utils/get-persisted-value.js +19 -5
  63. package/lib/utils/get-persisted-value.js.map +1 -1
  64. package/lib/utils/is-async-storage.d.ts +7 -0
  65. package/lib/utils/is-async-storage.d.ts.map +1 -1
  66. package/lib/utils/is-async-storage.js +51 -20
  67. package/lib/utils/is-async-storage.js.map +1 -1
  68. package/lib/utils/storage-event-router.d.ts +17 -0
  69. package/lib/utils/storage-event-router.d.ts.map +1 -0
  70. package/lib/utils/storage-event-router.js +54 -0
  71. package/lib/utils/storage-event-router.js.map +1 -0
  72. package/lib/utils/use-storage-handler.d.ts +14 -3
  73. package/lib/utils/use-storage-handler.d.ts.map +1 -1
  74. package/lib/utils/use-storage-handler.js +109 -26
  75. package/lib/utils/use-storage-handler.js.map +1 -1
  76. package/package.json +97 -41
  77. package/src/@types/storage.ts +13 -13
  78. package/src/create-async-persisted-state.ts +125 -20
  79. package/src/create-persisted-state.ts +59 -23
  80. package/src/index.ts +12 -7
  81. package/src/storages/browser-storage.ts +8 -36
  82. package/src/storages/chrome-storage.ts +24 -46
  83. package/src/storages/local-storage.ts +3 -1
  84. package/src/storages/session-storage.ts +3 -1
  85. package/src/utils/change-notifier.ts +43 -0
  86. package/src/utils/create-web-storage.ts +74 -77
  87. package/src/utils/extension-storage.ts +79 -0
  88. package/src/utils/get-new-item.ts +32 -9
  89. package/src/utils/get-new-value.ts +1 -1
  90. package/src/utils/get-persisted-value.ts +22 -7
  91. package/src/utils/is-async-storage.ts +72 -27
  92. package/src/utils/storage-event-router.ts +67 -0
  93. package/src/utils/use-storage-handler.ts +144 -36
  94. package/storages/browser-storage.d.ts +1 -0
  95. package/storages/browser-storage.js +11 -0
  96. package/storages/chrome-storage.d.ts +1 -0
  97. package/storages/chrome-storage.js +11 -0
  98. package/storages/local-storage.d.ts +1 -0
  99. package/storages/local-storage.js +9 -0
  100. package/storages/session-storage.d.ts +1 -0
  101. package/storages/session-storage.js +9 -0
@@ -1,62 +1,170 @@
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
+ // 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.
67
+ const MAX_PENDING_OWN_WRITES = 8
68
+
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
+ */
79
+ export function recordOwnWrite(pendingOwnWrites: React.RefObject<string[]>, item: string): void {
80
+ const pending = pendingOwnWrites.current
81
+
82
+ if (pending.length >= MAX_PENDING_OWN_WRITES) pending.shift()
83
+
84
+ pending.push(item)
85
+ }
86
+
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
+ */
106
+ function consumeOwnWriteEcho(entry: string, pendingOwnWrites: React.RefObject<string[]>): boolean {
107
+ const matched = pendingOwnWrites.current.indexOf(entry)
108
+
109
+ if (matched === -1) return false
110
+
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.
114
+ pendingOwnWrites.current.splice(0, matched + 1)
115
+
116
+ return true
117
+ }
118
+
119
+ // Builds the change handler. Not a hook, despite living next to one.
120
+ function createStorageHandler<T>(
18
121
  itemKey: string,
19
122
  storageKey: string,
20
- setState: React.Dispatch<React.SetStateAction<T>>,
21
- initialValue: T | (() => T),
123
+ applyValue: (value: T) => void,
124
+ latestInitialValue: React.RefObject<T | (() => T)>,
125
+ pendingOwnWrites: React.RefObject<string[]>,
22
126
  ) {
23
127
  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)
128
+ for (const [key, change] of Object.entries(changes)) {
129
+ if (key !== storageKey) continue
130
+
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.
134
+ if (change.newValue === null || change.newValue === undefined) {
135
+ applyRemoval<T>(change, itemKey, applyValue, latestInitialValue)
136
+ continue
36
137
  }
37
138
 
38
- if (
39
- key === storageKey
40
- && change.newValue !== null
41
- && change.newValue !== undefined
42
- ) {
43
- const newValue = getValue<T>(itemKey, change.newValue)
139
+ if (consumeOwnWriteEcho(change.newValue, pendingOwnWrites)) continue
44
140
 
45
- if (newValue !== null) setState(newValue)
46
- }
47
- })
141
+ const newValue = readStoredValue<T>(itemKey, change.newValue)
142
+
143
+ if (newValue.status === 'stored') applyValue(newValue.value)
144
+ }
48
145
  }
49
146
  }
50
147
 
51
- export default function<T>(
148
+ export default function useStorageHandler<T>(
52
149
  key: string,
53
150
  storageKey: string,
54
- setState: React.Dispatch<React.SetStateAction<T>>,
151
+ applyValue: (value: T) => void,
55
152
  storage: AsyncStorage | Storage,
56
153
  initialValue: T | (() => T),
154
+ pendingOwnWrites: React.RefObject<string[]>,
57
155
  ): 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.
162
+ const latestInitialValue = useRef(initialValue)
163
+
164
+ latestInitialValue.current = initialValue
165
+
58
166
  useEffect(() => {
59
- const handleStorage = useStorageHandler<T>(key, storageKey, setState, initialValue)
167
+ const handleStorage = createStorageHandler<T>(key, storageKey, applyValue, latestInitialValue, pendingOwnWrites)
60
168
 
61
169
  storage.onChanged.addListener(handleStorage)
62
170
 
@@ -65,5 +173,5 @@ export default function<T>(
65
173
  storage.onChanged.removeListener(handleStorage)
66
174
  }
67
175
  }
68
- }, [initialValue, key, storage.onChanged, storageKey, setState])
176
+ }, [key, storage.onChanged, storageKey, applyValue, pendingOwnWrites])
69
177
  }
@@ -0,0 +1 @@
1
+ export { local, sync, managed } from '../lib/storages/browser-storage'
@@ -0,0 +1,11 @@
1
+ // Resolution stub for tooling that predates the exports map: TypeScript's
2
+ // node10 resolver and older bundlers look for a real file at this path and
3
+ // would otherwise fail. Node 16+ never reaches this file — the exports map
4
+ // answers first. Each area is re-exported by reference, so both paths hand out
5
+ // the same adapter instance and the listener registry stays single.
6
+ const source = require('../lib/storages/browser-storage.js')
7
+
8
+ Object.defineProperty(exports, '__esModule', { value: true })
9
+ exports.local = source.local
10
+ exports.sync = source.sync
11
+ exports.managed = source.managed
@@ -0,0 +1 @@
1
+ export { local, sync, managed } from '../lib/storages/chrome-storage'
@@ -0,0 +1,11 @@
1
+ // Resolution stub for tooling that predates the exports map: TypeScript's
2
+ // node10 resolver and older bundlers look for a real file at this path and
3
+ // would otherwise fail. Node 16+ never reaches this file — the exports map
4
+ // answers first. Each area is re-exported by reference, so both paths hand out
5
+ // the same adapter instance and the listener registry stays single.
6
+ const source = require('../lib/storages/chrome-storage.js')
7
+
8
+ Object.defineProperty(exports, '__esModule', { value: true })
9
+ exports.local = source.local
10
+ exports.sync = source.sync
11
+ exports.managed = source.managed
@@ -0,0 +1 @@
1
+ export { default } from '../lib/storages/local-storage'
@@ -0,0 +1,9 @@
1
+ // Resolution stub for tooling that predates the exports map: TypeScript's
2
+ // node10 resolver and older bundlers look for a real file at this path and
3
+ // would otherwise fail. Node 16+ never reaches this file — the exports map
4
+ // answers first. The lib module's shape is mirrored rather than re-assigned to
5
+ // module.exports, so the declaration beside it stays true for both resolvers.
6
+ const source = require('../lib/storages/local-storage.js')
7
+
8
+ Object.defineProperty(exports, '__esModule', { value: true })
9
+ exports.default = source.default
@@ -0,0 +1 @@
1
+ export { default } from '../lib/storages/session-storage'
@@ -0,0 +1,9 @@
1
+ // Resolution stub for tooling that predates the exports map: TypeScript's
2
+ // node10 resolver and older bundlers look for a real file at this path and
3
+ // would otherwise fail. Node 16+ never reaches this file — the exports map
4
+ // answers first. The lib module's shape is mirrored rather than re-assigned to
5
+ // module.exports, so the declaration beside it stays true for both resolvers.
6
+ const source = require('../lib/storages/session-storage.js')
7
+
8
+ Object.defineProperty(exports, '__esModule', { value: true })
9
+ exports.default = source.default