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 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
- if (typeof require !== 'undefined') {
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
- * Initialize storage for persistence
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 initializeStorage() {
1464
- try {
1465
- if (typeof window !== 'undefined') {
1466
- storage = localforage.createInstance({
1467
- name: 'SCOPE_STATE',
1468
- description: 'Scope state management storage'
1469
- });
1470
- // Add memory driver as fallback
1471
- localforage.defineDriver(memoryDriver);
1472
- localforage.setDriver([
1473
- localforage.INDEXEDDB,
1474
- localforage.LOCALSTORAGE,
1475
- localforage.WEBSQL,
1476
- memoryDriver._driver
1477
- ]);
1478
- console.log('💾 Storage initialized successfully');
1479
- }
1480
- }
1481
- catch (error) {
1482
- console.error('❌ Error creating storage instance:', error);
1483
- storage = null;
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 instance
1710
+ * Get the current storage adapter, lazily initializing the default if needed.
1488
1711
  */
1489
- function getStorage() {
1490
- return storage;
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
- // Initialize storage by default
1493
- if (typeof window !== 'undefined') {
1494
- initializeStorage();
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
- // Batch persistence state
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
- paths: new Set(),
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
- * Process all paths in the batch
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.paths.size === 0) {
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.paths.size} paths`);
1886
+ startTime = logTimestamp(`💾 Batch persisting ${persistenceBatch.roots.size} slices`);
1535
1887
  }
1536
1888
  try {
1537
- // Convert set to array of path arrays
1538
- const pathArrays = Array.from(persistenceBatch.paths).map(path => path.split('.'));
1539
- // Persist entire state if we're persisting many paths
1540
- if (pathArrays.length > 10) {
1541
- persistState();
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 paths were added during processing, schedule another batch
1558
- if (persistenceBatch.paths.size > 0) {
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
- * Schedule batch persistence with debounce
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 schedulePersistenceBatch() {
1570
- if (persistenceBatch.timeoutId) {
1571
- clearTimeout(persistenceBatch.timeoutId);
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
- * Add a path to the persistence batch
1952
+ * Persist the entire store as one entry (used by persistenceAPI.persist).
1580
1953
  */
1581
- function addToPersistenceBatch(path) {
1582
- if (!persistenceConfig.enabled)
1954
+ function persistEntireState() {
1955
+ if (!persistenceConfig.enabled || !globalStoreRef)
1583
1956
  return;
1584
- const pathKey = path.join('.');
1585
- if (shouldPersistPath(pathKey)) {
1586
- persistenceBatch.paths.add(pathKey);
1587
- schedulePersistenceBatch();
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
- * Function to determine if a path should be persisted
1972
+ * Check if a value is a Promise/thenable.
1592
1973
  */
1593
- function shouldPersistPath(path) {
1594
- if (!persistenceConfig.enabled)
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
- * Save state to storage
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 persistState(path = []) {
1993
+ function mergePersistedIntoState(initialState) {
1994
+ // No localStorage on the server
1610
1995
  if (typeof window === 'undefined')
1611
- return;
1612
- if (!persistenceConfig.enabled)
1613
- return;
1614
- const currentStorage = getStorage();
1615
- if (!currentStorage)
1616
- return;
1617
- const pathKey = path.join('.');
1618
- if (path.length === 0) {
1619
- // We need access to the store to persist entire state
1620
- // For now, we'll skip this and only support path-specific persistence
1621
- console.warn('Cannot persist entire state without store reference');
1622
- return;
1623
- }
1624
- if (shouldPersistPath(pathKey)) {
1625
- // For individual path persistence, we'd need access to the store
1626
- // This will be implemented when integrating with the main store
1627
- try {
1628
- currentStorage.setItem(`${PERSISTED_STATE_KEY}_${pathKey}`, JSON.stringify({}));
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
- catch (e) {
1631
- console.error(`Error persisting path ${pathKey}:`, e);
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
- if (typeof window === 'undefined')
1640
- return false;
1641
- if (!store) {
1642
- console.warn('Cannot hydrate state without store reference');
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 currentStorage = getStorage();
1646
- if (!currentStorage)
1647
- return false;
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 currentStorage.getItem(PERSISTED_STATE_KEY);
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 store) {
1656
- store[key] = parsedState[key];
2112
+ if (key in writeTarget) {
2113
+ writeTarget[key] = parsedState[key];
1657
2114
  }
1658
2115
  });
1659
2116
  if (monitoringConfig.enabled) {
1660
- console.log('🔄 State hydrated from storage');
2117
+ console.log('🔄 Full state hydrated from storage');
1661
2118
  }
1662
2119
  }
1663
- // Then try to load individual persisted paths
1664
- const keys = await currentStorage.keys();
1665
- if (!keys)
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
- for (const key of keys) {
1668
- if (key.startsWith(`${PERSISTED_STATE_KEY}_`)) {
1669
- const path = key.replace(`${PERSISTED_STATE_KEY}_`, '').split('.');
1670
- const value = await currentStorage.getItem(key);
1671
- if (value) {
1672
- try {
1673
- const parsedValue = JSON.parse(value);
1674
- // Set the value in the store
1675
- let current = store;
1676
- for (let i = 0; i < path.length - 1; i++) {
1677
- if (!(path[i] in current)) {
1678
- current[path[i]] = {};
1679
- }
1680
- current = current[path[i]];
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
- const lastKey = path[path.length - 1];
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 ${keys.length} individual paths`);
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
- if (typeof window === 'undefined')
1706
- return;
1707
- const currentStorage = getStorage();
1708
- if (!currentStorage)
1709
- return;
2183
+ const adapter = getStorageAdapter();
1710
2184
  try {
1711
- const config = await currentStorage.getItem(PERSISTENCE_CONFIG_KEY);
2185
+ const config = await adapter.getItem(PERSISTENCE_CONFIG_KEY);
1712
2186
  if (config) {
1713
2187
  const parsedConfig = JSON.parse(config);
1714
- Object.assign(persistenceConfig, parsedConfig);
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
- if (typeof window === 'undefined')
1726
- return;
1727
- const currentStorage = getStorage();
1728
- if (!currentStorage)
1729
- return;
2201
+ const adapter = getStorageAdapter();
1730
2202
  try {
1731
- currentStorage.setItem(PERSISTENCE_CONFIG_KEY, JSON.stringify(persistenceConfig));
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 - matches the original implementation
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 currentStorage = getStorage();
1764
- if (currentStorage) {
1765
- paths.forEach(path => {
1766
- currentStorage.removeItem(`${PERSISTED_STATE_KEY}_${path}`);
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 currentStorage = getStorage();
1776
- if (currentStorage) {
1777
- paths.forEach(path => {
1778
- currentStorage.removeItem(`${PERSISTED_STATE_KEY}_${path}`);
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 currentStorage = getStorage();
1797
- if (!currentStorage)
1798
- return;
1799
- // Clear all persisted state
1800
- const keys = await currentStorage.keys();
1801
- if (keys) {
1802
- for (const key of keys) {
1803
- if (key.startsWith(PERSISTED_STATE_KEY)) {
1804
- await currentStorage.removeItem(key);
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 current state
1818
- persist: () => persistState(),
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
- // Force rehydrate state
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
- pendingPaths: Array.from(persistenceBatch.paths),
2330
+ pendingRoots: Array.from(persistenceBatch.roots),
1832
2331
  isPersisting: persistenceBatch.isPersisting,
1833
- batchSize: persistenceBatch.paths.size,
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
- if (typeof window !== 'undefined') {
1841
- loadPersistenceConfig();
1842
- console.log('💾 Advanced persistence system initialized');
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
- // Update the global store
1873
- if (config.initialState) {
1874
- globalStore = { ...config.initialState };
1875
- // Store initial state for reset functionality
1876
- setInitialStoreState(globalStore);
1877
- if (typeof window !== 'undefined' && monitoringConfig.enabled) {
1878
- console.log('🏪 Store configured with custom state');
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
- // Hydrate persisted state if persistence is enabled
1884
- if (typeof window !== 'undefined' && persistenceConfig.enabled) {
1885
- hydrateState(globalStore).then((success) => {
1886
- if (success && monitoringConfig.enabled) {
1887
- console.log('🔄 State hydrated from persistence');
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
- console.log('🎯 Scope State initialized with advanced features - ready for reactive state management');
2012
- console.log('💡 Tip: Call configure() with your initialState for full TypeScript support');
2013
- console.log('🔧 Advanced features: WeakMap caching, leak detection, batch persistence, memory management');
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