Theme updates: performance baseline
A radius edit went from 54 CSS-variable writes to 1. This note records the measurements, their limits, and the unit tests that now check which variables each operation returns.
Measured 2026-08-06, before any change to lib/theme/.
Commit: ae009eb.
Recorded ahead of the Active Theme refactor (see CONTEXT.md,
docs/adr/0002-clamp-invalid-theme-values-silently.md) so that
"this must not get slower" is checkable rather than argued.
Method
Dev server (npm run dev), Chromium via Playwright, /themes/editor.
CSSStyleDeclaration.prototype.setProperty wrapped to count calls and record
variable names. Counter reset immediately before each interaction, read 300ms
after — long enough for the React commit and effects, shorter than the 500ms
persistence debounce, so localStorage and URL-encoding work is excluded.
Call counts are app logic and identical in dev and production. Wall-clock timings are dev-mode and indicative only.
Results
| Interaction | setProperty calls | Unique vars | Time in setProperty |
|---|---|---|---|
| One single-field edit (radius, +1 step) | 54 | 54 | 0.10 ms |
| Colour Mode toggle (light → dark) | 52 | 52 | 0.10 ms |
| Colour Mode toggle (dark → light) | 52 | 52 | — |
Breakdown of the 52 on a Colour Mode toggle
| Group | Count | Changes between modes? |
|---|---|---|
| Colour vars | 32 | yes |
Shadow vars (--shadow*) | 14 | no |
| Font vars | 3 | no |
--radius, --spacing, --tracking-normal | 3 | no |
20 of the 52 writes on a mode toggle are provably redundant — those values live on the Theme, not on either palette, so they are rewritten with identical values.
For a single-field edit the redundancy is worse: 53 of 54 writes are unchanged values.
After the Active Theme seam (measured 2026-08-08)
A single "single-field edit" number turned out not to exist. Different fields
own different numbers of variables, so the table is per-operation. All fourteen
--shadow* variables derive from the same five numbers plus a colour, so a
shadow edit costs 14 and always will; layout is structural and costs 0.
| Operation | Variable map | Before | Measured after |
|---|---|---|---|
editRadius, editColour, editFont | 1 | 54 | 1 ✓ |
editFonts | 3 | 54 | — |
editShadow | 14 | 54 | — |
editLayout | 0 | 54 | — |
switchColourMode | 32 | 52 | 31 ✓ |
applyPreset / loadTheme / resetToDefault | 52 | 52 | 47 ✓ |
| Any operation that changes nothing | — | 54 | 0 |
Two numbers come in under their map size because writes are filtered against
what is already on the document (changedVars): the mode toggle found one
colour identical across both palettes, and the preset swap found five values
shared between the outgoing and incoming Themes. Filtering applies to the
write only — state and identity always update, so re-picking the Preset
already applied still resets identity per ADR-0001.
The filter is also what keeps page load at one full apply. DarkModeToggle
runs its own mount effect that settles the Colour Mode a second time; without
filtering that would have added 32 writes to every page load, which the
interaction-only measurements above would never have caught.
Where this is enforced
tests/active-theme.test.ts asserts the Variable map column directly —
each operation returns a Record<string, string>, and the tests assert its
size and contents. That runs in npm test with no browser.
The browser method below is now a once-per-refactor sanity check, not the enforcement mechanism. It is still the only thing that can catch a regression caused by something other than call volume — a write moved to a worse point in the frame, or a second caller re-applying behind the seam's back.
Re-running the browser check
Same method as the baseline. The instrument is ~20 lines injected into the
page: wrap setProperty, count calls, reset before the interaction, read after
300ms. Filter out variables the theme system does not own — Base UI's popover
positioning (--anchor-width, --transform-origin, and friends) writes a
dozen of its own whenever a dropdown is open, and they are not ours.
A regression shows up as a count that went up, which needs no timing precision to detect.
Caveat
setProperty call count is a proxy for style-invalidation work, not a frame
timing. It is the right proxy here because the change is purely "make fewer
calls" — but it will not catch a regression caused by something other than call
volume, such as a write moved to a worse point in the frame. If frame timing
matters later, that needs a separate profile against a production build.