Skip to content

perf: stylesheet-aware snapshot (PERF-5) + cache/bloom/bg reuse - #492

Draft
thelabcorner wants to merge 1 commit into
zumerlab:mainfrom
thelabcorner:perf/pr
Draft

perf: stylesheet-aware snapshot (PERF-5) + cache/bloom/bg reuse#492
thelabcorner wants to merge 1 commit into
zumerlab:mainfrom
thelabcorner:perf/pr

Conversation

@thelabcorner

@thelabcorner thelabcorner commented Sep 1, 2026

Copy link
Copy Markdown

perf: stylesheet-aware snapshot + cache/bloom reuse

Hi Juan, thanks for Snapdom and for all the work on v3! Wanted to share a perf pass I’ve been testing on 2.24.x in case it’s useful. I saw v3 is adding stylesheet-aware scanning and auto memoization, so happy to retarget this there if you prefer.

Why this PR

snapdom.toRaw() spends most of its snapshot phase reading ~370 computed props per node with getPropertyValue. Most of those are just browser defaults. On same-process snapdom.toRaw() vs upstream/main (2dc5348):

  • huge repetitive 6521 nodes: 3563ms to 599ms (5.9x)
  • diverse 1200 nodes: 332ms to 182ms (1.82x, BENCH-DIVERSE 2.04x)
  • window.__SNAPDOM_FULL_PROPS=1 vs allow-list: 0px diff

What changed

All on one hot path (src/utils/css.js and src/modules/styles.js / background.js / pseudo.js):

  • Allow-list (PERF-5): scans document.styleSheets plus @import, adoptedStyleSheets and shadow roots once per epoch, expands shorthands to longhands and seeds with inline styles top-down. snapshotComputedStyleFull then iterates 137 props instead of 370. MODULE_REQUIRED_PROPS plus a UA diff for 19 tags keeps pre, th, table and others correct. window.__SNAPDOM_FULL_PROPS forces a full read for verification.
  • Cache reuse: getStyle now uses split WeakMap caches for null and pseudo, bridged to cache.computedStyle and cleared on bumpEpoch so it does not return stale values after a mutation. emptyStyle is frozen. background.js reuses the snapshot with getCachedSnapshot instead of 24k extra reads.
  • Bloom for pseudos: pseudo.js collects selectors for ::before, ::after and ::first-letter including @import and shadow, and skips getComputedStyle when matches says there is nothing to do.

No new options, no scratch or .idea, based on main and rebased on current upstream/main. The whitespace diff that shows up is just the functional if (!allow) guard (52 lines).

Tests

npx vitest run --browser.headless gives 110 passed, 1 failed, 3 skipped. The one failure is d489-reconcile-transform at 2.82 percent, and it also fails on a clean upstream/main stash. Added a small guard test __tests__/utils.css.splitcache.test.js for the epoch fix.

Note on AI

I used AI (Muse Spark via OpenCode) to help iterate, measure and draft. All benchmarks are same-process medians with ranges checked, and the 0px check is in the repo so you can rerun it.

Really appreciate your work on this project. Happy to split this into separate PRs if you would like, just let me know. Thanks!

Single coherent perf pass on the snapshot hot path — 1.82x diverse /
5.9x huge vs upstream/main (neutral snapdom.toRaw()), 0px diff via
window.__SNAPDOM_FULL_PROPS (allow 137 vs 370 props).

- css: split getStyle caches (WeakMap) + epoch invalidation via
  bumpEpoch() -> _invalidateSplitCaches(), frozen emptyStyle (§6/§8)
- styles: allow-list scans author sheets + @import + adopted +
  shadow, shorthands->longhands, inline-seed top-down, UA diff 19 tags,
  A2 clone-before-mutate, B2 SVG early-return, C3/C8 hoists (§4)
- background: reuse snapshot via getCachedSnapshot (§6)
- pseudo: bloom filter for ::before/::after/::first-letter, now covers
  @import + shadow same as allow-list (§8)
- tests: 110 passed / 1 failed / 3 skipped (d489 2.82% fails on clean
  upstream stashed, isolation proof), guard __tests__/utils.css.splitcache

Each intermediate was 110/1 green; squash for one big PR. Happy to split
into 4 PRs on request. Base: main (26/26 recent merges -> main). Rebased
on upstream/main, no scratch/.idea, no whitespace churn.
@tinchox5

tinchox5 commented Sep 1, 2026

Copy link
Copy Markdown
Member

Hi Jackson, thanks a lot for this, and for the care that went into writing it up. The
measurements, the __SNAPDOM_FULL_PROPS escape hatch, the upfront note about the AI
assistance: all of that made it much easier to review.

I took it to a separate checkout and tested it in a few scenarios beyond the test suite.
The suite passes identically on both branches (same 3 pre-existing failures), but capture
fidelity does change in some common cases:

  • Cross-origin stylesheets. The allow-list is built from sheet.cssRules, which throws
    for any CSS served from another origin (Bootstrap or Tailwind from a CDN, Font Awesome,
    Google Fonts). With a stylesheet served from a second port, letter-spacing,
    text-transform, font-style and text-indent all disappear from the capture.
  • The UA diff never runs. getDefaultStyleForTag is not imported in styles.js, so the
    first iteration throws a ReferenceError and the outer try/catch skips the loop (eslint
    flags it as no-undef, so npm test stops at lint). On a page with no author CSS:
    <pre> loses white-space: pre, <th> loses bold and center, <em> loses italic,
    <ol> loses list-style-type: decimal.
  • The structural snapshot cache. It keys on ancestors + tag + class + inline style,
    which does not imply the same computed style. li:nth-child(even) rules vanish (all four
    li collapse onto one class), and two sibling .card divs with different content
    collapse onto the first one's height.

I also measured where the speed comes from, on a repetitive 1950 node DOM, median of 7 runs:

variant median
main 458 ms
this PR 69 ms
structural cache off 160 ms
structural cache + allow-list off 469 ms

So the win is essentially all from those two mechanisms. The rest of the pass (hoisted
arrays, canonical prop order, cache reuse, walk fusion, pseudo bloom filter) lands at roughly
zero on this shape, which is useful to know on its own and something I would not have
measured without your PR.

The direction is right, and it is the same one v3 takes: v3 already ships stylesheet-aware
scanning (styleScan) plus automatic memoization, with the cross-origin fallback and the
per-element geometry handled. That is why I would rather not land this on 2.x, where it
trades fidelity for speed on very common page shapes.

Genuinely, thank you. I would really like to have you back once v3 is out: the hot path
there is different enough that a perf pass on top of it is worth a lot more than one on 2.x,
and you clearly know how to measure. Happy to point you at what is still open when the time
comes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants