Skip to content

fix(sortable): make onItemSnapEnd a ref so finalizeDrag is actually called - #283

Open
MFA-G wants to merge 1 commit into
nuclearpasta:mainfrom
MFA-G:fix/236-item-snap-end-ref
Open

fix(sortable): make onItemSnapEnd a ref so finalizeDrag is actually called#283
MFA-G wants to merge 1 commit into
nuclearpasta:mainfrom
MFA-G:fix/236-item-snap-end-ref

Conversation

@MFA-G

@MFA-G MFA-G commented Aug 7, 2026

Copy link
Copy Markdown

Fixes #236.

Problem

SortableItem destructures onItemSnapEnd from sortable._internal at render time:

const { ..., onItemSnapEnd, ... } = sortable._internal;

but SortableContainer only assigns it in a useLayoutEffect, which runs after render:

useLayoutEffect(() => {
  sortable._internal.onItemSnapEnd = finalizeDrag;
}, [sortable._internal, finalizeDrag]);

useSortableList rebuilds _internal on every render with onItemSnapEnd: undefined, so the value SortableItem captured is always undefined — the assignment from the previous render is written onto an object that has already been replaced.

Net effect: finalizeDrag is never called from onSnapEnd, the reorder is never committed, and the item visually snaps back to its original position after the drop.

Why the previous approach could not work

The type already documented the intended semantics:

/** 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. */

That contract requires a stable identity across renders, which a plain property on a per-render object cannot provide. This PR makes it an actual ref.

Change

  • useSortableList now owns onItemSnapEndRef — a useRef created once, so its identity is stable for the lifetime of the hook.
  • SortableContainer writes sortable._internal.onItemSnapEndRef.current = finalizeDrag.
  • SortableItem calls onItemSnapEndRef.current?.() — read at call time, so it sees the registered callback no matter when _internal was captured.
  • SortableListInternal.onItemSnapEnd?: () => void becomes onItemSnapEndRef: RefObject<(() => void) | undefined>.

Destructuring the ref in SortableItem is safe now precisely because the ref object is stable; only its .current changes.

This also covers the stale-_internal case the original comment was worried about (e.g. the MATCH path skipping a FlatList re-render): even a SortableItem holding an older _internal reaches the same ref object and therefore the latest finalizeDrag.

Note on the report in the issue thread that a one-line patch (sortable._internal.onItemSnapEnd?.()) did not fully help: that variant reads the current render's _internal, which has just been rebuilt with undefined and is only patched by the useLayoutEffect afterwards — so it still misses depending on timing. The ref removes the ordering dependency entirely.

SortableListInternal is an internal type (_internal), so this is not a public API change.

Validation

yarn typecheck   # tsc + example tsconfig — clean
yarn lint        # eslint — clean
yarn test        # jest — no tests in repo, passes
yarn prepare     # bob build — module + typescript definitions written OK

I also updated the two sortable-item.mdx architecture notes that referenced the old name so the docs match the code.

…alled

`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 nuclearpasta#236
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

@MFA-G is attempting to deploy a commit to the Nuclear Pasta Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SortableItem: onItemSnapEnd is always undefined — finalizeDrag never called

1 participant