cogsbox-state 0.5.297 → 0.5.299

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/src/CogsState.tsx CHANGED
@@ -1814,25 +1814,29 @@ function createProxyHandler<T>(
1814
1814
  endIndex: 10,
1815
1815
  });
1816
1816
 
1817
- // --- STATE AND CALLBACKS FOR HEIGHTS ---
1818
- // This state value is the key. We increment it to force a re-calculation.
1819
1817
  const [heightsVersion, setHeightsVersion] = useState(0);
1820
- // This callback is stable and won't cause re-renders itself.
1821
1818
  const forceRecalculate = useCallback(
1822
1819
  () => setHeightsVersion((v) => v + 1),
1823
1820
  []
1824
1821
  );
1825
1822
 
1826
- // --- ON MOUNT: SCHEDULE A RECALCULATION ---
1827
- // This solves the "initial load" problem. It ensures that after the first
1828
- // items render and measure themselves, we run the calculations again
1829
- // with the new, correct height data.
1823
+ // --- This useEffect now cleanly subscribes to height changes ---
1830
1824
  useEffect(() => {
1831
- const timer = setTimeout(() => {
1832
- forceRecalculate();
1833
- }, 50); // A small delay helps batch initial measurements.
1834
- return () => clearTimeout(timer);
1835
- }, [forceRecalculate]);
1825
+ // Subscribe to shadow state changes for this specific key.
1826
+ const unsubscribe = getGlobalStore
1827
+ .getState()
1828
+ .subscribeToShadowState(stateKey, forceRecalculate);
1829
+
1830
+ // On initial mount, we still need to trigger one recalculation
1831
+ // to capture heights from the very first render.
1832
+ const timer = setTimeout(forceRecalculate, 50);
1833
+
1834
+ // Cleanup function to unsubscribe when the component unmounts.
1835
+ return () => {
1836
+ unsubscribe();
1837
+ clearTimeout(timer);
1838
+ };
1839
+ }, [stateKey, forceRecalculate]); // Runs only once on mount.
1836
1840
 
1837
1841
  const isAtBottomRef = useRef(stickToBottom);
1838
1842
  const previousTotalCountRef = useRef(0);
@@ -1844,27 +1848,21 @@ function createProxyHandler<T>(
1844
1848
  ) as any[];
1845
1849
  const totalCount = sourceArray.length;
1846
1850
 
1847
- // --- EFFICIENT HEIGHT & POSITION CALCULATION ---
1848
1851
  const { totalHeight, positions } = useMemo(() => {
1849
- // Get the shadow object for the whole array ONCE. This is fast.
1850
1852
  const shadowArray =
1851
1853
  getGlobalStore.getState().getShadowMetadata(stateKey, path) ||
1852
1854
  [];
1853
-
1854
1855
  let height = 0;
1855
1856
  const pos: number[] = [];
1856
1857
  for (let i = 0; i < totalCount; i++) {
1857
1858
  pos[i] = height;
1858
- // Access the height from the local shadowArray. No repeated deep lookups.
1859
1859
  const measuredHeight =
1860
1860
  shadowArray[i]?.virtualizer?.itemHeight;
1861
1861
  height += measuredHeight || itemHeight;
1862
1862
  }
1863
1863
  return { totalHeight: height, positions: pos };
1864
- // This now depends on `heightsVersion`, so it re-runs when we force it.
1865
1864
  }, [totalCount, stateKey, path, itemHeight, heightsVersion]);
1866
1865
 
1867
- // This logic is from your original working code.
1868
1866
  const virtualState = useMemo(() => {
1869
1867
  const start = Math.max(0, range.startIndex);
1870
1868
  const end = Math.min(totalCount, range.endIndex);
@@ -1877,9 +1875,7 @@ function createProxyHandler<T>(
1877
1875
  ...meta,
1878
1876
  validIndices,
1879
1877
  });
1880
- }, [range.startIndex, range.endIndex, sourceArray, totalCount]);
1881
-
1882
- // This is your original useLayoutEffect with the robust index calculation.
1878
+ }, [range.startIndex, range.endIndex, sourceArray]);
1883
1879
  useLayoutEffect(() => {
1884
1880
  const container = containerRef.current;
1885
1881
  if (!container) return;
@@ -1891,9 +1887,9 @@ function createProxyHandler<T>(
1891
1887
  const handleScroll = () => {
1892
1888
  const { scrollTop, clientHeight, scrollHeight } = container;
1893
1889
  isAtBottomRef.current =
1894
- scrollHeight - scrollTop - clientHeight < 10;
1890
+ scrollHeight - scrollTop - clientHeight < 5; // Use a small tolerance
1895
1891
 
1896
- // ROBUST: Binary search to find the start index. Prevents errors.
1892
+ // ... (binary search logic to setRange) ...
1897
1893
  let search = (list: number[], value: number) => {
1898
1894
  let low = 0;
1899
1895
  let high = list.length - 1;
@@ -1907,9 +1903,7 @@ function createProxyHandler<T>(
1907
1903
  }
1908
1904
  return low;
1909
1905
  };
1910
-
1911
1906
  let startIndex = search(positions, scrollTop);
1912
-
1913
1907
  let endIndex = startIndex;
1914
1908
  while (
1915
1909
  endIndex < totalCount &&
@@ -1917,10 +1911,8 @@ function createProxyHandler<T>(
1917
1911
  ) {
1918
1912
  endIndex++;
1919
1913
  }
1920
-
1921
1914
  startIndex = Math.max(0, startIndex - overscan);
1922
1915
  endIndex = Math.min(totalCount, endIndex + overscan);
1923
-
1924
1916
  setRange((prevRange) => {
1925
1917
  if (
1926
1918
  prevRange.startIndex !== startIndex ||
@@ -1935,27 +1927,28 @@ function createProxyHandler<T>(
1935
1927
  container.addEventListener("scroll", handleScroll, {
1936
1928
  passive: true,
1937
1929
  });
1930
+ handleScroll(); // Run once to set initial view
1938
1931
 
1939
- // This stickToBottom logic is from your original working code.
1932
+ // --- THE SIMPLE FIX ---
1933
+ // We check the flags *after* handleScroll has updated them.
1940
1934
  if (stickToBottom) {
1941
1935
  if (isInitialMountRef.current) {
1936
+ // On first load, always go to the bottom.
1942
1937
  container.scrollTo({
1943
1938
  top: container.scrollHeight,
1944
1939
  behavior: "auto",
1945
1940
  });
1946
- } else if (wasAtBottom && listGrew) {
1947
- requestAnimationFrame(() => {
1948
- container.scrollTo({
1949
- top: container.scrollHeight,
1950
- behavior: "smooth",
1951
- });
1941
+ isInitialMountRef.current = false;
1942
+ } else if (listGrew && wasAtBottom) {
1943
+ // If a new item was added AND we were already at the bottom,
1944
+ // scroll to the new bottom.
1945
+ container.scrollTo({
1946
+ top: container.scrollHeight,
1947
+ behavior: "auto",
1952
1948
  });
1953
1949
  }
1954
1950
  }
1955
1951
 
1956
- isInitialMountRef.current = false;
1957
- handleScroll();
1958
-
1959
1952
  return () =>
1960
1953
  container.removeEventListener("scroll", handleScroll);
1961
1954
  }, [totalCount, overscan, stickToBottom, positions]);
@@ -2950,7 +2943,6 @@ export function $cogsSignalStore(proxy: {
2950
2943
  );
2951
2944
  return createElement("text", {}, String(value));
2952
2945
  }
2953
-
2954
2946
  function CogsItemWrapper({
2955
2947
  stateKey,
2956
2948
  itemComponentId,
@@ -2962,19 +2954,31 @@ function CogsItemWrapper({
2962
2954
  itemPath: string[];
2963
2955
  children: React.ReactNode;
2964
2956
  }) {
2957
+ // This hook handles the re-rendering when the item's own data changes.
2965
2958
  const [, forceUpdate] = useState({});
2959
+ // This hook measures the element.
2966
2960
  const [ref, bounds] = useMeasure();
2961
+ // This ref prevents sending the same height update repeatedly.
2962
+ const lastReportedHeight = useRef<number | null>(null);
2967
2963
 
2964
+ // This is the primary effect for this component.
2968
2965
  useEffect(() => {
2969
- if (bounds.height > 0) {
2966
+ // We only report a height if it's a valid number AND it's different
2967
+ // from the last height we reported. This prevents infinite loops.
2968
+ if (bounds.height > 0 && bounds.height !== lastReportedHeight.current) {
2969
+ // Store the new height so we don't report it again.
2970
+ lastReportedHeight.current = bounds.height;
2971
+
2972
+ // Call the store function to save the height and notify listeners.
2970
2973
  getGlobalStore.getState().setShadowMetadata(stateKey, itemPath, {
2971
2974
  virtualizer: {
2972
2975
  itemHeight: bounds.height,
2973
2976
  },
2974
2977
  });
2975
2978
  }
2976
- }, [bounds.height]);
2979
+ }, [bounds.height, stateKey, itemPath]); // Reruns whenever the measured height changes.
2977
2980
 
2981
+ // This effect handles subscribing the item to its own data path for updates.
2978
2982
  useLayoutEffect(() => {
2979
2983
  const fullComponentId = `${stateKey}////${itemComponentId}`;
2980
2984
  const stateEntry = getGlobalStore
@@ -3000,5 +3004,6 @@ function CogsItemWrapper({
3000
3004
  };
3001
3005
  }, [stateKey, itemComponentId, itemPath.join(".")]);
3002
3006
 
3007
+ // The rendered output is a simple div that gets measured.
3003
3008
  return <div ref={ref}>{children}</div>;
3004
3009
  }
package/src/store.ts CHANGED
@@ -98,8 +98,12 @@ type ShadowState<T> =
98
98
  : T extends object
99
99
  ? { [K in keyof T]: ShadowState<T[K]> } & ShadowMetadata
100
100
  : ShadowMetadata;
101
+
101
102
  export type CogsGlobalState = {
103
+ // --- Shadow State and Subscription System ---
102
104
  shadowStateStore: { [key: string]: any };
105
+ shadowStateSubscribers: Map<string, Set<() => void>>; // Stores subscribers for shadow state updates
106
+ subscribeToShadowState: (key: string, callback: () => void) => () => void; // Subscribes a listener, returns an unsubscribe function
103
107
  initializeShadowState: (key: string, initialState: any) => void;
104
108
  updateShadowAtPath: (key: string, path: string[], newValue: any) => void;
105
109
  insertShadowArrayElement: (
@@ -115,9 +119,8 @@ export type CogsGlobalState = {
115
119
  getShadowMetadata: (key: string, path: string[]) => any;
116
120
  setShadowMetadata: (key: string, path: string[], metadata: any) => void;
117
121
 
122
+ // --- Selected Item State ---
118
123
  selectedIndicesMap: Map<string, Map<string, number>>; // stateKey -> (parentPath -> selectedIndex)
119
-
120
- // Add these new methods
121
124
  getSelectedIndex: (
122
125
  stateKey: string,
123
126
  parentPath: string
@@ -135,36 +138,16 @@ export type CogsGlobalState = {
135
138
  path: string[];
136
139
  }) => void;
137
140
  clearSelectedIndexesForState: (stateKey: string) => void;
141
+
142
+ // --- Core State and Updaters ---
138
143
  updaterState: { [key: string]: any };
139
144
  initialStateOptions: { [key: string]: OptionsType };
140
145
  cogsStateStore: { [key: string]: StateValue };
141
146
  isLoadingGlobal: { [key: string]: boolean };
142
-
143
147
  initialStateGlobal: { [key: string]: StateValue };
144
148
  iniitialCreatedState: { [key: string]: StateValue };
145
- validationErrors: Map<string, string[]>;
146
-
147
149
  serverState: { [key: string]: StateValue };
148
- serverSyncActions: { [key: string]: SyncActionsType<any> };
149
-
150
- serverSyncLog: { [key: string]: SyncLogType[] };
151
- serverSideOrNot: { [key: string]: boolean };
152
- setServerSyncLog: (key: string, newValue: SyncLogType) => void;
153
-
154
- setServerSideOrNot: (key: string, value: boolean) => void;
155
- getServerSideOrNot: (key: string) => boolean | undefined;
156
- setServerState: <StateKey extends StateKeys>(
157
- key: StateKey,
158
- value: StateValue
159
- ) => void;
160
150
 
161
- getThisLocalUpdate: (key: string) => UpdateTypeDetail[] | undefined;
162
- setServerSyncActions: (key: string, value: SyncActionsType<any>) => void;
163
- addValidationError: (path: string, message: string) => void;
164
- getValidationErrors: (path: string) => string[];
165
- updateInitialStateGlobal: (key: string, newState: StateValue) => void;
166
- updateInitialCreatedState: (key: string, newState: StateValue) => void;
167
- getInitialOptions: (key: string) => OptionsType | undefined;
168
151
  getUpdaterState: (key: string) => StateUpdater<StateValue>;
169
152
  setUpdaterState: (key: string, newUpdater: any) => void;
170
153
  getKeyState: <StateKey extends StateKeys>(key: StateKey) => StateValue;
@@ -178,15 +161,41 @@ export type CogsGlobalState = {
178
161
  ) => void;
179
162
  setInitialStates: (initialState: StateValue) => void;
180
163
  setCreatedState: (initialState: StateValue) => void;
164
+ updateInitialStateGlobal: (key: string, newState: StateValue) => void;
165
+ updateInitialCreatedState: (key: string, newState: StateValue) => void;
166
+ setIsLoadingGlobal: (key: string, value: boolean) => void;
167
+ setServerState: <StateKey extends StateKeys>(
168
+ key: StateKey,
169
+ value: StateValue
170
+ ) => void;
171
+ getInitialOptions: (key: string) => OptionsType | undefined;
172
+ setInitialStateOptions: (key: string, value: OptionsType) => void;
173
+
174
+ // --- Validation ---
175
+ validationErrors: Map<string, string[]>;
176
+ addValidationError: (path: string, message: string) => void;
177
+ getValidationErrors: (path: string) => string[];
178
+ removeValidationError: (path: string) => void;
179
+
180
+ // --- Server Sync and Logging ---
181
+ serverSyncActions: { [key: string]: SyncActionsType<any> };
182
+ serverSyncLog: { [key: string]: SyncLogType[] };
181
183
  stateLog: { [key: string]: UpdateTypeDetail[] };
184
+ syncInfoStore: Map<string, SyncInfo>;
185
+ serverSideOrNot: { [key: string]: boolean };
186
+ setServerSyncLog: (key: string, newValue: SyncLogType) => void;
187
+ setServerSideOrNot: (key: string, value: boolean) => void;
188
+ getServerSideOrNot: (key: string) => boolean | undefined;
189
+ getThisLocalUpdate: (key: string) => UpdateTypeDetail[] | undefined;
190
+ setServerSyncActions: (key: string, value: SyncActionsType<any>) => void;
182
191
  setStateLog: (
183
192
  key: string,
184
193
  updater: (prevUpdates: UpdateTypeDetail[]) => UpdateTypeDetail[]
185
194
  ) => void;
186
- setIsLoadingGlobal: (key: string, value: boolean) => void;
195
+ setSyncInfo: (key: string, syncInfo: SyncInfo) => void;
196
+ getSyncInfo: (key: string) => SyncInfo | null;
187
197
 
188
- setInitialStateOptions: (key: string, value: OptionsType) => void;
189
- removeValidationError: (path: string) => void;
198
+ // --- Component and DOM Integration ---
190
199
  signalDomElements: Map<
191
200
  string,
192
201
  Set<{
@@ -208,8 +217,10 @@ export type CogsGlobalState = {
208
217
  }
209
218
  ) => void;
210
219
  removeSignalElement: (signalId: string, instanceId: string) => void;
211
- reRenderTriggerPrevValue: Record<string, any>;
220
+ stateComponents: Map<string, ComponentsType>;
212
221
 
222
+ // --- Deprecated/Legacy (Review for removal) ---
223
+ reRenderTriggerPrevValue: Record<string, any>;
213
224
  reactiveDeps: Record<
214
225
  string,
215
226
  {
@@ -228,11 +239,6 @@ export type CogsGlobalState = {
228
239
  ) => void;
229
240
  deleteReactiveDeps: (key: string) => void;
230
241
  subscribe: (listener: () => void) => () => void;
231
-
232
- stateComponents: Map<string, ComponentsType>;
233
- syncInfoStore: Map<string, SyncInfo>;
234
- setSyncInfo: (key: string, syncInfo: SyncInfo) => void;
235
- getSyncInfo: (key: string) => SyncInfo | null;
236
242
  };
237
243
 
238
244
  export const getGlobalStore = create<CogsGlobalState>((set, get) => ({
@@ -250,30 +256,6 @@ export const getGlobalStore = create<CogsGlobalState>((set, get) => ({
250
256
  return current;
251
257
  },
252
258
 
253
- setShadowMetadata: (key: string, path: string[], metadata: any) => {
254
- set((state) => {
255
- const newShadow = { ...state.shadowStateStore };
256
- if (!newShadow[key]) return state;
257
-
258
- newShadow[key] = JSON.parse(JSON.stringify(newShadow[key]));
259
-
260
- let current: any = newShadow[key];
261
- for (const segment of path) {
262
- if (!current[segment]) current[segment] = {};
263
- current = current[segment];
264
- }
265
-
266
- // Merge the metadata into the existing structure
267
- Object.keys(metadata).forEach((category) => {
268
- if (!current[category]) {
269
- current[category] = {};
270
- }
271
- Object.assign(current[category], metadata[category]);
272
- });
273
-
274
- return { shadowStateStore: newShadow };
275
- });
276
- },
277
259
  initializeShadowState: (key: string, initialState: any) => {
278
260
  const createShadowStructure = (obj: any): any => {
279
261
  if (Array.isArray(obj)) {
@@ -389,6 +371,63 @@ export const getGlobalStore = create<CogsGlobalState>((set, get) => ({
389
371
  return { shadowStateStore: newShadow };
390
372
  });
391
373
  },
374
+ shadowStateSubscribers: new Map<string, Set<() => void>>(), // key -> Set of callbacks
375
+
376
+ subscribeToShadowState: (key: string, callback: () => void) => {
377
+ set((state) => {
378
+ const newSubs = new Map(state.shadowStateSubscribers);
379
+ const subsForKey = newSubs.get(key) || new Set();
380
+ subsForKey.add(callback);
381
+ newSubs.set(key, subsForKey);
382
+ return { shadowStateSubscribers: newSubs };
383
+ });
384
+ // Return an unsubscribe function
385
+ return () => {
386
+ set((state) => {
387
+ const newSubs = new Map(state.shadowStateSubscribers);
388
+ const subsForKey = newSubs.get(key);
389
+ if (subsForKey) {
390
+ subsForKey.delete(callback);
391
+ }
392
+ return { shadowStateSubscribers: newSubs };
393
+ });
394
+ };
395
+ },
396
+
397
+ setShadowMetadata: (key: string, path: string[], metadata: any) => {
398
+ let hasChanged = false;
399
+ set((state) => {
400
+ const newShadow = { ...state.shadowStateStore };
401
+ if (!newShadow[key]) return state;
402
+
403
+ newShadow[key] = JSON.parse(JSON.stringify(newShadow[key]));
404
+
405
+ let current: any = newShadow[key];
406
+ for (const segment of path) {
407
+ if (!current[segment]) current[segment] = {};
408
+ current = current[segment];
409
+ }
410
+
411
+ const oldHeight = current.virtualizer?.itemHeight;
412
+ const newHeight = metadata.virtualizer?.itemHeight;
413
+
414
+ if (newHeight && oldHeight !== newHeight) {
415
+ hasChanged = true;
416
+ if (!current.virtualizer) current.virtualizer = {};
417
+ current.virtualizer.itemHeight = newHeight;
418
+ }
419
+
420
+ return { shadowStateStore: newShadow };
421
+ });
422
+
423
+ // If a height value was actually changed, notify the specific subscribers.
424
+ if (hasChanged) {
425
+ const subscribers = get().shadowStateSubscribers.get(key);
426
+ if (subscribers) {
427
+ subscribers.forEach((callback) => callback());
428
+ }
429
+ }
430
+ },
392
431
  selectedIndicesMap: new Map<string, Map<string, number>>(),
393
432
 
394
433
  // Add the new methods