Skip to content

fix(native): render containers in item order so accessibility follows the screen - #527

Open
wsulais wants to merge 1 commit into
LegendApp:mainfrom
wsulais:fix/container-render-order-accessibility
Open

fix(native): render containers in item order so accessibility follows the screen#527
wsulais wants to merge 1 commit into
LegendApp:mainfrom
wsulais:fix/container-render-order-accessibility

Conversation

@wsulais

@wsulais wsulais commented Aug 17, 2026

Copy link
Copy Markdown

The problem

Containers renders the recycled container pool with a plain index loop, and items are
assigned into that pool:

for (let i = 0; i < numContainersPooled; i++) {
    containers.push(<ContainerSlot id={i} key={i}  />);
}

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. useDOMOrder listens to lastPositionUpdate and sorts the DOM by
containerItemIndex once positions settle — then returns early when
Platform.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.tsx is untouched.

The change

1. Containers.native sorts its children by the item each container holds.

  • Children are keyed by container id, so React reorders existing elements rather than
    remounting
    them. Recycling is untouched.
  • Nothing moves visually: on-screen position never came from child order.
  • Containers holding no item (containerItemIndex is undefined, as happens off-screen)
    sort last.

2. lastPositionUpdate is now emitted on every platform, not only on web.

-        if (Platform.OS === "web" && didChangePositions) {
+        if (didChangePositions) {
             set$(ctx, "lastPositionUpdate", Date.now());
         }

Containers subscribes to that signal, which is what makes it re-render — and therefore
re-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 useDOMOrder already no-ops off web.

No public API change, no new dependency.

Reproducing it

__tests__/components/Containers.itemOrder.native.test.tsx sets up a pool that has been
recycled — container 0 holding the last item — and asserts the order of the rendered
children. Two of its three cases fail on main:

$ bun test __tests__/components/Containers.itemOrder.native.test.tsx

(fail) Containers native render order > renders pooled containers in the order of the items they hold
(fail) Containers native render order > keeps containers holding no item after the ones that do
 1 pass
 2 fail

and pass with the change (3 pass). The three cases cover a recycled pool, containers
holding no item, and an already-ordered pool that must stay untouched.

calculateItemsInView also gains coverage that lastPositionUpdate is emitted on native as
well 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:

tree order : Intro, AAA, On & On,        Walk Like This, How Does It Feel?, In My Bag
by screen Y: Intro, AAA, Walk Like This, How Does It Feel?, In My Bag,      On & On
app state  : Intro, AAA, Walk Like This, How Does It Feel?, In My Bag,      On & On

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 fail
  • bun run tsc:src — clean
  • bun run lint — clean

Note

If you would rather have one mechanism for both platforms, this render-order approach would
work on web too and would let useDOMOrder go away. I left it alone because the DOM sorter
presumably exists for reasons I can't see from here — happy to fold them together if you
prefer.

… 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
jmeistrich force-pushed the main branch 2 times, most recently from bbc41f1 to 6d423f3 Compare August 18, 2026 12:52
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.

2 participants