Skip to content

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

Draft
thelabcorner wants to merge 2 commits into
zumerlab:mainfrom
thelabcorner:perf/pr
Draft

perf: stylesheet-aware snapshot (PERF-5) + cache/bloom/bg reuse#492
thelabcorner wants to merge 2 commits 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.

F1 cross-origin fallback to full read per doc. F2 adaptive UA probe reusing snapshot declaration, no extra gcs. F3 structural sharing removed. Round-2: shorthand-free allow set, capture-scoped shadow scan, container-rule fingerprint dedup, author-vars gate for CSSVar, flex-parent memo, cached getStyle for IMG and placeholder. Guards: juan-regressions (4), residual (8), CSSVar skips (2), all mutation-tested. Suite: 896 passed / 1 failed (d489 environmental, also fails on upstream/main) / 3 skipped.
@thelabcorner

Copy link
Copy Markdown
Author

Hi Juan,
Thanks for the review, and especially for pulling it into a separate checkout and testing past the suite.
I opened this PR to share my findings, so I appreciate the close read. You know this codebase better than I do, and all three of your fidelity calls were right!

Where the branch is now:

Cross-origin stylesheets. If any sheet throws on cssRules, that document falls back to the full computed-style read, cached per epoch. There's a regression test that mocks a denying sheet and checks letter-spacing, text-transform, font-style and text-indent come out identical to the full-read control.

UA diff. The missing import is just my mistake. (Gotta love agents! Haha.) I had the agent running vitest directly and never hit the lint gate, so I never saw the no-undef, which also means the safeguard I described in the PR body was dead code the whole time. (lol) -- Fixed, and I replaced the sandbox probe with one that reuses the computed declaration the snapshot already holds, so it costs no extra getComputedStyle calls. Guarded for pre, th, em and ol with no author CSS.

Removed the structural cache. Your nth-child case and the two .card divs are both right, and I don't think that key design can be rescued, so I just got rid of it aswell.

Your breakdown matches what I see, and thanks for publishing it! Knowing the micro-opts land at roughly zero on that shape is useful on its own. 😊

On landing it, point taken, (and I somewhat anticipated it! haha!)
Since v3 already has stylesheet-aware scanning plus memoization with the cross-origin fallback handled, I would rather this not sit against 2.x trading fidelity/universality for speed!! Happy for you to close it. If there's a subset you want to consider, I can certainly narrow it to that, but no hard feelings either way!

One note on the latest commit: I just pushed my current work to the PR branch just so it is there, even if you close the PR. I have been working on this for a little bit in my codebase & decided to share it, so others doing perf passes can see what I found or reuse the code if they want to. I will personally keep running this v2-based branch in my own codebase until v3 is stable and I have had time to update and test everything against it!

Context on why I went at this: I'm building PresGEN, a browser-first presentation tool, and I have a LOT of capture-heavy paths using snapDOM, so snapshot time pays me back directly! (I'll also admit I just like this kind of work! Instrumenting a hot path and watching a counter fall, can't beat the feeling!!!)

Once v3 settles, I'd be more than glad to do a perf pass on top of it on my own time!
Just point me at what's open and I'll come with counters! 😊

Disclosure: as noted on the initial PR, I work with AI coding agents. The missing import getting past me was just me not watching my agent closely enough, so I have earned the lint gate lesson. This time I had separate agents verify each claim in this reply against the diff and the test output before I wrote it up. The fixes above are pushed as the latest commit on this PR branch, so they are there if you want to look, and fine to ignore if you would rather close it.

Branch state, for reference:

Full suite: 896 passed, 1 failed, 3 skipped. The failure (d489-reconcile-transform) still fails here. Style-read counts on a ~900 node bench DOM through the public pipeline, same DOM both sides: getPropertyValue 384,449 to 153,641, getComputedStyle 3,969 to 2,366.
Bench timing (perf.bench.dom huge, same run): median 687ms (min 611, p90 2396). This machine is noisy so I treat the deterministic read counts as the signal, not the wall clock.
Guards: stylesheet-scan suite (4 tests), CSSOM/shadow residual suite (8), CSSVar skip guards (2), each mutation tested.

Thanks again, I've gotten more use out of this project than I can put into words!
Jackson

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