fix(native): render containers in item order so accessibility follows the screen - #527
Open
wsulais wants to merge 1 commit into
Open
fix(native): render containers in item order so accessibility follows the screen#527wsulais wants to merge 1 commit into
wsulais wants to merge 1 commit into
Conversation
… the screen `Containers` renders the recycled container pool with a plain index loop, and items are assigned into that pool. Containers are absolutely positioned, so where a row appears comes from its own offset — but the native view order, and therefore the accessibility order, comes from child order. The two agree on first render and diverge as soon as items reorder, because a reorder moves items between containers and leaves the pool order alone. The result is that a screen reader reads a reordered list in the wrong sequence. Every label is correct, which makes it look like a stale accessibility tree rather than an ordering problem. Web already handles this: `useDOMOrder` sorts the DOM by `containerItemIndex` after positions settle, and returns early off web. This is the React Native equivalent, done in render order rather than by mutating the tree afterwards. Two changes: - `Containers.native` sorts its children by the item each container holds. Children are keyed by container id, so React reorders the existing elements instead of remounting them; recycling, scrolling and layout are untouched because on-screen position never came from child order. - `lastPositionUpdate` is now emitted on every platform rather than only on web, so the component above re-renders when container assignments change. It was web-only because the DOM sorter was its only consumer; that is no longer true. Nothing else listens to it, and `useDOMOrder` already no-ops off web. Tests: - __tests__/components/Containers.itemOrder.native.test.tsx covers a recycled pool, containers holding no item, and an already-ordered pool. The first two fail without the sort. - calculateItemsInView gains coverage that `lastPositionUpdate` is emitted on native as well as web. Without the change above, the native case fails — which is the failure mode that matters, since a sort that never re-runs looks correct in a first-render-only test.
jmeistrich
force-pushed
the
main
branch
2 times, most recently
from
August 18, 2026 12:52
bbc41f1 to
6d423f3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Containersrenders the recycled container pool with a plain index loop, and items areassigned into that pool:
Containers are absolutely positioned, so where a row appears comes from its own offset.
But the native view order — and therefore the accessibility order — comes from
child order. The two agree on first render and diverge as soon as items reorder, because a
reorder moves items between containers and leaves the pool order alone.
The result is that a screen reader reads a reordered list in the wrong sequence. Every
label is correct, which is what makes it hard to spot: it looks like a stale accessibility
tree rather than an ordering problem, and sends you looking for cache invalidation that
isn't there. That is exactly how I misdiagnosed it at first.
Why native-only
Web already handles this.
useDOMOrderlistens tolastPositionUpdateand sorts the DOM bycontainerItemIndexonce positions settle — then returns early whenPlatform.OS !== "web". There is no React Native equivalent, so this PR adds one.Rather than mutating the tree afterwards, it does the same thing in render order, which
avoids the debounce and any imperative reordering.
Containers.tsxis untouched.The change
1.
Containers.nativesorts its children by the item each container holds.remounting them. Recycling is untouched.
containerItemIndexisundefined, as happens off-screen)sort last.
2.
lastPositionUpdateis now emitted on every platform, not only on web.Containerssubscribes to that signal, which is what makes it re-render — and thereforere-sort — when container assignments change.
Without this second change the first one is dead code on native: the sort runs once on
mount and never again. I had this wrong in my first draft of the patch, and the
first-render-only tests passed anyway, which is why the test below exists.
The signal was web-only because the DOM sorter was its sole consumer; that is no longer
true. Nothing else listens to it, and
useDOMOrderalready no-ops off web.No public API change, no new dependency.
Reproducing it
__tests__/components/Containers.itemOrder.native.test.tsxsets up a pool that has beenrecycled — container 0 holding the last item — and asserts the order of the rendered
children. Two of its three cases fail on
main:and pass with the change (
3 pass). The three cases cover a recycled pool, containersholding no item, and an already-ordered pool that must stay untouched.
calculateItemsInViewalso gains coverage thatlastPositionUpdateis emitted on native aswell as web. Re-gate the emission and that case fails — which is the failure mode that
matters, because a sort that never re-runs still looks correct in a first-render-only test.
Where this was hit
A drag-to-reorder play queue in a react-native-macos app, verified through the platform
accessibility API. After dragging one row down three slots, reading each row's label
together with its on-screen Y:
Y-sorted matches the app exactly; tree order does not. With the change applied all three
agree, and the list still virtualizes — 19 of 21 rows mounted for a 21-track queue,
unchanged.
Checks
bun test— 1612 pass, 0 failbun run tsc:src— cleanbun run lint— cleanNote
If you would rather have one mechanism for both platforms, this render-order approach would
work on web too and would let
useDOMOrdergo away. I left it alone because the DOM sorterpresumably exists for reasons I can't see from here — happy to fold them together if you
prefer.