Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
40bd428
Restyle a :has() change's earlier siblings only for a :has(+ x) or :h…
brainkim Oct 14, 2026
63aebc4
Test that the box model and marker style caches follow every kind of …
brainkim Oct 14, 2026
afca46e
Split a text into graphemes once, and build each output line as a string
brainkim Oct 14, 2026
ad1efd2
Look up a pseudo-element's rules by its type instead of filtering eve…
brainkim Oct 14, 2026
9caa674
Drop an @import or @namespace out of its place, and read namespaces f…
brainkim Oct 14, 2026
8f4e028
Read each selector once, not once per document that parses it
brainkim Oct 14, 2026
d5b7230
Build a window's class record and parse its operations once, not per …
brainkim Oct 14, 2026
c87eb46
Try only the rules an element's name allows, and skip pseudo-element …
brainkim Oct 14, 2026
d1f9e87
Derive no boxes for a mutation inside a tree that has left the document
brainkim Oct 14, 2026
8e0c779
Attach no pseudo-elements in a detached tree, and walk an added tree …
brainkim Oct 14, 2026
4c83342
Restyle only the children a sibling rule could name when a child come…
brainkim Oct 14, 2026
3be561d
Ask whether a scroller is the root box only when it has scrolled
brainkim Oct 14, 2026
191fcce
Add a benchmark harness, and compare each pull request against its base
brainkim Oct 14, 2026
c9702fb
Make the canvas contexts' internal steps plain functions
brainkim Oct 14, 2026
5de8c09
Name internal steps with a leading underscore instead of a symbol, an…
brainkim Oct 14, 2026
4585fd7
Read a walker's filter, a token list's tokens and a validity state's …
brainkim Oct 14, 2026
28a370a
Name the slots live collections register in, which the comment called…
brainkim Oct 14, 2026
09f1522
Put stray comments back on the code they describe, and drop ones that…
brainkim Oct 14, 2026
4a1f0c8
Report an image's position from x and y, reject fullscreen on a detac…
brainkim Oct 14, 2026
faedea9
Drop checks for an Event or EventTarget the runtime always has, unuse…
brainkim Oct 14, 2026
f1f7ef2
Call what one-line wrappers stood for, and stop exporting what no oth…
brainkim Oct 14, 2026
1d00436
Write each repeated piece of logic once: a URL attribute's read, a sc…
brainkim Oct 14, 2026
0efa2c2
Look a custom element's definition up in one function, and drop a pop…
brainkim Oct 14, 2026
204a064
Drop a popover's unread trigger, and build flushLayout on flushStyle
brainkim Oct 14, 2026
d5ac58c
Make Painter's methods plain functions that take the painter
brainkim Oct 14, 2026
57eb5e7
Make eleven of Cascade's methods plain functions that take the cascade
brainkim Oct 14, 2026
07b8480
Make Framebuffer's methods plain functions that take the framebuffer
brainkim Oct 14, 2026
00139f2
Make the Layout methods that no module layout.ts loads calls into pla…
brainkim Oct 14, 2026
6d43d9e
Make the Exchange methods no module exchange.ts loads calls into plai…
brainkim Oct 14, 2026
abf133e
Make LayoutNode's, CellGrid's, CellContext's and Input's methods plai…
brainkim Oct 14, 2026
9588af5
Move the node tree's plain reads out of dom.ts into tree.ts
brainkim Oct 14, 2026
364368c
Take editing.ts and images.ts out of the DOM's import cycle
brainkim Oct 14, 2026
e7463bc
Take resources.ts out of the DOM's import cycle
brainkim Oct 14, 2026
44bfbb0
Take the selector matcher, cssvalues, the framebuffer and canvas out …
brainkim Oct 14, 2026
332e226
Take mathml.ts out of the DOM's import cycle
brainkim Oct 14, 2026
bafc4e9
Move the cell size and its unit conversions out of cssom.ts into unit…
brainkim Oct 14, 2026
3989732
Break the last import cycle, between dom.ts and cssom.ts, and test th…
brainkim Oct 14, 2026
cec1297
Draw a key's frame without sleeping first, and measure key-to-frame l…
brainkim Oct 15, 2026
9039003
Run the CodeMirror example at the terminal's own cell size, and drop …
brainkim Oct 15, 2026
6845f0e
Give the CodeMirror example's lines and line numbers a cell of padding
brainkim Oct 15, 2026
1ac82fb
Skip rounding and paint extents for subtrees no layout touched
brainkim Oct 15, 2026
9874dfe
Run the editor benchmark at the terminal's own cell size, as the exam…
brainkim Oct 15, 2026
8dbedcc
Lay the root out as a block, and time CodeMirror keys under load
brainkim Oct 15, 2026
6050a7b
Spare geometry reads the scroll clamp and the math search when nothin…
brainkim Oct 15, 2026
279f889
Keep the non-breaking spaces a run opens and closes on
brainkim Oct 15, 2026
c1be4f7
Give solitaire's height breakpoints a unit
brainkim Oct 15, 2026
2d44d69
Repaint only the rows a frame changed
brainkim Oct 15, 2026
17f9051
Find the children on some rows by binary search whenever they are in …
brainkim Oct 15, 2026
88fae70
Wait for every event a chunk holds in the mouse tests' send helper
brainkim Oct 15, 2026
7c8c8d2
Satisfy the linter: wrap long lines, sort an import, declare before use
brainkim Oct 15, 2026
2936eb1
Break text to the whole columns its box is drawn across
brainkim Oct 15, 2026
c935af4
Keep one computed-style view per element, so a restyle defines no ind…
brainkim Oct 15, 2026
2ca1c35
Lay text out again where it landed only when its lines would change t…
brainkim Oct 15, 2026
16bfb05
Make a client rect in one DOMRect, and find fixed space from the boxes
brainkim Oct 15, 2026
78aa431
Wrap a line the linter found too long
brainkim Oct 15, 2026
62e9948
Join borders and diff only the rows a partial frame changed
brainkim Oct 15, 2026
65d696a
Keep the root's margins in, repaint rows for paint-only restyles, and…
brainkim Oct 15, 2026
3b74a76
Declare the list numbering attributes before their use
brainkim Oct 15, 2026
3324db1
Round the grid from layout's own values, renumber counters a change m…
brainkim Oct 15, 2026
2fbe346
Repaint a restyle inside an inline-block on the rows the inline-block…
brainkim Oct 15, 2026
8365ac0
Keep positioned table parts and inline-block contents current through…
brainkim Oct 15, 2026
c037f49
Measure an inline-block again when its width or height changes, and p…
brainkim Oct 15, 2026
35b01a7
Apply a sheet added inside a subtree, show a textarea's new text, and…
brainkim Oct 15, 2026
9f80997
Type the fuzz pages' value makers
brainkim Oct 15, 2026
3873c75
Lay out a positioned row's absolute boxes, and place absolute boxes f…
brainkim Oct 15, 2026
314a18a
Hold every frame's layout to a solve from nothing, and measure again …
brainkim Oct 15, 2026
5d71c36
Check each frame's layout against boxes derived again from nothing
brainkim Oct 15, 2026
9c1d489
Check each frame's styles against styles resolved from nothing
brainkim Oct 15, 2026
2772671
Sort the test utilities' imports
brainkim Oct 15, 2026
61b305f
Keep the grid in a record of its own, so layout never reads a rounded…
brainkim Oct 15, 2026
a9b8d1a
Keep form controls, shadow children and sticky boxes current
brainkim Oct 15, 2026
0047e3c
Damage what a box painted, not only where its rect was
brainkim Oct 15, 2026
f24759a
Count counters in tree order, and retire boxes along the enumeration
brainkim Oct 15, 2026
f540509
Wrap stray table parts in anonymous tables, and key sizes by what the…
brainkim Oct 15, 2026
b378968
Keep test-only code out of the build
brainkim Oct 15, 2026
79fee03
Read the window's interface names from its classes in the IDL generator
brainkim Oct 15, 2026
eed37fe
Place an absolute box after an anonymous table, and keep percentage w…
brainkim Oct 15, 2026
8de3bb7
Count ::before and ::after as their element's first and last child, a…
brainkim Oct 15, 2026
6e6cf28
Lay out display: inline-table as a table that sits on a line
brainkim Oct 15, 2026
f30525c
Count the band from an inline-block's root when its positioned boxes …
brainkim Oct 15, 2026
190d0a5
Hit-test boxes an inline-block lays out by their rects, not by extent…
brainkim Oct 15, 2026
e0be557
Let a line's extent cover what its inline-blocks paint
brainkim Oct 15, 2026
f8b4c93
Record the frame an extent's rows are counted in, and read extents on…
brainkim Oct 15, 2026
48f194b
Tell a run's width-dependence from its content's styles, not from whe…
brainkim Oct 15, 2026
a147f34
Make a counter key from the inputs counting applies
brainkim Oct 15, 2026
4c8760d
Wrap a comment to the line length
brainkim Oct 15, 2026
7fe483f
Draw every form control from its state each frame, and check none wen…
brainkim Oct 15, 2026
3981ebd
Stick a box an inline-block lays out to the scroller around the inlin…
brainkim Oct 15, 2026
a06b5df
Inherit counters through the flat tree
brainkim Oct 15, 2026
a33b993
Style an inline-block's own root from its element as it is now, and w…
brainkim Oct 15, 2026
aa516cb
Drop an element's own root when a restyle leaves it laying out none
brainkim Oct 15, 2026
572ffdb
Give no static position where the box's line and its containing block…
brainkim Oct 15, 2026
8f0b858
Style a run member's kept node as the element is now
brainkim Oct 15, 2026
efd76be
Keep no node for an element its line measures, and check that every n…
brainkim Oct 15, 2026
801b8f4
Place an element's out-of-flow boxes again when its own root comes or…
brainkim Oct 15, 2026
b9a251f
Give a root of its own only to a box a run measures
brainkim Oct 15, 2026
00eb584
Read a run's indent from its element when the run has no container
brainkim Oct 15, 2026
64ed699
Align atomic inlines on the baseline
brainkim Oct 15, 2026
a571df5
Put an empty textarea's caret inside its border in a flex row
brainkim Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/bench.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Benchmarks

on:
pull_request:

# Shared runners are too noisy to fail a pull request on a number, so the
# comparison is a report in the job summary, not a check.
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- uses: actions/setup-node@v5
with:
node-version: 26

- run: bun install --frozen-lockfile

- name: Compare with the base branch
run: |
{
echo "## Benchmarks: median CPU time, base against this pull request"
echo
node bench/run.ts --runs 3 --against "origin/${{ github.base_ref }}" --markdown
} >> "$GITHUB_STEP_SUMMARY"
49 changes: 49 additions & 0 deletions bench/editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* CodeMirror as the example runs it, on a 400-line file, with the cursor
* near the top.
*/
import {installGlobals, mount, nextFrame} from "./harness.ts";

export async function mountEditor(): Promise<{proc: any; dom: any; view: any}> {
const {proc, dom} = await mount(100, 40, {cellSize: "auto"});
installGlobals(dom);
const {EditorState} = await import("@codemirror/state");
const {EditorView, keymap, lineNumbers} = await import("@codemirror/view");
const {defaultKeymap, history, historyKeymap} = await import(
"@codemirror/commands",
);
const {javascript} = await import("@codemirror/lang-javascript");
const {defaultHighlightStyle, syntaxHighlighting} = await import(
"@codemirror/language",
);

const {document} = dom;
document.body.innerHTML = "<h1>Editor</h1><div id=editor></div>";
const doc = Array.from(
{length: 100},
(_, i) =>
[
`function step${i}(value: number, rect: {x: number; y: number}): number {`,
`\tconst scaled = value * ${i + 1} + rect.x; // step ${i}`,
"\treturn Math.max(scaled, rect.y);",
"}",
].join("\n"),
).join("\n");
const view = new EditorView({
state: EditorState.create({
doc,
extensions: [
lineNumbers(),
history(),
javascript({typescript: true}),
syntaxHighlighting(defaultHighlightStyle),
keymap.of([...defaultKeymap, ...historyKeymap]),
],
}),
parent: document.getElementById("editor"),
});
view.focus();
view.dispatch({selection: {anchor: doc.indexOf("\n", 200)}});
await nextFrame(dom);
return {proc, dom, view};
}
161 changes: 161 additions & 0 deletions bench/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* What every scenario shares. A scenario runs against the checkout named
* by its first argument, so the runner can point the same scenario at two
* checkouts, and prints one JSON line per measurement.
*/
import {spawn} from "node:child_process";
import {readFileSync} from "node:fs";
import {availableParallelism} from "node:os";

export const root = process.argv[2];

/** The bench's own checkout, which every input is read from. */
export const benchRoot = new URL("..", import.meta.url).pathname;

const index = await import(`${root}/src/index.ts`);
const utils = await import(`${root}/tests/test-utils.ts`);
const painter = await import(`${root}/src/internal/painter.ts`);
// The test utilities check every partial frame against a whole one, which
// is not what a user's frame costs.
if (painter.partialFrameCheck !== undefined) {
painter.partialFrameCheck.enabled = false;
}
const cssom = await import(`${root}/src/internal/cssom.ts`);
if (cssom.styleCheck !== undefined) {
cssom.styleCheck.enabled = false;
}
const layout = await import(`${root}/src/internal/layout.ts`);
if (layout.layoutCheck !== undefined) {
layout.layoutCheck.enabled = false;
}

export const TermDOM = index.TermDOM;
export const installGlobals = index.installGlobals;
export const MockProcess = utils.MockProcess;
export const nextFrame: (dom: unknown) => Promise<void> = utils.nextFrame;

export function readBenchFile(path: string): string {
return readFileSync(`${benchRoot}${path}`, "utf8");
}

export async function mount(
cols = 100,
rows = 40,
options: object = {},
): Promise<{proc: any; dom: any}> {
const proc = new MockProcess({cols, rows});
const dom = new TermDOM({...options, transport: proc.transport});
dom.attach();
await nextFrame(dom);
return {proc, dom};
}

/**
* The terminal becomes a sink once the page is up, so what is measured
* is the engine and not the emulator reading its output.
*/
export function silence(proc: any): void {
proc.stdout.write = (_chunk: unknown, enc?: unknown, cb?: unknown) => {
const callback = typeof enc === "function" ? enc : cb;
if (typeof callback === "function") {
(callback as () => void)();
}
return true;
};
}

/** CPU time, so the time a frame waits for its turn is not counted. */
export function cpuMs(): number {
const {user, system} = process.cpuUsage();
return (user + system) / 1000;
}

export function report(label: string, times: number[]): void {
const sorted = [...times].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
console.log(JSON.stringify({label, median}));
}

export async function measure(
label: string,
steps: number,
step: (i: number) => Promise<void> | void,
warm = 10,
): Promise<void> {
for (let i = 0; i < warm; i++) {
await step(i);
}
const times: number[] = [];
for (let i = warm; i < warm + steps; i++) {
const start = cpuMs();
await step(i);
times.push(cpuMs() - start);
}
report(label, times);
}

/**
* Reports wall times as a measurement, and prints their percentiles, which
* a median alone hides, to stderr.
*/
export function describeLatency(label: string, times: number[]): void {
report(label, times);
const sorted = [...times].sort((a, b) => a - b);
const at = (q: number): string =>
sorted[Math.floor(sorted.length * q)].toFixed(1);
console.error(
`${label}: p50 ${at(0.5)} p90 ${at(0.9)} p99 ${at(0.99)} max ${sorted.at(-1)!.toFixed(1)}`,
);
}

/**
* Times each key from reaching the program to its frame reaching the
* terminal, typed at a person's pace.
*/
export function keyLatency(proc: any): (key: string) => Promise<number> {
let wrote: (() => void) | null = null;
proc.stdout.write = (_chunk: unknown, enc?: unknown, cb?: unknown) => {
wrote?.();
const callback = typeof enc === "function" ? enc : cb;
if (typeof callback === "function") {
(callback as () => void)();
}
return true;
};
return async (key) => {
await new Promise((resolve) => setTimeout(resolve, 40));
const painted = new Promise<void>((resolve) => {
wrote = resolve;
});
const start = performance.now();
proc.stdin.simulateResponse(key);
await painted;
wrote = null;
return performance.now() - start;
};
}

/** Runs the work with an idle machine, every core busy, and two spinners a core. */
export async function underLoad(
label: string,
work: () => Promise<number[]>,
): Promise<void> {
describeLatency(`${label}, idle`, await work());
for (const [load, perCore] of [
["every core busy", 1],
["two spinners a core", 2],
] as const) {
const spinners = Array.from(
{length: availableParallelism() * perCore},
() => spawn(process.execPath, ["-e", "for (;;) {}"], {stdio: "ignore"}),
);
await new Promise((resolve) => setTimeout(resolve, 300));
try {
describeLatency(`${label}, ${load}`, await work());
} finally {
for (const spinner of spinners) {
spinner.kill();
}
}
}
}
145 changes: 145 additions & 0 deletions bench/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Runs the scenarios in bench/scenarios, each in a process of its own,
* and prints the median CPU time of each measurement.
*
* node bench/run.ts [--runs N] [--only a,b] [--against REF] [--markdown]
*
* --against checks REF out into a temporary worktree and runs the same
* scenarios against it, alternating with this checkout, so both sides
* share whatever the machine was doing.
*/
import {spawnSync} from "node:child_process";
import {mkdtempSync, readdirSync, rmSync, symlinkSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";

const benchRoot = new URL("..", import.meta.url).pathname;
const scenarioDirectory = new URL("scenarios/", import.meta.url).pathname;

function option(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
}

const runs = Number(option("--runs") ?? 3);
const only = option("--only")?.split(",");
const against = option("--against");
const markdown = process.argv.includes("--markdown");

const scenarios = readdirSync(scenarioDirectory)
.filter((name) => name.endsWith(".ts"))
.map((name) => name.slice(0, -3))
.filter((name) => only === undefined || only.includes(name));

function git(...args: string[]): void {
const result = spawnSync("git", args, {cwd: benchRoot, stdio: "inherit"});
if (result.status !== 0) {
throw new Error(`git ${args.join(" ")} failed`);
}
}

function runScenario(name: string, root: string): Map<string, number> {
const result = spawnSync(
process.execPath,
[...process.execArgv, join(scenarioDirectory, `${name}.ts`), root],
{
cwd: root,
encoding: "utf8",
timeout: 600_000,
stdio: ["ignore", "pipe", "pipe"],
},
);
const medians = new Map<string, number>();
for (const line of result.stdout.split("\n")) {
if (line.startsWith("{")) {
const {label, median} = JSON.parse(line);
medians.set(label, median);
}
}
if (medians.size === 0) {
console.error(`${name} on ${root} reported nothing:\n${result.stderr}`);
}
return medians;
}

function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}

const roots: Array<{name: string; path: string}> = [];
let worktree: string | undefined;
if (against !== undefined) {
worktree = mkdtempSync(join(tmpdir(), "termdom-bench-"));
git("worktree", "add", "--detach", worktree, against);
symlinkSync(join(benchRoot, "node_modules"), join(worktree, "node_modules"));
roots.push({name: against, path: worktree});
}
roots.push({name: "this checkout", path: benchRoot.replace(/\/$/, "")});

const samples = new Map<string, Map<string, number[]>>();
try {
for (let run = 0; run < runs; run++) {
for (const scenario of scenarios) {
for (const root of roots) {
for (const [label, value] of runScenario(scenario, root.path)) {
let byRoot = samples.get(label);
if (byRoot === undefined) {
byRoot = new Map();
samples.set(label, byRoot);
}
const values = byRoot.get(root.name) ?? [];
values.push(value);
byRoot.set(root.name, values);
}
}
}
console.error(`run ${run + 1} of ${runs} done`);
}
} finally {
if (worktree !== undefined) {
rmSync(join(worktree, "node_modules"));
git("worktree", "remove", "--force", worktree);
}
}

const header = [
"Measurement",
...roots.map((root) => `${root.name} (ms)`),
...(roots.length === 2 ? ["Change"] : []),
];
const rows = [...samples].map(([label, byRoot]) => {
const values = roots.map((root) => {
const values = byRoot.get(root.name);
return values === undefined ? NaN : median(values);
});
const cells = [label, ...values.map((value) => value.toFixed(2))];
if (values.length === 2) {
const change = (values[1] - values[0]) / values[0] * 100;
cells.push(`${change > 0 ? "+" : ""}${change.toFixed(0)}%`);
}
return cells;
});

if (markdown) {
console.log(`| ${header.join(" | ")} |`);
console.log(
`|${header.map((_, i) => i === 0 ? " --- " : " ---: ").join("|")}|`,
);
for (const row of rows) {
console.log(`| ${row.join(" | ")} |`);
}
} else {
const widths = header.map((_, i) =>
Math.max(header[i].length, ...rows.map((row) => row[i].length)));
const format = (cells: string[]): string =>
cells
.map((cell, i) =>
i === 0 ? cell.padEnd(widths[i]) : cell.padStart(widths[i]),
)
.join(" ");
console.log(format(header));
for (const row of rows) {
console.log(format(row));
}
}
Loading
Loading