Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions examples/app/component-assets/tests/component-assets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ async function loadedTemplateNames(page: Page): Promise<string[]> {
});
}

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;
Expand Down Expand Up @@ -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 }) => {
Expand Down
17 changes: 17 additions & 0 deletions packages/webui-framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<style>`
or `<link>`, server-rendered or reused roots, and Light descendants) promote on
first use instead, so every instance adopts the same sheet. A response that
fails authorization is remembered, so later roots keep native links without
another timing lookup. Shared sheets and authorization results live as long as
the Document's style catalog.

### Intent-time Link preloading for component assets

In a Shadow build, call `assets.preload(tag)` from pointer, focus, or other
Expand Down
47 changes: 47 additions & 0 deletions packages/webui-framework/src/element/link-styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { describe, test } from 'node:test';
import {
preloadComponentAssetStyles,
rewriteCssUrls,
templateMayContainAuthorStyles,
templateMayContainLinkStyles,
} from './link-styles.js';

Expand Down Expand Up @@ -42,6 +43,52 @@ describe('Link stylesheet scanning', () => {
);
});

test('finds author styles in any template block', () => {
assert.equal(
templateMayContainAuthorStyles({ h: '<p class="card">Ready</p>' }),
false,
);
assert.equal(
templateMayContainAuthorStyles({ h: '<style-card></style-card><link-card></link-card>' }),
false,
);
assert.equal(
templateMayContainAuthorStyles({ h: '<STYLE>.a{}</STYLE><p></p>' }),
true,
);
assert.equal(
templateMayContainAuthorStyles({
h: '<p></p>',
b: [{ h: '<span></span>' }, { h: '<link rel="stylesheet" href="x.css">' }],
}),
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(
Expand Down
Loading
Loading