From 8f582a5839a69e7a48e6cfefa4b17d4e9da48764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Wed, 15 Jul 2026 20:55:11 +0200 Subject: [PATCH] Fix autoAdjustOffsetDuringDrag stale offset and missing reorder Two bugs in Sortable.Grid autoAdjustOffsetDuringDrag with collapsible items: - An at-rest size change (e.g. a collapse/expand toggle) after a drag resolved a stale prevActiveItemKey, applied a bogus offset and set sortEnabled=false with no drop event to restore it, permanently disabling sorting. Pass through when no item is active. - A drag with no mid-drag size change never reordered because othersLayout was gated on additionalCrossOffset being non-null. Compute it unconditionally and hold ordering only during the mid-drag size transition, snapshotting the drag-start cross sizes via a reaction so consecutive same-key drags keep working. --- .../grid/AutoOffsetAdjustmentProvider.tsx | 9 +++ .../grid/GridLayoutProvider/updates/common.ts | 73 +++++++++++++++---- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx b/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx index ae402943..28d9fed8 100644 --- a/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx +++ b/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx @@ -274,6 +274,15 @@ const { AutoOffsetAdjustmentProvider, useAutoOffsetAdjustmentContext } = }; } + // At-rest cross-size change (e.g. a "collapse all"/"expand all" toggle) + // with no active drag: `itemKey` above fell back to a stale + // `prevActiveItemKey` from the previous drag. Applying an offset here + // would shift the whole layout and set `sortEnabled = false` with no + // drop event ever coming to restore it, permanently disabling sorting. + if (activeItemKey.value === null) { + return props; + } + let snapBasedOffset = 0; if ( diff --git a/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/updates/common.ts b/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/updates/common.ts index cc536ec6..c507a146 100644 --- a/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/updates/common.ts +++ b/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/updates/common.ts @@ -1,8 +1,14 @@ -import { type SharedValue, useDerivedValue } from 'react-native-reanimated'; +import { + type SharedValue, + useAnimatedReaction, + useDerivedValue +} from 'react-native-reanimated'; +import { useMutableValue } from '../../../../integrations/reanimated'; import type { Coordinate, Dimension, + ItemSizes, ReorderFunction, SortStrategyFactory } from '../../../../types'; @@ -23,6 +29,7 @@ export const createGridStrategy = ): SortStrategyFactory => () => { const { + activeItemKey, containerHeight, containerWidth, indexToKey, @@ -37,21 +44,37 @@ export const createGridStrategy = const othersIndexToKey = useInactiveIndexToKey(); const debugBox = useDebugBoundingBox(); + // Cross sizes captured at each drag start. Used to detect a mid-drag size + // change (e.g. items collapsing on drag start) that is still in flight + // before the auto offset is applied - see the gate in the order updater. + // Re-captured on every drag start (activeItemKey null -> key), so it stays + // correct even when the same item is dragged twice in a row. + const dragStartCrossSizes = useMutableValue(null); + + useAnimatedReaction( + () => activeItemKey.value, + key => { + if (key !== null) { + dragStartCrossSizes.value = isVertical + ? itemHeights.value + : itemWidths.value; + } + } + ); + const othersLayout = useDerivedValue(() => - additionalCrossOffset?.value === null - ? null - : calculateLayout({ - gaps: { - cross: crossGap.value, - main: mainGap.value - }, - indexToKey: othersIndexToKey.value, - isVertical, - itemHeights: itemHeights.value, - itemWidths: itemWidths.value, - numGroups, - startCrossOffset: additionalCrossOffset?.value - }) + calculateLayout({ + gaps: { + cross: crossGap.value, + main: mainGap.value + }, + indexToKey: othersIndexToKey.value, + isVertical, + itemHeights: itemHeights.value, + itemWidths: itemWidths.value, + numGroups, + startCrossOffset: additionalCrossOffset?.value ?? 0 + }) ); let mainContainerSize: SharedValue; @@ -76,6 +99,26 @@ export const createGridStrategy = return ({ activeIndex, dimensions, position }) => { 'worklet'; + // While the auto offset is enabled (additionalCrossOffset defined) but not + // yet applied (value still null), hold ordering only during a mid-drag + // size transition - when the cross sizes changed since the drag started. + // In that window othersLayout (new sizes, offset 0) is inconsistent with + // the on-screen layout and the finger position, so the bounds walk would + // fire a far-jump reorder that the offset block then anchors on. Ordering + // resumes as soon as the offset is applied. Drags with no size change + // (uniform or already-collapsed items) are unaffected - the snapshot + // stays reference-equal, so ordering works from the first move. + const currentCrossSizes = isVertical + ? itemHeights.value + : itemWidths.value; + if ( + additionalCrossOffset && + additionalCrossOffset.value === null && + dragStartCrossSizes.value !== currentCrossSizes + ) { + return; + } + if ( !othersLayout.value || crossContainerSize.value === null ||