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
- Render the grid with several expanded (tall) items.
- Perform one drag of any item and drop it (everything works).
- Without dragging, tap "collapse all" so every item's height shrinks at once.
- 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.
- 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:
adaptLayoutProps runs from GridLayoutProvider's layout reaction on every cross-size change — drag or no drag.
- 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.
- 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.
- 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
- Same setup; make all items the collapsed (uniform, small) height — so the
onDragStart collapse changes nothing.
- 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.
- 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.
Description
We use
Sortable.GridwithautoAdjustOffsetDuringDragfor 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
Setup
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
oldExpandedOffset − newCollapsedOffsetof the previously dragged item), and from now on no drag can be activated.Step 2 is essential: on a fresh mount (no completed drag) step 3 is harmless.
Root cause
All in
src/providers/grid/AutoOffsetAdjustmentProvider.tsx:adaptLayoutPropsruns fromGridLayoutProvider's layout reaction on every cross-size change — drag or no drag.activeItemKey.value ?? prevActiveItemKey.value, andprevActiveItemKeypersists 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.activeItemDroppedreaction's else-branch setsctx.state = ENABLEDat mount/drag start, anddisableAutoOffsetAdjustmentearly-returns without resetting state whenctx.prevSortEnabled === null.startCrossOffsetis computed from the stale key, andsortEnabled.value = falseis set with no drop event ever coming to restore it.DragProvider.handleTouchStartthen 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)
Optional hardening: make
disableAutoOffsetAdjustmentresetctx.state = DISABLEDeven whenctx.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
onDragStartcollapse changes nothing.Root cause
src/providers/grid/GridLayoutProvider/updates/common.ts(createGridStrategy):and the strategy body early-returns on
!othersLayout.value. With the provider mounted,additionalCrossOffsetstaysnulluntil a mid-drag size change routes through the offset-application block — so a drag during which no item changes size never computesothersLayoutand never produces an order change. Without the provider,additionalCrossOffsetisundefined(undefined === null→ false), which is why vanilla grids are unaffected.The null-gate is unnecessary for layout correctness:
calculateLayoutalready treats the param asstartCrossOffset ?? 0(utils/layout.ts).The interlock: removing the gate alone regresses size-changing drags
We first shipped only
startCrossOffset: additionalCrossOffset?.value ?? 0(computingothersLayoutunconditionally). 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 anchorsstartCrossOffseton that wrong index — an old-layout-sized offset that persists for the whole drag (re-served by the!crossOffsetsChangedbranch). 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":
(Reference equality works because
MeasurementsProviderassigns 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
disableAutoOffsetAdjustmenthardening) if you agree with the direction. Thanks for the library — collapsible-items support is exactly what our use case needs, these edge cases aside.