cogsbox-state 0.5.381 → 0.5.383

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.381",
3
+ "version": "0.5.383",
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
@@ -1817,7 +1817,7 @@ function createProxyHandler<T>(
1817
1817
  | "LOCKED_AT_BOTTOM"
1818
1818
  | "IDLE_NOT_AT_BOTTOM";
1819
1819
 
1820
- const shouldNotScroll = useRef(false);
1820
+ // Refs and State
1821
1821
  const containerRef = useRef<HTMLDivElement | null>(null);
1822
1822
  const [range, setRange] = useState({
1823
1823
  startIndex: 0,
@@ -1827,9 +1827,15 @@ function createProxyHandler<T>(
1827
1827
  const isProgrammaticScroll = useRef(false);
1828
1828
  const prevTotalCountRef = useRef(0);
1829
1829
  const prevDepsRef = useRef(dependencies);
1830
- const lastUpdateAtScrollTop = useRef(0);
1831
1830
  const [shadowUpdateTrigger, setShadowUpdateTrigger] = useState(0);
1832
-
1831
+ // CHANGE: Add a ref to store the scroll position BEFORE new items are added.
1832
+ // This is the key to preventing the jump when scrolled up.
1833
+ const scrollAnchorRef = useRef<{
1834
+ scrollTop: number;
1835
+ scrollHeight: number;
1836
+ } | null>(null);
1837
+
1838
+ // ... (Your existing useEffect for shadow state and useMemo for data are fine) ...
1833
1839
  useEffect(() => {
1834
1840
  const unsubscribe = getGlobalStore
1835
1841
  .getState()
@@ -1866,7 +1872,6 @@ function createProxyHandler<T>(
1866
1872
  shadowUpdateTrigger,
1867
1873
  ]);
1868
1874
 
1869
- // THIS IS THE FULL, NON-PLACEHOLDER FUNCTION
1870
1875
  const virtualState = useMemo(() => {
1871
1876
  const start = Math.max(0, range.startIndex);
1872
1877
  const end = Math.min(totalCount, range.endIndex);
@@ -1881,22 +1886,28 @@ function createProxyHandler<T>(
1881
1886
  });
1882
1887
  }, [range.startIndex, range.endIndex, sourceArray, totalCount]);
1883
1888
 
1884
- // --- 1. STATE CONTROLLER ---
1885
- // This effect decides which state to transition TO.
1889
+ // --- 1. STATE CONTROLLER (REVISED LOGIC) ---
1886
1890
  useLayoutEffect(() => {
1891
+ const container = containerRef.current;
1892
+ if (!container) return;
1893
+
1894
+ const hasNewItems = totalCount > prevTotalCountRef.current;
1887
1895
  const depsChanged = !isDeepEqual(
1888
1896
  dependencies,
1889
1897
  prevDepsRef.current
1890
1898
  );
1891
- const hasNewItems = totalCount > prevTotalCountRef.current;
1892
1899
 
1900
+ // Condition 1: Hard Reset.
1901
+ // This happens when you load a completely new list (e.g., switch chats).
1893
1902
  if (depsChanged) {
1894
- console.log("TRANSITION: Deps changed -> IDLE_AT_TOP");
1903
+ console.log(
1904
+ "TRANSITION (Hard Reset): Deps changed -> IDLE_AT_TOP"
1905
+ );
1895
1906
  setStatus("IDLE_AT_TOP");
1896
- return; // Stop here, let the next effect handle the action for the new state.
1907
+ container.scrollTo({ top: 0 }); // Reset scroll position
1897
1908
  }
1898
-
1899
- if (
1909
+ // Condition 2: New items arrive while we're locked to the bottom.
1910
+ else if (
1900
1911
  hasNewItems &&
1901
1912
  status === "LOCKED_AT_BOTTOM" &&
1902
1913
  stickToBottom
@@ -1906,14 +1917,29 @@ function createProxyHandler<T>(
1906
1917
  );
1907
1918
  setStatus("GETTING_HEIGHTS");
1908
1919
  }
1920
+ // CHANGE: Condition 3: New items arrive while we are scrolled up.
1921
+ // This is the "scroll anchoring" logic that prevents the jump.
1922
+ else if (hasNewItems && scrollAnchorRef.current) {
1923
+ console.log(
1924
+ "ACTION: Maintaining scroll position after new items added."
1925
+ );
1926
+ // We adjust the scroll position by the amount of height that was just added.
1927
+ container.scrollTop =
1928
+ scrollAnchorRef.current.scrollTop +
1929
+ (container.scrollHeight -
1930
+ scrollAnchorRef.current.scrollHeight);
1931
+ }
1909
1932
 
1933
+ // Finally, update the refs for the next render.
1910
1934
  prevTotalCountRef.current = totalCount;
1911
1935
  prevDepsRef.current = dependencies;
1912
- }, [totalCount, ...dependencies]);
1936
+ // Clear the anchor after using it. It will be re-set by the user scroll handler.
1937
+ scrollAnchorRef.current = null;
1938
+ }, [totalCount, ...dependencies]); // This dependency array is correct.
1913
1939
 
1914
- // --- 2. STATE ACTION HANDLER ---
1915
- // This effect performs the ACTION for the current state.
1940
+ // --- 2. STATE ACTION HANDLER (Mostly Unchanged) ---
1916
1941
  useLayoutEffect(() => {
1942
+ // ... (This effect's logic for GETTING_HEIGHTS and SCROLLING_TO_BOTTOM is correct and can remain the same)
1917
1943
  const container = containerRef.current;
1918
1944
  if (!container) return;
1919
1945
 
@@ -1924,15 +1950,12 @@ function createProxyHandler<T>(
1924
1950
  stickToBottom &&
1925
1951
  totalCount > 0
1926
1952
  ) {
1927
- // If we just loaded a new chat, start the process.
1928
1953
  console.log(
1929
- "ACTION (IDLE_AT_TOP): Data has arrived -> GETTING_HEIGHTS"
1954
+ "ACTION (IDLE_AT_TOP): Data arrived -> GETTING_HEIGHTS"
1930
1955
  );
1931
1956
  setStatus("GETTING_HEIGHTS");
1932
1957
  } else if (status === "GETTING_HEIGHTS") {
1933
- console.log(
1934
- "ACTION (GETTING_HEIGHTS): Setting range to end and starting loop."
1935
- );
1958
+ console.log("ACTION (GETTING_HEIGHTS): Setting range to end");
1936
1959
  setRange({
1937
1960
  startIndex: Math.max(0, totalCount - 10 - overscan),
1938
1961
  endIndex: totalCount,
@@ -1949,13 +1972,10 @@ function createProxyHandler<T>(
1949
1972
 
1950
1973
  if (lastItemHeight > 0) {
1951
1974
  clearInterval(intervalId);
1952
- if (!shouldNotScroll.current) {
1953
- console.log(
1954
- "ACTION (GETTING_HEIGHTS): Measurement success -> SCROLLING_TO_BOTTOM"
1955
- );
1956
-
1957
- setStatus("SCROLLING_TO_BOTTOM");
1958
- }
1975
+ console.log(
1976
+ "ACTION (GETTING_HEIGHTS): Measurement success -> SCROLLING_TO_BOTTOM"
1977
+ );
1978
+ setStatus("SCROLLING_TO_BOTTOM");
1959
1979
  }
1960
1980
  }, 100);
1961
1981
  } else if (status === "SCROLLING_TO_BOTTOM") {
@@ -1963,7 +1983,6 @@ function createProxyHandler<T>(
1963
1983
  "ACTION (SCROLLING_TO_BOTTOM): Executing scroll."
1964
1984
  );
1965
1985
  isProgrammaticScroll.current = true;
1966
- // Use 'auto' for initial load, 'smooth' for new messages.
1967
1986
  const scrollBehavior =
1968
1987
  prevTotalCountRef.current === 0 ? "auto" : "smooth";
1969
1988
 
@@ -1974,16 +1993,11 @@ function createProxyHandler<T>(
1974
1993
 
1975
1994
  const timeoutId = setTimeout(
1976
1995
  () => {
1977
- console.log(
1978
- "ACTION (SCROLLING_TO_BOTTOM): Scroll finished -> LOCKED_AT_BOTTOM"
1979
- );
1980
1996
  isProgrammaticScroll.current = false;
1981
- shouldNotScroll.current = false;
1982
1997
  setStatus("LOCKED_AT_BOTTOM");
1983
1998
  },
1984
1999
  scrollBehavior === "smooth" ? 500 : 50
1985
2000
  );
1986
-
1987
2001
  return () => clearTimeout(timeoutId);
1988
2002
  }
1989
2003
 
@@ -1992,46 +2006,70 @@ function createProxyHandler<T>(
1992
2006
  };
1993
2007
  }, [status, totalCount, positions]);
1994
2008
 
2009
+ // --- 3. USER INTERACTION & RANGE UPDATER (REVISED) ---
1995
2010
  useEffect(() => {
1996
2011
  const container = containerRef.current;
1997
2012
  if (!container) return;
1998
2013
 
1999
- const scrollThreshold = itemHeight;
2014
+ // CHANGE: Add a buffer to prevent jittering at the bottom.
2015
+ const bottomLockThreshold = 10; // 10px buffer
2000
2016
 
2001
2017
  const handleUserScroll = () => {
2002
2018
  if (isProgrammaticScroll.current) {
2003
2019
  return;
2004
2020
  }
2005
2021
 
2006
- const scrollTop = container.scrollTop;
2007
- if (
2008
- Math.abs(scrollTop - lastUpdateAtScrollTop.current) <
2009
- scrollThreshold
2010
- ) {
2011
- return;
2012
- }
2022
+ const { scrollTop, clientHeight, scrollHeight } = container;
2013
2023
 
2014
- console.log(
2015
- `Threshold passed at ${scrollTop}px. Recalculating range...`
2016
- );
2024
+ // CHANGE: Before the next state update, save the current scroll positions.
2025
+ // This 'anchors' our view, so the State Controller knows where we were.
2026
+ scrollAnchorRef.current = { scrollTop, scrollHeight };
2027
+
2028
+ // Part 1: Update the state machine with a tolerance
2029
+ const isAtBottom =
2030
+ scrollHeight - scrollTop - clientHeight <
2031
+ bottomLockThreshold;
2017
2032
 
2018
- // NOW we do the expensive work.
2019
- const { clientHeight } = container;
2033
+ if (isAtBottom) {
2034
+ if (status !== "LOCKED_AT_BOTTOM") {
2035
+ console.log(
2036
+ "SCROLL EVENT: Reached bottom -> LOCKED_AT_BOTTOM"
2037
+ );
2038
+ setStatus("LOCKED_AT_BOTTOM");
2039
+ }
2040
+ } else {
2041
+ if (status !== "IDLE_NOT_AT_BOTTOM") {
2042
+ console.log(
2043
+ "SCROLL EVENT: Scrolled up -> IDLE_NOT_AT_BOTTOM"
2044
+ );
2045
+ setStatus("IDLE_NOT_AT_BOTTOM");
2046
+ }
2047
+ }
2048
+
2049
+ // Part 2: Efficiently update the rendered range (this logic is good)
2020
2050
  let high = totalCount - 1;
2021
2051
  let low = 0;
2022
- let topItemIndex = 0;
2052
+ let potentialTopIndex = 0;
2023
2053
  while (low <= high) {
2024
2054
  const mid = Math.floor((low + high) / 2);
2025
2055
  if (positions[mid]! < scrollTop) {
2026
- topItemIndex = mid;
2056
+ potentialTopIndex = mid;
2027
2057
  low = mid + 1;
2028
2058
  } else {
2029
2059
  high = mid - 1;
2030
2060
  }
2031
2061
  }
2032
2062
 
2033
- const startIndex = Math.max(0, topItemIndex - overscan);
2034
- let endIndex = startIndex;
2063
+ const potentialStartIndex = Math.max(
2064
+ 0,
2065
+ potentialTopIndex - overscan
2066
+ );
2067
+
2068
+ if (potentialStartIndex === range.startIndex) {
2069
+ return;
2070
+ }
2071
+
2072
+ let endIndex = potentialStartIndex;
2035
2073
  const visibleEnd = scrollTop + clientHeight;
2036
2074
  while (
2037
2075
  endIndex < totalCount &&
@@ -2040,13 +2078,13 @@ function createProxyHandler<T>(
2040
2078
  endIndex++;
2041
2079
  }
2042
2080
 
2081
+ console.log(
2082
+ `Index changed from ${range.startIndex} to ${potentialStartIndex}. Updating range.`
2083
+ );
2043
2084
  setRange({
2044
- startIndex,
2085
+ startIndex: potentialStartIndex,
2045
2086
  endIndex: Math.min(totalCount, endIndex + overscan),
2046
2087
  });
2047
-
2048
- // Finally, we record that we did the work at THIS scroll position.
2049
- lastUpdateAtScrollTop.current = scrollTop;
2050
2088
  };
2051
2089
 
2052
2090
  container.addEventListener("scroll", handleUserScroll, {
@@ -2054,14 +2092,16 @@ function createProxyHandler<T>(
2054
2092
  });
2055
2093
  return () =>
2056
2094
  container.removeEventListener("scroll", handleUserScroll);
2057
- }, [totalCount, positions, itemHeight, overscan, status]);
2095
+ }, [totalCount, positions, status, range.startIndex]);
2058
2096
 
2097
+ // --- (The rest of your code: scrollToBottom, scrollToIndex, virtualizerProps is fine) ---
2059
2098
  const scrollToBottom = useCallback(() => {
2060
2099
  console.log(
2061
2100
  "USER ACTION: Clicked scroll button -> SCROLLING_TO_BOTTOM"
2062
2101
  );
2102
+ if (totalCount === 0) return;
2063
2103
  setStatus("SCROLLING_TO_BOTTOM");
2064
- }, []);
2104
+ }, [totalCount]);
2065
2105
 
2066
2106
  const scrollToIndex = useCallback(
2067
2107
  (index: number, behavior: ScrollBehavior = "smooth") => {