Skip to content

Commit 8d99f91

Browse files
sgammonclaude
andcommitted
fix(ui): use stable content-derived list keys instead of array indexes
Index keys reattach React state and DOM to the wrong rows if a list ever reorders or filters. Breadcrumb segments now key by href/label and stats by label; code lines and support-matrix rows, whose content can legitimately repeat, key via a new `keyed()` helper (content + occurrence counter) so keys follow the item and duplicates never collide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 807363f commit 8d99f91

5 files changed

Lines changed: 37 additions & 13 deletions

File tree

packages/ui/src/components/breadcrumbs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function Breadcrumbs({ segments, className, ...props }: BreadcrumbsProps)
2323
{segments.map((segment, i) => {
2424
const isLast = i === segments.length - 1;
2525
return (
26-
<li key={`${segment.label}-${i}`} className="flex items-center gap-1.5">
26+
<li key={segment.href ?? segment.label} className="flex items-center gap-1.5">
2727
{isLast ? (
2828
<span aria-current="page" className="font-medium text-foreground">
2929
{segment.label}

packages/ui/src/components/code-block.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from "react";
22
import { Check, Copy } from "lucide-react";
33
import { Highlight, type PrismTheme } from "prism-react-renderer";
4-
import { cn } from "../lib/utils";
4+
import { cn, keyed } from "../lib/utils";
55
import { useMessages } from "../i18n/provider";
66

77
/**
@@ -102,13 +102,16 @@ function Highlighted({ code, lang }: { code: string; lang?: string }) {
102102
<Highlight theme={eldPrismTheme} code={code} language={prismLang(lang)}>
103103
{({ tokens, getLineProps, getTokenProps }) => (
104104
<>
105-
{tokens.map((line, i) => (
106-
<span key={i} {...getLineProps({ line })} className="block">
107-
{line.map((token, j) => (
108-
<span key={j} {...getTokenProps({ token })} />
109-
))}
110-
</span>
111-
))}
105+
{/* Lines carry no ids, so key by content (disambiguated for repeats). */}
106+
{keyed(tokens, (line) => line.map((token) => token.content).join("")).map(
107+
({ item: line, key }) => (
108+
<span key={key} {...getLineProps({ line })} className="block">
109+
{line.map((token, j) => (
110+
<span key={j} {...getTokenProps({ token })} />
111+
))}
112+
</span>
113+
),
114+
)}
112115
</>
113116
)}
114117
</Highlight>

packages/ui/src/components/stat-strip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function StatStrip({
3939
const last = i === stats.length - 1;
4040
return (
4141
<div
42-
key={i}
42+
key={stat.label}
4343
className={cn("flex flex-col gap-1.5 p-5", !lastInRow && !last && "border-r border-border")}
4444
>
4545
{/* Semantic order is <dt> (term/label) then <dd> (value); `order-*`

packages/ui/src/components/support-matrix.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from "react";
22
import { Check, CircleDot, X, type LucideIcon } from "lucide-react";
3-
import { cn } from "../lib/utils";
3+
import { cn, keyed } from "../lib/utils";
44
import { useMessages } from "../i18n/provider";
55
import type { Messages } from "../i18n/messages";
66

@@ -71,12 +71,15 @@ export function SupportMatrix({
7171
</tr>
7272
</thead>
7373
<tbody>
74-
{rows.map((row, i) => {
74+
{/* Rows carry no ids — key by the method name when it's a string
75+
(the common case), disambiguated for repeats via `keyed`. */}
76+
{keyed(rows, (row) => (typeof row.method === "string" ? row.method : row.status)).map(
77+
({ item: row, key }) => {
7578
const status = STATUS_META[row.status];
7679
const StatusIcon = status.icon;
7780
const statusLabel = m.supportMatrix[status.messageKey];
7881
return (
79-
<tr key={i} className="border-t border-border">
82+
<tr key={key} className="border-t border-border">
8083
<td className="px-4 py-2.5 font-mono text-[var(--primary-emphasis)]">{row.method}</td>
8184
<td className="px-4 py-2.5 text-center">
8285
<StatusIcon

packages/ui/src/lib/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,21 @@ import { twMerge } from "tailwind-merge";
55
export function cn(...inputs: ClassValue[]) {
66
return twMerge(clsx(inputs));
77
}
8+
9+
/**
10+
* Stable React keys for items with no id: `base(item)` disambiguated by an
11+
* occurrence counter, so keys follow content when the list reorders or filters
12+
* (unlike array indexes) and stay unique when two items share a base.
13+
*/
14+
export function keyed<T>(
15+
items: readonly T[],
16+
base: (item: T) => string,
17+
): { item: T; key: string }[] {
18+
const seen = new Map<string, number>();
19+
return items.map((item) => {
20+
const b = base(item);
21+
const n = seen.get(b) ?? 0;
22+
seen.set(b, n + 1);
23+
return { item, key: n === 0 ? b : `${b}~${n}` };
24+
});
25+
}

0 commit comments

Comments
 (0)