Files
paseo/packages/app/src/git/diff-document/measurement-cost.test.ts
T
Mohamed Boudra 3998ea11d9 Keep large diffs responsive across web and mobile (#4174)
* perf(app): keep large diffs responsive

Render file headers on the canvas and bound expensive text shaping to the active viewport while preserving sticky, hover, and horizontal-scroll behavior. Let native scroll cancellation arbitrate header presses so finger scrolling cannot expand files.

* test(app): assert canvas diff header accessibility

* test(app): assert canvas diff header semantics
2026-09-01 23:52:11 +02:00

117 lines
4.0 KiB
TypeScript

import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { buildDiffDocumentModel } from "./model";
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
/**
* A text backend that can only measure a whole string costs O(string length)
* per call, so measuring cumulative advances by re-shaping a growing prefix
* shapes n^2/2 characters for one line of n characters. That froze the renderer
* for ~10s on a 42-line CHANGELOG.md diff and for over 15 minutes on a diff
* containing a single 1.2M-character line.
*
* Cost is counted in *shaped characters* rather than calls, because that is the
* quantity that maps to renderer CPU time. The budgets below are the linear
* contract of the shared advance measurement in `text-measurement.ts`: work
* stays proportional to the text, with a constant bounded by the shaped-run cap.
*/
const SHAPED_RUN_LIMIT = 64;
function countingMeasurer(): { stats: { shapedCharacters: number }; measurer: TextMeasurer } {
const stats = { shapedCharacters: 0 };
return {
stats,
measurer: {
measure(text) {
stats.shapedCharacters += text.length;
return text.length * 10;
},
},
};
}
/** Ligature-bearing source, so measurement takes the shaping path rather than the cache. */
function codeLine(length: number): string {
const unit = "const value = (a, b) => a === b ? 1 : 0; ";
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length);
}
function diffFile(lines: readonly string[]): ParsedDiffFile {
return {
path: "src/a.ts",
isNew: false,
isDeleted: false,
additions: lines.length,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 0,
newStart: 1,
newCount: lines.length,
lines: lines.map((content) => ({ type: "add" as const, content })),
},
],
};
}
function shapedCharactersFor(options: { lineCount: number; lineLength: number }): number {
const { stats, measurer } = countingMeasurer();
const line = codeLine(options.lineLength);
const input: BuildDiffDocumentModelInput = {
files: [diffFile(Array.from({ length: options.lineCount }, () => line))],
collapsedFilePaths: new Set(),
layout: "unified",
wrapLines: false,
viewportWidth: 1200,
typography: { family: "monospace", size: 12, lineHeight: 18 },
measureText: measurer,
palette: {
surface: "#000",
headerSurface: "#111",
border: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
addition: "green",
deletion: "red",
additionBackground: "#010",
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
};
buildDiffDocumentModel(input);
return stats.shapedCharacters;
}
describe("diff document measurement cost", () => {
it("shapes a bounded number of characters per character of text", () => {
// The shape of the CHANGELOG.md working diff that froze the renderer: 42
// lines, longest 597 characters.
const lineCount = 42;
const lineLength = 597;
const shaped = shapedCharactersFor({ lineCount, lineLength });
expect(shaped).toBeLessThanOrEqual(lineCount * lineLength * SHAPED_RUN_LIMIT);
});
it("does not grow super-linearly with line length", () => {
const short = shapedCharactersFor({ lineCount: 1, lineLength: 50 });
const long = shapedCharactersFor({ lineCount: 1, lineLength: 800 });
// 16x the characters must not cost more than ~16x the shaping work.
expect(long / short).toBeLessThan(32);
});
it("grows no faster than linearly with line count", () => {
const ten = shapedCharactersFor({ lineCount: 10, lineLength: 100 });
const hundred = shapedCharactersFor({ lineCount: 100, lineLength: 100 });
expect(hundred).toBeLessThanOrEqual(ten * 10);
});
});