Skip to content

autoAdjustOffsetDuringDrag: stale offset on at-rest size changes disables sorting; ordering never fires for drags without a mid-drag size change #605

Description

@lordpotato89

Description

We use Sortable.Grid with autoAdjustOffsetDuringDrag for a list of collapsible cards (items collapse during a drag, and can also be collapsed/expanded outside of drags). We found two independent bugs in the feature, plus an interlock between their fixes that's worth documenting together — fixing bug 2 naively regresses drags that do have a mid-drag size change. We've been running the complete two-part fix in production via patch-package and are happy to open a PR.

Environment

  • react-native-sortables 1.9.4
  • react-native 0.81.5 (new architecture), react-native-reanimated 4.1.6, Expo SDK 54
  • iOS (logic is platform-independent)

Setup

<Sortable.Grid
  data={items}
  renderItem={renderItem}          // items render expanded or collapsed
  columns={1}
  scrollableRef={scrollableRef}    // Reanimated ScrollView ref
  autoAdjustOffsetDuringDrag
  onDragStart={() => setCollapsed(true)}   // collapse items during drag
  onDragEnd={() => setCollapsed(false)}
  dimensionsAnimationType="worklet"
/>

Items are also collapsible outside of drags (per-item chevron + a "collapse all" toggle).


Bug 1 — item-size change outside a drag applies a stale offset and permanently disables sorting

Reproduction

  1. Render the grid with several expanded (tall) items.
  2. Perform one drag of any item and drop it (everything works).
  3. Without dragging, tap "collapse all" so every item's height shrinks at once.
  4. Observe: the entire list shifts down by a large blank offset (roughly oldExpandedOffset − newCollapsedOffset of the previously dragged item), and from now on no drag can be activated.
  5. Expanding any item again snaps the layout back and sorting works — which makes the bug look intermittent in the field.

Step 2 is essential: on a fresh mount (no completed drag) step 3 is harmless.

Root cause

All in src/providers/grid/AutoOffsetAdjustmentProvider.tsx:

  1. adaptLayoutProps runs from GridLayoutProvider's layout reaction on every cross-size change — drag or no drag.
  2. It resolves the "active" key as activeItemKey.value ?? prevActiveItemKey.value, and prevActiveItemKey persists after a completed drag (cleared only at the next drag start). So after any drag, a non-drag size change still resolves a non-null key.
  3. The provider is armed at rest: the activeItemDropped reaction's else-branch sets ctx.state = ENABLED at mount/drag start, and disableAutoOffsetAdjustment early-returns without resetting state when ctx.prevSortEnabled === null.
  4. A non-drag cross-size change therefore falls through to the offset-application block: a bogus startCrossOffset is computed from the stale key, and sortEnabled.value = false is set with no drop event ever coming to restore it. DragProvider.handleTouchStart then fails on !sortEnabled.value — the "sorting permanently dead" symptom. A later size change that happens to hit the post-drop restore branch heals it (why expanding an item "fixes" the grid).

Fix (shipping in our patch)

--- a/src/providers/grid/AutoOffsetAdjustmentProvider.tsx
+++ b/src/providers/grid/AutoOffsetAdjustmentProvider.tsx
@@ -274,6 +274,16 @@
           };
         }
 
+        // Cross-size change with no active drag and no pending post-drop
+        // restore (additionalCrossOffset === null) — e.g. a collapse/
+        // expand-all toggle. itemKey above resolved to a STALE
+        // prevActiveItemKey, and applying an offset here would shift the
+        // whole layout and set sortEnabled = false with no drop event ever
+        // coming to restore it. Unrelated to dragging: pass through.
+        if (activeItemKey.value === null) {
+          return props;
+        }
+
         let snapBasedOffset = 0;
 
         if (

Optional hardening: make disableAutoOffsetAdjustment reset ctx.state = DISABLED even when ctx.prevSortEnabled === null, so the provider isn't armed at rest.


Bug 2 — ordering is a silent no-op for any drag with no mid-drag size change

Reproduction

  1. Same setup; make all items the collapsed (uniform, small) height — so the onDragStart collapse changes nothing.
  2. Long-press any item: it activates and follows the finger, but no reorder ever fires — the vacated slot stays put, all other items are frozen, and the drop reverts. Deterministic, from fresh mount.
  3. Expand at least one item and the problem "disappears" (see interlock below for why).

Root cause

src/providers/grid/GridLayoutProvider/updates/common.ts (createGridStrategy):

const othersLayout = useDerivedValue(() =>
  additionalCrossOffset?.value === null
    ? null
    : calculateLayout({ ..., startCrossOffset: additionalCrossOffset?.value })
);

and the strategy body early-returns on !othersLayout.value. With the provider mounted, additionalCrossOffset stays null until a mid-drag size change routes through the offset-application block — so a drag during which no item changes size never computes othersLayout and never produces an order change. Without the provider, additionalCrossOffset is undefined (undefined === null → false), which is why vanilla grids are unaffected.

The null-gate is unnecessary for layout correctness: calculateLayout already treats the param as startCrossOffset ?? 0 (utils/layout.ts).

The interlock: removing the gate alone regresses size-changing drags

We first shipped only startCrossOffset: additionalCrossOffset?.value ?? 0 (computing othersLayout unconditionally). That fixed bug 2 but exposed why the gate existed: in the window between the drag-start size flip and the offset application (separate mappers), a strategy tick compares the finger — still at its old-layout coordinate — against a new-sizes + offset-0 model. The bounds-walk marches to the end of the list, fires a far-jump order change, and the offset block then anchors startCrossOffset on that wrong index — an old-layout-sized offset that persists for the whole drag (re-served by the !crossOffsetsChanged branch). Symptom: all non-active items pushed far down during the drag.

The gate's correct scope is exactly the transition, not "until the first offset":

--- a/src/providers/grid/GridLayoutProvider/updates/common.ts
+++ b/src/providers/grid/GridLayoutProvider/updates/common.ts
@@ -1,4 +1,4 @@
-import { type SharedValue, useDerivedValue } from 'react-native-reanimated';
+import { type SharedValue, useDerivedValue, useSharedValue } from 'react-native-reanimated';
@@ -37,21 +37,31 @@
     const othersIndexToKey = useInactiveIndexToKey();
     const debugBox = useDebugBoundingBox();
 
+    // Drag-start snapshot of the cross sizes — used to hold ordering during
+    // a mid-drag size transition (see gate in the updater).
+    const lastActiveKey = useSharedValue<null | string>(null);
+    const dragStartCrossSizes = useSharedValue<unknown>(null);
+
     const othersLayout = useDerivedValue(() =>
-      additionalCrossOffset?.value === null
-        ? null
-        : calculateLayout({
-            ...
-            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
+      })
     );
@@ (updater)
-    return ({ activeIndex, dimensions, position }) => {
+    return ({ activeIndex, activeKey, dimensions, position }) => {
       'worklet';
+      // Hold ordering ONLY while a mid-drag size change is in flight and the
+      // auto-offset has not been applied yet — in that window othersLayout
+      // (new sizes + offset 0) is inconsistent with the on-screen layout and
+      // the finger position, and the bounds-walk fires a far-jump order
+      // change that the offset block then anchors on. Resumes as soon as
+      // adaptLayoutProps applies the offset. Drags with no size change are
+      // unaffected (snapshot stays reference-equal).
+      const currentCrossSizes = isVertical ? itemHeights.value : itemWidths.value;
+      if (activeKey !== lastActiveKey.value) {
+        lastActiveKey.value = activeKey;
+        dragStartCrossSizes.value = currentCrossSizes;
+      }
+      if (
+        additionalCrossOffset &&
+        additionalCrossOffset.value === null &&
+        dragStartCrossSizes.value !== currentCrossSizes
+      ) {
+        return;
+      }

(Reference equality works because MeasurementsProvider assigns a fresh sizes object per measurement batch.)


Status on our side

All three changes (bug-1 guard, unconditional othersLayout, transition-scoped gate) are running together in production via patch-package and are device-verified across: all-collapsed drags, all-expanded drags with drag-start collapse, mixed states, collapse-all at rest after drags, and repeated alternating drags.

Happy to open a PR with the complete set (and the optional disableAutoOffsetAdjustment hardening) if you agree with the direction. Thanks for the library — collapsible-items support is exactly what our use case needs, these edge cases aside.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions