perf(vcs): keep Git Log rows from re-rendering on every selection change

Commit rows were memoized but received a new Set and new closures on every
render, so moving the selection re-rendered every visible row, and each row
built a new Intl.DateTimeFormat for its date. Rows now get stable handlers and
shared remote-name sets, and date formatting reuses cached formatters.
Цей коміт міститься в:
l0ng-ai committed 2026-11-07 09:38:44 +08:00
1 джерело b524d547d5
коміт 6999d4034f
3 files changed
+113 -47

No files matched your search

+75 -42
Переглянути файл
@@ -1388,6 +1388,28 @@ function CommitList() {
else if (e.metaKey || e.ctrlKey) toggleCommitSelection(id);
else selectCommit(id);
}, []);
// CommitRow is memoized: rows get handlers that stay the same across renders and
// call this render's (assigned before rendering).
const rowEvents = useRef<RowEvents | null>(null);
const rowHandlers = useMemo<RowEvents>(
() => ({
onHover: (edge) => rowEvents.current?.onHover(edge),
onFragment: (from, target, expand) => rowEvents.current?.onFragment(from, target, expand),
onNavigate: (index, key) => rowEvents.current?.onNavigate(index, key),
}),
[],
);
// Called while a row renders its graph, so it changes with what it reads.
const targetTitle = useCallback(
(index: number, key?: string) => {
const target = commits[index];
return target
? `${target.shortId} ${target.summary} — ${target.author}`
: (key && boundaryTitles.get(key)) ||
`${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`;
},
[commits, boundaryTitles],
);
const onKeyDown = (e: KeyboardEvent) => {
if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key.length === 1 || e.key === "Backspace" || e.key === "Escape")) {
@@ -1559,6 +1581,38 @@ function CommitList() {
}
};
rowEvents.current = {
onHover: (edge) => {
setHoverColor(edge);
if (!edge) return;
const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge));
const target = source?.parents.find((p) => `${source.id}\0${p}` === edge);
if (!source || !target || commits.some((c) => commitKey(c) === target) || boundaryRequests.current.has(target))
return;
const original = all.find((c) => commitKey(c) === source.id);
if (!original) return;
boundaryRequests.current.add(target);
const split = target.lastIndexOf("\0"),
id = target.slice(split + 1);
void loadLogDetails({ ...original, id })
.then((details) =>
setBoundaryTitles((current) =>
new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`),
),
)
.catch(() => undefined);
},
onFragment: fragment,
onNavigate: (index, key) => {
const target = commits[index];
if (target) selectCommit(commitKey(target), true);
else if (key) {
const split = key.lastIndexOf("\0");
void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo());
}
},
};
return (
<div className={styles.listWrap}>
{earlier && (
@@ -1647,52 +1701,15 @@ function CommitList() {
mine={opts.highlightMine && isMine(c, user)}
onBranch={!!onBranch?.has(commitKey(c))}
opts={opts}
remoteNames={new Set((c as RepoCommit).remoteNames ?? remoteNames)}
remoteNames={rowRemoteNames((c as RepoCommit).remoteNames, remoteNames)}
onPick={pick}
hoverColor={hoverColor}
highlighted={highlighted}
highlightedNodes={highlightedNodes}
onHover={(edge) => {
setHoverColor(edge);
if (!edge) return;
const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge));
const target = source?.parents.find((p) => `${source.id}\0${p}` === edge);
if (
!source ||
!target ||
commits.some((c) => commitKey(c) === target) ||
boundaryRequests.current.has(target)
)
return;
const original = all.find((c) => commitKey(c) === source.id);
if (!original) return;
boundaryRequests.current.add(target);
const split = target.lastIndexOf("\0"),
id = target.slice(split + 1);
void loadLogDetails({ ...original, id })
.then((details) =>
setBoundaryTitles((current) =>
new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`),
),
)
.catch(() => undefined);
}}
onFragment={fragment}
onNavigate={(index, key) => {
const target = commits[index];
if (target) selectCommit(commitKey(target), true);
else if (key) {
const split = key.lastIndexOf("\0");
void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo());
}
}}
targetTitle={(index, key) => {
const target = commits[index];
return target
? `${target.shortId} ${target.summary} — ${target.author}`
: (key && boundaryTitles.get(key)) ||
`${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`;
}}
onHover={rowHandlers.onHover}
onFragment={rowHandlers.onFragment}
onNavigate={rowHandlers.onNavigate}
targetTitle={targetTitle}
columns={columns}
bookmarked={!!bookmarks[commitKey(c)]}
/>
@@ -1804,6 +1821,22 @@ function LogColumnHeader({
);
}
type RowEvents = {
onHover: (edge: string | null) => void;
onFragment: (from: string, target: string, expand: boolean) => void;
onNavigate: (index: number, key?: string) => void;
};
const remoteNameSets = new WeakMap<readonly string[], ReadonlySet<string>>();
/** A commit's own remote names (multi-root logs) as one set per array, else the shared one. */
function rowRemoteNames(own: readonly string[] | undefined, shared: ReadonlySet<string>): ReadonlySet<string> {
if (!own) return shared;
let set = remoteNameSets.get(own);
if (!set) remoteNameSets.set(own, (set = new Set(own)));
return set;
}
const CommitRow = memo(function CommitRow({
id,
commit: c,
+23
Переглянути файл
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DATE_FORMAT, formatClock, formatDay, formatPattern } from "./dateFormat";
describe("dateFormat", () => {
const d = new Date(2026, 10, 5, 9, 7);
const system = { ...DEFAULT_DATE_FORMAT, overrideSystem: false };
const long = { year: "numeric", month: "short", day: "numeric" } as const;
it("formats like the locale methods it replaces", () => {
expect(formatDay(d, system)).toBe(d.toLocaleDateString(undefined, long));
expect(formatDay(d, system, true)).toBe(
d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }),
);
expect(formatClock(d, system)).toBe(d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }));
expect(formatPattern(d, "MMMM MMM dd.MM.yyyy")).toBe("November Nov 05.11.2026");
});
it("formats another date with the same cached formatter", () => {
const e = new Date(2025, 0, 31);
formatDay(d, system);
expect(formatDay(e, system)).toBe(e.toLocaleDateString(undefined, long));
});
});
+15 -5
Переглянути файл
@@ -34,13 +34,23 @@ export function dateFormatOptions(): DateFormatOptions {
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
// `toLocale*String` with options builds a new `Intl.DateTimeFormat` on every call,
// far too slow for lists that format a date per row.
const formatters = new Map<string, Intl.DateTimeFormat>();
function formatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale ?? ""}|${JSON.stringify(options)}`;
let f = formatters.get(key);
if (!f) formatters.set(key, (f = new Intl.DateTimeFormat(locale, options)));
return f;
}
/** Formats the date part with a `dd.MM.yyyy`-style pattern. */
export function formatPattern(d: Date, pattern: string): string {
const tokens: Record<string, () => string> = {
yyyy: () => String(d.getFullYear()),
yy: () => pad(d.getFullYear() % 100),
MMMM: () => d.toLocaleString("en", { month: "long" }),
MMM: () => d.toLocaleString("en", { month: "short" }),
MMMM: () => formatter("en", { month: "long" }).format(d),
MMM: () => formatter("en", { month: "short" }).format(d),
MM: () => pad(d.getMonth() + 1),
M: () => String(d.getMonth() + 1),
dd: () => pad(d.getDate()),
@@ -50,7 +60,7 @@ export function formatPattern(d: Date, pattern: string): string {
}
export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions()): string {
if (!o.overrideSystem) return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
if (!o.overrideSystem) return formatter(undefined, { hour: "2-digit", minute: "2-digit" }).format(d);
if (o.use24Hour) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const h = d.getHours() % 12 || 12;
return `${h}:${pad(d.getMinutes())} ${d.getHours() < 12 ? "AM" : "PM"}`;
@@ -60,8 +70,8 @@ export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions())
export function formatDay(d: Date, o: DateFormatOptions = dateFormatOptions(), short = false): string {
if (o.overrideSystem) return formatPattern(d, o.pattern);
return short
? d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" })
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
? formatter(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }).format(d)
: formatter(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d);
}
/** Timestamp for lists: pretty ("Today 10:42", "5 min ago") when enabled, else date + time. */