scope-state 0.1.5 → 0.1.7

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('.'));
@@ -815,27 +782,40 @@ function addArrayMethods(target, path) {
815
782
  writable: true,
816
783
  configurable: true
817
784
  });
785
+ // Override find with smart tracking during dependency collection
786
+ Object.defineProperty(target, 'find', {
787
+ value: function (predicate, thisArg) {
788
+ if (!isCurrentlyTracking() || !proxyConfig.smartArrayTracking) {
789
+ return Array.prototype.find.call(this, predicate, thisArg);
790
+ }
791
+ const currentPath = proxyPathMap.get(this) || path;
792
+ const length = skipTracking(() => this.length);
793
+ for (let i = 0; i < length; i++) {
794
+ const element = skipTracking(() => Reflect.get(this, i));
795
+ const { value: matched, paths } = capturePathsDuring(() => Boolean(predicate.call(thisArg, element, i, this)));
796
+ if (matched) {
797
+ addTrackedPaths([[...currentPath, String(i)].join('.')]);
798
+ addTrackedPaths(paths);
799
+ return element;
800
+ }
801
+ }
802
+ return undefined;
803
+ },
804
+ writable: true,
805
+ configurable: true
806
+ });
818
807
  // Override splice
819
808
  Object.defineProperty(target, 'splice', {
820
809
  value: function (start, deleteCount, ...items) {
821
810
  const currentPath = proxyPathMap.get(this) || path;
822
811
  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
812
  const actualDeleteCount = deleteCount === undefined ? (arrayLength - start) : deleteCount;
831
- const result = originalSplice.apply(this, [start, actualDeleteCount, ...processedItems]);
813
+ const result = originalSplice.apply(this, [start, actualDeleteCount, ...items]);
832
814
  if (currentPath.length > 0) {
833
815
  if (proxyConfig.trackPathUsage) {
834
816
  pathUsageStats.modifiedPaths.add(currentPath.join('.'));
835
817
  }
836
- // Notify the array itself
837
818
  notifyListeners(currentPath);
838
- // Also notify about each index that was affected
839
819
  for (let i = start; i < arrayLength; i++) {
840
820
  const indexPath = [...currentPath, i.toString()];
841
821
  notifyListeners(indexPath);
@@ -856,14 +836,7 @@ function addArrayMethods(target, path) {
856
836
  }
857
837
  const currentPath = proxyPathMap.get(this) || path;
858
838
  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);
839
+ originalPush.apply(this, newArray);
867
840
  if (currentPath.length > 0) {
868
841
  if (proxyConfig.trackPathUsage) {
869
842
  pathUsageStats.modifiedPaths.add(currentPath.join('.'));
@@ -895,15 +868,8 @@ function addArrayMethods(target, path) {
895
868
  initialValue = [];
896
869
  }
897
870
  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);
871
+ if (initialValue.length > 0) {
872
+ originalPush.apply(this, JSON.parse(JSON.stringify(initialValue)));
907
873
  }
908
874
  if (currentPath.length > 0) {
909
875
  notifyListeners(currentPath);
@@ -978,48 +944,125 @@ function optimizeMemoryUsage(aggressive = false) {
978
944
  };
979
945
  }
980
946
 
981
- // Track the current path we're accessing during a selector function
982
- let currentPath = [];
947
+ // Unique full dotted paths accessed during selector execution
948
+ let trackedPaths = new Set();
983
949
  let isTracking = false;
984
950
  let skipTrackingDepth = 0;
985
951
  /**
986
- * Track dependencies during selector execution - tracks individual path segments
952
+ * Track dependencies during selector execution - tracks full dotted paths
987
953
  */
988
954
  function trackDependencies(selector) {
989
- // Start tracking
990
955
  isTracking = true;
991
- currentPath = [];
956
+ trackedPaths = new Set();
992
957
  skipTrackingDepth = 0;
993
- // Execute selector to track dependencies
994
958
  const value = selector();
995
- // Stop tracking and get the tracked paths
996
959
  isTracking = false;
997
- // Clean up and return individual path segments (not full paths)
998
- const cleanedPaths = [...currentPath].filter(segment => {
999
- // Filter out segments that would create overly long paths
1000
- return segment && segment.length < 100; // Basic length check for individual segments
960
+ const paths = Array.from(trackedPaths).filter(path => path && path.length < 500);
961
+ trackedPaths = new Set();
962
+ return { value, paths };
963
+ }
964
+ /**
965
+ * Check if we're currently tracking dependencies
966
+ */
967
+ function isCurrentlyTracking() {
968
+ return isTracking && skipTrackingDepth === 0;
969
+ }
970
+ /**
971
+ * Temporarily skip tracking (for array method internals)
972
+ */
973
+ function skipTracking(fn) {
974
+ skipTrackingDepth++;
975
+ try {
976
+ return fn();
977
+ }
978
+ finally {
979
+ skipTrackingDepth--;
980
+ }
981
+ }
982
+ /**
983
+ * Run fn while capturing any newly tracked paths, then remove them from the main set.
984
+ * Caller decides whether to re-add captured paths (e.g. on a matched find iteration).
985
+ */
986
+ function capturePathsDuring(fn) {
987
+ const snapshot = new Set(trackedPaths);
988
+ const value = fn();
989
+ const newPaths = [];
990
+ trackedPaths.forEach(path => {
991
+ if (!snapshot.has(path)) {
992
+ newPaths.push(path);
993
+ trackedPaths.delete(path);
994
+ }
1001
995
  });
1002
- currentPath = [];
1003
- return { value, paths: cleanedPaths };
996
+ return { value, paths: newPaths };
997
+ }
998
+ /**
999
+ * Add paths to the active tracking set (used by smart array method overrides)
1000
+ */
1001
+ function addTrackedPaths(paths) {
1002
+ if (!isTracking)
1003
+ return;
1004
+ paths.forEach(path => trackedPaths.add(path));
1004
1005
  }
1005
1006
  /**
1006
- * Add a path segment to tracking during proxy get operations
1007
+ * Add a full path to tracking during proxy get operations
1007
1008
  */
1008
1009
  function trackPathAccess(path) {
1009
1010
  if (!isTracking || skipTrackingDepth > 0)
1010
1011
  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
1012
+ if (path.length <= proxyConfig.maxPathLength) {
1017
1013
  const fullPath = path.join('.');
1014
+ trackedPaths.add(fullPath);
1018
1015
  pathUsageStats.accessedPaths.add(fullPath);
1019
1016
  selectorPaths.add(fullPath);
1020
1017
  }
1021
1018
  }
1022
1019
 
1020
+ function isPlainObject(value) {
1021
+ const prototype = Object.getPrototypeOf(value);
1022
+ return prototype === Object.prototype || prototype === null;
1023
+ }
1024
+ function unwrapProxyTarget(value) {
1025
+ if (value && typeof value === 'object' && proxyTargetMap.has(value)) {
1026
+ return proxyTargetMap.get(value);
1027
+ }
1028
+ return value;
1029
+ }
1030
+ /**
1031
+ * Create a read-only snapshot suitable for rendering.
1032
+ *
1033
+ * Snapshots are plain arrays/objects with no proxy methods, which makes them
1034
+ * safe for React Compiler memoization while keeping `$` as the mutable API.
1035
+ */
1036
+ function createReadonlySnapshot(value, seen = new WeakMap()) {
1037
+ if (value === null || typeof value !== 'object') {
1038
+ return value;
1039
+ }
1040
+ const source = unwrapProxyTarget(value);
1041
+ if (seen.has(source)) {
1042
+ return seen.get(source);
1043
+ }
1044
+ if (Array.isArray(source)) {
1045
+ const snapshot = [];
1046
+ seen.set(source, snapshot);
1047
+ source.forEach(item => {
1048
+ snapshot.push(createReadonlySnapshot(item, seen));
1049
+ });
1050
+ return snapshot;
1051
+ }
1052
+ if (!isPlainObject(source)) {
1053
+ return source;
1054
+ }
1055
+ const snapshot = {};
1056
+ seen.set(source, snapshot);
1057
+ Object.keys(source).forEach(key => {
1058
+ const propertyValue = source[key];
1059
+ if (typeof propertyValue !== 'function') {
1060
+ snapshot[key] = createReadonlySnapshot(propertyValue, seen);
1061
+ }
1062
+ });
1063
+ return snapshot;
1064
+ }
1065
+
1023
1066
  /**
1024
1067
  * Hook to subscribe to the global store and re-render when specific data changes.
1025
1068
  *
@@ -1027,15 +1070,16 @@ function trackPathAccess(path) {
1027
1070
  * re-renders the component when those specific paths change. This provides
1028
1071
  * fine-grained reactivity without unnecessary renders.
1029
1072
  *
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.
1073
+ * The returned value is a read-only snapshot of the selected state. Mutate the
1074
+ * store through the main `$` proxy (or a proxy created explicitly for commands),
1075
+ * and use the hook return value only for rendering.
1032
1076
  *
1033
1077
  * @example
1034
1078
  * // Subscribe to user data
1035
1079
  * const user = useScope(() => $.user);
1036
1080
  *
1037
- * // Update user data directly (triggers re-render only for components using $.user)
1038
- * user.$merge({ name: 'New Name' });
1081
+ * // Mutate through the main store proxy
1082
+ * $.user.$merge({ name: 'New Name' });
1039
1083
  *
1040
1084
  * // Subscribe to a specific property
1041
1085
  * const userName = useScope(() => $.user.name);
@@ -1044,22 +1088,24 @@ function trackPathAccess(path) {
1044
1088
  * const isAdmin = useScope(() => $.user.role === 'admin');
1045
1089
  *
1046
1090
  * @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
1091
+ * @returns A read-only snapshot of the selected data
1048
1092
  */
1049
1093
  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);
1094
+ const snapshotCacheRef = react.useRef({
1095
+ revision: -1,
1096
+ source: undefined,
1097
+ snapshot: undefined,
1098
+ });
1099
+ // Track dependencies and get the selected value from the store
1100
+ const { value: selectedValue, paths: trackedPaths } = trackDependencies(selector);
1055
1101
  // Add tracked paths to selector paths for ultra-selective proxying
1056
1102
  trackedPaths.forEach(path => {
1057
1103
  selectorPaths.add(path);
1058
1104
  pathUsageStats.subscribedPaths.add(path);
1059
1105
  });
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);
1106
+ // Use a counter to invalidate the cached snapshot only when this hook
1107
+ // receives a relevant store notification.
1108
+ const [revision, forceUpdate] = react.useState(0);
1063
1109
  // Create stable update handler that forces re-render
1064
1110
  const handleChange = react.useCallback(() => {
1065
1111
  try {
@@ -1080,11 +1126,7 @@ function useScope(selector) {
1080
1126
  handleChange();
1081
1127
  }, 16);
1082
1128
  }
1083
- // Create path keys for subscription using the original approach
1084
- // If trackedPaths is ['user', 'name'], create subscriptions for ['user', 'user.name']
1085
- const pathKeys = trackedPaths.length > 0
1086
- ? trackedPaths.map((_, index, array) => array.slice(0, index + 1).join('.'))
1087
- : [''];
1129
+ const pathKeys = trackedPaths.length > 0 ? trackedPaths : [''];
1088
1130
  // Subscribe to all relevant paths
1089
1131
  const unsubscribeFunctions = pathKeys.map(pathKey => {
1090
1132
  // Mark this path as subscribed for usage tracking
@@ -1096,8 +1138,18 @@ function useScope(selector) {
1096
1138
  unsubscribeFunctions.forEach(unsubscribe => unsubscribe());
1097
1139
  };
1098
1140
  }, [trackedPaths.join(','), handleChange]); // Stable dependencies
1099
- // Always return the fresh result from the selector (preserves proxy and methods)
1100
- return selectorRef.current();
1141
+ if (selectedValue === null || typeof selectedValue !== 'object') {
1142
+ return selectedValue;
1143
+ }
1144
+ if (snapshotCacheRef.current.revision !== revision ||
1145
+ snapshotCacheRef.current.source !== selectedValue) {
1146
+ snapshotCacheRef.current = {
1147
+ revision,
1148
+ source: selectedValue,
1149
+ snapshot: createReadonlySnapshot(selectedValue),
1150
+ };
1151
+ }
1152
+ return snapshotCacheRef.current.snapshot;
1101
1153
  }
1102
1154
 
1103
1155
  /**