diff --git a/__tests__/components/Containers.itemOrder.native.test.tsx b/__tests__/components/Containers.itemOrder.native.test.tsx new file mode 100644 index 00000000..99de4d15 --- /dev/null +++ b/__tests__/components/Containers.itemOrder.native.test.tsx @@ -0,0 +1,134 @@ +import type * as React from "react"; + +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import "../setup"; + +import { type StateContext, StateProvider, useStateContext } from "@/state/state"; +import { render } from "../helpers/testingLibrary"; + +// Containers are absolutely positioned, so where a row APPEARS comes from its own offset. +// Child order is what feeds the native view order, and therefore the accessibility order — +// which is why these tests assert on the order of the rendered children rather than on any +// style or position. +function registerContainerSlotMock() { + mock.module("@/components/ContainerSlot", () => ({ + ContainerSlot: ({ id }: { id: number }) => , + })); +} + +type SetupProps = { + children: React.ReactNode; + // Which item index each pooled container currently holds; index in this array is the + // container id. `undefined` is a container holding nothing, as happens off-screen. + itemIndexByContainer: (number | undefined)[]; + onContext?: (ctx: StateContext) => void; +}; + +const Setup = ({ children, itemIndexByContainer, onContext }: SetupProps) => { + const ctx = useStateContext(); + onContext?.(ctx); + ctx.columnWrapperStyle = undefined; + ctx.values.set("numColumns", 1); + ctx.values.set("numContainersPooled", itemIndexByContainer.length); + ctx.values.set("otherAxisSize", 0); + ctx.values.set("readyToRender", true); + ctx.values.set("totalSize", 0); + itemIndexByContainer.forEach((itemIndex, containerId) => { + ctx.values.set(`containerItemIndex${containerId}`, itemIndex); + }); + return <>{children}; +}; + +// Depth-first walk collecting the mocked slots in render order. +function renderedContainerIds(tree: any): number[] { + const ids: number[] = []; + const visit = (node: any): void => { + if (!node || typeof node !== "object") { + return; + } + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + const testID: string | undefined = node.props?.testID; + if (testID?.startsWith("container-")) { + ids.push(Number(testID.slice("container-".length))); + } + (node.children ?? []).forEach(visit); + }; + visit(tree); + return ids; +} + +describe("Containers native render order", () => { + beforeEach(() => { + registerContainerSlotMock(); + }); + + it("renders pooled containers in the order of the items they hold", async () => { + const { Containers } = await import("@/components/Containers"); + + // A pool that has been recycled: container 0 now holds the LAST item, and the + // first item is in container 1. This is the steady state after a reorder. + const { toJSON, unmount } = render( + + + null} + horizontal={false} + recycleItems={false} + /> + + , + ); + + // Item order is 0, 1, 2 -> containers 1, 2, 0. Rendered in pool order (0, 1, 2) a + // screen reader would read the last item first. + expect(renderedContainerIds(toJSON())).toEqual([1, 2, 0]); + + unmount(); + }); + + it("keeps containers holding no item after the ones that do", async () => { + const { Containers } = await import("@/components/Containers"); + + const { toJSON, unmount } = render( + + + null} + horizontal={false} + recycleItems={false} + /> + + , + ); + + expect(renderedContainerIds(toJSON())).toEqual([2, 1, 0]); + + unmount(); + }); + + it("leaves an already-ordered pool untouched", async () => { + const { Containers } = await import("@/components/Containers"); + + const { toJSON, unmount } = render( + + + null} + horizontal={false} + recycleItems={false} + /> + + , + ); + + expect(renderedContainerIds(toJSON())).toEqual([0, 1, 2]); + + unmount(); + }); +}); diff --git a/__tests__/core/calculateItemsInView.test.ts b/__tests__/core/calculateItemsInView.test.ts index dccf63a5..7b2ed0c4 100644 --- a/__tests__/core/calculateItemsInView.test.ts +++ b/__tests__/core/calculateItemsInView.test.ts @@ -2219,4 +2219,36 @@ describe("calculateItemsInView", () => { expect(results.every((ids) => Array.isArray(ids))).toBe(true); }); }); + describe("lastPositionUpdate", () => { + // `Containers.native` re-renders on this signal so it can re-sort its children into + // item order (see Containers.native.tsx). It used to be emitted only on web, for the + // DOM sorter in useDOMOrder, which left the native path never re-sorting. + it("is emitted when positions change on native", () => { + const prevPlatform = Platform.OS; + Platform.OS = "ios"; + try { + setupFixedSizeItems(10, 50); + + calculateItemsInView(mockCtx); + + expect(typeof mockCtx.values.get("lastPositionUpdate")).toBe("number"); + } finally { + Platform.OS = prevPlatform; + } + }); + + it("is emitted when positions change on web", () => { + const prevPlatform = Platform.OS; + Platform.OS = "web"; + try { + setupFixedSizeItems(10, 50); + + calculateItemsInView(mockCtx); + + expect(typeof mockCtx.values.get("lastPositionUpdate")).toBe("number"); + } finally { + Platform.OS = prevPlatform; + } + }); + }); }); diff --git a/src/components/Containers.native.tsx b/src/components/Containers.native.tsx index e0cb771d..f2333a1e 100644 --- a/src/components/Containers.native.tsx +++ b/src/components/Containers.native.tsx @@ -6,7 +6,7 @@ import { ContainerLayoutCoordinator } from "@/components/ContainerLayoutCoordina import { ContainerSlot } from "@/components/ContainerSlot"; import { useFreshDataTransitionVisibility } from "@/hooks/useFreshDataTransitionVisibility"; import { useValue$ } from "@/hooks/useValue$"; -import { useArr$, useStateContext } from "@/state/state"; +import { peek$, useArr$, useStateContext } from "@/state/state"; import type { StickyHeaderConfig } from "@/types.base"; import { type GetRenderedItem, typedMemo } from "@/types.internal"; @@ -85,7 +85,10 @@ export const Containers = typedMemo(function Containers({ stickyHeaderConfig, getRenderedItem, }: ContainersProps) { - const [numContainersPooled] = useArr$(["numContainersPooled"]); + const ctx = useStateContext(); + // `lastPositionUpdate` is subscribed to purely to re-render when container assignments + // change — the same signal the web DOM reordering in `useDOMOrder` listens to. + const [numContainersPooled] = useArr$(["numContainersPooled", "lastPositionUpdate"]); const containers: React.ReactNode[] = []; for (let i = 0; i < numContainersPooled; i++) { @@ -104,9 +107,26 @@ export const Containers = typedMemo(function Containers({ ); } + // Render the children in the order of the items they hold. Containers are a recycled + // pool, so their creation order stops matching the screen as soon as items reorder. + // Position on screen comes from each container's own absolute offset, so this moves + // nothing visually — but the native view order, and therefore the ACCESSIBILITY order, + // is taken from child order, and a screen reader would otherwise walk a reordered list + // in the wrong sequence. Web solves the same problem by sorting the DOM in `useDOMOrder`. + // + // Children are keyed by container id, so React reorders the existing elements rather + // than remounting them, leaving recycling untouched. + const containersInItemOrder = containers + .map((container, i) => ({ + container, + index: peek$(ctx, `containerItemIndex${i}`) ?? Number.MAX_SAFE_INTEGER, + })) + .sort((a, b) => a.index - b.index) + .map(({ container }) => container); + return ( - {containers} + {containersInItemOrder} ); }); diff --git a/src/core/calculateItemsInView.ts b/src/core/calculateItemsInView.ts index 9835a74c..c0cc9833 100644 --- a/src/core/calculateItemsInView.ts +++ b/src/core/calculateItemsInView.ts @@ -910,7 +910,10 @@ export function calculateItemsInView( scheduleContainerLayout(ctx, changedContainerIds); } - if (Platform.OS === "web" && didChangePositions) { + // Emitted on every platform, not just web: the web DOM sorter in `useDOMOrder` is no + // longer the only consumer — `Containers.native` needs it to re-render and re-sort its + // children when container assignments change. + if (didChangePositions) { set$(ctx, "lastPositionUpdate", Date.now()); }