scope-state 0.1.1 → 0.1.3
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/dist/{src/config → config}/index.d.ts +2 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/{src/core → core}/listeners.d.ts +5 -0
- package/dist/core/listeners.d.ts.map +1 -0
- package/dist/core/monitoring.d.ts.map +1 -0
- package/dist/core/proxy.d.ts.map +1 -0
- package/dist/core/store.d.ts.map +1 -0
- package/dist/core/tracking.d.ts.map +1 -0
- package/dist/hooks/useLocal.d.ts.map +1 -0
- package/dist/hooks/useScope.d.ts.map +1 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +761 -225
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +768 -243
- package/dist/index.js.map +1 -0
- package/dist/persistence/adapters.d.ts +73 -0
- package/dist/persistence/adapters.d.ts.map +1 -0
- package/dist/persistence/advanced.d.ts +100 -0
- package/dist/persistence/advanced.d.ts.map +1 -0
- package/dist/persistence/storage.d.ts +27 -0
- package/dist/persistence/storage.d.ts.map +1 -0
- package/dist/{src/types → types}/index.d.ts +107 -2
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +2 -7
- package/dist/src/config/index.d.ts.map +0 -1
- package/dist/src/core/listeners.d.ts.map +0 -1
- package/dist/src/core/monitoring.d.ts.map +0 -1
- package/dist/src/core/proxy.d.ts.map +0 -1
- package/dist/src/core/store.d.ts.map +0 -1
- package/dist/src/core/tracking.d.ts.map +0 -1
- package/dist/src/hooks/useLocal.d.ts.map +0 -1
- package/dist/src/hooks/useScope.d.ts.map +0 -1
- package/dist/src/index.d.ts +0 -33
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/persistence/advanced.d.ts +0 -47
- package/dist/src/persistence/advanced.d.ts.map +0 -1
- package/dist/src/persistence/storage.d.ts +0 -14
- package/dist/src/persistence/storage.d.ts.map +0 -1
- package/dist/src/types/index.d.ts.map +0 -1
- /package/dist/{src/core → core}/monitoring.d.ts +0 -0
- /package/dist/{src/core → core}/proxy.d.ts +0 -0
- /package/dist/{src/core → core}/store.d.ts +0 -0
- /package/dist/{src/core → core}/tracking.d.ts +0 -0
- /package/dist/{src/hooks → hooks}/useLocal.d.ts +0 -0
- /package/dist/{src/hooks → hooks}/useScope.d.ts +0 -0
package/dist/index.js
CHANGED
|
@@ -1,27 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var react = require('react');
|
|
4
|
-
var localforage = require('localforage');
|
|
5
|
-
var memoryDriver = require('localforage-driver-memory');
|
|
6
|
-
|
|
7
|
-
function _interopNamespaceDefault(e) {
|
|
8
|
-
var n = Object.create(null);
|
|
9
|
-
if (e) {
|
|
10
|
-
Object.keys(e).forEach(function (k) {
|
|
11
|
-
if (k !== 'default') {
|
|
12
|
-
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
13
|
-
Object.defineProperty(n, k, d.get ? d : {
|
|
14
|
-
enumerable: true,
|
|
15
|
-
get: function () { return e[k]; }
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
n.default = e;
|
|
21
|
-
return Object.freeze(n);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
var memoryDriver__namespace = /*#__PURE__*/_interopNamespaceDefault(memoryDriver);
|
|
25
4
|
|
|
26
5
|
// Default configurations
|
|
27
6
|
const defaultProxyConfig = {
|
|
@@ -67,6 +46,7 @@ const defaultPersistenceConfig = {
|
|
|
67
46
|
paths: [],
|
|
68
47
|
blacklist: [],
|
|
69
48
|
batchDelay: 300,
|
|
49
|
+
autoHydrate: true,
|
|
70
50
|
};
|
|
71
51
|
// Current active configurations (will be modified by configure())
|
|
72
52
|
let proxyConfig = { ...defaultProxyConfig };
|
|
@@ -168,6 +148,16 @@ const presets = {
|
|
|
168
148
|
|
|
169
149
|
// Path-specific listeners
|
|
170
150
|
const pathListeners = new Map();
|
|
151
|
+
// Post-notification callback — used by the persistence layer to react to state changes
|
|
152
|
+
// without creating a circular dependency (listeners -> persistence -> config -> listeners).
|
|
153
|
+
let onStateChangeCallback = null;
|
|
154
|
+
/**
|
|
155
|
+
* Register a callback that fires after every notifyListeners call.
|
|
156
|
+
* Used internally by the persistence system to batch-persist changes.
|
|
157
|
+
*/
|
|
158
|
+
function setOnStateChangeCallback(callback) {
|
|
159
|
+
onStateChangeCallback = callback;
|
|
160
|
+
}
|
|
171
161
|
// Statistics for monitoring
|
|
172
162
|
let monitoringStats = {
|
|
173
163
|
proxyCount: 0,
|
|
@@ -281,6 +271,26 @@ function notifyListeners(path) {
|
|
|
281
271
|
const duration = logTimingEnd$1('Notification cycle', startTime);
|
|
282
272
|
updateTimingStat('notify', duration);
|
|
283
273
|
}
|
|
274
|
+
// Fire the post-notification callback (persistence, etc.)
|
|
275
|
+
if (onStateChangeCallback) {
|
|
276
|
+
onStateChangeCallback(path);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Get total number of active listeners
|
|
281
|
+
*/
|
|
282
|
+
function getListenerCount() {
|
|
283
|
+
let total = 0;
|
|
284
|
+
pathListeners.forEach(listeners => {
|
|
285
|
+
total += listeners.size;
|
|
286
|
+
});
|
|
287
|
+
return total;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Get all active paths
|
|
291
|
+
*/
|
|
292
|
+
function getActivePaths() {
|
|
293
|
+
return Array.from(pathListeners.keys());
|
|
284
294
|
}
|
|
285
295
|
// Logging helper functions
|
|
286
296
|
function logTimestamp$1(action) {
|
|
@@ -569,15 +579,7 @@ function createAdvancedProxy(target, path = [], depth = 0) {
|
|
|
569
579
|
selectorPaths.add(propPathKey);
|
|
570
580
|
}
|
|
571
581
|
// Track path access for dependency tracking (inline to avoid circular dependency)
|
|
572
|
-
|
|
573
|
-
try {
|
|
574
|
-
const { trackPathAccess } = require('./tracking');
|
|
575
|
-
trackPathAccess(currentPropPath);
|
|
576
|
-
}
|
|
577
|
-
catch (e) {
|
|
578
|
-
// Skip tracking if module not available
|
|
579
|
-
}
|
|
580
|
-
}
|
|
582
|
+
trackPathAccess(currentPropPath);
|
|
581
583
|
const value = obj[prop];
|
|
582
584
|
// For objects, create proxies for nested values
|
|
583
585
|
if (value && typeof value === 'object' && path.length < proxyConfig.maxDepth) {
|
|
@@ -978,13 +980,20 @@ function optimizeMemoryUsage(aggressive = false) {
|
|
|
978
980
|
|
|
979
981
|
// Track the current path we're accessing during a selector function
|
|
980
982
|
let currentPath = [];
|
|
983
|
+
let isTracking = false;
|
|
984
|
+
let skipTrackingDepth = 0;
|
|
981
985
|
/**
|
|
982
986
|
* Track dependencies during selector execution - tracks individual path segments
|
|
983
987
|
*/
|
|
984
988
|
function trackDependencies(selector) {
|
|
989
|
+
// Start tracking
|
|
990
|
+
isTracking = true;
|
|
985
991
|
currentPath = [];
|
|
992
|
+
skipTrackingDepth = 0;
|
|
986
993
|
// Execute selector to track dependencies
|
|
987
994
|
const value = selector();
|
|
995
|
+
// Stop tracking and get the tracked paths
|
|
996
|
+
isTracking = false;
|
|
988
997
|
// Clean up and return individual path segments (not full paths)
|
|
989
998
|
const cleanedPaths = [...currentPath].filter(segment => {
|
|
990
999
|
// Filter out segments that would create overly long paths
|
|
@@ -993,6 +1002,23 @@ function trackDependencies(selector) {
|
|
|
993
1002
|
currentPath = [];
|
|
994
1003
|
return { value, paths: cleanedPaths };
|
|
995
1004
|
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Add a path segment to tracking during proxy get operations
|
|
1007
|
+
*/
|
|
1008
|
+
function trackPathAccess(path) {
|
|
1009
|
+
if (!isTracking || skipTrackingDepth > 0)
|
|
1010
|
+
return;
|
|
1011
|
+
// Track only the last property name (individual segment)
|
|
1012
|
+
const prop = path[path.length - 1];
|
|
1013
|
+
// Only track if prop exists and path isn't too deep
|
|
1014
|
+
if (prop && path.length <= proxyConfig.maxPathLength) {
|
|
1015
|
+
currentPath.push(prop);
|
|
1016
|
+
// Add full path to usage stats and selector paths for ultra-selective proxying
|
|
1017
|
+
const fullPath = path.join('.');
|
|
1018
|
+
pathUsageStats.accessedPaths.add(fullPath);
|
|
1019
|
+
selectorPaths.add(fullPath);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
996
1022
|
|
|
997
1023
|
/**
|
|
998
1024
|
* Hook to subscribe to the global store and re-render when specific data changes.
|
|
@@ -1477,50 +1503,276 @@ if (typeof window !== 'undefined' && monitoringConfig.enabled && monitoringConfi
|
|
|
1477
1503
|
setTimeout(() => monitorAPI.startAutoLeakDetection(), 5000); // Start after 5 seconds
|
|
1478
1504
|
}
|
|
1479
1505
|
|
|
1480
|
-
let storage = null;
|
|
1481
1506
|
/**
|
|
1482
|
-
*
|
|
1507
|
+
* Creates a localStorage-based storage adapter.
|
|
1508
|
+
*
|
|
1509
|
+
* This is the default adapter used in browser environments.
|
|
1510
|
+
* All methods are synchronous (localStorage is synchronous by nature).
|
|
1511
|
+
*
|
|
1512
|
+
* @param prefix - Optional key prefix to namespace all stored keys. Defaults to 'scope_state:'.
|
|
1513
|
+
* @returns A StorageAdapter backed by window.localStorage
|
|
1514
|
+
*
|
|
1515
|
+
* @example
|
|
1516
|
+
* import { configure, createLocalStorageAdapter } from 'scope-state';
|
|
1517
|
+
*
|
|
1518
|
+
* configure({
|
|
1519
|
+
* initialState: { ... },
|
|
1520
|
+
* persistence: {
|
|
1521
|
+
* enabled: true,
|
|
1522
|
+
* storageAdapter: createLocalStorageAdapter('myapp:'),
|
|
1523
|
+
* },
|
|
1524
|
+
* });
|
|
1483
1525
|
*/
|
|
1484
|
-
function
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1526
|
+
function createLocalStorageAdapter(prefix = 'scope_state:') {
|
|
1527
|
+
return {
|
|
1528
|
+
getItem(key) {
|
|
1529
|
+
try {
|
|
1530
|
+
return localStorage.getItem(prefix + key);
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
},
|
|
1536
|
+
setItem(key, value) {
|
|
1537
|
+
try {
|
|
1538
|
+
localStorage.setItem(prefix + key, value);
|
|
1539
|
+
}
|
|
1540
|
+
catch (e) {
|
|
1541
|
+
console.error(`[scope-state] localStorage.setItem failed for key "${key}":`, e);
|
|
1542
|
+
}
|
|
1543
|
+
},
|
|
1544
|
+
removeItem(key) {
|
|
1545
|
+
try {
|
|
1546
|
+
localStorage.removeItem(prefix + key);
|
|
1547
|
+
}
|
|
1548
|
+
catch (e) {
|
|
1549
|
+
console.error(`[scope-state] localStorage.removeItem failed for key "${key}":`, e);
|
|
1550
|
+
}
|
|
1551
|
+
},
|
|
1552
|
+
keys() {
|
|
1553
|
+
try {
|
|
1554
|
+
const allKeys = [];
|
|
1555
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
1556
|
+
const key = localStorage.key(i);
|
|
1557
|
+
if (key && key.startsWith(prefix)) {
|
|
1558
|
+
allKeys.push(key.slice(prefix.length));
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return allKeys;
|
|
1562
|
+
}
|
|
1563
|
+
catch {
|
|
1564
|
+
return [];
|
|
1565
|
+
}
|
|
1566
|
+
},
|
|
1567
|
+
clear() {
|
|
1568
|
+
try {
|
|
1569
|
+
// Only remove keys with our prefix, not the entire localStorage
|
|
1570
|
+
const keysToRemove = [];
|
|
1571
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
1572
|
+
const key = localStorage.key(i);
|
|
1573
|
+
if (key && key.startsWith(prefix)) {
|
|
1574
|
+
keysToRemove.push(key);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
keysToRemove.forEach(key => localStorage.removeItem(key));
|
|
1578
|
+
}
|
|
1579
|
+
catch (e) {
|
|
1580
|
+
console.error('[scope-state] localStorage.clear failed:', e);
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Creates an in-memory storage adapter.
|
|
1587
|
+
*
|
|
1588
|
+
* Useful for:
|
|
1589
|
+
* - Server-side rendering (SSR) where no persistent storage is available
|
|
1590
|
+
* - Testing environments
|
|
1591
|
+
* - Ephemeral state that should not survive page reloads
|
|
1592
|
+
*
|
|
1593
|
+
* All methods are synchronous.
|
|
1594
|
+
*
|
|
1595
|
+
* @returns A StorageAdapter backed by an in-memory Map
|
|
1596
|
+
*
|
|
1597
|
+
* @example
|
|
1598
|
+
* import { configure, createMemoryAdapter } from 'scope-state';
|
|
1599
|
+
*
|
|
1600
|
+
* configure({
|
|
1601
|
+
* initialState: { ... },
|
|
1602
|
+
* persistence: {
|
|
1603
|
+
* enabled: true,
|
|
1604
|
+
* storageAdapter: createMemoryAdapter(),
|
|
1605
|
+
* },
|
|
1606
|
+
* });
|
|
1607
|
+
*/
|
|
1608
|
+
function createMemoryAdapter() {
|
|
1609
|
+
const store = new Map();
|
|
1610
|
+
return {
|
|
1611
|
+
getItem(key) {
|
|
1612
|
+
var _a;
|
|
1613
|
+
return (_a = store.get(key)) !== null && _a !== void 0 ? _a : null;
|
|
1614
|
+
},
|
|
1615
|
+
setItem(key, value) {
|
|
1616
|
+
store.set(key, value);
|
|
1617
|
+
},
|
|
1618
|
+
removeItem(key) {
|
|
1619
|
+
store.delete(key);
|
|
1620
|
+
},
|
|
1621
|
+
keys() {
|
|
1622
|
+
return Array.from(store.keys());
|
|
1623
|
+
},
|
|
1624
|
+
clear() {
|
|
1625
|
+
store.clear();
|
|
1626
|
+
},
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Wraps any StorageAdapter in an in-memory cache layer.
|
|
1631
|
+
*
|
|
1632
|
+
* This is automatically applied to all adapters by the persistence system.
|
|
1633
|
+
* The cache prevents hydration mismatches by ensuring:
|
|
1634
|
+
*
|
|
1635
|
+
* 1. **On startup**: the cache is empty, so reads return `null` and the app
|
|
1636
|
+
* renders with `initialState` (matching SSR output).
|
|
1637
|
+
* 2. **After hydration**: data is pulled from the backing store into the cache,
|
|
1638
|
+
* and components re-render with persisted data.
|
|
1639
|
+
* 3. **On writes**: both the cache and the backing store are updated, so
|
|
1640
|
+
* subsequent reads are instant from memory.
|
|
1641
|
+
*
|
|
1642
|
+
* @param backingAdapter - The underlying storage adapter (MMKV, AsyncStorage, etc.)
|
|
1643
|
+
* @returns A StorageAdapter with an in-memory cache in front of the backing store
|
|
1644
|
+
*/
|
|
1645
|
+
function createCachedAdapter(backingAdapter) {
|
|
1646
|
+
const cache = new Map();
|
|
1647
|
+
return {
|
|
1648
|
+
getItem(key) {
|
|
1649
|
+
var _a;
|
|
1650
|
+
// Always read from the in-memory cache (instant, sync)
|
|
1651
|
+
return (_a = cache.get(key)) !== null && _a !== void 0 ? _a : null;
|
|
1652
|
+
},
|
|
1653
|
+
setItem(key, value) {
|
|
1654
|
+
// Write-through: update cache immediately, then persist to backing store
|
|
1655
|
+
cache.set(key, value);
|
|
1656
|
+
return backingAdapter.setItem(key, value);
|
|
1657
|
+
},
|
|
1658
|
+
removeItem(key) {
|
|
1659
|
+
cache.delete(key);
|
|
1660
|
+
return backingAdapter.removeItem(key);
|
|
1661
|
+
},
|
|
1662
|
+
keys() {
|
|
1663
|
+
// Return keys from cache (this is the source of truth after warmup)
|
|
1664
|
+
return Array.from(cache.keys());
|
|
1665
|
+
},
|
|
1666
|
+
clear() {
|
|
1667
|
+
cache.clear();
|
|
1668
|
+
if (backingAdapter.clear) {
|
|
1669
|
+
return backingAdapter.clear();
|
|
1670
|
+
}
|
|
1671
|
+
},
|
|
1672
|
+
/**
|
|
1673
|
+
* Pre-populate the in-memory cache from the backing store.
|
|
1674
|
+
* Called automatically during hydration. After this completes,
|
|
1675
|
+
* all reads are served instantly from memory.
|
|
1676
|
+
*/
|
|
1677
|
+
async warmCache() {
|
|
1678
|
+
const keys = await backingAdapter.keys();
|
|
1679
|
+
for (const key of keys) {
|
|
1680
|
+
const value = await backingAdapter.getItem(key);
|
|
1681
|
+
if (value !== null) {
|
|
1682
|
+
cache.set(key, value);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
},
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Returns the default storage adapter for the current environment.
|
|
1690
|
+
*
|
|
1691
|
+
* - In browsers: returns a localStorage adapter
|
|
1692
|
+
* - In non-browser environments (Node, SSR): returns a memory adapter
|
|
1693
|
+
*/
|
|
1694
|
+
function getDefaultAdapter() {
|
|
1695
|
+
if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') {
|
|
1696
|
+
return createLocalStorageAdapter();
|
|
1505
1697
|
}
|
|
1698
|
+
return createMemoryAdapter();
|
|
1506
1699
|
}
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* The active storage adapter used by the persistence layer.
|
|
1703
|
+
*
|
|
1704
|
+
* Defaults to a localStorage adapter in browsers or a memory adapter in SSR/Node.
|
|
1705
|
+
* Can be overridden via `configure()` or `setStorageAdapter()`.
|
|
1706
|
+
*
|
|
1707
|
+
* All adapters are automatically wrapped in an in-memory cache layer
|
|
1708
|
+
* to prevent hydration mismatches and provide instant reads.
|
|
1709
|
+
*/
|
|
1710
|
+
let storageAdapter = null;
|
|
1507
1711
|
/**
|
|
1508
|
-
* Get the current storage
|
|
1712
|
+
* Get the current storage adapter, lazily initializing the default if needed.
|
|
1509
1713
|
*/
|
|
1510
|
-
function
|
|
1511
|
-
|
|
1714
|
+
function getStorageAdapter() {
|
|
1715
|
+
if (!storageAdapter) {
|
|
1716
|
+
// Default adapters (localStorage, memory) are already fast/sync,
|
|
1717
|
+
// so we don't need to wrap them in a cache — they ARE the cache.
|
|
1718
|
+
storageAdapter = getDefaultAdapter();
|
|
1719
|
+
}
|
|
1720
|
+
return storageAdapter;
|
|
1512
1721
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1722
|
+
/**
|
|
1723
|
+
* Set a custom storage adapter.
|
|
1724
|
+
*
|
|
1725
|
+
* Synchronous adapters (like `createLocalStorageAdapter` and `createMemoryAdapter`)
|
|
1726
|
+
* are stored directly — this enables **synchronous hydration** during `configure()`,
|
|
1727
|
+
* so components render with persisted values on the very first render (no flash of
|
|
1728
|
+
* default values).
|
|
1729
|
+
*
|
|
1730
|
+
* For asynchronous adapters (AsyncStorage, MMKV with async API), wrap them in
|
|
1731
|
+
* `createCachedAdapter()` before passing to `configure()`. The cache layer provides
|
|
1732
|
+
* instant reads after the initial warm-up.
|
|
1733
|
+
*
|
|
1734
|
+
* @param adapter - A StorageAdapter implementation
|
|
1735
|
+
*/
|
|
1736
|
+
function setStorageAdapter(adapter) {
|
|
1737
|
+
storageAdapter = adapter;
|
|
1516
1738
|
}
|
|
1517
1739
|
|
|
1518
1740
|
// Storage constants
|
|
1519
1741
|
const PERSISTED_STATE_KEY = 'persisted_state';
|
|
1520
1742
|
const PERSISTENCE_CONFIG_KEY = 'persistence_config';
|
|
1521
|
-
//
|
|
1743
|
+
// Global store references — set by configure()
|
|
1744
|
+
// The raw store is used for reading/serializing state during persistence.
|
|
1745
|
+
// The proxy is used for writing during hydration so that set traps fire
|
|
1746
|
+
// and components re-render with the hydrated values.
|
|
1747
|
+
let globalStoreRef = null;
|
|
1748
|
+
let globalProxyRef = null;
|
|
1749
|
+
// Flag to suppress persistence during hydration — hydration writes through
|
|
1750
|
+
// the proxy (to trigger re-renders), which also triggers the persistence
|
|
1751
|
+
// callback. We skip that to avoid wastefully re-persisting the same data.
|
|
1752
|
+
let isHydrating = false;
|
|
1753
|
+
// Flag to block persistence until the first hydration has completed.
|
|
1754
|
+
// This prevents React effects that fire between the initial render and
|
|
1755
|
+
// hydration from persisting default values over the correct stored data.
|
|
1756
|
+
let hasHydrated = false;
|
|
1757
|
+
/**
|
|
1758
|
+
* Set the global store reference (raw object) for serialization during persistence.
|
|
1759
|
+
* Called internally by configure().
|
|
1760
|
+
*/
|
|
1761
|
+
function setGlobalStoreRef(store) {
|
|
1762
|
+
globalStoreRef = store;
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Set the global proxy reference for hydration writes.
|
|
1766
|
+
* Writing through the proxy ensures set traps fire and components re-render.
|
|
1767
|
+
* Called internally by configure() after the proxy is created.
|
|
1768
|
+
*/
|
|
1769
|
+
function setGlobalProxyRef(proxy) {
|
|
1770
|
+
globalProxyRef = proxy;
|
|
1771
|
+
}
|
|
1772
|
+
// Batch persistence state — stores persistence *roots* (e.g. "user", "todos"),
|
|
1773
|
+
// not individual leaf paths like "user.preferences.theme".
|
|
1522
1774
|
const persistenceBatch = {
|
|
1523
|
-
|
|
1775
|
+
roots: new Set(),
|
|
1524
1776
|
timeoutId: null,
|
|
1525
1777
|
isPersisting: false,
|
|
1526
1778
|
};
|
|
@@ -1541,42 +1793,114 @@ function logTimingEnd(action, startTime) {
|
|
|
1541
1793
|
return duration;
|
|
1542
1794
|
}
|
|
1543
1795
|
/**
|
|
1544
|
-
*
|
|
1796
|
+
* Determine the "persistence root" for a given change path.
|
|
1797
|
+
*
|
|
1798
|
+
* When a deep property changes (e.g. "user.preferences.theme"), we don't
|
|
1799
|
+
* want to create a separate storage entry for every leaf — that leads to
|
|
1800
|
+
* fragmented, inconsistent data. Instead we figure out the appropriate
|
|
1801
|
+
* root-level slice and persist the entire object at that level.
|
|
1802
|
+
*
|
|
1803
|
+
* Rules:
|
|
1804
|
+
* 1. If `persistenceConfig.paths` lists specific paths, use the matching
|
|
1805
|
+
* configured path as the root (e.g. if configured with "user.preferences",
|
|
1806
|
+
* a change to "user.preferences.theme" persists at "user.preferences").
|
|
1807
|
+
* 2. Otherwise, use the first segment of the path (top-level key),
|
|
1808
|
+
* so "user.preferences.theme" → "user".
|
|
1809
|
+
*/
|
|
1810
|
+
function getPersistenceRoot(changePath) {
|
|
1811
|
+
const configuredPaths = persistenceConfig.paths;
|
|
1812
|
+
if (configuredPaths && configuredPaths.length > 0) {
|
|
1813
|
+
// Find the configured path that is a parent of (or equal to) the change path
|
|
1814
|
+
for (const configPath of configuredPaths) {
|
|
1815
|
+
if (changePath === configPath || changePath.startsWith(configPath + '.')) {
|
|
1816
|
+
return configPath;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
// The change path might itself be a parent of a configured path — in that
|
|
1820
|
+
// case we persist at the change path's top-level key
|
|
1821
|
+
}
|
|
1822
|
+
// Default: persist at the first-level key (e.g. "user", "todos", "counters")
|
|
1823
|
+
return changePath.split('.')[0];
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Check if a change path should be persisted (respects blacklist + paths config).
|
|
1827
|
+
*/
|
|
1828
|
+
function shouldPersistPath(path) {
|
|
1829
|
+
if (!persistenceConfig.enabled)
|
|
1830
|
+
return false;
|
|
1831
|
+
// Check if path (or any of its ancestors) is blacklisted
|
|
1832
|
+
const segments = path.split('.');
|
|
1833
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
1834
|
+
const ancestor = segments.slice(0, i).join('.');
|
|
1835
|
+
if (persistenceConfig.blacklist.some(b => b === ancestor)) {
|
|
1836
|
+
return false;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
// If paths array is empty/undefined, persist everything not blacklisted
|
|
1840
|
+
if (!persistenceConfig.paths || persistenceConfig.paths.length === 0)
|
|
1841
|
+
return true;
|
|
1842
|
+
// Check if path falls under one of the configured persistence paths
|
|
1843
|
+
return persistenceConfig.paths.some(persistedPath => path === persistedPath ||
|
|
1844
|
+
path.startsWith(`${persistedPath}.`) ||
|
|
1845
|
+
persistedPath.startsWith(`${path}.`));
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Add a changed path to the persistence batch.
|
|
1849
|
+
*
|
|
1850
|
+
* The path is converted to its persistence root before being added,
|
|
1851
|
+
* so multiple changes within the same slice (e.g. "user.name" and
|
|
1852
|
+
* "user.preferences.theme") are deduplicated into a single "user" persist.
|
|
1853
|
+
*/
|
|
1854
|
+
function addToPersistenceBatch(path) {
|
|
1855
|
+
if (!persistenceConfig.enabled || isHydrating || !hasHydrated)
|
|
1856
|
+
return;
|
|
1857
|
+
const pathKey = path.join('.');
|
|
1858
|
+
if (shouldPersistPath(pathKey)) {
|
|
1859
|
+
const root = getPersistenceRoot(pathKey);
|
|
1860
|
+
persistenceBatch.roots.add(root);
|
|
1861
|
+
schedulePersistenceBatch();
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Schedule batch persistence with debounce
|
|
1866
|
+
*/
|
|
1867
|
+
function schedulePersistenceBatch() {
|
|
1868
|
+
if (persistenceBatch.timeoutId) {
|
|
1869
|
+
clearTimeout(persistenceBatch.timeoutId);
|
|
1870
|
+
}
|
|
1871
|
+
persistenceBatch.timeoutId = setTimeout(() => {
|
|
1872
|
+
persistenceBatch.timeoutId = null;
|
|
1873
|
+
processPersistenceBatch();
|
|
1874
|
+
}, persistenceConfig.batchDelay);
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Process all roots in the batch — persist each root-level slice.
|
|
1545
1878
|
*/
|
|
1546
1879
|
function processPersistenceBatch() {
|
|
1547
1880
|
if (typeof window === 'undefined')
|
|
1548
1881
|
return;
|
|
1549
|
-
if (persistenceBatch.isPersisting || persistenceBatch.
|
|
1882
|
+
if (persistenceBatch.isPersisting || persistenceBatch.roots.size === 0) {
|
|
1550
1883
|
return;
|
|
1551
1884
|
}
|
|
1552
1885
|
persistenceBatch.isPersisting = true;
|
|
1553
1886
|
let startTime = 0;
|
|
1554
1887
|
if (monitoringConfig.enabled && monitoringConfig.logPersistence) {
|
|
1555
|
-
startTime = logTimestamp(`💾 Batch persisting ${persistenceBatch.
|
|
1888
|
+
startTime = logTimestamp(`💾 Batch persisting ${persistenceBatch.roots.size} slices`);
|
|
1556
1889
|
}
|
|
1557
1890
|
try {
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
}
|
|
1564
|
-
else {
|
|
1565
|
-
// Otherwise persist individual paths
|
|
1566
|
-
pathArrays.forEach(pathArray => {
|
|
1567
|
-
persistState(pathArray);
|
|
1568
|
-
});
|
|
1569
|
-
}
|
|
1570
|
-
// Clear the batch
|
|
1571
|
-
persistenceBatch.paths.clear();
|
|
1891
|
+
const roots = Array.from(persistenceBatch.roots);
|
|
1892
|
+
persistenceBatch.roots.clear();
|
|
1893
|
+
roots.forEach(root => {
|
|
1894
|
+
persistSlice(root);
|
|
1895
|
+
});
|
|
1572
1896
|
}
|
|
1573
1897
|
catch (e) {
|
|
1574
1898
|
console.error('Error during batch persistence:', e);
|
|
1575
1899
|
}
|
|
1576
1900
|
finally {
|
|
1577
1901
|
persistenceBatch.isPersisting = false;
|
|
1578
|
-
// If new
|
|
1579
|
-
if (persistenceBatch.
|
|
1902
|
+
// If new roots were added during processing, schedule another batch
|
|
1903
|
+
if (persistenceBatch.roots.size > 0) {
|
|
1580
1904
|
schedulePersistenceBatch();
|
|
1581
1905
|
}
|
|
1582
1906
|
if (monitoringConfig.enabled && monitoringConfig.logPersistence && startTime > 0) {
|
|
@@ -1585,154 +1909,287 @@ function processPersistenceBatch() {
|
|
|
1585
1909
|
}
|
|
1586
1910
|
}
|
|
1587
1911
|
/**
|
|
1588
|
-
*
|
|
1912
|
+
* Persist a single slice of the store to storage.
|
|
1913
|
+
*
|
|
1914
|
+
* @param rootPath - Dot-separated path like "user" or "user.preferences".
|
|
1915
|
+
* The entire value at this path is serialized as one entry.
|
|
1589
1916
|
*/
|
|
1590
|
-
function
|
|
1591
|
-
if (
|
|
1592
|
-
|
|
1917
|
+
function persistSlice(rootPath) {
|
|
1918
|
+
if (!persistenceConfig.enabled)
|
|
1919
|
+
return;
|
|
1920
|
+
if (!globalStoreRef) {
|
|
1921
|
+
console.warn('Cannot persist state without store reference. Call configure() first.');
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const adapter = getStorageAdapter();
|
|
1925
|
+
// Navigate to the value at this path in the store
|
|
1926
|
+
const segments = rootPath.split('.');
|
|
1927
|
+
let value = globalStoreRef;
|
|
1928
|
+
for (const segment of segments) {
|
|
1929
|
+
if (value === undefined || value === null)
|
|
1930
|
+
return;
|
|
1931
|
+
value = value[segment];
|
|
1932
|
+
}
|
|
1933
|
+
if (value === undefined)
|
|
1934
|
+
return;
|
|
1935
|
+
try {
|
|
1936
|
+
const serialized = JSON.stringify(value);
|
|
1937
|
+
const storageKey = `${PERSISTED_STATE_KEY}_${rootPath}`;
|
|
1938
|
+
const result = adapter.setItem(storageKey, serialized);
|
|
1939
|
+
if (result && typeof result.catch === 'function') {
|
|
1940
|
+
result.catch(e => {
|
|
1941
|
+
console.error(`Error persisting slice "${rootPath}":`, e);
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
catch (e) {
|
|
1946
|
+
// JSON.stringify can fail on circular references or functions — that's fine,
|
|
1947
|
+
// non-serializable values just won't be persisted.
|
|
1948
|
+
if (monitoringConfig.enabled) {
|
|
1949
|
+
console.warn(`Could not serialize slice "${rootPath}" for persistence:`, e);
|
|
1950
|
+
}
|
|
1593
1951
|
}
|
|
1594
|
-
persistenceBatch.timeoutId = setTimeout(() => {
|
|
1595
|
-
persistenceBatch.timeoutId = null;
|
|
1596
|
-
processPersistenceBatch();
|
|
1597
|
-
}, persistenceConfig.batchDelay);
|
|
1598
1952
|
}
|
|
1599
1953
|
/**
|
|
1600
|
-
*
|
|
1954
|
+
* Persist the entire store as one entry (used by persistenceAPI.persist).
|
|
1601
1955
|
*/
|
|
1602
|
-
function
|
|
1603
|
-
if (!persistenceConfig.enabled)
|
|
1956
|
+
function persistEntireState() {
|
|
1957
|
+
if (!persistenceConfig.enabled || !globalStoreRef)
|
|
1604
1958
|
return;
|
|
1605
|
-
const
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1959
|
+
const adapter = getStorageAdapter();
|
|
1960
|
+
try {
|
|
1961
|
+
const serialized = JSON.stringify(globalStoreRef);
|
|
1962
|
+
const result = adapter.setItem(PERSISTED_STATE_KEY, serialized);
|
|
1963
|
+
if (result && typeof result.catch === 'function') {
|
|
1964
|
+
result.catch(e => {
|
|
1965
|
+
console.error('Error persisting entire state:', e);
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
catch (e) {
|
|
1970
|
+
console.error('Error persisting entire state:', e);
|
|
1609
1971
|
}
|
|
1610
1972
|
}
|
|
1611
1973
|
/**
|
|
1612
|
-
*
|
|
1974
|
+
* Check if a value is a Promise/thenable.
|
|
1613
1975
|
*/
|
|
1614
|
-
function
|
|
1615
|
-
|
|
1616
|
-
return false;
|
|
1617
|
-
// Check if path is blacklisted
|
|
1618
|
-
if (persistenceConfig.blacklist.some(blacklistedPath => path === blacklistedPath || path.startsWith(`${blacklistedPath}.`))) {
|
|
1619
|
-
return false;
|
|
1620
|
-
}
|
|
1621
|
-
// If paths array is empty, persist everything not blacklisted
|
|
1622
|
-
if (!persistenceConfig.paths || persistenceConfig.paths.length === 0)
|
|
1623
|
-
return true;
|
|
1624
|
-
// Check if path is in the persistence paths list
|
|
1625
|
-
return persistenceConfig.paths.some(persistedPath => path === persistedPath || path.startsWith(`${persistedPath}.`));
|
|
1976
|
+
function isPromiseLike(value) {
|
|
1977
|
+
return value !== null && value !== undefined && typeof value.then === 'function';
|
|
1626
1978
|
}
|
|
1627
1979
|
/**
|
|
1628
|
-
*
|
|
1980
|
+
* Merge persisted data into the initial state object **before** the proxy is created.
|
|
1981
|
+
*
|
|
1982
|
+
* This is the fastest possible hydration path for synchronous adapters (localStorage,
|
|
1983
|
+
* memory). Because the merge happens on a plain object — not through the proxy —
|
|
1984
|
+
* there are no set traps, no `notifyListeners`, and no React re-renders. The proxy
|
|
1985
|
+
* is then created wrapping the already-correct state, so components render with
|
|
1986
|
+
* persisted values on their very first render.
|
|
1987
|
+
*
|
|
1988
|
+
* For async adapters (those wrapped in `createCachedAdapter`), this returns `null`
|
|
1989
|
+
* and the caller falls back to the async `hydrateState()` path.
|
|
1990
|
+
*
|
|
1991
|
+
* @param initialState - The default state from the user's config. Not mutated.
|
|
1992
|
+
* @returns A new state object with persisted data merged in, or `null` if the
|
|
1993
|
+
* adapter is async and synchronous merge isn't possible.
|
|
1629
1994
|
*/
|
|
1630
|
-
function
|
|
1995
|
+
function mergePersistedIntoState(initialState) {
|
|
1996
|
+
// No localStorage on the server
|
|
1631
1997
|
if (typeof window === 'undefined')
|
|
1632
|
-
return;
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
//
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1998
|
+
return null;
|
|
1999
|
+
const adapter = getStorageAdapter();
|
|
2000
|
+
// Cached/async adapters can't be read synchronously
|
|
2001
|
+
if (typeof adapter.warmCache === 'function')
|
|
2002
|
+
return null;
|
|
2003
|
+
try {
|
|
2004
|
+
// Shallow clone — persisted slices replace entire top-level values
|
|
2005
|
+
const merged = { ...initialState };
|
|
2006
|
+
// --- Full state blob (from persistenceAPI.persist) ---
|
|
2007
|
+
const fullResult = adapter.getItem(PERSISTED_STATE_KEY);
|
|
2008
|
+
if (isPromiseLike(fullResult))
|
|
2009
|
+
return null;
|
|
2010
|
+
if (fullResult) {
|
|
2011
|
+
try {
|
|
2012
|
+
const parsed = JSON.parse(fullResult);
|
|
2013
|
+
Object.keys(parsed).forEach(key => {
|
|
2014
|
+
if (key in merged)
|
|
2015
|
+
merged[key] = parsed[key];
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
catch { /* skip malformed blob */ }
|
|
1650
2019
|
}
|
|
1651
|
-
|
|
1652
|
-
|
|
2020
|
+
// --- Individual slice entries (persisted_state_user, etc.) ---
|
|
2021
|
+
const keysResult = adapter.keys();
|
|
2022
|
+
if (isPromiseLike(keysResult))
|
|
2023
|
+
return null;
|
|
2024
|
+
const allKeys = keysResult;
|
|
2025
|
+
const sliceKeys = allKeys
|
|
2026
|
+
.filter(key => key.startsWith(`${PERSISTED_STATE_KEY}_`))
|
|
2027
|
+
.sort((a, b) => a.split('.').length - b.split('.').length);
|
|
2028
|
+
const hydratedRoots = new Set();
|
|
2029
|
+
for (const key of sliceKeys) {
|
|
2030
|
+
const pathStr = key.replace(`${PERSISTED_STATE_KEY}_`, '');
|
|
2031
|
+
// Respect blacklist
|
|
2032
|
+
if (!shouldPersistPath(pathStr))
|
|
2033
|
+
continue;
|
|
2034
|
+
// Skip child entries if a parent was already merged
|
|
2035
|
+
const isChild = Array.from(hydratedRoots).some(root => pathStr.startsWith(root + '.'));
|
|
2036
|
+
if (isChild)
|
|
2037
|
+
continue;
|
|
2038
|
+
const valueResult = adapter.getItem(key);
|
|
2039
|
+
if (isPromiseLike(valueResult))
|
|
2040
|
+
return null;
|
|
2041
|
+
const value = valueResult;
|
|
2042
|
+
if (value) {
|
|
2043
|
+
try {
|
|
2044
|
+
const parsedValue = JSON.parse(value);
|
|
2045
|
+
const segments = pathStr.split('.');
|
|
2046
|
+
if (segments.length === 1) {
|
|
2047
|
+
// Top-level key (common case) — direct assignment
|
|
2048
|
+
merged[segments[0]] = parsedValue;
|
|
2049
|
+
}
|
|
2050
|
+
else {
|
|
2051
|
+
// Nested path — navigate with shallow copies to avoid mutating original
|
|
2052
|
+
let current = merged;
|
|
2053
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
2054
|
+
const seg = segments[i];
|
|
2055
|
+
if (current[seg] && typeof current[seg] === 'object') {
|
|
2056
|
+
current[seg] = Array.isArray(current[seg])
|
|
2057
|
+
? [...current[seg]]
|
|
2058
|
+
: { ...current[seg] };
|
|
2059
|
+
}
|
|
2060
|
+
else {
|
|
2061
|
+
current[seg] = {};
|
|
2062
|
+
}
|
|
2063
|
+
current = current[seg];
|
|
2064
|
+
}
|
|
2065
|
+
current[segments[segments.length - 1]] = parsedValue;
|
|
2066
|
+
}
|
|
2067
|
+
hydratedRoots.add(pathStr);
|
|
2068
|
+
}
|
|
2069
|
+
catch { /* skip malformed entries */ }
|
|
2070
|
+
}
|
|
1653
2071
|
}
|
|
2072
|
+
hasHydrated = true;
|
|
2073
|
+
if (monitoringConfig.enabled && hydratedRoots.size > 0) {
|
|
2074
|
+
console.log(`🔄 Merged ${hydratedRoots.size} persisted slices into initial state`);
|
|
2075
|
+
}
|
|
2076
|
+
return merged;
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
return null;
|
|
1654
2080
|
}
|
|
1655
2081
|
}
|
|
1656
2082
|
/**
|
|
1657
|
-
* Hydrate state from storage
|
|
2083
|
+
* Hydrate state from storage using the configured StorageAdapter (async path).
|
|
2084
|
+
*
|
|
2085
|
+
* This is the fallback for asynchronous adapters (e.g. AsyncStorage wrapped in
|
|
2086
|
+
* `createCachedAdapter`). For synchronous adapters, `mergePersistedIntoState()`
|
|
2087
|
+
* is used instead — it's faster because it avoids proxy overhead entirely.
|
|
2088
|
+
*
|
|
2089
|
+
* @param store - The store object to hydrate into. If omitted, uses the
|
|
2090
|
+
* global store set by `configure()`.
|
|
1658
2091
|
*/
|
|
1659
2092
|
async function hydrateState(store) {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
2093
|
+
// Prefer writing through the proxy so that set traps fire and components
|
|
2094
|
+
// re-render with the hydrated values. Fall back to the raw store ref.
|
|
2095
|
+
const writeTarget = store || globalProxyRef || globalStoreRef;
|
|
2096
|
+
if (!writeTarget) {
|
|
2097
|
+
console.warn('Cannot hydrate state without store reference. Call configure() first or pass a store.');
|
|
1664
2098
|
return false;
|
|
1665
2099
|
}
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
2100
|
+
const adapter = getStorageAdapter();
|
|
2101
|
+
// If the adapter has a cache layer, warm it first so reads below are fast
|
|
2102
|
+
if (typeof adapter.warmCache === 'function') {
|
|
2103
|
+
await adapter.warmCache();
|
|
2104
|
+
}
|
|
2105
|
+
// Suppress persistence during hydration — writing through the proxy triggers
|
|
2106
|
+
// the persistence callback, but we don't want to re-persist data we just loaded.
|
|
2107
|
+
isHydrating = true;
|
|
1669
2108
|
try {
|
|
1670
|
-
// First try to load entire state
|
|
1671
|
-
const savedState = await
|
|
2109
|
+
// First try to load the entire state blob (from persistenceAPI.persist)
|
|
2110
|
+
const savedState = await adapter.getItem(PERSISTED_STATE_KEY);
|
|
1672
2111
|
if (savedState) {
|
|
1673
2112
|
const parsedState = JSON.parse(savedState);
|
|
1674
|
-
// Direct replacement instead of merging
|
|
1675
2113
|
Object.keys(parsedState).forEach(key => {
|
|
1676
|
-
if (key in
|
|
1677
|
-
|
|
2114
|
+
if (key in writeTarget) {
|
|
2115
|
+
writeTarget[key] = parsedState[key];
|
|
1678
2116
|
}
|
|
1679
2117
|
});
|
|
1680
2118
|
if (monitoringConfig.enabled) {
|
|
1681
|
-
console.log('🔄
|
|
2119
|
+
console.log('🔄 Full state hydrated from storage');
|
|
1682
2120
|
}
|
|
1683
2121
|
}
|
|
1684
|
-
// Then
|
|
1685
|
-
const
|
|
1686
|
-
if (!
|
|
2122
|
+
// Then load individual slice entries (persisted_state_user, persisted_state_todos, etc.)
|
|
2123
|
+
const allKeys = await adapter.keys();
|
|
2124
|
+
if (!allKeys)
|
|
1687
2125
|
return true;
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
2126
|
+
// Collect slice keys and sort by depth (shortest first) so parent slices
|
|
2127
|
+
// are applied before child slices, ensuring correct overwrite order.
|
|
2128
|
+
const sliceKeys = allKeys
|
|
2129
|
+
.filter(key => key.startsWith(`${PERSISTED_STATE_KEY}_`))
|
|
2130
|
+
.sort((a, b) => a.split('.').length - b.split('.').length);
|
|
2131
|
+
// Track which root paths we've already hydrated so we don't
|
|
2132
|
+
// apply stale child entries from the old fragmented format.
|
|
2133
|
+
const hydratedRoots = new Set();
|
|
2134
|
+
for (const key of sliceKeys) {
|
|
2135
|
+
const pathStr = key.replace(`${PERSISTED_STATE_KEY}_`, '');
|
|
2136
|
+
// Skip this entry if a parent slice was already hydrated
|
|
2137
|
+
// (prevents old fragmented entries from overwriting clean slice data)
|
|
2138
|
+
const isChildOfHydrated = Array.from(hydratedRoots).some(root => pathStr.startsWith(root + '.'));
|
|
2139
|
+
if (isChildOfHydrated)
|
|
2140
|
+
continue;
|
|
2141
|
+
const path = pathStr.split('.');
|
|
2142
|
+
const value = await adapter.getItem(key);
|
|
2143
|
+
if (value) {
|
|
2144
|
+
try {
|
|
2145
|
+
const parsedValue = JSON.parse(value);
|
|
2146
|
+
// Navigate to the parent in the store, writing through the proxy
|
|
2147
|
+
// so that set traps fire and components re-render.
|
|
2148
|
+
let current = writeTarget;
|
|
2149
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
2150
|
+
if (current[path[i]] === undefined || current[path[i]] === null) {
|
|
2151
|
+
current[path[i]] = {};
|
|
1702
2152
|
}
|
|
1703
|
-
|
|
1704
|
-
current[lastKey] = parsedValue;
|
|
1705
|
-
}
|
|
1706
|
-
catch (e) {
|
|
1707
|
-
console.error(`Error hydrating path ${path.join('.')}:`, e);
|
|
2153
|
+
current = current[path[i]];
|
|
1708
2154
|
}
|
|
2155
|
+
const lastKey = path[path.length - 1];
|
|
2156
|
+
current[lastKey] = parsedValue;
|
|
2157
|
+
// Mark this path as hydrated
|
|
2158
|
+
hydratedRoots.add(pathStr);
|
|
2159
|
+
}
|
|
2160
|
+
catch (e) {
|
|
2161
|
+
console.error(`Error hydrating path "${pathStr}":`, e);
|
|
1709
2162
|
}
|
|
1710
2163
|
}
|
|
1711
2164
|
}
|
|
1712
|
-
if (monitoringConfig.enabled) {
|
|
1713
|
-
console.log(`🔄 Hydrated ${
|
|
2165
|
+
if (monitoringConfig.enabled && sliceKeys.length > 0) {
|
|
2166
|
+
console.log(`🔄 Hydrated ${hydratedRoots.size} slices from storage`);
|
|
1714
2167
|
}
|
|
1715
2168
|
}
|
|
1716
2169
|
catch (e) {
|
|
1717
2170
|
console.error('Error hydrating state:', e);
|
|
1718
2171
|
return false;
|
|
1719
2172
|
}
|
|
2173
|
+
finally {
|
|
2174
|
+
isHydrating = false;
|
|
2175
|
+
// Enable persistence now that the store has been hydrated.
|
|
2176
|
+
// Any writes from this point forward are intentional user changes.
|
|
2177
|
+
hasHydrated = true;
|
|
2178
|
+
}
|
|
1720
2179
|
return true;
|
|
1721
2180
|
}
|
|
1722
2181
|
/**
|
|
1723
2182
|
* Load persistence configuration
|
|
1724
2183
|
*/
|
|
1725
2184
|
async function loadPersistenceConfig() {
|
|
1726
|
-
|
|
1727
|
-
return;
|
|
1728
|
-
const currentStorage = getStorage();
|
|
1729
|
-
if (!currentStorage)
|
|
1730
|
-
return;
|
|
2185
|
+
const adapter = getStorageAdapter();
|
|
1731
2186
|
try {
|
|
1732
|
-
const config = await
|
|
2187
|
+
const config = await adapter.getItem(PERSISTENCE_CONFIG_KEY);
|
|
1733
2188
|
if (config) {
|
|
1734
2189
|
const parsedConfig = JSON.parse(config);
|
|
1735
|
-
|
|
2190
|
+
// Only restore serializable config fields (not the adapter itself, not autoHydrate)
|
|
2191
|
+
const { storageAdapter: _a, autoHydrate: _b, ...restConfig } = parsedConfig;
|
|
2192
|
+
Object.assign(persistenceConfig, restConfig);
|
|
1736
2193
|
}
|
|
1737
2194
|
}
|
|
1738
2195
|
catch (e) {
|
|
@@ -1740,23 +2197,26 @@ async function loadPersistenceConfig() {
|
|
|
1740
2197
|
}
|
|
1741
2198
|
}
|
|
1742
2199
|
/**
|
|
1743
|
-
* Save persistence configuration
|
|
2200
|
+
* Save persistence configuration (excluding non-serializable fields)
|
|
1744
2201
|
*/
|
|
1745
2202
|
function savePersistenceConfig() {
|
|
1746
|
-
|
|
1747
|
-
return;
|
|
1748
|
-
const currentStorage = getStorage();
|
|
1749
|
-
if (!currentStorage)
|
|
1750
|
-
return;
|
|
2203
|
+
const adapter = getStorageAdapter();
|
|
1751
2204
|
try {
|
|
1752
|
-
|
|
2205
|
+
// Exclude the storageAdapter and autoHydrate from serialization
|
|
2206
|
+
const { storageAdapter: _a, autoHydrate: _b, ...serializableConfig } = persistenceConfig;
|
|
2207
|
+
const result = adapter.setItem(PERSISTENCE_CONFIG_KEY, JSON.stringify(serializableConfig));
|
|
2208
|
+
if (result && typeof result.catch === 'function') {
|
|
2209
|
+
result.catch(e => {
|
|
2210
|
+
console.error('Error saving persistence configuration:', e);
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
1753
2213
|
}
|
|
1754
2214
|
catch (e) {
|
|
1755
2215
|
console.error('Error saving persistence configuration:', e);
|
|
1756
2216
|
}
|
|
1757
2217
|
}
|
|
1758
2218
|
/**
|
|
1759
|
-
* Persistence API
|
|
2219
|
+
* Persistence API — the public interface for controlling persistence at runtime.
|
|
1760
2220
|
*/
|
|
1761
2221
|
const persistenceAPI = {
|
|
1762
2222
|
// Enable or disable persistence
|
|
@@ -1781,24 +2241,30 @@ const persistenceAPI = {
|
|
|
1781
2241
|
savePersistenceConfig();
|
|
1782
2242
|
}
|
|
1783
2243
|
// Remove from storage
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
2244
|
+
const adapter = getStorageAdapter();
|
|
2245
|
+
paths.forEach(path => {
|
|
2246
|
+
const result = adapter.removeItem(`${PERSISTED_STATE_KEY}_${path}`);
|
|
2247
|
+
if (result && typeof result.catch === 'function') {
|
|
2248
|
+
result.catch(e => {
|
|
2249
|
+
console.error(`Error removing persisted path "${path}":`, e);
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
});
|
|
1790
2253
|
},
|
|
1791
2254
|
// Add paths to blacklist
|
|
1792
2255
|
blacklistPaths: (paths) => {
|
|
1793
2256
|
persistenceConfig.blacklist = Array.from(new Set([...persistenceConfig.blacklist, ...paths]));
|
|
1794
2257
|
savePersistenceConfig();
|
|
1795
2258
|
// Remove from storage
|
|
1796
|
-
const
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2259
|
+
const adapter = getStorageAdapter();
|
|
2260
|
+
paths.forEach(path => {
|
|
2261
|
+
const result = adapter.removeItem(`${PERSISTED_STATE_KEY}_${path}`);
|
|
2262
|
+
if (result && typeof result.catch === 'function') {
|
|
2263
|
+
result.catch(e => {
|
|
2264
|
+
console.error(`Error removing blacklisted path "${path}":`, e);
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
});
|
|
1802
2268
|
},
|
|
1803
2269
|
// Remove paths from blacklist
|
|
1804
2270
|
unblacklistPaths: (paths) => {
|
|
@@ -1812,31 +2278,38 @@ const persistenceAPI = {
|
|
|
1812
2278
|
persistenceConfig.batchDelay = delay;
|
|
1813
2279
|
savePersistenceConfig();
|
|
1814
2280
|
},
|
|
1815
|
-
// Reset persistence
|
|
2281
|
+
// Reset persistence — clears all persisted data and resets config to defaults
|
|
1816
2282
|
reset: async () => {
|
|
1817
|
-
const
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2283
|
+
const adapter = getStorageAdapter();
|
|
2284
|
+
// Use clear() if available, otherwise iterate and remove
|
|
2285
|
+
if (adapter.clear) {
|
|
2286
|
+
await adapter.clear();
|
|
2287
|
+
}
|
|
2288
|
+
else {
|
|
2289
|
+
const keys = await adapter.keys();
|
|
2290
|
+
if (keys) {
|
|
2291
|
+
for (const key of keys) {
|
|
2292
|
+
if (key.startsWith(PERSISTED_STATE_KEY)) {
|
|
2293
|
+
await adapter.removeItem(key);
|
|
2294
|
+
}
|
|
1826
2295
|
}
|
|
1827
2296
|
}
|
|
1828
2297
|
}
|
|
1829
|
-
// Reset configuration to defaults
|
|
2298
|
+
// Reset configuration to defaults (preserve the current storageAdapter and autoHydrate)
|
|
2299
|
+
const currentAdapter = persistenceConfig.storageAdapter;
|
|
2300
|
+
const currentAutoHydrate = persistenceConfig.autoHydrate;
|
|
1830
2301
|
Object.assign(persistenceConfig, {
|
|
1831
2302
|
enabled: true,
|
|
1832
2303
|
paths: [],
|
|
1833
2304
|
blacklist: [],
|
|
1834
2305
|
batchDelay: 300,
|
|
2306
|
+
storageAdapter: currentAdapter,
|
|
2307
|
+
autoHydrate: currentAutoHydrate,
|
|
1835
2308
|
});
|
|
1836
2309
|
savePersistenceConfig();
|
|
1837
2310
|
},
|
|
1838
|
-
// Force persist
|
|
1839
|
-
persist: () =>
|
|
2311
|
+
// Force persist the entire store as one blob
|
|
2312
|
+
persist: () => persistEntireState(),
|
|
1840
2313
|
// Force persist current batch immediately
|
|
1841
2314
|
flushBatch: () => {
|
|
1842
2315
|
if (persistenceBatch.timeoutId) {
|
|
@@ -1845,26 +2318,41 @@ const persistenceAPI = {
|
|
|
1845
2318
|
}
|
|
1846
2319
|
processPersistenceBatch();
|
|
1847
2320
|
},
|
|
1848
|
-
|
|
2321
|
+
/**
|
|
2322
|
+
* Manually hydrate state from the backing storage adapter.
|
|
2323
|
+
*
|
|
2324
|
+
* Use this when `autoHydrate` is `false` and you want to control
|
|
2325
|
+
* exactly when persisted data is loaded (e.g., after a splash screen).
|
|
2326
|
+
*
|
|
2327
|
+
* @param store - Optional store to hydrate into. Defaults to the global store.
|
|
2328
|
+
*/
|
|
1849
2329
|
rehydrate: (store) => hydrateState(store),
|
|
1850
2330
|
// Get batch status
|
|
1851
2331
|
getBatchStatus: () => ({
|
|
1852
|
-
|
|
2332
|
+
pendingRoots: Array.from(persistenceBatch.roots),
|
|
1853
2333
|
isPersisting: persistenceBatch.isPersisting,
|
|
1854
|
-
batchSize: persistenceBatch.
|
|
2334
|
+
batchSize: persistenceBatch.roots.size,
|
|
1855
2335
|
}),
|
|
1856
2336
|
};
|
|
1857
2337
|
/**
|
|
1858
|
-
* Initialize persistence system
|
|
2338
|
+
* Initialize persistence system.
|
|
2339
|
+
*
|
|
2340
|
+
* @param willAutoHydrate - Whether hydrateState will be called automatically.
|
|
2341
|
+
* If false (autoHydrate disabled), persistence is enabled immediately since
|
|
2342
|
+
* the developer will manually call rehydrate() when ready.
|
|
1859
2343
|
*/
|
|
1860
|
-
function initializePersistence() {
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2344
|
+
function initializePersistence(willAutoHydrate = true) {
|
|
2345
|
+
loadPersistenceConfig();
|
|
2346
|
+
// If auto-hydration is disabled, the developer is in control.
|
|
2347
|
+
// Enable persistence immediately so state changes before manual rehydrate()
|
|
2348
|
+
// are captured. When they call rehydrate(), hasHydrated will be set again.
|
|
2349
|
+
if (!willAutoHydrate) {
|
|
2350
|
+
hasHydrated = true;
|
|
2351
|
+
}
|
|
2352
|
+
if (typeof window !== 'undefined' && monitoringConfig.enabled) {
|
|
2353
|
+
console.log('💾 Persistence system initialized');
|
|
1864
2354
|
}
|
|
1865
2355
|
}
|
|
1866
|
-
// Initialize automatically
|
|
1867
|
-
initializePersistence();
|
|
1868
2356
|
|
|
1869
2357
|
// Main exports for the library
|
|
1870
2358
|
// Global state
|
|
@@ -1888,26 +2376,55 @@ function configure(config) {
|
|
|
1888
2376
|
Object.assign(monitoringConfig, config.monitoring);
|
|
1889
2377
|
}
|
|
1890
2378
|
if (config.persistence) {
|
|
2379
|
+
// Register the storage adapter before applying config so it's ready for reads
|
|
2380
|
+
if (config.persistence.storageAdapter) {
|
|
2381
|
+
setStorageAdapter(config.persistence.storageAdapter);
|
|
2382
|
+
}
|
|
1891
2383
|
Object.assign(persistenceConfig, config.persistence);
|
|
1892
2384
|
}
|
|
1893
|
-
//
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2385
|
+
// ─── Build the store state ───────────────────────────────────────────
|
|
2386
|
+
// For synchronous adapters (localStorage, memory), merge persisted data
|
|
2387
|
+
// into the initial state BEFORE creating the proxy. This way the proxy
|
|
2388
|
+
// wraps the already-correct data and React renders persisted values on
|
|
2389
|
+
// the very first render — no flash of defaults, no re-renders.
|
|
2390
|
+
const originalDefaults = config.initialState;
|
|
2391
|
+
const shouldAutoHydrate = persistenceConfig.enabled && persistenceConfig.autoHydrate !== false;
|
|
2392
|
+
let syncMerged = false;
|
|
2393
|
+
if (shouldAutoHydrate && originalDefaults) {
|
|
2394
|
+
const merged = mergePersistedIntoState(originalDefaults);
|
|
2395
|
+
if (merged) {
|
|
2396
|
+
globalStore = merged;
|
|
2397
|
+
syncMerged = true;
|
|
2398
|
+
}
|
|
2399
|
+
else {
|
|
2400
|
+
globalStore = { ...originalDefaults };
|
|
1900
2401
|
}
|
|
1901
2402
|
}
|
|
2403
|
+
else if (originalDefaults) {
|
|
2404
|
+
globalStore = { ...originalDefaults };
|
|
2405
|
+
}
|
|
2406
|
+
// Store original defaults for $reset() (always the un-merged defaults)
|
|
2407
|
+
setInitialStoreState(originalDefaults || globalStore);
|
|
2408
|
+
if (typeof window !== 'undefined' && monitoringConfig.enabled) {
|
|
2409
|
+
console.log('🏪 Store configured with custom state');
|
|
2410
|
+
}
|
|
2411
|
+
// Store raw store ref (used for serialization during persistence)
|
|
2412
|
+
setGlobalStoreRef(globalStore);
|
|
1902
2413
|
// Create and cache the advanced proxy
|
|
1903
2414
|
globalStoreProxy = createAdvancedProxy(globalStore);
|
|
1904
|
-
//
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2415
|
+
// Store proxy ref (used for writes during hydration so set traps fire)
|
|
2416
|
+
setGlobalProxyRef(globalStoreProxy);
|
|
2417
|
+
// ─── Persistence setup ───────────────────────────────────────────────
|
|
2418
|
+
if (persistenceConfig.enabled) {
|
|
2419
|
+
initializePersistence(shouldAutoHydrate);
|
|
2420
|
+
// If sync merge didn't work (async adapter), fall back to async hydration
|
|
2421
|
+
if (shouldAutoHydrate && !syncMerged) {
|
|
2422
|
+
hydrateState(globalStoreProxy).then((success) => {
|
|
2423
|
+
if (success && typeof window !== 'undefined' && monitoringConfig.enabled) {
|
|
2424
|
+
console.log('🔄 State hydrated from persistence (async)');
|
|
2425
|
+
}
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
1911
2428
|
}
|
|
1912
2429
|
if (typeof window !== 'undefined' && monitoringConfig.enabled) {
|
|
1913
2430
|
console.log('🔧 Scope configured with advanced features');
|
|
@@ -2010,18 +2527,23 @@ function $get(path) {
|
|
|
2010
2527
|
}
|
|
2011
2528
|
return getProxy(path);
|
|
2012
2529
|
}
|
|
2530
|
+
// Register the persistence callback so state changes are automatically batched for persistence.
|
|
2531
|
+
// This connects the proxy notification system (listeners.ts) to the persistence system (advanced.ts)
|
|
2532
|
+
// without creating circular dependencies.
|
|
2533
|
+
setOnStateChangeCallback((path) => {
|
|
2534
|
+
if (persistenceConfig.enabled) {
|
|
2535
|
+
addToPersistenceBatch(path);
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2013
2538
|
// For debugging - matches original API
|
|
2014
2539
|
const debugInfo = {
|
|
2015
2540
|
getListenerCount: () => {
|
|
2016
|
-
const { getListenerCount } = require('./core/listeners');
|
|
2017
2541
|
return getListenerCount();
|
|
2018
2542
|
},
|
|
2019
2543
|
getPathCount: () => {
|
|
2020
|
-
const { pathListeners } = require('./core/listeners');
|
|
2021
2544
|
return pathListeners.size;
|
|
2022
2545
|
},
|
|
2023
2546
|
getActivePaths: () => {
|
|
2024
|
-
const { getActivePaths } = require('./core/listeners');
|
|
2025
2547
|
return getActivePaths();
|
|
2026
2548
|
}
|
|
2027
2549
|
};
|
|
@@ -2029,14 +2551,9 @@ const debugInfo = {
|
|
|
2029
2551
|
const rawStore = globalStore;
|
|
2030
2552
|
// Initialize library with enhanced features
|
|
2031
2553
|
if (typeof window !== 'undefined') {
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
// Initialize auto-hydration if persistence is enabled
|
|
2036
|
-
if (persistenceConfig.enabled) {
|
|
2037
|
-
setTimeout(() => {
|
|
2038
|
-
hydrateState(globalStore);
|
|
2039
|
-
}, 100);
|
|
2554
|
+
if (monitoringConfig.enabled) {
|
|
2555
|
+
console.log('🎯 Scope State initialized — ready for reactive state management');
|
|
2556
|
+
console.log('💡 Tip: Call configure() with your initialState for full TypeScript support');
|
|
2040
2557
|
}
|
|
2041
2558
|
}
|
|
2042
2559
|
|
|
@@ -2048,14 +2565,21 @@ exports.activate = activate;
|
|
|
2048
2565
|
exports.clearProxyCache = clearProxyCache;
|
|
2049
2566
|
exports.configure = configure;
|
|
2050
2567
|
exports.createAdvancedProxy = createAdvancedProxy;
|
|
2568
|
+
exports.createCachedAdapter = createCachedAdapter;
|
|
2569
|
+
exports.createLocalStorageAdapter = createLocalStorageAdapter;
|
|
2570
|
+
exports.createMemoryAdapter = createMemoryAdapter;
|
|
2051
2571
|
exports.createReactive = createReactive;
|
|
2052
2572
|
exports.debugInfo = debugInfo;
|
|
2053
2573
|
exports.getConfig = getConfig;
|
|
2574
|
+
exports.getDefaultAdapter = getDefaultAdapter;
|
|
2054
2575
|
exports.getProxy = getProxy;
|
|
2055
2576
|
exports.getProxyCacheStats = getProxyCacheStats;
|
|
2577
|
+
exports.getStorageAdapter = getStorageAdapter;
|
|
2056
2578
|
exports.getStore = getStore;
|
|
2579
|
+
exports.hydrateState = hydrateState;
|
|
2057
2580
|
exports.initializeStore = initializeStore;
|
|
2058
2581
|
exports.isReactive = isReactive;
|
|
2582
|
+
exports.mergePersistedIntoState = mergePersistedIntoState;
|
|
2059
2583
|
exports.monitorAPI = monitorAPI;
|
|
2060
2584
|
exports.optimizeMemoryUsage = optimizeMemoryUsage;
|
|
2061
2585
|
exports.pathUsageStats = pathUsageStats;
|
|
@@ -2067,6 +2591,7 @@ exports.resetConfig = resetConfig;
|
|
|
2067
2591
|
exports.resetStore = resetStore;
|
|
2068
2592
|
exports.selectorPaths = selectorPaths;
|
|
2069
2593
|
exports.setInitialStoreState = setInitialStoreState;
|
|
2594
|
+
exports.setStorageAdapter = setStorageAdapter;
|
|
2070
2595
|
exports.trackDependenciesAdvanced = trackDependenciesAdvanced;
|
|
2071
2596
|
exports.useLocal = useLocal;
|
|
2072
2597
|
exports.useScope = useScope;
|