react-tab-refresh 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/LICENSE +21 -0
- package/README.md +362 -0
- package/dist/__tests__/setup.d.ts +2 -0
- package/dist/__tests__/setup.d.ts.map +1 -0
- package/dist/components/PruneProvider.d.ts +3 -0
- package/dist/components/PruneProvider.d.ts.map +1 -0
- package/dist/context/PruningContext.d.ts +3 -0
- package/dist/context/PruningContext.d.ts.map +1 -0
- package/dist/core/InactivityMonitor.d.ts +26 -0
- package/dist/core/InactivityMonitor.d.ts.map +1 -0
- package/dist/hooks/usePrunableState.d.ts +5 -0
- package/dist/hooks/usePrunableState.d.ts.map +1 -0
- package/dist/hooks/usePruningState.d.ts +3 -0
- package/dist/hooks/usePruningState.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +669 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +677 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +66 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/utils/StorageManager.d.ts +14 -0
- package/dist/utils/StorageManager.d.ts.map +1 -0
- package/dist/utils/helpers.d.ts +18 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { jsx, Fragment } from 'react/jsx-runtime';
|
|
2
|
+
import { createContext, useState, useRef, useEffect, useCallback, useContext } from 'react';
|
|
3
|
+
|
|
4
|
+
const PruningContext = createContext(null);
|
|
5
|
+
PruningContext.displayName = 'PruningContext';
|
|
6
|
+
|
|
7
|
+
function parseTime(time) {
|
|
8
|
+
if (typeof time === 'number') {
|
|
9
|
+
return time;
|
|
10
|
+
}
|
|
11
|
+
const match = time.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error(`Invalid time format: "${time}". Use format like "30m", "1h", "2d", or milliseconds.`);
|
|
14
|
+
}
|
|
15
|
+
const [, value, unit] = match;
|
|
16
|
+
const num = parseInt(value, 10);
|
|
17
|
+
const multipliers = {
|
|
18
|
+
ms: 1,
|
|
19
|
+
s: 1000,
|
|
20
|
+
m: 60 * 1000,
|
|
21
|
+
h: 60 * 60 * 1000,
|
|
22
|
+
d: 24 * 60 * 60 * 1000,
|
|
23
|
+
};
|
|
24
|
+
return num * multipliers[unit];
|
|
25
|
+
}
|
|
26
|
+
function isMemoryAPIAvailable() {
|
|
27
|
+
return (typeof performance !== 'undefined' &&
|
|
28
|
+
'memory' in performance &&
|
|
29
|
+
performance.memory !== null);
|
|
30
|
+
}
|
|
31
|
+
function getMemoryUsage() {
|
|
32
|
+
if (!isMemoryAPIAvailable()) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const memory = performance.memory;
|
|
36
|
+
return Math.round(memory.usedJSHeapSize / 1024 / 1024);
|
|
37
|
+
}
|
|
38
|
+
function getDomNodeCount() {
|
|
39
|
+
return document.getElementsByTagName('*').length;
|
|
40
|
+
}
|
|
41
|
+
function createStorageKey(key) {
|
|
42
|
+
return `rtr_${key}`;
|
|
43
|
+
}
|
|
44
|
+
function createLogger(namespace, enabled = false) {
|
|
45
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
46
|
+
const shouldLog = isDev && enabled;
|
|
47
|
+
return {
|
|
48
|
+
log: (...args) => {
|
|
49
|
+
if (shouldLog) {
|
|
50
|
+
console.log(`[react-tab-refresh:${namespace}]`, ...args);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
warn: (...args) => {
|
|
54
|
+
if (shouldLog) {
|
|
55
|
+
console.warn(`[react-tab-refresh:${namespace}]`, ...args);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
error: (...args) => {
|
|
59
|
+
if (shouldLog) {
|
|
60
|
+
console.error(`[react-tab-refresh:${namespace}]`, ...args);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function isSerializable(value) {
|
|
66
|
+
if (value === null || value === undefined) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const type = typeof value;
|
|
70
|
+
if (type === 'boolean' || type === 'number' || type === 'string') {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (type === 'function' || type === 'symbol') {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return value.every(isSerializable);
|
|
78
|
+
}
|
|
79
|
+
if (type === 'object') {
|
|
80
|
+
if (value.constructor !== Object && value.constructor !== Array) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return Object.values(value).every(isSerializable);
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
function safeSerialize(value) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.stringify(value);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
console.error('[react-tab-refresh] Serialization failed:', error);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function safeDeserialize(json) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(json);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
console.error('[react-tab-refresh] Deserialization failed:', error);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function debounce(fn, delay) {
|
|
106
|
+
let timeoutId = null;
|
|
107
|
+
return (...args) => {
|
|
108
|
+
if (timeoutId) {
|
|
109
|
+
clearTimeout(timeoutId);
|
|
110
|
+
}
|
|
111
|
+
timeoutId = setTimeout(() => {
|
|
112
|
+
fn(...args);
|
|
113
|
+
timeoutId = null;
|
|
114
|
+
}, delay);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function isPageVisibilitySupported() {
|
|
118
|
+
return typeof document !== 'undefined' && 'hidden' in document;
|
|
119
|
+
}
|
|
120
|
+
function isPageVisible() {
|
|
121
|
+
if (!isPageVisibilitySupported()) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
return !document.hidden;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class InactivityMonitor {
|
|
128
|
+
constructor(config) {
|
|
129
|
+
this.listeners = new Map();
|
|
130
|
+
this.intervalId = null;
|
|
131
|
+
this.hiddenAt = null;
|
|
132
|
+
this.isRunning = false;
|
|
133
|
+
this.handleVisibilityChange = () => {
|
|
134
|
+
const isVisible = isPageVisible();
|
|
135
|
+
if (isVisible) {
|
|
136
|
+
this.logger.log('Tab became visible');
|
|
137
|
+
this.hiddenAt = null;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
this.logger.log('Tab became hidden');
|
|
141
|
+
this.hiddenAt = Date.now();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
this.config = {
|
|
145
|
+
maxInactivityMs: config.maxInactivityMs ?? 30 * 60 * 1000,
|
|
146
|
+
maxMemoryMb: config.maxMemoryMb,
|
|
147
|
+
maxDomNodes: config.maxDomNodes,
|
|
148
|
+
enableMemoryMonitoring: config.enableMemoryMonitoring ?? false,
|
|
149
|
+
pollingInterval: config.pollingInterval ?? 30000,
|
|
150
|
+
debug: config.debug ?? false,
|
|
151
|
+
};
|
|
152
|
+
this.logger = createLogger('InactivityMonitor', this.config.debug);
|
|
153
|
+
if (!isPageVisibilitySupported()) {
|
|
154
|
+
this.logger.warn('Page Visibility API not supported. Inactivity monitoring disabled.');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
start() {
|
|
158
|
+
if (this.isRunning) {
|
|
159
|
+
this.logger.warn('Monitor already running');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!isPageVisibilitySupported()) {
|
|
163
|
+
this.logger.error('Cannot start monitor: Page Visibility API not supported');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.isRunning = true;
|
|
167
|
+
this.setupVisibilityListener();
|
|
168
|
+
this.startPolling();
|
|
169
|
+
this.logger.log('Monitor started', {
|
|
170
|
+
maxInactivityMs: this.config.maxInactivityMs,
|
|
171
|
+
maxMemoryMb: this.config.maxMemoryMb,
|
|
172
|
+
maxDomNodes: this.config.maxDomNodes,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
stop() {
|
|
176
|
+
if (!this.isRunning) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.isRunning = false;
|
|
180
|
+
this.removeVisibilityListener();
|
|
181
|
+
this.stopPolling();
|
|
182
|
+
this.logger.log('Monitor stopped');
|
|
183
|
+
}
|
|
184
|
+
on(event, callback) {
|
|
185
|
+
if (!this.listeners.has(event)) {
|
|
186
|
+
this.listeners.set(event, new Set());
|
|
187
|
+
}
|
|
188
|
+
this.listeners.get(event).add(callback);
|
|
189
|
+
}
|
|
190
|
+
off(event, callback) {
|
|
191
|
+
const callbacks = this.listeners.get(event);
|
|
192
|
+
if (callbacks) {
|
|
193
|
+
callbacks.delete(callback);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
getCurrentMetrics() {
|
|
197
|
+
const inactiveMs = this.getInactiveTime();
|
|
198
|
+
const memoryMb = this.config.enableMemoryMonitoring
|
|
199
|
+
? getMemoryUsage()
|
|
200
|
+
: undefined;
|
|
201
|
+
const domNodes = this.config.maxDomNodes ? getDomNodeCount() : undefined;
|
|
202
|
+
return {
|
|
203
|
+
inactiveMs,
|
|
204
|
+
memoryMb,
|
|
205
|
+
domNodes,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
checkThresholds() {
|
|
209
|
+
const metrics = this.getCurrentMetrics();
|
|
210
|
+
let exceeded = false;
|
|
211
|
+
const reasons = [];
|
|
212
|
+
if (metrics.inactiveMs >= this.config.maxInactivityMs) {
|
|
213
|
+
exceeded = true;
|
|
214
|
+
reasons.push(`Inactivity: ${Math.round(metrics.inactiveMs / 1000)}s / ${Math.round(this.config.maxInactivityMs / 1000)}s`);
|
|
215
|
+
}
|
|
216
|
+
if (this.config.enableMemoryMonitoring &&
|
|
217
|
+
this.config.maxMemoryMb &&
|
|
218
|
+
metrics.memoryMb !== undefined &&
|
|
219
|
+
metrics.memoryMb >= this.config.maxMemoryMb) {
|
|
220
|
+
exceeded = true;
|
|
221
|
+
reasons.push(`Memory: ${metrics.memoryMb}MB / ${this.config.maxMemoryMb}MB`);
|
|
222
|
+
}
|
|
223
|
+
if (this.config.maxDomNodes &&
|
|
224
|
+
metrics.domNodes !== undefined &&
|
|
225
|
+
metrics.domNodes >= this.config.maxDomNodes) {
|
|
226
|
+
exceeded = true;
|
|
227
|
+
reasons.push(`DOM Nodes: ${metrics.domNodes} / ${this.config.maxDomNodes}`);
|
|
228
|
+
}
|
|
229
|
+
if (exceeded) {
|
|
230
|
+
this.logger.log('Threshold exceeded:', reasons.join(', '));
|
|
231
|
+
this.emit('threshold-exceeded', metrics);
|
|
232
|
+
}
|
|
233
|
+
this.emit('metrics-updated', metrics);
|
|
234
|
+
}
|
|
235
|
+
emit(event, metrics) {
|
|
236
|
+
const callbacks = this.listeners.get(event);
|
|
237
|
+
if (callbacks) {
|
|
238
|
+
callbacks.forEach((callback) => {
|
|
239
|
+
try {
|
|
240
|
+
callback(metrics);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
this.logger.error(`Error in ${event} callback:`, error);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
setupVisibilityListener() {
|
|
249
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
250
|
+
}
|
|
251
|
+
removeVisibilityListener() {
|
|
252
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
253
|
+
}
|
|
254
|
+
getInactiveTime() {
|
|
255
|
+
if (!this.hiddenAt || isPageVisible()) {
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
return Date.now() - this.hiddenAt;
|
|
259
|
+
}
|
|
260
|
+
startPolling() {
|
|
261
|
+
if (this.intervalId) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.intervalId = setInterval(() => {
|
|
265
|
+
if (!isPageVisible()) {
|
|
266
|
+
this.checkThresholds();
|
|
267
|
+
}
|
|
268
|
+
}, this.config.pollingInterval);
|
|
269
|
+
}
|
|
270
|
+
stopPolling() {
|
|
271
|
+
if (this.intervalId) {
|
|
272
|
+
clearInterval(this.intervalId);
|
|
273
|
+
this.intervalId = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
updateConfig(config) {
|
|
277
|
+
this.config = { ...this.config, ...config };
|
|
278
|
+
this.logger.log('Config updated', this.config);
|
|
279
|
+
}
|
|
280
|
+
reset() {
|
|
281
|
+
this.hiddenAt = null;
|
|
282
|
+
this.logger.log('Monitor reset');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function PruneProvider({ children, config = {}, placeholder, }) {
|
|
287
|
+
const { pruneAfter = '30m', maxMemoryMb, enableMemoryMonitoring = false, maxDomNodes, onPrune, onRehydrate, debug = false, } = config;
|
|
288
|
+
const logger = createLogger('PruneProvider', debug);
|
|
289
|
+
const [isPruned, setIsPruned] = useState(false);
|
|
290
|
+
const [isRehydrating, setIsRehydrating] = useState(false);
|
|
291
|
+
const [metrics, setMetrics] = useState({
|
|
292
|
+
inactiveMs: 0,
|
|
293
|
+
});
|
|
294
|
+
const monitorRef = useRef(null);
|
|
295
|
+
const cleanupHandlers = useRef(new Map());
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
const maxInactivityMs = parseTime(pruneAfter);
|
|
298
|
+
const monitor = new InactivityMonitor({
|
|
299
|
+
maxInactivityMs,
|
|
300
|
+
maxMemoryMb,
|
|
301
|
+
enableMemoryMonitoring,
|
|
302
|
+
maxDomNodes,
|
|
303
|
+
debug,
|
|
304
|
+
});
|
|
305
|
+
monitorRef.current = monitor;
|
|
306
|
+
monitor.on('threshold-exceeded', handleThresholdExceeded);
|
|
307
|
+
monitor.on('metrics-updated', (newMetrics) => {
|
|
308
|
+
setMetrics(newMetrics);
|
|
309
|
+
});
|
|
310
|
+
monitor.start();
|
|
311
|
+
logger.log('Provider initialized', {
|
|
312
|
+
pruneAfter: maxInactivityMs,
|
|
313
|
+
maxMemoryMb,
|
|
314
|
+
maxDomNodes,
|
|
315
|
+
});
|
|
316
|
+
return () => {
|
|
317
|
+
monitor.stop();
|
|
318
|
+
};
|
|
319
|
+
}, [pruneAfter, maxMemoryMb, enableMemoryMonitoring, maxDomNodes, debug]);
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
const handleVisibilityChange = async () => {
|
|
322
|
+
if (isPageVisible() && isPruned) {
|
|
323
|
+
logger.log('Tab became visible, triggering rehydration');
|
|
324
|
+
await handleRehydrate();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
328
|
+
return () => {
|
|
329
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
330
|
+
};
|
|
331
|
+
}, [isPruned]);
|
|
332
|
+
const handleThresholdExceeded = useCallback(async (newMetrics) => {
|
|
333
|
+
if (isPruned) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
logger.log('Threshold exceeded, starting prune process', newMetrics);
|
|
337
|
+
try {
|
|
338
|
+
if (onPrune) {
|
|
339
|
+
await onPrune();
|
|
340
|
+
}
|
|
341
|
+
for (const [key, cleanup] of cleanupHandlers.current.entries()) {
|
|
342
|
+
try {
|
|
343
|
+
logger.log(`Running cleanup handler: ${key}`);
|
|
344
|
+
await cleanup();
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
logger.error(`Cleanup handler "${key}" failed:`, error);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
setIsPruned(true);
|
|
351
|
+
setMetrics((prev) => ({ ...prev, lastPruneAt: Date.now() }));
|
|
352
|
+
logger.log('Prune complete');
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
logger.error('Error during prune:', error);
|
|
356
|
+
}
|
|
357
|
+
}, [isPruned, onPrune, logger]);
|
|
358
|
+
const handleRehydrate = useCallback(async () => {
|
|
359
|
+
if (!isPruned) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
logger.log('Starting rehydration');
|
|
363
|
+
setIsRehydrating(true);
|
|
364
|
+
try {
|
|
365
|
+
if (placeholder) {
|
|
366
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
367
|
+
}
|
|
368
|
+
setIsPruned(false);
|
|
369
|
+
if (onRehydrate) {
|
|
370
|
+
await onRehydrate();
|
|
371
|
+
}
|
|
372
|
+
setMetrics((prev) => ({ ...prev, lastRehydrateAt: Date.now() }));
|
|
373
|
+
logger.log('Rehydration complete');
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
logger.error('Error during rehydration:', error);
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
setIsRehydrating(false);
|
|
380
|
+
}
|
|
381
|
+
}, [isPruned, onRehydrate, placeholder, logger]);
|
|
382
|
+
const forceRehydrate = useCallback(() => {
|
|
383
|
+
logger.log('Force rehydration requested');
|
|
384
|
+
handleRehydrate();
|
|
385
|
+
}, [handleRehydrate, logger]);
|
|
386
|
+
const registerCleanup = useCallback((key, cleanup) => {
|
|
387
|
+
logger.log(`Registered cleanup handler: ${key}`);
|
|
388
|
+
cleanupHandlers.current.set(key, cleanup);
|
|
389
|
+
}, [logger]);
|
|
390
|
+
const unregisterCleanup = useCallback((key) => {
|
|
391
|
+
logger.log(`Unregistered cleanup handler: ${key}`);
|
|
392
|
+
cleanupHandlers.current.delete(key);
|
|
393
|
+
}, [logger]);
|
|
394
|
+
const contextValue = {
|
|
395
|
+
isPruned,
|
|
396
|
+
isRehydrating,
|
|
397
|
+
metrics,
|
|
398
|
+
forceRehydrate,
|
|
399
|
+
registerCleanup,
|
|
400
|
+
unregisterCleanup,
|
|
401
|
+
};
|
|
402
|
+
if (isPruned) {
|
|
403
|
+
if (isRehydrating && placeholder) {
|
|
404
|
+
return jsx(Fragment, { children: placeholder });
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
return (jsx(PruningContext.Provider, { value: contextValue, children: children }));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
class SessionStorageAdapter {
|
|
412
|
+
constructor() {
|
|
413
|
+
this.logger = createLogger('SessionStorage', false);
|
|
414
|
+
}
|
|
415
|
+
get(key) {
|
|
416
|
+
try {
|
|
417
|
+
const storageKey = createStorageKey(key);
|
|
418
|
+
const item = sessionStorage.getItem(storageKey);
|
|
419
|
+
if (!item) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const metadata = safeDeserialize(item);
|
|
423
|
+
if (!metadata) {
|
|
424
|
+
this.logger.warn(`Failed to parse stored data for key: ${key}`);
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
return metadata.value;
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
this.logger.error(`Failed to get item from storage:`, error);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
set(key, value) {
|
|
435
|
+
try {
|
|
436
|
+
const storageKey = createStorageKey(key);
|
|
437
|
+
const metadata = {
|
|
438
|
+
value,
|
|
439
|
+
timestamp: Date.now(),
|
|
440
|
+
version: '1.0.0',
|
|
441
|
+
};
|
|
442
|
+
const serialized = safeSerialize(metadata);
|
|
443
|
+
if (!serialized) {
|
|
444
|
+
this.logger.error(`Cannot serialize value for key "${key}". Value contains non-serializable data.`);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
sessionStorage.setItem(storageKey, serialized);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (error instanceof DOMException &&
|
|
452
|
+
(error.name === 'QuotaExceededError' ||
|
|
453
|
+
error.name === 'NS_ERROR_DOM_QUOTA_REACHED')) {
|
|
454
|
+
this.logger.error(`SessionStorage quota exceeded. Consider reducing state size or implementing cleanup.`);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
this.logger.error(`Failed to set item in storage:`, error);
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
remove(key) {
|
|
463
|
+
try {
|
|
464
|
+
const storageKey = createStorageKey(key);
|
|
465
|
+
sessionStorage.removeItem(storageKey);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
this.logger.error(`Failed to remove item from storage:`, error);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
clear() {
|
|
472
|
+
try {
|
|
473
|
+
const keys = Object.keys(sessionStorage);
|
|
474
|
+
const rtrKeys = keys.filter((key) => key.startsWith('rtr_'));
|
|
475
|
+
rtrKeys.forEach((key) => {
|
|
476
|
+
sessionStorage.removeItem(key);
|
|
477
|
+
});
|
|
478
|
+
this.logger.log(`Cleared ${rtrKeys.length} items from storage`);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
this.logger.error(`Failed to clear storage:`, error);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
getUsage() {
|
|
485
|
+
try {
|
|
486
|
+
let used = 0;
|
|
487
|
+
const keys = Object.keys(sessionStorage);
|
|
488
|
+
keys.forEach((key) => {
|
|
489
|
+
const value = sessionStorage.getItem(key);
|
|
490
|
+
if (value) {
|
|
491
|
+
used += key.length + value.length;
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
const available = 5 * 1024 * 1024;
|
|
495
|
+
const percentage = (used / available) * 100;
|
|
496
|
+
return {
|
|
497
|
+
used,
|
|
498
|
+
available,
|
|
499
|
+
percentage: Math.min(percentage, 100),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
this.logger.error(`Failed to get storage usage:`, error);
|
|
504
|
+
return {
|
|
505
|
+
used: 0,
|
|
506
|
+
available: 0,
|
|
507
|
+
percentage: 0,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
getMetadata(key) {
|
|
512
|
+
try {
|
|
513
|
+
const storageKey = createStorageKey(key);
|
|
514
|
+
const item = sessionStorage.getItem(storageKey);
|
|
515
|
+
if (!item) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
return safeDeserialize(item);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
this.logger.error(`Failed to get metadata:`, error);
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
isExpired(key, ttl) {
|
|
526
|
+
const metadata = this.getMetadata(key);
|
|
527
|
+
if (!metadata) {
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
const age = Date.now() - metadata.timestamp;
|
|
531
|
+
return age > ttl;
|
|
532
|
+
}
|
|
533
|
+
cleanupExpired(ttl) {
|
|
534
|
+
try {
|
|
535
|
+
const keys = Object.keys(sessionStorage);
|
|
536
|
+
const rtrKeys = keys.filter((key) => key.startsWith('rtr_'));
|
|
537
|
+
let cleaned = 0;
|
|
538
|
+
rtrKeys.forEach((storageKey) => {
|
|
539
|
+
const key = storageKey.replace('rtr_', '');
|
|
540
|
+
if (this.isExpired(key, ttl)) {
|
|
541
|
+
sessionStorage.removeItem(storageKey);
|
|
542
|
+
cleaned++;
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
if (cleaned > 0) {
|
|
546
|
+
this.logger.log(`Cleaned up ${cleaned} expired items`);
|
|
547
|
+
}
|
|
548
|
+
return cleaned;
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
this.logger.error(`Failed to cleanup expired items:`, error);
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function createStorageAdapter() {
|
|
557
|
+
return new SessionStorageAdapter();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function usePrunableState(key, initialValue, options = {}) {
|
|
561
|
+
const { validate, ttl, onExpired, debug = false, } = options;
|
|
562
|
+
const logger = createLogger(`usePrunableState:${key}`, debug);
|
|
563
|
+
const storage = useRef(createStorageAdapter()).current;
|
|
564
|
+
const isMounted = useRef(true);
|
|
565
|
+
const [state, setState] = useState(() => {
|
|
566
|
+
try {
|
|
567
|
+
const stored = storage.get(key);
|
|
568
|
+
if (stored === null) {
|
|
569
|
+
logger.log('No stored value found, using initial value');
|
|
570
|
+
return initialValue;
|
|
571
|
+
}
|
|
572
|
+
if (ttl) {
|
|
573
|
+
const metadata = storage.getMetadata(key);
|
|
574
|
+
if (metadata) {
|
|
575
|
+
const age = Date.now() - metadata.timestamp;
|
|
576
|
+
if (age > ttl) {
|
|
577
|
+
logger.log(`Stored value expired (age: ${age}ms, ttl: ${ttl}ms)`);
|
|
578
|
+
if (onExpired) {
|
|
579
|
+
onExpired();
|
|
580
|
+
}
|
|
581
|
+
return initialValue;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (validate && !validate(stored)) {
|
|
586
|
+
logger.warn('Stored value failed validation, using initial value');
|
|
587
|
+
return initialValue;
|
|
588
|
+
}
|
|
589
|
+
logger.log('Restored value from storage');
|
|
590
|
+
return stored;
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
logger.error('Failed to restore from storage:', error);
|
|
594
|
+
return initialValue;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
const saveToStorage = useRef(debounce((value) => {
|
|
598
|
+
if (!isMounted.current) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
if (!isSerializable(value)) {
|
|
603
|
+
logger.error(`Cannot serialize value for key "${key}". Value contains non-serializable data (functions, classes, etc.).
|
|
604
|
+
|
|
605
|
+
Fix: Use the 'transform' option to convert to JSON-safe format.
|
|
606
|
+
Docs: https://github.com/yourusername/react-tab-refresh#serialization`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const success = storage.set(key, value);
|
|
610
|
+
if (success) {
|
|
611
|
+
logger.log('Saved to storage');
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
logger.error('Failed to save to storage (quota exceeded?)');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
logger.error('Error saving to storage:', error);
|
|
619
|
+
}
|
|
620
|
+
}, 100)).current;
|
|
621
|
+
useEffect(() => {
|
|
622
|
+
saveToStorage(state);
|
|
623
|
+
}, [state, saveToStorage]);
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
return () => {
|
|
626
|
+
isMounted.current = false;
|
|
627
|
+
};
|
|
628
|
+
}, []);
|
|
629
|
+
const setStateWrapper = useCallback((value) => {
|
|
630
|
+
setState((prev) => {
|
|
631
|
+
const newValue = typeof value === 'function' ? value(prev) : value;
|
|
632
|
+
return newValue;
|
|
633
|
+
});
|
|
634
|
+
}, []);
|
|
635
|
+
return [state, setStateWrapper];
|
|
636
|
+
}
|
|
637
|
+
function useClearPrunableState() {
|
|
638
|
+
const storage = useRef(createStorageAdapter()).current;
|
|
639
|
+
return useCallback((key) => {
|
|
640
|
+
storage.remove(key);
|
|
641
|
+
}, [storage]);
|
|
642
|
+
}
|
|
643
|
+
function useStorageUsage() {
|
|
644
|
+
const storage = useRef(createStorageAdapter()).current;
|
|
645
|
+
const [usage, setUsage] = useState(() => storage.getUsage());
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
const interval = setInterval(() => {
|
|
648
|
+
setUsage(storage.getUsage());
|
|
649
|
+
}, 5000);
|
|
650
|
+
return () => clearInterval(interval);
|
|
651
|
+
}, [storage]);
|
|
652
|
+
return usage;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function usePruningState() {
|
|
656
|
+
const context = useContext(PruningContext);
|
|
657
|
+
if (!context) {
|
|
658
|
+
throw new Error('usePruningState must be used within a PruneProvider.\n\n' +
|
|
659
|
+
'Wrap your app with <PruneProvider>:\n' +
|
|
660
|
+
' <PruneProvider>\n' +
|
|
661
|
+
' <App />\n' +
|
|
662
|
+
' </PruneProvider>\n\n' +
|
|
663
|
+
'Docs: https://github.com/yourusername/react-tab-refresh#usage');
|
|
664
|
+
}
|
|
665
|
+
return context;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export { InactivityMonitor, PruneProvider, createStorageAdapter, useClearPrunableState, usePrunableState, usePruningState, useStorageUsage };
|
|
669
|
+
//# sourceMappingURL=index.esm.js.map
|