cogsbox-state 0.5.409 → 0.5.411

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.409",
3
+ "version": "0.5.411",
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
@@ -1821,11 +1821,8 @@ function createProxyHandler<T>(
1821
1821
  endIndex: 10,
1822
1822
  });
1823
1823
  const [shadowUpdateTrigger, setShadowUpdateTrigger] = useState(0);
1824
- const isProgrammaticScrollRef = useRef(false); // Track if we're scrolling programmatically
1825
- const shouldStickToBottomRef = useRef(true);
1826
- const scrollToBottomIntervalRef = useRef<NodeJS.Timeout | null>(
1827
- null
1828
- );
1824
+ const wasAtBottomRef = useRef(true);
1825
+ const previousCountRef = useRef(0);
1829
1826
 
1830
1827
  // Subscribe to shadow state updates
1831
1828
  useEffect(() => {
@@ -1880,104 +1877,91 @@ function createProxyHandler<T>(
1880
1877
  });
1881
1878
  }, [range.startIndex, range.endIndex, sourceArray, totalCount]);
1882
1879
 
1883
- // Handle auto-scroll to bottom
1884
- useEffect(() => {
1885
- if (!stickToBottom || !containerRef.current || totalCount === 0)
1886
- return;
1887
- if (!shouldStickToBottomRef.current) return;
1888
-
1889
- // Clear any existing interval
1890
- if (scrollToBottomIntervalRef.current) {
1891
- clearInterval(scrollToBottomIntervalRef.current);
1880
+ // Helper to scroll to last item using stored ref
1881
+ const scrollToLastItem = useCallback(() => {
1882
+ const shadowArray =
1883
+ getGlobalStore.getState().getShadowMetadata(stateKey, path) ||
1884
+ [];
1885
+ const lastIndex = totalCount - 1;
1886
+
1887
+ if (lastIndex >= 0) {
1888
+ const lastItemData = shadowArray[lastIndex];
1889
+ if (lastItemData?.virtualizer?.domRef) {
1890
+ const element = lastItemData.virtualizer.domRef;
1891
+ if (element && element.scrollIntoView) {
1892
+ element.scrollIntoView({
1893
+ behavior: "auto",
1894
+ block: "end",
1895
+ inline: "nearest",
1896
+ });
1897
+ return true;
1898
+ }
1899
+ }
1892
1900
  }
1901
+ return false;
1902
+ }, [stateKey, path, totalCount]);
1893
1903
 
1894
- // For initial load or big jumps, show the end immediately
1895
- const jumpThreshold = 50;
1896
- const isInitialLoad = range.endIndex < jumpThreshold;
1897
- const isBigJump = totalCount > range.endIndex + jumpThreshold;
1904
+ // Handle new items when at bottom
1905
+ useEffect(() => {
1906
+ if (!stickToBottom || totalCount === 0) return;
1898
1907
 
1899
- if (isInitialLoad || isBigJump) {
1900
- // Set programmatic scroll flag BEFORE changing range
1901
- isProgrammaticScrollRef.current = true;
1908
+ const hasNewItems = totalCount > previousCountRef.current;
1909
+ const isInitialLoad =
1910
+ previousCountRef.current === 0 && totalCount > 0;
1902
1911
 
1912
+ if ((hasNewItems || isInitialLoad) && wasAtBottomRef.current) {
1913
+ // First, ensure the last items are in range
1914
+ const visibleCount = Math.ceil(
1915
+ containerRef.current?.clientHeight || 0 / itemHeight
1916
+ );
1903
1917
  const newRange = {
1904
- startIndex: Math.max(0, totalCount - 20),
1918
+ startIndex: Math.max(
1919
+ 0,
1920
+ totalCount - visibleCount - overscan
1921
+ ),
1905
1922
  endIndex: totalCount,
1906
1923
  };
1907
- setRange(newRange);
1908
1924
 
1909
- // Reset flag after a delay to ensure scroll events are ignored
1910
- setTimeout(() => {
1911
- isProgrammaticScrollRef.current = false;
1912
- }, 100);
1913
- }
1914
-
1915
- // Keep scrolling to bottom until we're actually there
1916
- let attempts = 0;
1917
- const maxAttempts = 50; // 5 seconds max
1925
+ setRange(newRange);
1918
1926
 
1919
- scrollToBottomIntervalRef.current = setInterval(() => {
1920
- const container = containerRef.current;
1921
- if (!container) return;
1927
+ // Then scroll to the last item after it renders
1928
+ const timeoutId = setTimeout(() => {
1929
+ const scrolled = scrollToLastItem();
1930
+ if (!scrolled && containerRef.current) {
1931
+ // Fallback if ref not available yet
1932
+ containerRef.current.scrollTop =
1933
+ containerRef.current.scrollHeight;
1934
+ }
1935
+ }, 50);
1922
1936
 
1923
- attempts++;
1937
+ previousCountRef.current = totalCount;
1924
1938
 
1925
- const { scrollTop, scrollHeight, clientHeight } = container;
1926
- const currentBottom = scrollTop + clientHeight;
1927
- const actualBottom = scrollHeight;
1928
- const isAtBottom = actualBottom - currentBottom < 50; // Increased tolerance
1929
-
1930
- if (isAtBottom || attempts >= maxAttempts) {
1931
- clearInterval(scrollToBottomIntervalRef.current!);
1932
- scrollToBottomIntervalRef.current = null;
1933
- } else {
1934
- // Set flag before scrolling
1935
- isProgrammaticScrollRef.current = true;
1936
- container.scrollTop = container.scrollHeight;
1937
-
1938
- // Reset flag after a short delay
1939
- setTimeout(() => {
1940
- isProgrammaticScrollRef.current = false;
1941
- }, 50);
1942
- }
1943
- }, 100);
1939
+ return () => clearTimeout(timeoutId);
1940
+ }
1944
1941
 
1945
- // Cleanup
1946
- return () => {
1947
- if (scrollToBottomIntervalRef.current) {
1948
- clearInterval(scrollToBottomIntervalRef.current);
1949
- scrollToBottomIntervalRef.current = null;
1950
- }
1951
- };
1952
- }, [totalCount, stickToBottom, range.startIndex, range.endIndex]);
1942
+ previousCountRef.current = totalCount;
1943
+ }, [
1944
+ totalCount,
1945
+ stickToBottom,
1946
+ itemHeight,
1947
+ overscan,
1948
+ scrollToLastItem,
1949
+ ]);
1953
1950
 
1954
- // Handle user scroll
1951
+ // Handle scroll events
1955
1952
  useEffect(() => {
1956
1953
  const container = containerRef.current;
1957
1954
  if (!container) return;
1958
1955
 
1959
1956
  const handleScroll = () => {
1960
- // Ignore programmatic scrolls
1961
- if (isProgrammaticScrollRef.current) {
1962
- return;
1963
- }
1964
-
1965
- // This is a real user scroll
1966
1957
  const { scrollTop, scrollHeight, clientHeight } = container;
1967
1958
  const distanceFromBottom =
1968
1959
  scrollHeight - scrollTop - clientHeight;
1969
- const isAtBottom = distanceFromBottom < 50; // Increased tolerance
1970
1960
 
1971
- // Stop any auto-scrolling if user scrolls
1972
- if (scrollToBottomIntervalRef.current) {
1973
- clearInterval(scrollToBottomIntervalRef.current);
1974
- scrollToBottomIntervalRef.current = null;
1975
- }
1976
-
1977
- // Only update this for real user scrolls
1978
- shouldStickToBottomRef.current = isAtBottom;
1961
+ // Track if we're at bottom (with tolerance)
1962
+ wasAtBottomRef.current = distanceFromBottom < 100;
1979
1963
 
1980
- // Update visible range
1964
+ // Update visible range based on scroll position
1981
1965
  let startIndex = 0;
1982
1966
  for (let i = 0; i < positions.length; i++) {
1983
1967
  if (positions[i]! > scrollTop - itemHeight * overscan) {
@@ -2004,42 +1988,53 @@ function createProxyHandler<T>(
2004
1988
  container.addEventListener("scroll", handleScroll, {
2005
1989
  passive: true,
2006
1990
  });
2007
- handleScroll(); // Initial calculation
1991
+
1992
+ // Initial setup
1993
+ if (stickToBottom && totalCount > 0) {
1994
+ // For initial load, jump to bottom
1995
+ container.scrollTop = container.scrollHeight;
1996
+ }
1997
+ handleScroll();
2008
1998
 
2009
1999
  return () => {
2010
2000
  container.removeEventListener("scroll", handleScroll);
2011
2001
  };
2012
- }, [positions, totalCount, itemHeight, overscan]);
2013
-
2014
- const scrollToBottom = useCallback(
2015
- (behavior: ScrollBehavior = "auto") => {
2016
- shouldStickToBottomRef.current = true;
2017
- isProgrammaticScrollRef.current = true;
2018
- if (containerRef.current) {
2019
- containerRef.current.scrollTop =
2020
- containerRef.current.scrollHeight;
2021
- }
2022
- setTimeout(() => {
2023
- isProgrammaticScrollRef.current = false;
2024
- }, 100);
2025
- },
2026
- []
2027
- );
2002
+ }, [positions, totalCount, itemHeight, overscan, stickToBottom]);
2003
+
2004
+ const scrollToBottom = useCallback(() => {
2005
+ wasAtBottomRef.current = true;
2006
+ const scrolled = scrollToLastItem();
2007
+ if (!scrolled && containerRef.current) {
2008
+ containerRef.current.scrollTop =
2009
+ containerRef.current.scrollHeight;
2010
+ }
2011
+ }, [scrollToLastItem]);
2028
2012
 
2029
2013
  const scrollToIndex = useCallback(
2030
2014
  (index: number, behavior: ScrollBehavior = "smooth") => {
2031
- isProgrammaticScrollRef.current = true;
2015
+ const shadowArray =
2016
+ getGlobalStore
2017
+ .getState()
2018
+ .getShadowMetadata(stateKey, path) || [];
2019
+ const itemData = shadowArray[index];
2020
+
2021
+ if (itemData?.virtualizer?.domRef) {
2022
+ const element = itemData.virtualizer.domRef;
2023
+ if (element && element.scrollIntoView) {
2024
+ element.scrollIntoView({ behavior, block: "center" });
2025
+ return;
2026
+ }
2027
+ }
2028
+
2029
+ // Fallback to position-based scrolling
2032
2030
  if (containerRef.current && positions[index] !== undefined) {
2033
2031
  containerRef.current.scrollTo({
2034
2032
  top: positions[index],
2035
2033
  behavior,
2036
2034
  });
2037
2035
  }
2038
- setTimeout(() => {
2039
- isProgrammaticScrollRef.current = false;
2040
- }, 100);
2041
2036
  },
2042
- [positions]
2037
+ [positions, stateKey, path]
2043
2038
  );
2044
2039
 
2045
2040
  const virtualizerProps = {
@@ -2068,128 +2063,6 @@ function createProxyHandler<T>(
2068
2063
  };
2069
2064
  };
2070
2065
  }
2071
- if (prop === "stateSort") {
2072
- return (
2073
- compareFn: (
2074
- a: InferArrayElement<T>,
2075
- b: InferArrayElement<T>
2076
- ) => number
2077
- ) => {
2078
- const sourceWithIndices = getSourceArrayAndIndices();
2079
- const sortedResult = [...sourceWithIndices].sort((a, b) =>
2080
- compareFn(a.item, b.item)
2081
- );
2082
- const newCurrentState = sortedResult.map(({ item }) => item);
2083
- // We construct the meta object with the CORRECT property name: `validIndices`.
2084
- const newMeta = {
2085
- ...meta,
2086
- validIndices: sortedResult.map(
2087
- ({ originalIndex }) => originalIndex
2088
- ),
2089
- };
2090
- return rebuildStateShape(newCurrentState as any, path, newMeta);
2091
- };
2092
- }
2093
-
2094
- if (prop === "stateFilter") {
2095
- return (
2096
- callbackfn: (
2097
- value: InferArrayElement<T>,
2098
- index: number
2099
- ) => boolean
2100
- ) => {
2101
- const sourceWithIndices = getSourceArrayAndIndices();
2102
- const filteredResult = sourceWithIndices.filter(
2103
- ({ item }, index) => callbackfn(item, index)
2104
- );
2105
- const newCurrentState = filteredResult.map(({ item }) => item);
2106
- // We construct the meta object with the CORRECT property name: `validIndices`.
2107
- const newMeta = {
2108
- ...meta,
2109
- validIndices: filteredResult.map(
2110
- ({ originalIndex }) => originalIndex
2111
- ),
2112
- };
2113
- return rebuildStateShape(newCurrentState as any, path, newMeta);
2114
- };
2115
- }
2116
-
2117
- if (prop === "stateMap") {
2118
- return (
2119
- callbackfn: (
2120
- value: InferArrayElement<T>,
2121
- setter: StateObject<InferArrayElement<T>>,
2122
- info: {
2123
- register: () => void;
2124
- index: number;
2125
- originalIndex: number;
2126
- }
2127
- ) => any
2128
- ) => {
2129
- const arrayToMap = getGlobalStore
2130
- .getState()
2131
- .getNestedState(stateKey, path) as any[];
2132
-
2133
- // Defensive check to make sure we are mapping over an array
2134
- if (!Array.isArray(arrayToMap)) {
2135
- console.warn(
2136
- `stateMap called on a non-array value at path: ${path.join(".")}. The current value is:`,
2137
- arrayToMap
2138
- );
2139
- return null;
2140
- }
2141
-
2142
- // If we have validIndices, only map those items
2143
- const indicesToMap =
2144
- meta?.validIndices ||
2145
- Array.from({ length: arrayToMap.length }, (_, i) => i);
2146
-
2147
- return indicesToMap.map((originalIndex, localIndex) => {
2148
- const item = arrayToMap[originalIndex];
2149
- const finalPath = [...path, originalIndex.toString()];
2150
- const setter = rebuildStateShape(item, finalPath, meta);
2151
-
2152
- // Create the register function right here. It closes over the necessary variables.
2153
- const register = () => {
2154
- const [, forceUpdate] = useState({});
2155
- const itemComponentId = `${componentId}-${path.join(".")}-${originalIndex}`;
2156
-
2157
- useLayoutEffect(() => {
2158
- const fullComponentId = `${stateKey}////${itemComponentId}`;
2159
- const stateEntry = getGlobalStore
2160
- .getState()
2161
- .stateComponents.get(stateKey) || {
2162
- components: new Map(),
2163
- };
2164
-
2165
- stateEntry.components.set(fullComponentId, {
2166
- forceUpdate: () => forceUpdate({}),
2167
- paths: new Set([finalPath.join(".")]),
2168
- });
2169
-
2170
- getGlobalStore
2171
- .getState()
2172
- .stateComponents.set(stateKey, stateEntry);
2173
-
2174
- return () => {
2175
- const currentEntry = getGlobalStore
2176
- .getState()
2177
- .stateComponents.get(stateKey);
2178
- if (currentEntry) {
2179
- currentEntry.components.delete(fullComponentId);
2180
- }
2181
- };
2182
- }, [stateKey, itemComponentId]);
2183
- };
2184
-
2185
- return callbackfn(item, setter, {
2186
- register,
2187
- index: localIndex,
2188
- originalIndex,
2189
- });
2190
- });
2191
- };
2192
- }
2193
2066
  if (prop === "stateMapNoRender") {
2194
2067
  return (
2195
2068
  callbackfn: (
@@ -3031,6 +2904,8 @@ export function $cogsSignalStore(proxy: {
3031
2904
  );
3032
2905
  return createElement("text", {}, String(value));
3033
2906
  }
2907
+
2908
+ // Modified CogsItemWrapper that stores the DOM ref
3034
2909
  function CogsItemWrapper({
3035
2910
  stateKey,
3036
2911
  itemComponentId,
@@ -3045,10 +2920,21 @@ function CogsItemWrapper({
3045
2920
  // This hook handles the re-rendering when the item's own data changes.
3046
2921
  const [, forceUpdate] = useState({});
3047
2922
  // This hook measures the element.
3048
- const [ref, bounds] = useMeasure();
2923
+ const [measureRef, bounds] = useMeasure();
2924
+ // Store the actual DOM element
2925
+ const elementRef = useRef<HTMLDivElement | null>(null);
3049
2926
  // This ref prevents sending the same height update repeatedly.
3050
2927
  const lastReportedHeight = useRef<number | null>(null);
3051
2928
 
2929
+ // Combine both refs
2930
+ const setRefs = useCallback(
2931
+ (element: HTMLDivElement | null) => {
2932
+ measureRef(element);
2933
+ elementRef.current = element;
2934
+ },
2935
+ [measureRef]
2936
+ );
2937
+
3052
2938
  // This is the primary effect for this component.
3053
2939
  useEffect(() => {
3054
2940
  // We only report a height if it's a valid number AND it's different
@@ -3057,14 +2943,15 @@ function CogsItemWrapper({
3057
2943
  // Store the new height so we don't report it again.
3058
2944
  lastReportedHeight.current = bounds.height;
3059
2945
 
3060
- // Call the store function to save the height and notify listeners.
2946
+ // Call the store function to save the height AND the ref
3061
2947
  getGlobalStore.getState().setShadowMetadata(stateKey, itemPath, {
3062
2948
  virtualizer: {
3063
2949
  itemHeight: bounds.height,
2950
+ domRef: elementRef.current, // Store the actual DOM element reference
3064
2951
  },
3065
2952
  });
3066
2953
  }
3067
- }, [bounds.height, stateKey, itemPath]); // Reruns whenever the measured height changes.
2954
+ }, [bounds.height, stateKey, itemPath]); // Removed ref.current as dependency
3068
2955
 
3069
2956
  // This effect handles subscribing the item to its own data path for updates.
3070
2957
  useLayoutEffect(() => {
@@ -3093,5 +2980,5 @@ function CogsItemWrapper({
3093
2980
  }, [stateKey, itemComponentId, itemPath.join(".")]);
3094
2981
 
3095
2982
  // The rendered output is a simple div that gets measured.
3096
- return <div ref={ref}>{children}</div>;
2983
+ return <div ref={setRefs}>{children}</div>;
3097
2984
  }