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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<title>CSS module import: parse errors produce empty stylesheet</title>
<meta name="author" title="Kurt Catti-Schmidt" href="mailto:[email protected]" />
<link rel='help' href='https://github.com/whatwg/html/pull/11687'>
<script src='/resources/testharness.js'></script>
<script src='/resources/testharnessreport.js'></script>
<body>
<script type="module">
promise_test(async (t) => {
// A CSS module with parse errors should still import successfully,
// producing a CSSStyleSheet with no valid rules.
const sheet = await import("./resources/parse-error.css",
{ with: { type: "css" } });
assert_equals(sheet.default.cssRules.length, 0,
"Parse-error CSS module should have no valid rules.");
}, "CSS module with parse errors imports successfully with empty rules.");

promise_test(async (t) => {
// A second import of the same parse-error module should return the
// same result — not a stale pre-created sheet or a different object.
const sheet1 = await import("./resources/parse-error.css",
{ with: { type: "css" } });
const sheet2 = await import("./resources/parse-error.css",
{ with: { type: "css" } });
assert_equals(sheet1.default, sheet2.default,
"Repeated imports should return the same CSSStyleSheet.");
assert_equals(sheet1.default.cssRules.length, 0,
"Sheet should still have no valid rules on second import.");
}, "Repeated import of parse-error CSS module returns same sheet.");

promise_test(async (t) => {
// A CSS module with some malformed and some valid rules should import
// successfully, with only the valid rules present.
const sheet = await import("./resources/malformed.css",
{ with: { type: "css" } });
assert_true(sheet.default.cssRules.length > 0,
"Malformed CSS module should have at least one valid rule.");
}, "CSS module with partially malformed CSS imports with valid rules.");
</script>
</body>
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
<script type="module">
promise_test(async (t) => {
// Host is inside an iframe that is destroyed during the fetch. Destroying
// the iframe invalidates its browsing context. The completion callback for
// the pending module fetch should detect that the context is gone and bail
// out without crashing.
// the iframe invalidates its browsing context, which causes the pending
// module fetch to fail. The cleanup callback must handle this without
// crashing; the orphaned shadow root retains its synchronously-adopted
// empty sheet.
const cssUrl = new URL("./support/styles-red.css?iframe", location.href).href;

const iframe = document.createElement("iframe");
Expand All @@ -34,23 +35,21 @@
const shadowRoot = iframeHost.shadowRoot;

assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"Before iframe destruction: expected 1 placeholder(s).");
"Before iframe destruction: one entry exists synchronously.");
assert_equals(shadowRoot.adoptedStyleSheets[0].cssRules.length, 0,
"Before iframe destruction: placeholder at index 0 should be empty.");
"Before iframe destruction: entry at index 0 is empty pre-fetch.");

iframe.remove();

await new Promise(resolve => step_timeout(resolve, 500));
await new Promise(resolve => requestAnimationFrame(resolve));

// After the browsing context is invalidated, the failed fetch is
// observed without crashing. The empty sheet is left in place.
assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"Orphaned shadow root should still have one entry after iframe destruction.");

// The browsing context was invalidated, so the placeholder should remain empty.
assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"After iframe destruction: expected 1 placeholder(s).");
"After iframe destruction: empty sheet remains.");
assert_equals(shadowRoot.adoptedStyleSheets[0].cssRules.length, 0,
"After iframe destruction: placeholder at index 0 should be empty.");
"After iframe destruction: sheet remains empty.");

// Re-inserting the iframe creates a new browsing context with a fresh document.
const iframeReloaded = new Promise(
Expand All @@ -62,9 +61,7 @@
assert_equals(iframe.contentDocument.getElementById("host"), null,
"The new iframe document should not contain the original host element.");
assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"After iframe re-insertion: expected 1 placeholder(s).");
assert_equals(shadowRoot.adoptedStyleSheets[0].cssRules.length, 0,
"After iframe re-insertion: placeholder at index 0 should be empty.");
"After iframe re-insertion: orphaned shadow root still holds sheet.");
}, "Destroying iframe during async adoptedStyleSheets fetch does not crash.");
</script>
</body>
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<title>shadowrootadoptedstylesheets failed fetches are sticky across consumers</title>
<meta name="author" title="Kurt Catti-Schmidt" href="mailto:[email protected]" />
<link rel='help' href='https://github.com/whatwg/html/pull/11687'>
<script src='/resources/testharness.js'></script>
<script src='/resources/testharnessreport.js'></script>
<script src='./support/helpers.js'></script>
<body>
<script type="module">
// Verifies that a CSS module fetch failure originating from
// `shadowrootadoptedstylesheets` leaves a sticky failed entry in the module
// map, so subsequent declarative or imperative consumers of the same URL
// observe the failure consistently rather than seeing a stale populated
// sheet from the failed attempt.
//
// The first declarative consumer keeps its synchronously-adopted empty
// placeholder sheet after failure (no cleanup runs). Later consumers that
// observe the URL only after the entry has reached the finished-failed
// state get nothing, since there is no pre-created sheet to adopt.

promise_test(async (t) => {
const url = "./support/nonexistent-shared-1.css";

// First consumer: declarative shadow root initiates the fetch.
const { shadowRoot: first } = createStylesheetHost(url, "first_host");
// Synchronous empty sheet while fetching.
assert_equals(first.adoptedStyleSheets.length, 1,
"First consumer: empty sheet present synchronously.");

await fetchAndWait(url);

assert_equals(first.adoptedStyleSheets.length, 1,
"First consumer: empty sheet remains after failed fetch.");
assert_equals(first.adoptedStyleSheets[0].cssRules.length, 0,
"First consumer: sheet remains empty after failed fetch.");

// Second consumer: another declarative shadow root for the same URL.
// The module map already holds a finished failed entry; with no
// pre-created sheet to adopt, the second shadow root contributes
// nothing for this specifier.
const { shadowRoot: second } = createStylesheetHost(url, "second_host");
assert_equals(second.adoptedStyleSheets.length, 0,
"Second declarative consumer of an already-failed URL: no sheet.");
}, "Failed fetch initiated by shadowrootadoptedstylesheets stays failed for later declarative consumers.");

promise_test(async (t) => {
const url = "./support/nonexistent-shared-2.css";

// Declarative consumer initiates the fetch and waits for it to fail.
const { shadowRoot } = createStylesheetHost(url, "decl_then_imp_host");
await fetchAndWait(url);
assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"Declarative consumer: empty sheet remains after failed fetch.");

// Imperative `import` of the same URL must reject from the cached
// failure — same as if the original fetch had been imperative — even
// though the declarative consumer still holds an empty placeholder.
await promise_rejects_js(t, TypeError,
import(url, { with: { type: "css" } }),
"import() of an already-failed CSS module should reject.");
}, "Declarative failure: subsequent import() of the same URL rejects (consistent with imperative-first failure).");

promise_test(async (t) => {
const url = "./support/nonexistent-shared-3.css";

// Imperative consumer fails first.
await promise_rejects_js(t, TypeError,
import(url, { with: { type: "css" } }),
"import() of a 404 CSS module should reject.");

// A subsequent declarative consumer must observe nothing — the sticky
// failed entry has no pre-created sheet to adopt.
const { shadowRoot } = createStylesheetHost(url, "imp_then_decl_host");
assert_equals(shadowRoot.adoptedStyleSheets.length, 0,
"Declarative consumer after imperative failure: no sheet.");
}, "Failed import() is observed by subsequent shadowrootadoptedstylesheets.");
</script>
</body>
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,157 @@
<script src='/resources/testharnessreport.js'></script>
<script src='./support/helpers.js'></script>
<body>
<script type="importmap">
{
"imports": {
"blocked-by-null": null
}
}
</script>
<script type="module">
// A failed CSS module fetch leaves its synchronously-adopted empty sheet
// in `adoptedStyleSheets`. There is no observable cleanup when the fetch
// fails: the empty sheet remains, but the module map's entry is sticky-
// failed so subsequent imperative imports of the same URL still reject
// and subsequent declarative consumers contribute no sheet for that URL.

promise_test(async (t) => {
// --- Scenario 1: a specifier that resolves to a 404 URL. ---
// The fetch should fail gracefully; the placeholder sheet should remain
// empty and no crash should occur.
// Synchronously, an empty sheet is adopted while the fetch is pending.
// Once the fetch fails, that empty sheet is left behind as-is.
const nonexistentUrl = "./support/nonexistent.css";
const { shadowRoot, testElement } = createStylesheetHost(nonexistentUrl);
const { shadowRoot } = createStylesheetHost(nonexistentUrl);

assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"Before fetch settles: expected 1 placeholder(s).");
assert_equals(shadowRoot.adoptedStyleSheets[0].cssRules.length, 0,
"Before fetch settles: placeholder at index 0 should be empty.");
"Before fetch settles: expected 1 (empty) sheet.");
const placeholder = shadowRoot.adoptedStyleSheets[0];
assert_equals(placeholder.cssRules.length, 0,
"Before fetch settles: sheet at index 0 should be empty.");

await fetchAndWait(nonexistentUrl);

assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"After failed fetch: expected 1 placeholder(s).");
"After failed fetch: empty sheet remains in adoptedStyleSheets.");
assert_equals(shadowRoot.adoptedStyleSheets[0], placeholder,
"After failed fetch: sheet identity is unchanged.");
assert_equals(shadowRoot.adoptedStyleSheets[0].cssRules.length, 0,
"After failed fetch: placeholder at index 0 should be empty.");
"After failed fetch: sheet remains empty.");
}, "Async fetch failure: empty placeholder sheet remains in adoptedStyleSheets.");

promise_test(async (t) => {
// --- Scenario 2: mixed valid and invalid specifiers. ---
const nonexistentUrl2 = "./support/nonexistent-2.css";
// The valid sheet is populated; the invalid one stays empty. Both
// entries remain in the array.
const nonexistentUrl = "./support/nonexistent-2.css";
const validUrl = "./support/styles.css?failure";
const { shadowRoot: shadowRoot2, testElement: testElement2 } =
createStylesheetHost([nonexistentUrl2, validUrl]);
const { shadowRoot, testElement } =
createStylesheetHost([nonexistentUrl, validUrl]);

assert_equals(shadowRoot2.adoptedStyleSheets.length, 2,
"Two entries should be present (one placeholder, one fetched or placeholder).");
assert_equals(shadowRoot.adoptedStyleSheets.length, 2,
"Two entries should be present synchronously (both empty pre-fetch).");

await fetchAndWait(nonexistentUrl2, validUrl);
await fetchAndWait(nonexistentUrl, validUrl);

assert_equals(shadowRoot2.adoptedStyleSheets.length, 2,
"adoptedStyleSheets should still have two entries.");
assert_equals(shadowRoot2.adoptedStyleSheets[0].cssRules.length, 0,
"The first entry (failed fetch) should be an empty placeholder.");
assertSheetRule(shadowRoot2, 1, "span { color: blue; }", "Second entry (valid)");
assert_equals(getComputedStyle(testElement2).color, "rgb(0, 0, 255)",
assert_equals(shadowRoot.adoptedStyleSheets.length, 2,
"After settled: both sheets remain (failed and valid).");
assert_equals(shadowRoot.adoptedStyleSheets[0].cssRules.length, 0,
"Failed entry remains empty.");
assertSheetRule(shadowRoot, 1, "span { color: blue; }",
"Valid entry");
assert_equals(getComputedStyle(testElement).color, "rgb(0, 0, 255)",
"The valid specifier's styles (blue) should be applied.");
}, "Async fetch failure: 404 leaves placeholder empty, valid specifier still works.");
}, "Async fetch failure: failed sheet remains empty; valid specifier still works.");

promise_test(async (t) => {
// JS may insert additional references to the synchronously-created
// placeholder before the fetch settles. Because nothing removes the
// placeholder on failure, all duplicate references remain.
const nonexistentUrl = "./support/nonexistent-3.css";
const { shadowRoot } = createStylesheetHost(nonexistentUrl);

assert_equals(shadowRoot.adoptedStyleSheets.length, 1,
"Sanity: declarative path produced one (empty) sheet synchronously.");
const declarativeSheet = shadowRoot.adoptedStyleSheets[0];

shadowRoot.adoptedStyleSheets = [
declarativeSheet, declarativeSheet, declarativeSheet];
assert_equals(shadowRoot.adoptedStyleSheets.length, 3,
"Sanity: JS-duplicated references produced three entries.");

await fetchAndWait(nonexistentUrl);

assert_equals(shadowRoot.adoptedStyleSheets.length, 3,
"After failed fetch: all duplicate references remain.");
for (let i = 0; i < 3; ++i) {
assert_equals(shadowRoot.adoptedStyleSheets[i], declarativeSheet,
`Entry ${i} should still reference the original empty sheet.`);
assert_equals(shadowRoot.adoptedStyleSheets[i].cssRules.length, 0,
`Entry ${i} should still be empty.`);
}
}, "Async fetch failure: JS-duplicated references all remain after failure.");

promise_test(async (t) => {
// A bare module specifier (one that is not a relative URL and has no
// matching import map entry) cannot be resolved, so no fetch is
// initiated and no sheet is contributed for that specifier.
// adoptedStyleSheets remains empty for the host. A subsequent imperative
// `import()` of the same specifier should also reject.
const bareSpecifier = "bare";
const { shadowRoot } = createStylesheetHost(bareSpecifier);

assert_equals(shadowRoot.adoptedStyleSheets.length, 0,
"Bare specifier contributes no sheet to adoptedStyleSheets.");

await promise_rejects_js(t, TypeError,
import(bareSpecifier, { with: { type: "css" } }),
"Imperative import of a bare specifier should reject.");

assert_equals(shadowRoot.adoptedStyleSheets.length, 0,
"After import rejection: still no sheet adopted.");
}, "Async fetch failure: bare module specifier contributes no sheet.");

promise_test(async (t) => {
// An invalid URL (containing a lone surrogate, which is not valid in
// any UTF-8-encoded URL) cannot be parsed, so module specifier
// resolution fails for the same reason as the bare-specifier case
// above. No fetch is initiated and no sheet is contributed.
const invalidUrl = "\uD800"; // Lone leading surrogate, no low surrogate.
const { shadowRoot } = createStylesheetHost(invalidUrl);

assert_equals(shadowRoot.adoptedStyleSheets.length, 0,
"Invalid URL (lone surrogate) contributes no sheet to " +
"adoptedStyleSheets.");

await promise_rejects_js(t, TypeError,
import(invalidUrl, { with: { type: "css" } }),
"Imperative import of an invalid URL (lone surrogate) should reject.");

assert_equals(shadowRoot.adoptedStyleSheets.length, 0,
"After import rejection: still no sheet adopted.");
}, "Async fetch failure: invalid URL (lone surrogate) contributes no sheet.");

promise_test(async (t) => {
// The specifier here is by itself a valid bare specifier, AND the
// import map declared at the top of the document does have an entry
// for it -- but that entry is `null`, which the HTML spec defines as
// "blocked by a null entry" and surfaces to ResolveModuleSpecifier as
// an invalid resolved URL. This should be skipped for
// shadowrootadoptedstylesheets processing (no fetch, no sheet
// contributed). A subsequent imperative `import()` of the same specifier
// rejects with TypeError.
const blockedSpecifier = "blocked-by-null";
const { shadowRoot } = createStylesheetHost(blockedSpecifier);

assert_equals(shadowRoot.adoptedStyleSheets.length, 0,
"Specifier blocked by null import map entry contributes no sheet.");

await promise_rejects_js(t, TypeError,
import(blockedSpecifier, { with: { type: "css" } }),
"Imperative import of a specifier mapped to null should reject.");

assert_equals(shadowRoot.adoptedStyleSheets.length, 0,
"After import rejection: still no sheet adopted.");
}, "Async fetch failure: import map mapping specifier to null contributes " +
"no sheet.");
</script>
</body>
Loading
Loading