feat: LegendListDatasets#433
Conversation
Fix style prop of KeyboardAvoidingLegendList on Non-android platforms.
…tching Introduces LegendListDatasets — a new component that keeps multiple data arrays (datasets) simultaneously mounted in the React tree, using React.Activity to defer work for inactive datasets. Switching between datasets (e.g. spot/futures/favorites tabs) is near-instant: no React reconciliation, no re-measurement, no re-allocation. Architecture: - One shared ScrollView and ListHeaderComponent (rendered once) - Each dataset gets its own StateProvider + React.Activity wrapper - Active dataset: Activity mode="visible", Containers in normal flow - Inactive datasets: Activity mode="hidden", wrapper is position:absolute height:0 overflow:hidden — items live at translateY:-9999 but stay mounted so their sizes are cached - On dataset switch: parent calls activate(scrollOffset) on the new active layer which runs calculateItemsInView with cached sizes New exports: LegendListDatasets, DatasetEntry, LegendListDatasetsProps
- Wire up forwardedRef with full LegendListRef imperative API (scrollToIndex, scrollToItem, scrollToEnd, scrollToOffset, scrollIndexIntoView, scrollItemIntoView, getState, setScrollProcessingEnabled, setVisibleContentAnchorOffset, flashScrollIndicators, getNativeScrollRef, etc.) All methods delegate to the active dataset's ctx/state via getCtx()/getState() on DatasetLayerHandle - Add initialScrollIndex / initialScrollOffset support Applied on first activation of each dataset; subsequent activations preserve the current scroll position - Add snapToIndices support Each DatasetLayerInner calculates snap offsets via updateSnapToOffsets; LegendListDatasets subscribes to the active dataset's snapToOffsets via listen$ and passes them directly to the ScrollView - Fix onScrollHandler to use activeIndexRef instead of captured activeIndex so it always routes to the correct layer without needing activeIndex in the dependency array
Three root causes fixed:
1. DatasetLayer had no React.memo — any parent re-render would cascade
through all dataset layers unconditionally. Wrapped in typedMemo so
sibling layers are skipped when only one dataset's data changes.
2. Callback props (renderItem, onEndReached, keyExtractor, etc.) were in
sharedLayerProps deps, so un-memoized inline functions in the parent
would invalidate sharedLayerProps every render, breaking the memo.
Stabilized via refs + stable wrapper callbacks.
3. dataVersion was shared across all datasets — bumping it triggered
calculateItemsInView({dataChanged:true}) on every layer simultaneously,
clearing all positions for all datasets at once. Moved to DatasetEntry
as a per-dataset field so only the affected dataset rebuilds.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33cb1e85dd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| {!showEmpty && | ||
| datasets.map((dataset, index) => ( |
There was a problem hiding this comment.
Keep dataset layers mounted when showing empty state
When showEmpty is true this branch skips rendering datasets.map(...), which unmounts all DatasetLayer instances, not just the active empty one. If a user switches to an empty tab and then back, every other tab loses its cached containers/positions and scroll state, defeating the tab-switch preservation this feature is meant to provide. Render the layers regardless and only overlay the empty component for the active dataset.
Useful? React with 👍 / 👎.
| if (prev !== activeIndex && activeIndex >= 0) { | ||
| layerRefs.current[activeIndex]?.activate(currentScrollRef.current); |
There was a problem hiding this comment.
Activate the initial dataset on first mount
The activation effect only calls activate(...) when the active index changes, but on first render prevActiveIndexRef already equals activeIndex, so the initial active layer is never activated. Because initialScrollIndex/initialScrollOffset are applied inside DatasetLayer.activate, initial scroll is ignored on mount and can later cause an unexpected jump the first time the user returns to that tab. The initial active dataset should be activated once after mount.
Useful? React with 👍 / 👎.
| const didDataChange = state.props.dataVersion !== dataVersion || state.props.data !== dataProp; | ||
| if (didDataChange) { | ||
| state.dataChangeNeedsScrollUpdate = true; | ||
| } |
There was a problem hiding this comment.
Clear cached sizes when data changes without keyExtractor
On data changes this layer only sets dataChangeNeedsScrollUpdate, but unlike LegendList it never clears state.sizes/state.positions when no explicit keyExtractor is provided. In that configuration keys default to indices, so inserting/reordering items reuses stale size/position cache entries and can produce incorrect offsets/render windows until items are remeasured. DatasetLayer should mirror the reset behavior used in LegendList.tsx for this case.
Useful? React with 👍 / 👎.
- Use opacity: 0 + pointerEvents: none instead of height: 0 for inactive layers so native layout stays warm across tab switches - Skip clearing scrollForNextCalculateItemsInView on activate so calculateItemsInView can early-return when scroll hasn't moved - Add ActivityOrFragment fallback for React 18 compatibility - Add datasets example app with continent-based tabs - Add DATASETS.md documentation
dataVersion is only meaningful per-dataset (for detecting in-place mutations). A shared top-level dataVersion has no clear target dataset, so remove it from LegendListDatasetsProps. Also remove initialHeaderSize references that leaked in from the feat/initial-header-size branch.
Summary
LegendListDatasetscomponent for rendering multiple independent lists under a single shared ScrollView, header, and footerReact.Activityfor effect freezingopacity: 0+pointerEvents: noneinstead ofheight: 0to keep native layout warmcalculateItemsInViewrecalculation on reactivation when scroll position is still within the cached visible rangeActivityOrFragmentfallback for React 18 compatibilitykeyExtractor,estimatedItemSize,getEstimatedItemSize,getFixedItemSize, andgetItemTypeLegendListRefimperative API delegates to the active datasetNew files
src/components/LegendListDatasets.tsx— parent component owning the shared ScrollViewsrc/components/DatasetLayer.tsx— per-dataset list engine (headless LegendList)DATASETS.md— full documentation (architecture, usage, performance tips, footguns)example/app/datasets/index.tsx— example app with 5 continent-based tabsTest plan
recycleItems={true}andrecycleItems={false}