Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions __tests__/components/Containers.itemOrder.native.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <mock-container-slot testID={`container-${id}`} />,
}));
}

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(
<StateProvider>
<Setup itemIndexByContainer={[2, 0, 1]}>
<Containers
freshDataTransitionEpoch={0}
getRenderedItem={() => null}
horizontal={false}
recycleItems={false}
/>
</Setup>
</StateProvider>,
);

// 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(
<StateProvider>
<Setup itemIndexByContainer={[undefined, 1, 0]}>
<Containers
freshDataTransitionEpoch={0}
getRenderedItem={() => null}
horizontal={false}
recycleItems={false}
/>
</Setup>
</StateProvider>,
);

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(
<StateProvider>
<Setup itemIndexByContainer={[0, 1, 2]}>
<Containers
freshDataTransitionEpoch={0}
getRenderedItem={() => null}
horizontal={false}
recycleItems={false}
/>
</Setup>
</StateProvider>,
);

expect(renderedContainerIds(toJSON())).toEqual([0, 1, 2]);

unmount();
});
});
32 changes: 32 additions & 0 deletions __tests__/core/calculateItemsInView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
});
26 changes: 23 additions & 3 deletions src/components/Containers.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -85,7 +85,10 @@ export const Containers = typedMemo(function Containers<ItemT>({
stickyHeaderConfig,
getRenderedItem,
}: ContainersProps<ItemT>) {
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++) {
Expand All @@ -104,9 +107,26 @@ export const Containers = typedMemo(function Containers<ItemT>({
);
}

// 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 (
<ContainersLayer freshDataTransitionEpoch={freshDataTransitionEpoch} horizontal={horizontal}>
{containers}
{containersInItemOrder}
</ContainersLayer>
);
});
5 changes: 4 additions & 1 deletion src/core/calculateItemsInView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down