@akb2/react-use-local-storage 1.0.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.
- package/.gitignore +144 -0
- package/.prettierignore +5 -0
- package/.prettierrc.json +11 -0
- package/LICENSE +373 -0
- package/README.md +197 -0
- package/eslint.config.mjs +55 -0
- package/package.json +74 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/hooks/use-local-storage-state.ts +33 -0
- package/src/index.ts +6 -0
- package/src/react.inject.ts +3 -0
- package/src/types/window.ts +10 -0
- package/src/utils/add-listener-by-key.ts +34 -0
- package/src/utils/clear-local-storage.ts +25 -0
- package/src/utils/deep-freeze.ts +18 -0
- package/src/utils/delete-listener-by-key.ts +29 -0
- package/src/utils/get-listeners-keys-size.ts +14 -0
- package/src/utils/get-local-storage-value.ts +49 -0
- package/src/utils/get-original-data-storage-key.ts +8 -0
- package/src/utils/listen-external-store-events.ts +35 -0
- package/src/utils/remove-local-storage-value.ts +3 -0
- package/src/utils/set-local-storage-value.ts +50 -0
- package/tests/local-storage-listening.test.ts +28 -0
- package/tests/simple-change.test.ts +40 -0
- package/tests/tsconfig.json +3 -0
- package/tsconfig.json +33 -0
- package/tsconfig.test.json +31 -0
- package/vitest.config.ts +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# react-use-local-storage
|
|
2
|
+
|
|
3
|
+
A React state hook backed by `localStorage`, built on `useSyncExternalStore`. Read and update values by key, subscribe to changes, and access storage outside React components.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add react-use-local-storage
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Declared peer dependencies: `react@^18.3.1` and `@akb2/types-tools@^1.3.0`. The current React peer dependency range does not include React 19.
|
|
12
|
+
|
|
13
|
+
The package provides ESM, CommonJS, and TypeScript declarations.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { useLocalStorageState } from "react-use-local-storage";
|
|
19
|
+
|
|
20
|
+
export const Counter = () => {
|
|
21
|
+
const [count, setCount] = useLocalStorageState<number>("counter");
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<button onClick={() => setCount((previous) => (previous ?? 0) + 1)}>
|
|
25
|
+
Clicks: {count ?? 0}
|
|
26
|
+
</button>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The hook returns `undefined` for a missing key. There is no second argument for an initial value. Use `??` to provide a display fallback; this does not write the fallback to storage.
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
### `useLocalStorageState<T>(key)`
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const [value, setValue, storageKey] = useLocalStorageState<string>("name");
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Element | Description |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `value` | The current value, or `undefined` for a missing key |
|
|
44
|
+
| `setValue` | Writes a value or computes a new value from the previous one |
|
|
45
|
+
| `storageKey` | The key passed to the hook |
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
setValue("Andrew");
|
|
49
|
+
setValue((previous) => `${previous ?? ""}!`);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
An updater callback receives the current value from storage. At runtime, this can be `undefined` when the key is missing, so handle that case in the callback. The current setter signature is `Dispatch<SetStateAction<T>>`, which does not reflect this possible `undefined` argument.
|
|
53
|
+
|
|
54
|
+
Functions cannot be stored as values: a function argument is treated as an updater, and returning a function from that updater throws an error.
|
|
55
|
+
|
|
56
|
+
### `getLocalStorageValue<T>(key)`
|
|
57
|
+
|
|
58
|
+
Reads a value without creating a React subscription:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { getLocalStorageValue } from "react-use-local-storage";
|
|
62
|
+
|
|
63
|
+
const name = getLocalStorageValue<string>("name");
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Returns `undefined` for a missing key. The generic type `T` describes the expected value; it does not validate stored data at runtime.
|
|
67
|
+
|
|
68
|
+
### `setLocalStorageValue<T>(key, valueOrCallback)`
|
|
69
|
+
|
|
70
|
+
Writes a value and notifies subscribers to that key in the current window:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { setLocalStorageValue } from "react-use-local-storage";
|
|
74
|
+
|
|
75
|
+
setLocalStorageValue("name", "Andrew");
|
|
76
|
+
setLocalStorageValue<number>("counter", (previous) => (previous ?? 0) + 1);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Writing the same serialized content does not notify subscribers again. Passing `null` removes the value.
|
|
80
|
+
|
|
81
|
+
### `removeLocalStorageValue(key)`
|
|
82
|
+
|
|
83
|
+
Removes a value through the library's setter and notifies subscribers if the stored value changes:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { removeLocalStorageValue } from "react-use-local-storage";
|
|
87
|
+
|
|
88
|
+
removeLocalStorageValue("name");
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `clearLocalStorage()`
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { clearLocalStorage } from "react-use-local-storage";
|
|
95
|
+
|
|
96
|
+
clearLocalStorage();
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Clears **all `localStorage` entries for the current origin**, including keys written by other code. It also clears the internal cache and notifies subscribers to keys that existed before the operation.
|
|
100
|
+
|
|
101
|
+
## Storage format
|
|
102
|
+
|
|
103
|
+
Values are stored in a JSON wrapper with a `value` property:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
setLocalStorageValue("name", "Andrew");
|
|
107
|
+
|
|
108
|
+
localStorage.getItem("name");
|
|
109
|
+
// '{"value":"Andrew"}'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Use JSON-compatible data. For example, `Date` becomes a string during serialization; circular objects and `BigInt` cause serialization errors.
|
|
113
|
+
|
|
114
|
+
Read values are cached by storage key and raw content. Parsed data is passed through `deepFreeze`. Create new values when updating objects:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { useLocalStorageState } from "react-use-local-storage";
|
|
118
|
+
|
|
119
|
+
type Preferences = {
|
|
120
|
+
theme: "light" | "dark";
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const ThemeButton = () => {
|
|
124
|
+
const [preferences, setPreferences] =
|
|
125
|
+
useLocalStorageState<Preferences>("preferences");
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<button
|
|
129
|
+
onClick={() =>
|
|
130
|
+
setPreferences((previous) => ({ ...previous, theme: "dark" }))
|
|
131
|
+
}
|
|
132
|
+
>
|
|
133
|
+
Theme: {preferences?.theme ?? "light"}
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
When reading data written by other code, the getter reads the `.value` property of parsed JSON. If parsing or subsequent processing throws, it returns the original nonempty string. Arbitrary JSON without the wrapper is not the library's storage format.
|
|
140
|
+
|
|
141
|
+
## Synchronization
|
|
142
|
+
|
|
143
|
+
- In the current window, use the hook's setter or the library utilities to notify subscribers.
|
|
144
|
+
- External changes are handled through `storage` events for `localStorage`. The handler supports key updates, removal, and storage clearing.
|
|
145
|
+
- Direct calls to `localStorage.setItem()`, `removeItem()`, or `clear()` do not fire a `storage` event in the window that made the change. This subscription does not automatically detect those local writes.
|
|
146
|
+
- Cross-tab synchronization requires the same origin: protocol, host, and port.
|
|
147
|
+
|
|
148
|
+
When writing directly from another tab, use the `JSON.stringify({ value: ... })` format.
|
|
149
|
+
|
|
150
|
+
## Server-side rendering
|
|
151
|
+
|
|
152
|
+
The hook's server snapshot is `undefined`. When `window` is unavailable, reads return `undefined`, and writes and clearing are no-ops. After hydration, React uses the client snapshot from `localStorage`.
|
|
153
|
+
|
|
154
|
+
Provide a fallback for the initial display, such as `value ?? ""`.
|
|
155
|
+
|
|
156
|
+
## Limitations
|
|
157
|
+
|
|
158
|
+
- Accessing or writing to `localStorage` can throw, for example when access is denied or the storage quota is exceeded. The library does not catch these errors.
|
|
159
|
+
- Updates to multiple keys are not transactional.
|
|
160
|
+
- Stored data is not validated against the declared TypeScript type.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pnpm install
|
|
166
|
+
pnpm test
|
|
167
|
+
pnpm run test:watch
|
|
168
|
+
pnpm build
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Build output is written to `.dist`: `index.js` for ESM, `index.cjs` for CommonJS, and type declarations.
|
|
172
|
+
|
|
173
|
+
Additional commands:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pnpm lint
|
|
177
|
+
pnpm run lint:fix
|
|
178
|
+
pnpm format
|
|
179
|
+
pnpm run format:check
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
For package maintainers, publish with:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pnpm release
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
This command builds the package and then publishes it with public access.
|
|
189
|
+
|
|
190
|
+
## Links
|
|
191
|
+
|
|
192
|
+
- [Source code](https://github.com/akb2/react-use-local-storage)
|
|
193
|
+
- [Report an issue](https://github.com/akb2/react-use-local-storage/issues)
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MPL-2.0. Author: akb2.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import stylistic from "@stylistic/eslint-plugin";
|
|
3
|
+
import { defineConfig } from "eslint/config";
|
|
4
|
+
import reactHooks from "eslint-plugin-react-hooks";
|
|
5
|
+
import globals from "globals";
|
|
6
|
+
import tseslint from "typescript-eslint";
|
|
7
|
+
import prettier from "eslint-config-prettier/flat";
|
|
8
|
+
|
|
9
|
+
export default defineConfig([
|
|
10
|
+
{
|
|
11
|
+
ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**"],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
files: ["**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}"],
|
|
15
|
+
extends: [js.configs.recommended],
|
|
16
|
+
plugins: { "@stylistic": stylistic },
|
|
17
|
+
languageOptions: {
|
|
18
|
+
ecmaVersion: "latest",
|
|
19
|
+
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
20
|
+
},
|
|
21
|
+
rules: {
|
|
22
|
+
eqeqeq: ["error", "always"],
|
|
23
|
+
"prefer-const": "error",
|
|
24
|
+
"no-var": "error",
|
|
25
|
+
"prefer-arrow-callback": "error",
|
|
26
|
+
"@stylistic/quotes": ["error", "double", { avoidEscape: true }],
|
|
27
|
+
"@stylistic/jsx-quotes": ["error", "prefer-double"],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
files: ["**/*.{ts,tsx,mts,cts}"],
|
|
32
|
+
extends: [tseslint.configs.recommended],
|
|
33
|
+
rules: {
|
|
34
|
+
"@typescript-eslint/no-unused-vars": [
|
|
35
|
+
"error",
|
|
36
|
+
{ argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
files: ["{backend,core}/**/*.{js,mjs,cjs,ts,mts,cts}", "**/*.config.{js,mjs,cjs,ts,mts,cts}"],
|
|
42
|
+
languageOptions: { globals: globals.node },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
files: ["frontend/**/*.{js,jsx,ts,tsx}"],
|
|
46
|
+
ignores: ["**/*.config.*"],
|
|
47
|
+
languageOptions: { globals: globals.browser },
|
|
48
|
+
plugins: { "react-hooks": reactHooks },
|
|
49
|
+
rules: {
|
|
50
|
+
"react-hooks/rules-of-hooks": "error",
|
|
51
|
+
"react-hooks/exhaustive-deps": "warn",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
prettier,
|
|
55
|
+
]);
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@akb2/react-use-local-storage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A state hook with browser's localStorage",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./.dist/index.d.ts",
|
|
8
|
+
"import": "./.dist/index.js",
|
|
9
|
+
"require": "./.dist/index.cjs"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"localStorage",
|
|
15
|
+
"hook"
|
|
16
|
+
],
|
|
17
|
+
"author": "akb2",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/akb2/react-use-local-storage.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/akb2/react-use-local-storage/issues"
|
|
24
|
+
},
|
|
25
|
+
"license": "MPL-2.0",
|
|
26
|
+
"devEngines": {
|
|
27
|
+
"packageManager": {
|
|
28
|
+
"name": "pnpm",
|
|
29
|
+
"version": "12.5.1",
|
|
30
|
+
"onFail": "download"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"type": "module",
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@testing-library/dom": "^10.4.2",
|
|
36
|
+
"@testing-library/react": "^16.3.3",
|
|
37
|
+
"@types/node": "^26.6.2",
|
|
38
|
+
"@types/react": "^18.3.31",
|
|
39
|
+
"@types/react-dom": "^18.3.7",
|
|
40
|
+
"eslint-config-prettier": "^10.1.8",
|
|
41
|
+
"jsdom": "^30.1.0",
|
|
42
|
+
"prettier": "^3.9.7",
|
|
43
|
+
"react-dom": "^18.3.1",
|
|
44
|
+
"tsup": "^8.5.1",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"vitest": "^5.0.1"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@akb2/types-tools": "^1.3.0",
|
|
50
|
+
"react": "^18.3.1"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@akb2/types-tools": "^1.3.0",
|
|
54
|
+
"react": "^18.3.1"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@akb2/types-tools": {
|
|
58
|
+
"optional": false
|
|
59
|
+
},
|
|
60
|
+
"react": {
|
|
61
|
+
"optional": false
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsup src/index.ts --inject ./src/react.inject.ts --dts --format esm,cjs --clean --out-dir .dist --minify",
|
|
66
|
+
"release": "pnpm run build && pnpm publish --access public",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"test:watch": "vitest",
|
|
69
|
+
"lint": "eslint .",
|
|
70
|
+
"lint:fix": "eslint . --fix",
|
|
71
|
+
"format": "prettier . --write",
|
|
72
|
+
"format:check": "prettier . --check"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NotDefinable } from "@akb2/types-tools";
|
|
2
|
+
import { addListenerByKey } from "@utils/add-listener-by-key";
|
|
3
|
+
import { deleteListenerByKey } from "@utils/delete-listener-by-key";
|
|
4
|
+
import { getLocalStorageValue } from "@utils/get-local-storage-value";
|
|
5
|
+
import { setLocalStorageValue } from '@utils/set-local-storage-value';
|
|
6
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
7
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A custom React hook that synchronizes a state variable with local storage.
|
|
11
|
+
*
|
|
12
|
+
* @template T The type of the state variable.
|
|
13
|
+
* @param key The key in local storage to associate with the state variable.
|
|
14
|
+
* @returns A tuple containing the state variable, a setter function, and the key.
|
|
15
|
+
*/
|
|
16
|
+
export const useLocalStorageState = <T>(key: string): [NotDefinable<T>, Dispatch<SetStateAction<T>>, string] => {
|
|
17
|
+
const setState = useCallback(setLocalStorageValue.bind(null, key), [key]);
|
|
18
|
+
const state = useSyncExternalStore(
|
|
19
|
+
(onStoreChange) => {
|
|
20
|
+
if (typeof window === 'undefined') {
|
|
21
|
+
return (): void => {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
addListenerByKey(key, onStoreChange);
|
|
25
|
+
|
|
26
|
+
return (): void => deleteListenerByKey(key, onStoreChange);
|
|
27
|
+
},
|
|
28
|
+
() => getLocalStorageValue<T>(key) as T,
|
|
29
|
+
() => undefined,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return [state, setState, key];
|
|
33
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useLocalStorageState } from './hooks/use-local-storage-state';
|
|
2
|
+
export * from './types/window';
|
|
3
|
+
export { clearLocalStorage } from './utils/clear-local-storage';
|
|
4
|
+
export { getLocalStorageValue } from './utils/get-local-storage-value';
|
|
5
|
+
export { removeLocalStorageValue } from './utils/remove-local-storage-value';
|
|
6
|
+
export { setLocalStorageValue } from './utils/set-local-storage-value';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isDefined } from "@akb2/types-tools";
|
|
2
|
+
import { getListenersKeysSize } from "@utils/get-listeners-keys-size";
|
|
3
|
+
import { listenExternalStoreEvents } from "@utils/listen-external-store-events";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Adds a listener for changes to a specific key in local storage.
|
|
7
|
+
*
|
|
8
|
+
* @param key The key in local storage to listen for changes on.
|
|
9
|
+
* @param listener The callback function to invoke when the specified key changes.
|
|
10
|
+
* @returns void
|
|
11
|
+
*/
|
|
12
|
+
export const addListenerByKey = (key: string, listener: () => void): void => {
|
|
13
|
+
if (typeof window === 'undefined') {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (getListenersKeysSize() === 0) {
|
|
18
|
+
addEventListener('storage', listenExternalStoreEvents);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__)){
|
|
22
|
+
window.__AKB2_LOCAL_STORAGE__ = { } as typeof window.__AKB2_LOCAL_STORAGE__;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__.listeners)){
|
|
26
|
+
window.__AKB2_LOCAL_STORAGE__.listeners = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!window.__AKB2_LOCAL_STORAGE__.listeners.has(key)) {
|
|
30
|
+
window.__AKB2_LOCAL_STORAGE__.listeners.set(key, new Set());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
window.__AKB2_LOCAL_STORAGE__.listeners.get(key)!.add(listener);
|
|
34
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isDefined, Nullable } from "@akb2/types-tools";
|
|
2
|
+
|
|
3
|
+
export const clearLocalStorage = (): void => {
|
|
4
|
+
if (typeof window === 'undefined') {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const keys = Array.from(window.__AKB2_LOCAL_STORAGE__?.listeners?.keys()??[]);
|
|
9
|
+
const currentValues = keys.reduce((acc, key) => {
|
|
10
|
+
acc.set(key, localStorage.getItem(key) ?? null);
|
|
11
|
+
|
|
12
|
+
return acc;
|
|
13
|
+
}, new Map<string, Nullable<string>>());
|
|
14
|
+
|
|
15
|
+
localStorage.clear();
|
|
16
|
+
window.__AKB2_LOCAL_STORAGE__?.originalData?.clear();
|
|
17
|
+
|
|
18
|
+
window.__AKB2_LOCAL_STORAGE__?.listeners?.forEach((listeners, key) => {
|
|
19
|
+
const currentValue = currentValues.get(key);
|
|
20
|
+
|
|
21
|
+
if (isDefined(currentValue)) {
|
|
22
|
+
listeners.forEach((listener) => listener());
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isDefined } from "@akb2/types-tools";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deeply freezes an object, making it immutable.
|
|
5
|
+
*
|
|
6
|
+
* @param value - The object to freeze.
|
|
7
|
+
* @returns The frozen object.
|
|
8
|
+
*/
|
|
9
|
+
export const deepFreeze = <T>(value: T): T => {
|
|
10
|
+
if (!isDefined(value) || typeof value !== 'object' || Object.isFrozen(value)) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
Object.freeze(value);
|
|
15
|
+
Object.values(value as Record<string, unknown>).forEach(deepFreeze);
|
|
16
|
+
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getListenersKeysSize } from "@utils/get-listeners-keys-size";
|
|
2
|
+
import { listenExternalStoreEvents } from "@utils/listen-external-store-events";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deletes a listener for changes to a specific key in local storage.
|
|
6
|
+
*
|
|
7
|
+
* @param key The key in local storage to stop listening for changes on.
|
|
8
|
+
* @param listener The callback function to remove.
|
|
9
|
+
* @returns void
|
|
10
|
+
*/
|
|
11
|
+
export const deleteListenerByKey = (key: string, listener: () => void): void => {
|
|
12
|
+
if (typeof window === 'undefined') {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!window.__AKB2_LOCAL_STORAGE__?.listeners?.has(key)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
window.__AKB2_LOCAL_STORAGE__.listeners.get(key)!.delete(listener);
|
|
21
|
+
|
|
22
|
+
if (window.__AKB2_LOCAL_STORAGE__.listeners.get(key)?.size === 0) {
|
|
23
|
+
window.__AKB2_LOCAL_STORAGE__.listeners.delete(key);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (getListenersKeysSize() === 0) {
|
|
27
|
+
removeEventListener('storage', listenExternalStoreEvents);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { anyToInt } from "@akb2/types-tools";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gets the number of keys that have listeners in local storage.
|
|
5
|
+
*
|
|
6
|
+
* @returns The number of keys with listeners.
|
|
7
|
+
*/
|
|
8
|
+
export const getListenersKeysSize = (): number => {
|
|
9
|
+
if (typeof window === 'undefined') {
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return anyToInt(window.__AKB2_LOCAL_STORAGE__?.listeners?.size);
|
|
14
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isDefined, NotDefinable } from "@akb2/types-tools";
|
|
2
|
+
import { deepFreeze } from "@utils/deep-freeze";
|
|
3
|
+
import { getOriginalDataStorageKey } from "./get-original-data-storage-key";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Retrieves the value associated with a specific key from local storage.
|
|
7
|
+
*
|
|
8
|
+
* @template T The expected type of the value.
|
|
9
|
+
* @param key The key in local storage to retrieve the value for.
|
|
10
|
+
* @returns The value associated with the key, or undefined if not found.
|
|
11
|
+
*/
|
|
12
|
+
export const getLocalStorageValue = <T>(key: string): NotDefinable<T> => {
|
|
13
|
+
if (typeof window === 'undefined') {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const raw = localStorage.getItem(key);
|
|
18
|
+
|
|
19
|
+
if (!isDefined(raw)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__)){
|
|
24
|
+
window.__AKB2_LOCAL_STORAGE__ = { } as typeof window.__AKB2_LOCAL_STORAGE__;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__.originalData)){
|
|
28
|
+
window.__AKB2_LOCAL_STORAGE__.originalData = new Map();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const cachedKey = getOriginalDataStorageKey(key, raw);
|
|
33
|
+
const cachedData = window.__AKB2_LOCAL_STORAGE__.originalData.get(cachedKey);
|
|
34
|
+
|
|
35
|
+
if (isDefined(cachedData)) {
|
|
36
|
+
return cachedData as T;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parsedData = deepFreeze(JSON.parse(raw).value);
|
|
40
|
+
|
|
41
|
+
if (isDefined(parsedData)) {
|
|
42
|
+
window.__AKB2_LOCAL_STORAGE__.originalData.set(cachedKey, parsedData);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return parsedData as Readonly<T>;
|
|
46
|
+
} catch {
|
|
47
|
+
return raw.length > 0 ? (raw as unknown as T) : undefined;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a unique key for storing the original data in local storage.
|
|
3
|
+
*
|
|
4
|
+
* @param key The key in local storage.
|
|
5
|
+
* @param value The value associated with the key.
|
|
6
|
+
* @returns A unique key for storing the original data.
|
|
7
|
+
*/
|
|
8
|
+
export const getOriginalDataStorageKey = (key: string, value: string): string => `${key}:{${value}}`;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isDefined } from "@akb2/types-tools";
|
|
2
|
+
import { getOriginalDataStorageKey } from "@utils/get-original-data-storage-key";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Listens for external changes to the local storage and updates the internal cache and listeners accordingly.
|
|
6
|
+
*
|
|
7
|
+
* @param event The storage event triggered by changes to the local storage.
|
|
8
|
+
* @returns void
|
|
9
|
+
*/
|
|
10
|
+
export const listenExternalStoreEvents = (event: StorageEvent): void => {
|
|
11
|
+
if (event.storageArea === localStorage) {
|
|
12
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__)){
|
|
13
|
+
window.__AKB2_LOCAL_STORAGE__ = { } as typeof window.__AKB2_LOCAL_STORAGE__;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__.listeners)){
|
|
17
|
+
window.__AKB2_LOCAL_STORAGE__.listeners = new Map();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__.originalData)){
|
|
21
|
+
window.__AKB2_LOCAL_STORAGE__.originalData = new Map();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (isDefined(event.key)) {
|
|
25
|
+
if (isDefined(event.oldValue) && event.oldValue !== event.newValue) {
|
|
26
|
+
window.__AKB2_LOCAL_STORAGE__.originalData.delete(getOriginalDataStorageKey(event.key, event.oldValue));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
window.__AKB2_LOCAL_STORAGE__.listeners.get(event.key)?.forEach((listener) => listener());
|
|
30
|
+
} else {
|
|
31
|
+
window.__AKB2_LOCAL_STORAGE__.originalData.clear();
|
|
32
|
+
window.__AKB2_LOCAL_STORAGE__.listeners.forEach((listeners) => listeners.forEach((listener) => listener()));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isDefined, Nullable } from "@akb2/types-tools";
|
|
2
|
+
import { deepFreeze } from "@utils/deep-freeze";
|
|
3
|
+
import { getLocalStorageValue } from "@utils/get-local-storage-value";
|
|
4
|
+
import { getOriginalDataStorageKey } from "@utils/get-original-data-storage-key";
|
|
5
|
+
import { SetStateAction } from "react";
|
|
6
|
+
|
|
7
|
+
export const setLocalStorageValue = <T>(key: string, valueOrCallback: SetStateAction<Nullable<T>>): void => {
|
|
8
|
+
if (typeof window === 'undefined') {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const value = typeof valueOrCallback === 'function' ? (valueOrCallback as Function)(getLocalStorageValue<T>(key)) : valueOrCallback;
|
|
13
|
+
|
|
14
|
+
if (typeof value === 'function') {
|
|
15
|
+
throw new Error('Functional updates are not supported in useLocalStorageState setter. Please provide the new value directly.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const currentValue = localStorage.getItem(key) ?? null;
|
|
19
|
+
const newValue = isDefined(value) ? JSON.stringify({ value }) : null;
|
|
20
|
+
|
|
21
|
+
if (isDefined(newValue)) {
|
|
22
|
+
localStorage.setItem(key, newValue);
|
|
23
|
+
} else {
|
|
24
|
+
localStorage.removeItem(key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (currentValue !== newValue) {
|
|
28
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__)){
|
|
29
|
+
window.__AKB2_LOCAL_STORAGE__ = { } as typeof window.__AKB2_LOCAL_STORAGE__;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__.listeners)){
|
|
33
|
+
window.__AKB2_LOCAL_STORAGE__.listeners = new Map();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if(!isDefined(window.__AKB2_LOCAL_STORAGE__.originalData)){
|
|
37
|
+
window.__AKB2_LOCAL_STORAGE__.originalData = new Map();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
window.__AKB2_LOCAL_STORAGE__.listeners.get(key)?.forEach((listener) => listener());
|
|
41
|
+
|
|
42
|
+
if (isDefined(currentValue)) {
|
|
43
|
+
window.__AKB2_LOCAL_STORAGE__.originalData.delete(getOriginalDataStorageKey(key, currentValue));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isDefined(newValue)) {
|
|
47
|
+
window.__AKB2_LOCAL_STORAGE__.originalData.set(getOriginalDataStorageKey(key, newValue), deepFreeze(JSON.parse(newValue).value));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|