Repository navigation
perf: share Link stylesheets across component mounts - #621
Merged
Mohamed Mansour (mohamedmansour) merged 3 commits intoOct 29, 2026
Merged
Mohamed Mansour (mohamedmansour) merged 3 commits into
Mohamed Mansour (mohamedmansour) merged 3 commits into
Conversation
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>
muhammadhaider-msft
approved these changes
Oct 29, 2026
Mohamed Mansour (mohamedmansour)
requested review from
Bang Lee (Qusic),
atul (atulkatti),
mcritzjam and
mddinbox
October 29, 2026 20:19
Bang Lee (Qusic)
previously approved these changes
Oct 29, 2026
mddinbox
reviewed
Oct 29, 2026
mddinbox
left a comment
Contributor
There was a problem hiding this comment.
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>
Mohamed Mansour (mohamedmansour)
requested review from
Bang Lee (Qusic),
mddinbox and
muhammadhaider-msft
October 29, 2026 21:33
atul (atulkatti)
approved these changes
Oct 29, 2026
Mohamed Mansour (mohamedmansour)
merged commit Oct 29, 2026
6ceba63
into
main
68 of 70 checks passed
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, aloadwait, a re-read oflink.sheet.cssRules, and a separately parsedCSSStyleSheetadopted 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.
<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.<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.TargetStyleStaterecord per target replaces five WeakMaps, and resource IDs stay plain strings until a second ID arrives. WhenTemplateElementattached the Shadow root in the current mount, adoption writesadoptedStyleSheetswithout reading it. A read makes Blink build and retain a wrapper for the root's lifetime. The publicinstallComponentStylesstill reads, so sheets an author adopted are kept.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
installComponentStylesandprepareComponentStyleLinks. Memory isperformance.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:
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.
<link>per rootCommerce 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:
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
installComponentStyleskeep the reading path: 100 ms / 294 KB for 1,000 rows and 266 ms / 747 KB at scale.link-styles.tsgrows from 1,245 to 1,561 lines.Tests
component-link-sharinge2e 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-assetse2e: 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}.cargo xtask checkpasses. Framework e2e: 391 passed. component-assets: 5 of 5. Router: 42 passed (4 pre-existingfixme).specs/hydration.mdand the framework README describe the new Link behavior.