scope-state 0.1.4 → 0.1.6

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.js CHANGED
@@ -353,6 +353,7 @@ function logSubscriptionRemoved(path) {
353
353
 
354
354
  // Track proxy to path mapping for type-safe activation
355
355
  const proxyPathMap = new WeakMap();
356
+ const proxyTargetMap = new WeakMap();
356
357
  // Store a deep clone of the initial store state for use with $reset
357
358
  let initialStoreState$1 = {};
358
359
  // Path usage tracking
@@ -570,6 +571,11 @@ function createAdvancedProxy(target, path = [], depth = 0) {
570
571
  if (typeof prop === 'symbol') {
571
572
  return Reflect.get(obj, prop, receiver);
572
573
  }
574
+ const value = Reflect.get(obj, prop, receiver);
575
+ // Functions are commands/helpers, not reactive data dependencies.
576
+ if (typeof value === 'function') {
577
+ return value;
578
+ }
573
579
  const currentPropPath = [...path, prop.toString()];
574
580
  const propPathKey = currentPropPath.join('.');
575
581
  // Track path access during dependency tracking
@@ -580,9 +586,12 @@ function createAdvancedProxy(target, path = [], depth = 0) {
580
586
  }
581
587
  // Track path access for dependency tracking (inline to avoid circular dependency)
582
588
  trackPathAccess(currentPropPath);
583
- const value = obj[prop];
584
589
  // For objects, create proxies for nested values
585
590
  if (value && typeof value === 'object' && path.length < proxyConfig.maxDepth) {
591
+ // If value is already a proxy, return it directly to prevent double-wrapping
592
+ if (proxyPathMap.has(value)) {
593
+ return value;
594
+ }
586
595
  const shouldProxy = !proxyConfig.lazyProxyDeepObjects ||
587
596
  pathUsageStats.accessedPaths.has(propPathKey) ||
588
597
  pathUsageStats.modifiedPaths.has(propPathKey) ||
@@ -594,27 +603,16 @@ function createAdvancedProxy(target, path = [], depth = 0) {
594
603
  }
595
604
  return value;
596
605
  },
597
- set(obj, prop, value, receiver) {
606
+ set(obj, prop, value) {
598
607
  if (typeof prop === 'symbol') {
599
- return Reflect.set(obj, prop, value, receiver);
608
+ return Reflect.set(obj, prop, value);
600
609
  }
601
610
  const propPath = [...path, prop.toString()];
602
611
  const propPathKey = propPath.join('.');
603
- // Track modification
604
612
  if (proxyConfig.trackPathUsage) {
605
613
  pathUsageStats.modifiedPaths.add(propPathKey);
606
614
  }
607
- // Handle object assignment with proxying
608
- if (value && typeof value === 'object' && !proxyCache.has(value)) {
609
- const newPath = [...path, prop.toString()];
610
- const proxiedValue = createAdvancedProxy(value, newPath, 0);
611
- const result = Reflect.set(obj, prop, proxiedValue, receiver);
612
- // Notify the specific property path and all parent/child paths
613
- notifyListeners(propPath);
614
- return result;
615
- }
616
- const result = Reflect.set(obj, prop, value, receiver);
617
- // Notify the specific property path (this will also notify parent/child paths)
615
+ const result = Reflect.set(obj, prop, value);
618
616
  notifyListeners(propPath);
619
617
  return result;
620
618
  },
@@ -640,6 +638,7 @@ function createAdvancedProxy(target, path = [], depth = 0) {
640
638
  proxyCacheLRU.add(target, proxy);
641
639
  // Track the path for this proxy
642
640
  proxyPathMap.set(proxy, [...path]);
641
+ proxyTargetMap.set(proxy, target);
643
642
  return proxy;
644
643
  }
645
644
  /**
@@ -656,16 +655,7 @@ function addObjectMethods(target, path) {
656
655
  const propPath = [...currentPath, key].join('.');
657
656
  pathUsageStats.modifiedPaths.add(propPath);
658
657
  }
659
- const newValue = newProps[key];
660
- if (newValue && typeof newValue === 'object' && !proxyCache.has(newValue)) {
661
- const newPath = [...currentPath, key];
662
- // Use Reflect.set with this proxy as the receiver to trigger the set handler
663
- Reflect.set(this, key, createAdvancedProxy(newValue, newPath, 0), this);
664
- }
665
- else {
666
- // Use Reflect.set with this proxy as the receiver to trigger the set handler
667
- Reflect.set(this, key, newValue, this);
668
- }
658
+ Reflect.set(this, key, newProps[key], this);
669
659
  });
670
660
  return this;
671
661
  },
@@ -676,28 +666,17 @@ function addObjectMethods(target, path) {
676
666
  methodsToDefine.$set = {
677
667
  value: function (newProps) {
678
668
  const currentPath = proxyPathMap.get(this) || path;
679
- // Clear existing properties
680
669
  Object.keys(this).forEach(key => {
681
670
  if (typeof this[key] !== 'function') {
682
671
  Reflect.deleteProperty(this, key);
683
672
  }
684
673
  });
685
- // Set new properties
686
674
  Object.keys(newProps || {}).forEach(key => {
687
675
  if (proxyConfig.trackPathUsage) {
688
676
  const propPath = [...currentPath, key].join('.');
689
677
  pathUsageStats.modifiedPaths.add(propPath);
690
678
  }
691
- const newValue = newProps[key];
692
- if (newValue && typeof newValue === 'object' && !proxyCache.has(newValue)) {
693
- const newPath = [...currentPath, key];
694
- // Use Reflect.set with this proxy as the receiver to trigger the set handler
695
- Reflect.set(this, key, createAdvancedProxy(newValue, newPath, 0), this);
696
- }
697
- else {
698
- // Use Reflect.set with this proxy as the receiver to trigger the set handler
699
- Reflect.set(this, key, newValue, this);
700
- }
679
+ Reflect.set(this, key, newProps[key], this);
701
680
  });
702
681
  return this;
703
682
  },
@@ -727,12 +706,7 @@ function addObjectMethods(target, path) {
727
706
  pathUsageStats.modifiedPaths.add(propPath);
728
707
  }
729
708
  const currentValue = this[key];
730
- let newValue = updater(currentValue);
731
- if (newValue && typeof newValue === 'object' && !proxyCache.has(newValue)) {
732
- const newPath = [...currentPath, key];
733
- newValue = createAdvancedProxy(newValue, newPath, 0);
734
- }
735
- // Use Reflect.set with this proxy as the receiver to trigger the set handler
709
+ const newValue = updater(currentValue);
736
710
  Reflect.set(this, key, newValue, this);
737
711
  return this;
738
712
  },
@@ -790,20 +764,13 @@ function addObjectMethods(target, path) {
790
764
  * Add custom methods to array targets
791
765
  */
792
766
  function addArrayMethods(target, path) {
793
- const originalPush = target.push;
794
- const originalSplice = target.splice;
767
+ const originalPush = Array.prototype.push;
768
+ const originalSplice = Array.prototype.splice;
795
769
  // Override push
796
770
  Object.defineProperty(target, 'push', {
797
771
  value: function (...items) {
798
772
  const currentPath = proxyPathMap.get(this) || path;
799
- const processedItems = items.map(item => {
800
- if (item && typeof item === 'object' && !proxyCache.has(item)) {
801
- const itemPath = [...currentPath, '_item'];
802
- return createAdvancedProxy(item, itemPath, 0);
803
- }
804
- return item;
805
- });
806
- const result = originalPush.apply(this, processedItems);
773
+ const result = originalPush.apply(this, items);
807
774
  if (currentPath.length > 0) {
808
775
  if (proxyConfig.trackPathUsage) {
809
776
  pathUsageStats.modifiedPaths.add(currentPath.join('.'));
@@ -820,22 +787,13 @@ function addArrayMethods(target, path) {
820
787
  value: function (start, deleteCount, ...items) {
821
788
  const currentPath = proxyPathMap.get(this) || path;
822
789
  const arrayLength = this.length;
823
- const processedItems = items.map((item, index) => {
824
- if (item && typeof item === 'object' && !proxyCache.has(item)) {
825
- const itemPath = [...currentPath, (start + index).toString()];
826
- return createAdvancedProxy(item, itemPath, 0);
827
- }
828
- return item;
829
- });
830
790
  const actualDeleteCount = deleteCount === undefined ? (arrayLength - start) : deleteCount;
831
- const result = originalSplice.apply(this, [start, actualDeleteCount, ...processedItems]);
791
+ const result = originalSplice.apply(this, [start, actualDeleteCount, ...items]);
832
792
  if (currentPath.length > 0) {
833
793
  if (proxyConfig.trackPathUsage) {
834
794
  pathUsageStats.modifiedPaths.add(currentPath.join('.'));
835
795
  }
836
- // Notify the array itself
837
796
  notifyListeners(currentPath);
838
- // Also notify about each index that was affected
839
797
  for (let i = start; i < arrayLength; i++) {
840
798
  const indexPath = [...currentPath, i.toString()];
841
799
  notifyListeners(indexPath);
@@ -856,14 +814,7 @@ function addArrayMethods(target, path) {
856
814
  }
857
815
  const currentPath = proxyPathMap.get(this) || path;
858
816
  this.length = 0;
859
- const processedItems = newArray.map((item, index) => {
860
- if (item && typeof item === 'object' && !proxyCache.has(item)) {
861
- const itemPath = [...currentPath, index.toString()];
862
- return createAdvancedProxy(item, itemPath, 0);
863
- }
864
- return item;
865
- });
866
- originalPush.apply(this, processedItems);
817
+ originalPush.apply(this, newArray);
867
818
  if (currentPath.length > 0) {
868
819
  if (proxyConfig.trackPathUsage) {
869
820
  pathUsageStats.modifiedPaths.add(currentPath.join('.'));
@@ -895,15 +846,8 @@ function addArrayMethods(target, path) {
895
846
  initialValue = [];
896
847
  }
897
848
  this.length = 0;
898
- const processedItems = initialValue.map((item, index) => {
899
- if (item && typeof item === 'object' && !proxyCache.has(item)) {
900
- const itemPath = [...currentPath, index.toString()];
901
- return createAdvancedProxy(item, itemPath, 0);
902
- }
903
- return item;
904
- });
905
- if (processedItems.length > 0) {
906
- originalPush.apply(this, processedItems);
849
+ if (initialValue.length > 0) {
850
+ originalPush.apply(this, JSON.parse(JSON.stringify(initialValue)));
907
851
  }
908
852
  if (currentPath.length > 0) {
909
853
  notifyListeners(currentPath);
@@ -1020,6 +964,52 @@ function trackPathAccess(path) {
1020
964
  }
1021
965
  }
1022
966
 
967
+ function isPlainObject(value) {
968
+ const prototype = Object.getPrototypeOf(value);
969
+ return prototype === Object.prototype || prototype === null;
970
+ }
971
+ function unwrapProxyTarget(value) {
972
+ if (value && typeof value === 'object' && proxyTargetMap.has(value)) {
973
+ return proxyTargetMap.get(value);
974
+ }
975
+ return value;
976
+ }
977
+ /**
978
+ * Create a read-only snapshot suitable for rendering.
979
+ *
980
+ * Snapshots are plain arrays/objects with no proxy methods, which makes them
981
+ * safe for React Compiler memoization while keeping `$` as the mutable API.
982
+ */
983
+ function createReadonlySnapshot(value, seen = new WeakMap()) {
984
+ if (value === null || typeof value !== 'object') {
985
+ return value;
986
+ }
987
+ const source = unwrapProxyTarget(value);
988
+ if (seen.has(source)) {
989
+ return seen.get(source);
990
+ }
991
+ if (Array.isArray(source)) {
992
+ const snapshot = [];
993
+ seen.set(source, snapshot);
994
+ source.forEach(item => {
995
+ snapshot.push(createReadonlySnapshot(item, seen));
996
+ });
997
+ return snapshot;
998
+ }
999
+ if (!isPlainObject(source)) {
1000
+ return source;
1001
+ }
1002
+ const snapshot = {};
1003
+ seen.set(source, snapshot);
1004
+ Object.keys(source).forEach(key => {
1005
+ const propertyValue = source[key];
1006
+ if (typeof propertyValue !== 'function') {
1007
+ snapshot[key] = createReadonlySnapshot(propertyValue, seen);
1008
+ }
1009
+ });
1010
+ return snapshot;
1011
+ }
1012
+
1023
1013
  /**
1024
1014
  * Hook to subscribe to the global store and re-render when specific data changes.
1025
1015
  *
@@ -1027,15 +1017,16 @@ function trackPathAccess(path) {
1027
1017
  * re-renders the component when those specific paths change. This provides
1028
1018
  * fine-grained reactivity without unnecessary renders.
1029
1019
  *
1030
- * For objects, the returned value includes custom methods ($merge, $set, $delete, $update)
1031
- * that allow you to modify the data directly and trigger reactive updates.
1020
+ * The returned value is a read-only snapshot of the selected state. Mutate the
1021
+ * store through the main `$` proxy (or a proxy created explicitly for commands),
1022
+ * and use the hook return value only for rendering.
1032
1023
  *
1033
1024
  * @example
1034
1025
  * // Subscribe to user data
1035
1026
  * const user = useScope(() => $.user);
1036
1027
  *
1037
- * // Update user data directly (triggers re-render only for components using $.user)
1038
- * user.$merge({ name: 'New Name' });
1028
+ * // Mutate through the main store proxy
1029
+ * $.user.$merge({ name: 'New Name' });
1039
1030
  *
1040
1031
  * // Subscribe to a specific property
1041
1032
  * const userName = useScope(() => $.user.name);
@@ -1044,22 +1035,24 @@ function trackPathAccess(path) {
1044
1035
  * const isAdmin = useScope(() => $.user.role === 'admin');
1045
1036
  *
1046
1037
  * @param selector - Function that returns the data you want to subscribe to
1047
- * @returns The selected data, with custom methods attached if it's an object
1038
+ * @returns A read-only snapshot of the selected data
1048
1039
  */
1049
1040
  function useScope(selector) {
1050
- // Use ref to store the latest selector to avoid stale closures
1051
- const selectorRef = react.useRef(selector);
1052
- selectorRef.current = selector;
1053
- // Track dependencies and get initial value with advanced tracking
1054
- const { value: initialValue, paths: trackedPaths } = trackDependencies(selector);
1041
+ const snapshotCacheRef = react.useRef({
1042
+ revision: -1,
1043
+ source: undefined,
1044
+ snapshot: undefined,
1045
+ });
1046
+ // Track dependencies and get the selected value from the store
1047
+ const { value: selectedValue, paths: trackedPaths } = trackDependencies(selector);
1055
1048
  // Add tracked paths to selector paths for ultra-selective proxying
1056
1049
  trackedPaths.forEach(path => {
1057
1050
  selectorPaths.add(path);
1058
1051
  pathUsageStats.subscribedPaths.add(path);
1059
1052
  });
1060
- // Use a counter to force re-renders instead of storing the value
1061
- // This way we always return the fresh proxy object from the selector
1062
- const [, forceUpdate] = react.useState(0);
1053
+ // Use a counter to invalidate the cached snapshot only when this hook
1054
+ // receives a relevant store notification.
1055
+ const [revision, forceUpdate] = react.useState(0);
1063
1056
  // Create stable update handler that forces re-render
1064
1057
  const handleChange = react.useCallback(() => {
1065
1058
  try {
@@ -1070,6 +1063,16 @@ function useScope(selector) {
1070
1063
  }
1071
1064
  }, []);
1072
1065
  react.useEffect(() => {
1066
+ if (typeof requestAnimationFrame === 'function') {
1067
+ requestAnimationFrame(() => {
1068
+ handleChange();
1069
+ });
1070
+ }
1071
+ else {
1072
+ setTimeout(() => {
1073
+ handleChange();
1074
+ }, 16);
1075
+ }
1073
1076
  // Create path keys for subscription using the original approach
1074
1077
  // If trackedPaths is ['user', 'name'], create subscriptions for ['user', 'user.name']
1075
1078
  const pathKeys = trackedPaths.length > 0
@@ -1086,8 +1089,18 @@ function useScope(selector) {
1086
1089
  unsubscribeFunctions.forEach(unsubscribe => unsubscribe());
1087
1090
  };
1088
1091
  }, [trackedPaths.join(','), handleChange]); // Stable dependencies
1089
- // Always return the fresh result from the selector (preserves proxy and methods)
1090
- return selectorRef.current();
1092
+ if (selectedValue === null || typeof selectedValue !== 'object') {
1093
+ return selectedValue;
1094
+ }
1095
+ if (snapshotCacheRef.current.revision !== revision ||
1096
+ snapshotCacheRef.current.source !== selectedValue) {
1097
+ snapshotCacheRef.current = {
1098
+ revision,
1099
+ source: selectedValue,
1100
+ snapshot: createReadonlySnapshot(selectedValue),
1101
+ };
1102
+ }
1103
+ return snapshotCacheRef.current.snapshot;
1091
1104
  }
1092
1105
 
1093
1106
  /**