Skip to content

perf: share Link stylesheets across component mounts - #621

Merged
Mohamed Mansour (mohamedmansour) merged 3 commits into
mainfrom
mohamedmansour-shared-link-stylesheet-fix
Oct 29, 2026
Merged

Mohamed Mansour (mohamedmansour) merged 3 commits into
mainfrom
mohamedmansour-shared-link-stylesheet-fix

Conversation

@mohamedmansour

Copy link
Copy Markdown
Contributor

Every client mount of a component that uses Link CSS paid for its own copy of the stylesheet: a new <link>, a guard <style> that hides the host, a load wait, a re-read of link.sheet.cssRules, and a separately parsed CSSStyleSheet adopted by that root alone. Router soft navigations, opened lazy regions and any client-created component therefore paid N parses, N guards and N load waits for N instances. Template-owned links already cache their sheets per template; component catalog links had no cross-instance cache.

Approach

Component Link stylesheets are now shared per Document and resolved href, and promoted on their second use. What users see does not change.

  • First use stays native. The first newly attached Shadow root to mount a stylesheet keeps its native <link> and only records whether that load is authorized under the existing fix: prevent dynamic component stylesheet flashes #454 rule (unredirected, not served by a service worker, successful). A component used once pays no CSSOM serialization, second parse or duplicate contents.
  • Second use promotes once. The next root builds one constructable sheet from the CSSOM of any loaded, authorized link for that href and caches it. Every later root adopts the cached sheet synchronously, before its template is appended, with no link, guard or load wait. This covers a later root in the same pass, a reopened lazy region, and a cold load that settles.
  • No visible switch. A guarded root that is still hidden may switch to the shared sheet before it is revealed. A visible root never switches.
  • One cascade per component. Adopted sheets cascade after tree styles, so roots whose cascade includes other author styles promote on first use instead: templates with their own <style> or <link>, server-rendered or reused roots, and Light descendants. All instances of such a component adopt the same sheet, and a template-link reset that clears the root can no longer drop a native catalog link.
  • Authorization is remembered per Document and href, failures included. Memory-cache hits add no resource timing entry, so a repeated check could not pass.
  • Less per-root bookkeeping. One TargetStyleState record per target replaces five WeakMaps, and resource IDs stay plain strings until a second ID arrives. When TemplateElement attached the Shadow root in the current mount, adoption writes adoptedStyleSheets without reading it. A read makes Blink build and retain a wrapper for the root's lifetime. The public installComponentStyles still reads, so sheets an author adopted are kept.
  • Smaller wins: URL rewriting is skipped when the CSS has no url(, image-set( or escape, and resolved hrefs are cached per base URL.

Cold loads, unpromotable stylesheets and failures keep the existing native-link, guard and error handling.

Performance

Edge 156 with 4x CPU throttling, measured with local Playwright harnesses that bundle the framework's real installComponentStyles and prepareComponentStyleLinks. Memory is performance.measureUserAgentSpecificMemory() after GC, minus a no-CSS page with the same roots. "This PR" is the path component mounts take.

N instances of one card component (~3 KB of CSS), time to styled and CSS memory, medians of 10 runs:

Rows main This PR One shared sheet (ideal)
20 107 ms, 170 KB 18 ms, 92 KB 8 ms, 7 KB
200 409 ms, 562 KB 32 ms, 117 KB 17 ms, 18 KB
1,000 1,738 ms, 2,227 KB 88 ms, 193 KB 69 ms, 30 KB
Reopen 1,000 473 ms 71 ms 65 ms

200 component types (~1 MB of CSS), 1,199 Shadow roots (a 1,000-item list plus one instance of each other type), medians of 5 runs. No component's rules leak into another's shadow root.

Variant Time to styled CSS memory
main 3,099 ms 3,105 KB
This PR 261 ms 602 KB
Native <link> per root 254 ms 613 KB
One sheet per stylesheet (ideal) 226 ms 551 KB

Commerce app, Link CSS. Two 30-sample runs with the server ports swapped, averaged to cancel a measured 5-17 ms per-port bias. Mean +/- standard error:

Metric main This PR Change
Soft nav home -> product: soft FCP and LCP 272.5 ms 257.5 ms -15.0 +/- 5.2
Soft nav home -> product: main-thread busy 311.4 ms 296.0 ms -15.4 +/- 3.2
Soft nav product -> product: soft FCP 164.0 ms 160.0 ms -3.9 +/- 2.8
Full loads of home and product: FCP, LCP, hydration each within 1 SE

The same crossover with Module CSS shows no change on full loads. The soft-nav gain comes from no longer promoting four single-use components into four separate sheets: all 49 roots now keep native links.

Review notes

  • Bundle size: +1.0 KB gzip (+0.9 KB brotli) in the shared framework chunk, about 4% of commerce's main chunk.
  • CSS loaded by the server-rendered page gets no gain. Commerce's repeated product cards stay on native links, as on main: their only timing entry predates the mount, so the fix: prevent dynamic component stylesheet flashes #454 rule cannot authorize them. I did not relax that rule.
  • Stable responses are assumed. Sharing assumes a stylesheet URL returns the same CSS for the Document's lifetime, which holds for compiler-emitted assets. Shared sheets and authorization results live as long as the Document's style catalog.
  • First-use roots remain slightly above native at scale (261 vs 254 ms), within run-to-run variance.
  • Direct callers of the public installComponentStyles keep the reading path: 100 ms / 294 KB for 1,000 rows and 266 ms / 747 KB at scale.
  • link-styles.ts grows from 1,245 to 1,561 lines.

Tests

  • New component-link-sharing e2e fixture (7 tests): one native link then one shared sheet across a mount pass, sharing after the preload is released, second use after a lone first mount, promotion from a later root after the first is gone, guarded cold mounts, unpromotable stylesheets, and eager promotion for components with template styles. The sharing tests fail on main, where every instance gets its own link and sheet.
  • component-assets e2e: a single lazy panel now pins {adopted: 0, disabled: false, links: 1} (first use stays native), and a new reopen case pins {adopted: 1, disabled: true, links: 1}.
  • Unit tests for sharing, eager promotion, write-only adoption on fresh roots, preserving existing adopted sheets, remembered failed authorization, base URL changes, template author-style detection and the URL-rewrite fast path.
  • cargo xtask check passes. Framework e2e: 391 passed. component-assets: 5 of 5. Router: 42 passed (4 pre-existing fixme).

specs/hydration.md and the framework README describe the new Link behavior.

Every client-created Shadow root using Link CSS appended its own <link>,
a guard <style>, waited for that link to load, then re-read the CSSOM
into a separate constructable sheet. N instances of one component paid
for N parses, N sheets, N guards, N links, and N load waits. Once the
speculative preload was released, later mounts could not be authorized
at all, because memory cache hits add no resource timing entry, so they
fell back to per-instance native links.

Track each compiler-owned stylesheet per Document and resolved URL. The
first root keeps its native link and records whether that load is
authorized, so a component used once pays no CSSOM serialization or
second parse. The next root promotes the authorized CSSOM once, and
every later root adopts the shared sheet before its template is
appended, with no link, guard, or load wait. Memory-cached links expose
their sheet on insertion, so after preparation a whole mount pass shares
one sheet synchronously. A guarded root switches only while hidden and a
visible root never switches. Roots whose template adds its own <style>
or <link>, and Light descendants, keep native links so every instance of
a component keeps one cascade order. Concurrent cold mounts authorize
against the first use's timing window, whichever load settles first.

Also skip the URL rewrite scan for rules without a url()/image-set()
candidate or escape, keep no marker cache for fresh client roots, and
store a target's single completed closure without a Set.

Edge 156, 4x CPU, harness medians (before -> after):
- bench.mjs, 1,000 rows: first open 1,738 -> 99 ms, reopen 473 -> 79 ms,
  CSS memory 2.23 MB -> 417 KB, 1,000 sheets -> 1
- bench.mjs, 200 rows: 409 -> 33 ms; 20 rows: 107 -> 17 ms
- scale.mjs, 200 types / 1,199 roots: 3,099 -> ~288 ms,
  3.18 MB -> 862 KB (native <link> ~258 ms / 612 KB)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Roots that adopt a shared Link sheet still paid per-root framework
bookkeeping: four WeakMap entries, a Set of installed IDs, and the
adoptedStyleSheets wrapper Blink materializes and keeps on the first
read of the list.

- Keep one TargetStyleState record per Document or ShadowRoot. Installed
  and completed IDs stay a plain string until a second ID arrives.
- TemplateElement tells the installer when it attached the ShadowRoot in
  the current mount (COMPONENT_ROOT_FRESH). Synchronous adoption then
  writes adoptedStyleSheets without reading it. The guarded async path
  and the public installComponentStyles still read and preserve it.
- Promote on first use (COMPONENT_LINKS_EAGER) when a root's cascade has
  other author styles: templates with their own <style> or <link>,
  server-rendered or reused roots, and Light descendants. All instances
  of such a component adopt the same sheet, and a reset that clears the
  root's children can no longer drop a native catalog link.
- Remember a failed authorization per Document and href, so later roots
  skip a resource timing lookup that cannot pass for the cached response.
- Cache resolved catalog hrefs per Document base URL, and key speculative
  preloads by link.href, which is already absolute.

Edge 156, 4x CPU throttling. Memory is after GC, minus a no-CSS page.
"Before" is the previous commit; framework mounts take the fresh path.

| Harness, styled / CSS memory  | Before          | After           |
|-------------------------------|-----------------|-----------------|
| bench, 1,000 rows, public API | 99 ms / 417 KB  | 100 ms / 294 KB |
| bench, 1,000 rows, fresh root | 99 ms / 417 KB  | 88 ms / 193 KB  |
| scale, 1,199 roots, public    | 278-325 / 862KB | 266 ms / 747 KB |
| scale, 1,199 roots, fresh     | 278-325 / 862KB | 261 ms / 602 KB |
| scale, native <link> per root | 237-262 / 612KB | 254 ms / 613 KB |

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@mddinbox mddinbox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Target: aab3350...dbed0e8 | Coverage: complete; CI: PR Checks, Test, Build (Linux/macOS/Windows), Lint, Docs, CodeQL — success
Challenge: 1 confirmed, 0 revised | Compatibility: no API change; memory retention only
Action: COMMENT | The inline finding identifies one medium-severity lifecycle retention issue.

The per-Document component link record kept the first root's loaded
<link> as its promotion source. The record lives as long as the
Document, and only a later root using the same stylesheet cleared the
source. So a component mounted once and then removed, with no later
root needing its stylesheet, stayed reachable with its shadow root,
host and state until the page unloaded.

Hold the source through a WeakRef. A collected, removed or disabled
source already falls back to native links and lets a later load
replace it, so mounting behavior is unchanged.

Add an e2e regression test: mount one card, keep only a WeakRef to it,
remove it, force garbage collection over CDP, and expect the host to
be collected. It fails without the fix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mohamedmansour
Mohamed Mansour (mohamedmansour) merged commit 6ceba63 into main Oct 29, 2026
68 of 70 checks passed
@mohamedmansour
Mohamed Mansour (mohamedmansour) deleted the mohamedmansour-shared-link-stylesheet-fix branch October 29, 2026 22:21
Mohamed Mansour (mohamedmansour) added a commit that referenced this pull request Oct 31, 2026
## Release

Bumps WebUI to `3.0.5`.

Previous release tag: `v3.0.4`

## Changes since `v3.0.4`

Features:

- Press supports reusable, per-page outlines with responsive navigation
(feat: redesign the docs site and add reusable page outlines #613 by
@mohamedmansour).
- Press tabs can synchronize categories and persist selections;
package-manager preferences now follow readers across documentation
pages and reloads (feat: persist package-manager tabs and simplify
documentation #620 by @mohamedmansour).
- Press adds independent theme families, shared appearance preferences,
System/Light/Dark controls, and extensible header regions with a
cacheable pre-paint initializer (feat: add Press theme families and
extensible appearance controls #623 by @mohamedmansour).

Fixes:

- Hydrate conditional and repeated SSR ranges in place, avoiding
unnecessary custom-element disconnect/reconnect cycles and correcting
trailing formatting whitespace (fix: hydrate SSR block ranges in place
#619 by @mohamedmansour).
- Preserve pending development-server file events by retargeting the
existing watcher instead of replacing it after rebuilds (fix: retarget
the dev-server watcher instead of replacing it #622 by @mohamedmansour).
- Remove unapproved transitive TLS/WebSocket crypto dependencies while
retaining HTTP/2 support. The commerce example now serves HTTP/1.1 and
h2c behind a TLS-terminating ingress; its direct TLS flags are removed
(fix: remove unapproved crypto dependencies #596 by @mohamedmansour).

Docs:

- Redesign the documentation homepage and reading experience, organize
guides into Start/Learn/Build/Reference with legacy redirects, and
simplify installation and package-manager instructions (feat: redesign
the docs site and add reusable page outlines #613 by @mohamedmansour,
chore: clarify the homepage and organize documentation #617 by
@mohamedmansour, feat: persist package-manager tabs and simplify
documentation #620 by @mohamedmansour).
- Refresh benchmark previews, methodology, and downloadable results
around the uniform 15-case official WebUI 3.0.4 campaign, including
provenance and unavailable memory-sample disclosures (docs: support the
uniform 15-case official benchmark campaign #614 by @mohamedmansour).
- Update source-build requirements, Rust writer guidance, and Press
appearance configuration and deployment documentation (chore: upgrade
Rust to 1.99.1 #612 by @mohamedmansour, perf: reduce renderer output
allocations #615 by @mohamedmansour, feat: add Press theme families and
extensible appearance controls #623 by @mohamedmansour).

Maintenance:

- Reduce renderer allocation churn by writing escaped values directly
into output buffers, reusing attribute buffers, and borrowing quoted
condition text (perf: reduce renderer output allocations #615 by
@mohamedmansour).
- Share eligible Link stylesheets across component mounts within a
document, preserving native first-use loading and avoiding repeated
parsing on subsequent uses (perf: share Link stylesheets across
component mounts #621 by @mohamedmansour).
- Align development and release toolchains on Rust 1.99.1; consumers
building from source now require Rust 1.99.1 or newer (chore: upgrade
Rust to 1.99.1 #612 by @mohamedmansour).

## Validation

- `cargo xtask check` passed, including lint, protocol drift, dependency
audit, tests, native/WASM builds, examples, benchmark validation, and
docs.
- Used `WEBUI_PRESS_BINARY_PATH` to select the freshly built workspace
Press 3.0.5 binary instead of an older global installation.
- Refreshed the standalone Tauri lockfile with Cargo; all 45 changed
files contain only `3.0.4` to `3.0.5` substitutions.
Mohamed Mansour (mohamedmansour) added a commit that referenced this pull request Oct 31, 2026
Keeps this branch's style-runtime boundary: the core calls the installed
style runtime's `install(tag, target, host, freshShadow, meta)`, and
`styles.js` maps that to #621's mounted-install flags. #621's fixture
and tests use this branch's entry points and v3 template metadata.

The component-assets startup budget is re-baselined to the merged build
(+644 B brotli startup): #621's shared-sheet promotion and this
branch's asset-base resolution and low-priority stylesheet preparation
both land in styles.js.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 90a93005-7b2a-4ea4-bce5-902f79da872d
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.

5 participants