cogsbox-state 0.5.291 → 0.5.293

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
@@ -42,7 +42,7 @@ import useMeasure from "react-use-measure";
42
42
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
43
43
 
44
44
  export type VirtualViewOptions = {
45
- itemHeight: number;
45
+ itemHeight?: number;
46
46
  overscan?: number;
47
47
  stickToBottom?: boolean;
48
48
  };
@@ -1039,6 +1039,8 @@ export function useCogsStateFn<TStateObject extends unknown>(
1039
1039
  const pathKey = `${thisKey}-${path.join(".")}`;
1040
1040
  componentUpdatesRef.current.add(pathKey);
1041
1041
  }
1042
+ const store = getGlobalStore.getState();
1043
+
1042
1044
  setState(thisKey, (prevValue: TStateObject) => {
1043
1045
  const payload = isFunction<TStateObject>(newStateOrFunction)
1044
1046
  ? newStateOrFunction(prevValue as TStateObject)
@@ -1047,9 +1049,7 @@ export function useCogsStateFn<TStateObject extends unknown>(
1047
1049
  const signalId = `${thisKey}-${path.join(".")}`;
1048
1050
  if (signalId) {
1049
1051
  let isArrayOperation = false;
1050
- let elements = getGlobalStore
1051
- .getState()
1052
- .signalDomElements.get(signalId);
1052
+ let elements = store.signalDomElements.get(signalId);
1053
1053
 
1054
1054
  if (
1055
1055
  (!elements || elements.size === 0) &&
@@ -1062,9 +1062,7 @@ export function useCogsStateFn<TStateObject extends unknown>(
1062
1062
  if (Array.isArray(arrayValue)) {
1063
1063
  isArrayOperation = true;
1064
1064
  const arraySignalId = `${thisKey}-${arrayPath.join(".")}`;
1065
- elements = getGlobalStore
1066
- .getState()
1067
- .signalDomElements.get(arraySignalId);
1065
+ elements = store.signalDomElements.get(arraySignalId);
1068
1066
  }
1069
1067
  }
1070
1068
 
@@ -1089,32 +1087,7 @@ export function useCogsStateFn<TStateObject extends unknown>(
1089
1087
  }
1090
1088
  }
1091
1089
 
1092
- const shadowUpdate = () => {
1093
- const store = getGlobalStore.getState();
1094
-
1095
- switch (updateObj.updateType) {
1096
- case "update":
1097
- // For updates, just mirror the structure at the path
1098
- store.updateShadowAtPath(thisKey, path, payload);
1099
- break;
1100
-
1101
- case "insert":
1102
- // For array insert, add empty element to shadow array
1103
- const parentPath = path.slice(0, -1);
1104
- store.insertShadowArrayElement(thisKey, parentPath);
1105
- break;
1106
-
1107
- case "cut":
1108
- // For array cut, remove element from shadow array
1109
- const arrayPath = path.slice(0, -1);
1110
- const index = parseInt(path[path.length - 1]!);
1111
- store.removeShadowArrayElement(thisKey, arrayPath, index);
1112
- break;
1113
- }
1114
- };
1115
-
1116
- shadowUpdate();
1117
- console.log("shadowState", getGlobalStore.getState().shadowStateStore);
1090
+ console.log("shadowState", store.shadowStateStore);
1118
1091
  if (
1119
1092
  updateObj.updateType === "update" &&
1120
1093
  (validationKey || latestInitialOptionsRef.current?.validation?.key) &&
@@ -1164,7 +1137,7 @@ export function useCogsStateFn<TStateObject extends unknown>(
1164
1137
  });
1165
1138
  }
1166
1139
 
1167
- const stateEntry = getGlobalStore.getState().stateComponents.get(thisKey);
1140
+ const stateEntry = store.stateComponents.get(thisKey);
1168
1141
  console.log("stateEntry >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>", stateEntry);
1169
1142
  if (stateEntry) {
1170
1143
  const changedPaths = getDifferences(prevValue, payload);
@@ -1282,6 +1255,27 @@ export function useCogsStateFn<TStateObject extends unknown>(
1282
1255
  newValue,
1283
1256
  } satisfies UpdateTypeDetail;
1284
1257
 
1258
+ switch (updateObj.updateType) {
1259
+ case "update":
1260
+ // For updates, just mirror the structure at the path
1261
+ store.updateShadowAtPath(thisKey, path, payload);
1262
+ break;
1263
+
1264
+ case "insert":
1265
+ // For array insert, add empty element to shadow array
1266
+
1267
+ const parentPath = path.slice(0, -1);
1268
+ store.insertShadowArrayElement(thisKey, parentPath, newValue);
1269
+ break;
1270
+
1271
+ case "cut":
1272
+ // For array cut, remove element from shadow array
1273
+ const arrayPath = path.slice(0, -1);
1274
+ const index = parseInt(path[path.length - 1]!);
1275
+ store.removeShadowArrayElement(thisKey, arrayPath, index);
1276
+ break;
1277
+ }
1278
+
1285
1279
  setStateLog(thisKey, (prevLogs) => {
1286
1280
  const logs = [...(prevLogs ?? []), newUpdate];
1287
1281
 
@@ -1322,7 +1316,7 @@ export function useCogsStateFn<TStateObject extends unknown>(
1322
1316
  });
1323
1317
  }
1324
1318
  if (latestInitialOptionsRef.current?.serverSync) {
1325
- const serverStateStore = getGlobalStore.getState().serverState[thisKey];
1319
+ const serverStateStore = store.serverState[thisKey];
1326
1320
  const serverSync = latestInitialOptionsRef.current?.serverSync;
1327
1321
  setServerSyncActions(thisKey, {
1328
1322
  syncKey:
@@ -1564,6 +1558,7 @@ function createProxyHandler<T>(
1564
1558
  "_stateKey",
1565
1559
  "getComponents",
1566
1560
  ]);
1561
+
1567
1562
  if (
1568
1563
  prop !== "then" &&
1569
1564
  !prop.startsWith("$") &&
@@ -1808,7 +1803,7 @@ function createProxyHandler<T>(
1808
1803
  options: VirtualViewOptions
1809
1804
  ): VirtualStateObjectResult<any[]> => {
1810
1805
  const {
1811
- itemHeight,
1806
+ itemHeight = 50, // Default/estimated height
1812
1807
  overscan = 5,
1813
1808
  stickToBottom = false,
1814
1809
  } = options;
@@ -1818,17 +1813,13 @@ function createProxyHandler<T>(
1818
1813
  startIndex: 0,
1819
1814
  endIndex: 10,
1820
1815
  });
1821
- const getItemHeight = useCallback((index: number) => {
1822
- const metadata = getGlobalStore
1823
- .getState()
1824
- .getShadowMetadata(stateKey, [...path, index.toString()]);
1825
- return metadata?.virtualizer?.itemHeight || options.itemHeight;
1826
- }, []);
1827
- // --- State Tracking Refs ---
1816
+
1817
+ // --- State Tracking Refs for Stability ---
1828
1818
  const isAtBottomRef = useRef(stickToBottom);
1829
- const previousTotalCountRef = useRef(0);
1830
- // NEW: Ref to explicitly track if this is the component's first render cycle.
1831
- const isInitialMountRef = useRef(true);
1819
+ // Store the scroll position before a new item is added
1820
+ const scrollOffsetRef = useRef(0);
1821
+ // Ref to track if the list has grown, to trigger scroll correction
1822
+ const listGrewRef = useRef(false);
1832
1823
 
1833
1824
  const sourceArray = getGlobalStore().getNestedState(
1834
1825
  stateKey,
@@ -1836,6 +1827,31 @@ function createProxyHandler<T>(
1836
1827
  ) as any[];
1837
1828
  const totalCount = sourceArray.length;
1838
1829
 
1830
+ // Helper to get measured heights or the default
1831
+ const getItemHeight = useCallback(
1832
+ (index: number): number => {
1833
+ const metadata = getGlobalStore
1834
+ .getState()
1835
+ .getShadowMetadata(stateKey, [...path, index.toString()]);
1836
+ return metadata?.virtualizer?.itemHeight || itemHeight;
1837
+ },
1838
+ [itemHeight, stateKey, path]
1839
+ );
1840
+
1841
+ // Pre-calculate total height and the top offset of each item
1842
+ const { totalHeight, positions } = useMemo(() => {
1843
+ let currentHeight = 0;
1844
+ const pos: number[] = [];
1845
+ for (let i = 0; i < totalCount; i++) {
1846
+ pos[i] = currentHeight;
1847
+ currentHeight += getItemHeight(i);
1848
+ }
1849
+
1850
+ console.log("totalHeight", totalHeight);
1851
+ return { totalHeight: currentHeight, positions: pos };
1852
+ }, [totalCount, getItemHeight]);
1853
+
1854
+ // This is identical to your original code
1839
1855
  const virtualState = useMemo(() => {
1840
1856
  const start = Math.max(0, range.startIndex);
1841
1857
  const end = Math.min(totalCount, range.endIndex);
@@ -1850,73 +1866,92 @@ function createProxyHandler<T>(
1850
1866
  });
1851
1867
  }, [range.startIndex, range.endIndex, sourceArray, totalCount]);
1852
1868
 
1869
+ // --- STABLE SCROLL LOGIC ---
1870
+ // useLayoutEffect runs after DOM mutations but before the browser paints.
1871
+ // This is the perfect place to correct scroll positions.
1853
1872
  useLayoutEffect(() => {
1854
1873
  const container = containerRef.current;
1855
1874
  if (!container) return;
1856
1875
 
1857
- const wasAtBottom = isAtBottomRef.current;
1858
- const listGrew = totalCount > previousTotalCountRef.current;
1859
- previousTotalCountRef.current = totalCount;
1876
+ // If the list grew, we need to adjust the scroll position
1877
+ // to prevent the content from jumping.
1878
+ if (listGrewRef.current) {
1879
+ listGrewRef.current = false; // Reset the flag
1880
+
1881
+ if (isAtBottomRef.current) {
1882
+ // If we were at the bottom, stay at the bottom.
1883
+ // This is the fix for the auto-scroll issue.
1884
+ container.scrollTop = container.scrollHeight;
1885
+ } else {
1886
+ // If we were in the middle, restore the previous scroll position
1887
+ // plus the height of the content that was added above us.
1888
+ // This is an advanced case, but for now, let's keep it simple
1889
+ // as most use-cases are for chat-like views. For a simple list,
1890
+ // just staying at the bottom is the main goal.
1891
+ }
1892
+ }
1893
+ }, [totalHeight]); // This effect runs whenever the total height changes
1894
+
1895
+ useEffect(() => {
1896
+ const container = containerRef.current;
1897
+ if (!container) return;
1898
+
1899
+ // Track the previous total count to detect when new items are added
1900
+ let previousTotalCount = totalCount;
1860
1901
 
1861
1902
  const handleScroll = () => {
1903
+ if (!container) return;
1862
1904
  const { scrollTop, clientHeight, scrollHeight } = container;
1905
+ // Update "is at bottom" status on every scroll
1863
1906
  isAtBottomRef.current =
1864
1907
  scrollHeight - scrollTop - clientHeight < 10;
1865
- const start = Math.max(
1866
- 0,
1867
- Math.floor(scrollTop / itemHeight) - overscan
1868
- );
1869
- const end = Math.min(
1870
- totalCount,
1871
- Math.ceil((scrollTop + clientHeight) / itemHeight) +
1872
- overscan
1873
- );
1908
+ scrollOffsetRef.current = scrollTop;
1909
+
1910
+ // Find start/end indices based on positions
1911
+ let startIndex = 0;
1912
+ for (let i = 0; i < positions.length; i++) {
1913
+ if (positions[i]! >= scrollTop) {
1914
+ startIndex = i;
1915
+ break;
1916
+ }
1917
+ }
1918
+
1919
+ let endIndex = startIndex;
1920
+ while (
1921
+ endIndex < totalCount &&
1922
+ positions[endIndex]! < scrollTop + clientHeight
1923
+ ) {
1924
+ endIndex++;
1925
+ }
1926
+
1927
+ startIndex = Math.max(0, startIndex - overscan);
1928
+ endIndex = Math.min(totalCount, endIndex + overscan);
1929
+
1874
1930
  setRange((prevRange) => {
1875
1931
  if (
1876
- prevRange.startIndex !== start ||
1877
- prevRange.endIndex !== end
1932
+ prevRange.startIndex !== startIndex ||
1933
+ prevRange.endIndex !== endIndex
1878
1934
  ) {
1879
- return { startIndex: start, endIndex: end };
1935
+ return { startIndex, endIndex };
1880
1936
  }
1881
1937
  return prevRange;
1882
1938
  });
1883
1939
  };
1884
1940
 
1941
+ // Check if the list has grown *before* the next render cycle
1942
+ if (totalCount > previousTotalCount) {
1943
+ listGrewRef.current = true;
1944
+ }
1945
+ previousTotalCount = totalCount;
1946
+
1885
1947
  container.addEventListener("scroll", handleScroll, {
1886
1948
  passive: true,
1887
1949
  });
1888
-
1889
- // --- THE CORRECTED DECISION LOGIC ---
1890
- if (stickToBottom) {
1891
- if (isInitialMountRef.current) {
1892
- // SCENARIO 1: First render of the component.
1893
- // Go to the bottom unconditionally. Use `auto` scroll for an instant jump.
1894
- container.scrollTo({
1895
- top: container.scrollHeight,
1896
- behavior: "auto",
1897
- });
1898
- } else if (wasAtBottom && listGrew) {
1899
- // SCENARIO 2: Subsequent renders (new messages arrive).
1900
- // Only scroll if the user was already at the bottom.
1901
- // Use `smooth` for a nice animated scroll for new messages.
1902
- requestAnimationFrame(() => {
1903
- container.scrollTo({
1904
- top: container.scrollHeight,
1905
- behavior: "smooth",
1906
- });
1907
- });
1908
- }
1909
- }
1910
-
1911
- // After the logic runs, it's no longer the initial mount.
1912
- isInitialMountRef.current = false;
1913
-
1914
- // Always run handleScroll once to set the initial visible window.
1915
- handleScroll();
1950
+ handleScroll(); // Initial calculation
1916
1951
 
1917
1952
  return () =>
1918
1953
  container.removeEventListener("scroll", handleScroll);
1919
- }, [totalCount, itemHeight, overscan, stickToBottom]);
1954
+ }, [totalCount, overscan, positions]); // Depend on positions to re-run scroll logic
1920
1955
 
1921
1956
  const scrollToBottom = useCallback(
1922
1957
  (behavior: ScrollBehavior = "smooth") => {
@@ -1932,31 +1967,27 @@ function createProxyHandler<T>(
1932
1967
 
1933
1968
  const scrollToIndex = useCallback(
1934
1969
  (index: number, behavior: ScrollBehavior = "smooth") => {
1935
- if (containerRef.current) {
1970
+ if (containerRef.current && positions[index] !== undefined) {
1936
1971
  containerRef.current.scrollTo({
1937
- top: index * itemHeight,
1972
+ top: positions[index],
1938
1973
  behavior,
1939
1974
  });
1940
1975
  }
1941
1976
  },
1942
- [itemHeight]
1977
+ [positions]
1943
1978
  );
1944
1979
 
1945
- // Same virtualizer props as before
1946
1980
  const virtualizerProps = {
1947
1981
  outer: {
1948
1982
  ref: containerRef,
1949
1983
  style: { overflowY: "auto", height: "100%" },
1950
1984
  },
1951
1985
  inner: {
1952
- style: {
1953
- height: `${totalCount * itemHeight}px`,
1954
- position: "relative",
1955
- },
1986
+ style: { height: `${totalHeight}px`, position: "relative" },
1956
1987
  },
1957
1988
  list: {
1958
1989
  style: {
1959
- transform: `translateY(${range.startIndex * itemHeight}px)`,
1990
+ transform: `translateY(${positions[range.startIndex] || 0}px)`,
1960
1991
  },
1961
1992
  },
1962
1993
  };
package/src/store.ts CHANGED
@@ -102,7 +102,11 @@ export type CogsGlobalState = {
102
102
  shadowStateStore: { [key: string]: any };
103
103
  initializeShadowState: (key: string, initialState: any) => void;
104
104
  updateShadowAtPath: (key: string, path: string[], newValue: any) => void;
105
- insertShadowArrayElement: (key: string, arrayPath: string[]) => void;
105
+ insertShadowArrayElement: (
106
+ key: string,
107
+ arrayPath: string[],
108
+ newItem: any
109
+ ) => void;
106
110
  removeShadowArrayElement: (
107
111
  key: string,
108
112
  arrayPath: string[],
@@ -325,22 +329,46 @@ export const getGlobalStore = create<CogsGlobalState>((set, get) => ({
325
329
  });
326
330
  },
327
331
 
328
- insertShadowArrayElement: (key: string, arrayPath: string[]) => {
332
+ insertShadowArrayElement: (
333
+ key: string,
334
+ arrayPath: string[],
335
+ newItem: any
336
+ ) => {
329
337
  set((state) => {
330
338
  const newShadow = { ...state.shadowStateStore };
331
- let current = newShadow[key];
339
+ if (!newShadow[key]) return state;
340
+
341
+ newShadow[key] = JSON.parse(JSON.stringify(newShadow[key]));
342
+
343
+ let current: any = newShadow[key];
332
344
 
333
345
  for (const segment of arrayPath) {
334
- current = current?.[segment];
346
+ current = current[segment];
347
+ if (!current) return state;
335
348
  }
336
349
 
337
350
  if (Array.isArray(current)) {
338
- current.push({});
351
+ // Create shadow structure based on the actual new item
352
+ const createShadowStructure = (obj: any): any => {
353
+ if (Array.isArray(obj)) {
354
+ return obj.map((item) => createShadowStructure(item));
355
+ }
356
+ if (typeof obj === "object" && obj !== null) {
357
+ const shadow: any = {};
358
+ for (const k in obj) {
359
+ shadow[k] = createShadowStructure(obj[k]);
360
+ }
361
+ return shadow;
362
+ }
363
+ return {}; // Leaf nodes get empty object for metadata
364
+ };
365
+
366
+ current.push(createShadowStructure(newItem));
339
367
  }
368
+
340
369
  return { shadowStateStore: newShadow };
341
370
  });
342
371
  },
343
-
344
372
  removeShadowArrayElement: (
345
373
  key: string,
346
374
  arrayPath: string[],