@djangocfg/ui-core 2.1.603 → 2.1.604
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
CHANGED
|
@@ -117,6 +117,39 @@ fields (`DateField` / `TimeField` / `DateTimeField`) have their own reference in
|
|
|
117
117
|
| `time/` | `useNow` · `useInterval` · `useTimeout` |
|
|
118
118
|
| `router/` | `useLink` · `useRouter` (router-adapter consumer) |
|
|
119
119
|
|
|
120
|
+
### Storage keys and namespaces (`hooks/state`)
|
|
121
|
+
|
|
122
|
+
`useLocalStorage`, `useSessionStorage` and `useStoredValue` (every component
|
|
123
|
+
`storageKey` prop) write under one scheme, `<owner>.<area>.<name>`, e.g.
|
|
124
|
+
`cfg.ui.sidebar_width`, `kino.player.volume`. Full rules for agents:
|
|
125
|
+
`@djangocfg/api` → `@docs/STORAGE_KEYS.md`.
|
|
126
|
+
|
|
127
|
+
- A key that already follows the scheme is used as is.
|
|
128
|
+
- A bare name (`'search-filter'`) is folded to `snake_case` and put under the
|
|
129
|
+
active owner: `cfg.ui.search_filter`. The owner is the hook's `namespace`
|
|
130
|
+
option, else the nearest `<StorageNamespaceProvider owner="kino">`, else the
|
|
131
|
+
framework default `cfg`.
|
|
132
|
+
- Quota recovery drops only non-personal keys of our scheme, never tokens,
|
|
133
|
+
drafts, the profile or third-party keys (`NEXT_LOCALE`).
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
// app root, once
|
|
137
|
+
<StorageNamespaceProvider owner="kino">…</StorageNamespaceProvider>
|
|
138
|
+
|
|
139
|
+
// lib/storage.ts, fixed keys declared once
|
|
140
|
+
export const kino = createStorageNamespace('kino');
|
|
141
|
+
export const VOLUME = kino.key('player', 'volume'); // kino.player.volume
|
|
142
|
+
|
|
143
|
+
const [volume, setVolume] = useLocalStorage(VOLUME, 1);
|
|
144
|
+
kino.keys(); // every kino.* key in localStorage
|
|
145
|
+
kino.clear(); // "reset preferences" (personal cfg keys survive)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Helpers: `resolveStorageKey`, `isSchemeKey`, `listStorageKeys(owner, area)`,
|
|
149
|
+
`clearStorageNamespace(owner, { area, includePersonal })`,
|
|
150
|
+
`createStorageNamespace(owner)` (`key`, `storage` = guarded read/write/onChange, `keys`, `clear`).
|
|
151
|
+
Do not write `localStorage` literals or ad-hoc keys in components.
|
|
152
|
+
|
|
120
153
|
## Lib utilities (`/lib`)
|
|
121
154
|
|
|
122
155
|
- `cn(...)` — `clsx` + `tailwind-merge` shortcut
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.604",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -133,8 +133,8 @@
|
|
|
133
133
|
"check:docs": "node scripts/check-docs.mjs"
|
|
134
134
|
},
|
|
135
135
|
"peerDependencies": {
|
|
136
|
-
"@djangocfg/api": "^2.1.
|
|
137
|
-
"@djangocfg/i18n": "^2.1.
|
|
136
|
+
"@djangocfg/api": "^2.1.604",
|
|
137
|
+
"@djangocfg/i18n": "^2.1.604",
|
|
138
138
|
"consola": "^3.4.2",
|
|
139
139
|
"lucide-react": "^0.545.0",
|
|
140
140
|
"moment": "^2.30.1",
|
|
@@ -210,10 +210,10 @@
|
|
|
210
210
|
"vaul": "1.1.2"
|
|
211
211
|
},
|
|
212
212
|
"devDependencies": {
|
|
213
|
-
"@djangocfg/api": "^2.1.
|
|
214
|
-
"@djangocfg/eslint-config": "^2.1.
|
|
215
|
-
"@djangocfg/i18n": "^2.1.
|
|
216
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
213
|
+
"@djangocfg/api": "^2.1.604",
|
|
214
|
+
"@djangocfg/eslint-config": "^2.1.604",
|
|
215
|
+
"@djangocfg/i18n": "^2.1.604",
|
|
216
|
+
"@djangocfg/typescript-config": "^2.1.604",
|
|
217
217
|
"@storybook/react-vite": "^10.5.0",
|
|
218
218
|
"@types/node": "^24.13.3",
|
|
219
219
|
"@types/react": "19.2.15",
|
package/src/hooks/state/index.ts
CHANGED
|
@@ -19,3 +19,19 @@ export { useStateMachine } from './useStateMachine';
|
|
|
19
19
|
export type { StateMachineConfig } from './useStateMachine';
|
|
20
20
|
export { useCallbackRef } from './useCallbackRef';
|
|
21
21
|
export { usePrevious } from './usePrevious';
|
|
22
|
+
export {
|
|
23
|
+
DEFAULT_STORAGE_NAMESPACE,
|
|
24
|
+
StorageNamespaceProvider,
|
|
25
|
+
useStorageNamespace,
|
|
26
|
+
resolveStorageKey,
|
|
27
|
+
isSchemeKey,
|
|
28
|
+
listStorageKeys,
|
|
29
|
+
clearStorageNamespace,
|
|
30
|
+
createStorageNamespace,
|
|
31
|
+
} from './storage-namespace';
|
|
32
|
+
export type {
|
|
33
|
+
StorageNamespace,
|
|
34
|
+
StorageNamespaceProviderProps,
|
|
35
|
+
StorageArea,
|
|
36
|
+
ClearStorageOptions,
|
|
37
|
+
} from './storage-namespace';
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Storage namespaces for the state hooks.
|
|
5
|
+
*
|
|
6
|
+
* Every key we write to the browser follows one scheme (full rules:
|
|
7
|
+
* `@djangocfg/api` → `@docs/STORAGE_KEYS.md`):
|
|
8
|
+
*
|
|
9
|
+
* `<owner>.<area>.<name>[.<id>]` e.g. `cfg.ui.sidebar_width`, `kino.player.volume`
|
|
10
|
+
*
|
|
11
|
+
* The hooks (`useLocalStorage`, `useSessionStorage`, `useStoredValue`) enforce
|
|
12
|
+
* it by resolving the key you pass:
|
|
13
|
+
*
|
|
14
|
+
* 1. A key that already follows the scheme (`kino.player.volume`) is used as is.
|
|
15
|
+
* 2. A bare name (`sidebar-width`, `Last Volume`) is folded to `snake_case` and
|
|
16
|
+
* placed under the active namespace: `<owner>.ui.sidebar_width`.
|
|
17
|
+
*
|
|
18
|
+
* The active owner is, in order: the hook's `namespace` option, the nearest
|
|
19
|
+
* `<StorageNamespaceProvider owner="…">`, then `cfg` (the framework default).
|
|
20
|
+
* An app sets its owner once at the root and forgets about it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
|
24
|
+
import {
|
|
25
|
+
CFG_FAMILIES,
|
|
26
|
+
CFG_KEYS,
|
|
27
|
+
createStorage,
|
|
28
|
+
normalizeSegment,
|
|
29
|
+
type GuardedStorage,
|
|
30
|
+
} from '@djangocfg/api/storage';
|
|
31
|
+
|
|
32
|
+
/** The framework's owner. Used when nothing else is declared. */
|
|
33
|
+
export const DEFAULT_STORAGE_NAMESPACE = 'cfg';
|
|
34
|
+
|
|
35
|
+
/** Area given to a bare key. Hook-level state is UI state unless the caller names the area. */
|
|
36
|
+
export const DEFAULT_STORAGE_AREA = 'ui';
|
|
37
|
+
|
|
38
|
+
const OWNER_RE = /^[a-z][a-z0-9_]*$/;
|
|
39
|
+
const SCHEME_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
|
|
40
|
+
|
|
41
|
+
const NamespaceContext = createContext<string>(DEFAULT_STORAGE_NAMESPACE);
|
|
42
|
+
|
|
43
|
+
export interface StorageNamespaceProviderProps {
|
|
44
|
+
/** Short lower-case owner for every key written below: `kino`, `mls`, `crm`. */
|
|
45
|
+
owner: string;
|
|
46
|
+
children?: ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Declares the owner of every bare storage key in the subtree.
|
|
51
|
+
* Place it once, near the root of an app.
|
|
52
|
+
*/
|
|
53
|
+
export function StorageNamespaceProvider({ owner, children }: StorageNamespaceProviderProps) {
|
|
54
|
+
if (!OWNER_RE.test(owner)) {
|
|
55
|
+
throw new TypeError(`StorageNamespaceProvider: owner "${owner}" must match ${OWNER_RE}`);
|
|
56
|
+
}
|
|
57
|
+
return createElement(NamespaceContext.Provider, { value: owner }, children);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The owner the hooks use here (the provider's, or `cfg`). */
|
|
61
|
+
export function useStorageNamespace(): string {
|
|
62
|
+
return useContext(NamespaceContext);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when `key` already follows `<owner>.<area>.<name>`. */
|
|
66
|
+
export function isSchemeKey(key: string): boolean {
|
|
67
|
+
return SCHEME_RE.test(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pure resolver behind the hooks: scheme keys pass through, bare names are
|
|
72
|
+
* folded and prefixed with `<owner>.ui.`. Throws when nothing usable is left.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveStorageKey(key: string, owner: string = DEFAULT_STORAGE_NAMESPACE): string {
|
|
75
|
+
if (isSchemeKey(key)) return key;
|
|
76
|
+
const name = normalizeSegment(key);
|
|
77
|
+
if (!name) throw new TypeError(`storage key "${key}" is empty after normalisation`);
|
|
78
|
+
return `${owner}.${DEFAULT_STORAGE_AREA}.${name}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Hook form: owner = option → provider → `cfg`. */
|
|
82
|
+
export function useResolvedStorageKey(key: string, namespace?: string): string {
|
|
83
|
+
const fromContext = useStorageNamespace();
|
|
84
|
+
return resolveStorageKey(key, namespace ?? fromContext);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- eviction (shared by the quota-recovery paths of the hooks) -------------
|
|
88
|
+
|
|
89
|
+
const PERSONAL_KEYS = new Set(
|
|
90
|
+
Object.values(CFG_KEYS)
|
|
91
|
+
.filter((spec) => spec.personal)
|
|
92
|
+
.map((spec) => spec.key),
|
|
93
|
+
);
|
|
94
|
+
const PERSONAL_PREFIXES = Object.values(CFG_FAMILIES)
|
|
95
|
+
.filter((spec) => spec.personal)
|
|
96
|
+
.map((spec) => spec.prefix);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* May the quota-recovery code drop this key? Only keys that follow our scheme
|
|
100
|
+
* and are not personal (tokens, profile, drafts). Third-party keys such as
|
|
101
|
+
* `NEXT_LOCALE` and everything signed-in-user related are never evicted.
|
|
102
|
+
*/
|
|
103
|
+
export function isEvictableKey(key: string): boolean {
|
|
104
|
+
if (!isSchemeKey(key)) return false;
|
|
105
|
+
if (key.startsWith('cfg.auth.') || PERSONAL_KEYS.has(key)) return false;
|
|
106
|
+
return !PERSONAL_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Names in `storage` that the quota recovery is allowed to remove. */
|
|
110
|
+
export function evictableKeys(storage: Storage): string[] {
|
|
111
|
+
const out: string[] = [];
|
|
112
|
+
for (let i = 0; i < storage.length; i += 1) {
|
|
113
|
+
const name = storage.key(i);
|
|
114
|
+
if (name && isEvictableKey(name)) out.push(name);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- inspection / reset ------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export type StorageArea = 'local' | 'session';
|
|
122
|
+
|
|
123
|
+
function nativeArea(area: StorageArea): Storage | null {
|
|
124
|
+
if (typeof window === 'undefined') return null;
|
|
125
|
+
try {
|
|
126
|
+
return area === 'session' ? window.sessionStorage : window.localStorage;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every key an owner holds in one store (`cfg` by default). */
|
|
133
|
+
export function listStorageKeys(owner: string = DEFAULT_STORAGE_NAMESPACE, area: StorageArea = 'local'): string[] {
|
|
134
|
+
const native = nativeArea(area);
|
|
135
|
+
if (!native) return [];
|
|
136
|
+
const prefix = `${owner}.`;
|
|
137
|
+
const out: string[] = [];
|
|
138
|
+
for (let i = 0; i < native.length; i += 1) {
|
|
139
|
+
const name = native.key(i);
|
|
140
|
+
if (name?.startsWith(prefix)) out.push(name);
|
|
141
|
+
}
|
|
142
|
+
return out.sort();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ClearStorageOptions {
|
|
146
|
+
/** Store to clear. Default: both. */
|
|
147
|
+
area?: StorageArea;
|
|
148
|
+
/** Also remove personal keys (tokens, profile, drafts). Default false: preferences only. */
|
|
149
|
+
includePersonal?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Removes the keys of one owner. By default personal keys survive, so a
|
|
154
|
+
* "reset preferences" button never signs the user out.
|
|
155
|
+
* Hooks mounted on a removed key keep their in-memory value until they write.
|
|
156
|
+
*/
|
|
157
|
+
export function clearStorageNamespace(
|
|
158
|
+
owner: string = DEFAULT_STORAGE_NAMESPACE,
|
|
159
|
+
{ area, includePersonal = false }: ClearStorageOptions = {},
|
|
160
|
+
): string[] {
|
|
161
|
+
const areas: StorageArea[] = area ? [area] : ['local', 'session'];
|
|
162
|
+
const removed: string[] = [];
|
|
163
|
+
for (const a of areas) {
|
|
164
|
+
const native = nativeArea(a);
|
|
165
|
+
if (!native) continue;
|
|
166
|
+
for (const name of listStorageKeys(owner, a)) {
|
|
167
|
+
if (!includePersonal && owner === DEFAULT_STORAGE_NAMESPACE && !isEvictableKey(name)) continue;
|
|
168
|
+
try {
|
|
169
|
+
native.removeItem(name);
|
|
170
|
+
removed.push(name);
|
|
171
|
+
} catch {
|
|
172
|
+
/* ignore */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return removed;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- an app's namespace -------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
export interface StorageNamespace {
|
|
182
|
+
readonly owner: string;
|
|
183
|
+
/** `kino.player.volume` from `('player', 'volume')`; parts are folded to snake_case. */
|
|
184
|
+
key(...parts: string[]): string;
|
|
185
|
+
/** Guarded non-React access (`read`, `write`, `readJSON`, `onChange`, `clearAll`…). */
|
|
186
|
+
readonly storage: GuardedStorage;
|
|
187
|
+
keys(area?: StorageArea): string[];
|
|
188
|
+
clear(options?: ClearStorageOptions): string[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Declares an app's namespace in one place:
|
|
193
|
+
*
|
|
194
|
+
* ```ts
|
|
195
|
+
* // lib/storage.ts
|
|
196
|
+
* export const kinoStorage = createStorageNamespace('kino');
|
|
197
|
+
* export const PLAYER_VOLUME = kinoStorage.key('player', 'volume'); // kino.player.volume
|
|
198
|
+
*
|
|
199
|
+
* // component
|
|
200
|
+
* const [volume, setVolume] = useLocalStorage(PLAYER_VOLUME, 1);
|
|
201
|
+
* ```
|
|
202
|
+
*
|
|
203
|
+
* and wraps the app root in `<StorageNamespaceProvider owner="kino">` so bare
|
|
204
|
+
* `storageKey` props on ui-core components land under `kino.ui.*`.
|
|
205
|
+
*/
|
|
206
|
+
export function createStorageNamespace(owner: string): StorageNamespace {
|
|
207
|
+
const storage = createStorage(owner);
|
|
208
|
+
return {
|
|
209
|
+
owner,
|
|
210
|
+
key: (...parts) => storage.key(...parts),
|
|
211
|
+
storage,
|
|
212
|
+
keys: (area = 'local') => listStorageKeys(owner, area),
|
|
213
|
+
clear: (options) => clearStorageNamespace(owner, options),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { isQuotaExceededError } from './storage-quota';
|
|
6
|
+
import { evictableKeys, useResolvedStorageKey } from './storage-namespace';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Storage wrapper format with metadata
|
|
@@ -27,6 +28,12 @@ export interface UseLocalStorageOptions {
|
|
|
27
28
|
* @example 24 * 60 * 60 * 1000 // 24 hours
|
|
28
29
|
*/
|
|
29
30
|
ttl?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Owner for a bare key (`'sidebar-width'` → `<owner>.ui.sidebar_width`).
|
|
33
|
+
* Default: the nearest `StorageNamespaceProvider`, else `cfg`.
|
|
34
|
+
* A key that already follows `<owner>.<area>.<name>` is used as is.
|
|
35
|
+
*/
|
|
36
|
+
namespace?: string;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/**
|
|
@@ -152,10 +159,11 @@ function broadcastKey(
|
|
|
152
159
|
* patch({ b: 3 }); // merges → { a: 1, b: 3 }, only writes if it changed
|
|
153
160
|
*/
|
|
154
161
|
export function useLocalStorage<T>(
|
|
155
|
-
|
|
162
|
+
keyInput: string,
|
|
156
163
|
initialValue: T,
|
|
157
164
|
options?: UseLocalStorageOptions
|
|
158
165
|
): UseLocalStorageReturn<T> {
|
|
166
|
+
const key = useResolvedStorageKey(keyInput, options?.namespace);
|
|
159
167
|
const ttl = options?.ttl;
|
|
160
168
|
// Always start with initialValue to match SSR
|
|
161
169
|
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
|
@@ -276,43 +284,25 @@ export function useLocalStorage<T>(
|
|
|
276
284
|
};
|
|
277
285
|
|
|
278
286
|
// Clear old data when localStorage is full
|
|
279
|
-
|
|
287
|
+
// Drop evictable keys (our scheme, non-personal) when the quota is full.
|
|
288
|
+
// Tokens, drafts and third-party keys are never touched.
|
|
289
|
+
const evict = (fraction: number) => {
|
|
280
290
|
try {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const itemsToRemove = Math.ceil(keys.length * 0.2);
|
|
285
|
-
for (let i = 0; i < itemsToRemove; i++) {
|
|
286
|
-
try {
|
|
287
|
-
const key = keys[i];
|
|
288
|
-
if (key) {
|
|
289
|
-
localStorage.removeItem(key);
|
|
290
|
-
}
|
|
291
|
-
} catch {
|
|
292
|
-
// Ignore errors when removing items
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
} catch (error) {
|
|
297
|
-
console.error('Error clearing old localStorage data:', error);
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
// Force clear all data if quota is exceeded
|
|
302
|
-
const forceClearAll = () => {
|
|
303
|
-
try {
|
|
304
|
-
const keys = Object.keys(localStorage);
|
|
305
|
-
for (const key of keys) {
|
|
291
|
+
const names = evictableKeys(window.localStorage).filter((name) => name !== key);
|
|
292
|
+
const count = fraction >= 1 ? names.length : Math.ceil(names.length * fraction);
|
|
293
|
+
for (const name of names.slice(0, count)) {
|
|
306
294
|
try {
|
|
307
|
-
localStorage.removeItem(
|
|
295
|
+
window.localStorage.removeItem(name);
|
|
308
296
|
} catch {
|
|
309
297
|
// Ignore errors when removing items
|
|
310
298
|
}
|
|
311
299
|
}
|
|
312
300
|
} catch (error) {
|
|
313
|
-
console.error('Error
|
|
301
|
+
console.error('Error clearing old localStorage data:', error);
|
|
314
302
|
}
|
|
315
303
|
};
|
|
304
|
+
const clearOldData = () => evict(0.2);
|
|
305
|
+
const forceClearAll = () => evict(1);
|
|
316
306
|
|
|
317
307
|
// Prepare data for storage (with or without TTL wrapper)
|
|
318
308
|
const prepareForStorage = (value: T): string => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { isQuotaExceededError } from './storage-quota';
|
|
6
|
+
import { evictableKeys, useResolvedStorageKey } from './storage-namespace';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Storage wrapper format with metadata
|
|
@@ -27,6 +28,12 @@ export interface UseSessionStorageOptions {
|
|
|
27
28
|
* @example 24 * 60 * 60 * 1000 // 24 hours
|
|
28
29
|
*/
|
|
29
30
|
ttl?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Owner for a bare key (`'wizard-step'` → `<owner>.ui.wizard_step`).
|
|
33
|
+
* Default: the nearest `StorageNamespaceProvider`, else `cfg`.
|
|
34
|
+
* A key that already follows `<owner>.<area>.<name>` is used as is.
|
|
35
|
+
*/
|
|
36
|
+
namespace?: string;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/**
|
|
@@ -89,10 +96,11 @@ function isExpired<T>(wrapped: StorageWrapper<T>): boolean {
|
|
|
89
96
|
* });
|
|
90
97
|
*/
|
|
91
98
|
export function useSessionStorage<T>(
|
|
92
|
-
|
|
99
|
+
keyInput: string,
|
|
93
100
|
initialValue: T,
|
|
94
101
|
options?: UseSessionStorageOptions
|
|
95
102
|
): UseSessionStorageReturn<T> {
|
|
103
|
+
const key = useResolvedStorageKey(keyInput, options?.namespace);
|
|
96
104
|
const ttl = options?.ttl;
|
|
97
105
|
|
|
98
106
|
// Get initial value from sessionStorage or use provided initialValue
|
|
@@ -152,44 +160,25 @@ export function useSessionStorage<T>(
|
|
|
152
160
|
}
|
|
153
161
|
};
|
|
154
162
|
|
|
155
|
-
//
|
|
156
|
-
|
|
163
|
+
// Drop evictable keys (our scheme, non-personal) when the quota is full.
|
|
164
|
+
// Tokens, drafts and third-party keys are never touched.
|
|
165
|
+
const evict = (fraction: number) => {
|
|
157
166
|
try {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const itemsToRemove = Math.ceil(keys.length * 0.2);
|
|
162
|
-
for (let i = 0; i < itemsToRemove; i++) {
|
|
163
|
-
try {
|
|
164
|
-
const k = keys[i];
|
|
165
|
-
if (k) {
|
|
166
|
-
sessionStorage.removeItem(k);
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
// Ignore errors when removing items
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
} catch (error) {
|
|
174
|
-
console.error('Error clearing old sessionStorage data:', error);
|
|
175
|
-
}
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
// Force clear all data if quota is exceeded
|
|
179
|
-
const forceClearAll = () => {
|
|
180
|
-
try {
|
|
181
|
-
const keys = Object.keys(sessionStorage);
|
|
182
|
-
for (const k of keys) {
|
|
167
|
+
const names = evictableKeys(window.sessionStorage).filter((name) => name !== key);
|
|
168
|
+
const count = fraction >= 1 ? names.length : Math.ceil(names.length * fraction);
|
|
169
|
+
for (const name of names.slice(0, count)) {
|
|
183
170
|
try {
|
|
184
|
-
sessionStorage.removeItem(
|
|
171
|
+
window.sessionStorage.removeItem(name);
|
|
185
172
|
} catch {
|
|
186
173
|
// Ignore errors when removing items
|
|
187
174
|
}
|
|
188
175
|
}
|
|
189
176
|
} catch (error) {
|
|
190
|
-
console.error('Error
|
|
177
|
+
console.error('Error clearing old sessionStorage data:', error);
|
|
191
178
|
}
|
|
192
179
|
};
|
|
180
|
+
const clearOldData = () => evict(0.2);
|
|
181
|
+
const forceClearAll = () => evict(1);
|
|
193
182
|
|
|
194
183
|
// Prepare data for storage (with or without TTL wrapper)
|
|
195
184
|
const prepareForStorage = (value: T): string => {
|
|
@@ -19,6 +19,11 @@ export interface UseStoredValueOptions {
|
|
|
19
19
|
* @example ttl: 24 * 60 * 60 * 1000 // 24 hours
|
|
20
20
|
*/
|
|
21
21
|
ttl?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Owner for a bare `storageKey`. Default: nearest `StorageNamespaceProvider`, else `cfg`.
|
|
24
|
+
* @see resolveStorageKey
|
|
25
|
+
*/
|
|
26
|
+
namespace?: string;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
/**
|
|
@@ -41,6 +46,9 @@ export type UseStoredValueReturn<T> = readonly [
|
|
|
41
46
|
* Used by components (Input, Combobox, MultiSelect, Tabs, etc.) to add
|
|
42
47
|
* optional persistence via a single `storageKey` prop.
|
|
43
48
|
*
|
|
49
|
+
* `storageKey` follows the storage-key scheme (see `./storage-namespace`): a full
|
|
50
|
+
* `owner.area.name` key is used as is, a bare name lands under `<owner>.ui.<name>`.
|
|
51
|
+
*
|
|
44
52
|
* When storageKey is undefined the hook is a no-op:
|
|
45
53
|
* - returns initialValue as current value
|
|
46
54
|
* - setValue / removeValue / patch are no-ops
|
|
@@ -55,12 +63,12 @@ export function useStoredValue<T>(
|
|
|
55
63
|
options?: UseStoredValueOptions,
|
|
56
64
|
): UseStoredValueReturn<T> {
|
|
57
65
|
const storageType = options?.storage ?? 'local';
|
|
58
|
-
const ttlOption =
|
|
66
|
+
const ttlOption = { ttl: options?.ttl, namespace: options?.namespace };
|
|
59
67
|
|
|
60
68
|
// Both hooks are always called (rules of hooks — no conditional calls).
|
|
61
69
|
// Only one of them is actually used depending on storageType.
|
|
62
70
|
// When storageKey is undefined we pass a dummy key; the result is discarded.
|
|
63
|
-
const dummyKey = '
|
|
71
|
+
const dummyKey = 'cfg.ui.noop';
|
|
64
72
|
|
|
65
73
|
const localResult = useLocalStorage<T>(
|
|
66
74
|
storageType === 'local' && storageKey ? storageKey : dummyKey,
|