@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.
- package/README.md +3 -3
- package/lib/create-async-persisted-state.d.ts.map +1 -1
- package/lib/create-async-persisted-state.js +14 -89
- package/lib/create-async-persisted-state.js.map +1 -1
- package/lib/create-persisted-state.d.ts.map +1 -1
- package/lib/create-persisted-state.js +6 -53
- package/lib/create-persisted-state.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/storages/chrome-storage.d.ts.map +1 -1
- package/lib/storages/chrome-storage.js +6 -15
- package/lib/storages/chrome-storage.js.map +1 -1
- package/lib/storages/local-storage.d.ts.map +1 -1
- package/lib/storages/local-storage.js +1 -2
- package/lib/storages/local-storage.js.map +1 -1
- package/lib/storages/session-storage.d.ts.map +1 -1
- package/lib/storages/session-storage.js +1 -2
- package/lib/storages/session-storage.js.map +1 -1
- package/lib/utils/change-notifier.d.ts +1 -8
- package/lib/utils/change-notifier.d.ts.map +1 -1
- package/lib/utils/change-notifier.js +1 -10
- package/lib/utils/change-notifier.js.map +1 -1
- package/lib/utils/create-web-storage.d.ts +1 -8
- package/lib/utils/create-web-storage.d.ts.map +1 -1
- package/lib/utils/create-web-storage.js +5 -17
- package/lib/utils/create-web-storage.js.map +1 -1
- package/lib/utils/extension-storage.d.ts +2 -12
- package/lib/utils/extension-storage.d.ts.map +1 -1
- package/lib/utils/extension-storage.js +3 -14
- package/lib/utils/extension-storage.js.map +1 -1
- package/lib/utils/get-new-item.d.ts +1 -12
- package/lib/utils/get-new-item.d.ts.map +1 -1
- package/lib/utils/get-new-item.js +2 -16
- package/lib/utils/get-new-item.js.map +1 -1
- package/lib/utils/get-persisted-value.d.ts +1 -7
- package/lib/utils/get-persisted-value.d.ts.map +1 -1
- package/lib/utils/get-persisted-value.js +3 -13
- package/lib/utils/get-persisted-value.js.map +1 -1
- package/lib/utils/is-async-storage.d.ts +1 -7
- package/lib/utils/is-async-storage.d.ts.map +1 -1
- package/lib/utils/is-async-storage.js +6 -25
- package/lib/utils/is-async-storage.js.map +1 -1
- package/lib/utils/storage-event-router.d.ts +1 -5
- package/lib/utils/storage-event-router.d.ts.map +1 -1
- package/lib/utils/storage-event-router.js +3 -14
- package/lib/utils/storage-event-router.js.map +1 -1
- package/lib/utils/use-storage-handler.d.ts +1 -13
- package/lib/utils/use-storage-handler.d.ts.map +1 -1
- package/lib/utils/use-storage-handler.js +8 -78
- package/lib/utils/use-storage-handler.js.map +1 -1
- package/package.json +1 -1
- package/src/create-async-persisted-state.ts +14 -58
- package/src/create-persisted-state.ts +6 -22
- package/src/index.ts +1 -4
- package/src/storages/chrome-storage.ts +6 -18
- package/src/storages/local-storage.ts +1 -2
- package/src/storages/session-storage.ts +1 -2
- package/src/utils/change-notifier.ts +1 -10
- package/src/utils/create-web-storage.ts +5 -17
- package/src/utils/extension-storage.ts +3 -14
- package/src/utils/get-new-item.ts +2 -16
- package/src/utils/get-persisted-value.ts +3 -13
- package/src/utils/is-async-storage.ts +6 -26
- package/src/utils/storage-event-router.ts +3 -14
- 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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
73
|
+
await commitEntry<T>(key, newValue)
|
|
101
74
|
},
|
|
102
75
|
[key, applyValue],
|
|
103
76
|
)
|
|
104
77
|
|
|
105
|
-
//
|
|
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
|
|
111
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
// `
|
|
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
|
-
// `
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|