authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 22:48:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 22:48:58-07:00
logb402f5fc65f6f4554266dea385a7da7ff353f028
tree9575ffb279763ce39620baf1bf6a4e7fdc0d50f9
parent833a03afe32b2f332ab19ce72c0e0dc3cf72c341
signature Signed by PGP key B5690EEEBB952194

perf(mobile): reuse the settled sort when settled rows are unchanged (#16369)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 166 insertions(+), 4 deletions(-)

apps/mobile/src/features/threads/threadListV2.test.ts+135
...@@ -991,6 +991,141 @@ describe("buildThreadListV2Items settled paging", () => {...@@ -991,6 +991,141 @@ describe("buildThreadListV2Items settled paging", () => {
991 });991 });
992});992});
993993
994describe("buildThreadListV2Items settled sort reuse", () => {
995 const otherEnvironmentId = EnvironmentId.make("environment-2");
996
997 function makeSettled(
998 id: string,
999 settledAt: string,
1000 overrides: Partial<EnvironmentThreadShell> = {},
1001 ): EnvironmentThreadShell {
1002 return makeThread({
1003 id: ThreadId.make(id),
1004 title: id,
1005 settledOverride: "settled",
1006 settledAt,
1007 ...overrides,
1008 });
1009 }
1010
1011 function settledOrder(
1012 threads: ReadonlyArray<EnvironmentThreadShell>,
1013 scope: EnvironmentId | null = null,
1014 ) {
1015 return buildThreadListV2Items({
1016 threads,
1017 environmentId: scope,
1018 searchQuery: "",
1019 now: NOW,
1020 })
1021 .items.filter((item) => item.variant === "slim")
1022 .map((item) => `${item.thread.environmentId}:${item.thread.id}`);
1023 }
1024
1025 function countDateParses(build: () => void): number {
1026 const spy = vi.spyOn(Date, "parse");
1027 try {
1028 build();
1029 return spy.mock.calls.length;
1030 } finally {
1031 spy.mockRestore();
1032 }
1033 }
1034
1035 const older = makeSettled("older", "2026-06-01T01:00:00.000Z");
1036 const newer = makeSettled("newer", "2026-06-01T03:00:00.000Z");
1037 const middle = makeSettled("middle", "2026-06-01T02:00:00.000Z");
1038
1039 it("orders settled rows newest first regardless of input order", () => {
1040 expect(settledOrder([older, newer, middle])).toEqual([
1041 `${environmentId}:newer`,
1042 `${environmentId}:middle`,
1043 `${environmentId}:older`,
1044 ]);
1045 expect(settledOrder([middle, older, newer])).toEqual([
1046 `${environmentId}:newer`,
1047 `${environmentId}:middle`,
1048 `${environmentId}:older`,
1049 ]);
1050 });
1051
1052 it("re-sorts when a settled row is replaced, added, or removed", () => {
1053 expect(settledOrder([older, newer])).toEqual([
1054 `${environmentId}:newer`,
1055 `${environmentId}:older`,
1056 ]);
1057 const resettledOlder = makeSettled("older", "2026-06-01T04:00:00.000Z");
1058 expect(settledOrder([resettledOlder, newer])).toEqual([
1059 `${environmentId}:older`,
1060 `${environmentId}:newer`,
1061 ]);
1062 expect(settledOrder([resettledOlder, newer, middle])).toEqual([
1063 `${environmentId}:older`,
1064 `${environmentId}:newer`,
1065 `${environmentId}:middle`,
1066 ]);
1067 expect(settledOrder([newer, middle])).toEqual([
1068 `${environmentId}:newer`,
1069 `${environmentId}:middle`,
1070 ]);
1071 });
1072
1073 it("re-sorts when the same settled rows arrive in a different order", () => {
1074 // Same id and timestamp on two environments: only the stable input order
1075 // breaks the tie, so a reordered input must not reuse the previous output.
1076 const first = makeSettled("twin", "2026-06-01T01:00:00.000Z");
1077 const second = makeSettled("twin", "2026-06-01T01:00:00.000Z", {
1078 environmentId: otherEnvironmentId,
1079 });
1080 expect(settledOrder([first, second])).toEqual([
1081 `${environmentId}:twin`,
1082 `${otherEnvironmentId}:twin`,
1083 ]);
1084 expect(settledOrder([second, first])).toEqual([
1085 `${otherEnvironmentId}:twin`,
1086 `${environmentId}:twin`,
1087 ]);
1088 });
1089
1090 it("skips the settled sort when only unsettled rows change", () => {
1091 const settled = Array.from({ length: 50 }, (_, index) =>
1092 makeSettled(`settled-${index}`, `2026-06-01T00:${String(index).padStart(2, "0")}:00.000Z`),
1093 );
1094 const streaming = makeThread({ id: ThreadId.make("streaming"), title: "Streaming" });
1095 const baseline = countDateParses(() => settledOrder([streaming, ...settled]));
1096 const updated = { ...streaming, updatedAt: "2026-06-01T23:00:00.000Z" };
1097 let order: string[] = [];
1098 const reused = countDateParses(() => {
1099 order = settledOrder([updated, ...settled]);
1100 });
1101
1102 expect(order).toEqual(settled.toReversed().map((thread) => `${environmentId}:${thread.id}`));
1103 // The settled sort parses every settled row at least once.
1104 expect(baseline - reused).toBeGreaterThanOrEqual(settled.length);
1105 });
1106
1107 it("recomputes when builds alternate between environments", () => {
1108 const remoteOlder = makeSettled("remote-older", "2026-06-01T01:30:00.000Z", {
1109 environmentId: otherEnvironmentId,
1110 });
1111 const remoteNewer = makeSettled("remote-newer", "2026-06-01T05:00:00.000Z", {
1112 environmentId: otherEnvironmentId,
1113 });
1114 const threads = [older, newer, remoteOlder, remoteNewer];
1115
1116 for (let round = 0; round < 2; round += 1) {
1117 expect(settledOrder(threads, environmentId)).toEqual([
1118 `${environmentId}:newer`,
1119 `${environmentId}:older`,
1120 ]);
1121 expect(settledOrder(threads, otherEnvironmentId)).toEqual([
1122 `${otherEnvironmentId}:remote-newer`,
1123 `${otherEnvironmentId}:remote-older`,
1124 ]);
1125 }
1126 });
1127});
1128
994function makePendingTask(id: string): PendingNewTask {1129function makePendingTask(id: string): PendingNewTask {
995 const creation = {1130 const creation = {
996 projectId: ProjectId.make("project-1"),1131 projectId: ProjectId.make("project-1"),
apps/mobile/src/features/threads/threadListV2.ts+31-4
...@@ -609,6 +609,32 @@ export function buildThreadListV2ListItems(input: {...@@ -609,6 +609,32 @@ export function buildThreadListV2ListItems(input: {
609 });609 });
610}610}
611611
612// Streams update a few unsettled rows, so most rebuilds classify the same
613// settled shells in the same order. Shells are immutable and the settled sort
614// reads only their own fields, so an element-identical input has an identical
615// output. One entry keeps memory bounded; callers with different scopes (Home
616// and the split-view sidebar) just recompute.
617let lastSettledInput: ReadonlyArray<EnvironmentThreadShell> = [];
618let lastSettledOutput: ReadonlyArray<EnvironmentThreadShell> = [];
619
620function sortSettledThreadsReusingLast(
621 settled: ReadonlyArray<EnvironmentThreadShell>,
622): ReadonlyArray<EnvironmentThreadShell> {
623 if (settled.length === lastSettledInput.length) {
624 let same = true;
625 for (let index = 0; index < settled.length; index += 1) {
626 if (settled[index] !== lastSettledInput[index]) {
627 same = false;
628 break;
629 }
630 }
631 if (same) return lastSettledOutput;
632 }
633 lastSettledInput = settled;
634 lastSettledOutput = sortSettledThreads(settled);
635 return lastSettledOutput;
636}
637
612/**638/**
613 * Partitions visible threads into the active card block (saved order) and639 * Partitions visible threads into the active card block (saved order) and
614 * the settled recency tail, matching the web v2 list.640 * the settled recency tail, matching the web v2 list.
...@@ -754,17 +780,18 @@ export function buildThreadListV2Items(input: {...@@ -754,17 +780,18 @@ export function buildThreadListV2Items(input: {
754 : orderedSnoozed.filter(780 : orderedSnoozed.filter(
755 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,781 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
756 );782 );
757 const orderedSettled = sortSettledThreads(settled);783 const orderedSettled = sortSettledThreadsReusingLast(settled);
758 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;784 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
759 const pagedSettled =785 const limitedSettled =
760 orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;786 orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
761 const selectedSettled =787 const selectedSettled =
762 selectedThreadKey === null788 selectedThreadKey === null
763 ? undefined789 ? undefined
764 : orderedSettled790 : orderedSettled
765 .slice(pagedSettled.length)791 .slice(limitedSettled.length)
766 .find((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey);792 .find((thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey);
767 if (selectedSettled !== undefined) pagedSettled.push(selectedSettled);793 const pagedSettled =
794 selectedSettled === undefined ? limitedSettled : [...limitedSettled, selectedSettled];
768 const visibleSettled =795 const visibleSettled =
769 input.settledShelfExpanded !== false796 input.settledShelfExpanded !== false
770 ? pagedSettled797 ? pagedSettled