Use IntersectionObserver for side tree in documentation#5832
Open
evnchn wants to merge 2 commits intozauberzeug:mainfrom
Open
Use IntersectionObserver for side tree in documentation#5832evnchn wants to merge 2 commits intozauberzeug:mainfrom
evnchn wants to merge 2 commits intozauberzeug:mainfrom
Conversation
Collaborator
Author
|
Note This comment was generated by Claude Code acting on behalf of @evnchn. Independent LCP BenchmarkI ran an automated A/B benchmark comparing
Results
n=12 per cell. Trimmed mean drops top & bottom 20%. TL;DR
Full measurement script (measure_lcp.mjs)/**
* A/B LCP benchmark: main vs intersection-tree
*
* Features:
* - CDP network throttling ("Slow 3G"-ish) + CPU throttling (4x = low-end mobile)
* - Interleaved A/B: alternates server restarts between branches
* - Incremental JSONL saves: each measurement written immediately to disk
* - Resume: reads existing JSONL and skips completed schedule slots
* - --report mode: print summary from existing data without running
*
* Usage:
* node measure_lcp.mjs [--runs N] [--warmup N] [--out FILE] [--report]
*/
import { chromium } from 'playwright';
import { readFileSync, writeFileSync, copyFileSync, appendFileSync, existsSync } from 'fs';
import { execSync, spawn } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// ── Config ──────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
const getArg = (name, def) => { const i = argv.indexOf(name); return i !== -1 && argv[i+1] ? argv[i+1] : def; };
const hasFlag = (name) => argv.includes(name);
const RUNS_PER_BRANCH = parseInt(getArg('--runs', '15'), 10);
const WARMUP = parseInt(getArg('--warmup', '3'), 10);
const RESULTS_FILE = getArg('--out', '/tmp/lcp_results.jsonl');
const REPORT_ONLY = hasFlag('--report');
const BASE_URL = 'http://localhost:8080';
const PAGES = ['/documentation', '/documentation/section_action_events'];
// Network: ~400 KB/s down, ~100 KB/s up, 150ms latency (between Fast 3G and Slow 3G)
const NETWORK = { downloadThroughput: 400_000, uploadThroughput: 100_000, latency: 150 };
const CPU_SLOWDOWN = 4;
// ── File paths ──────────────────────────────────────────────────────
const MAIN_PY = resolve(__dirname, 'main.py');
const INTERSECTION_JS = resolve(__dirname, 'website/documentation/intersection_observer.js');
const MAIN_PY_BACKUP = '/tmp/bench_main_main.py';
const INT_JS_BACKUP = '/tmp/bench_intersection_observer_main.js';
// ── JSONL persistence ───────────────────────────────────────────────
function loadResults() {
if (!existsSync(RESULTS_FILE)) return [];
return readFileSync(RESULTS_FILE, 'utf-8')
.split('\n')
.filter(l => l.trim())
.map(l => JSON.parse(l));
}
function appendResult(record) {
appendFileSync(RESULTS_FILE, JSON.stringify(record) + '\n');
}
// ── Branch swapping ─────────────────────────────────────────────────
function backupOriginals() {
if (!existsSync(MAIN_PY_BACKUP)) {
execSync('git show HEAD:main.py > ' + MAIN_PY_BACKUP, { cwd: __dirname, shell: true });
}
if (!existsSync(INT_JS_BACKUP)) {
execSync('git show HEAD:website/documentation/intersection_observer.js > ' + INT_JS_BACKUP,
{ cwd: __dirname, shell: true });
}
}
function patchShowFalse(filePath) {
let src = readFileSync(filePath, 'utf-8');
if (!src.includes('show=False')) {
src = src.replace(/ui\.run\(([^)]*)\)/, 'ui.run($1, show=False)');
writeFileSync(filePath, src);
}
}
function applyVariant(variant) {
if (variant === 'main') {
copyFileSync(MAIN_PY_BACKUP, MAIN_PY);
copyFileSync(INT_JS_BACKUP, INTERSECTION_JS);
} else {
execSync(
'git checkout origin/intersection-tree -- main.py website/documentation/intersection_observer.js',
{ cwd: __dirname }
);
}
patchShowFalse(MAIN_PY);
}
function restoreOriginals() {
try { copyFileSync(MAIN_PY_BACKUP, MAIN_PY); } catch {}
try { copyFileSync(INT_JS_BACKUP, INTERSECTION_JS); } catch {}
}
// ── Server lifecycle ────────────────────────────────────────────────
function startServer() {
return new Promise((resolve, reject) => {
const proc = spawn('python', ['main.py'], {
cwd: __dirname,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
proc.stdout.on('data', d => output += d);
proc.stderr.on('data', d => output += d);
const deadline = setTimeout(() => reject(new Error('Server start timeout:\n' + output)), 90_000);
const poll = setInterval(async () => {
try {
const res = await fetch(BASE_URL + '/status');
if (res.ok) {
clearInterval(poll);
clearTimeout(deadline);
resolve({
proc,
kill: () => new Promise(done => {
proc.on('exit', done);
proc.kill('SIGTERM');
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000);
setTimeout(done, 6000);
}),
});
}
} catch {}
}, 500);
});
}
async function waitPortFree(maxWait = 12_000) {
const t0 = Date.now();
while (Date.now() - t0 < maxWait) {
try {
const r = await fetch(BASE_URL + '/status');
if (r.ok) { await sleep(500); continue; }
} catch { return; }
await sleep(300);
}
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ── LCP measurement ─────────────────────────────────────────────────
async function measureLCP(browser, url, timeout = 30_000) {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: NETWORK.downloadThroughput,
uploadThroughput: NETWORK.uploadThroughput,
latency: NETWORK.latency,
});
await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU_SLOWDOWN });
await page.addInitScript(() => {
window.__LCP_ENTRIES = [];
window.__LCP_VALUE = null;
const obs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
window.__LCP_ENTRIES.push({
startTime: e.startTime, renderTime: e.renderTime,
loadTime: e.loadTime, size: e.size,
element: e.element ? e.element.tagName : null,
});
window.__LCP_VALUE = e.renderTime || e.loadTime || e.startTime;
}
});
obs.observe({ type: 'largest-contentful-paint', buffered: true });
});
try {
await page.goto(url, { waitUntil: 'load', timeout });
} catch (e) {
if (!e.message.includes('Timeout')) throw e;
}
// Wait for NiceGUI WebSocket content
await page.waitForTimeout(5000);
// Finalize LCP (spec: stops on user input)
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
const lcp = await page.evaluate(() => window.__LCP_VALUE);
const entries = await page.evaluate(() => window.__LCP_ENTRIES);
const perf = await page.evaluate(() => {
const n = performance.getEntriesByType('navigation')[0];
return n ? { dcl: n.domContentLoadedEventEnd, load: n.loadEventEnd,
transferSize: n.transferSize, decodedBodySize: n.decodedBodySize } : null;
});
const resources = await page.evaluate(() => {
const rs = performance.getEntriesByType('resource');
return { totalTransfer: rs.reduce((s,r) => s + (r.transferSize||0), 0), count: rs.length };
});
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
await cdp.send('Network.emulateNetworkConditions', {
offline: false, downloadThroughput: -1, uploadThroughput: -1, latency: 0 });
await cdp.detach();
await context.close();
return { lcp, entries, perf, resources };
}
// ── Statistics ───────────────────────────────────────────────────────
function stats(arr) {
if (!arr.length) return null;
const s = [...arr].sort((a,b) => a - b);
const mean = s.reduce((a,b) => a+b, 0) / s.length;
const variance = s.reduce((sum,v) => sum + (v-mean)**2, 0) / s.length;
const trimN = Math.max(1, Math.floor(s.length * 0.2));
const trimmed = s.slice(trimN, s.length - trimN);
const tMean = trimmed.length ? trimmed.reduce((a,b) => a+b, 0) / trimmed.length : mean;
return {
n: s.length, median: s[Math.floor(s.length/2)],
mean: +mean.toFixed(1), trimmedMean: +tMean.toFixed(1),
p25: s[Math.floor(s.length*0.25)], p75: s[Math.floor(s.length*0.75)],
min: s[0], max: s[s.length-1], stddev: +Math.sqrt(variance).toFixed(1),
};
}
function printReport(records) {
const data = {};
for (const p of PAGES) data[p] = { main: [], tree: [] };
for (const r of records) {
if (r.warmup) continue;
if (data[r.page]?.[r.variant]) data[r.page][r.variant].push(r);
}
console.log(`\nRESULTS (CPU ${CPU_SLOWDOWN}x, network ${NETWORK.latency}ms/${(NETWORK.downloadThroughput/1000).toFixed(0)}KB/s)\n`);
for (const page of PAGES) {
const d = data[page];
const mLcp = d.main.map(r=>r.lcp).filter(v=>v!=null);
const tLcp = d.tree.map(r=>r.lcp).filter(v=>v!=null);
const mS = stats(mLcp), tS = stats(tLcp);
console.log(`${page}`);
if (mS && tS) {
const pM = ((tS.median-mS.median)/mS.median*100).toFixed(1);
const pT = ((tS.trimmedMean-mS.trimmedMean)/mS.trimmedMean*100).toFixed(1);
console.log(` LCP median: main=${mS.median}ms tree=${tS.median}ms (${pM}%)`);
console.log(` LCP trimmed mean: main=${mS.trimmedMean}ms tree=${tS.trimmedMean}ms (${pT}%)`);
console.log(` n=${mS.n} per variant`);
}
console.log('');
}
}
// ── Main ─────────────────────────────────────────────────────────────
async function main() {
if (REPORT_ONLY) {
const records = loadResults();
console.log(`Loaded ${records.length} records from ${RESULTS_FILE}`);
printReport(records);
return;
}
console.log(`A/B LCP Benchmark: main vs intersection-tree`);
console.log(`Runs per branch/page : ${RUNS_PER_BRANCH}`);
console.log(`Warmup per server : ${WARMUP}`);
console.log(`Network : ${NETWORK.latency}ms latency, ${(NETWORK.downloadThroughput/1000).toFixed(0)} KB/s down`);
console.log(`CPU : ${CPU_SLOWDOWN}x slowdown`);
console.log(`Results file : ${RESULTS_FILE}`);
backupOriginals();
process.on('SIGINT', () => { restoreOriginals(); process.exit(1); });
const existing = loadResults().filter(r => !r.warmup);
const completedKeys = new Set(existing.map(r => `${r.variant}:${r.page}:${r.scheduleIdx}`));
console.log(`Existing results : ${existing.length} measurements (resuming)\n`);
const schedule = [];
for (let i = 0; i < RUNS_PER_BRANCH; i++) {
for (const variant of ['main', 'tree']) {
for (const page of PAGES) {
schedule.push({ variant, page, scheduleIdx: i });
}
}
}
const browser = await chromium.launch({
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
let currentVariant = null;
let server = null;
async function ensureServer(variant) {
if (currentVariant === variant) return;
if (server) {
await server.kill(); server = null; await waitPortFree();
}
applyVariant(variant);
server = await startServer();
currentVariant = variant;
for (const p of PAGES) {
for (let w = 0; w < WARMUP; w++) {
try {
const r = await measureLCP(browser, BASE_URL + p);
appendResult({ variant, page: p, warmup: true, lcp: r.lcp, ts: Date.now() });
console.log(` warmup ${variant} ${p} #${w+1}: LCP=${r.lcp?.toFixed(0) ?? 'N/A'}ms`);
} catch (e) {
console.log(` warmup ${variant} ${p} #${w+1}: ERROR ${e.message}`);
}
}
}
}
for (let si = 0; si < schedule.length; si++) {
const { variant, page, scheduleIdx } = schedule[si];
const key = `${variant}:${page}:${scheduleIdx}`;
if (completedKeys.has(key)) continue;
await ensureServer(variant);
try {
const r = await measureLCP(browser, BASE_URL + page);
const record = {
variant, page, scheduleIdx,
lcp: r.lcp, dcl: r.perf?.dcl, load: r.perf?.load,
htmlTransfer: r.perf?.transferSize, htmlDecoded: r.perf?.decodedBodySize,
totalTransfer: r.resources?.totalTransfer,
lcpElement: r.entries?.[r.entries.length-1]?.element ?? null,
lcpSize: r.entries?.[r.entries.length-1]?.size ?? null,
ts: Date.now(),
};
appendResult(record);
console.log(
`[${si+1}/${schedule.length}] ${variant.padEnd(5)} run ${String(scheduleIdx+1).padStart(2)} ${page.padEnd(45)} ` +
`LCP=${r.lcp?.toFixed(0)?.padStart(5) ?? ' N/A'}ms`
);
} catch (e) {
console.log(`[${si+1}/${schedule.length}] ${variant} ${page} ERROR: ${e.message}`);
}
}
if (server) { await server.kill(); await waitPortFree(); }
await browser.close();
restoreOriginals();
printReport(loadResults());
}
main().catch(e => {
restoreOriginals();
console.error('Fatal:', e);
process.exit(1);
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
After benchmarking, my evaluation is that quite a bit of the render time on NiceGUI is being used to render the sidebar's tree.
Mind you, it's hidden (1) if not on documentation, and (2) if not manually expanded on Mobile.
Implementation
There is already a
IntersectionObserverlying around for the demo. I expanded it to detect left-offscreen elements, and used it for theui.tree.I used my
spinner.giffor an efficient spinner which does not involve style recalsI also hide the tree using
set_visibility(False)because it would show "No nodes available" otherwise.Performance comparison
Evaluation page:
http://127.0.0.1:8080/documentation/section_testing(chosen for no demos).Evaluation environment: Low-tier mobile (16.3x on my machine), no network throttling nor caching
Page size:
LCP:
Note: Would have more improvement in LCP in real life considering it less time to download the page after the PR.
Progress