@plq/use-persisted-state 1.2.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 (110) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +168 -43
  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 +63 -13
  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 +32 -14
  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 +5 -3
  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 +1 -1
  56. package/lib/utils/get-new-item.js.map +1 -1
  57. package/lib/utils/get-new-value.d.ts +1 -1
  58. package/lib/utils/get-new-value.d.ts.map +1 -1
  59. package/lib/utils/get-new-value.js +1 -1
  60. package/lib/utils/get-new-value.js.map +1 -1
  61. package/lib/utils/get-persisted-value.d.ts +8 -1
  62. package/lib/utils/get-persisted-value.d.ts.map +1 -1
  63. package/lib/utils/get-persisted-value.js +20 -6
  64. package/lib/utils/get-persisted-value.js.map +1 -1
  65. package/lib/utils/is-async-storage.d.ts +7 -0
  66. package/lib/utils/is-async-storage.d.ts.map +1 -1
  67. package/lib/utils/is-async-storage.js +52 -30
  68. package/lib/utils/is-async-storage.js.map +1 -1
  69. package/lib/utils/storage-event-router.d.ts +17 -0
  70. package/lib/utils/storage-event-router.d.ts.map +1 -0
  71. package/lib/utils/storage-event-router.js +54 -0
  72. package/lib/utils/storage-event-router.js.map +1 -0
  73. package/lib/utils/use-storage-handler.d.ts +3 -3
  74. package/lib/utils/use-storage-handler.d.ts.map +1 -1
  75. package/lib/utils/use-storage-handler.js +80 -26
  76. package/lib/utils/use-storage-handler.js.map +1 -1
  77. package/package.json +105 -44
  78. package/src/@types/storage.ts +13 -13
  79. package/src/create-async-persisted-state.ts +81 -18
  80. package/src/create-persisted-state.ts +48 -22
  81. package/src/index.ts +12 -7
  82. package/src/storages/browser-storage.ts +8 -36
  83. package/src/storages/chrome-storage.ts +24 -46
  84. package/src/storages/local-storage.ts +3 -1
  85. package/src/storages/session-storage.ts +3 -1
  86. package/src/utils/change-notifier.ts +43 -0
  87. package/src/utils/create-web-storage.ts +74 -77
  88. package/src/utils/extension-storage.ts +79 -0
  89. package/src/utils/get-new-item.ts +1 -1
  90. package/src/utils/get-new-value.ts +1 -1
  91. package/src/utils/get-persisted-value.ts +23 -8
  92. package/src/utils/is-async-storage.ts +72 -37
  93. package/src/utils/storage-event-router.ts +67 -0
  94. package/src/utils/use-storage-handler.ts +112 -36
  95. package/storages/browser-storage.d.ts +1 -0
  96. package/storages/browser-storage.js +11 -0
  97. package/storages/chrome-storage.d.ts +1 -0
  98. package/storages/chrome-storage.js +11 -0
  99. package/storages/local-storage.d.ts +1 -0
  100. package/storages/local-storage.js +9 -0
  101. package/storages/session-storage.d.ts +1 -0
  102. package/storages/session-storage.js +9 -0
  103. package/lib/utils/is-function.d.ts +0 -2
  104. package/lib/utils/is-function.d.ts.map +0 -1
  105. package/lib/utils/is-function.js +0 -9
  106. package/lib/utils/is-function.js.map +0 -1
  107. package/lib/utils/is-promise.d.ts +0 -2
  108. package/lib/utils/is-promise.d.ts.map +0 -1
  109. package/lib/utils/is-promise.js +0 -10
  110. package/lib/utils/is-promise.js.map +0 -1
package/package.json CHANGED
@@ -1,47 +1,110 @@
1
1
  {
2
2
  "name": "@plq/use-persisted-state",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "useState hook with persistence in storage",
5
+ "type": "commonjs",
5
6
  "main": "lib/index.js",
6
7
  "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./esm/index.d.mts",
12
+ "default": "./esm/index.mjs"
13
+ },
14
+ "require": {
15
+ "types": "./lib/index.d.ts",
16
+ "default": "./lib/index.js"
17
+ },
18
+ "default": "./lib/index.js"
19
+ },
20
+ "./storages/local-storage": {
21
+ "import": {
22
+ "types": "./esm/storages/local-storage.d.mts",
23
+ "default": "./esm/storages/local-storage.mjs"
24
+ },
25
+ "require": {
26
+ "types": "./lib/storages/local-storage.d.ts",
27
+ "default": "./lib/storages/local-storage.js"
28
+ },
29
+ "default": "./lib/storages/local-storage.js"
30
+ },
31
+ "./storages/session-storage": {
32
+ "import": {
33
+ "types": "./esm/storages/session-storage.d.mts",
34
+ "default": "./esm/storages/session-storage.mjs"
35
+ },
36
+ "require": {
37
+ "types": "./lib/storages/session-storage.d.ts",
38
+ "default": "./lib/storages/session-storage.js"
39
+ },
40
+ "default": "./lib/storages/session-storage.js"
41
+ },
42
+ "./storages/browser-storage": {
43
+ "import": {
44
+ "types": "./esm/storages/browser-storage.d.mts",
45
+ "default": "./esm/storages/browser-storage.mjs"
46
+ },
47
+ "require": {
48
+ "types": "./lib/storages/browser-storage.d.ts",
49
+ "default": "./lib/storages/browser-storage.js"
50
+ },
51
+ "default": "./lib/storages/browser-storage.js"
52
+ },
53
+ "./storages/chrome-storage": {
54
+ "import": {
55
+ "types": "./esm/storages/chrome-storage.d.mts",
56
+ "default": "./esm/storages/chrome-storage.mjs"
57
+ },
58
+ "require": {
59
+ "types": "./lib/storages/chrome-storage.d.ts",
60
+ "default": "./lib/storages/chrome-storage.js"
61
+ },
62
+ "default": "./lib/storages/chrome-storage.js"
63
+ },
64
+ "./lib": "./lib/index.js",
65
+ "./lib/*.js": "./lib/*.js",
66
+ "./lib/*": "./lib/*.js",
67
+ "./src": "./src/index.ts",
68
+ "./src/*.ts": "./src/*.ts",
69
+ "./src/*": "./src/*.ts",
70
+ "./package.json": "./package.json"
71
+ },
7
72
  "engines": {
8
73
  "node": ">=16.0.0"
9
74
  },
10
75
  "files": [
11
76
  "/src",
12
77
  "/lib",
78
+ "/esm",
79
+ "/storages",
13
80
  "LICENSE"
14
81
  ],
15
82
  "scripts": {
16
83
  "build": "tsc",
17
84
  "build:watch": "tsc --watch",
18
85
  "build:types": "tsc --emitDeclarationOnly",
86
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
19
87
  "test": "jest --verbose",
88
+ "test:random": "jest --randomize --showSeed",
20
89
  "test:watch": "npm test -- --watchAll",
21
- "lint": "eslint ./src/**/*.ts",
90
+ "lint": "biome check",
91
+ "lint:fix": "biome check --write",
92
+ "check:package": "node scripts/check-package.mjs",
93
+ "check:publint": "npx --yes publint",
94
+ "check:attw": "npx --yes @arethetypeswrong/cli --pack . --exclude-entrypoints lib",
95
+ "check:smoke": "node scripts/smoke.mjs",
96
+ "check:types": "tsc -p tsconfig.package.json --noEmit",
97
+ "prepublishOnly": "npm run build",
98
+ "format": "biome format --write",
22
99
  "release": "dotenv release-it",
23
- "examples": "npm run build:watch & parcel examples/index.html"
100
+ "demo": "vite",
101
+ "demo:build": "vite build",
102
+ "demo:preview": "vite preview"
24
103
  },
25
104
  "repository": {
26
105
  "type": "git",
27
106
  "url": "git+https://github.com/Akurganow/use-persisted-state.git"
28
107
  },
29
- "release-it": {
30
- "hooks": {
31
- "before:init": [
32
- "npm run lint",
33
- "npm test",
34
- "npm run build"
35
- ],
36
- "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}."
37
- },
38
- "plugins": {
39
- "@release-it/conventional-changelog": {
40
- "preset": "conventionalcommits",
41
- "infile": "CHANGELOG.md"
42
- }
43
- }
44
- },
45
108
  "keywords": [
46
109
  "react",
47
110
  "hook",
@@ -52,8 +115,9 @@
52
115
  "persist",
53
116
  "sync"
54
117
  ],
55
- "author": "Alex Kurganov <me@akurganow.ru>",
118
+ "author": "Alexander Kurganov <a.kurganow@gmail.com>",
56
119
  "license": "MIT",
120
+ "funding": "https://ko-fi.com/akurganow",
57
121
  "publishConfig": {
58
122
  "access": "public"
59
123
  },
@@ -62,31 +126,28 @@
62
126
  },
63
127
  "homepage": "https://github.com/Akurganow/use-persisted-state#readme",
64
128
  "devDependencies": {
65
- "@release-it/conventional-changelog": "^7.0.2",
66
- "@testing-library/jest-dom": "^6.1.3",
67
- "@testing-library/react": "^14.0.0",
68
- "@types/chrome": "0.0.246",
69
- "@types/firefox-webext-browser": "^111.0.2",
70
- "@types/jest": "^29.5.5",
71
- "@types/react": "^18.2.25",
72
- "@typescript-eslint/eslint-plugin": "^6.7.4",
73
- "@typescript-eslint/parser": "^6.7.4",
74
- "conventional-changelog-cli": "^4.1.0",
75
- "dotenv-cli": "^7.3.0",
76
- "eslint": "^8.51.0",
77
- "eslint-plugin-jest": "^27.2.1",
78
- "eslint-plugin-react": "^7.21.5",
79
- "eslint-plugin-react-hooks": "^4.2.0",
80
- "jest": "^29.7.0",
81
- "jest-environment-jsdom": "^29.7.0",
129
+ "@biomejs/biome": "^2.5.6",
130
+ "@release-it/conventional-changelog": "^12.0.0",
131
+ "@testing-library/dom": "^10.4.1",
132
+ "@testing-library/jest-dom": "^7.0.0",
133
+ "@testing-library/react": "^16.3.0",
134
+ "@types/chrome": "^0.2.2",
135
+ "@types/firefox-webext-browser": "^143.0.0",
136
+ "@types/jest": "^30.0.0",
137
+ "@types/react": "^19.0.0",
138
+ "@types/react-dom": "^19.0.0",
139
+ "@vitejs/plugin-react": "^6.0.0",
140
+ "dotenv-cli": "^11.0.0",
141
+ "jest": "^30.0.5",
142
+ "jest-environment-jsdom": "^30.0.5",
82
143
  "jest-localstorage-mock": "^2.4.6",
83
- "jest-webextension-mock": "^3.7.6",
84
- "parcel": "^2.9.3",
85
- "process": "^0.11.10",
86
- "react-test-renderer": "^18.2.0",
87
- "release-it": "^16.2.1",
88
- "ts-jest": "^29.1.1",
89
- "typescript": "^5.2.2"
144
+ "jest-webextension-mock": "^4.0.0",
145
+ "react": "^19.1.1",
146
+ "react-dom": "^19.1.1",
147
+ "release-it": "^21.0.0",
148
+ "ts-jest": "^29.4.0",
149
+ "typescript": "^6.0.3",
150
+ "vite": "^8.2.0"
90
151
  },
91
152
  "peerDependencies": {
92
153
  "react": ">=16.8.0",
@@ -1,26 +1,26 @@
1
1
  export interface StorageChange {
2
- oldValue?: string | null;
3
- newValue?: string | null;
2
+ oldValue?: string | null
3
+ newValue?: string | null
4
4
  }
5
5
 
6
6
  export type StorageChangeListener = (changes: { [key: string]: StorageChange }) => void
7
7
 
8
8
  export interface StorageChangeEvent {
9
- addListener(listener: StorageChangeListener): void;
10
- removeListener(listener: StorageChangeListener): void;
11
- hasListener(listener: StorageChangeListener): boolean;
9
+ addListener(listener: StorageChangeListener): void
10
+ removeListener(listener: StorageChangeListener): void
11
+ hasListener(listener: StorageChangeListener): boolean
12
12
  }
13
13
 
14
14
  export interface Storage {
15
- get: (keys: string | string[]) => { [key: string]: string };
16
- set: (items: { [key: string]: string }) => void;
17
- remove: (keys: string | string[]) => void;
18
- onChanged: StorageChangeEvent;
15
+ get: (keys: string | string[]) => { [key: string]: string }
16
+ set: (items: { [key: string]: string }) => void
17
+ remove: (keys: string | string[]) => void
18
+ onChanged: StorageChangeEvent
19
19
  }
20
20
 
21
21
  export interface AsyncStorage {
22
- get: (keys: string | string[]) => Promise<{ [key: string]: string }>;
23
- set: (items: { [key: string]: string }) => Promise<void>;
24
- remove: (keys: string | string[]) => Promise<void>;
25
- onChanged: StorageChangeEvent;
22
+ get: (keys: string | string[]) => Promise<{ [key: string]: string }>
23
+ set: (items: { [key: string]: string }) => Promise<void>
24
+ remove: (keys: string | string[]) => Promise<void>
25
+ onChanged: StorageChangeEvent
26
26
  }
@@ -1,12 +1,13 @@
1
- import React, {useEffect, useState} from 'react'
1
+ import type React from 'react'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
3
 
3
4
  import useStorageHandler from './utils/use-storage-handler'
4
5
  import getNewValue from './utils/get-new-value'
5
6
  import getNewItem from './utils/get-new-item'
6
7
  import getPersistedValue from './utils/get-persisted-value'
7
8
 
8
- import {AsyncStorage} from './@types/storage'
9
- import {PersistedState, UsePersistedState} from './@types/hook'
9
+ import type { AsyncStorage } from './@types/storage'
10
+ import type { PersistedState, UsePersistedState } from './@types/hook'
10
11
 
11
12
  export default function createAsyncPersistedState<S extends AsyncStorage>(
12
13
  storageKey: string,
@@ -21,30 +22,92 @@ export default function createAsyncPersistedState<S extends AsyncStorage>(
21
22
  const usePersistedState = <T>(key: string, initialValue: T | (() => T)): UsePersistedState<T> => {
22
23
  const [state, setState] = useState<T>(initialValue)
23
24
 
24
- const setPersistedState = async (newState: React.SetStateAction<T>): Promise<void> => {
25
- const newValue = getNewValue<T>(newState, state)
25
+ // The setter must resolve updater functions against the last value applied,
26
+ // not against the one captured by the render that created it. Reading the
27
+ // render closure collapses updates batched into a single event.
28
+ const latestValue = useRef(state)
26
29
 
27
- setState(newValue)
30
+ // Whether anything has already put a value in place. A load that settles after
31
+ // the caller set one must not revert it, and no signal local to the load can
32
+ // answer this: the write it loses to happens outside the load entirely.
33
+ const hasAppliedValue = useRef(false)
28
34
 
29
- const persistedItem = await storage.get(safeStorageKey)
30
- const newItem = getNewItem<T>(key, persistedItem[safeStorageKey], newValue)
35
+ const applyValue = useCallback((value: T): void => {
36
+ latestValue.current = value
37
+ hasAppliedValue.current = true
31
38
 
32
- await storage.set({[safeStorageKey]: newItem})
33
- }
39
+ setState(value)
40
+ }, [])
41
+
42
+ // The exact entry this hook last wrote and has not yet seen reported back.
43
+ const pendingOwnWrite = useRef<string | null>(null)
44
+
45
+ const setPersistedState = useCallback(
46
+ async (newState: React.SetStateAction<T>): Promise<void> => {
47
+ const newValue = getNewValue<T>(newState, latestValue.current)
48
+
49
+ applyValue(newValue)
50
+
51
+ const persistedItem = await storage.get(safeStorageKey)
52
+ const newItem = getNewItem<T>(key, persistedItem[safeStorageKey], newValue)
53
+
54
+ // Recorded before the write, because the backend may report it before the
55
+ // promise settles.
56
+ pendingOwnWrite.current = newItem
57
+
58
+ await storage.set({ [safeStorageKey]: newItem })
59
+ },
60
+ [key, applyValue],
61
+ )
62
+
63
+ // As in `useState`, the value the load falls back to is the one given on the
64
+ // first render. Later identities of an inline object must not reload, or the
65
+ // effect re-runs on every render and never settles.
66
+ const mountInitialValue = useRef(initialValue)
67
+
68
+ // Subscribed before the load below, and the order is load-bearing: React runs
69
+ // effects in declaration order, so reading first would leave an interval
70
+ // between the read and the subscription in which a write belongs to neither
71
+ // and is lost. Reading last covers everything written before the subscription
72
+ // began, and the listener covers everything after.
73
+ useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue, pendingOwnWrite)
34
74
 
35
75
  useEffect(() => {
36
- const setInitialValue = async () => {
37
- const persist = await storage.get(safeStorageKey)
38
- const persistedState = persist[safeStorageKey]
39
- const initialOrPersistedValue = getPersistedValue<T>(key, initialValue, persistedState)
76
+ // Two separate questions, and one cell cannot hold both: whether this load
77
+ // is still the current one, which only the closure it belongs to can answer,
78
+ // and whether a value has been applied meanwhile, which outlives it. A load
79
+ // abandoned by a key change would otherwise read the flag its successor had
80
+ // just set, apply the previous key's value and shut the successor out. The
81
+ // second question is also what makes subscribing first safe: a value the
82
+ // listener delivers while the load is in flight is not overwritten by it.
83
+ let isCancelled = false
84
+
85
+ hasAppliedValue.current = false
40
86
 
41
- setState(initialOrPersistedValue)
87
+ const loadPersistedValue = async (): Promise<void> => {
88
+ try {
89
+ const persist = await storage.get(safeStorageKey)
90
+
91
+ if (isCancelled || hasAppliedValue.current) return
92
+
93
+ applyValue(getPersistedValue<T>(key, mountInitialValue.current, persist[safeStorageKey]))
94
+ } catch (err) {
95
+ // An extension backend rejects on a quota error or an invalidated
96
+ // extension context. Nothing awaits this load, so an unclaimed
97
+ // rejection terminates the process on Node 15+ and surfaces as an
98
+ // uncaught error in the browser. The initial value is already in
99
+ // state, so the mount stands on it and the failure is reported rather
100
+ // than swallowed, as the synchronous path reports its own.
101
+ console.error("use-persisted-state: Can't read value from storage", err)
102
+ }
42
103
  }
43
104
 
44
- setInitialValue()
45
- }, [initialValue, key])
105
+ loadPersistedValue()
46
106
 
47
- useStorageHandler<T>(key, safeStorageKey, setState, storage, initialValue)
107
+ return () => {
108
+ isCancelled = true
109
+ }
110
+ }, [key, applyValue])
48
111
 
49
112
  return [state, setPersistedState]
50
113
  }
@@ -1,47 +1,73 @@
1
- import React, {useEffect, useState} from 'react'
1
+ import type React from 'react'
2
+ import { useCallback, useRef, useState } from 'react'
2
3
 
3
- import {Storage} from './@types/storage'
4
- import {PersistedState, UsePersistedState} from './@types/hook'
4
+ import type { Storage } from './@types/storage'
5
+ import type { PersistedState, UsePersistedState } from './@types/hook'
5
6
 
6
7
  import useStorageHandler from './utils/use-storage-handler'
7
8
  import getNewValue from './utils/get-new-value'
8
9
  import getNewItem from './utils/get-new-item'
9
10
  import getPersistedValue from './utils/get-persisted-value'
10
11
 
11
- export default function createPersistedState(
12
- storageKey: string,
13
- storage: Storage,
14
- ): [PersistedState, () => void] {
12
+ export default function createPersistedState(storageKey: string, storage: Storage): [PersistedState, () => void] {
15
13
  const safeStorageKey = `persisted_state_hook:${storageKey}`
16
14
  const clear = (): void => {
17
15
  storage.remove(safeStorageKey)
18
16
  }
19
17
 
18
+ const readPersisted = <T>(key: string, initialValue: T): T =>
19
+ getPersistedValue<T>(key, initialValue, storage.get(safeStorageKey)[safeStorageKey])
20
+
20
21
  const usePersistedState = <T>(key: string, initialValue: T): UsePersistedState<T> => {
21
- const persist = storage.get(safeStorageKey)
22
- const persistedState = persist[safeStorageKey]
23
- const initialOrPersistedValue = getPersistedValue<T>(key, initialValue, persistedState)
22
+ // Reading through the initializer keeps the storage out of the render path:
23
+ // the hook runs on every render of every consuming component, so a re-read is
24
+ // only warranted when the key it is asked for actually changes.
25
+ const [state, setState] = useState<T>(() => readPersisted(key, initialValue))
26
+
27
+ // The setter must resolve updater functions against the last value applied,
28
+ // not against the one captured by the render that created it. Reading the
29
+ // render closure collapses updates batched into a single event.
30
+ const latestValue = useRef(state)
24
31
 
25
- const [state, setState] = useState<T>(initialOrPersistedValue)
32
+ const applyValue = useCallback((value: T): void => {
33
+ latestValue.current = value
26
34
 
27
- const setPersistedState = (newState: React.SetStateAction<T>): void => {
28
- const newValue = getNewValue<T>(newState, state)
35
+ setState(value)
36
+ }, [])
29
37
 
30
- setState(newValue)
38
+ // A mounted hook handed a different key has to show that key's value before
39
+ // anything can be written, or the next update stores the previous key's value
40
+ // under the new key and destroys what was there. Adjusting during render ties
41
+ // the re-read to the change itself, where an effect would cost a read and a
42
+ // second render on every mount as well.
43
+ const renderedKey = useRef(key)
31
44
 
32
- const persistedItem = storage.get(safeStorageKey)[safeStorageKey]
33
- const newItem = getNewItem<T>(key, persistedItem, newValue)
45
+ if (renderedKey.current !== key) {
46
+ renderedKey.current = key
34
47
 
35
- storage.set({[safeStorageKey]: newItem})
48
+ applyValue(readPersisted(key, initialValue))
36
49
  }
37
50
 
38
- useEffect(() => {
39
- const initialOrPersistedValue = getPersistedValue<T>(key, initialValue, persistedState)
51
+ // The exact entry this hook last wrote and has not yet seen reported back.
52
+ const pendingOwnWrite = useRef<string | null>(null)
53
+
54
+ const setPersistedState = useCallback(
55
+ (newState: React.SetStateAction<T>): void => {
56
+ const newValue = getNewValue<T>(newState, latestValue.current)
57
+
58
+ applyValue(newValue)
59
+
60
+ const persistedItem = storage.get(safeStorageKey)[safeStorageKey]
61
+ const newItem = getNewItem<T>(key, persistedItem, newValue)
62
+
63
+ pendingOwnWrite.current = newItem
40
64
 
41
- setState(initialOrPersistedValue)
42
- }, [initialValue, key, persistedState])
65
+ storage.set({ [safeStorageKey]: newItem })
66
+ },
67
+ [key, applyValue],
68
+ )
43
69
 
44
- useStorageHandler<T>(key, safeStorageKey, setState, storage, initialValue)
70
+ useStorageHandler<T>(key, safeStorageKey, applyValue, storage, initialValue, pendingOwnWrite)
45
71
 
46
72
  return [state, setPersistedState]
47
73
  }
package/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { default as createSyncPersistedState } from './create-persisted-state'
1
+ import createSyncPersistedState from './create-persisted-state'
2
2
  import createAsyncPersistedState from './create-async-persisted-state'
3
3
 
4
4
  import isAsyncStorage from './utils/is-async-storage'
5
5
 
6
- import { Storage, AsyncStorage } from './@types/storage'
7
- import { PersistedState } from './@types/hook'
6
+ import type { Storage, AsyncStorage } from './@types/storage'
7
+ import type { PersistedState } from './@types/hook'
8
8
 
9
9
  export default function createPersistedState<S extends Storage | AsyncStorage>(
10
10
  name: string,
@@ -17,7 +17,12 @@ export default function createPersistedState<S extends Storage | AsyncStorage>(
17
17
  return createSyncPersistedState(name, storage as Storage)
18
18
  }
19
19
 
20
- export {
21
- createSyncPersistedState as createPersistedState,
22
- createAsyncPersistedState,
23
- }
20
+ export { default as createPersistedState } from './create-persisted-state'
21
+ export { default as createAsyncPersistedState } from './create-async-persisted-state'
22
+
23
+ // The contract a backend implements, and the shape the hook hands back. Both appear in the
24
+ // signatures above, so a consumer writing an adapter or typing a wrapper has to be able to name
25
+ // them from the supported entry point rather than from `lib/`, which the exports map keeps open
26
+ // only as a frozen legacy path.
27
+ export type { AsyncStorage, Storage, StorageChange, StorageChangeEvent, StorageChangeListener } from './@types/storage'
28
+ export type { PersistedState, UsePersistedState } from './@types/hook'
@@ -1,50 +1,22 @@
1
- import { AsyncStorage, StorageChange, StorageChangeEvent, StorageChangeListener } from '../@types/storage'
1
+ import type { AsyncStorage } from '../@types/storage'
2
+ import { type Area, createListenerRegistry, toStorageChanges, toStoredItems } from '../utils/extension-storage'
2
3
 
3
- const listeners = {
4
- local: new Set<StorageChangeListener>(),
5
- sync: new Set<StorageChangeListener>(),
6
- managed: new Set<StorageChangeListener>(),
7
- }
8
-
9
- type Area = keyof typeof listeners
10
-
11
- function fireStorageEvent(changes: { [key: string]: StorageChange }, area: Area) {
12
- listeners[area].forEach(listener => {
13
- listener(changes)
14
- })
15
- }
4
+ const registry = createListenerRegistry()
16
5
 
17
6
  browser.storage.onChanged.addListener((changes, area) => {
18
- fireStorageEvent(changes, area as Area)
7
+ registry.fire(toStorageChanges(changes), area)
19
8
  })
20
9
 
21
- function createOnChanged(area: Area): StorageChangeEvent {
22
- return {
23
- addListener(listener) {
24
- listeners[area].add(listener)
25
- },
26
- removeListener(listener) {
27
- listeners[area].delete(listener)
28
- },
29
- hasListener(listener) {
30
- return listeners[area].has(listener)
31
- },
32
- }
33
- }
34
-
10
+ // browser.storage is promise-based, unlike its callback-based chrome counterpart.
35
11
  const createStorage = (storage: browser.storage.StorageArea, area: Area): AsyncStorage => ({
36
- get: storage.get,
12
+ get: async keys => toStoredItems(await storage.get(keys)),
37
13
  set: storage.set,
38
14
  remove: storage.remove,
39
- onChanged: createOnChanged(area),
15
+ onChanged: registry.createOnChanged(area),
40
16
  })
41
17
 
42
18
  const local = createStorage(browser.storage.local, 'local')
43
19
  const sync = createStorage(browser.storage.sync, 'sync')
44
20
  const managed = createStorage(browser.storage.managed, 'managed')
45
21
 
46
- export {
47
- local,
48
- sync,
49
- managed,
50
- }
22
+ export { local, sync, managed }
@@ -1,58 +1,36 @@
1
- import { AsyncStorage, StorageChange, StorageChangeEvent, StorageChangeListener } from '../@types/storage'
1
+ import type { AsyncStorage } from '../@types/storage'
2
+ import { type Area, createListenerRegistry, toStorageChanges, toStoredItems } from '../utils/extension-storage'
2
3
 
3
- const listeners = {
4
- local: new Set<StorageChangeListener>(),
5
- sync: new Set<StorageChangeListener>(),
6
- managed: new Set<StorageChangeListener>(),
7
- }
8
-
9
- type Area = keyof typeof listeners
10
-
11
- function fireStorageEvent(changes: { [key: string]: StorageChange }, area: Area) {
12
- listeners[area].forEach(listener => {
13
- listener(changes)
14
- })
15
- }
4
+ const registry = createListenerRegistry()
16
5
 
17
6
  chrome.storage.onChanged.addListener((changes, area) => {
18
- fireStorageEvent(changes, area as Area)
7
+ registry.fire(toStorageChanges(changes), area)
19
8
  })
20
9
 
21
- function createOnChanged(area: Area): StorageChangeEvent {
22
- return {
23
- addListener(listener) {
24
- listeners[area].add(listener)
25
- },
26
- removeListener(listener) {
27
- listeners[area].delete(listener)
28
- },
29
- hasListener(listener) {
30
- return listeners[area].has(listener)
31
- },
32
- }
33
- }
34
-
10
+ // chrome.storage is callback-based, unlike its promise-based firefox counterpart.
35
11
  const createStorage = (storage: chrome.storage.StorageArea, area: Area): AsyncStorage => ({
36
- get: keys => new Promise(resolve => {
37
- storage.get(keys, items => {
38
- resolve(items)
39
- })
40
- }),
41
- set: items => new Promise(resolve => {
42
- storage.set(items, resolve)
43
- }),
44
- remove: keys => new Promise(resolve => {
45
- storage.remove(keys, resolve)
46
- }),
47
- onChanged: createOnChanged(area),
12
+ // Declared async, like the firefox adapter, so that asking whether this
13
+ // storage is asynchronous can be answered from the function itself instead of
14
+ // by calling it — a call here costs a round trip to the extension process.
15
+ get: async keys =>
16
+ new Promise(resolve => {
17
+ storage.get(keys, items => {
18
+ resolve(toStoredItems(items))
19
+ })
20
+ }),
21
+ set: items =>
22
+ new Promise(resolve => {
23
+ storage.set(items, resolve)
24
+ }),
25
+ remove: keys =>
26
+ new Promise(resolve => {
27
+ storage.remove(keys, resolve)
28
+ }),
29
+ onChanged: registry.createOnChanged(area),
48
30
  })
49
31
 
50
32
  const local = createStorage(chrome.storage.local, 'local')
51
33
  const sync = createStorage(chrome.storage.sync, 'sync')
52
34
  const managed = createStorage(chrome.storage.managed, 'managed')
53
35
 
54
- export {
55
- local,
56
- sync,
57
- managed,
58
- }
36
+ export { local, sync, managed }
@@ -1,3 +1,5 @@
1
1
  import createStorage from '../utils/create-web-storage'
2
2
 
3
- export default createStorage(localStorage)
3
+ // The typeof guard keeps this import from throwing on server runtimes (SSR),
4
+ // where the browser storage globals are not defined.
5
+ export default createStorage(typeof localStorage !== 'undefined' ? localStorage : undefined)
@@ -1,3 +1,5 @@
1
1
  import createStorage from '../utils/create-web-storage'
2
2
 
3
- export default createStorage(sessionStorage)
3
+ // The typeof guard keeps this import from throwing on server runtimes (SSR),
4
+ // where the browser storage globals are not defined.
5
+ export default createStorage(typeof sessionStorage !== 'undefined' ? sessionStorage : undefined)