cogsbox-state 0.5.331 → 0.5.332

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cogsbox-state",
3
- "version": "0.5.331",
3
+ "version": "0.5.332",
4
4
  "description": "React state management library with form controls and server sync",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/CogsState.tsx CHANGED
@@ -1813,9 +1813,13 @@ function createProxyHandler<T>(
1813
1813
  startIndex: 0,
1814
1814
  endIndex: 10,
1815
1815
  });
1816
+
1817
+ // This ref tracks if the user is locked to the bottom.
1818
+ const isLockedToBottomRef = useRef(stickToBottom);
1819
+
1820
+ // This state triggers a re-render when item heights change.
1816
1821
  const [shadowUpdateTrigger, setShadowUpdateTrigger] = useState(0);
1817
1822
 
1818
- // Subscribe to shadow updates
1819
1823
  useEffect(() => {
1820
1824
  const unsubscribe = getGlobalStore
1821
1825
  .getState()
@@ -1831,18 +1835,20 @@ function createProxyHandler<T>(
1831
1835
  ) as any[];
1832
1836
  const totalCount = sourceArray.length;
1833
1837
 
1834
- // Calculate total height
1835
- const totalHeight = useMemo(() => {
1838
+ // Calculate heights from shadow state. This runs when data or measurements change.
1839
+ const { totalHeight, positions } = useMemo(() => {
1836
1840
  const shadowArray =
1837
1841
  getGlobalStore.getState().getShadowMetadata(stateKey, path) ||
1838
1842
  [];
1839
1843
  let height = 0;
1844
+ const pos: number[] = [];
1840
1845
  for (let i = 0; i < totalCount; i++) {
1846
+ pos[i] = height;
1841
1847
  const measuredHeight =
1842
1848
  shadowArray[i]?.virtualizer?.itemHeight;
1843
1849
  height += measuredHeight || itemHeight;
1844
1850
  }
1845
- return height;
1851
+ return { totalHeight: height, positions: pos };
1846
1852
  }, [
1847
1853
  totalCount,
1848
1854
  stateKey,
@@ -1851,7 +1857,7 @@ function createProxyHandler<T>(
1851
1857
  shadowUpdateTrigger,
1852
1858
  ]);
1853
1859
 
1854
- // Create virtual slice
1860
+ // Memoize the virtualized slice of data.
1855
1861
  const virtualState = useMemo(() => {
1856
1862
  const start = Math.max(0, range.startIndex);
1857
1863
  const end = Math.min(totalCount, range.endIndex);
@@ -1865,47 +1871,123 @@ function createProxyHandler<T>(
1865
1871
  validIndices,
1866
1872
  });
1867
1873
  }, [range.startIndex, range.endIndex, sourceArray, totalCount]);
1868
-
1869
- // Handle scroll
1870
1874
  useEffect(() => {
1871
- const container = containerRef.current;
1872
- if (!container) return;
1873
-
1874
- const handleScroll = () => {
1875
- const { scrollTop, clientHeight } = container;
1876
- const startIndex = Math.floor(scrollTop / itemHeight);
1877
- const visibleCount = Math.ceil(clientHeight / itemHeight);
1875
+ if (stickToBottom && totalCount > 0 && containerRef.current) {
1876
+ // When count increases, immediately adjust range to show bottom
1877
+ const container = containerRef.current;
1878
+ const visibleCount = Math.ceil(
1879
+ container.clientHeight / itemHeight
1880
+ );
1878
1881
 
1882
+ // Set range to show the last items including the new one
1879
1883
  setRange({
1880
- startIndex: Math.max(0, startIndex - overscan),
1881
- endIndex: Math.min(
1882
- totalCount,
1883
- startIndex + visibleCount + overscan
1884
+ startIndex: Math.max(
1885
+ 0,
1886
+ totalCount - visibleCount - overscan
1884
1887
  ),
1888
+ endIndex: totalCount,
1885
1889
  });
1890
+
1891
+ // Then scroll to bottom after a short delay
1892
+ setTimeout(() => {
1893
+ container.scrollTop = container.scrollHeight;
1894
+ }, 100);
1895
+ }
1896
+ }, [totalCount]);
1897
+ // This is the main effect that handles all scrolling and updates.
1898
+ useLayoutEffect(() => {
1899
+ const container = containerRef.current;
1900
+ if (!container) return;
1901
+
1902
+ let scrollTimeoutId: NodeJS.Timeout;
1903
+
1904
+ // This function determines what's visible in the viewport.
1905
+ const updateVirtualRange = () => {
1906
+ if (!container) return;
1907
+ const { scrollTop } = container;
1908
+ let low = 0,
1909
+ high = totalCount - 1;
1910
+ while (low <= high) {
1911
+ const mid = Math.floor((low + high) / 2);
1912
+ if (positions[mid]! < scrollTop) low = mid + 1;
1913
+ else high = mid - 1;
1914
+ }
1915
+ const startIndex = Math.max(0, high - overscan);
1916
+ let endIndex = startIndex;
1917
+ const visibleEnd = scrollTop + container.clientHeight;
1918
+ while (
1919
+ endIndex < totalCount &&
1920
+ positions[endIndex]! < visibleEnd
1921
+ ) {
1922
+ endIndex++;
1923
+ }
1924
+ endIndex = Math.min(totalCount, endIndex + overscan);
1925
+ setRange({ startIndex, endIndex });
1886
1926
  };
1887
1927
 
1888
- container.addEventListener("scroll", handleScroll);
1889
- handleScroll(); // Initial calculation
1928
+ // This function handles ONLY user-initiated scrolls.
1929
+ const handleUserScroll = () => {
1930
+ isLockedToBottomRef.current =
1931
+ container.scrollHeight -
1932
+ container.scrollTop -
1933
+ container.clientHeight <
1934
+ 1;
1935
+ updateVirtualRange();
1936
+ };
1890
1937
 
1891
- return () =>
1892
- container.removeEventListener("scroll", handleScroll);
1893
- }, [totalCount, itemHeight, overscan]);
1938
+ container.addEventListener("scroll", handleUserScroll, {
1939
+ passive: true,
1940
+ });
1894
1941
 
1895
- const scrollToBottom = useCallback(() => {
1896
- if (containerRef.current) {
1897
- containerRef.current.scrollTop =
1898
- containerRef.current.scrollHeight;
1942
+ // --- THE CORE FIX ---
1943
+ if (stickToBottom && isLockedToBottomRef.current) {
1944
+ // We use a timeout to wait for React to render AND for useMeasure to update heights.
1945
+ // This is the CRUCIAL part that fixes the race condition.
1946
+ scrollTimeoutId = setTimeout(() => {
1947
+ console.log("totalHeight", totalHeight);
1948
+ if (isLockedToBottomRef.current) {
1949
+ container.scrollTo({
1950
+ top: 999999999,
1951
+ behavior: "smooth", // ALWAYS 'auto' for an instant, correct jump.
1952
+ });
1953
+ }
1954
+ }, 200); // A small 50ms delay is a robust buffer.
1899
1955
  }
1900
- }, []);
1901
1956
 
1902
- const scrollToIndex = useCallback(
1903
- (index: number) => {
1957
+ updateVirtualRange();
1958
+
1959
+ // Cleanup function is vital to prevent memory leaks.
1960
+ return () => {
1961
+ clearTimeout(scrollTimeoutId);
1962
+ container.removeEventListener("scroll", handleUserScroll);
1963
+ };
1964
+ // This effect re-runs whenever the list size or item heights change.
1965
+ }, [totalCount, positions, totalHeight, stickToBottom]);
1966
+
1967
+ const scrollToBottom = useCallback(
1968
+ (behavior: ScrollBehavior = "smooth") => {
1904
1969
  if (containerRef.current) {
1905
- containerRef.current.scrollTop = index * itemHeight;
1970
+ isLockedToBottomRef.current = true;
1971
+ containerRef.current.scrollTo({
1972
+ top: containerRef.current.scrollHeight,
1973
+ behavior,
1974
+ });
1975
+ }
1976
+ },
1977
+ []
1978
+ );
1979
+
1980
+ const scrollToIndex = useCallback(
1981
+ (index: number, behavior: ScrollBehavior = "smooth") => {
1982
+ if (containerRef.current && positions[index] !== undefined) {
1983
+ isLockedToBottomRef.current = false;
1984
+ containerRef.current.scrollTo({
1985
+ top: positions[index],
1986
+ behavior,
1987
+ });
1906
1988
  }
1907
1989
  },
1908
- [itemHeight]
1990
+ [positions]
1909
1991
  );
1910
1992
 
1911
1993
  const virtualizerProps = {
@@ -1921,10 +2003,7 @@ function createProxyHandler<T>(
1921
2003
  },
1922
2004
  list: {
1923
2005
  style: {
1924
- position: "absolute" as const,
1925
- top: `${range.startIndex * itemHeight}px`,
1926
- left: 0,
1927
- right: 0,
2006
+ transform: `translateY(${positions[range.startIndex] || 0}px)`,
1928
2007
  },
1929
2008
  },
1930
2009
  };
@@ -2136,24 +2215,21 @@ function createProxyHandler<T>(
2136
2215
  return null;
2137
2216
  }
2138
2217
 
2139
- const arrayLength = arrayToMap.length;
2140
2218
  const indicesToMap =
2141
2219
  meta?.validIndices ||
2142
- Array.from({ length: arrayLength }, (_, i) => i);
2220
+ Array.from({ length: arrayToMap.length }, (_, i) => i);
2143
2221
 
2144
2222
  return indicesToMap.map((originalIndex, localIndex) => {
2145
2223
  const item = arrayToMap[originalIndex];
2146
2224
  const finalPath = [...path, originalIndex.toString()];
2147
2225
  const setter = rebuildStateShape(item, finalPath, meta);
2148
2226
  const itemComponentId = `${componentId}-${path.join(".")}-${originalIndex}`;
2149
- const isLastItem = originalIndex === arrayLength - 1;
2150
2227
 
2151
2228
  return createElement(CogsItemWrapper, {
2152
2229
  key: originalIndex,
2153
2230
  stateKey,
2154
2231
  itemComponentId,
2155
2232
  itemPath: finalPath,
2156
- isLastItem, // Pass it here!
2157
2233
  children: callbackfn(
2158
2234
  item,
2159
2235
  setter,
@@ -2883,25 +2959,21 @@ export function $cogsSignalStore(proxy: {
2883
2959
  );
2884
2960
  return createElement("text", {}, String(value));
2885
2961
  }
2886
-
2887
2962
  function CogsItemWrapper({
2888
2963
  stateKey,
2889
2964
  itemComponentId,
2890
2965
  itemPath,
2891
- isLastItem,
2892
2966
  children,
2893
2967
  }: {
2894
2968
  stateKey: string;
2895
2969
  itemComponentId: string;
2896
2970
  itemPath: string[];
2897
- isLastItem: boolean;
2898
2971
  children: React.ReactNode;
2899
2972
  }) {
2900
2973
  // This hook handles the re-rendering when the item's own data changes.
2901
2974
  const [, forceUpdate] = useState({});
2902
2975
  // This hook measures the element.
2903
- const [measureRef, bounds] = useMeasure();
2904
- const scrollRef = useRef<HTMLDivElement | null>(null);
2976
+ const [ref, bounds] = useMeasure();
2905
2977
  // This ref prevents sending the same height update repeatedly.
2906
2978
  const lastReportedHeight = useRef<number | null>(null);
2907
2979
 
@@ -2947,25 +3019,7 @@ function CogsItemWrapper({
2947
3019
  }
2948
3020
  };
2949
3021
  }, [stateKey, itemComponentId, itemPath.join(".")]);
2950
- useEffect(() => {
2951
- if (isLastItem && scrollRef.current) {
2952
- setTimeout(() => {
2953
- scrollRef.current?.scrollIntoView({
2954
- behavior: "smooth",
2955
- block: "end",
2956
- });
2957
- }, 50);
2958
- }
2959
- }, [isLastItem]);
3022
+
2960
3023
  // The rendered output is a simple div that gets measured.
2961
- return (
2962
- <div
2963
- ref={(el) => {
2964
- measureRef(el);
2965
- scrollRef.current = el;
2966
- }}
2967
- >
2968
- {children}
2969
- </div>
2970
- );
3024
+ return <div ref={ref}>{children}</div>;
2971
3025
  }