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.
Files changed (46) hide show
  1. package/dist/{src/config → config}/index.d.ts +2 -0
  2. package/dist/config/index.d.ts.map +1 -0
  3. package/dist/{src/core → core}/listeners.d.ts +5 -0
  4. package/dist/core/listeners.d.ts.map +1 -0
  5. package/dist/core/monitoring.d.ts.map +1 -0
  6. package/dist/core/proxy.d.ts.map +1 -0
  7. package/dist/core/store.d.ts.map +1 -0
  8. package/dist/core/tracking.d.ts.map +1 -0
  9. package/dist/hooks/useLocal.d.ts.map +1 -0
  10. package/dist/hooks/useScope.d.ts.map +1 -0
  11. package/dist/index.d.ts +7 -5
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.esm.js +761 -225
  14. package/dist/index.esm.js.map +1 -0
  15. package/dist/index.js +768 -243
  16. package/dist/index.js.map +1 -0
  17. package/dist/persistence/adapters.d.ts +73 -0
  18. package/dist/persistence/adapters.d.ts.map +1 -0
  19. package/dist/persistence/advanced.d.ts +100 -0
  20. package/dist/persistence/advanced.d.ts.map +1 -0
  21. package/dist/persistence/storage.d.ts +27 -0
  22. package/dist/persistence/storage.d.ts.map +1 -0
  23. package/dist/{src/types → types}/index.d.ts +107 -2
  24. package/dist/types/index.d.ts.map +1 -0
  25. package/package.json +2 -7
  26. package/dist/src/config/index.d.ts.map +0 -1
  27. package/dist/src/core/listeners.d.ts.map +0 -1
  28. package/dist/src/core/monitoring.d.ts.map +0 -1
  29. package/dist/src/core/proxy.d.ts.map +0 -1
  30. package/dist/src/core/store.d.ts.map +0 -1
  31. package/dist/src/core/tracking.d.ts.map +0 -1
  32. package/dist/src/hooks/useLocal.d.ts.map +0 -1
  33. package/dist/src/hooks/useScope.d.ts.map +0 -1
  34. package/dist/src/index.d.ts +0 -33
  35. package/dist/src/index.d.ts.map +0 -1
  36. package/dist/src/persistence/advanced.d.ts +0 -47
  37. package/dist/src/persistence/advanced.d.ts.map +0 -1
  38. package/dist/src/persistence/storage.d.ts +0 -14
  39. package/dist/src/persistence/storage.d.ts.map +0 -1
  40. package/dist/src/types/index.d.ts.map +0 -1
  41. /package/dist/{src/core → core}/monitoring.d.ts +0 -0
  42. /package/dist/{src/core → core}/proxy.d.ts +0 -0
  43. /package/dist/{src/core → core}/store.d.ts +0 -0
  44. /package/dist/{src/core → core}/tracking.d.ts +0 -0
  45. /package/dist/{src/hooks → hooks}/useLocal.d.ts +0 -0
  46. /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
- if (typeof require !== 'undefined') {
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
- * Initialize storage for persistence
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 initializeStorage() {
1485
- try {
1486
- if (typeof window !== 'undefined') {
1487
- storage = localforage.createInstance({
1488
- name: 'SCOPE_STATE',
1489
- description: 'Scope state management storage'
1490
- });
1491
- // Add memory driver as fallback
1492
- localforage.defineDriver(memoryDriver__namespace);
1493
- localforage.setDriver([
1494
- localforage.INDEXEDDB,
1495
- localforage.LOCALSTORAGE,
1496
- localforage.WEBSQL,
1497
- memoryDriver__namespace._driver
1498
- ]);
1499
- console.log('💾 Storage initialized successfully');
1500
- }
1501
- }
1502
- catch (error) {
1503
- console.error('❌ Error creating storage instance:', error);
1504
- storage = null;
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 instance
1712
+ * Get the current storage adapter, lazily initializing the default if needed.
1509
1713
  */
1510
- function getStorage() {
1511
- return storage;
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
- // Initialize storage by default
1514
- if (typeof window !== 'undefined') {
1515
- initializeStorage();
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
- // Batch persistence state
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
- paths: new Set(),
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
- * Process all paths in the batch
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.paths.size === 0) {
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.paths.size} paths`);
1888
+ startTime = logTimestamp(`💾 Batch persisting ${persistenceBatch.roots.size} slices`);
1556
1889
  }
1557
1890
  try {
1558
- // Convert set to array of path arrays
1559
- const pathArrays = Array.from(persistenceBatch.paths).map(path => path.split('.'));
1560
- // Persist entire state if we're persisting many paths
1561
- if (pathArrays.length > 10) {
1562
- persistState();
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 paths were added during processing, schedule another batch
1579
- if (persistenceBatch.paths.size > 0) {
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
- * Schedule batch persistence with debounce
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 schedulePersistenceBatch() {
1591
- if (persistenceBatch.timeoutId) {
1592
- clearTimeout(persistenceBatch.timeoutId);
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
- * Add a path to the persistence batch
1954
+ * Persist the entire store as one entry (used by persistenceAPI.persist).
1601
1955
  */
1602
- function addToPersistenceBatch(path) {
1603
- if (!persistenceConfig.enabled)
1956
+ function persistEntireState() {
1957
+ if (!persistenceConfig.enabled || !globalStoreRef)
1604
1958
  return;
1605
- const pathKey = path.join('.');
1606
- if (shouldPersistPath(pathKey)) {
1607
- persistenceBatch.paths.add(pathKey);
1608
- schedulePersistenceBatch();
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
- * Function to determine if a path should be persisted
1974
+ * Check if a value is a Promise/thenable.
1613
1975
  */
1614
- function shouldPersistPath(path) {
1615
- if (!persistenceConfig.enabled)
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
- * Save state to storage
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 persistState(path = []) {
1995
+ function mergePersistedIntoState(initialState) {
1996
+ // No localStorage on the server
1631
1997
  if (typeof window === 'undefined')
1632
- return;
1633
- if (!persistenceConfig.enabled)
1634
- return;
1635
- const currentStorage = getStorage();
1636
- if (!currentStorage)
1637
- return;
1638
- const pathKey = path.join('.');
1639
- if (path.length === 0) {
1640
- // We need access to the store to persist entire state
1641
- // For now, we'll skip this and only support path-specific persistence
1642
- console.warn('Cannot persist entire state without store reference');
1643
- return;
1644
- }
1645
- if (shouldPersistPath(pathKey)) {
1646
- // For individual path persistence, we'd need access to the store
1647
- // This will be implemented when integrating with the main store
1648
- try {
1649
- currentStorage.setItem(`${PERSISTED_STATE_KEY}_${pathKey}`, JSON.stringify({}));
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
- catch (e) {
1652
- console.error(`Error persisting path ${pathKey}:`, e);
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
- if (typeof window === 'undefined')
1661
- return false;
1662
- if (!store) {
1663
- console.warn('Cannot hydrate state without store reference');
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 currentStorage = getStorage();
1667
- if (!currentStorage)
1668
- return false;
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 currentStorage.getItem(PERSISTED_STATE_KEY);
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 store) {
1677
- store[key] = parsedState[key];
2114
+ if (key in writeTarget) {
2115
+ writeTarget[key] = parsedState[key];
1678
2116
  }
1679
2117
  });
1680
2118
  if (monitoringConfig.enabled) {
1681
- console.log('🔄 State hydrated from storage');
2119
+ console.log('🔄 Full state hydrated from storage');
1682
2120
  }
1683
2121
  }
1684
- // Then try to load individual persisted paths
1685
- const keys = await currentStorage.keys();
1686
- if (!keys)
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
- for (const key of keys) {
1689
- if (key.startsWith(`${PERSISTED_STATE_KEY}_`)) {
1690
- const path = key.replace(`${PERSISTED_STATE_KEY}_`, '').split('.');
1691
- const value = await currentStorage.getItem(key);
1692
- if (value) {
1693
- try {
1694
- const parsedValue = JSON.parse(value);
1695
- // Set the value in the store
1696
- let current = store;
1697
- for (let i = 0; i < path.length - 1; i++) {
1698
- if (!(path[i] in current)) {
1699
- current[path[i]] = {};
1700
- }
1701
- current = current[path[i]];
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
- const lastKey = path[path.length - 1];
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 ${keys.length} individual paths`);
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
- if (typeof window === 'undefined')
1727
- return;
1728
- const currentStorage = getStorage();
1729
- if (!currentStorage)
1730
- return;
2185
+ const adapter = getStorageAdapter();
1731
2186
  try {
1732
- const config = await currentStorage.getItem(PERSISTENCE_CONFIG_KEY);
2187
+ const config = await adapter.getItem(PERSISTENCE_CONFIG_KEY);
1733
2188
  if (config) {
1734
2189
  const parsedConfig = JSON.parse(config);
1735
- Object.assign(persistenceConfig, parsedConfig);
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
- if (typeof window === 'undefined')
1747
- return;
1748
- const currentStorage = getStorage();
1749
- if (!currentStorage)
1750
- return;
2203
+ const adapter = getStorageAdapter();
1751
2204
  try {
1752
- currentStorage.setItem(PERSISTENCE_CONFIG_KEY, JSON.stringify(persistenceConfig));
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 - matches the original implementation
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 currentStorage = getStorage();
1785
- if (currentStorage) {
1786
- paths.forEach(path => {
1787
- currentStorage.removeItem(`${PERSISTED_STATE_KEY}_${path}`);
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 currentStorage = getStorage();
1797
- if (currentStorage) {
1798
- paths.forEach(path => {
1799
- currentStorage.removeItem(`${PERSISTED_STATE_KEY}_${path}`);
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 currentStorage = getStorage();
1818
- if (!currentStorage)
1819
- return;
1820
- // Clear all persisted state
1821
- const keys = await currentStorage.keys();
1822
- if (keys) {
1823
- for (const key of keys) {
1824
- if (key.startsWith(PERSISTED_STATE_KEY)) {
1825
- await currentStorage.removeItem(key);
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 current state
1839
- persist: () => persistState(),
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
- // Force rehydrate state
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
- pendingPaths: Array.from(persistenceBatch.paths),
2332
+ pendingRoots: Array.from(persistenceBatch.roots),
1853
2333
  isPersisting: persistenceBatch.isPersisting,
1854
- batchSize: persistenceBatch.paths.size,
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
- if (typeof window !== 'undefined') {
1862
- loadPersistenceConfig();
1863
- console.log('💾 Advanced persistence system initialized');
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
- // Update the global store
1894
- if (config.initialState) {
1895
- globalStore = { ...config.initialState };
1896
- // Store initial state for reset functionality
1897
- setInitialStoreState(globalStore);
1898
- if (typeof window !== 'undefined' && monitoringConfig.enabled) {
1899
- console.log('🏪 Store configured with custom state');
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
- // Hydrate persisted state if persistence is enabled
1905
- if (typeof window !== 'undefined' && persistenceConfig.enabled) {
1906
- hydrateState(globalStore).then((success) => {
1907
- if (success && monitoringConfig.enabled) {
1908
- console.log('🔄 State hydrated from persistence');
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
- console.log('🎯 Scope State initialized with advanced features - ready for reactive state management');
2033
- console.log('💡 Tip: Call configure() with your initialState for full TypeScript support');
2034
- console.log('🔧 Advanced features: WeakMap caching, leak detection, batch persistence, memory management');
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;