cogsbox-state 0.5.313 → 0.5.314

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.313",
3
+ "version": "0.5.314",
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
@@ -1814,12 +1814,10 @@ function createProxyHandler<T>(
1814
1814
  endIndex: 10,
1815
1815
  });
1816
1816
 
1817
- // --- State and Lock Management Refs ---
1817
+ // This ref tracks if the user is locked to the bottom.
1818
1818
  const isLockedToBottomRef = useRef(stickToBottom);
1819
- // This ref tracks the item count to determine when to scroll smoothly vs instantly.
1820
- const previousTotalCountRef = useRef(0);
1821
1819
 
1822
- // Subscribe to shadow state changes for height updates
1820
+ // This state triggers a re-render when item heights change.
1823
1821
  const [shadowUpdateTrigger, setShadowUpdateTrigger] = useState(0);
1824
1822
 
1825
1823
  useEffect(() => {
@@ -1837,7 +1835,7 @@ function createProxyHandler<T>(
1837
1835
  ) as any[];
1838
1836
  const totalCount = sourceArray.length;
1839
1837
 
1840
- // Calculate heights and positions from shadow state
1838
+ // Calculate heights from shadow state. This runs when data or measurements change.
1841
1839
  const { totalHeight, positions } = useMemo(() => {
1842
1840
  const shadowArray =
1843
1841
  getGlobalStore.getState().getShadowMetadata(stateKey, path) ||
@@ -1859,6 +1857,7 @@ function createProxyHandler<T>(
1859
1857
  shadowUpdateTrigger,
1860
1858
  ]);
1861
1859
 
1860
+ // Memoize the virtualized slice of data.
1862
1861
  const virtualState = useMemo(() => {
1863
1862
  const start = Math.max(0, range.startIndex);
1864
1863
  const end = Math.min(totalCount, range.endIndex);
@@ -1873,19 +1872,17 @@ function createProxyHandler<T>(
1873
1872
  });
1874
1873
  }, [range.startIndex, range.endIndex, sourceArray, totalCount]);
1875
1874
 
1875
+ // This is the main effect that handles all scrolling and updates.
1876
1876
  useLayoutEffect(() => {
1877
1877
  const container = containerRef.current;
1878
1878
  if (!container) return;
1879
1879
 
1880
- const listGrew = totalCount > previousTotalCountRef.current;
1880
+ let scrollTimeoutId: NodeJS.Timeout;
1881
1881
 
1882
- const handleScroll = () => {
1883
- const { scrollTop, clientHeight, scrollHeight } = container;
1884
- const isNowAtBottom =
1885
- scrollHeight - scrollTop - clientHeight < 1;
1886
- isLockedToBottomRef.current = isNowAtBottom;
1887
-
1888
- // ... (virtualization range calculation logic is unchanged)
1882
+ // This function determines what's visible in the viewport.
1883
+ const updateVirtualRange = () => {
1884
+ if (!container) return;
1885
+ const { scrollTop } = container;
1889
1886
  let low = 0,
1890
1887
  high = totalCount - 1;
1891
1888
  while (low <= high) {
@@ -1895,7 +1892,7 @@ function createProxyHandler<T>(
1895
1892
  }
1896
1893
  const startIndex = Math.max(0, high - overscan);
1897
1894
  let endIndex = startIndex;
1898
- const visibleEnd = scrollTop + clientHeight;
1895
+ const visibleEnd = scrollTop + container.clientHeight;
1899
1896
  while (
1900
1897
  endIndex < totalCount &&
1901
1898
  positions[endIndex]! < visibleEnd
@@ -1903,49 +1900,54 @@ function createProxyHandler<T>(
1903
1900
  endIndex++;
1904
1901
  }
1905
1902
  endIndex = Math.min(totalCount, endIndex + overscan);
1906
- setRange((prevRange) => {
1907
- if (
1908
- prevRange.startIndex !== startIndex ||
1909
- prevRange.endIndex !== endIndex
1910
- ) {
1911
- return { startIndex, endIndex };
1912
- }
1913
- return prevRange;
1914
- });
1903
+ setRange({ startIndex, endIndex });
1915
1904
  };
1916
1905
 
1917
- container.addEventListener("scroll", handleScroll, {
1906
+ // This function handles ONLY user-initiated scrolls.
1907
+ const handleUserScroll = () => {
1908
+ isLockedToBottomRef.current =
1909
+ container.scrollHeight -
1910
+ container.scrollTop -
1911
+ container.clientHeight <
1912
+ 1;
1913
+ updateVirtualRange();
1914
+ };
1915
+
1916
+ container.addEventListener("scroll", handleUserScroll, {
1918
1917
  passive: true,
1919
1918
  });
1920
1919
 
1921
- // --- ROBUST STICK-TO-BOTTOM LOGIC ---
1922
- if (stickToBottom && isLockedToBottomRef.current) {
1923
- // If the list grew (a new message was added), we want a smooth scroll.
1924
- // For all other cases (initial load, height recalculation), we MUST
1925
- // use 'auto' to instantly snap to the bottom and prevent the "jump up".
1926
- const behavior =
1927
- listGrew && previousTotalCountRef.current > 0
1928
- ? "smooth"
1929
- : "auto";
1930
-
1931
- container.scrollTo({
1932
- top: container.scrollHeight,
1933
- behavior,
1934
- });
1920
+ // --- THE CORE FIX ---
1921
+ if (stickToBottom) {
1922
+ // We use a timeout to wait for React to render AND for useMeasure to update heights.
1923
+ // This is the CRUCIAL part that fixes the race condition.
1924
+ scrollTimeoutId = setTimeout(() => {
1925
+ // By the time this runs, `container.scrollHeight` is accurate.
1926
+ // We only scroll if the user hasn't manually scrolled up in the meantime.
1927
+ if (isLockedToBottomRef.current) {
1928
+ container.scrollTo({
1929
+ top: container.scrollHeight,
1930
+ behavior: "auto", // ALWAYS 'auto' for an instant, correct jump.
1931
+ });
1932
+ }
1933
+ }, 50); // A small 50ms delay is a robust buffer.
1935
1934
  }
1936
1935
 
1937
- // Update the count for the next run AFTER the scroll logic.
1938
- previousTotalCountRef.current = totalCount;
1936
+ // Update the visible range on initial load.
1937
+ updateVirtualRange();
1939
1938
 
1940
- handleScroll();
1941
-
1942
- return () =>
1943
- container.removeEventListener("scroll", handleScroll);
1944
- }, [totalCount, positions, overscan, stickToBottom]);
1939
+ // Cleanup function is vital to prevent memory leaks.
1940
+ return () => {
1941
+ clearTimeout(scrollTimeoutId);
1942
+ container.removeEventListener("scroll", handleUserScroll);
1943
+ };
1944
+ // This effect re-runs whenever the list size or item heights change.
1945
+ }, [totalCount, positions, stickToBottom]);
1945
1946
 
1946
1947
  const scrollToBottom = useCallback(
1947
1948
  (behavior: ScrollBehavior = "smooth") => {
1948
1949
  if (containerRef.current) {
1950
+ isLockedToBottomRef.current = true;
1949
1951
  containerRef.current.scrollTo({
1950
1952
  top: containerRef.current.scrollHeight,
1951
1953
  behavior,
@@ -1958,6 +1960,7 @@ function createProxyHandler<T>(
1958
1960
  const scrollToIndex = useCallback(
1959
1961
  (index: number, behavior: ScrollBehavior = "smooth") => {
1960
1962
  if (containerRef.current && positions[index] !== undefined) {
1963
+ isLockedToBottomRef.current = false;
1961
1964
  containerRef.current.scrollTo({
1962
1965
  top: positions[index],
1963
1966
  behavior,