Apply fragment parse-mode to DOMParser

Closes the DOMParser gap left as a follow-up in the previous review-fix
commit. DOMParser.parseFromString built its target Document via the
frame's parser without touching `_parse_mode`, so `Build.created` →
`linkAddedCallback` → `loadExternalStylesheet` saw `_parse_mode ==
.document` and fetched/registered sheets on the LIVE frame document for
every stylesheet link in the parsed string.

Bracket both the text/html and XML branches with the same fragment
parse-mode `parseHtmlAsChildren` uses. The existing gate in
`loadExternalStylesheet` already short-circuits on .fragment, so no
change is needed there. Side benefits: parser-emitted scripts in
DOMParser content stop reaching `scriptAddedCallback` against the live
frame, default-script injection skips DOMParser content, and mutation
observers on the live document no longer fan out for parsed nodes —
all of which match what DOMParser should do per spec.

Regression test extended to cover the DOMParser path alongside the
existing innerHTML case.

Refs #2343
This commit is contained in:
Navid EMAD
2026-05-17 16:57:02 +02:00
parent f05efd6719
commit 32dbd716b1
2 changed files with 28 additions and 11 deletions

View File

@@ -125,22 +125,28 @@
{
// Stylesheet links parsed via innerHTML / outerHTML /
// insertAdjacentHTML / Range.createContextualFragment / <template>
// content must NOT trigger a network fetch or register a sheet on
// the live document — the owning subtree may never be attached.
// Regression test for the fragment-mode bypass caught in code review.
// content / DOMParser.parseFromString must NOT trigger a network
// fetch or register a sheet on the live document — the owning
// subtree may never be attached or belongs to a different Document.
//
// Assertion is synchronous on purpose: parseHtmlAsChildren runs
// inline, so any unintended sheet registration would be visible
// immediately. Deferring to testing.onload would race against the
// async tests above that legitimately add sheets.
//
// DOMParser.parseFromString is a separate case (parses with
// `_parse_mode = .document` into a *different* Document) and is not
// covered by the same gate — tracked as a follow-up.
// Assertion is synchronous on purpose: both parse paths run inline,
// so any unintended sheet registration would be visible immediately.
// Deferring to testing.onload would race against the async tests
// above that legitimately add sheets.
const before = document.styleSheets.length;
const div = document.createElement('div');
div.innerHTML = '<link rel="stylesheet" href="/styles/visibility.css">';
testing.expectEqual(before, document.styleSheets.length);
const parsed = new DOMParser().parseFromString(
'<html><head><link rel="stylesheet" href="/styles/visibility2.css"></head><body></body></html>',
'text/html'
);
// Parsed link exists in the new document...
testing.expectEqual(1, parsed.querySelectorAll('link').length);
// ...but no sheet on the live document.
testing.expectEqual(before, document.styleSheets.length);
}
</script>

View File

@@ -53,6 +53,17 @@ pub fn parseFromString(
const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
defer frame.releaseArena(arena);
// DOMParser builds a detached Document. Borrow the same fragment
// parse-mode that `parseHtmlAsChildren` uses so frame-side hooks
// triggered from `Build.created` / `nodeIsReady` (external stylesheet
// fetches, script execution, mutation-observer fan-out, default-script
// injection) treat the parsed nodes as detached and skip
// side effects on the live document. The frame's `_parse_mode` is
// restored on exit.
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;
return switch (target_mime) {
.@"text/html" => {
// Create a new HTMLDocument