Skip to content

Comments

Use IntersectionObserver for side tree in documentation#5832

Open
evnchn wants to merge 2 commits intozauberzeug:mainfrom
evnchn:intersection-tree
Open

Use IntersectionObserver for side tree in documentation#5832
evnchn wants to merge 2 commits intozauberzeug:mainfrom
evnchn:intersection-tree

Conversation

@evnchn
Copy link
Collaborator

@evnchn evnchn commented Feb 22, 2026

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 IntersectionObserver lying around for the demo. I expanded it to detect left-offscreen elements, and used it for the ui.tree.

I used my spinner.gif for an efficient spinner which does not involve style recals

I 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:

  • Before PR: 27.6kB
  • After PR: 18.3kB

LCP:

  • Before PR: 2.53s
  • After PR: 2.07s

Note: Would have more improvement in LCP in real life considering it less time to download the page after the PR.

Progress

  • I chose a meaningful title that completes the sentence: "If applied, this PR will..."
  • The implementation is complete.
  • If this PR addresses a security issue, it has been coordinated via the security advisory process.
  • Pytests have been added (or are not necessary).
  • Documentation has been added (or is not necessary).

@evnchn evnchn requested a review from rodja February 22, 2026 14:11
@evnchn evnchn added the documentation Type/scope: Documentation, examples and website label Feb 22, 2026
@evnchn
Copy link
Collaborator Author

evnchn commented Feb 22, 2026

Note

This comment was generated by Claude Code acting on behalf of @evnchn.

Independent LCP Benchmark

I ran an automated A/B benchmark comparing main vs this branch using Playwright + Chrome DevTools Protocol with low-end mobile simulation:

  • CPU: 4x slowdown
  • Network: 150ms latency, 400 KB/s down, 100 KB/s up
  • Method: Interleaved A/B (server restarted between branch switches to cancel drift), 3 warmup runs per server start, 12 measured runs per variant per page
  • LCP capture: PerformanceObserver for largest-contentful-paint, finalized via synthetic keyboard input per spec

Results

Metric /documentation /documentation/section_action_events
main this PR main this PR
LCP median 2012 ms 1964 ms (-2.4%) 2772 ms 2432 ms (-12.3%)
LCP trimmed mean 2011 ms 1966 ms (-2.2%) 2837 ms 2463 ms (-13.2%)
LCP p25 1992 ms 1956 ms 2752 ms 2420 ms
LCP p75 2036 ms 1984 ms 3072 ms 2612 ms
LCP stddev 34 ms 33 ms 152 ms 95 ms
HTML decoded size 160,158 B 109,035 B (-31.9%) 300,162 B 248,359 B (-17.3%)
Total transfer 674,414 B 677,920 B (+0.5%) 727,639 B 729,867 B (+0.3%)

n=12 per cell. Trimmed mean drops top & bottom 20%.

TL;DR

  • Detail pages see a clear LCP win: ~13% faster (2837 → 2463 ms). The ~52 KB of deferred tree data meaningfully accelerates initial render on content-heavy pages. Variance also dropped (stddev 152 → 95 ms), so the page is more consistently fast.
  • Landing page sees a marginal ~2% improvement — real but within noise at this sample size.
  • HTML payload is substantially smaller (-32% and -17%), but total transfer is unchanged because the tree data still arrives later via WebSocket. The benefit is strictly about unblocking the browser's HTML parser so it can paint main content sooner.
  • These results corroborate the PR author's findings (18.3 KB vs 27.6 KB page size, 2.07s vs 2.53s LCP on a detail page).
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);
});

@falkoschindler falkoschindler added the review Status: PR is open and needs review label Feb 23, 2026
@falkoschindler falkoschindler added this to the 3.10 milestone Feb 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Type/scope: Documentation, examples and website review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants