From 3bdcab33b2734654ab980f56efcf2a7d6a033c41 Mon Sep 17 00:00:00 2001 From: Albert Hansrisuk Date: Thu, 3 Sep 2026 13:03:46 -0400 Subject: [PATCH 1/2] fix: stale cell measurements after a delete corrupt reorder position math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing an item from `data` shifts the `index` of every cell below it, but a cell's own `onLayout` doesn't necessarily refire on an index-only shift (its dimensions haven't changed) — so `cellDataRef`'s stored offset/size for cells below a deleted row went stale, corrupting drag-reorder position math for the remaining items. Previously this re-measure was only forced on web via a mount-time RAF; native FlatList cells need it too, on every index change, not just once at mount. That re-measure firing more often exposed a second bug: `heldTanslate` (the anti-flicker hold that keeps a cell's drag-end transform steady until its real settled position is confirmed — see `animStyle`) was being released the instant the re-measure was *kicked off*, not once it actually resolved. Since the re-measure is now also speculative (fired a frame after every index change, not gated behind a confirmed `onLayout`), that could release the hold before the FlatList's own relayout had actually settled the shifted cells into their new flow position — flashing/snapping visibly right as a reorder finished. Now the hold only releases once the measurement itself resolves (success or failure), preserving the original anti-flicker guarantee. Also cleans up `keyToIndexRef`/`cellDataRef` entries for keys no longer present in `data`, so a removed row's stale entry can't linger and be read by a future cell that reuses a similar key shape. --- src/components/CellRendererComponent.tsx | 29 ++++++++++++++++++------ src/components/DraggableFlatList.tsx | 13 ++++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/components/CellRendererComponent.tsx b/src/components/CellRendererComponent.tsx index e4e861a6..2245d8e9 100644 --- a/src/components/CellRendererComponent.tsx +++ b/src/components/CellRendererComponent.tsx @@ -80,12 +80,26 @@ function CellRendererComponent(props: Props) { size.value = cellSize; offset.value = cellOffset; + // Only release the held translate (see animStyle) once this cell's real, + // settled position is actually known — not merely once onCellLayout was + // called. onCellLayout is now also invoked speculatively, a frame after + // every index change (see the effect below), to force a re-measure for + // cells whose native onLayout doesn't refire on an index-only shift; that + // speculative call can land before the FlatList's own relayout has + // actually settled the cell into its new flow position. Clearing + // heldTanslate right there (rather than here) let the cell's transform + // drop to 0 a beat before its real position caught up, flashing/snapping + // visibly right as a reorder finished. + heldTanslate.value = 0; }; const onFail = () => { if (propsRef.current?.debug) { console.log(`## on measure fail, index: ${index}`); } + // Still release the hold on failure so a cell can't get stuck visibly + // offset forever just because one measurement attempt didn't land. + heldTanslate.value = 0; }; const containerNode = containerRef.current; @@ -99,18 +113,19 @@ function CellRendererComponent(props: Props) { }); const onCellLayout = useStableCallback((e?: LayoutChangeEvent) => { - heldTanslate.value = 0; updateCellMeasurements(); if (onLayout && e) onLayout(e); }); useEffect(() => { - if (isWeb) { - // onLayout isn't called on web when the cell index changes, so we manually re-measure - requestAnimationFrame(() => { - onCellLayout(); - }); - } + // onLayout isn't reliably called when a cell's index shifts (e.g. an item above it + // was removed) without its own dimensions changing, so we manually re-measure. This + // was previously gated to web only, but native FlatList cells left stale, un-refreshed + // offset/size measurements after a reflow just the same — corrupting drag-reorder + // position math for cells below a deleted item. + requestAnimationFrame(() => { + onCellLayout(); + }); }, [index, onCellLayout]); const baseStyle = useMemo(() => { diff --git a/src/components/DraggableFlatList.tsx b/src/components/DraggableFlatList.tsx index 7c88afc5..38e9b913 100644 --- a/src/components/DraggableFlatList.tsx +++ b/src/components/DraggableFlatList.tsx @@ -138,11 +138,22 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { }, [activeKey]); useLayoutEffect(() => { + const currentKeys = new Set(); props.data.forEach((d, i) => { const key = keyExtractor(d, i); + currentKeys.add(key); keyToIndexRef.current.set(key, i); }); - }, [props.data, keyExtractor, keyToIndexRef]); + // Clean up entries for items no longer in `data` (e.g. removed via delete) so a + // removed row's stale offset/size measurement doesn't stick around and corrupt + // drag-reorder position math for the remaining items. + Array.from(keyToIndexRef.current.keys()).forEach((key) => { + if (!currentKeys.has(key)) { + keyToIndexRef.current.delete(key); + cellDataRef.current.delete(key); + } + }); + }, [props.data, keyExtractor, keyToIndexRef, cellDataRef]); const drag = useStableCallback((activeKey: string) => { if (disabled.value) return; From a4157c9994148f154062171610310dbbedfcc53d Mon Sep 17 00:00:00 2001 From: Albert Hansrisuk Date: Thu, 3 Sep 2026 13:03:59 -0400 Subject: [PATCH 2/2] fix: dragging silently disabled for cells outside the initial viewable range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useCellTranslate bailed out of computing a cell's drag translate for any cell outside [viewableIndexMin, viewableIndexMax] — populated from onViewableItemsChanged, which only updates on scroll or initial layout. A list whose container grows to reveal more content some other way (for example an outer animated height expanding a sheet, rather than the list itself scrolling) never fires a new viewability check, so those bounds stay stuck at whatever was visible in the very first — often barely-visible, still-collapsing — layout pass. Every cell beyond that range then silently can't be dragged: `drag()` still fires and sets `activeIndexAnim`, but the cell's own translate always computes to 0, so nothing visibly follows the gesture. This optimization only matters for a list long enough to be virtualized in the first place; for a short, fully-rendered list it has no benefit and can only make cells undraggable. Removing it fixes reordering for any consumer whose list's visible bounds can grow without a scroll event, without changing behavior for lists that rely on normal scrolling (virtualized or not) to reveal new cells. --- src/hooks/useCellTranslate.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/hooks/useCellTranslate.tsx b/src/hooks/useCellTranslate.tsx index efea2403..a100eaeb 100644 --- a/src/hooks/useCellTranslate.tsx +++ b/src/hooks/useCellTranslate.tsx @@ -17,8 +17,6 @@ export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) { spacerIndexAnim, placeholderOffset, hoverAnim, - viewableIndexMin, - viewableIndexMax, } = useAnimatedValues(); const { activeKey } = useDraggableFlatListContext(); @@ -27,11 +25,18 @@ export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) { const translate = useDerivedValue(() => { const isActiveCell = cellIndex === activeIndexAnim.value; - const isOutsideViewableRange = - !isActiveCell && - (cellIndex < viewableIndexMin.value || - cellIndex > viewableIndexMax.value); - if (!activeKey || activeIndexAnim.value < 0 || isOutsideViewableRange) { + // NOTE: the upstream isOutsideViewableRange check (bailing out for cells outside + // [viewableIndexMin, viewableIndexMax]) is intentionally removed here. FlatList's + // viewability tracking is driven by onScroll + initial layout and does not + // automatically re-fire when a container grows to reveal more content without an + // actual scroll event — which is exactly our case (the queue sheet expands via an + // outer animated height, not by scrolling the inner list). That left + // viewableIndexMax permanently stuck at whatever was visible in the very first + // (collapsed, barely-visible) layout pass, silently excluding every cell beyond it + // from ever being a valid drag target. Since our queue is always a short, + // fully-rendered list (never meaningfully virtualized), this optimization isn't + // needed and was actively breaking drag-to-reorder. + if (!activeKey || activeIndexAnim.value < 0) { return 0; }