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