Skip to content

Support virtualized lists in Reorder#3636

Merged
mattgperry merged 5 commits intomainfrom
worktree-fix-issue-3061
Mar 16, 2026
Merged

Support virtualized lists in Reorder#3636
mattgperry merged 5 commits intomainfrom
worktree-fix-issue-3061

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

  • Bug: Reorder.Group's updateOrder callback only returned measured/rendered items to onReorder, dropping any items not currently in the DOM (e.g. offscreen items in a virtualized list)
  • Cause: updateOrder mapped the internal order array (which only contains measured items) back to values and filtered against the values prop, discarding unmeasured items entirely
  • Fix: Instead of returning only measured items, detect which two values checkReorder swapped and apply that same swap to the full values array. This preserves all unmeasured items in their correct positions

Fixes #3061

Test plan

  • Unit test: verifies that when only a subset of items are registered (simulating virtualization), onReorder returns the full values array with the swap applied
  • All 770 existing tests pass
  • Build succeeds

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR fixes a long-standing bug (#3061) where Reorder.Group's onReorder callback only returned the measured (rendered) items — silently dropping any items that were outside the viewport in a virtualized list.

What changed:

  • Group.tsxupdateOrder no longer maps the internal order array back to values and filters against values. Instead it detects the single pairwise swap that checkReorder produced (by finding the first position where order[i] and newOrder[i] differ), then applies that same swap directly to a copy of the full values prop. This preserves every item, measured or not.
  • index.test.tsx — A new unit test renders a Reorder.Group with 5 values but only registers 3 of them (simulating offscreen items in a virtualized list), triggers a drag past the threshold, and asserts that onReorder returns all 5 values with the correct swap applied.

Key considerations:

  • The swap-detection logic is correct given that checkReorder always delegates to moveItem(order, index, index ± 1), which for adjacent indices is always a simple pairwise swap. The approach would need revisiting if checkReorder were ever extended to support multi-item moves.
  • values.indexOf uses reference equality, which is consistent with the rest of the Reorder API but means duplicate primitive values or re-created object references would not behave correctly — this is a pre-existing limitation, not introduced here.
  • There is a minor robustness gap: if indexOf returns -1 (e.g., a registered item whose value has already been removed from values), the swap silently becomes a no-op rather than throwing or logging a warning.

Confidence Score: 4/5

  • This PR is safe to merge; the fix is logically correct and well-tested, with one minor robustness gap around missing values.
  • The swap-detection algorithm is provably correct given checkReorder's adjacent-only move behaviour (verified by reading moveItem). The new unit test exercises the exact virtualized-list scenario described in the bug. The only concern — a silent no-op when indexOf returns -1 — is a pre-existing edge case that is unlikely to surface in practice and does not regress existing behaviour.
  • The swap logic in packages/framer-motion/src/components/Reorder/Group.tsx lines 129–133 should be reviewed for the silent indexOf === -1 edge case.

Important Files Changed

Filename Overview
packages/framer-motion/src/components/Reorder/Group.tsx Core fix: replaces the old map+filter approach (which dropped unmeasured items) with a swap-detection loop that finds the two values exchanged by checkReorder and applies that same swap to the full values array. Logic is correct for the current adjacent-only swap behavior of checkReorder/moveItem, but silently no-ops if a registered item's value is missing from values.
packages/framer-motion/src/components/Reorder/tests/index.test.tsx New test directly exercises the virtualized-list scenario by registering only a subset of items (2, 3, 4 out of [1,2,3,4,5]) and asserting that onReorder returns the full array with the correct swap applied ([1,3,2,4,5]).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["updateOrder called"] --> B{isReordering?}
    B -- yes --> Z[return early]
    B -- no --> C["checkReorder - order, item, offset, velocity"]
    C --> D{newOrder !== order?}
    D -- no change --> Z
    D -- swap detected --> E["isReordering = true"]
    E --> F["newValues = spread of full values prop"]
    F --> G["Loop over newOrder positions"]
    G --> H{Values differ at position i?}
    H -- no --> G
    H -- yes --> I["Find index a of old value in values\nFind index b of new value in values"]
    I --> J["Swap newValues at a and b"]
    J --> K[break]
    K --> L["onReorder called with full newValues array"]

    style L fill:#d4edda,stroke:#28a745
    style Z fill:#f8d7da,stroke:#dc3545
Loading

Last reviewed commit: c3c3c7f

Comment on lines +129 to +133
const a = values.indexOf(order[i].value)
const b = values.indexOf(newOrder[i].value)
newValues[a] = newOrder[i].value
newValues[b] = order[i].value
break
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent no-op when a registered value is absent from values

If either order[i].value or newOrder[i].value is not present in values, indexOf returns -1 and newValues[-1] = … silently sets a non-indexed property on the array instead of throwing. The result is that onReorder is called with the array effectively unchanged — the intended swap never happens.

This scenario arises when a currently-rendered/registered item's value has already been removed from values (e.g. the user removed a row while it was being dragged). It was not easily observable with the old code either, but the new path makes it worth guarding explicitly:

const a = values.indexOf(order[i].value)
const b = values.indexOf(newOrder[i].value)
if (a !== -1 && b !== -1) {
    newValues[a] = newOrder[i].value
    newValues[b] = order[i].value
}
break

mattgperry and others added 5 commits March 16, 2026 15:05
…egration

- Match estimateSize to actual item height (50px)
- Add top/bottom spacer <li> elements for correct scroll area height
- Make the Reorder.Group the scroll container directly
- Use 50 items so the list is clearly scrollable
- Use overscan: 0 so only visible items render

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Move scroll container to a wrapper div so findScrollableAncestor
can find it — the group element itself is skipped by the ancestor walk.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@mattgperry mattgperry force-pushed the worktree-fix-issue-3061 branch from d281da6 to a95c487 Compare March 16, 2026 14:06
@mattgperry mattgperry merged commit 7686c19 into main Mar 16, 2026
5 checks passed
@mattgperry mattgperry deleted the worktree-fix-issue-3061 branch March 16, 2026 14:33
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.

[FEATURE] Support virtualized lists when using Reorder

1 participant