From fba410c6862e1288011bc26b35c1c075b1a5fc70 Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Tue, 19 May 2026 18:01:53 -0500 Subject: [PATCH 1/8] feat: add stable dataset list layers Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/LegendListDatasets.test.tsx | 282 ++++++++ .../reanimated.itemLayoutAnimation.test.tsx | 7 +- .../reanimated.sharedValues.test.tsx | 4 + example/screens/fixtures/datasets-tabs.tsx | 98 +++ example/screens/routes.tsx | 10 + src/components/DatasetLayerInner.tsx | 632 ++++++++++++++++++ src/components/LegendListDatasets.tsx | 554 +++++++++++++++ src/entrypoints/shared.ts | 8 + src/react-native.ts | 9 +- src/react.ts | 9 +- 10 files changed, 1610 insertions(+), 3 deletions(-) create mode 100644 __tests__/components/LegendListDatasets.test.tsx create mode 100644 example/screens/fixtures/datasets-tabs.tsx create mode 100644 src/components/DatasetLayerInner.tsx create mode 100644 src/components/LegendListDatasets.tsx diff --git a/__tests__/components/LegendListDatasets.test.tsx b/__tests__/components/LegendListDatasets.test.tsx new file mode 100644 index 00000000..41d2b8b0 --- /dev/null +++ b/__tests__/components/LegendListDatasets.test.tsx @@ -0,0 +1,282 @@ +import * as React from "react"; +import { Text, View } from "react-native"; + +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { useArr$ } from "../../src/state/state"; +import TestRenderer, { act } from "../helpers/testRenderer"; +import { registerBaseModuleMocks } from "../setup"; + +const layoutEvent = { + nativeEvent: { layout: { height: 200, width: 320, x: 0, y: 0 } }, +}; + +function TestContainer({ getRenderedItem, id }: { getRenderedItem: (key: string) => any; id: number }) { + const [data, itemKey, extraData] = useArr$([ + `containerItemData${id}` as const, + `containerItemKey${id}` as const, + "extraData", + ]); + const renderedItemInfo = React.useMemo( + () => (itemKey !== undefined ? getRenderedItem(itemKey) : null), + [data, extraData, getRenderedItem, itemKey], + ); + + return <>{renderedItemInfo?.renderedItem ?? null}; +} + +function TestContainers({ getRenderedItem }: { getRenderedItem: (key: string) => any }) { + const [numContainersPooled = 0] = useArr$(["numContainersPooled"]); + + return ( + <> + {Array.from({ length: numContainersPooled }, (_, id) => ( + + ))} + + ); +} + +function registerDatasetsMocks() { + mock.module("@/components/Containers", () => ({ + Containers: TestContainers, + })); +} + +function collectTextFromTree(node: any, values: string[] = []) { + if (node == null) { + return values; + } + + if (typeof node === "string") { + values.push(node); + return values; + } + + if (Array.isArray(node)) { + for (const child of node) { + collectTextFromTree(child, values); + } + return values; + } + + if (node.children) { + collectTextFromTree(node.children, values); + } + + return values; +} + +function getRenderedLabels(renderer: TestRenderer.ReactTestRenderer) { + return Array.from(new Set(collectTextFromTree(renderer.toJSON()))); +} + +async function flushAsync() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function flushFrames(count = 4) { + for (let i = 0; i < count; i++) { + await flushAsync(); + } +} + +async function createRenderer(element: React.ReactElement) { + let renderer: ReturnType; + await act(async () => { + renderer = TestRenderer.create(element); + }); + return renderer!; +} + +async function layoutDefaultScrollView(renderer: ReturnType) { + await act(async () => { + renderer.root.findByType("AnimatedScrollView").props.onLayout?.(layoutEvent as any); + }); +} + +async function cleanupRenderer(renderer: ReturnType) { + await act(async () => { + renderer.unmount(); + }); +} + +beforeEach(() => { + mock.restore(); + registerBaseModuleMocks(); + registerDatasetsMocks(); +}); + +afterEach(() => { + mock.restore(); + registerBaseModuleMocks(); +}); + +describe("LegendListDatasets", () => { + it("keeps hidden dataset rows mounted when switching active keys with display hiding", async () => { + const events: string[] = []; + const Row = ({ id, label }: { id: string; label: string }) => { + React.useEffect(() => { + events.push(`mount:${id}`); + return () => { + events.push(`unmount:${id}`); + }; + }, [id]); + + return {label}; + }; + + const datasets = [ + { + data: [{ id: "spot-1", label: "Spot" }], + key: "spot", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { id: string; label: string } }) => ( + + ), + }, + { + data: [{ id: "futures-1", label: "Futures" }], + key: "futures", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { id: string; label: string } }) => ( + + ), + }, + ]; + + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?stable-shell"); + const renderer = await createRenderer( + 50} + inactiveBehavior="hide" + recycleItems={false} + staggerMountMs={0} + />, + ); + + await flushFrames(); + await layoutDefaultScrollView(renderer); + await flushFrames(8); + + expect(getRenderedLabels(renderer)).toContain("Spot"); + expect(getRenderedLabels(renderer)).toContain("Futures"); + expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); + + await act(async () => { + renderer.update( + 50} + inactiveBehavior="hide" + recycleItems={false} + staggerMountMs={0} + />, + ); + }); + await flushFrames(4); + + expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); + + await cleanupRenderer(renderer); + }); + + it("renders ListEmptyComponent for the active dataset only", async () => { + const datasets = [ + { + data: [] as Array<{ id: string; label: string }>, + key: "empty", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { label: string } }) => {item.label}, + }, + { + data: [{ id: "spot-1", label: "Spot" }], + key: "spot", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { label: string } }) => {item.label}, + }, + ]; + + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?active-empty"); + const renderer = await createRenderer( + Empty active dataset} + recycleItems={false} + staggerMountMs={0} + />, + ); + + expect(getRenderedLabels(renderer)).toContain("Empty active dataset"); + + await act(async () => { + renderer.update( + Empty active dataset} + recycleItems={false} + staggerMountMs={0} + />, + ); + }); + + expect(getRenderedLabels(renderer)).not.toContain("Empty active dataset"); + + await cleanupRenderer(renderer); + }); + + it("shares the outer ScrollView ref with dataset imperative handles", async () => { + const scrollRef = React.createRef(); + const scrollMethods = { + flashScrollIndicators: () => {}, + getScrollableNode: () => ({}), + getScrollResponder: () => null, + measure: (cb: (x: number, y: number, width: number, height: number) => void) => cb(0, 0, 320, 200), + scrollTo: () => {}, + scrollToEnd: () => {}, + }; + const ScrollHost = React.forwardRef(({ children, ...props }, ref) => { + React.useImperativeHandle(ref, () => scrollMethods, []); + return {children}; + }); + const datasets = [ + { + data: [{ id: "spot-1", label: "Spot" }], + key: "spot", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { label: string } }) => {item.label}, + }, + ]; + + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?shared-ref"); + const renderer = await createRenderer( + } + staggerMountMs={0} + />, + ); + + await flushFrames(); + + expect(scrollRef.current?.getNativeScrollRef()).toBe(scrollMethods); + + await cleanupRenderer(renderer); + }); +}); diff --git a/__tests__/integrations/reanimated.itemLayoutAnimation.test.tsx b/__tests__/integrations/reanimated.itemLayoutAnimation.test.tsx index 1c17f3c2..5648fd68 100644 --- a/__tests__/integrations/reanimated.itemLayoutAnimation.test.tsx +++ b/__tests__/integrations/reanimated.itemLayoutAnimation.test.tsx @@ -7,8 +7,10 @@ import { getStickyPushLimit } from "../../src/components/stickyPositionUtils"; import { POSITION_OUT_OF_VIEW } from "../../src/constants"; import { IsNewArchitecture } from "../../src/constants-platform"; import { useCombinedRef } from "../../src/hooks/useCombinedRef"; +import { useLatestRef } from "../../src/hooks/useLatestRef"; +import { useStableRenderComponent } from "../../src/hooks/useStableRenderComponent"; import { peek$, StateProvider, set$, useArr$, useStateContext } from "../../src/state/state"; -import { typedMemo } from "../../src/types.internal"; +import { typedForwardRef, typedMemo } from "../../src/types.internal"; import { getComponent } from "../../src/utils/getComponent"; import { createMockState } from "../__mocks__/createMockState"; import TestRenderer, { act } from "../helpers/testRenderer"; @@ -83,9 +85,12 @@ function registerLegendListModuleMock(isNewArchitecture = IsNewArchitecture) { IsNewArchitecture: isNewArchitecture, POSITION_OUT_OF_VIEW, peek$, + typedForwardRef, typedMemo, useArr$, useCombinedRef, + useLatestRef, + useStableRenderComponent, useStateContext, }, LegendList: LegendListMock, diff --git a/__tests__/integrations/reanimated.sharedValues.test.tsx b/__tests__/integrations/reanimated.sharedValues.test.tsx index ee041f1c..e308998f 100644 --- a/__tests__/integrations/reanimated.sharedValues.test.tsx +++ b/__tests__/integrations/reanimated.sharedValues.test.tsx @@ -7,6 +7,8 @@ import { getStickyPushLimit } from "../../src/components/stickyPositionUtils"; import { POSITION_OUT_OF_VIEW } from "../../src/constants"; import { IsNewArchitecture } from "../../src/constants-platform"; import { useCombinedRef } from "../../src/hooks/useCombinedRef"; +import { useLatestRef } from "../../src/hooks/useLatestRef"; +import { useStableRenderComponent } from "../../src/hooks/useStableRenderComponent"; import { peek$, useArr$, useStateContext } from "../../src/state/state"; import { typedForwardRef, typedMemo } from "../../src/types.internal"; import { getComponent } from "../../src/utils/getComponent"; @@ -149,6 +151,8 @@ function registerLegendListModuleMock(isNewArchitecture = IsNewArchitecture) { typedMemo, useArr$, useCombinedRef, + useLatestRef, + useStableRenderComponent, useStateContext, }, LegendList: LegendListMock, diff --git a/example/screens/fixtures/datasets-tabs.tsx b/example/screens/fixtures/datasets-tabs.tsx new file mode 100644 index 00000000..4460f6b5 --- /dev/null +++ b/example/screens/fixtures/datasets-tabs.tsx @@ -0,0 +1,98 @@ +import { useMemo, useState } from "react"; +import { Pressable, Text, View } from "react-native"; + +import { LegendListDatasets } from "@legendapp/list/react-native"; + +type Item = { id: string; title: string; subtitle: string }; + +function makeItems(prefix: string, count: number): Item[] { + return Array.from({ length: count }, (_, i) => ({ + id: `${prefix}-${i}`, + subtitle: `subtitle for ${prefix} ${i}`, + title: `${prefix} ${i}`, + })); +} + +const RED = makeItems("Red", 500); +const GREEN = makeItems("Green", 500); +const BLUE = makeItems("Blue", 500); + +const TABS = [ + { color: "#ffcccc", data: RED, key: "red", label: "Red" }, + { color: "#ccffcc", data: GREEN, key: "green", label: "Green" }, + { color: "#cce4ff", data: BLUE, key: "blue", label: "Blue" }, +]; + +let headerRenderCount = 0; + +function SharedHeader() { + headerRenderCount += 1; + const renderedAt = headerRenderCount; + return ( + + Shared Header + + Header render count: {renderedAt} (should stay at 1 across tab switches) + + + ); +} + +export default function DatasetsTabsFixture() { + const [active, setActive] = useState("red"); + + const datasets = useMemo( + () => + TABS.map((t) => ({ + data: t.data, + estimatedItemSize: 70, + key: t.key, + keyExtractor: (item: Item) => item.id, + renderItem: ({ item }: { item: Item }) => ( + + {item.title} + {item.subtitle} + + ), + })), + [], + ); + + return ( + + + {TABS.map((t) => ( + setActive(t.key)} + style={{ + alignItems: "center", + backgroundColor: active === t.key ? "#333" : "#bbb", + borderRadius: 6, + flex: 1, + paddingVertical: 10, + }} + > + {t.label} + + ))} + + } + recycleItems + staggerMountMs={100} + /> + + ); +} diff --git a/example/screens/routes.tsx b/example/screens/routes.tsx index f2fe60ca..1ab42ecb 100644 --- a/example/screens/routes.tsx +++ b/example/screens/routes.tsx @@ -38,6 +38,7 @@ import CountriesReorderFixture from "~/screens/fixtures/countries-reorder"; import CountriesWithHeadersFixture from "~/screens/fixtures/countries-with-headers"; import CountriesWithHeadersFixedFixture from "~/screens/fixtures/countries-with-headers-fixed"; import CountriesWithHeadersStickyFixture from "~/screens/fixtures/countries-with-headers-sticky"; +import DatasetsTabsFixture from "~/screens/fixtures/datasets-tabs"; import ExtraDataFixture from "~/screens/fixtures/extra-data"; import FilterElementsFixture from "~/screens/fixtures/filter-elements"; import HorizontalCrossAxisFixture from "~/screens/fixtures/horizontal-cross-axis"; @@ -459,6 +460,15 @@ export const FIXTURE_ROUTES: FixtureRouteDefinition[] = [ slug: "cards", title: "Cards", }, + { + component: DatasetsTabsFixture, + description: "LegendListDatasets v1: shared ScrollView + header, N datasets, tab switching.", + groupKey: "comparison", + groupTitle: "Comparisons & Media", + kind: "fixture", + slug: "datasets-tabs", + title: "Datasets (Tabs)", + }, { component: MoviesLFixture, description: "Media browsing layout with posters and dense metadata.", diff --git a/src/components/DatasetLayerInner.tsx b/src/components/DatasetLayerInner.tsx new file mode 100644 index 00000000..c469376a --- /dev/null +++ b/src/components/DatasetLayerInner.tsx @@ -0,0 +1,632 @@ +// DatasetLayerInner — headless per-dataset renderer used by LegendListDatasets. +// +// This is a copy-fork of LegendListInner (src/components/LegendList.tsx). It owns +// per-dataset state (ctx.state, MVCP, viewability, anchored-end, snap, sticky data), +// but does NOT own the ScrollView, scroll handling, layout sync, refScroller, or the +// header/footer — those live in the shared outer (LegendListDatasets). +// +// It renders only (plus per-ctx ), absolutely positioned +// inside the outer ContentArea so that N layers can stack at the same coordinates. + +import * as React from "react"; +import { type ForwardedRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react"; + +import { Containers } from "@/components/Containers"; +import { ScrollAdjust } from "@/components/ScrollAdjust"; +import { useDevChecks } from "@/components/useDevChecks"; +import { IsNewArchitecture } from "@/constants-platform"; +import { calculateItemsInView } from "@/core/calculateItemsInView"; +import { checkResetContainers } from "@/core/checkResetContainers"; +import { checkStructuralDataChange } from "@/core/checkStructuralDataChange"; +import { doInitialAllocateContainers } from "@/core/doInitialAllocateContainers"; +import { clearPreservedInitialScrollTarget } from "@/core/finishInitialScroll"; +import { handleInitialScrollDataChange } from "@/core/initialScrollLifecycle"; +import { resetLayoutCachesForDataChange } from "@/core/resetLayoutCachesForDataChange"; +import { ScrollAdjustHandler } from "@/core/ScrollAdjustHandler"; +import { maybeUpdateAnchoredEndSpace } from "@/core/updateAnchoredEndSpace"; +import { updateContentInsetEndAdjustment } from "@/core/updateContentInsetEndAdjustment"; +import { updateItemPositions } from "@/core/updateItemPositions"; +import { updateItemSize } from "@/core/updateItemSize"; +import { updateScroll } from "@/core/updateScroll"; +import { useWrapIfItem } from "@/core/useWrapIfItem"; +import { setupViewability } from "@/core/viewability"; +import { useInit } from "@/hooks/useInit"; +import type { AnimatedValue } from "@/platform/Animated"; +import { getWindowSize } from "@/platform/getWindowSize"; +import { Platform } from "@/platform/Platform"; +import type { LooseScrollViewProps, ViewStyle } from "@/platform/scrollview-types"; +import type { StateContext } from "@/state/state"; +import { listen$, peek$, set$, useStateContext } from "@/state/state"; +import type { LegendListMetrics, LegendListRef, LegendListRenderItemProps } from "@/types.base"; +import type { InternalState, LegendListPropsBase, LegendListScrollerRef } from "@/types.internal"; +import { typedForwardRef } from "@/types.internal"; +import type { StylesAsSharedValue } from "@/typesInternal"; +import { createColumnWrapperStyle } from "@/utils/createColumnWrapperStyle"; +import { createImperativeHandle } from "@/utils/createImperativeHandle"; +import { IS_DEV } from "@/utils/devEnvironment"; +import { getAlwaysRenderIndices } from "@/utils/getAlwaysRenderIndices"; +import { getId } from "@/utils/getId"; +import { getRenderedItem } from "@/utils/getRenderedItem"; +import { extractPadding, warnDevOnce } from "@/utils/helpers"; +import { normalizeMaintainScrollAtEnd } from "@/utils/normalizeMaintainScrollAtEnd"; +import { normalizeMaintainVisibleContentPosition } from "@/utils/normalizeMaintainVisibleContentPosition"; +import { requestAdjust } from "@/utils/requestAdjust"; +import { isHorizontalRTLProps } from "@/utils/rtl"; +import { setPaddingTop } from "@/utils/setPaddingTop"; +import { updateSnapToOffsets } from "@/utils/updateSnapToOffsets"; + +export interface DatasetLayerHandle { + ctx: StateContext; + setCanRender: (value: boolean) => void; + dataLength: number; + horizontal: boolean; + usesBootstrapInitialScroll: boolean; + initialScroll: InternalState["initialScroll"]; + stylePaddingBottom: number; +} + +export interface DatasetLayerInnerProps extends Omit, "children"> { + childrenMode?: boolean; + data: ReadonlyArray; + renderItem: (props: LegendListRenderItemProps) => React.ReactNode; + // Shared scroll resources (from outer) + sharedAnimatedScrollY: AnimatedValue; + sharedRefScroller: React.RefObject; + // Is this layer currently active? Affects sticky/MVCP gating. + isActive: boolean; + // Outer registers this layer so it can fan out layout/header/scroll/padding events + registerLayer: (key: string, handle: DatasetLayerHandle | null) => void; + layerKey: string; +} + +// biome-ignore lint/nursery/noShadow: const function name shadowing is intentional +export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( + props: DatasetLayerInnerProps, + forwardedRef: ForwardedRef, +) { + if (props.recycleItems === undefined) { + warnDevOnce( + "recycleItems-omitted", + "recycleItems was not provided to LegendListDatasets. It defaults to false. Set it explicitly to true for better performance with recycling-aware rows, or false to preserve remount-on-reuse.", + ); + } + const { + alignItemsAtEnd = false, + anchoredEndSpace, + alwaysRender, + columnWrapperStyle, + contentContainerStyle: contentContainerStyleProp, + contentInset, + data: dataProp = [], + dataVersion, + drawDistance = 250, + contentInsetEndAdjustment, + estimatedItemSize = 100, + estimatedListSize, + extraData, + getEstimatedItemSize, + getFixedItemSize, + getItemType, + horizontal, + rtl, + initialContainerPoolRatio = 3, + estimatedHeaderSize, + initialScrollAtEnd = false, + initialScrollIndex: initialScrollIndexProp, + initialScrollOffset: initialScrollOffsetProp, + itemsAreEqual, + keyExtractor: keyExtractorProp, + ListFooterComponent: _ListFooterComponent, + ListHeaderComponent: _ListHeaderComponent, + maintainScrollAtEnd = false, + maintainScrollAtEndThreshold = 0.1, + maintainVisibleContentPosition: maintainVisibleContentPositionProp, + numColumns: numColumnsProp = 1, + overrideItemLayout, + onEndReached, + onEndReachedThreshold = 0.5, + onItemSizeChanged, + onMetricsChange, + onLoad, + onScroll: onScrollProp, + onStartReached, + onStartReachedThreshold = 0.5, + onStickyHeaderChange, + onViewableItemsChanged, + recycleItems = false, + snapToIndices, + stickyHeaderIndices: stickyHeaderIndicesProp, + stickyIndices: stickyIndicesDeprecated, + style: styleProp, + useWindowScroll: _useWindowScroll = false, + viewabilityConfig, + viewabilityConfigCallbackPairs, + ItemSeparatorComponent, + stickyHeaderConfig, + // Shared + sharedAnimatedScrollY, + sharedRefScroller, + isActive: _isActive, + registerLayer, + layerKey, + } = props as DatasetLayerInnerProps & { + ItemSeparatorComponent?: React.ComponentType; + stickyHeaderConfig?: any; + }; + + const animatedPropsInternal = (props as any).animatedPropsInternal as StylesAsSharedValue; + const positionComponentInternal = (props as any).positionComponentInternal as React.ComponentType | undefined; + const stickyPositionComponentInternal = (props as any).stickyPositionComponentInternal as + | React.ComponentType + | undefined; + + // Re-derive padding the same way LegendListInner does. + const baseContent = contentContainerStyleProp as ViewStyle | undefined; + const stylePaddingTopState = extractPadding(styleProp as any, baseContent as any, "Top"); + const stylePaddingBottomState = extractPadding(styleProp as any, baseContent as any, "Bottom"); + const stylePaddingLeftState = extractPadding(styleProp as any, baseContent as any, "Left"); + const stylePaddingRightState = extractPadding(styleProp as any, baseContent as any, "Right"); + + const maintainScrollAtEndConfig = normalizeMaintainScrollAtEnd(maintainScrollAtEnd); + const maintainVisibleContentPositionConfig = normalizeMaintainVisibleContentPosition( + maintainVisibleContentPositionProp, + ); + + const hasInitialScrollIndex = initialScrollIndexProp !== undefined && initialScrollIndexProp !== null; + const hasInitialScrollOffset = initialScrollOffsetProp !== undefined && initialScrollOffsetProp !== null; + const shouldInitializeHorizontalRTL = + !initialScrollAtEnd && + !hasInitialScrollIndex && + !hasInitialScrollOffset && + isHorizontalRTLProps({ horizontal, rtl }); + const initialScrollUsesOffsetOnly = + !initialScrollAtEnd && !hasInitialScrollIndex && (hasInitialScrollOffset || shouldInitializeHorizontalRTL); + const usesBootstrapInitialScroll = initialScrollAtEnd || hasInitialScrollIndex; + const initialScrollProp: InternalState["initialScroll"] = initialScrollAtEnd + ? { + index: Math.max(0, dataProp.length - 1), + preserveForBottomPadding: true, + viewOffset: -stylePaddingBottomState, + viewPosition: 1, + } + : hasInitialScrollIndex + ? typeof initialScrollIndexProp === "object" + ? { + index: initialScrollIndexProp.index ?? 0, + preserveForBottomPadding: + initialScrollIndexProp.viewOffset === undefined && initialScrollIndexProp.viewPosition === 1 + ? true + : undefined, + viewOffset: + initialScrollIndexProp.viewOffset ?? + (initialScrollIndexProp.viewPosition === 1 ? -stylePaddingBottomState : 0), + viewPosition: initialScrollIndexProp.viewPosition ?? 0, + } + : { + index: initialScrollIndexProp ?? 0, + viewOffset: initialScrollOffsetProp ?? 0, + } + : initialScrollUsesOffsetOnly + ? { + contentOffset: initialScrollOffsetProp ?? 0, + index: 0, + viewOffset: 0, + } + : undefined; + + const [canRender, setCanRender] = React.useState(!IsNewArchitecture); + + const ctx = useStateContext(); + ctx.columnWrapperStyle = + columnWrapperStyle || (baseContent ? createColumnWrapperStyle(baseContent as any) : undefined); + + const keyExtractor = keyExtractorProp ?? ((_item: T, index: number) => index.toString()); + const stickyHeaderIndices = stickyHeaderIndicesProp ?? stickyIndicesDeprecated; + const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : undefined; + const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); + const alwaysRenderIndices = useMemo(() => { + const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace?.anchorIndex); + return { arr: indices, set: new Set(indices) }; + }, [ + anchoredEndSpace?.anchorIndex, + alwaysRender?.top, + alwaysRender?.bottom, + alwaysRender?.indices?.join(","), + alwaysRender?.keys?.join(","), + dataProp, + dataVersion, + keyExtractor, + ]); + + const refState = useRef(undefined); + const hasOverrideItemLayout = !!overrideItemLayout; + const prevHasOverrideItemLayout = useRef(hasOverrideItemLayout); + + if (!refState.current) { + if (!ctx.state) { + const initialScrollLength = (estimatedListSize ?? + (IsNewArchitecture ? { height: 0, width: 0 } : getWindowSize()))[horizontal ? "width" : "height"]; + + // Overwrite the per-StateProvider animatedScrollY with the SHARED one so all + // layers' sticky/position math drives off a single scroll Animated.Value. + (ctx as any).animatedScrollY = sharedAnimatedScrollY; + + ctx.state = { + averageSizes: {}, + columnSpans: [], + columns: [], + containerItemKeys: new Map(), + containerItemTypes: new Map(), + contentInsetOverride: undefined, + dataChangeEpoch: 0, + dataChangeNeedsScrollUpdate: false, + didColumnsChange: false, + didDataChange: false, + enableScrollForNextCalculateItemsInView: true, + endBuffered: -1, + endNoBuffer: -1, + endReachedSnapshot: undefined, + firstFullyOnScreenIndex: -1, + idCache: [], + idsInView: [], + indexByKey: new Map(), + initialScroll: initialScrollProp, + initialScrollSession: initialScrollProp + ? { + kind: initialScrollUsesOffsetOnly ? "offset" : "bootstrap", + previousDataLength: dataProp.length, + } + : undefined, + isEndReached: null, + isFirst: true, + isStartReached: null, + lastBatchingAction: Date.now(), + lastLayout: undefined, + lastScrollDelta: 0, + loadStartTime: Date.now(), + minIndexSizeChanged: 0, + nativeContentInset: undefined, + nativeMarginTop: 0, + pendingDataComparison: undefined, + pendingNativeMVCPAdjust: undefined, + positions: [], + props: {} as any, + queuedCalculateItemsInView: 0, + refScroller: sharedRefScroller, + scroll: 0, + scrollAdjustHandler: new ScrollAdjustHandler(ctx), + scrollForNextCalculateItemsInView: undefined, + scrollHistory: [], + scrollLength: initialScrollLength, + scrollPending: 0, + scrollPrev: 0, + scrollPrevTime: 0, + scrollProcessingEnabled: true, + scrollTime: 0, + sizes: new Map(), + sizesKnown: new Map(), + startBuffered: -1, + startNoBuffer: -1, + startReachedSnapshot: undefined, + startReachedSnapshotDataChangeEpoch: undefined, + stickyContainerPool: new Set(), + stickyContainers: new Map(), + timeouts: new Set(), + totalSize: 0, + viewabilityConfigCallbackPairs: undefined as never, + }; + + const internalState = ctx.state; + internalState.triggerCalculateItemsInView = (params) => calculateItemsInView(ctx, params); + internalState.reprocessCurrentScroll = () => updateScroll(ctx, internalState.scroll, true); + + set$(ctx, "maintainVisibleContentPosition", maintainVisibleContentPositionConfig); + set$(ctx, "extraData", extraData); + if (estimatedHeaderSize !== undefined) { + set$(ctx, "headerSize", estimatedHeaderSize); + } + } + refState.current = ctx.state; + } + + const state = refState.current!; + const isFirstLocal = state.isFirst; + const previousNumColumnsProp = state.props.numColumns; + + state.didColumnsChange = numColumnsProp !== previousNumColumnsProp; + const didDataReferenceChangeLocal = state.props.data !== dataProp; + const didDataVersionChangeLocal = state.props.dataVersion !== dataVersion; + const didDataChangeLocal = + didDataVersionChangeLocal || + (didDataReferenceChangeLocal && checkStructuralDataChange(state, dataProp, state.props.data)); + if ( + didDataChangeLocal && + !initialScrollAtEnd && + state.didFinishInitialScroll && + state.initialScroll?.viewPosition === 1 && + state.props.data.length > 0 + ) { + clearPreservedInitialScrollTarget(state); + } + if (didDataChangeLocal) { + state.dataChangeEpoch += 1; + state.dataChangeNeedsScrollUpdate = true; + state.didDataChange = true; + state.previousData = state.props.data; + } + const anchoredEndSpaceResolved = + Platform.OS === "web" && anchoredEndSpace ? { ...anchoredEndSpace, includeInEndInset: true } : anchoredEndSpace; + const didAnchoredEndSpaceAnchorIndexChange = + !isFirstLocal && + !didDataChangeLocal && + state.props.anchoredEndSpace?.anchorIndex !== anchoredEndSpaceResolved?.anchorIndex; + + state.props = { + alignItemsAtEnd, + alwaysRender, + alwaysRenderIndicesArr: alwaysRenderIndices.arr, + alwaysRenderIndicesSet: alwaysRenderIndices.set, + anchoredEndSpace: anchoredEndSpaceResolved, + animatedProps: animatedPropsInternal, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, + data: dataProp, + dataVersion, + drawDistance, + estimatedItemSize, + getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize), + getFixedItemSize: useWrapIfItem(getFixedItemSize), + getItemType: useWrapIfItem(getItemType), + horizontal: !!horizontal, + initialContainerPoolRatio, + itemsAreEqual, + keyExtractor: useWrapIfItem(keyExtractor), + maintainScrollAtEnd: maintainScrollAtEndConfig, + maintainScrollAtEndThreshold, + maintainVisibleContentPosition: maintainVisibleContentPositionConfig, + numColumns: numColumnsProp, + onEndReached, + onEndReachedThreshold, + onItemSizeChanged, + onLoad, + onScroll: onScrollProp, + onStartReached, + onStartReachedThreshold, + onStickyHeaderChange, + overrideItemLayout, + positionComponentInternal, + recycleItems: !!recycleItems, + renderItem: props.renderItem!, + rtl, + snapToIndices, + stickyIndicesArr: stickyHeaderIndices ?? [], + stickyIndicesSet: useMemo(() => new Set(stickyHeaderIndices ?? []), [stickyHeaderIndices?.join(",")]), + stickyPositionComponentInternal, + stylePaddingBottom: stylePaddingBottomState, + stylePaddingLeft: stylePaddingLeftState, + stylePaddingRight: stylePaddingRightState, + stylePaddingTop: stylePaddingTopState, + useWindowScroll: false, + }; + + state.refScroller = sharedRefScroller; + + // Register / unregister this layer with the outer for fan-out wiring. + useLayoutEffect(() => { + registerLayer(layerKey, { + ctx, + dataLength: dataProp.length, + horizontal: !!horizontal, + initialScroll: state.initialScroll, + setCanRender, + stylePaddingBottom: stylePaddingBottomState, + usesBootstrapInitialScroll, + }); + return () => registerLayer(layerKey, null); + // Re-register when these load-bearing values change so outer's bootstrap + // footer-layout handler has current data. + }, [ + ctx, + layerKey, + registerLayer, + dataProp.length, + horizontal, + usesBootstrapInitialScroll, + stylePaddingBottomState, + ]); + + const memoizedLastItemKeys = useMemo(() => { + if (!dataProp.length) return []; + return Array.from({ length: Math.min(numColumnsProp, dataProp.length) }, (_, i) => + getId(state, dataProp.length - 1 - i), + ); + }, [dataProp, dataVersion, numColumnsProp]); + + const initializeStateVars = (shouldAdjustPadding: boolean) => { + set$(ctx, "lastItemKeys", memoizedLastItemKeys); + set$(ctx, "numColumns", numColumnsProp); + + const prevPaddingTop = peek$(ctx, "stylePaddingTop"); + setPaddingTop(ctx, { stylePaddingTop: stylePaddingTopState }); + refState.current!.props.stylePaddingBottom = stylePaddingBottomState; + + let paddingDiff = stylePaddingTopState - prevPaddingTop; + if ( + shouldAdjustPadding && + maintainVisibleContentPositionConfig.size && + paddingDiff && + prevPaddingTop !== undefined && + Platform.OS === "ios" + ) { + if (state.scroll < 0) { + paddingDiff += state.scroll; + } + requestAdjust(ctx, paddingDiff); + } + }; + + if (isFirstLocal) { + initializeStateVars(false); + resetLayoutCachesForDataChange(state); + updateItemPositions(ctx, /*dataChanged*/ true); + } + + if (isFirstLocal || didDataChangeLocal || numColumnsProp !== peek$(ctx, "numColumns")) { + refState.current.lastBatchingAction = Date.now(); + if (!keyExtractorProp && !isFirstLocal && didDataChangeLocal) { + refState.current.sizes.clear(); + refState.current.positions.length = 0; + refState.current.totalSize = 0; + set$(ctx, "totalSize", 0); + } + } + + if (IS_DEV) { + useDevChecks(props as any); + } + + useLayoutEffect(() => { + handleInitialScrollDataChange(ctx, { + dataLength: dataProp.length, + didDataChange: didDataChangeLocal, + initialScrollAtEnd, + stylePaddingBottom: stylePaddingBottomState, + useBootstrapInitialScroll: usesBootstrapInitialScroll, + }); + }, [dataProp.length, didDataChangeLocal, initialScrollAtEnd, stylePaddingBottomState, usesBootstrapInitialScroll]); + + useLayoutEffect(() => { + if (didAnchoredEndSpaceAnchorIndexChange) { + state.scrollForNextCalculateItemsInView = undefined; + state.triggerCalculateItemsInView?.(); + } + maybeUpdateAnchoredEndSpace(ctx); + }, [ + ctx, + dataProp, + dataVersion, + anchoredEndSpace?.anchorIndex, + anchoredEndSpace?.anchorMaxSize, + anchoredEndSpace?.anchorOffset, + didAnchoredEndSpaceAnchorIndexChange, + numColumnsProp, + ]); + + useLayoutEffect(() => { + const previousContentInsetEndAdjustment = previousContentInsetEndAdjustmentRef.current; + previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustmentResolved; + updateContentInsetEndAdjustment(ctx, previousContentInsetEndAdjustment); + }, [ctx, contentInsetEndAdjustmentResolved]); + + useLayoutEffect(() => { + if (snapToIndices) { + updateSnapToOffsets(ctx); + } + }, [snapToIndices]); + + useLayoutEffect( + () => initializeStateVars(true), + [dataVersion, memoizedLastItemKeys.join(","), numColumnsProp, stylePaddingBottomState, stylePaddingTopState], + ); + + useLayoutEffect(() => { + const { + didColumnsChange, + didDataChange, + isFirst, + props: { data }, + } = state; + const didAllocateContainers = data.length > 0 && doInitialAllocateContainers(ctx); + if (!didAllocateContainers && !isFirst && (didDataChange || didColumnsChange)) { + checkResetContainers(ctx, data, { didColumnsChange }); + } + if (didDataChange) { + state.pendingDataComparison = undefined; + } + state.didColumnsChange = false; + state.didDataChange = false; + state.isFirst = false; + }, [dataProp, dataVersion, numColumnsProp]); + + useLayoutEffect(() => { + set$(ctx, "extraData", extraData); + const didToggleOverride = prevHasOverrideItemLayout.current !== hasOverrideItemLayout; + prevHasOverrideItemLayout.current = hasOverrideItemLayout; + if ((hasOverrideItemLayout || didToggleOverride) && numColumnsProp > 1) { + state.triggerCalculateItemsInView?.({ forceFullItemPositions: true }); + } + }, [extraData, hasOverrideItemLayout, numColumnsProp]); + + useEffect(() => { + if (!onMetricsChange) return; + let lastMetrics: LegendListMetrics | undefined; + const emitMetrics = () => { + const metrics: LegendListMetrics = { + footerSize: peek$(ctx, "footerSize") || 0, + headerSize: peek$(ctx, "headerSize") || 0, + }; + if ( + !lastMetrics || + metrics.headerSize !== lastMetrics.headerSize || + metrics.footerSize !== lastMetrics.footerSize + ) { + lastMetrics = metrics; + onMetricsChange(metrics); + } + }; + emitMetrics(); + const unsubscribe = [listen$(ctx, "headerSize", emitMetrics), listen$(ctx, "footerSize", emitMetrics)]; + return () => { + for (const unsub of unsubscribe) unsub(); + }; + }, [ctx, onMetricsChange]); + + useEffect(() => { + const viewability = setupViewability({ + onViewableItemsChanged, + viewabilityConfig, + viewabilityConfigCallbackPairs, + }); + state.viewabilityConfigCallbackPairs = viewability; + state.enableScrollForNextCalculateItemsInView = !viewability; + }, [viewabilityConfig, viewabilityConfigCallbackPairs, onViewableItemsChanged]); + + useInit(() => { + if (!IsNewArchitecture) { + doInitialAllocateContainers(ctx); + } + }); + + useImperativeHandle(forwardedRef, () => createImperativeHandle(ctx), []); + + const fns = useMemo( + () => ({ + getRenderedItem: (key: string) => getRenderedItem(ctx, key), + updateItemSize: (itemKey: string, sizeObj: { width: number; height: number }) => + updateItemSize(ctx, itemKey, sizeObj), + }), + [], + ); + + // We render absolutely-positioned: the outer ContentArea owns the scroll-height. + // Each layer fills the content area. inside has its own animated + // height-View; that's fine since per-item positions are absolute within it. + if (!canRender) { + // Match LegendListInner: on new arch, defer Containers until layout has run. + return ; + } + + return ( + <> + + + + ); +}); diff --git a/src/components/LegendListDatasets.tsx b/src/components/LegendListDatasets.tsx new file mode 100644 index 00000000..902d2c76 --- /dev/null +++ b/src/components/LegendListDatasets.tsx @@ -0,0 +1,554 @@ +// LegendListDatasets — outer orchestrator. +// +// Renders ONE outer ScrollView shared across N datasets, with ListHeaderComponent +// rendered exactly once. Each dataset gets its own + +// (headless), absolutely positioned inside a shared ContentArea. +// +// v1 architecture (see plan_legend_list_datasets_v1.md): +// LegendListDatasets +// └── ListComponentScrollView (shared scroll/layout/refScroller) +// ├── ScrollAdjust (per-layer; rendered inside each layer) +// ├── ListHeaderComponent (ONCE, fans headerSize to all layers) +// ├── ContentArea (Animated height = active layer's totalSize) +// │ ├── dataset 0 +// │ │ └── (absolute) +// │ └── ... × N +// └── ListFooterComponent (ONCE, fans footerSize to all layers) +// +// Known v1 limitations: +// - Sticky headers across datasets: outer onScroll is not yet sticky-aware. +// If you need sticky behavior, use the single-dataset . +// - initialScroll uses the active dataset at mount; switching datasets later +// does not auto-scroll. + +import * as React from "react"; +import { + type ForwardedRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Animated, View } from "react-native"; + +import { type DatasetLayerHandle, DatasetLayerInner } from "@/components/DatasetLayerInner"; +import { ListComponentScrollView } from "@/components/ListComponentScrollView"; +import { IsNewArchitecture } from "@/constants-platform"; +import { + handleBootstrapInitialScrollFooterLayout, + handleBootstrapInitialScrollLayoutChange, +} from "@/core/bootstrapInitialScroll"; +import { checkFinishedScrollFallback } from "@/core/checkFinishedScroll"; +import { handleLayout } from "@/core/handleLayout"; +import { advanceCurrentInitialScrollSession, resolveInitialScrollOffset } from "@/core/initialScroll"; +import { initializeInitialScrollOnMount } from "@/core/initialScrollLifecycle"; +import { onScroll as routeOnScroll } from "@/core/onScroll"; +import { maybeUpdateAnchoredEndSpace } from "@/core/updateAnchoredEndSpace"; +import { updateScroll } from "@/core/updateScroll"; +import { useCombinedRef } from "@/hooks/useCombinedRef"; +import { useOnLayoutSync } from "@/hooks/useOnLayoutSync"; +import { LayoutView } from "@/platform/LayoutView"; +import { Platform } from "@/platform/Platform"; +import { RefreshControl } from "@/platform/RefreshControl"; +import { StyleSheet } from "@/platform/StyleSheet"; +import type { + LayoutRectangle, + LooseScrollView, + LooseScrollViewProps, + LooseView, + NativeScrollEvent, + NativeSyntheticEvent, + ViewStyle, +} from "@/platform/scrollview-types"; +import { listen$, peek$, StateProvider, set$ } from "@/state/state"; +import type { LegendListRef, LegendListRenderItemProps } from "@/types.base"; +import type { LegendListPropsBase, LegendListScrollerRef } from "@/types.internal"; +import { typedForwardRef, typedMemo } from "@/types.internal"; +import { getComponent } from "@/utils/getComponent"; +import { extractPadding } from "@/utils/helpers"; +import { useThrottledOnScroll } from "@/utils/throttledOnScroll"; + +// React 19.2+ stable Activity with display fallback for older React. +const ReactActivity = (React as any).Activity as + | React.ComponentType<{ mode: "visible" | "hidden"; children: React.ReactNode }> + | undefined; +const Activity: React.ComponentType<{ mode: "visible" | "hidden"; children: React.ReactNode }> = + ReactActivity ?? (({ children }) => <>{children}); + +interface DatasetLayerShellProps { + children: React.ReactNode; + inactiveBehavior: DatasetInactiveBehavior; + isActive: boolean; +} + +function DatasetLayerShell({ children, inactiveBehavior, isActive }: DatasetLayerShellProps) { + const shouldPause = !!ReactActivity && !isActive && inactiveBehavior === "pause"; + const shouldHide = !isActive && (inactiveBehavior === "hide" || (!ReactActivity && inactiveBehavior === "pause")); + + return ( + + + {children} + + + ); +} + +export type DatasetInactiveBehavior = "pause" | "hide" | "unmount"; + +export interface LegendListDataset { + key: string; + data: ReadonlyArray; + renderItem: (props: LegendListRenderItemProps) => React.ReactNode; + keyExtractor?: (item: T, index: number) => string; + getItemType?: (item: T, index: number) => string | undefined; + estimatedItemSize?: number; +} + +export interface LegendListDatasetsProps + extends Omit< + LegendListPropsBase, + "data" | "renderItem" | "keyExtractor" | "getItemType" | "children" + > { + datasets: ReadonlyArray>; + activeKey: string; + inactiveBehavior?: DatasetInactiveBehavior; + /** Delay (ms) before mounting non-active datasets on first paint. Default 100. */ + staggerMountMs?: number; +} + +const styles = StyleSheet.create({ + layerHidden: { + display: "none" as const, + flex: 1, + }, + layerRoot: { + bottom: 0, + left: 0, + position: "absolute" as const, + right: 0, + top: 0, + }, + layerVisible: { + flex: 1, + }, +}); + +export const LegendListDatasets = typedMemo( + // biome-ignore lint/nursery/noShadow: const function name shadowing is intentional + typedForwardRef(function LegendListDatasets( + props: LegendListDatasetsProps, + forwardedRef: ForwardedRef, + ) { + const { + alignItemsAtEnd = false, + datasets, + activeKey, + inactiveBehavior = "pause", + staggerMountMs = 100, + ListHeaderComponent, + ListHeaderComponentStyle, + ListFooterComponent, + ListFooterComponentStyle, + ListEmptyComponent, + onScroll: onScrollProp, + onMomentumScrollEnd, + onLayout: onLayoutProp, + onRefresh, + refreshControl, + refreshing, + refScrollView, + renderScrollComponent, + scrollEventThrottle, + style: styleProp, + contentContainerStyle: contentContainerStyleProp, + progressViewOffset, + horizontal, + ...rest + } = props; + + // Shared resources. + const sharedAnimatedScrollY = useRef(new Animated.Value(0)).current; + const sharedRefScroller = useRef(null); + const refScroller = useRef(null); + const combinedRef = useCombinedRef(refScroller, sharedRefScroller as any, refScrollView); + const sharedContentHeight = useRef(new Animated.Value(0)).current; + const latestLayoutRef = useRef<{ fromLayoutEffect: boolean; layout: LayoutRectangle } | undefined>(undefined); + const latestHeaderSizeRef = useRef(ListHeaderComponent ? undefined : 0); + const latestFooterSizeRef = useRef(ListFooterComponent ? undefined : 0); + const latestScrollEventRef = useRef | undefined>(undefined); + + // Layer registry. Mutations don't trigger re-render by themselves; + // we bump layerVersion when registrations change so effects can re-bind. + const layersRef = useRef>(new Map()); + const [layerVersion, setLayerVersion] = useState(0); + + const registerLayer = useCallback((key: string, handle: DatasetLayerHandle | null) => { + if (handle) { + layersRef.current.set(key, handle); + } else { + layersRef.current.delete(key); + } + setLayerVersion((v) => v + 1); + }, []); + + const activeDataset = datasets.find((d) => d.key === activeKey); + + // Track which dataset keys are mounted (active + staggered others). + const [mountedKeys, setMountedKeys] = useState>(() => new Set([activeKey])); + const everActiveRef = useRef>(new Set([activeKey])); + if (!everActiveRef.current.has(activeKey)) { + everActiveRef.current.add(activeKey); + } + useEffect(() => { + if (staggerMountMs <= 0) { + setMountedKeys(new Set(datasets.map((d) => d.key))); + return; + } + const t = setTimeout(() => { + setMountedKeys(new Set(datasets.map((d) => d.key))); + }, staggerMountMs); + return () => clearTimeout(t); + }, [staggerMountMs, datasets.map((d) => d.key).join(",")]); + + const applyLayoutToLayer = useCallback( + (layer: DatasetLayerHandle, layout: LayoutRectangle, fromLayoutEffect: boolean) => { + const previousScrollLength = layer.ctx.state.scrollLength; + const previousOtherAxisSize = layer.ctx.state.otherAxisSize; + handleLayout(layer.ctx, layout, layer.setCanRender); + maybeUpdateAnchoredEndSpace(layer.ctx); + const didLayoutAffectBootstrap = + previousScrollLength !== layer.ctx.state.scrollLength || + previousOtherAxisSize !== layer.ctx.state.otherAxisSize; + if (layer.usesBootstrapInitialScroll && !fromLayoutEffect && didLayoutAffectBootstrap) { + handleBootstrapInitialScrollLayoutChange(layer.ctx); + } + if (!layer.usesBootstrapInitialScroll) { + advanceCurrentInitialScrollSession(layer.ctx); + } + }, + [], + ); + + const applyFooterSizeToLayer = useCallback((layer: DatasetLayerHandle, size: number) => { + set$(layer.ctx, "footerSize", size); + if (layer.usesBootstrapInitialScroll) { + handleBootstrapInitialScrollFooterLayout(layer.ctx, { + dataLength: layer.dataLength, + footerSize: size, + initialScrollAtEnd: !!layer.initialScroll?.preserveForBottomPadding, + stylePaddingBottom: layer.stylePaddingBottom, + }); + } + }, []); + + const syncLayerToCurrentScroll = useCallback((layer: DatasetLayerHandle) => { + if (latestScrollEventRef.current) { + routeOnScroll(layer.ctx, latestScrollEventRef.current); + return; + } + + const currentOffset = (refScroller.current as any)?.getCurrentScrollOffset?.(); + if (typeof currentOffset === "number") { + updateScroll(layer.ctx, currentOffset, true); + } + }, []); + + useLayoutEffect(() => { + for (const [key, layer] of layersRef.current) { + if (latestHeaderSizeRef.current !== undefined) { + set$(layer.ctx, "headerSize", latestHeaderSizeRef.current); + } + + if (latestFooterSizeRef.current !== undefined) { + applyFooterSizeToLayer(layer, latestFooterSizeRef.current); + } + + if (latestLayoutRef.current) { + applyLayoutToLayer(layer, latestLayoutRef.current.layout, latestLayoutRef.current.fromLayoutEffect); + } + + if (key === activeKey) { + syncLayerToCurrentScroll(layer); + } + } + }, [activeKey, applyFooterSizeToLayer, applyLayoutToLayer, layerVersion, syncLayerToCurrentScroll]); + + // Sync ContentArea height to active layer's totalSize. + useEffect(() => { + const activeLayer = layersRef.current.get(activeKey); + if (!activeLayer) return; + const sync = () => { + const v = peek$(activeLayer.ctx, "totalSize") || 0; + sharedContentHeight.setValue(v); + }; + sync(); + return listen$(activeLayer.ctx, "totalSize", sync); + }, [activeKey, layerVersion, sharedContentHeight]); + + // Bootstrap initial scroll once, using the ACTIVE layer's intent (if any). + const didBootstrapRef = useRef(false); + useEffect(() => { + if (didBootstrapRef.current) return; + const activeLayer = layersRef.current.get(activeKey); + if (!activeLayer) return; + didBootstrapRef.current = true; + + const initialScroll = activeLayer.initialScroll; + const usesBootstrap = activeLayer.usesBootstrapInitialScroll; + const initialContentOffset = initialScroll + ? (initialScroll.contentOffset ?? resolveInitialScrollOffset(activeLayer.ctx, initialScroll)) + : undefined; + + initializeInitialScrollOnMount(activeLayer.ctx, { + alwaysDispatchInitialScroll: false, + dataLength: activeLayer.dataLength, + hasFooterComponent: !!ListFooterComponent, + initialContentOffset, + initialScrollAtEnd: !!initialScroll?.preserveForBottomPadding, + useBootstrapInitialScroll: usesBootstrap, + }); + + if (Platform.OS === "web" && !usesBootstrap) { + advanceCurrentInitialScrollSession(activeLayer.ctx); + } + }, [activeKey, layerVersion, ListFooterComponent]); + + // Layout fan-out: invoke handleLayout for every registered layer. + const onLayoutChange = useCallback( + (layout: LayoutRectangle, fromLayoutEffect: boolean) => { + latestLayoutRef.current = { fromLayoutEffect, layout }; + for (const layer of layersRef.current.values()) { + applyLayoutToLayer(layer, layout, fromLayoutEffect); + } + }, + [applyLayoutToLayer], + ); + + const { onLayout } = useOnLayoutSync({ + onLayoutChange, + onLayoutProp, + ref: refScroller as unknown as React.RefObject, + }); + + // Scroll routing: only update the active layer's ctx.scroll. + const baseOnScroll = useCallback( + (event: NativeSyntheticEvent) => { + latestScrollEventRef.current = event; + const activeLayer = layersRef.current.get(activeKey); + if (activeLayer) { + routeOnScroll(activeLayer.ctx, event); + } + }, + [activeKey], + ); + const noopOnScroll = useCallback((_event: NativeSyntheticEvent) => {}, []); + const throttledOnScroll = useThrottledOnScroll(onScrollProp ?? noopOnScroll, scrollEventThrottle ?? 0); + const propScroll = scrollEventThrottle && onScrollProp ? throttledOnScroll : onScrollProp; + + const onScrollHandler = useCallback( + (event: NativeSyntheticEvent) => { + baseOnScroll(event); + propScroll?.(event); + }, + [baseOnScroll, propScroll], + ); + + const onMomentumScrollEndHandler = useCallback( + (event: NativeSyntheticEvent) => { + const activeLayer = layersRef.current.get(activeKey); + if (activeLayer) { + checkFinishedScrollFallback(activeLayer.ctx); + } + onMomentumScrollEnd?.(event as any); + }, + [activeKey, onMomentumScrollEnd], + ); + + // Header / footer fan-out. + const onLayoutHeader = useCallback( + (rect: LayoutRectangle) => { + const size = rect[horizontal ? "width" : "height"]; + latestHeaderSizeRef.current = size; + for (const layer of layersRef.current.values()) { + set$(layer.ctx, "headerSize", size); + } + }, + [horizontal], + ); + + const onLayoutFooterInternal = useCallback( + (rect: LayoutRectangle, _fromLayoutEffect: boolean) => { + const size = rect[horizontal ? "width" : "height"]; + latestFooterSizeRef.current = size; + for (const layer of layersRef.current.values()) { + applyFooterSizeToLayer(layer, size); + } + }, + [applyFooterSizeToLayer, horizontal], + ); + + useLayoutEffect(() => { + if (ListHeaderComponent) { + return; + } + + latestHeaderSizeRef.current = 0; + for (const layer of layersRef.current.values()) { + set$(layer.ctx, "headerSize", 0); + } + }, [ListHeaderComponent, layerVersion]); + + useLayoutEffect(() => { + if (ListFooterComponent) { + return; + } + + latestFooterSizeRef.current = 0; + for (const layer of layersRef.current.values()) { + applyFooterSizeToLayer(layer, 0); + } + }, [ListFooterComponent, applyFooterSizeToLayer, layerVersion]); + + // Imperative ref forwards to active layer's imperative handle. + const layerRefs = useRef>(new Map()); + useImperativeHandle( + forwardedRef, + () => + new Proxy({} as LegendListRef, { + get: (_t, prop) => { + const target = layerRefs.current.get(activeKey); + const value = target ? (target as any)[prop] : undefined; + return typeof value === "function" ? value.bind(target) : value; + }, + }), + [activeKey], + ); + + // Padding (for refresh control offset) — same derivation as LegendListInner. + const style = { ...StyleSheet.flatten(styleProp) }; + const contentContainerStyleBase = StyleSheet.flatten(contentContainerStyleProp) as ViewStyle | undefined; + const shouldFlexGrow = + alignItemsAtEnd && + (horizontal ? contentContainerStyleBase?.minWidth == null : contentContainerStyleBase?.minHeight == null); + const contentContainerStyle: ViewStyle = { + ...contentContainerStyleBase, + ...(alignItemsAtEnd + ? { + display: "flex", + flexDirection: horizontal ? "row" : "column", + ...(shouldFlexGrow ? { flexGrow: 1 } : {}), + justifyContent: "flex-end", + } + : {}), + }; + const stylePaddingTopState = extractPadding(style as any, contentContainerStyle as any, "Top"); + + const refreshControlElement = refreshControl as React.ReactElement<{ progressViewOffset?: number }> | undefined; + const resolvedRefreshControl = refreshControlElement + ? stylePaddingTopState > 0 + ? React.cloneElement(refreshControlElement, { + progressViewOffset: (refreshControlElement.props.progressViewOffset ?? 0) + stylePaddingTopState, + }) + : refreshControlElement + : onRefresh && ( + + ); + + // Resolve which scroll component to render. + const ScrollComponent = useMemo(() => { + if (!renderScrollComponent) return ListComponentScrollView; + return React.forwardRef((p: LooseScrollViewProps, ref) => + renderScrollComponent({ ...p, ref } as LooseScrollViewProps), + ); + }, [renderScrollComponent]); + + const contentAreaStyle: Animated.WithAnimatedValue = horizontal + ? { height: "100%", width: sharedContentHeight } + : { height: sharedContentHeight, width: "100%" }; + + const restScrollProps = rest as any; + + return ( + + {ListHeaderComponent && ( + + {getComponent(ListHeaderComponent)} + + )} + + {ListEmptyComponent && activeDataset?.data.length === 0 && getComponent(ListEmptyComponent)} + + + {datasets.map((ds) => { + const isActive = ds.key === activeKey; + const shouldRender = + inactiveBehavior === "unmount" + ? isActive + : mountedKeys.has(ds.key) || everActiveRef.current.has(ds.key) || isActive; + + if (!shouldRender) return null; + + return ( + + + { + if (r) layerRefs.current.set(ds.key, r); + else layerRefs.current.delete(ds.key); + }} + registerLayer={registerLayer} + renderItem={ds.renderItem} + sharedAnimatedScrollY={sharedAnimatedScrollY} + sharedRefScroller={ + sharedRefScroller as React.RefObject + } + style={style} + /> + + + ); + })} + + + {ListFooterComponent && ( + + {getComponent(ListFooterComponent)} + + )} + + ); + }), +); + +// suppress unused-import warnings for things the linter might not see used through JSX +void IsNewArchitecture; diff --git a/src/entrypoints/shared.ts b/src/entrypoints/shared.ts index dbc6c3fd..8b22a12e 100644 --- a/src/entrypoints/shared.ts +++ b/src/entrypoints/shared.ts @@ -1,4 +1,5 @@ import { LegendList as LegendListImpl } from "@/components/LegendList"; +import { LegendListDatasets as LegendListDatasetsImpl } from "@/components/LegendListDatasets"; import { getStickyPushLimit } from "@/components/stickyPositionUtils"; import { POSITION_OUT_OF_VIEW } from "@/constants"; import { IsNewArchitecture } from "@/constants-platform"; @@ -10,6 +11,13 @@ import { typedForwardRef, typedMemo } from "@/types.internal"; import { getComponent } from "@/utils/getComponent"; export const LegendListRuntime = LegendListImpl; +export const LegendListDatasetsRuntime = LegendListDatasetsImpl; + +export type { + DatasetInactiveBehavior, + LegendListDataset, + LegendListDatasetsProps, +} from "@/components/LegendListDatasets"; // Internal bridge exports used by integration entrypoints to avoid duplicating local modules. /** @internal */ diff --git a/src/react-native.ts b/src/react-native.ts index cf1e019c..f34153f0 100644 --- a/src/react-native.ts +++ b/src/react-native.ts @@ -1,6 +1,13 @@ -import { LegendListRuntime, internal as sharedInternal } from "@/entrypoints/shared"; +import { LegendListDatasetsRuntime, LegendListRuntime, internal as sharedInternal } from "@/entrypoints/shared"; import type { LegendListComponent } from "@/types.react-native"; export const LegendList = LegendListRuntime as LegendListComponent; +export const LegendListDatasets = LegendListDatasetsRuntime; + +export type { + DatasetInactiveBehavior, + LegendListDataset, + LegendListDatasetsProps, +} from "@/entrypoints/shared"; /** @internal */ export const internal = sharedInternal; diff --git a/src/react.ts b/src/react.ts index 42fbaf37..215eb687 100644 --- a/src/react.ts +++ b/src/react.ts @@ -1,7 +1,14 @@ -import { LegendListRuntime, internal as sharedInternal } from "@/entrypoints/shared"; +import { LegendListDatasetsRuntime, LegendListRuntime, internal as sharedInternal } from "@/entrypoints/shared"; import type { LegendListComponent } from "@/types.web"; export const LegendList = LegendListRuntime as LegendListComponent; +export const LegendListDatasets = LegendListDatasetsRuntime; + +export type { + DatasetInactiveBehavior, + LegendListDataset, + LegendListDatasetsProps, +} from "@/entrypoints/shared"; /** @internal */ export const internal = sharedInternal; From 6b31ba45226976ae0ecf712df2b4600ae6a3e9a7 Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Tue, 19 May 2026 18:25:46 -0500 Subject: [PATCH 2/8] fix: rely on typed React Activity Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/LegendListDatasets.test.tsx | 33 +++- bun.lock | 14 +- example-web/bun.lock | 22 +-- example-web/package.json | 8 +- example/screens/fixtures/layout-animation.tsx | 7 +- package.json | 6 +- src/components/DatasetLayerInner.tsx | 109 +++++++++---- src/components/LegendListDatasets.tsx | 154 ++++++++++++------ 8 files changed, 236 insertions(+), 117 deletions(-) diff --git a/__tests__/components/LegendListDatasets.test.tsx b/__tests__/components/LegendListDatasets.test.tsx index 41d2b8b0..e013dc40 100644 --- a/__tests__/components/LegendListDatasets.test.tsx +++ b/__tests__/components/LegendListDatasets.test.tsx @@ -3,6 +3,7 @@ import { Text, View } from "react-native"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { useArr$ } from "../../src/state/state"; +import type { LegendListRef } from "../../src/types.base"; import TestRenderer, { act } from "../helpers/testRenderer"; import { registerBaseModuleMocks } from "../setup"; @@ -10,7 +11,16 @@ const layoutEvent = { nativeEvent: { layout: { height: 200, width: 320, x: 0, y: 0 } }, }; -function TestContainer({ getRenderedItem, id }: { getRenderedItem: (key: string) => any; id: number }) { +type RenderedItemInfo = { renderedItem: React.ReactNode }; +type TreeNode = ReturnType["toJSON"]> | string; + +function TestContainer({ + getRenderedItem, + id, +}: { + getRenderedItem: (key: string) => RenderedItemInfo | null; + id: number; +}) { const [data, itemKey, extraData] = useArr$([ `containerItemData${id}` as const, `containerItemKey${id}` as const, @@ -24,7 +34,7 @@ function TestContainer({ getRenderedItem, id }: { getRenderedItem: (key: string) return <>{renderedItemInfo?.renderedItem ?? null}; } -function TestContainers({ getRenderedItem }: { getRenderedItem: (key: string) => any }) { +function TestContainers({ getRenderedItem }: { getRenderedItem: (key: string) => RenderedItemInfo | null }) { const [numContainersPooled = 0] = useArr$(["numContainersPooled"]); return ( @@ -42,7 +52,7 @@ function registerDatasetsMocks() { })); } -function collectTextFromTree(node: any, values: string[] = []) { +function collectTextFromTree(node: TreeNode, values: string[] = []) { if (node == null) { return values; } @@ -92,7 +102,10 @@ async function createRenderer(element: React.ReactElement) { async function layoutDefaultScrollView(renderer: ReturnType) { await act(async () => { - renderer.root.findByType("AnimatedScrollView").props.onLayout?.(layoutEvent as any); + const props = renderer.root.findByType("AnimatedScrollView").props as { + onLayout?: (event: typeof layoutEvent) => void; + }; + props.onLayout?.(layoutEvent); }); } @@ -238,7 +251,7 @@ describe("LegendListDatasets", () => { }); it("shares the outer ScrollView ref with dataset imperative handles", async () => { - const scrollRef = React.createRef(); + const scrollRef = React.createRef(); const scrollMethods = { flashScrollIndicators: () => {}, getScrollableNode: () => ({}), @@ -247,10 +260,12 @@ describe("LegendListDatasets", () => { scrollTo: () => {}, scrollToEnd: () => {}, }; - const ScrollHost = React.forwardRef(({ children, ...props }, ref) => { - React.useImperativeHandle(ref, () => scrollMethods, []); - return {children}; - }); + const ScrollHost = React.forwardRef>( + ({ children, ...props }, ref) => { + React.useImperativeHandle(ref, () => scrollMethods, []); + return {children}; + }, + ); const datasets = [ { data: [{ id: "spot-1", label: "Spot" }], diff --git a/bun.lock b/bun.lock index bbf70be0..8c7fbc67 100644 --- a/bun.lock +++ b/bun.lock @@ -12,18 +12,18 @@ "@testing-library/react-native": "^13.2.0", "@testing-library/user-event": "^14.6.1", "@types/bun": "^1.1.13", - "@types/react": "~19.1.10", + "@types/react": "19.2.0", "@types/react-dom": "^19.2.0", "@types/react-test-renderer": "^19.1.0", "@types/use-sync-external-store": "^1.5.0", "@typescript/native-preview": "^7.0.0-dev.20250816.1", "jest": "^30.0.4", - "react": "19.1.0", + "react": "19.2.0", "react-dom": "^19.2.0", "react-native": "0.81.5", "react-native-keyboard-controller": "^1.21.7", "react-native-reanimated": "~4.2.1", - "react-test-renderer": "19.1.0", + "react-test-renderer": "19.2.0", "tsup": "^8.5.1", "typescript": "^5.8.3", }, @@ -393,7 +393,7 @@ "@types/prop-types": ["@types/prop-types@15.7.13", "", {}, "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA=="], - "@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], "@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="], @@ -943,7 +943,7 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], @@ -963,7 +963,7 @@ "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], - "react-test-renderer": ["react-test-renderer@19.1.0", "", { "dependencies": { "react-is": "^19.1.0", "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw=="], + "react-test-renderer": ["react-test-renderer@19.2.0", "", { "dependencies": { "react-is": "^19.2.0", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ=="], "readdirp": ["readdirp@4.0.2", "", {}, "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA=="], @@ -1455,8 +1455,6 @@ "react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - "react-test-renderer/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - "regjsparser/jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], diff --git a/example-web/bun.lock b/example-web/bun.lock index 357318a5..6ef47177 100644 --- a/example-web/bun.lock +++ b/example-web/bun.lock @@ -8,16 +8,16 @@ "@tanstack/react-router": "^1.59.0", "@tanstack/react-virtual": "^3.13.12", "countries-list": "^3.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "19.2.0", + "react-dom": "19.2.0", "react-virtuoso": "^4.13.0", "react-window": "^2.2.5", "virtua": "^0.41.5", }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", - "@types/react": "^18.2.15", - "@types/react-dom": "^18.2.7", + "@types/react": "19.2.0", + "@types/react-dom": "19.2.0", "@vitejs/plugin-react": "^5.0.4", "tailwindcss": "^4.0.0", "typescript": "^5.0.2", @@ -229,11 +229,9 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], - "@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="], - - "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.4", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.38", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA=="], @@ -341,8 +339,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], @@ -371,9 +367,9 @@ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], - "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -383,7 +379,7 @@ "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/example-web/package.json b/example-web/package.json index 0f478bc6..f3268b25 100644 --- a/example-web/package.json +++ b/example-web/package.json @@ -16,16 +16,16 @@ "@tanstack/react-router": "^1.59.0", "@tanstack/react-virtual": "^3.13.12", "countries-list": "^3.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "19.2.0", + "react-dom": "19.2.0", "react-virtuoso": "^4.13.0", "react-window": "^2.2.5", "virtua": "^0.41.5" }, "devDependencies": { "@tailwindcss/vite": "^4.1.14", - "@types/react": "^18.2.15", - "@types/react-dom": "^18.2.7", + "@types/react": "19.2.0", + "@types/react-dom": "19.2.0", "@vitejs/plugin-react": "^5.0.4", "tailwindcss": "^4.0.0", "typescript": "^5.0.2", diff --git a/example/screens/fixtures/layout-animation.tsx b/example/screens/fixtures/layout-animation.tsx index 5bc807ad..3fcaf72e 100644 --- a/example/screens/fixtures/layout-animation.tsx +++ b/example/screens/fixtures/layout-animation.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { LinearTransition } from "react-native-reanimated"; -import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import { AnimatedLegendList, type AnimatedLegendListProps } from "@legendapp/list/reanimated"; type DemoItem = { id: string; @@ -11,6 +11,9 @@ type DemoItem = { }; const INITIAL_COUNT = 18; +const ITEM_LAYOUT_ANIMATION = LinearTransition.duration( + 280, +) as unknown as AnimatedLegendListProps["itemLayoutAnimation"]; function createItem(index: number): DemoItem { return { @@ -114,7 +117,7 @@ export default function LayoutAnimationExample() { item.id} maintainVisibleContentPosition={false} recycleItems diff --git a/package.json b/package.json index a4ad7dac..856de56c 100644 --- a/package.json +++ b/package.json @@ -120,18 +120,18 @@ "@testing-library/react-native": "^13.2.0", "@testing-library/user-event": "^14.6.1", "@types/bun": "^1.1.13", - "@types/react": "~19.1.10", + "@types/react": "19.2.0", "@types/react-dom": "^19.2.0", "@types/react-test-renderer": "^19.1.0", "@types/use-sync-external-store": "^1.5.0", "@typescript/native-preview": "^7.0.0-dev.20250816.1", "jest": "^30.0.4", - "react": "19.1.0", + "react": "19.2.0", "react-dom": "^19.2.0", "react-native": "0.81.5", "react-native-keyboard-controller": "^1.21.7", "react-native-reanimated": "~4.2.1", - "react-test-renderer": "19.1.0", + "react-test-renderer": "19.2.0", "tsup": "^8.5.1", "typescript": "^5.8.3" }, diff --git a/src/components/DatasetLayerInner.tsx b/src/components/DatasetLayerInner.tsx index c469376a..75bbf692 100644 --- a/src/components/DatasetLayerInner.tsx +++ b/src/components/DatasetLayerInner.tsx @@ -34,10 +34,11 @@ import { useInit } from "@/hooks/useInit"; import type { AnimatedValue } from "@/platform/Animated"; import { getWindowSize } from "@/platform/getWindowSize"; import { Platform } from "@/platform/Platform"; +import { StyleSheet } from "@/platform/StyleSheet"; import type { LooseScrollViewProps, ViewStyle } from "@/platform/scrollview-types"; import type { StateContext } from "@/state/state"; import { listen$, peek$, set$, useStateContext } from "@/state/state"; -import type { LegendListMetrics, LegendListRef, LegendListRenderItemProps } from "@/types.base"; +import type { LegendListMetrics, LegendListRef, LegendListRenderItemProps, StickyHeaderConfig } from "@/types.base"; import type { InternalState, LegendListPropsBase, LegendListScrollerRef } from "@/types.internal"; import { typedForwardRef } from "@/types.internal"; import type { StylesAsSharedValue } from "@/typesInternal"; @@ -66,9 +67,14 @@ export interface DatasetLayerHandle { } export interface DatasetLayerInnerProps extends Omit, "children"> { + animatedPropsInternal?: StylesAsSharedValue; childrenMode?: boolean; data: ReadonlyArray; + ItemSeparatorComponent?: React.ComponentType<{ leadingItem: T }>; + positionComponentInternal?: React.ComponentType; renderItem: (props: LegendListRenderItemProps) => React.ReactNode; + stickyHeaderConfig?: StickyHeaderConfig; + stickyPositionComponentInternal?: React.ComponentType; // Shared scroll resources (from outer) sharedAnimatedScrollY: AnimatedValue; sharedRefScroller: React.RefObject; @@ -149,23 +155,18 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( isActive: _isActive, registerLayer, layerKey, - } = props as DatasetLayerInnerProps & { - ItemSeparatorComponent?: React.ComponentType; - stickyHeaderConfig?: any; - }; + } = props; - const animatedPropsInternal = (props as any).animatedPropsInternal as StylesAsSharedValue; - const positionComponentInternal = (props as any).positionComponentInternal as React.ComponentType | undefined; - const stickyPositionComponentInternal = (props as any).stickyPositionComponentInternal as - | React.ComponentType - | undefined; + const { animatedPropsInternal, positionComponentInternal, stickyPositionComponentInternal } = props; // Re-derive padding the same way LegendListInner does. - const baseContent = contentContainerStyleProp as ViewStyle | undefined; - const stylePaddingTopState = extractPadding(styleProp as any, baseContent as any, "Top"); - const stylePaddingBottomState = extractPadding(styleProp as any, baseContent as any, "Bottom"); - const stylePaddingLeftState = extractPadding(styleProp as any, baseContent as any, "Left"); - const stylePaddingRightState = extractPadding(styleProp as any, baseContent as any, "Right"); + const baseContent: ViewStyle | undefined = StyleSheet.flatten(contentContainerStyleProp); + const style: ViewStyle = styleProp ? (StyleSheet.flatten(styleProp) ?? {}) : {}; + const contentContainerStyle: ViewStyle = baseContent ?? {}; + const stylePaddingTopState = extractPadding(style, contentContainerStyle, "Top"); + const stylePaddingBottomState = extractPadding(style, contentContainerStyle, "Bottom"); + const stylePaddingLeftState = extractPadding(style, contentContainerStyle, "Left"); + const stylePaddingRightState = extractPadding(style, contentContainerStyle, "Right"); const maintainScrollAtEndConfig = normalizeMaintainScrollAtEnd(maintainScrollAtEnd); const maintainVisibleContentPositionConfig = normalizeMaintainVisibleContentPosition( @@ -217,8 +218,7 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( const [canRender, setCanRender] = React.useState(!IsNewArchitecture); const ctx = useStateContext(); - ctx.columnWrapperStyle = - columnWrapperStyle || (baseContent ? createColumnWrapperStyle(baseContent as any) : undefined); + ctx.columnWrapperStyle = columnWrapperStyle || (baseContent ? createColumnWrapperStyle(baseContent) : undefined); const keyExtractor = keyExtractorProp ?? ((_item: T, index: number) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp ?? stickyIndicesDeprecated; @@ -237,6 +237,13 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( dataVersion, keyExtractor, ]); + const stickyIndicesSet = useMemo(() => new Set(stickyHeaderIndices ?? []), [stickyHeaderIndices?.join(",")]); + const wrappedGetEstimatedItemSize = useWrapIfItem(getEstimatedItemSize); + const wrappedGetFixedItemSize = useWrapIfItem(getFixedItemSize); + const wrappedGetItemType = useWrapIfItem(getItemType); + const wrappedKeyExtractor = useWrapIfItem(keyExtractor); + const anchoredEndSpaceResolved = + Platform.OS === "web" && anchoredEndSpace ? { ...anchoredEndSpace, includeInEndInset: true } : anchoredEndSpace; const refState = useRef(undefined); const hasOverrideItemLayout = !!overrideItemLayout; @@ -249,7 +256,7 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( // Overwrite the per-StateProvider animatedScrollY with the SHARED one so all // layers' sticky/position math drives off a single scroll Animated.Value. - (ctx as any).animatedScrollY = sharedAnimatedScrollY; + ctx.animatedScrollY = sharedAnimatedScrollY; ctx.state = { averageSizes: {}, @@ -290,7 +297,53 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( pendingDataComparison: undefined, pendingNativeMVCPAdjust: undefined, positions: [], - props: {} as any, + props: { + alignItemsAtEnd, + alwaysRender, + alwaysRenderIndicesArr: alwaysRenderIndices.arr, + alwaysRenderIndicesSet: alwaysRenderIndices.set, + anchoredEndSpace: anchoredEndSpaceResolved, + animatedProps: animatedPropsInternal ?? {}, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, + data: dataProp, + dataVersion, + drawDistance, + estimatedItemSize, + getEstimatedItemSize: wrappedGetEstimatedItemSize, + getFixedItemSize: wrappedGetFixedItemSize, + getItemType: wrappedGetItemType, + horizontal: !!horizontal, + initialContainerPoolRatio, + itemsAreEqual, + keyExtractor: wrappedKeyExtractor, + maintainScrollAtEnd: maintainScrollAtEndConfig, + maintainScrollAtEndThreshold, + maintainVisibleContentPosition: maintainVisibleContentPositionConfig, + numColumns: numColumnsProp, + onEndReached, + onEndReachedThreshold, + onItemSizeChanged, + onLoad, + onScroll: onScrollProp, + onStartReached, + onStartReachedThreshold, + onStickyHeaderChange, + overrideItemLayout, + positionComponentInternal, + recycleItems: !!recycleItems, + renderItem: props.renderItem, + rtl, + snapToIndices, + stickyIndicesArr: stickyHeaderIndices ?? [], + stickyIndicesSet, + stickyPositionComponentInternal, + stylePaddingBottom: stylePaddingBottomState, + stylePaddingLeft: stylePaddingLeftState, + stylePaddingRight: stylePaddingRightState, + stylePaddingTop: stylePaddingTopState, + useWindowScroll: false, + }, queuedCalculateItemsInView: 0, refScroller: sharedRefScroller, scroll: 0, @@ -354,8 +407,6 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( state.didDataChange = true; state.previousData = state.props.data; } - const anchoredEndSpaceResolved = - Platform.OS === "web" && anchoredEndSpace ? { ...anchoredEndSpace, includeInEndInset: true } : anchoredEndSpace; const didAnchoredEndSpaceAnchorIndexChange = !isFirstLocal && !didDataChangeLocal && @@ -367,20 +418,20 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( alwaysRenderIndicesArr: alwaysRenderIndices.arr, alwaysRenderIndicesSet: alwaysRenderIndices.set, anchoredEndSpace: anchoredEndSpaceResolved, - animatedProps: animatedPropsInternal, + animatedProps: animatedPropsInternal ?? {}, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, data: dataProp, dataVersion, drawDistance, estimatedItemSize, - getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize), - getFixedItemSize: useWrapIfItem(getFixedItemSize), - getItemType: useWrapIfItem(getItemType), + getEstimatedItemSize: wrappedGetEstimatedItemSize, + getFixedItemSize: wrappedGetFixedItemSize, + getItemType: wrappedGetItemType, horizontal: !!horizontal, initialContainerPoolRatio, itemsAreEqual, - keyExtractor: useWrapIfItem(keyExtractor), + keyExtractor: wrappedKeyExtractor, maintainScrollAtEnd: maintainScrollAtEndConfig, maintainScrollAtEndThreshold, maintainVisibleContentPosition: maintainVisibleContentPositionConfig, @@ -396,11 +447,11 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( overrideItemLayout, positionComponentInternal, recycleItems: !!recycleItems, - renderItem: props.renderItem!, + renderItem: props.renderItem, rtl, snapToIndices, stickyIndicesArr: stickyHeaderIndices ?? [], - stickyIndicesSet: useMemo(() => new Set(stickyHeaderIndices ?? []), [stickyHeaderIndices?.join(",")]), + stickyIndicesSet, stickyPositionComponentInternal, stylePaddingBottom: stylePaddingBottomState, stylePaddingLeft: stylePaddingLeftState, @@ -482,7 +533,7 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( } if (IS_DEV) { - useDevChecks(props as any); + useDevChecks(props); } useLayoutEffect(() => { diff --git a/src/components/LegendListDatasets.tsx b/src/components/LegendListDatasets.tsx index 902d2c76..14803a21 100644 --- a/src/components/LegendListDatasets.tsx +++ b/src/components/LegendListDatasets.tsx @@ -50,13 +50,13 @@ import { maybeUpdateAnchoredEndSpace } from "@/core/updateAnchoredEndSpace"; import { updateScroll } from "@/core/updateScroll"; import { useCombinedRef } from "@/hooks/useCombinedRef"; import { useOnLayoutSync } from "@/hooks/useOnLayoutSync"; +import { createAnimatedValue } from "@/platform/Animated"; import { LayoutView } from "@/platform/LayoutView"; import { Platform } from "@/platform/Platform"; import { RefreshControl } from "@/platform/RefreshControl"; import { StyleSheet } from "@/platform/StyleSheet"; import type { LayoutRectangle, - LooseScrollView, LooseScrollViewProps, LooseView, NativeScrollEvent, @@ -69,14 +69,43 @@ import type { LegendListPropsBase, LegendListScrollerRef } from "@/types.interna import { typedForwardRef, typedMemo } from "@/types.internal"; import { getComponent } from "@/utils/getComponent"; import { extractPadding } from "@/utils/helpers"; +import { normalizeMaintainVisibleContentPosition } from "@/utils/normalizeMaintainVisibleContentPosition"; import { useThrottledOnScroll } from "@/utils/throttledOnScroll"; -// React 19.2+ stable Activity with display fallback for older React. -const ReactActivity = (React as any).Activity as - | React.ComponentType<{ mode: "visible" | "hidden"; children: React.ReactNode }> - | undefined; -const Activity: React.ComponentType<{ mode: "visible" | "hidden"; children: React.ReactNode }> = - ReactActivity ?? (({ children }) => <>{children}); +const Activity = React.Activity; + +type SharedScrollRef = LegendListScrollerRef & LooseView; +type LayoutEventLike = { nativeEvent: { layout: LayoutRectangle } }; +type ScrollEventLike = { + nativeEvent: { + contentInset?: NativeScrollEvent["contentInset"]; + contentOffset: NativeScrollEvent["contentOffset"]; + contentSize?: NativeScrollEvent["contentSize"]; + layoutMeasurement?: NativeScrollEvent["layoutMeasurement"]; + zoomScale?: NativeScrollEvent["zoomScale"]; + }; +}; + +const DEFAULT_CONTENT_INSET: NativeScrollEvent["contentInset"] = { + bottom: 0, + left: 0, + right: 0, + top: 0, +}; + +const DEFAULT_SCROLL_SIZE = { height: 0, width: 0 }; + +function normalizeScrollEvent(event: ScrollEventLike): NativeSyntheticEvent { + return { + nativeEvent: { + contentInset: event.nativeEvent.contentInset ?? DEFAULT_CONTENT_INSET, + contentOffset: event.nativeEvent.contentOffset, + contentSize: event.nativeEvent.contentSize ?? DEFAULT_SCROLL_SIZE, + layoutMeasurement: event.nativeEvent.layoutMeasurement ?? DEFAULT_SCROLL_SIZE, + zoomScale: event.nativeEvent.zoomScale ?? 1, + }, + }; +} interface DatasetLayerShellProps { children: React.ReactNode; @@ -85,8 +114,8 @@ interface DatasetLayerShellProps { } function DatasetLayerShell({ children, inactiveBehavior, isActive }: DatasetLayerShellProps) { - const shouldPause = !!ReactActivity && !isActive && inactiveBehavior === "pause"; - const shouldHide = !isActive && (inactiveBehavior === "hide" || (!ReactActivity && inactiveBehavior === "pause")); + const shouldPause = !isActive && inactiveBehavior === "pause"; + const shouldHide = !isActive && inactiveBehavior === "hide"; return ( @@ -167,14 +196,17 @@ export const LegendListDatasets = typedMemo( contentContainerStyle: contentContainerStyleProp, progressViewOffset, horizontal, + estimatedItemSize, + maintainVisibleContentPosition, + recycleItems, ...rest } = props; // Shared resources. - const sharedAnimatedScrollY = useRef(new Animated.Value(0)).current; - const sharedRefScroller = useRef(null); - const refScroller = useRef(null); - const combinedRef = useCombinedRef(refScroller, sharedRefScroller as any, refScrollView); + const sharedAnimatedScrollY = useRef(createAnimatedValue(0)).current; + const sharedRefScroller = useRef(null); + const refScroller = useRef(null); + const combinedRef = useCombinedRef(refScroller, sharedRefScroller, refScrollView); const sharedContentHeight = useRef(new Animated.Value(0)).current; const latestLayoutRef = useRef<{ fromLayoutEffect: boolean; layout: LayoutRectangle } | undefined>(undefined); const latestHeaderSizeRef = useRef(ListHeaderComponent ? undefined : 0); @@ -251,7 +283,7 @@ export const LegendListDatasets = typedMemo( return; } - const currentOffset = (refScroller.current as any)?.getCurrentScrollOffset?.(); + const currentOffset = refScroller.current?.getCurrentScrollOffset?.(); if (typeof currentOffset === "number") { updateScroll(layer.ctx, currentOffset, true); } @@ -331,8 +363,10 @@ export const LegendListDatasets = typedMemo( const { onLayout } = useOnLayoutSync({ onLayoutChange, onLayoutProp, - ref: refScroller as unknown as React.RefObject, + ref: refScroller, }); + const noopOnLayout = useCallback((_event: LayoutEventLike) => {}, []); + const onLayoutHandler = onLayout ?? noopOnLayout; // Scroll routing: only update the active layer's ctx.scroll. const baseOnScroll = useCallback( @@ -350,20 +384,22 @@ export const LegendListDatasets = typedMemo( const propScroll = scrollEventThrottle && onScrollProp ? throttledOnScroll : onScrollProp; const onScrollHandler = useCallback( - (event: NativeSyntheticEvent) => { - baseOnScroll(event); - propScroll?.(event); + (event: ScrollEventLike) => { + const normalizedEvent = normalizeScrollEvent(event); + baseOnScroll(normalizedEvent); + propScroll?.(normalizedEvent); }, [baseOnScroll, propScroll], ); const onMomentumScrollEndHandler = useCallback( - (event: NativeSyntheticEvent) => { + (event: ScrollEventLike) => { + const normalizedEvent = normalizeScrollEvent(event); const activeLayer = layersRef.current.get(activeKey); if (activeLayer) { checkFinishedScrollFallback(activeLayer.ctx); } - onMomentumScrollEnd?.(event as any); + onMomentumScrollEnd?.(normalizedEvent); }, [activeKey, onMomentumScrollEnd], ); @@ -415,25 +451,43 @@ export const LegendListDatasets = typedMemo( // Imperative ref forwards to active layer's imperative handle. const layerRefs = useRef>(new Map()); + const getActiveLayerRef = useCallback(() => { + const activeLayerRef = layerRefs.current.get(activeKey); + if (!activeLayerRef) { + throw new Error("[legend-list] Active dataset layer is not mounted."); + } + return activeLayerRef; + }, [activeKey]); useImperativeHandle( forwardedRef, - () => - new Proxy({} as LegendListRef, { - get: (_t, prop) => { - const target = layerRefs.current.get(activeKey); - const value = target ? (target as any)[prop] : undefined; - return typeof value === "function" ? value.bind(target) : value; - }, - }), - [activeKey], + () => ({ + clearCaches: (options) => getActiveLayerRef().clearCaches(options), + flashScrollIndicators: () => getActiveLayerRef().flashScrollIndicators(), + getNativeScrollRef: () => getActiveLayerRef().getNativeScrollRef(), + getScrollableNode: () => getActiveLayerRef().getScrollableNode(), + getScrollResponder: () => getActiveLayerRef().getScrollResponder(), + getState: () => getActiveLayerRef().getState(), + reportContentInset: (inset) => getActiveLayerRef().reportContentInset(inset), + scrollIndexIntoView: (params) => getActiveLayerRef().scrollIndexIntoView(params), + scrollItemIntoView: (params) => getActiveLayerRef().scrollItemIntoView(params), + scrollToEnd: (options) => getActiveLayerRef().scrollToEnd(options), + scrollToIndex: (params) => getActiveLayerRef().scrollToIndex(params), + scrollToItem: (params) => getActiveLayerRef().scrollToItem(params), + scrollToOffset: (params) => getActiveLayerRef().scrollToOffset(params), + setScrollProcessingEnabled: (enabled) => getActiveLayerRef().setScrollProcessingEnabled(enabled), + setVisibleContentAnchorOffset: (value) => getActiveLayerRef().setVisibleContentAnchorOffset(value), + }), + [getActiveLayerRef], ); // Padding (for refresh control offset) — same derivation as LegendListInner. - const style = { ...StyleSheet.flatten(styleProp) }; - const contentContainerStyleBase = StyleSheet.flatten(contentContainerStyleProp) as ViewStyle | undefined; + const style: ViewStyle = StyleSheet.flatten(styleProp) ?? {}; + const contentContainerStyleBase: ViewStyle | undefined = StyleSheet.flatten(contentContainerStyleProp); const shouldFlexGrow = alignItemsAtEnd && (horizontal ? contentContainerStyleBase?.minWidth == null : contentContainerStyleBase?.minHeight == null); + const maintainVisibleContentPositionConfig = + normalizeMaintainVisibleContentPosition(maintainVisibleContentPosition); const contentContainerStyle: ViewStyle = { ...contentContainerStyleBase, ...(alignItemsAtEnd @@ -445,9 +499,11 @@ export const LegendListDatasets = typedMemo( } : {}), }; - const stylePaddingTopState = extractPadding(style as any, contentContainerStyle as any, "Top"); + const stylePaddingTopState = extractPadding(style, contentContainerStyle, "Top"); - const refreshControlElement = refreshControl as React.ReactElement<{ progressViewOffset?: number }> | undefined; + const refreshControlElement = React.isValidElement<{ progressViewOffset?: number }>(refreshControl) + ? refreshControl + : undefined; const resolvedRefreshControl = refreshControlElement ? stylePaddingTopState > 0 ? React.cloneElement(refreshControlElement, { @@ -465,26 +521,27 @@ export const LegendListDatasets = typedMemo( // Resolve which scroll component to render. const ScrollComponent = useMemo(() => { if (!renderScrollComponent) return ListComponentScrollView; - return React.forwardRef((p: LooseScrollViewProps, ref) => - renderScrollComponent({ ...p, ref } as LooseScrollViewProps), - ); + return React.forwardRef((p: LooseScrollViewProps, ref) => renderScrollComponent({ ...p, ref })); }, [renderScrollComponent]); const contentAreaStyle: Animated.WithAnimatedValue = horizontal ? { height: "100%", width: sharedContentHeight } : { height: sharedContentHeight, width: "100%" }; - const restScrollProps = rest as any; - return ( { if (r) layerRefs.current.set(ds.key, r); else layerRefs.current.delete(ds.key); @@ -529,9 +587,7 @@ export const LegendListDatasets = typedMemo( registerLayer={registerLayer} renderItem={ds.renderItem} sharedAnimatedScrollY={sharedAnimatedScrollY} - sharedRefScroller={ - sharedRefScroller as React.RefObject - } + sharedRefScroller={sharedRefScroller} style={style} /> From 80e507939430f86ddc04fdf19a19bfde072b749a Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Tue, 19 May 2026 18:45:50 -0500 Subject: [PATCH 3/8] fix: harden dataset layer boundaries Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/LegendListDatasets.test.tsx | 112 ++++++++++++++++++ example/bun.lock | 26 ++-- example/package.json | 8 +- example/screens/fixtures/layout-animation.tsx | 7 +- src/components/DatasetLayerInner.tsx | 75 +++++++++--- src/components/LegendList.tsx | 2 +- src/components/LegendListDatasets.tsx | 100 ++++++++++++++-- src/integrations/reanimated.tsx | 12 +- 8 files changed, 287 insertions(+), 55 deletions(-) diff --git a/__tests__/components/LegendListDatasets.test.tsx b/__tests__/components/LegendListDatasets.test.tsx index e013dc40..654f8937 100644 --- a/__tests__/components/LegendListDatasets.test.tsx +++ b/__tests__/components/LegendListDatasets.test.tsx @@ -294,4 +294,116 @@ describe("LegendListDatasets", () => { await cleanupRenderer(renderer); }); + + it("does not pass list-only props to the shared scroll component", async () => { + let scrollProps: Record | undefined; + const scrollMethods = { + flashScrollIndicators: () => {}, + getScrollableNode: () => ({}), + getScrollResponder: () => null, + measure: (cb: (x: number, y: number, width: number, height: number) => void) => cb(0, 0, 320, 200), + scrollTo: () => {}, + scrollToEnd: () => {}, + }; + const ScrollHost = React.forwardRef>( + ({ children, ...props }, ref) => { + scrollProps = props; + React.useImperativeHandle(ref, () => scrollMethods, []); + return {children}; + }, + ); + const datasets = [ + { + data: [{ id: "spot-1", label: "Spot" }], + key: "spot", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { label: string } }) => {item.label}, + }, + ]; + + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?scroll-props"); + const renderer = await createRenderer( + {}} + onViewableItemsChanged={() => {}} + recycleItems={false} + renderScrollComponent={(props) => } + showsVerticalScrollIndicator={false} + staggerMountMs={0} + stickyHeaderIndices={[0]} + viewabilityConfig={{ viewAreaCoveragePercentThreshold: 50 }} + />, + ); + + await flushFrames(); + + expect(scrollProps?.contentInset).toEqual({ bottom: 4, left: 3, right: 2, top: 1 }); + expect(scrollProps?.showsVerticalScrollIndicator).toBe(false); + expect(scrollProps).not.toHaveProperty("alwaysRender"); + expect(scrollProps).not.toHaveProperty("onEndReached"); + expect(scrollProps).not.toHaveProperty("onViewableItemsChanged"); + expect(scrollProps).not.toHaveProperty("stickyHeaderIndices"); + expect(scrollProps).not.toHaveProperty("viewabilityConfig"); + + await cleanupRenderer(renderer); + }); + + it("does not emit metrics for hidden inactive layers", async () => { + const metrics: Array<{ footerSize: number; headerSize: number }> = []; + const datasets = [ + { + data: [{ id: "spot-1", label: "Spot" }], + key: "spot", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { label: string } }) => {item.label}, + }, + { + data: [{ id: "futures-1", label: "Futures" }], + key: "futures", + keyExtractor: (item: { id: string }) => item.id, + renderItem: ({ item }: { item: { label: string } }) => {item.label}, + }, + ]; + + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?active-metrics"); + const renderer = await createRenderer( + metrics.push(value)} + recycleItems={false} + staggerMountMs={0} + />, + ); + + await flushFrames(); + + expect(metrics).toHaveLength(1); + + await act(async () => { + renderer.update( + metrics.push(value)} + recycleItems={false} + staggerMountMs={0} + />, + ); + }); + await flushFrames(); + + expect(metrics).toHaveLength(2); + + await cleanupRenderer(renderer); + }); }); diff --git a/example/bun.lock b/example/bun.lock index 57fae8cc..0b2abd8f 100644 --- a/example/bun.lock +++ b/example/bun.lock @@ -27,9 +27,9 @@ "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", - "react": "19.1.0", + "react": "19.2.0", "react-compiler-runtime": "^19.1.0-rc.2", - "react-dom": "19.1.0", + "react-dom": "19.2.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-controller": "^1.21.7", @@ -47,11 +47,11 @@ "@react-native-community/cli": "latest", "@types/expo__vector-icons": "^10.0.2", "@types/jest": "^29.5.14", - "@types/react": "~19.1.10", + "@types/react": "19.2.0", "@types/react-test-renderer": "^19.0.0", "jest": "~29.7.0", "jest-expo": "~54.0.17", - "react-test-renderer": "19.0.0", + "react-test-renderer": "19.2.0", "typescript": "~5.9.2", }, }, @@ -529,7 +529,7 @@ "@types/node": ["@types/node@24.0.1", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw=="], - "@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], @@ -1497,19 +1497,19 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-compiler-runtime": ["react-compiler-runtime@19.1.0-rc.2", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-852AwyIsbWJ5o1LkQVAZsVK3iLjMxOfKZuxqeGd/RfD+j1GqHb6j3DSHLtpu4HhFbQHsP2DzxjJyKR6luv4D8w=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], - "react-is": ["react-is@19.1.0", "", {}, "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg=="], + "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], @@ -1547,7 +1547,7 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "react-test-renderer": ["react-test-renderer@19.0.0", "", { "dependencies": { "react-is": "^19.0.0", "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-oX5u9rOQlHzqrE/64CNr0HB0uWxkCQmZNSfozlYvwE71TLVgeZxVf0IjouGEr1v7r1kcDifdAJBeOhdhxsG/DA=="], + "react-test-renderer": ["react-test-renderer@19.2.0", "", { "dependencies": { "react-is": "^19.2.0", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -1603,7 +1603,7 @@ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], - "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "schema-utils": ["schema-utils@4.3.2", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ=="], @@ -1985,6 +1985,8 @@ "@react-native/metro-config/metro-runtime": ["metro-runtime@0.84.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-NzzORY2+mmN3tLhsZ7N4GDOBERusalyM1o1k36euulUIEe8UkDhwzcsRexvxKaSkrGLiRQ9PYDLp9uxPkQ+A0Q=="], + "@react-navigation/core/react-is": ["react-is@19.1.0", "", {}, "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg=="], + "@types/expo__vector-icons/@expo/vector-icons": ["@expo/vector-icons@14.1.0", "", { "peerDependencies": { "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-7T09UE9h8QDTsUeMGymB4i+iqvtEeaO5VvUjryFB4tugDTG/bkzViWA74hm5pfjjDEhYMXWaX112mcvhccmIwQ=="], "@types/react-test-renderer/@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], @@ -2113,8 +2115,6 @@ "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -2281,6 +2281,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "jest-expo/react-test-renderer/react-is": ["react-is@19.1.0", "", {}, "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg=="], + "jest-expo/react-test-renderer/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "jest-runner/source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/example/package.json b/example/package.json index 3163d060..c1879cd0 100644 --- a/example/package.json +++ b/example/package.json @@ -43,9 +43,9 @@ "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", - "react": "19.1.0", + "react": "19.2.0", "react-compiler-runtime": "^19.1.0-rc.2", - "react-dom": "19.1.0", + "react-dom": "19.2.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-controller": "^1.21.7", @@ -63,11 +63,11 @@ "@react-native-community/cli": "latest", "@types/expo__vector-icons": "^10.0.2", "@types/jest": "^29.5.14", - "@types/react": "~19.1.10", + "@types/react": "19.2.0", "@types/react-test-renderer": "^19.0.0", "jest": "~29.7.0", "jest-expo": "~54.0.17", - "react-test-renderer": "19.0.0", + "react-test-renderer": "19.2.0", "typescript": "~5.9.2" }, "overrides": { diff --git a/example/screens/fixtures/layout-animation.tsx b/example/screens/fixtures/layout-animation.tsx index 3fcaf72e..5bc807ad 100644 --- a/example/screens/fixtures/layout-animation.tsx +++ b/example/screens/fixtures/layout-animation.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { LinearTransition } from "react-native-reanimated"; -import { AnimatedLegendList, type AnimatedLegendListProps } from "@legendapp/list/reanimated"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; type DemoItem = { id: string; @@ -11,9 +11,6 @@ type DemoItem = { }; const INITIAL_COUNT = 18; -const ITEM_LAYOUT_ANIMATION = LinearTransition.duration( - 280, -) as unknown as AnimatedLegendListProps["itemLayoutAnimation"]; function createItem(index: number): DemoItem { return { @@ -117,7 +114,7 @@ export default function LayoutAnimationExample() { item.id} maintainVisibleContentPosition={false} recycleItems diff --git a/src/components/DatasetLayerInner.tsx b/src/components/DatasetLayerInner.tsx index 75bbf692..95599870 100644 --- a/src/components/DatasetLayerInner.tsx +++ b/src/components/DatasetLayerInner.tsx @@ -42,6 +42,7 @@ import type { LegendListMetrics, LegendListRef, LegendListRenderItemProps, Stick import type { InternalState, LegendListPropsBase, LegendListScrollerRef } from "@/types.internal"; import { typedForwardRef } from "@/types.internal"; import type { StylesAsSharedValue } from "@/typesInternal"; +import { checkThresholds } from "@/utils/checkThresholds"; import { createColumnWrapperStyle } from "@/utils/createColumnWrapperStyle"; import { createImperativeHandle } from "@/utils/createImperativeHandle"; import { IS_DEV } from "@/utils/devEnvironment"; @@ -152,7 +153,7 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( // Shared sharedAnimatedScrollY, sharedRefScroller, - isActive: _isActive, + isActive, registerLayer, layerKey, } = props; @@ -244,6 +245,28 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( const wrappedKeyExtractor = useWrapIfItem(keyExtractor); const anchoredEndSpaceResolved = Platform.OS === "web" && anchoredEndSpace ? { ...anchoredEndSpace, includeInEndInset: true } : anchoredEndSpace; + const didEmitLoadRef = useRef(false); + const isActiveRef = useRef(isActive); + const becameActive = isActive && !isActiveRef.current; + isActiveRef.current = isActive; + const onEndReachedActive = isActive ? onEndReached : undefined; + const onItemSizeChangedActive = isActive ? onItemSizeChanged : undefined; + const onLoadActive = + isActive && onLoad + ? (info: Parameters>[0]) => { + didEmitLoadRef.current = true; + onLoad(info); + } + : undefined; + const onStartReachedActive = isActive ? onStartReached : undefined; + const onStickyHeaderChangeActive = isActive ? onStickyHeaderChange : undefined; + const onViewableItemsChangedActive = onViewableItemsChanged + ? (info: Parameters>[0]) => { + if (isActiveRef.current) { + onViewableItemsChanged(info); + } + } + : undefined; const refState = useRef(undefined); const hasOverrideItemLayout = !!overrideItemLayout; @@ -321,14 +344,14 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( maintainScrollAtEndThreshold, maintainVisibleContentPosition: maintainVisibleContentPositionConfig, numColumns: numColumnsProp, - onEndReached, + onEndReached: onEndReachedActive, onEndReachedThreshold, - onItemSizeChanged, - onLoad, + onItemSizeChanged: onItemSizeChangedActive, + onLoad: onLoadActive, onScroll: onScrollProp, - onStartReached, + onStartReached: onStartReachedActive, onStartReachedThreshold, - onStickyHeaderChange, + onStickyHeaderChange: onStickyHeaderChangeActive, overrideItemLayout, positionComponentInternal, recycleItems: !!recycleItems, @@ -366,7 +389,7 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( stickyContainers: new Map(), timeouts: new Set(), totalSize: 0, - viewabilityConfigCallbackPairs: undefined as never, + viewabilityConfigCallbackPairs: undefined, }; const internalState = ctx.state; @@ -436,14 +459,14 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( maintainScrollAtEndThreshold, maintainVisibleContentPosition: maintainVisibleContentPositionConfig, numColumns: numColumnsProp, - onEndReached, + onEndReached: onEndReachedActive, onEndReachedThreshold, - onItemSizeChanged, - onLoad, + onItemSizeChanged: onItemSizeChangedActive, + onLoad: onLoadActive, onScroll: onScrollProp, - onStartReached, + onStartReached: onStartReachedActive, onStartReachedThreshold, - onStickyHeaderChange, + onStickyHeaderChange: onStickyHeaderChangeActive, overrideItemLayout, positionComponentInternal, recycleItems: !!recycleItems, @@ -609,7 +632,7 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( }, [extraData, hasOverrideItemLayout, numColumnsProp]); useEffect(() => { - if (!onMetricsChange) return; + if (!isActive || !onMetricsChange) return; let lastMetrics: LegendListMetrics | undefined; const emitMetrics = () => { const metrics: LegendListMetrics = { @@ -630,17 +653,33 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( return () => { for (const unsub of unsubscribe) unsub(); }; - }, [ctx, onMetricsChange]); + }, [ctx, isActive, onMetricsChange]); useEffect(() => { const viewability = setupViewability({ - onViewableItemsChanged, - viewabilityConfig, - viewabilityConfigCallbackPairs, + onViewableItemsChanged: isActive ? onViewableItemsChangedActive : undefined, + viewabilityConfig: isActive ? viewabilityConfig : undefined, + viewabilityConfigCallbackPairs: isActive ? viewabilityConfigCallbackPairs : undefined, }); state.viewabilityConfigCallbackPairs = viewability; state.enableScrollForNextCalculateItemsInView = !viewability; - }, [viewabilityConfig, viewabilityConfigCallbackPairs, onViewableItemsChanged]); + }, [isActive, viewabilityConfig, viewabilityConfigCallbackPairs, onViewableItemsChangedActive]); + + useEffect(() => { + if (becameActive) { + state.isEndReached = null; + state.endReachedSnapshot = undefined; + state.isStartReached = null; + state.startReachedSnapshot = undefined; + state.startReachedSnapshotDataChangeEpoch = undefined; + set$(ctx, "activeStickyIndex", -1); + checkThresholds(ctx); + state.triggerCalculateItemsInView?.({ forceFullItemPositions: true }); + if (onLoadActive && !didEmitLoadRef.current && peek$(ctx, "readyToRender")) { + onLoadActive({ elapsedTimeInMs: Date.now() - state.loadStartTime }); + } + } + }, [becameActive, ctx, onLoadActive, state]); useInit(() => { if (!IsNewArchitecture) { diff --git a/src/components/LegendList.tsx b/src/components/LegendList.tsx index af121765..5d03dc1a 100644 --- a/src/components/LegendList.tsx +++ b/src/components/LegendList.tsx @@ -361,7 +361,7 @@ const LegendListInner = typedForwardRef(function LegendListInner( stickyContainers: new Map(), timeouts: new Set(), totalSize: 0, - viewabilityConfigCallbackPairs: undefined as never, + viewabilityConfigCallbackPairs: undefined, }; const internalState = ctx.state; diff --git a/src/components/LegendListDatasets.tsx b/src/components/LegendListDatasets.tsx index 14803a21..ca4f662b 100644 --- a/src/components/LegendListDatasets.tsx +++ b/src/components/LegendListDatasets.tsx @@ -174,32 +174,74 @@ export const LegendListDatasets = typedMemo( ) { const { alignItemsAtEnd = false, + anchoredEndSpace, + alwaysRender, + columnWrapperStyle, + contentContainerClassName, + contentInset, + contentInsetEndAdjustment, + contentContainerStyle: contentContainerStyleProp, + dataVersion, datasets, activeKey, + drawDistance, + estimatedHeaderSize, + estimatedListSize, + estimatedItemSize, + extraData, + getEstimatedItemSize, + getFixedItemSize, inactiveBehavior = "pause", + initialContainerPoolRatio, + initialScrollAtEnd, + initialScrollIndex, + initialScrollOffset, + itemsAreEqual, + ItemSeparatorComponent, staggerMountMs = 100, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListEmptyComponent, - onScroll: onScrollProp, - onMomentumScrollEnd, + maintainScrollAtEnd, + maintainScrollAtEndThreshold, + maintainVisibleContentPosition, + numColumns, + overrideItemLayout, + onEndReached, + onEndReachedThreshold, + onItemSizeChanged, onLayout: onLayoutProp, + onLoad, + onMetricsChange, + onMomentumScrollEnd, onRefresh, + onScroll: onScrollProp, + onStartReached, + onStartReachedThreshold, + onStickyHeaderChange, + onViewableItemsChanged, refreshControl, refreshing, refScrollView, renderScrollComponent, + rtl, scrollEventThrottle, + showsHorizontalScrollIndicator, + showsVerticalScrollIndicator, + snapToIndices, + stickyHeaderConfig, + stickyHeaderIndices, + stickyIndices, style: styleProp, - contentContainerStyle: contentContainerStyleProp, progressViewOffset, horizontal, - estimatedItemSize, - maintainVisibleContentPosition, recycleItems, - ...rest + useWindowScroll, + viewabilityConfig, + viewabilityConfigCallbackPairs, + ...scrollProps } = props; // Shared resources. @@ -530,8 +572,10 @@ export const LegendListDatasets = typedMemo( return ( {ListHeaderComponent && ( @@ -568,17 +615,47 @@ export const LegendListDatasets = typedMemo( { if (r) layerRefs.current.set(ds.key, r); @@ -586,9 +663,16 @@ export const LegendListDatasets = typedMemo( }} registerLayer={registerLayer} renderItem={ds.renderItem} + rtl={rtl} sharedAnimatedScrollY={sharedAnimatedScrollY} sharedRefScroller={sharedRefScroller} + snapToIndices={snapToIndices} + stickyHeaderConfig={stickyHeaderConfig} + stickyHeaderIndices={stickyHeaderIndices} + stickyIndices={stickyIndices} style={style} + viewabilityConfig={viewabilityConfig} + viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs} /> diff --git a/src/integrations/reanimated.tsx b/src/integrations/reanimated.tsx index bca66791..7a006eea 100644 --- a/src/integrations/reanimated.tsx +++ b/src/integrations/reanimated.tsx @@ -47,7 +47,8 @@ type ReanimatedScrollRenderProps = ReanimatedScrollViewProps & { ref?: React.Ref; }; -type ReanimatedLayoutAnimation = ComponentProps["layout"]; +type ReanimatedViewLayout = ComponentProps["layout"]; +type ReanimatedLayoutAnimation = ReanimatedViewLayout | { build: () => unknown }; export interface AnimatedLegendListSharedValues { activeStickyIndex?: SharedValue; @@ -218,13 +219,10 @@ const ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponen [horizontal, positionValue, style], ); + const layout = shouldSkipTransitionForRecycleReuse ? undefined : (layoutTransition as ReanimatedViewLayout); + return ( - + {children} ); From 7feac1754ba11efdc1f96e3a6c805cc117338774 Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Wed, 20 May 2026 11:14:13 -0500 Subject: [PATCH 4/8] feat: clean up dataset list API Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- MIGRATION.md | 198 ++++++++++++++++++ README.md | 2 + .../components/LegendListDatasets.test.tsx | 178 ++++++++++++---- example/screens/fixtures/datasets-tabs.tsx | 37 ++-- package.json | 4 +- src/components/DatasetLayerInner.tsx | 7 + src/components/LegendListDatasets.tsx | 158 +++++++++----- src/entrypoints/shared.ts | 1 + src/react-native.ts | 10 +- src/react.ts | 1 + .../react-native-datasets-props.tsx | 103 +++++++++ src/types.react-native.ts | 25 +++ 12 files changed, 603 insertions(+), 121 deletions(-) create mode 100644 MIGRATION.md create mode 100644 src/type-tests/react-native-datasets-props.tsx diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..2b4cde07 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,198 @@ +# Migration Guide + +## `LegendListDatasets` beta API cleanup + +This guide covers the `LegendListDatasets` API cleanup that moved the component to dataset-aware top-level props. + +### Summary + +- Rename `activeKey` to `activeDatasetKey`. +- Rename `inactiveBehavior` to `inactiveDatasetBehavior`. +- Keep each dataset entry minimal: `key` and `data`. +- Move `renderItem`, `keyExtractor`, `getItemType`, `getEstimatedItemSize`, and `getFixedItemSize` to `LegendListDatasets`. +- Use the new `datasetKey` argument to branch per-dataset behavior. + +There are no compatibility aliases for the old prop names or dataset-level callbacks. + +### Before + +```tsx + `spots:${item.id}`, + getItemType: () => "spot-row", + }, + { + key: "futures", + data: futuresRows, + renderItem: renderFuturesItem, + keyExtractor: (item) => `futures:${item.id}`, + getItemType: () => "futures-row", + }, + ]} +/> +``` + +### After + +```tsx + `${datasetKey}:${item.id}`} + getItemType={(_item, _index, datasetKey) => + datasetKey === "futures" ? "futures-row" : "spot-row" + } + renderItem={({ datasetKey, item, type }) => + datasetKey === "futures" ? ( + + ) : ( + + ) + } +/> +``` + +### Callback signatures + +```ts +type LegendListDataset = { + key: string; + data: ReadonlyArray; +}; + +type renderItem = (props: { + datasetKey: string; + data: readonly T[]; + extraData: unknown; + index: number; + item: T; + type: string | undefined; +}) => React.ReactNode; + +type keyExtractor = (item: T, index: number, datasetKey: string) => string; + +type getItemType = ( + item: T, + index: number, + datasetKey: string, +) => string | undefined; + +type getEstimatedItemSize = ( + item: T, + index: number, + type: string | undefined, + datasetKey: string, +) => number; + +type getFixedItemSize = ( + item: T, + index: number, + type: string | undefined, + datasetKey: string, +) => number | undefined; +``` + +### Prop renames + +| Old prop | New prop | +| --- | --- | +| `activeKey` | `activeDatasetKey` | +| `inactiveBehavior` | `inactiveDatasetBehavior` | + +`inactiveDatasetBehavior` supports the same values as before: + +- `"pause"`: keep inactive datasets mounted but hidden. This is the default. +- `"hide"`: keep inactive datasets mounted but hidden. +- `"unmount"`: unmount inactive datasets. + +### Dataset entries + +Dataset entries should only describe the dataset identity and data: + +```ts +const datasets = [ + { key: "spots", data: spotRows }, + { key: "futures", data: futuresRows }, +]; +``` + +Do not put list callbacks on dataset entries anymore: + +```ts +const datasets = [ + { + key: "spots", + data: spotRows, + renderItem, // remove + keyExtractor, // remove + getItemType, // remove + }, +]; +``` + +### Per-dataset keys and item types + +If multiple datasets can contain items with the same item key, include `datasetKey` in `keyExtractor`: + +```tsx +keyExtractor={(item, _index, datasetKey) => `${datasetKey}:${item.id}`} +``` + +If different datasets need different recycling pools, use `datasetKey` in `getItemType`: + +```tsx +getItemType={(item, _index, datasetKey) => { + if (item.type === "header") return "header"; + return datasetKey === "futures" ? "futures-row" : "spot-row"; +}} +``` + +### Per-dataset size hints + +Use `estimatedItemSize` when all datasets can share one estimate: + +```tsx + +``` + +Use `getEstimatedItemSize` or `getFixedItemSize` when a dataset needs a different size: + +```tsx +getEstimatedItemSize={(item, _index, _type, datasetKey) => { + if (item.type === "header") return 44; + return datasetKey === "lend" ? 96 : 72; +}} +``` + +### Empty datasets + +When there are no datasets, pass `activeDatasetKey=""` and `datasets={[]}`. `ListEmptyComponent` renders in that state: + +```tsx +} + renderItem={() => null} +/> +``` + +### Checklist + +1. Replace `activeKey` with `activeDatasetKey`. +2. Replace `inactiveBehavior` with `inactiveDatasetBehavior`. +3. Remove `renderItem`, `keyExtractor`, `getItemType`, and size callbacks from each dataset object. +4. Add top-level `renderItem`. +5. Move key/type/size logic to top-level callbacks and use `datasetKey`. +6. Include `datasetKey` in item keys when datasets can overlap. diff --git a/README.md b/README.md index 95710b9d..4a31fa98 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ For comprehensive documentation, guides, and the full API reference, please visi ➡️ **[Legend List Documentation Site](https://www.legendapp.com/open-source/list)** +For API upgrade notes, see the [migration guide](MIGRATION.md). + --- ## 💻 Usage diff --git a/__tests__/components/LegendListDatasets.test.tsx b/__tests__/components/LegendListDatasets.test.tsx index 654f8937..dc43974a 100644 --- a/__tests__/components/LegendListDatasets.test.tsx +++ b/__tests__/components/LegendListDatasets.test.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Text, View } from "react-native"; +import { StyleSheet, Text, View } from "react-native"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { useArr$ } from "../../src/state/state"; @@ -35,14 +35,14 @@ function TestContainer({ } function TestContainers({ getRenderedItem }: { getRenderedItem: (key: string) => RenderedItemInfo | null }) { - const [numContainersPooled = 0] = useArr$(["numContainersPooled"]); + const [numContainersPooled = 0, readyToRender] = useArr$(["numContainersPooled", "readyToRender"]); return ( - <> + {Array.from({ length: numContainersPooled }, (_, id) => ( ))} - + ); } @@ -80,6 +80,23 @@ function getRenderedLabels(renderer: TestRenderer.ReactTestRenderer) { return Array.from(new Set(collectTextFromTree(renderer.toJSON()))); } +function getDatasetLayerOpacityValues(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root + .findAllByType(View) + .map((node) => StyleSheet.flatten(node.props.style) as { flex?: number; opacity?: number } | undefined) + .filter( + (style): style is { flex?: number; opacity: number } => style?.flex === 1 && style.opacity !== undefined, + ) + .map((style) => style.opacity); +} + +function getContainerLayerOpacityValues(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root.findAllByProps({ testID: "mock-containers-layer" }).map((node) => { + const style = StyleSheet.flatten(node.props.style) as { opacity: number }; + return style.opacity; + }); +} + async function flushAsync() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -127,7 +144,7 @@ afterEach(() => { }); describe("LegendListDatasets", () => { - it("keeps hidden dataset rows mounted when switching active keys with display hiding", async () => { + it("keeps hidden dataset rows mounted when switching active keys with visibility hiding", async () => { const events: string[] = []; const Row = ({ id, label }: { id: string; label: string }) => { React.useEffect(() => { @@ -144,30 +161,24 @@ describe("LegendListDatasets", () => { { data: [{ id: "spot-1", label: "Spot" }], key: "spot", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { id: string; label: string } }) => ( - - ), }, { data: [{ id: "futures-1", label: "Futures" }], key: "futures", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { id: string; label: string } }) => ( - - ), }, ]; const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?stable-shell"); const renderer = await createRenderer( 50} - inactiveBehavior="hide" + inactiveDatasetBehavior="hide" + keyExtractor={(item, _index, datasetKey) => `${datasetKey}:${item.id}`} recycleItems={false} + renderItem={({ item }) => } staggerMountMs={0} />, ); @@ -178,17 +189,21 @@ describe("LegendListDatasets", () => { expect(getRenderedLabels(renderer)).toContain("Spot"); expect(getRenderedLabels(renderer)).toContain("Futures"); + expect(getDatasetLayerOpacityValues(renderer)).toContain(1); + expect(getDatasetLayerOpacityValues(renderer)).toContain(0); expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); await act(async () => { renderer.update( 50} - inactiveBehavior="hide" + inactiveDatasetBehavior="hide" + keyExtractor={(item, _index, datasetKey) => `${datasetKey}:${item.id}`} recycleItems={false} + renderItem={({ item }) => } staggerMountMs={0} />, ); @@ -196,6 +211,9 @@ describe("LegendListDatasets", () => { await flushFrames(4); expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); + expect(getDatasetLayerOpacityValues(renderer)).toContain(1); + expect(getDatasetLayerOpacityValues(renderer)).toContain(0); + expect(getContainerLayerOpacityValues(renderer).every((opacity) => opacity === 1)).toBe(true); await cleanupRenderer(renderer); }); @@ -205,26 +223,24 @@ describe("LegendListDatasets", () => { { data: [] as Array<{ id: string; label: string }>, key: "empty", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { label: string } }) => {item.label}, }, { data: [{ id: "spot-1", label: "Spot" }], key: "spot", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { label: string } }) => {item.label}, }, ]; const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?active-empty"); const renderer = await createRenderer( item.id} ListEmptyComponent={Empty active dataset} recycleItems={false} + renderItem={({ item }) => {item.label}} staggerMountMs={0} />, ); @@ -234,12 +250,14 @@ describe("LegendListDatasets", () => { await act(async () => { renderer.update( item.id} ListEmptyComponent={Empty active dataset} recycleItems={false} + renderItem={({ item }) => {item.label}} staggerMountMs={0} />, ); @@ -250,6 +268,88 @@ describe("LegendListDatasets", () => { await cleanupRenderer(renderer); }); + it("renders ListEmptyComponent when there are no datasets", async () => { + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?no-datasets"); + const renderer = await createRenderer( + No datasets} + recycleItems={false} + renderItem={({ item }: { item: { label: string } }) => {item.label}} + staggerMountMs={0} + />, + ); + + expect(getRenderedLabels(renderer)).toContain("No datasets"); + + await cleanupRenderer(renderer); + }); + + it("passes dataset keys to shared item callbacks", async () => { + const callbackEvents = new Set(); + const datasets = [ + { + data: [{ id: "spot-1", label: "Spot" }], + key: "spot", + }, + { + data: [{ id: "futures-1", label: "Futures" }], + key: "futures", + }, + ]; + + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?dataset-callbacks"); + const renderer = await createRenderer( + { + callbackEvents.add(`estimated:${datasetKey}:${type}:${item.id}:${index}`); + return 50; + }} + getFixedItemSize={(item, index, type, datasetKey) => { + callbackEvents.add(`fixed:${datasetKey}:${type}:${item.id}:${index}`); + return undefined; + }} + getItemType={(item, index, datasetKey) => { + callbackEvents.add(`type:${datasetKey}:${item.id}:${index}`); + return datasetKey === "spot" ? "spot-row" : "futures-row"; + }} + keyExtractor={(item, index, datasetKey) => { + callbackEvents.add(`key:${datasetKey}:${item.id}:${index}`); + return `${datasetKey}:${item.id}`; + }} + recycleItems={false} + renderItem={({ datasetKey, item, type }) => { + callbackEvents.add(`render:${datasetKey}:${type}:${item.id}`); + return {`${datasetKey}:${type}:${item.label}`}; + }} + staggerMountMs={0} + />, + ); + + await flushFrames(); + await layoutDefaultScrollView(renderer); + await flushFrames(8); + + expect(callbackEvents.has("key:spot:spot-1:0")).toBe(true); + expect(callbackEvents.has("key:futures:futures-1:0")).toBe(true); + expect(callbackEvents.has("type:spot:spot-1:0")).toBe(true); + expect(callbackEvents.has("type:futures:futures-1:0")).toBe(true); + expect(callbackEvents.has("fixed:spot:spot-row:spot-1:0")).toBe(true); + expect(callbackEvents.has("fixed:futures:futures-row:futures-1:0")).toBe(true); + expect(callbackEvents.has("estimated:spot:spot-row:spot-1:0")).toBe(true); + expect(callbackEvents.has("estimated:futures:futures-row:futures-1:0")).toBe(true); + expect(callbackEvents.has("render:spot:spot-row:spot-1")).toBe(true); + expect(callbackEvents.has("render:futures:futures-row:futures-1")).toBe(true); + expect(getRenderedLabels(renderer)).toContain("spot:spot-row:Spot"); + expect(getRenderedLabels(renderer)).toContain("futures:futures-row:Futures"); + + await cleanupRenderer(renderer); + }); + it("shares the outer ScrollView ref with dataset imperative handles", async () => { const scrollRef = React.createRef(); const scrollMethods = { @@ -270,19 +370,19 @@ describe("LegendListDatasets", () => { { data: [{ id: "spot-1", label: "Spot" }], key: "spot", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { label: string } }) => {item.label}, }, ]; const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?shared-ref"); const renderer = await createRenderer( item.id} recycleItems={false} ref={scrollRef} + renderItem={({ item }) => {item.label}} renderScrollComponent={(props) => } staggerMountMs={0} />, @@ -316,22 +416,22 @@ describe("LegendListDatasets", () => { { data: [{ id: "spot-1", label: "Spot" }], key: "spot", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { label: string } }) => {item.label}, }, ]; const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?scroll-props"); const renderer = await createRenderer( item.id} onEndReached={() => {}} onViewableItemsChanged={() => {}} recycleItems={false} + renderItem={({ item }) => {item.label}} renderScrollComponent={(props) => } showsVerticalScrollIndicator={false} staggerMountMs={0} @@ -359,26 +459,24 @@ describe("LegendListDatasets", () => { { data: [{ id: "spot-1", label: "Spot" }], key: "spot", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { label: string } }) => {item.label}, }, { data: [{ id: "futures-1", label: "Futures" }], key: "futures", - keyExtractor: (item: { id: string }) => item.id, - renderItem: ({ item }: { item: { label: string } }) => {item.label}, }, ]; const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?active-metrics"); const renderer = await createRenderer( item.id} onMetricsChange={(value) => metrics.push(value)} recycleItems={false} + renderItem={({ item }) => {item.label}} staggerMountMs={0} />, ); @@ -390,12 +488,14 @@ describe("LegendListDatasets", () => { await act(async () => { renderer.update( item.id} onMetricsChange={(value) => metrics.push(value)} recycleItems={false} + renderItem={({ item }) => {item.label}} staggerMountMs={0} />, ); diff --git a/example/screens/fixtures/datasets-tabs.tsx b/example/screens/fixtures/datasets-tabs.tsx index 4460f6b5..ba9f6a55 100644 --- a/example/screens/fixtures/datasets-tabs.tsx +++ b/example/screens/fixtures/datasets-tabs.tsx @@ -22,6 +22,7 @@ const TABS = [ { color: "#ccffcc", data: GREEN, key: "green", label: "Green" }, { color: "#cce4ff", data: BLUE, key: "blue", label: "Blue" }, ]; +const COLORS_BY_KEY = Object.fromEntries(TABS.map((tab) => [tab.key, tab.color])); let headerRenderCount = 0; @@ -45,23 +46,7 @@ export default function DatasetsTabsFixture() { () => TABS.map((t) => ({ data: t.data, - estimatedItemSize: 70, key: t.key, - keyExtractor: (item: Item) => item.id, - renderItem: ({ item }: { item: Item }) => ( - - {item.title} - {item.subtitle} - - ), })), [], ); @@ -86,11 +71,27 @@ export default function DatasetsTabsFixture() { ))} item.id} ListHeaderComponent={} recycleItems + renderItem={({ datasetKey, item }) => ( + + {item.title} + {item.subtitle} + + )} staggerMountMs={100} /> diff --git a/package.json b/package.json index 856de56c..666d431e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@legendapp/list", - "version": "3.0.0-beta.56", + "name": "@peterpme/list", + "version": "3.0.0-beta.62", "description": "Legend List is a drop-in replacement for FlatList with much better performance and supporting dynamically sized items.", "sideEffects": false, "private": false, diff --git a/src/components/DatasetLayerInner.tsx b/src/components/DatasetLayerInner.tsx index 95599870..16474827 100644 --- a/src/components/DatasetLayerInner.tsx +++ b/src/components/DatasetLayerInner.tsx @@ -54,6 +54,7 @@ import { normalizeMaintainScrollAtEnd } from "@/utils/normalizeMaintainScrollAtE import { normalizeMaintainVisibleContentPosition } from "@/utils/normalizeMaintainVisibleContentPosition"; import { requestAdjust } from "@/utils/requestAdjust"; import { isHorizontalRTLProps } from "@/utils/rtl"; +import { setInitialRenderState } from "@/utils/setInitialRenderState"; import { setPaddingTop } from "@/utils/setPaddingTop"; import { updateSnapToOffsets } from "@/utils/updateSnapToOffsets"; @@ -559,6 +560,12 @@ export const DatasetLayerInner = typedForwardRef(function DatasetLayerInner( useDevChecks(props); } + useLayoutEffect(() => { + if (!state.initialScroll && !state.didFinishInitialScroll) { + setInitialRenderState(ctx, { didInitialScroll: true }); + } + }, [ctx, state]); + useLayoutEffect(() => { handleInitialScrollDataChange(ctx, { dataLength: dataProp.length, diff --git a/src/components/LegendListDatasets.tsx b/src/components/LegendListDatasets.tsx index ca4f662b..9935cada 100644 --- a/src/components/LegendListDatasets.tsx +++ b/src/components/LegendListDatasets.tsx @@ -11,7 +11,7 @@ // ├── ListHeaderComponent (ONCE, fans headerSize to all layers) // ├── ContentArea (Animated height = active layer's totalSize) // │ ├── dataset 0 -// │ │ └── (absolute) +// │ │ └── (absolute) // │ └── ... × N // └── ListFooterComponent (ONCE, fans footerSize to all layers) // @@ -72,8 +72,6 @@ import { extractPadding } from "@/utils/helpers"; import { normalizeMaintainVisibleContentPosition } from "@/utils/normalizeMaintainVisibleContentPosition"; import { useThrottledOnScroll } from "@/utils/throttledOnScroll"; -const Activity = React.Activity; - type SharedScrollRef = LegendListScrollerRef & LooseView; type LayoutEventLike = { nativeEvent: { layout: LayoutRectangle } }; type ScrollEventLike = { @@ -109,19 +107,16 @@ function normalizeScrollEvent(event: ScrollEventLike): NativeSyntheticEvent - - {children} - + + {children} ); } @@ -131,28 +126,32 @@ export type DatasetInactiveBehavior = "pause" | "hide" | "unmount"; export interface LegendListDataset { key: string; data: ReadonlyArray; - renderItem: (props: LegendListRenderItemProps) => React.ReactNode; - keyExtractor?: (item: T, index: number) => string; - getItemType?: (item: T, index: number) => string | undefined; - estimatedItemSize?: number; } -export interface LegendListDatasetsProps - extends Omit< - LegendListPropsBase, - "data" | "renderItem" | "keyExtractor" | "getItemType" | "children" - > { +export type LegendListDatasetRenderItemProps = LegendListRenderItemProps & { + datasetKey: string; +}; + +export type LegendListDatasetsProps = Omit< + LegendListPropsBase, + "children" | "data" | "getEstimatedItemSize" | "getFixedItemSize" | "getItemType" | "keyExtractor" | "renderItem" +> & { + activeDatasetKey: string; datasets: ReadonlyArray>; - activeKey: string; - inactiveBehavior?: DatasetInactiveBehavior; + getEstimatedItemSize?: (item: T, index: number, type: string | undefined, datasetKey: string) => number; + getFixedItemSize?: (item: T, index: number, type: string | undefined, datasetKey: string) => number | undefined; + getItemType?: (item: T, index: number, datasetKey: string) => string | undefined; + inactiveDatasetBehavior?: DatasetInactiveBehavior; + keyExtractor?: (item: T, index: number, datasetKey: string) => string; + renderItem: (props: LegendListDatasetRenderItemProps) => React.ReactNode; /** Delay (ms) before mounting non-active datasets on first paint. Default 100. */ staggerMountMs?: number; -} +}; const styles = StyleSheet.create({ layerHidden: { - display: "none" as const, flex: 1, + opacity: 0, }, layerRoot: { bottom: 0, @@ -163,6 +162,7 @@ const styles = StyleSheet.create({ }, layerVisible: { flex: 1, + opacity: 1, }, }); @@ -173,6 +173,7 @@ export const LegendListDatasets = typedMemo( forwardedRef: ForwardedRef, ) { const { + activeDatasetKey, alignItemsAtEnd = false, anchoredEndSpace, alwaysRender, @@ -183,7 +184,6 @@ export const LegendListDatasets = typedMemo( contentContainerStyle: contentContainerStyleProp, dataVersion, datasets, - activeKey, drawDistance, estimatedHeaderSize, estimatedListSize, @@ -191,13 +191,15 @@ export const LegendListDatasets = typedMemo( extraData, getEstimatedItemSize, getFixedItemSize, - inactiveBehavior = "pause", + getItemType, + inactiveDatasetBehavior = "pause", initialContainerPoolRatio, initialScrollAtEnd, initialScrollIndex, initialScrollOffset, itemsAreEqual, ItemSeparatorComponent, + keyExtractor, staggerMountMs = 100, ListHeaderComponent, ListHeaderComponentStyle, @@ -225,6 +227,7 @@ export const LegendListDatasets = typedMemo( refreshControl, refreshing, refScrollView, + renderItem, renderScrollComponent, rtl, scrollEventThrottle, @@ -244,6 +247,37 @@ export const LegendListDatasets = typedMemo( ...scrollProps } = props; + const datasetKeys = datasets.map((d) => d.key).join("\u0000"); + const datasetCallbacks = useMemo(() => { + const callbacks = new Map< + string, + { + getEstimatedItemSize?: (item: T, index: number, type: string | undefined) => number; + getFixedItemSize?: (item: T, index: number, type: string | undefined) => number | undefined; + getItemType?: (item: T, index: number) => string | undefined; + keyExtractor?: (item: T, index: number) => string; + renderItem: (props: LegendListRenderItemProps) => React.ReactNode; + } + >(); + + for (const dataset of datasets) { + const datasetKey = dataset.key; + callbacks.set(datasetKey, { + getEstimatedItemSize: getEstimatedItemSize + ? (item, index, type) => getEstimatedItemSize(item, index, type, datasetKey) + : undefined, + getFixedItemSize: getFixedItemSize + ? (item, index, type) => getFixedItemSize(item, index, type, datasetKey) + : undefined, + getItemType: getItemType ? (item, index) => getItemType(item, index, datasetKey) : undefined, + keyExtractor: keyExtractor ? (item, index) => keyExtractor(item, index, datasetKey) : undefined, + renderItem: (itemProps) => renderItem({ ...itemProps, datasetKey }), + }); + } + + return callbacks; + }, [datasetKeys, getEstimatedItemSize, getFixedItemSize, getItemType, keyExtractor, renderItem]); + // Shared resources. const sharedAnimatedScrollY = useRef(createAnimatedValue(0)).current; const sharedRefScroller = useRef(null); @@ -269,13 +303,14 @@ export const LegendListDatasets = typedMemo( setLayerVersion((v) => v + 1); }, []); - const activeDataset = datasets.find((d) => d.key === activeKey); + const resolvedActiveDatasetKey = datasets.length === 0 ? "" : activeDatasetKey; + const activeDataset = datasets.find((d) => d.key === resolvedActiveDatasetKey); // Track which dataset keys are mounted (active + staggered others). - const [mountedKeys, setMountedKeys] = useState>(() => new Set([activeKey])); - const everActiveRef = useRef>(new Set([activeKey])); - if (!everActiveRef.current.has(activeKey)) { - everActiveRef.current.add(activeKey); + const [mountedKeys, setMountedKeys] = useState>(() => new Set([resolvedActiveDatasetKey])); + const everActiveRef = useRef>(new Set([resolvedActiveDatasetKey])); + if (!everActiveRef.current.has(resolvedActiveDatasetKey)) { + everActiveRef.current.add(resolvedActiveDatasetKey); } useEffect(() => { if (staggerMountMs <= 0) { @@ -286,7 +321,7 @@ export const LegendListDatasets = typedMemo( setMountedKeys(new Set(datasets.map((d) => d.key))); }, staggerMountMs); return () => clearTimeout(t); - }, [staggerMountMs, datasets.map((d) => d.key).join(",")]); + }, [staggerMountMs, datasetKeys]); const applyLayoutToLayer = useCallback( (layer: DatasetLayerHandle, layout: LayoutRectangle, fromLayoutEffect: boolean) => { @@ -345,15 +380,21 @@ export const LegendListDatasets = typedMemo( applyLayoutToLayer(layer, latestLayoutRef.current.layout, latestLayoutRef.current.fromLayoutEffect); } - if (key === activeKey) { + if (key === resolvedActiveDatasetKey) { syncLayerToCurrentScroll(layer); } } - }, [activeKey, applyFooterSizeToLayer, applyLayoutToLayer, layerVersion, syncLayerToCurrentScroll]); + }, [ + resolvedActiveDatasetKey, + applyFooterSizeToLayer, + applyLayoutToLayer, + layerVersion, + syncLayerToCurrentScroll, + ]); // Sync ContentArea height to active layer's totalSize. useEffect(() => { - const activeLayer = layersRef.current.get(activeKey); + const activeLayer = layersRef.current.get(resolvedActiveDatasetKey); if (!activeLayer) return; const sync = () => { const v = peek$(activeLayer.ctx, "totalSize") || 0; @@ -361,13 +402,13 @@ export const LegendListDatasets = typedMemo( }; sync(); return listen$(activeLayer.ctx, "totalSize", sync); - }, [activeKey, layerVersion, sharedContentHeight]); + }, [resolvedActiveDatasetKey, layerVersion, sharedContentHeight]); // Bootstrap initial scroll once, using the ACTIVE layer's intent (if any). const didBootstrapRef = useRef(false); useEffect(() => { if (didBootstrapRef.current) return; - const activeLayer = layersRef.current.get(activeKey); + const activeLayer = layersRef.current.get(resolvedActiveDatasetKey); if (!activeLayer) return; didBootstrapRef.current = true; @@ -389,7 +430,7 @@ export const LegendListDatasets = typedMemo( if (Platform.OS === "web" && !usesBootstrap) { advanceCurrentInitialScrollSession(activeLayer.ctx); } - }, [activeKey, layerVersion, ListFooterComponent]); + }, [resolvedActiveDatasetKey, layerVersion, ListFooterComponent]); // Layout fan-out: invoke handleLayout for every registered layer. const onLayoutChange = useCallback( @@ -414,12 +455,12 @@ export const LegendListDatasets = typedMemo( const baseOnScroll = useCallback( (event: NativeSyntheticEvent) => { latestScrollEventRef.current = event; - const activeLayer = layersRef.current.get(activeKey); + const activeLayer = layersRef.current.get(resolvedActiveDatasetKey); if (activeLayer) { routeOnScroll(activeLayer.ctx, event); } }, - [activeKey], + [resolvedActiveDatasetKey], ); const noopOnScroll = useCallback((_event: NativeSyntheticEvent) => {}, []); const throttledOnScroll = useThrottledOnScroll(onScrollProp ?? noopOnScroll, scrollEventThrottle ?? 0); @@ -437,13 +478,13 @@ export const LegendListDatasets = typedMemo( const onMomentumScrollEndHandler = useCallback( (event: ScrollEventLike) => { const normalizedEvent = normalizeScrollEvent(event); - const activeLayer = layersRef.current.get(activeKey); + const activeLayer = layersRef.current.get(resolvedActiveDatasetKey); if (activeLayer) { checkFinishedScrollFallback(activeLayer.ctx); } onMomentumScrollEnd?.(normalizedEvent); }, - [activeKey, onMomentumScrollEnd], + [resolvedActiveDatasetKey, onMomentumScrollEnd], ); // Header / footer fan-out. @@ -494,12 +535,12 @@ export const LegendListDatasets = typedMemo( // Imperative ref forwards to active layer's imperative handle. const layerRefs = useRef>(new Map()); const getActiveLayerRef = useCallback(() => { - const activeLayerRef = layerRefs.current.get(activeKey); + const activeLayerRef = layerRefs.current.get(resolvedActiveDatasetKey); if (!activeLayerRef) { throw new Error("[legend-list] Active dataset layer is not mounted."); } return activeLayerRef; - }, [activeKey]); + }, [resolvedActiveDatasetKey]); useImperativeHandle( forwardedRef, () => ({ @@ -599,20 +640,29 @@ export const LegendListDatasets = typedMemo( )} - {ListEmptyComponent && activeDataset?.data.length === 0 && getComponent(ListEmptyComponent)} + {ListEmptyComponent && + (datasets.length === 0 || activeDataset?.data.length === 0) && + getComponent(ListEmptyComponent)} {datasets.map((ds) => { - const isActive = ds.key === activeKey; + const isActive = ds.key === resolvedActiveDatasetKey; const shouldRender = - inactiveBehavior === "unmount" + inactiveDatasetBehavior === "unmount" ? isActive : mountedKeys.has(ds.key) || everActiveRef.current.has(ds.key) || isActive; if (!shouldRender) return null; + const callbacks = datasetCallbacks.get(ds.key); + if (!callbacks) return null; + return ( - + = T; +type MissingLegendListPassthroughProps = Exclude>; +type MissingLegendListDatasetsPassthroughProps = Exclude< + ReactNativeScrollPassthroughProp, + keyof LegendListDatasetsProps +>; +type IntentionalLegendListDatasetsPropDifferences = + | "children" + | "data" + | "getEstimatedItemSize" + | "getFixedItemSize" + | "getItemType" + | "keyExtractor" + | "renderItem"; +type MissingSharedLegendListProps = Exclude< + keyof LegendListProps, + keyof LegendListDatasetsProps | IntentionalLegendListDatasetsPropDifferences +>; + +export type AssertLegendListHasReactNativeScrollPassthroughProps = AssertNever; +export type AssertLegendListDatasetsHasReactNativeScrollPassthroughProps = + AssertNever; +export type AssertLegendListDatasetsHasSharedLegendListProps = AssertNever; + +const data: Item[] = [{ id: "1" }]; +const datasets: LegendListDataset[] = [{ data, key: "primary" }]; + +export const legendListAcceptsReactNativeScrollPassthroughProps = ( + item.id} + nestedScrollEnabled + overScrollMode="never" + renderItem={() => null} + scrollEventThrottle={16} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + /> +); + +export const legendListDatasetsAcceptsReactNativeScrollPassthroughProps = ( + item.id} + nestedScrollEnabled + overScrollMode="never" + renderItem={() => null} + scrollEventThrottle={16} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + /> +); + +export const legendListDatasetsRejectsActiveKey: LegendListDatasetsProps = { + activeDatasetKey: "primary", + // @ts-expect-error activeKey is not part of the LegendListDatasets v3 API. + activeKey: "primary", + datasets, + keyExtractor: (item) => item.id, + renderItem: () => null, +}; + +export const legendListDatasetsRejectsInactiveBehavior: LegendListDatasetsProps = { + activeDatasetKey: "primary", + datasets, + // @ts-expect-error inactiveBehavior is not part of the LegendListDatasets v3 API. + inactiveBehavior: "hide", + keyExtractor: (item) => item.id, + renderItem: () => null, +}; + +export const reactNativeDatasetsPropElements = React.createElement( + React.Fragment, + null, + legendListAcceptsReactNativeScrollPassthroughProps, + legendListDatasetsAcceptsReactNativeScrollPassthroughProps, +); diff --git a/src/types.react-native.ts b/src/types.react-native.ts index c5ad21d1..90b8e30f 100644 --- a/src/types.react-native.ts +++ b/src/types.react-native.ts @@ -12,6 +12,12 @@ import type { ViewStyle, } from "react-native"; +import type { + DatasetInactiveBehavior, + LegendListDataset, + LegendListDatasetRenderItemProps, + LegendListDatasetsProps as LegendListDatasetsPropsBase, +} from "@/components/LegendListDatasets"; import type { LegendListRef as LegendListRefBase, LegendListState as LegendListStateBase } from "@/types.base"; import type { LegendListPropsBase } from "@/types.internal"; @@ -69,6 +75,21 @@ export type LegendListProps< TItemType extends string | undefined = string | undefined, > = LegendListPropsOverrides; +type LegendListDatasetsPropsOverrides = Omit< + LegendListDatasetsPropsBase, + "onScroll" | "refScrollView" | "renderScrollComponent" | "ListHeaderComponentStyle" | "ListFooterComponentStyle" +> & { + onScroll?: (event: NativeSyntheticEvent) => void; + refScrollView?: React.Ref; + renderScrollComponent?: (props: ScrollViewProps) => React.ReactElement; + ListHeaderComponentStyle?: StyleProp | undefined; + ListFooterComponentStyle?: StyleProp | undefined; +}; + +export type LegendListDatasetsProps = LegendListDatasetsPropsOverrides; + +export type { DatasetInactiveBehavior, LegendListDataset, LegendListDatasetRenderItemProps }; + export type LegendListRef = Omit< LegendListRefBase, "getNativeScrollRef" | "getScrollResponder" | "reportContentInset" @@ -85,3 +106,7 @@ export type LegendListState = Omit & { export type LegendListComponent = ( props: LegendListProps & React.RefAttributes, ) => React.ReactElement | null; + +export type LegendListDatasetsComponent = ( + props: LegendListDatasetsProps & React.RefAttributes, +) => React.ReactElement | null; From 8be08f8009ec89eef63b50bcb3ea312670803df3 Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Wed, 20 May 2026 11:18:54 -0500 Subject: [PATCH 5/8] fix: preserve dataset activity pause behavior Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/LegendListDatasets.test.tsx | 62 ++++++++++++++++++- src/components/LegendListDatasets.tsx | 9 ++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/__tests__/components/LegendListDatasets.test.tsx b/__tests__/components/LegendListDatasets.test.tsx index dc43974a..ef57687f 100644 --- a/__tests__/components/LegendListDatasets.test.tsx +++ b/__tests__/components/LegendListDatasets.test.tsx @@ -187,10 +187,10 @@ describe("LegendListDatasets", () => { await layoutDefaultScrollView(renderer); await flushFrames(8); - expect(getRenderedLabels(renderer)).toContain("Spot"); - expect(getRenderedLabels(renderer)).toContain("Futures"); expect(getDatasetLayerOpacityValues(renderer)).toContain(1); expect(getDatasetLayerOpacityValues(renderer)).toContain(0); + expect(getRenderedLabels(renderer)).toContain("Spot"); + expect(getRenderedLabels(renderer)).toContain("Futures"); expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); await act(async () => { @@ -210,14 +210,69 @@ describe("LegendListDatasets", () => { }); await flushFrames(4); - expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); expect(getDatasetLayerOpacityValues(renderer)).toContain(1); expect(getDatasetLayerOpacityValues(renderer)).toContain(0); + expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); expect(getContainerLayerOpacityValues(renderer).every((opacity) => opacity === 1)).toBe(true); await cleanupRenderer(renderer); }); + it("pauses inactive dataset layers with React Activity", async () => { + const datasets = [ + { + data: [{ id: "spot-1", label: "Spot" }], + key: "spot", + }, + { + data: [{ id: "futures-1", label: "Futures" }], + key: "futures", + }, + ]; + + const { LegendListDatasets } = await import("../../src/components/LegendListDatasets?pause-activity"); + const renderer = await createRenderer( + 50} + keyExtractor={(item, _index, datasetKey) => `${datasetKey}:${item.id}`} + recycleItems={false} + renderItem={({ datasetKey, item }) => {`${datasetKey}:${item.label}`}} + staggerMountMs={0} + />, + ); + + await flushFrames(); + await layoutDefaultScrollView(renderer); + await flushFrames(8); + + expect(getRenderedLabels(renderer)).toContain("spot:Spot"); + expect(getRenderedLabels(renderer)).not.toContain("futures:Futures"); + + await act(async () => { + renderer.update( + 50} + keyExtractor={(item, _index, datasetKey) => `${datasetKey}:${item.id}`} + recycleItems={false} + renderItem={({ datasetKey, item }) => {`${datasetKey}:${item.label}`}} + staggerMountMs={0} + />, + ); + }); + await flushFrames(4); + + expect(getRenderedLabels(renderer)).toContain("futures:Futures"); + expect(getRenderedLabels(renderer)).not.toContain("spot:Spot"); + + await cleanupRenderer(renderer); + }); + it("renders ListEmptyComponent for the active dataset only", async () => { const datasets = [ { @@ -317,6 +372,7 @@ describe("LegendListDatasets", () => { callbackEvents.add(`type:${datasetKey}:${item.id}:${index}`); return datasetKey === "spot" ? "spot-row" : "futures-row"; }} + inactiveDatasetBehavior="hide" keyExtractor={(item, index, datasetKey) => { callbackEvents.add(`key:${datasetKey}:${item.id}:${index}`); return `${datasetKey}:${item.id}`; diff --git a/src/components/LegendListDatasets.tsx b/src/components/LegendListDatasets.tsx index 9935cada..b87c717f 100644 --- a/src/components/LegendListDatasets.tsx +++ b/src/components/LegendListDatasets.tsx @@ -72,6 +72,8 @@ import { extractPadding } from "@/utils/helpers"; import { normalizeMaintainVisibleContentPosition } from "@/utils/normalizeMaintainVisibleContentPosition"; import { useThrottledOnScroll } from "@/utils/throttledOnScroll"; +const Activity = React.Activity; + type SharedScrollRef = LegendListScrollerRef & LooseView; type LayoutEventLike = { nativeEvent: { layout: LayoutRectangle } }; type ScrollEventLike = { @@ -112,11 +114,14 @@ interface DatasetLayerShellProps { } function DatasetLayerShell({ children, inactiveDatasetBehavior, isActive }: DatasetLayerShellProps) { - const shouldHideLayer = !isActive && (inactiveDatasetBehavior === "pause" || inactiveDatasetBehavior === "hide"); + const shouldPauseLayer = !isActive && inactiveDatasetBehavior === "pause"; + const shouldHideLayer = !isActive && (shouldPauseLayer || inactiveDatasetBehavior === "hide"); return ( - {children} + + {children} + ); } From b25806e75995e53f0de8d5a18eb76166c52be91a Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Wed, 20 May 2026 11:20:54 -0500 Subject: [PATCH 6/8] docs: clarify dataset inactive behavior Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- MIGRATION.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 2b4cde07..70044952 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -110,10 +110,10 @@ type getFixedItemSize = ( | `activeKey` | `activeDatasetKey` | | `inactiveBehavior` | `inactiveDatasetBehavior` | -`inactiveDatasetBehavior` supports the same values as before: +`inactiveDatasetBehavior` supports these values: -- `"pause"`: keep inactive datasets mounted but hidden. This is the default. -- `"hide"`: keep inactive datasets mounted but hidden. +- `"pause"`: keep inactive datasets mounted but hidden with `React.Activity`. This is the default. +- `"hide"`: keep inactive datasets mounted and rendering, but visually hidden. - `"unmount"`: unmount inactive datasets. ### Dataset entries From 61862161e7a5ef51de0e38ffe25c3351a0124238 Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Wed, 20 May 2026 11:32:06 -0500 Subject: [PATCH 7/8] fix: hide inactive datasets with display none Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- MIGRATION.md | 2 +- __tests__/components/LegendListDatasets.test.tsx | 16 ++++++++-------- src/components/LegendListDatasets.tsx | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 70044952..1f414cd7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -113,7 +113,7 @@ type getFixedItemSize = ( `inactiveDatasetBehavior` supports these values: - `"pause"`: keep inactive datasets mounted but hidden with `React.Activity`. This is the default. -- `"hide"`: keep inactive datasets mounted and rendering, but visually hidden. +- `"hide"`: keep inactive datasets mounted and rendering, but visually hidden with `display: "none"`. - `"unmount"`: unmount inactive datasets. ### Dataset entries diff --git a/__tests__/components/LegendListDatasets.test.tsx b/__tests__/components/LegendListDatasets.test.tsx index ef57687f..e9042e0c 100644 --- a/__tests__/components/LegendListDatasets.test.tsx +++ b/__tests__/components/LegendListDatasets.test.tsx @@ -80,14 +80,14 @@ function getRenderedLabels(renderer: TestRenderer.ReactTestRenderer) { return Array.from(new Set(collectTextFromTree(renderer.toJSON()))); } -function getDatasetLayerOpacityValues(renderer: TestRenderer.ReactTestRenderer) { +function getDatasetLayerDisplayValues(renderer: TestRenderer.ReactTestRenderer) { return renderer.root .findAllByType(View) - .map((node) => StyleSheet.flatten(node.props.style) as { flex?: number; opacity?: number } | undefined) + .map((node) => StyleSheet.flatten(node.props.style) as { display?: string; flex?: number } | undefined) .filter( - (style): style is { flex?: number; opacity: number } => style?.flex === 1 && style.opacity !== undefined, + (style): style is { display: string; flex?: number } => style?.flex === 1 && style.display !== undefined, ) - .map((style) => style.opacity); + .map((style) => style.display); } function getContainerLayerOpacityValues(renderer: TestRenderer.ReactTestRenderer) { @@ -187,8 +187,8 @@ describe("LegendListDatasets", () => { await layoutDefaultScrollView(renderer); await flushFrames(8); - expect(getDatasetLayerOpacityValues(renderer)).toContain(1); - expect(getDatasetLayerOpacityValues(renderer)).toContain(0); + expect(getDatasetLayerDisplayValues(renderer)).toContain("flex"); + expect(getDatasetLayerDisplayValues(renderer)).toContain("none"); expect(getRenderedLabels(renderer)).toContain("Spot"); expect(getRenderedLabels(renderer)).toContain("Futures"); expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); @@ -210,8 +210,8 @@ describe("LegendListDatasets", () => { }); await flushFrames(4); - expect(getDatasetLayerOpacityValues(renderer)).toContain(1); - expect(getDatasetLayerOpacityValues(renderer)).toContain(0); + expect(getDatasetLayerDisplayValues(renderer)).toContain("flex"); + expect(getDatasetLayerDisplayValues(renderer)).toContain("none"); expect(events).toEqual(["mount:spot-1", "mount:futures-1"]); expect(getContainerLayerOpacityValues(renderer).every((opacity) => opacity === 1)).toBe(true); diff --git a/src/components/LegendListDatasets.tsx b/src/components/LegendListDatasets.tsx index b87c717f..dcf20aca 100644 --- a/src/components/LegendListDatasets.tsx +++ b/src/components/LegendListDatasets.tsx @@ -155,8 +155,8 @@ export type LegendListDatasetsProps const styles = StyleSheet.create({ layerHidden: { + display: "none" as const, flex: 1, - opacity: 0, }, layerRoot: { bottom: 0, @@ -166,8 +166,8 @@ const styles = StyleSheet.create({ top: 0, }, layerVisible: { + display: "flex" as const, flex: 1, - opacity: 1, }, }); From 8312e220cf0e66018c1623eb091a9f660f49566a Mon Sep 17 00:00:00 2001 From: Peter Piekarczyk Date: Wed, 20 May 2026 11:33:23 -0500 Subject: [PATCH 8/8] chore: bump beta package version Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 666d431e..a4c691c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@peterpme/list", - "version": "3.0.0-beta.62", + "version": "3.0.0-beta.63", "description": "Legend List is a drop-in replacement for FlatList with much better performance and supporting dynamically sized items.", "sideEffects": false, "private": false,