From 5ef1e94c17d7dc6fe2c865f86a1c05925b045bd7 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 29 Oct 2026 01:39:47 -0700 Subject: [PATCH 1/3] perf: share promoted Link stylesheets across component mounts Every client-created Shadow root using Link CSS appended its own , a guard

' }), + true, + ); + assert.equal( + templateMayContainAuthorStyles({ + h: '

', + b: [{ h: '' }, { h: '' }], + }), + true, + ); + }); + + test('returns rules without URL candidates unchanged', () => { + const href = 'https://cdn.example.test/components/card.css'; + const css = '.a{color:rgb(1,2,3);margin:calc(1px + 2px);width:var(--w)}'; + assert.equal(rewriteCssUrls(css, href), css); + assert.equal(rewriteCssUrls('.a{content:"\\2014"}', href), '.a{content:"\\2014"}'); + assert.equal( + rewriteCssUrls('/* url(old.svg) */.a{color:red}', href), + '/* url(old.svg) */.a{color:red}', + ); + }); + + test('still scans mixed-case URL functions and escapes', () => { + const href = 'https://cdn.example.test/components/card.css'; + assert.equal( + rewriteCssUrls('.a{width:calc(1px);--icon:URL(icons/a.svg)}', href), + '.a{width:calc(1px);--icon:URL("https://cdn.example.test/components/icons/a.svg")}', + ); + assert.equal( + rewriteCssUrls('.a{background:-WEBKIT-IMAGE-SET("a.png" 1x)}', href), + undefined, + ); + assert.equal(rewriteCssUrls('.a\\:hover{color:red}', href), undefined); + }); + test('resolves quoted and unquoted URLs against the stylesheet', () => { assert.equal( rewriteCssUrls( diff --git a/packages/webui-framework/src/element/link-styles.ts b/packages/webui-framework/src/element/link-styles.ts index 54fee4dac..8dd859ab5 100644 --- a/packages/webui-framework/src/element/link-styles.ts +++ b/packages/webui-framework/src/element/link-styles.ts @@ -3,6 +3,7 @@ import { templateHtmlMayContainLink, + templateHtmlMayContainStyles, getTemplateStylesheets, type TemplateStylesheetDescriptor, } from '../template-content.js'; @@ -26,8 +27,11 @@ interface TemplateLinkStyleState { interface SpeculativeStylesheetPreload { claimed: boolean; cleanupTimer?: ReturnType; + /** Preload result, recorded synchronously once `ready` settles. */ + failure?: LinkStyleFailure; readonly link: HTMLLinkElement; readonly ready: Promise; + settled: boolean; readonly startedAt: number; } @@ -47,6 +51,31 @@ interface DeferredContentCallbacks { readonly hasAuthorHydrationLifecycle?: boolean; } +/** + * One compiler-owned component stylesheet URL within a Document. + * + * The first root to mount the URL keeps its native link, so a component used + * once pays no CSSOM serialization or second parse. A later root promotes the + * authorized CSSOM of a loaded link once; every root after it adopts the + * shared sheet without a link, guard, or load wait. + */ +interface ComponentLinkRecord { + /** A native load of this URL passed `hasUnredirectedNativeTiming`. */ + authorized: boolean; + /** + * Timing window opened by the first use. Concurrent roots share its fetch, + * so they authorize against it whichever load event settles first. + */ + firstUseStartedAt: number; + sheet?: CSSStyleSheet; + /** Loaded, authorized link that can be promoted without a new load. */ + source?: HTMLLinkElement; + /** Its CSS cannot be represented as a constructable sheet. */ + unpromotable: boolean; + /** Some root has already mounted this URL. */ + used: boolean; +} + const READY = Promise.resolve(); const NO_DESCRIPTORS: readonly TemplateStylesheetDescriptor[] = Object.freeze([]); const NO_LINK_STYLE_STATE: TemplateLinkStyleState = { @@ -60,6 +89,19 @@ const GUARD_CSS = '@layer{:host{transition:none!important;visibility:hidden!important}}'; const templateLinkStyles = new WeakMap(); const activeStyleMounts = new WeakMap void>(); +const authorStyleTemplates = new WeakMap(); +/** + * Per-Document state for compiler-owned component links, keyed by resolved + * stylesheet URL. Component links carry no media or request attributes, so + * the URL is the whole parse input. + * + * Entries describe the Document's memory-cached response and live as long as + * its style catalog, which never removes a resource, so the map is bounded by + * the distinct Link resources the page has mounted. Dropping an entry would + * send the next mount back to a native load that can no longer be authorized: + * memory cache hits add no new resource timing entry. + */ +const componentLinkRecords = new WeakMap>(); let speculativeStylesheetPreloads: | Map | undefined; @@ -131,8 +173,14 @@ export function preloadComponentAssetStyles( claimed: false, link, ready: waitForStylesheetPreload(link, href), + settled: false, startedAt, }; + // Registered first, so every awaiting caller observes the settled state. + void preload.ready.then(failure => { + preload.settled = true; + preload.failure = failure; + }); preloads.set(href, preload); (pending ??= []).push(preload.ready); preload.cleanupTimer = setTimeout(() => { @@ -143,22 +191,53 @@ export function preloadComponentAssetStyles( } /** - * Promote compiler-owned component links after their native loads complete. + * Adopt shared sheets for every component link href a root still needs. + * + * An href qualifies once an earlier root mounted it and its sheet exists or + * can be promoted from that root's loaded, authorized link. Returns false + * without touching the root otherwise; the caller then installs native links. + */ +export function adoptPromotedComponentLinks( + root: ShadowRoot, + hrefs: readonly string[], +): boolean { + const owner = root.ownerDocument; + const records = componentLinkRecords.get(owner); + if (!records || hrefs.length === 0) return false; + const base = owner.baseURI; + const sheets = new Array(hrefs.length); + for (let i = 0; i < hrefs.length; i++) { + const record = records.get(resolveHref(hrefs[i], base)); + const sheet = record && (record.sheet ?? promoteSource(record)); + if (!sheet) return false; + sheets[i] = sheet; + } + return adoptPreparedSheets(root, sheets); +} + +/** + * Settle compiler-owned component links for one client Shadow root. * * Component style links are installed before a client template is wired, so - * they cannot use the staging-root path below. Keep the host guarded while - * the cache-warmed native links become readable, then adopt their CSSOM and - * disable the links just like template-owned styles. + * they cannot use the staging-root path below. The first root to use an href + * keeps its native link and only records whether that load is authorized. + * Later roots promote the authorized CSSOM once and adopt the shared sheet, + * disabling their links. A root is guarded until its native links load and + * never switches representation after it becomes visible. `nativeLinks` + * keeps roots whose cascade includes other author styles on native links. */ export function installComponentLinkStyles( host: HTMLElement, root: ShadowRoot, links: readonly HTMLLinkElement[], + nativeLinks = false, ): void { if (links.length === 0 || !supportsConstructableStylesheets()) return; const mountStartedAt = performanceNow(); let timingStartedAt = mountStartedAt; + let preloadsSettled = true; + let preloadFailed = false; const preloads: SpeculativeStylesheetPreload[] = []; const pendingPreloads: Promise[] = []; for (let i = 0; i < links.length; i++) { @@ -168,6 +247,35 @@ export function installComponentLinkStyles( preloads.push(speculative); pendingPreloads.push(speculative.ready); timingStartedAt = Math.min(timingStartedAt, speculative.startedAt); + if (!speculative.settled) preloadsSettled = false; + else if (speculative.failure) preloadFailed = true; + } + + const records = componentLinkRecordsFor(root.ownerDocument, links); + let share = !nativeLinks; + for (let i = 0; i < records.length; i++) { + if (records[i].used) continue; + records[i].used = true; + records[i].firstUseStartedAt = timingStartedAt; + share = false; + } + + const releasePreloads = (): void => { + for (let i = 0; i < preloads.length; i++) { + releaseStylesheetPreload(preloads[i].link); + } + }; + + // Memory-cached links expose their native sheets on insertion. Settle them + // before the template is wired: nothing paints in between, so no guard or + // load wait is needed, and later mounts in this pass can share the result. + if (preloadsSettled && hasLoadedSheets(links)) { + if (!preloadFailed) { + authorizeLoadedLinks(links, records, timingStartedAt); + if (share) shareLoadedLinks(root, links, records); + } + releasePreloads(); + return; } const guard = installGuard(host, root); @@ -177,11 +285,6 @@ export function installComponentLinkStyles( let remaining = 0; let cancelled = false; - const releasePreloads = (): void => { - for (let i = 0; i < preloads.length; i++) { - releaseStylesheetPreload(preloads[i].link); - } - }; const cleanup = (): void => { for (let i = 0; i < links.length; i++) { const onLoad = loadHandlers[i]; @@ -191,7 +294,7 @@ export function installComponentLinkStyles( } releasePreloads(); }; - const fail = (): void => { + const reveal = (): void => { if (cancelled) return; cancelled = true; cleanup(); @@ -200,19 +303,14 @@ export function installComponentLinkStyles( const finish = (): void => { if (cancelled || remaining !== 0) return; void Promise.all(pendingPreloads).then(results => { - if (cancelled || results.some(result => result !== undefined)) { - fail(); - return; - } - const sheets = constructComponentStylesheets(links, timingStartedAt); - if (!sheets || !adoptPreparedSheets(root, sheets)) { - fail(); - return; + if (cancelled) return; + if (!results.some(result => result !== undefined)) { + authorizeLoadedLinks(links, records, timingStartedAt); + // Still hidden by the guard, so switching to the shared sheet is + // never visible. + if (share) shareLoadedLinks(root, links, records); } - deactivatePromotedLinks(links); - cancelled = true; - cleanup(); - guard.release(); + reveal(); }); }; const settle = (index: number): void => { @@ -237,7 +335,7 @@ export function installComponentLinkStyles( } remaining += 1; const onLoad = (): void => settle(i); - const onError = (): void => fail(); + const onError = (): void => reveal(); loadHandlers[i] = onLoad; errorHandlers[i] = onError; links[i].addEventListener('load', onLoad, { once: true }); @@ -246,6 +344,29 @@ export function installComponentLinkStyles( if (remaining === 0) finish(); } +/** + * Whether any block of a template may add its own `

Styled

', + sd: 1, + }, +}); +TestSharedLinkCard.define('test-shared-link-card'); +TestColdLinkCard.define('test-cold-link-card'); +TestImportLinkCard.define('test-import-link-card'); +TestStyledLinkCard.define('test-styled-link-card'); + +function linkCatalog(tag: string): ComponentStyles { + return { + version: 1, + strategy: 'link', + resources: { [tag]: { kind: 'link', href: STYLESHEETS[tag] } }, + closures: { [tag]: [tag] }, + }; +} + +const firstSheets = new Map(); + +window.__registerLinkCards = (tag, prepare) => { + // The router and component assets register through this hook, which also + // warms the stylesheet. Plain registration models a cold client mount. + if (prepare) return window.__webuiRegisterComponentStyles?.(linkCatalog(tag)); + registerComponentStyles(linkCatalog(tag)); + return undefined; +}; + +window.__mountLinkCards = (tag, count) => { + const container = document.getElementById('cards'); + if (!container) throw new Error('Missing #cards container'); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < count; i++) fragment.append(document.createElement(tag)); + container.append(fragment); +}; + +window.__removeLinkCards = (tag) => { + for (const card of document.querySelectorAll(tag)) card.remove(); +}; + +window.__inspectLinkCards = (tag) => { + const sheets = new Set(); + const colors = new Set(); + let activeLinks = 0; + let adopted = 0; + let guards = 0; + let hidden = 0; + let links = 0; + let sameSheetAsFirstBatch = true; + const cards = document.querySelectorAll(tag); + for (const card of cards) { + const root = card.shadowRoot; + if (!root) throw new Error(`Missing shadow root for <${tag}>`); + for (const link of root.querySelectorAll('link')) { + links += 1; + if (!link.disabled) activeLinks += 1; + } + for (const style of root.querySelectorAll('style')) { + if (style.textContent?.includes(':host')) guards += 1; + } + adopted += root.adoptedStyleSheets.length; + for (const sheet of root.adoptedStyleSheets) { + sheets.add(sheet); + const first = firstSheets.get(tag); + if (!first) firstSheets.set(tag, sheet); + else if (first !== sheet) sameSheetAsFirstBatch = false; + } + if (!card.checkVisibility({ visibilityProperty: true })) hidden += 1; + const label = root.querySelector('.label'); + colors.add(label ? getComputedStyle(label).color : 'missing'); + } + return { + activeLinks, + adopted, + cards: cards.length, + colors: [...colors], + distinctSheets: sheets.size, + guards, + hidden, + links, + sameSheetAsFirstBatch, + }; +}; diff --git a/packages/webui-framework/tests/fixtures/component-link-sharing/fixture.html b/packages/webui-framework/tests/fixtures/component-link-sharing/fixture.html new file mode 100644 index 000000000..5dbd96916 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/component-link-sharing/fixture.html @@ -0,0 +1,11 @@ + + + + + Component Link Sharing Fixture + + +
+ + + diff --git a/packages/webui-framework/tests/fixtures/component-link-sharing/import-base.css b/packages/webui-framework/tests/fixtures/component-link-sharing/import-base.css new file mode 100644 index 000000000..70b7d478c --- /dev/null +++ b/packages/webui-framework/tests/fixtures/component-link-sharing/import-base.css @@ -0,0 +1 @@ +.label { font-style: italic; } \ No newline at end of file diff --git a/packages/webui-framework/tests/fixtures/component-link-sharing/import.css b/packages/webui-framework/tests/fixtures/component-link-sharing/import.css new file mode 100644 index 000000000..03bdd8a2c --- /dev/null +++ b/packages/webui-framework/tests/fixtures/component-link-sharing/import.css @@ -0,0 +1,2 @@ +@import url("import-base.css"); +.label { color: rgb(77, 88, 99); } diff --git a/packages/webui-framework/tests/fixtures/component-link-sharing/shared.css b/packages/webui-framework/tests/fixtures/component-link-sharing/shared.css new file mode 100644 index 000000000..8c290f9da --- /dev/null +++ b/packages/webui-framework/tests/fixtures/component-link-sharing/shared.css @@ -0,0 +1 @@ +.label { color: rgb(11, 22, 33); } \ No newline at end of file diff --git a/packages/webui-framework/tests/fixtures/component-link-sharing/styled.css b/packages/webui-framework/tests/fixtures/component-link-sharing/styled.css new file mode 100644 index 000000000..6160f2ece --- /dev/null +++ b/packages/webui-framework/tests/fixtures/component-link-sharing/styled.css @@ -0,0 +1 @@ +.label { color: rgb(101, 102, 103); } \ No newline at end of file diff --git a/specs/hydration.md b/specs/hydration.md index 250d048eb..73e6d1f63 100644 --- a/specs/hydration.md +++ b/specs/hydration.md @@ -528,6 +528,16 @@ required `componentStyles` catalog. Module resources carry their CSS directly; the catalog installs each specifier's import map once per owning Document before importing it, then reuses the cached parsed sheet for every target. +Client Shadow roots share Link resources per owning Document and resolved URL. +The first root to mount a stylesheet keeps its native link and records whether +that load is authorized: unredirected, not served by a service worker, and +successful. The next root promotes the authorized CSSOM of a loaded link once +into a constructable sheet, and every later root adopts it before its template +is appended. A root switches only while hidden by its loading guard, never after +it is visible. Roots whose template adds its own `