diff --git a/examples/app/component-assets/tests/component-assets.spec.ts b/examples/app/component-assets/tests/component-assets.spec.ts index 74910f9f1..a97e082dd 100644 --- a/examples/app/component-assets/tests/component-assets.spec.ts +++ b/examples/app/component-assets/tests/component-assets.spec.ts @@ -39,6 +39,19 @@ async function loadedTemplateNames(page: Page): Promise { }); } +function lazyPanelStylesheets( + page: Page, +): Promise<{ adopted: number; disabled: boolean; links: number }> { + return page.locator('lazy-panel').evaluate((panel) => ({ + adopted: panel.shadowRoot?.adoptedStyleSheets.length ?? 0, + disabled: + (panel.shadowRoot?.querySelector( + 'link[rel~="stylesheet"]', + ) as HTMLLinkElement | null)?.disabled ?? false, + links: panel.shadowRoot?.querySelectorAll('link[rel~="stylesheet"]').length ?? 0, + })); +} + test.describe('static component assets', () => { test('recovers a failed speculative native import through an explicit reload', async ({ page }) => { let assetRequests = 0; @@ -150,14 +163,43 @@ test.describe('static component assets', () => { releaseCss(); await expect(page.locator('lazy-panel')).toHaveCount(1); - await expect(page.locator('lazy-panel').evaluate((panel) => ({ - adopted: panel.shadowRoot?.adoptedStyleSheets.length ?? 0, - disabled: - (panel.shadowRoot?.querySelector( - 'link[rel~="stylesheet"]', - ) as HTMLLinkElement | null)?.disabled ?? false, - links: panel.shadowRoot?.querySelectorAll('link[rel~="stylesheet"]').length ?? 0, - }))).resolves.toEqual({ adopted: 1, disabled: true, links: 1 }); + // A component used once stays on its native stylesheet link. Promoting it + // would serialize and reparse CSS that no other root shares; the next + // instance promotes instead (see the reopen test below). + await expect(lazyPanelStylesheets(page)).resolves.toEqual({ + adopted: 0, + disabled: false, + links: 1, + }); + }); + + test('shares the promoted Link stylesheet with a reopened lazy component', async ({ page }) => { + await page.goto('/'); + const button = page.getByRole('button', { name: 'Load lazy panel' }); + await expect(button).toBeVisible(); + await button.click(); + await expect(page.locator('lazy-panel')).toHaveCount(1); + await expect(lazyPanelStylesheets(page)).resolves.toEqual({ + adopted: 0, + disabled: false, + links: 1, + }); + await page.locator('lazy-panel').evaluate((panel) => { + (panel as HTMLElement & { firstInstance?: boolean }).firstInstance = true; + }); + + // The second instance promotes the CSSOM that the first native load + // authorized, adopts that shared sheet, and keeps its own link disabled. + // Every later instance adopts the same sheet without a link. + await button.click(); + await expect.poll(() => page.locator('lazy-panel').evaluate((panel) => { + return (panel as HTMLElement & { firstInstance?: boolean }).firstInstance ?? false; + })).toBe(false); + await expect.poll(() => lazyPanelStylesheets(page)).toEqual({ + adopted: 1, + disabled: true, + links: 1, + }); }); test('lets the application bundler split and reuse the lazy graph', async ({ page }) => { diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 9ee105bdc..656c02341 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -998,6 +998,23 @@ guard, and completes deferred hydration. The component may be unstyled after a definitive stylesheet failure, but it remains visible and usable. SSR hydration, Style, Module, and authored/global Light DOM behavior are unchanged. +Compiler-owned component stylesheets follow the same authority rules but +promote on their second use. The first newly attached client Shadow root to +mount a stylesheet in a Document keeps its native link and only records whether +that load is authorized, so a component used once costs no CSSOM serialization +or second parse. The next root promotes the authorized CSSOM of a loaded link +once; every later root adopts that shared sheet before its template is +appended, with no link, guard, or load wait. Memory-cached links promote during +the mount, so every root created in one pass after `assets.preload()` or router +preparation shares one sheet. A root still hidden by its guard may switch to +the shared sheet before it is revealed; a visible root never switches. +Roots whose cascade includes other author styles (a template's own `

' }), + 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..dfc90c229 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,56 @@ 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. Held + * weakly: the record lives as long as the Document, and the link would keep + * its removed root and host reachable when no later root needs this URL. + */ + source?: WeakRef; + /** + * Promotion failed for this Document's response: it is not authorized, or + * its CSS cannot be represented as a constructable sheet. Neither changes, + * because memory-cache hits add no new timing entry. + */ + unpromotable: boolean; + /** Some root has already mounted this URL. */ + used: boolean; +} + +/** + * `installComponentLinkStyles` flag: promote a stylesheet on its first use. + * + * For roots whose cascade includes other author styles (a template's 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..ffa1ca65b 100644 --- a/specs/hydration.md +++ b/specs/hydration.md @@ -528,6 +528,18 @@ 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 newly attached 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 cascade includes other author styles (a +template's own `