Files
james.maes ddec18d8ad test(acceptance): performance area with large-table, dashboard and long-process budgets (#710)
- fixture/PerformanceFixtures.java (define + prime, wired into
  AcceptanceSampleServer):
  - prfWide: a read-only table of 10,000 rows and 40 columns of mixed
    types, generated by one INSERT ... SELECT FROM SYSTEM_RANGE. Every
    value is a function of id and column. prime() keeps an intact copy
    (row count, a checksum and the last name) instead of re-inserting
    400,000 values before every test of every area. It rebuilds
    anything else.
  - prfDashboard: 24 widgets (6 statistics, 6 bar, 4 line, 4 pie, 4
    tables of 100 rows) with deterministic data.
  - prfLongRun: an async job that walks all rows in 40 pages (about
    10 s), reporting progress. It logs rows, total and start/end epoch
    millis to prf_run_log.
- matrix/performance.json: PRF-001..005 (required). specs/performance
  measure wall time from the action to the exact expected content,
  record it as a timing annotation, and check it against
  specs/performance/budgets.ts. Each spec also checks structure: one
  page in the DOM (25 or 250 rows x 40 cells), skip/limit 250 on the
  query, each widget fetched exactly once, status polls never overlap
  and are spaced at least 1.5 s apart after each answer.
- Budgets are about 8x the idle times. A full four-project run on a
  busy machine measured up to 4x, and the first, tighter budgets failed
  that way twice in Firefox (next page 4,191 ms, dashboard 8,841 ms).
- docs/acceptance/performance.md: bundle and runtime budgets,
  measurements, fixes and open observations. README lists the new
  commands.

Full run (chromium, firefox, webkit, mobile) with the fixture: 1004
passed, 2 failed (those two Firefox budget breaches, before the budgets
were raised). Performance area rerun afterwards: 15/15 in chromium,
firefox and webkit.
2026-09-25 18:09:51 -05:00

5.1 KiB
Raw Permalink Blame History

Performance budgets (QRun-IO/qqq#710)

Two automated checks. A breach fails CI. To fix one, make the change smaller or faster. If a budget really has to rise, put the new measurement and the reason in the commit.

Check What it measures Budget file Runs in
Bundle budget First-load JS per route of the static export, total JS, largest chunk, CSS perf-budget.json ci.yml job "Bundle budget (static export)": pnpm build:export && pnpm perf:budget
Runtime budgets Large table, heavy dashboard, long process in the production export against the real sample backend tests/acceptance/specs/performance/budgets.ts acceptance.yml: matrix rows PRF-001 to PRF-005 in every browser
pnpm build:export && pnpm perf:budget          # bundle; report in test-results/performance/bundle.json
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit node scripts/acceptance.mjs --skip-build specs/performance

Bundle

First-load JS for a route is every script and stylesheet that the route's exported HTML loads before hydration, gzip level 9. Next 16 no longer prints these sizes, so the script measures them from out/. _ stands for a dynamic segment: /app/_ is /app/[slug] (app home, record query, process, report) and /app/_/_ is the record view.

Route Before (KB gzip) 1.0 (KB gzip) Budget
/, /login, /callback, /token, /404 218–219 218–219 230
/app, /app/developer, /app/search, /app/_/key, /app/_/dev, /app/_/_/dev 309–311 309–311 330
/app/_ (app home, record query, process, report) 562.9 448.2 470
/app/_/_ (record view) 431.6 431.5 455
/app/_/create, /app/_/_/edit, /app/_/_/copy 351–356 351–356 370–375
/app/_/savedView/_ 371.2 371.1 390
Total JS (all chunks) 689.2 in 54 chunks 675.0 in 51 chunks 710
Largest chunk (Recharts + d3, loaded only by chart widgets) 93.8 93.8 100
CSS 16 16 18

Regression fixed: /app/[slug] imported AppHome from the widgets barrel, and that barrel re-exports the chart widgets. Recharts was therefore in the first load of every table and process page, even though WidgetRenderer loads charts lazily. The page now imports its components by module path.

Runtime

Fixture: tests/acceptance/fixture/PerformanceFixtures.java, all synthetic data.

  • prfWide: 10,000 rows, 40 columns of mixed types, read-only.
  • prfDashboard: 24 widgets: 6 statistics, 6 bar, 4 line (60 points) and 4 pie charts, and 4 tables of 100 rows.
  • prfLongRun: a job that walks all 10,000 rows in 40 pages with a 250 ms pause per page.

Each test measures wall time from the user action until the exact expected content is on screen, and records it as a timing annotation in test-results/acceptance/report.json.

Row Measurement Budget (ms) chromium firefox webkit
PRF-001 Open the 10,000-row table until the first 25 rows × 40 columns show 5,000 535 353 602
PRF-002 Page size 25 → 250 (10,000 cells) 10,000 462 665 738
PRF-002 Next page at 250 rows 10,000 636 969 1,188
PRF-002 Last page (page 40) at 250 rows 10,000 612 1,158 1,064
PRF-003 Sort by Name over 10,000 rows 3,000 155 234 299
PRF-003 Filter Count 04 = 0 (40 matches) 3,000 173 157 150
PRF-004 Open the 24-widget dashboard until every widget has loaded 14,000 1,086 1,567 1,760
PRF-005 Job finished until its result screen shows (10.15 s job, 5 status polls) 5,000 415 838 521

These numbers come from an otherwise idle run of the performance area on an Apple M3 Max (macOS, Darwin 27), Playwright 1.58.2. Each budget is about 8× the slowest browser's time. In a full four-project run with other acceptance suites on the same machine, the same steps took up to about 4× longer: Firefox next page 4,191 ms, dashboard 8,841 ms. Retries are 0, so a tighter budget would flake. The budgets catch order-of-magnitude regressions. The structural checks below catch smaller ones, and don't depend on machine load.

The rows also check behavior besides time:

  • Only one page of rows is in the DOM: 25 or 250 rows × 40 cells, not the whole table.
  • The backend is asked for skip/limit 250 on each page.
  • Each dashboard widget's data is requested exactly once.
  • Status polls never overlap, and each poll waits at least 1.5 s (POLL_INITIAL_MILLIS) after the previous answer. The job never gets more polls than its length allows.

Grid rendering fixed for large pages: body rows are memoized. The grid used to re-render all 10,000 cells before the page request was sent, and again for the URL sync. Date cells no longer build an Intl.DateTimeFormat per cell. A 250-row page turn in chromium, from click to rendered rows: 659–669 ms → 518–561 ms.

Observed, not changed here:

  • Grid columns follow the field order of the metadata map, not the section order, and not Material's alphabetical-by-label order. For a 40-column table this looks unordered.
  • The pagination range is not locale-formatted, while the total is ("Showing 9751–10000 of 10,000").