cogsbox-state 0.5.410 → 0.5.412

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.410",
3
+ "version": "0.5.412",
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,9 @@ 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(false);
1825
+ const previousCountRef = useRef(0);
1826
+ const hasInitializedRef = useRef(false); // Track if we've done initial scroll
1829
1827
 
1830
1828
  // Subscribe to shadow state updates
1831
1829
  useEffect(() => {
@@ -1880,104 +1878,99 @@ function createProxyHandler<T>(
1880
1878
  });
1881
1879
  }, [range.startIndex, range.endIndex, sourceArray, totalCount]);
1882
1880
 
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);
1881
+ // Helper to scroll to last item using stored ref
1882
+ const scrollToLastItem = useCallback(() => {
1883
+ const shadowArray =
1884
+ getGlobalStore.getState().getShadowMetadata(stateKey, path) ||
1885
+ [];
1886
+ const lastIndex = totalCount - 1;
1887
+
1888
+ if (lastIndex >= 0) {
1889
+ const lastItemData = shadowArray[lastIndex];
1890
+ if (lastItemData?.virtualizer?.domRef) {
1891
+ const element = lastItemData.virtualizer.domRef;
1892
+ if (element && element.scrollIntoView) {
1893
+ element.scrollIntoView({
1894
+ behavior: "auto",
1895
+ block: "end",
1896
+ inline: "nearest",
1897
+ });
1898
+ return true;
1899
+ }
1900
+ }
1892
1901
  }
1902
+ return false;
1903
+ }, [stateKey, path, totalCount]);
1893
1904
 
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;
1905
+ // Handle ONLY new items - not height changes
1906
+ useEffect(() => {
1907
+ if (!stickToBottom || totalCount === 0) return;
1898
1908
 
1899
- if (isInitialLoad || isBigJump) {
1900
- // Set programmatic scroll flag BEFORE changing range
1901
- isProgrammaticScrollRef.current = true;
1909
+ const hasNewItems = totalCount > previousCountRef.current;
1902
1910
 
1903
- const newRange = {
1904
- startIndex: Math.max(0, totalCount - 20),
1911
+ // Initial load
1912
+ if (
1913
+ previousCountRef.current === 0 &&
1914
+ totalCount > 0 &&
1915
+ !hasInitializedRef.current
1916
+ ) {
1917
+ hasInitializedRef.current = true;
1918
+ const visibleCount = Math.ceil(
1919
+ (containerRef.current?.clientHeight || 600) / itemHeight
1920
+ );
1921
+ setRange({
1922
+ startIndex: Math.max(
1923
+ 0,
1924
+ totalCount - visibleCount - overscan
1925
+ ),
1905
1926
  endIndex: totalCount,
1906
- };
1907
- setRange(newRange);
1927
+ });
1908
1928
 
1909
- // Reset flag after a delay to ensure scroll events are ignored
1910
1929
  setTimeout(() => {
1911
- isProgrammaticScrollRef.current = false;
1930
+ if (containerRef.current) {
1931
+ containerRef.current.scrollTop =
1932
+ containerRef.current.scrollHeight;
1933
+ wasAtBottomRef.current = true;
1934
+ }
1912
1935
  }, 100);
1913
1936
  }
1937
+ // New items added AFTER initial load
1938
+ else if (hasNewItems && wasAtBottomRef.current) {
1939
+ const visibleCount = Math.ceil(
1940
+ (containerRef.current?.clientHeight || 600) / itemHeight
1941
+ );
1942
+ const newRange = {
1943
+ startIndex: Math.max(
1944
+ 0,
1945
+ totalCount - visibleCount - overscan
1946
+ ),
1947
+ endIndex: totalCount,
1948
+ };
1914
1949
 
1915
- // Keep scrolling to bottom until we're actually there
1916
- let attempts = 0;
1917
- const maxAttempts = 50; // 5 seconds max
1918
-
1919
- scrollToBottomIntervalRef.current = setInterval(() => {
1920
- const container = containerRef.current;
1921
- if (!container) return;
1922
-
1923
- attempts++;
1924
-
1925
- const { scrollTop, scrollHeight, clientHeight } = container;
1926
- const currentBottom = scrollTop + clientHeight;
1927
- const actualBottom = scrollHeight;
1928
- const isAtBottom = actualBottom - currentBottom < 10; // Increased tolerance
1950
+ setRange(newRange);
1929
1951
 
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);
1952
+ setTimeout(() => {
1953
+ scrollToLastItem();
1954
+ }, 50);
1955
+ }
1944
1956
 
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]);
1957
+ previousCountRef.current = totalCount;
1958
+ }, [totalCount]); // ONLY depend on totalCount, not positions!
1953
1959
 
1954
- // Handle user scroll
1960
+ // Handle scroll events
1955
1961
  useEffect(() => {
1956
1962
  const container = containerRef.current;
1957
1963
  if (!container) return;
1958
1964
 
1959
1965
  const handleScroll = () => {
1960
- // Ignore programmatic scrolls
1961
- if (isProgrammaticScrollRef.current) {
1962
- return;
1963
- }
1964
-
1965
- // This is a real user scroll
1966
1966
  const { scrollTop, scrollHeight, clientHeight } = container;
1967
1967
  const distanceFromBottom =
1968
1968
  scrollHeight - scrollTop - clientHeight;
1969
- const isAtBottom = distanceFromBottom < 50; // Increased tolerance
1970
-
1971
- // Stop any auto-scrolling if user scrolls
1972
- if (scrollToBottomIntervalRef.current) {
1973
- clearInterval(scrollToBottomIntervalRef.current);
1974
- scrollToBottomIntervalRef.current = null;
1975
- }
1976
1969
 
1977
- // Only update this for real user scrolls
1978
- shouldStickToBottomRef.current = isAtBottom;
1970
+ // Track if we're at bottom
1971
+ wasAtBottomRef.current = distanceFromBottom < 100;
1979
1972
 
1980
- // Update visible range
1973
+ // Update visible range based on scroll position
1981
1974
  let startIndex = 0;
1982
1975
  for (let i = 0; i < positions.length; i++) {
1983
1976
  if (positions[i]! > scrollTop - itemHeight * overscan) {
@@ -2004,42 +1997,49 @@ function createProxyHandler<T>(
2004
1997
  container.addEventListener("scroll", handleScroll, {
2005
1998
  passive: true,
2006
1999
  });
2007
- handleScroll(); // Initial calculation
2000
+
2001
+ // Just calculate visible range, no scrolling
2002
+ handleScroll();
2008
2003
 
2009
2004
  return () => {
2010
2005
  container.removeEventListener("scroll", handleScroll);
2011
2006
  };
2012
2007
  }, [positions, totalCount, itemHeight, overscan]);
2013
2008
 
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
- );
2009
+ const scrollToBottom = useCallback(() => {
2010
+ wasAtBottomRef.current = true;
2011
+ const scrolled = scrollToLastItem();
2012
+ if (!scrolled && containerRef.current) {
2013
+ containerRef.current.scrollTop =
2014
+ containerRef.current.scrollHeight;
2015
+ }
2016
+ }, [scrollToLastItem]);
2028
2017
 
2029
2018
  const scrollToIndex = useCallback(
2030
2019
  (index: number, behavior: ScrollBehavior = "smooth") => {
2031
- isProgrammaticScrollRef.current = true;
2020
+ const shadowArray =
2021
+ getGlobalStore
2022
+ .getState()
2023
+ .getShadowMetadata(stateKey, path) || [];
2024
+ const itemData = shadowArray[index];
2025
+
2026
+ if (itemData?.virtualizer?.domRef) {
2027
+ const element = itemData.virtualizer.domRef;
2028
+ if (element && element.scrollIntoView) {
2029
+ element.scrollIntoView({ behavior, block: "center" });
2030
+ return;
2031
+ }
2032
+ }
2033
+
2034
+ // Fallback to position-based scrolling
2032
2035
  if (containerRef.current && positions[index] !== undefined) {
2033
2036
  containerRef.current.scrollTo({
2034
2037
  top: positions[index],
2035
2038
  behavior,
2036
2039
  });
2037
2040
  }
2038
- setTimeout(() => {
2039
- isProgrammaticScrollRef.current = false;
2040
- }, 100);
2041
2041
  },
2042
- [positions]
2042
+ [positions, stateKey, path]
2043
2043
  );
2044
2044
 
2045
2045
  const virtualizerProps = {
@@ -2068,128 +2068,6 @@ function createProxyHandler<T>(
2068
2068
  };
2069
2069
  };
2070
2070
  }
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
2071
  if (prop === "stateMapNoRender") {
2194
2072
  return (
2195
2073
  callbackfn: (
@@ -3031,6 +2909,8 @@ export function $cogsSignalStore(proxy: {
3031
2909
  );
3032
2910
  return createElement("text", {}, String(value));
3033
2911
  }
2912
+
2913
+ // Modified CogsItemWrapper that stores the DOM ref
3034
2914
  function CogsItemWrapper({
3035
2915
  stateKey,
3036
2916
  itemComponentId,
@@ -3045,10 +2925,21 @@ function CogsItemWrapper({
3045
2925
  // This hook handles the re-rendering when the item's own data changes.
3046
2926
  const [, forceUpdate] = useState({});
3047
2927
  // This hook measures the element.
3048
- const [ref, bounds] = useMeasure();
2928
+ const [measureRef, bounds] = useMeasure();
2929
+ // Store the actual DOM element
2930
+ const elementRef = useRef<HTMLDivElement | null>(null);
3049
2931
  // This ref prevents sending the same height update repeatedly.
3050
2932
  const lastReportedHeight = useRef<number | null>(null);
3051
2933
 
2934
+ // Combine both refs
2935
+ const setRefs = useCallback(
2936
+ (element: HTMLDivElement | null) => {
2937
+ measureRef(element);
2938
+ elementRef.current = element;
2939
+ },
2940
+ [measureRef]
2941
+ );
2942
+
3052
2943
  // This is the primary effect for this component.
3053
2944
  useEffect(() => {
3054
2945
  // We only report a height if it's a valid number AND it's different
@@ -3057,14 +2948,15 @@ function CogsItemWrapper({
3057
2948
  // Store the new height so we don't report it again.
3058
2949
  lastReportedHeight.current = bounds.height;
3059
2950
 
3060
- // Call the store function to save the height and notify listeners.
2951
+ // Call the store function to save the height AND the ref
3061
2952
  getGlobalStore.getState().setShadowMetadata(stateKey, itemPath, {
3062
2953
  virtualizer: {
3063
2954
  itemHeight: bounds.height,
2955
+ domRef: elementRef.current, // Store the actual DOM element reference
3064
2956
  },
3065
2957
  });
3066
2958
  }
3067
- }, [bounds.height, stateKey, itemPath]); // Reruns whenever the measured height changes.
2959
+ }, [bounds.height, stateKey, itemPath]); // Removed ref.current as dependency
3068
2960
 
3069
2961
  // This effect handles subscribing the item to its own data path for updates.
3070
2962
  useLayoutEffect(() => {
@@ -3093,5 +2985,5 @@ function CogsItemWrapper({
3093
2985
  }, [stateKey, itemComponentId, itemPath.join(".")]);
3094
2986
 
3095
2987
  // The rendered output is a simple div that gets measured.
3096
- return <div ref={ref}>{children}</div>;
2988
+ return <div ref={setRefs}>{children}</div>;
3097
2989
  }