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());
}