Skip to content

Commit a871179

Browse files
committed
fix(webapp): key a repeated view block by its own position
ViewBlocks looked each surviving block's index up with indexOf inside the render loop: quadratic, and two occurrences of the same block object both answered with the first index, so they collided on one React key. latestRevisionEntries carries each survivor's position out instead.
1 parent 44cda8a commit a871179

3 files changed

Lines changed: 61 additions & 12 deletions

File tree

apps/webapp/app/components/dashboard-agent/view-blocks.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from "vitest";
2-
import { blockIdentity, blockKey, latestRevisionBlocks } from "./view-blocks";
2+
import {
3+
blockIdentity,
4+
blockKey,
5+
latestRevisionBlocks,
6+
latestRevisionEntries,
7+
} from "./view-blocks";
38

49
const enveloped = (id: string, revision: number, type = "diagnosis") => ({ type, id, revision });
510

@@ -62,6 +67,39 @@ describe("latestRevisionBlocks", () => {
6267
});
6368
});
6469

70+
/**
71+
* The positions are what `ViewBlocks` keys envelope-less blocks on. Looking one up afterwards
72+
* with `indexOf` answers with the first equal block, so two of them collide on one React key.
73+
*/
74+
describe("latestRevisionEntries", () => {
75+
it("reports each survivor's position in the original array", () => {
76+
const legacy = { type: "chart" };
77+
const blocks = [enveloped("d1", 1), legacy, enveloped("d1", 2)];
78+
expect(latestRevisionEntries(blocks)).toEqual([
79+
{ block: legacy, index: 1 },
80+
{ block: enveloped("d1", 2), index: 2 },
81+
]);
82+
});
83+
84+
it("gives two occurrences of the same block object distinct positions", () => {
85+
const repeated = { type: "chart" };
86+
const entries = latestRevisionEntries([repeated, repeated]);
87+
expect(entries.map((entry) => entry.index)).toEqual([0, 1]);
88+
expect(new Set(entries.map((entry) => blockKey(entry.block, entry.index))).size).toBe(2);
89+
});
90+
91+
it("agrees with `latestRevisionBlocks` on which blocks survive", () => {
92+
const blocks = [enveloped("d1", 1), { type: "chart" }, enveloped("d1", 2)];
93+
expect(latestRevisionEntries(blocks).map((entry) => entry.block)).toEqual(
94+
latestRevisionBlocks(blocks)
95+
);
96+
});
97+
98+
it("tolerates a non-array", () => {
99+
expect(latestRevisionEntries(undefined as unknown as unknown[])).toEqual([]);
100+
});
101+
});
102+
65103
describe("blockIdentity / blockKey", () => {
66104
it("identifies enveloped blocks by type and id", () => {
67105
expect(blockIdentity(enveloped("d1", 1))).toBe("diagnosis::d1");

apps/webapp/app/components/dashboard-agent/view-blocks.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ export function blockKey(block: unknown, index: number): string {
2525
return blockIdentity(block) ?? `index:${index}`;
2626
}
2727

28-
// Latest-wins within one array only: highest `revision` at the winner's position,
29-
// ties to the last. Blocks without an envelope are all kept, in order.
30-
export function latestRevisionBlocks<T>(blocks: readonly T[]): T[] {
28+
/**
29+
* Latest-wins within one array only: highest `revision` at the winner's position, ties to the
30+
* last. Blocks without an envelope are all kept, in order. Each survivor keeps the index it had
31+
* in `blocks`, which is what an envelope-less block is keyed on — a search for it afterwards
32+
* would answer with the first equal block, not this one.
33+
*/
34+
export function latestRevisionEntries<T>(blocks: readonly T[]): { block: T; index: number }[] {
3135
if (!Array.isArray(blocks)) return [];
3236

3337
const winnerIndexByIdentity = new Map<string, number>();
@@ -43,10 +47,17 @@ export function latestRevisionBlocks<T>(blocks: readonly T[]): T[] {
4347
}
4448
});
4549

46-
if (winnerIndexByIdentity.size === 0) return [...blocks];
47-
48-
return blocks.filter((block, index) => {
50+
const entries: { block: T; index: number }[] = [];
51+
blocks.forEach((block, index) => {
4952
const identity = blockIdentity(block);
50-
return identity === undefined || winnerIndexByIdentity.get(identity) === index;
53+
if (identity === undefined || winnerIndexByIdentity.get(identity) === index) {
54+
entries.push({ block, index });
55+
}
5156
});
57+
return entries;
58+
}
59+
60+
/** {@link latestRevisionEntries} without the positions. */
61+
export function latestRevisionBlocks<T>(blocks: readonly T[]): T[] {
62+
return latestRevisionEntries(blocks).map((entry) => entry.block);
5263
}

apps/webapp/app/components/dashboard-agent/view-catalog.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AgentChart } from "./AgentChart";
44
import { InvestigationCard } from "./InvestigationCard";
55
import { ReportView, type ResolvedUri } from "./ReportView";
66
import { RunDiagnosisCard } from "./RunDiagnosisCard";
7-
import { blockKey, latestRevisionBlocks } from "./view-blocks";
7+
import { blockKey, latestRevisionEntries } from "./view-blocks";
88

99
// Unknown block types are skipped, so an older or newer agent cannot render
1010
// arbitrary content. A new block needs a `case` here and a `viewBlockSchema` member.
@@ -25,10 +25,10 @@ export function ViewBlocks({
2525
if (!Array.isArray(blocks)) return null;
2626
return (
2727
<div className="space-y-2">
28-
{latestRevisionBlocks(blocks).map((block) => {
29-
// Index into the original array, so collapsing a revision above an
28+
{latestRevisionEntries(blocks).map(({ block, index }) => {
29+
// The original array's index, so collapsing a revision above an
3030
// envelope-less block can't shift its key.
31-
const key = blockKey(block, blocks.indexOf(block));
31+
const key = blockKey(block, index);
3232
switch (block.type) {
3333
case "diagnosis":
3434
return <RunDiagnosisCard key={key} block={block} />;

0 commit comments

Comments
 (0)