From b81ea4bdc439417b8f69edef353e2c71bbbf0ed9 Mon Sep 17 00:00:00 2001 From: Fadhlan Date: Fri, 7 Aug 2026 20:18:34 +0700 Subject: [PATCH] fix(sortable): make onItemSnapEnd a ref so finalizeDrag is actually called `SortableItem` destructures `onItemSnapEnd` from `sortable._internal` at render time, but `SortableContainer` only assigns it in a `useLayoutEffect` that runs after render. Since `useSortableList` rebuilds `_internal` on every render with `onItemSnapEnd: undefined`, the destructured value is always `undefined`. The result is that `finalizeDrag` is never invoked when the snap animation ends, so the reorder is not committed and the item visually snaps back to its original position. The type already documented the intent ("stored as a ref so the latest finalizeDrag is always called"), but a plain property on a per-render object cannot provide that. Store the callback in a stable `useRef` instead: - `useSortableList` owns `onItemSnapEndRef` (created once). - `SortableContainer` writes `onItemSnapEndRef.current = finalizeDrag`. - `SortableItem` reads `onItemSnapEndRef.current` at call time, so it always sees the registered callback regardless of when `_internal` was captured. Fixes #236 --- docs-site/docs/api/components/sortable-item.mdx | 2 +- .../version-1.x/api/components/sortable-item.mdx | 2 +- src/SortableContainer.tsx | 2 +- src/SortableItem.tsx | 4 ++-- src/hooks/useSortableList.ts | 9 ++++++++- src/types.ts | 2 +- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs-site/docs/api/components/sortable-item.mdx b/docs-site/docs/api/components/sortable-item.mdx index 9d415e5..dd155eb 100644 --- a/docs-site/docs/api/components/sortable-item.mdx +++ b/docs-site/docs/api/components/sortable-item.mdx @@ -102,7 +102,7 @@ Returns: `itemKey`, `index`, `isActive` (SharedValue), `activeItemId` (SharedVal - Shift values come from `shiftsRef` SharedValue (keyed by item key) - Visibility controlled by `hoverReadySV` + `draggedIdSV` — hidden when being dragged - Measures itself on layout and stores in `itemMeasurements` Map -- Calls `onItemSnapEnd` when snap-back completes (triggers reorder finalization) +- Calls `onItemSnapEndRef.current` when snap-back completes (triggers reorder finalization) - Respects reduced motion via `useReducedMotion()` ## Related diff --git a/docs-site/versioned_docs/version-1.x/api/components/sortable-item.mdx b/docs-site/versioned_docs/version-1.x/api/components/sortable-item.mdx index 9d415e5..dd155eb 100644 --- a/docs-site/versioned_docs/version-1.x/api/components/sortable-item.mdx +++ b/docs-site/versioned_docs/version-1.x/api/components/sortable-item.mdx @@ -102,7 +102,7 @@ Returns: `itemKey`, `index`, `isActive` (SharedValue), `activeItemId` (SharedVal - Shift values come from `shiftsRef` SharedValue (keyed by item key) - Visibility controlled by `hoverReadySV` + `draggedIdSV` — hidden when being dragged - Measures itself on layout and stores in `itemMeasurements` Map -- Calls `onItemSnapEnd` when snap-back completes (triggers reorder finalization) +- Calls `onItemSnapEndRef.current` when snap-back completes (triggers reorder finalization) - Respects reduced motion via `useReducedMotion()` ## Related diff --git a/src/SortableContainer.tsx b/src/SortableContainer.tsx index 9f1d0ae..8dd4008 100644 --- a/src/SortableContainer.tsx +++ b/src/SortableContainer.tsx @@ -214,7 +214,7 @@ export const SortableContainer = ({ // calls the latest version, even if it has a stale _internal reference // (e.g., after MATCH path skips FlatList re-render). useLayoutEffect(() => { - sortable._internal.onItemSnapEnd = finalizeDrag; + sortable._internal.onItemSnapEndRef.current = finalizeDrag; }, [sortable._internal, finalizeDrag]); // ── Auto-scroll ───────────────────────────────────────────────────── diff --git a/src/SortableItem.tsx b/src/SortableItem.tsx index c5504c8..54cf530 100644 --- a/src/SortableItem.tsx +++ b/src/SortableItem.tsx @@ -126,7 +126,7 @@ const SortableItemInner = ({ rawData, originalIndexes, scrollPosition, - onItemSnapEnd, + onItemSnapEndRef, fixedKeys, } = sortable._internal; @@ -221,7 +221,7 @@ const SortableItemInner = ({ draxViewProps.onDragDrop?.(event); }} onSnapEnd={(snapData) => { - onItemSnapEnd?.(); + onItemSnapEndRef.current?.(); draxViewProps.onSnapEnd?.(snapData); }} onMeasure={(measurements) => { diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index 1ff54d0..129bec6 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -121,6 +121,13 @@ export const useSortableList = ( // ── Drag tracking (refs, no re-render) ──────────────────────────── const draggedDisplayIndexRef = useRef(undefined); const dragStartIndexRef = useRef(undefined); + /** + * Holds the `finalizeDrag` callback registered by `SortableContainer`. + * A ref (not a plain property) so `SortableItem` reads the latest value at + * call time — `_internal` is rebuilt every render, so a value stored on it + * directly is always `undefined` for consumers that destructure at render. + */ + const onItemSnapEndRef = useRef<(() => void) | undefined>(undefined); /** * Pending reorder during drag. Tracks the desired display order * as indices into rawData. Updated by moveDraggedItem (ref, not state). @@ -1023,7 +1030,7 @@ export const useSortableList = ( getMeasurementByOriginalIndex, dropTargetPositionSV, dropTargetVisibleSV, - onItemSnapEnd: undefined as (() => void) | undefined, + onItemSnapEndRef, draggedDisplayIndexRef, dragStartIndexRef, shiftsRef, diff --git a/src/types.ts b/src/types.ts index f393ffc..2e8d274 100644 --- a/src/types.ts +++ b/src/types.ts @@ -812,7 +812,7 @@ export interface SortableListInternal { /** Called by SortableItem's onSnapEnd to finalize the drag. * Stored as a ref so the latest finalizeDrag is always called, * even if SortableItem has a stale _internal reference. */ - onItemSnapEnd?: () => void; + onItemSnapEndRef: RefObject<(() => void) | undefined>; /** Current display index of the dragged item (updated during live reorder) */ draggedDisplayIndexRef: RefObject; /** Original display index where the drag started */