@plq/use-persisted-state 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/lib/create-async-persisted-state.d.ts.map +1 -1
- package/lib/create-async-persisted-state.js +67 -38
- 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 +48 -17
- 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 +2 -1
- package/lib/utils/get-new-item.d.ts.map +1 -1
- package/lib/utils/get-new-item.js +13 -7
- 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 +3 -1
- package/lib/utils/use-storage-handler.d.ts.map +1 -1
- package/lib/utils/use-storage-handler.js +28 -46
- package/lib/utils/use-storage-handler.js.map +1 -1
- package/package.json +1 -1
- package/src/create-async-persisted-state.ts +42 -40
- package/src/create-persisted-state.ts +15 -17
- 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 +18 -9
- 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 +33 -58
|
@@ -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 from './utils/use-storage-handler'
|
|
7
|
+
import useStorageHandler, { recordOwnWrite } 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,8 +40,7 @@ export default function createPersistedState(storageKey: string, storage: Storag
|
|
|
48
40
|
applyValue(readPersisted(key, initialValue))
|
|
49
41
|
}
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
const pendingOwnWrite = useRef<string | null>(null)
|
|
43
|
+
const pendingOwnWrites = useRef<string[]>([])
|
|
53
44
|
|
|
54
45
|
const setPersistedState = useCallback(
|
|
55
46
|
(newState: React.SetStateAction<T>): void => {
|
|
@@ -58,16 +49,23 @@ export default function createPersistedState(storageKey: string, storage: Storag
|
|
|
58
49
|
applyValue(newValue)
|
|
59
50
|
|
|
60
51
|
const persistedItem = storage.get(safeStorageKey)[safeStorageKey]
|
|
61
|
-
|
|
52
|
+
let newItem: string
|
|
62
53
|
|
|
63
|
-
|
|
54
|
+
try {
|
|
55
|
+
newItem = getNewItem<T>(key, persistedItem, newValue)
|
|
56
|
+
} catch {
|
|
57
|
+
// A write replaces the whole entry, so an unreadable one is skipped rather than rebuilt without other keys.
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
recordOwnWrite(pendingOwnWrites, newItem)
|
|
64
62
|
|
|
65
63
|
storage.set({ [safeStorageKey]: newItem })
|
|
66
64
|
},
|
|
67
65
|
[key, applyValue],
|
|
68
66
|
)
|
|
69
67
|
|
|
70
|
-
useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue,
|
|
68
|
+
useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue, pendingOwnWrites)
|
|
71
69
|
|
|
72
70
|
return [state, setPersistedState]
|
|
73
71
|
}
|
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,16 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { isObject } from '@plq/is'
|
|
2
|
+
|
|
3
|
+
/** Merges `newValue` under `key` into the shared entry, throwing rather than rebuilding one it cannot read. */
|
|
4
|
+
export default function getNewItem<T>(key: string, persistedItem: string, newValue: T): string {
|
|
5
|
+
let persist: unknown
|
|
3
6
|
|
|
4
7
|
try {
|
|
5
8
|
persist = persistedItem ? JSON.parse(persistedItem) : {}
|
|
6
9
|
} catch (err) {
|
|
7
|
-
console.error(err)
|
|
8
|
-
|
|
10
|
+
console.error("use-persisted-state: Can't write value to storage", err)
|
|
11
|
+
|
|
12
|
+
throw err
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!isObject(persist)) {
|
|
16
|
+
// Neither a primitive nor an array survives being merged into: the value being set would disappear.
|
|
17
|
+
const err = new TypeError('the stored entry is not an object of keys')
|
|
18
|
+
|
|
19
|
+
console.error("use-persisted-state: Can't write value to storage", err)
|
|
20
|
+
|
|
21
|
+
throw err
|
|
9
22
|
}
|
|
10
23
|
|
|
11
|
-
return JSON.stringify(
|
|
12
|
-
Object.assign(persist, {
|
|
13
|
-
[key]: newValue,
|
|
14
|
-
}),
|
|
15
|
-
)
|
|
24
|
+
return JSON.stringify({ ...persist, [key]: newValue })
|
|
16
25
|
}
|
|
@@ -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
|
|
|
@@ -3,17 +3,7 @@ import { useEffect, useRef } from 'react'
|
|
|
3
3
|
import type { AsyncStorage, Storage, StorageChange } from '../@types/storage'
|
|
4
4
|
import { isFunction, isObject } from '@plq/is'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* One key read out of a serialized entry. `null` is a value a caller can store,
|
|
8
|
-
* so a stored `null` has to stay distinguishable from no value at all —
|
|
9
|
-
* collapsing the two is what dropped a persisted `null` on its way from a change
|
|
10
|
-
* event into state.
|
|
11
|
-
*
|
|
12
|
-
* A key that is not in the entry and an entry that will not parse are both
|
|
13
|
-
* `unavailable`. They differ in why, not in what a caller does next, and the
|
|
14
|
-
* difference that matters is reported where it happens: only a parse failure is
|
|
15
|
-
* a defect worth a diagnostic, an entry holding other keys is routine.
|
|
16
|
-
*/
|
|
6
|
+
// A stored `null` is a value, so absence needs a status of its own rather than a `null` value.
|
|
17
7
|
type StoredValue<T> = { status: 'stored'; value: T } | { status: 'unavailable' }
|
|
18
8
|
|
|
19
9
|
function readStoredValue<T>(key: string, entry: string): StoredValue<T> {
|
|
@@ -27,15 +17,12 @@ function readStoredValue<T>(key: string, entry: string): StoredValue<T> {
|
|
|
27
17
|
return { status: 'unavailable' }
|
|
28
18
|
}
|
|
29
19
|
|
|
30
|
-
//
|
|
31
|
-
// runs inside the adapter's notify loop, where a throw also cuts off every
|
|
32
|
-
// listener queued behind this one.
|
|
20
|
+
// `in` throws on a primitive, and a throw here cuts off every listener queued behind this one.
|
|
33
21
|
if (!isObject(parsed) || !(key in parsed)) return { status: 'unavailable' }
|
|
34
22
|
|
|
35
23
|
return { status: 'stored', value: parsed[key] as T }
|
|
36
24
|
}
|
|
37
25
|
|
|
38
|
-
// Restores the initial value when the whole entry is removed from the storage.
|
|
39
26
|
function applyRemoval<T>(
|
|
40
27
|
change: StorageChange,
|
|
41
28
|
itemKey: string,
|
|
@@ -46,66 +33,59 @@ function applyRemoval<T>(
|
|
|
46
33
|
|
|
47
34
|
const oldValue = readStoredValue<T>(itemKey, change.oldValue)
|
|
48
35
|
const declaredInitialValue = latestInitialValue.current
|
|
49
|
-
//
|
|
50
|
-
// produces, so comparing the declaration re-applies the initial value on every
|
|
51
|
-
// removal.
|
|
36
|
+
// Resolved before comparing: a factory is never equal to what it produces.
|
|
52
37
|
const initialValue = isFunction(declaredInitialValue) ? declaredInitialValue() : declaredInitialValue
|
|
53
38
|
|
|
54
|
-
//
|
|
55
|
-
// initial value, so the fallback is applied rather than skipped. The entry is
|
|
56
|
-
// gone either way, and stale state left in place is worse than a redundant
|
|
57
|
-
// restore.
|
|
39
|
+
// Restore redundantly rather than leave stale state when the entry's old value cannot be read.
|
|
58
40
|
if (oldValue.status === 'stored' && oldValue.value === initialValue) return
|
|
59
41
|
|
|
60
42
|
applyValue(initialValue)
|
|
61
43
|
}
|
|
62
44
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
pendingOwnWrite.current = null
|
|
45
|
+
// A list, not one slot: a write may still be awaiting its echo when the next one starts.
|
|
46
|
+
// Capped, because a backend that never reports back leaves every record unmatched.
|
|
47
|
+
const MAX_PENDING_OWN_WRITES = 8
|
|
48
|
+
|
|
49
|
+
/** Records an entry before it is written, because a backend may report the change before the write settles. */
|
|
50
|
+
export function recordOwnWrite(pendingOwnWrites: React.RefObject<string[]>, item: string): void {
|
|
51
|
+
const pending = pendingOwnWrites.current
|
|
52
|
+
|
|
53
|
+
if (pending.length >= MAX_PENDING_OWN_WRITES) pending.shift()
|
|
54
|
+
|
|
55
|
+
pending.push(item)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Applying a hook's own echo would re-decode the entry and re-render for a value it already holds.
|
|
59
|
+
function consumeOwnWriteEcho(entry: string, pendingOwnWrites: React.RefObject<string[]>): boolean {
|
|
60
|
+
const matched = pendingOwnWrites.current.indexOf(entry)
|
|
61
|
+
|
|
62
|
+
if (matched === -1) return false
|
|
63
|
+
|
|
64
|
+
// A backend reports writes in the order it took them, so a record still unmatched has no echo left.
|
|
65
|
+
pendingOwnWrites.current.splice(0, matched + 1)
|
|
86
66
|
|
|
87
67
|
return true
|
|
88
68
|
}
|
|
89
69
|
|
|
90
|
-
// Builds the change handler. Not a hook, despite living next to one.
|
|
91
70
|
function createStorageHandler<T>(
|
|
92
71
|
itemKey: string,
|
|
93
72
|
storageKey: string,
|
|
94
73
|
applyValue: (value: T) => void,
|
|
95
74
|
latestInitialValue: React.RefObject<T | (() => T)>,
|
|
96
|
-
|
|
75
|
+
pendingOwnWrites: React.RefObject<string[]>,
|
|
97
76
|
) {
|
|
98
77
|
return (changes: { [key: string]: StorageChange }): void => {
|
|
99
78
|
for (const [key, change] of Object.entries(changes)) {
|
|
100
79
|
if (key !== storageKey) continue
|
|
101
80
|
|
|
102
|
-
|
|
103
|
-
|
|
81
|
+
// Ahead of the echo check, which needs an entry to match and a removal has none.
|
|
104
82
|
if (change.newValue === null || change.newValue === undefined) {
|
|
105
83
|
applyRemoval<T>(change, itemKey, applyValue, latestInitialValue)
|
|
106
84
|
continue
|
|
107
85
|
}
|
|
108
86
|
|
|
87
|
+
if (consumeOwnWriteEcho(change.newValue, pendingOwnWrites)) continue
|
|
88
|
+
|
|
109
89
|
const newValue = readStoredValue<T>(itemKey, change.newValue)
|
|
110
90
|
|
|
111
91
|
if (newValue.status === 'stored') applyValue(newValue.value)
|
|
@@ -119,20 +99,15 @@ export default function useStorageHandler<T>(
|
|
|
119
99
|
applyValue: (value: T) => void,
|
|
120
100
|
storage: AsyncStorage | Storage,
|
|
121
101
|
initialValue: T | (() => T),
|
|
122
|
-
|
|
102
|
+
pendingOwnWrites: React.RefObject<string[]>,
|
|
123
103
|
): void {
|
|
124
|
-
// A
|
|
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.
|
|
104
|
+
// A ref, not a dependency: an inline initial value changes identity every render and would churn it.
|
|
130
105
|
const latestInitialValue = useRef(initialValue)
|
|
131
106
|
|
|
132
107
|
latestInitialValue.current = initialValue
|
|
133
108
|
|
|
134
109
|
useEffect(() => {
|
|
135
|
-
const handleStorage = createStorageHandler<T>(key, storageKey, applyValue, latestInitialValue,
|
|
110
|
+
const handleStorage = createStorageHandler<T>(key, storageKey, applyValue, latestInitialValue, pendingOwnWrites)
|
|
136
111
|
|
|
137
112
|
storage.onChanged.addListener(handleStorage)
|
|
138
113
|
|
|
@@ -141,5 +116,5 @@ export default function useStorageHandler<T>(
|
|
|
141
116
|
storage.onChanged.removeListener(handleStorage)
|
|
142
117
|
}
|
|
143
118
|
}
|
|
144
|
-
}, [key, storage.onChanged, storageKey, applyValue,
|
|
119
|
+
}, [key, storage.onChanged, storageKey, applyValue, pendingOwnWrites])
|
|
145
120
|
}
|