Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

<!doctype html>
<meta charset="utf-8" />
<title>streamHTMLUnsafe with parser blocking script</title>
<meta name="timeout" content="long">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<div id="placeholder"></div>
<script>
setup({ allow_uncaught_exception: true });
promise_test(async (t) => {
window.events = [];
window.resolvers = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writable = placeholder.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
// Chunk 1: A slow script that pushes 'slow' to events.
await writer.write("<script src='resources/slow.js?pipe=trickle(d0.3)&t=1'><" + "/script>");
// Chunk 2: An inline script that pushes 'inline' to events and resolves the promise.
// Because the parser should block on Chunk 1, 'inline' should not be executed until 'slow' is executed.
await writer.write("<script>window.events.push('inline'); window.resolvers.resolve();<" + "/script>");
// We close the writer.
await writer.close();
await window.resolvers.promise;
assert_array_equals(window.events, ["slow", "inline"]);
}, "streamHTMLUnsafe with parser blocking script should process in order");
promise_test(async (t) => {
window.events = [];
window.resolvers = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writable = placeholder.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
// Script 1 takes 0.3s, Script 2 takes 0.1s.
// Script 2 must NOT execute before Script 1.
await writer.write(
"<script src='resources/slow.js?pipe=trickle(d0.3)&t=2_1'><" + "/script>" +
"<p id='mid'>mid</p>" +
"<script src='resources/slow.js?pipe=trickle(d0.1)&t=2_2'><" + "/script>" +
"<script>window.events.push('inline'); window.resolvers.resolve();<" + "/script>"
);
await writer.close();
await window.resolvers.promise;
assert_array_equals(window.events, ["slow", "slow", "inline"]);
}, "Multiple parser-blocking scripts with out-of-order fetch times execute in document order");
promise_test(async (t) => {
window.inline_split_result = null;
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writable = placeholder.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
await writer.write("<script>window.inline_");
assert_equals(window.inline_split_result, null, "Script must not execute while incomplete");
await writer.write("split_result = 10 + ");
assert_equals(window.inline_split_result, null, "Script must not execute while incomplete");
await writer.write("20;<" + "/");
assert_equals(window.inline_split_result, null, "Script must not execute until end tag is complete");
await writer.write("script>");
assert_equals(window.inline_split_result, 30, "Script executes immediately once end tag is parsed");
await writer.close();
}, "Inline script split across chunk boundaries executes only when tag closes");
promise_test(async (t) => {
window.events = [];
window.resolvers = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writable = placeholder.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
await writer.write("<script sr");
await writer.write("c='resources/slow.js?pipe=trickle(d0.1)&split=1'");
await writer.write("><" + "/");
await writer.write("script>");
await writer.write("<script>window.events.push('inline'); window.resolvers.resolve();<" + "/script>");
await writer.close();
await window.resolvers.promise;
assert_array_equals(window.events, ["slow", "inline"]);
}, "External script tag split across chunk boundaries parses and executes correctly");
promise_test(async (t) => {
window.resolvers = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writable = placeholder.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
const scriptCode = `
const p = document.createElement('p');
p.id = 'from-script';
p.textContent = 'from script';
document.getElementById('placeholder').append(p);
`;
await writer.write(
"<p id='p1'>p1</p>" +
`<script src="data:text/javascript,${encodeURIComponent(scriptCode)}"><` + `/script>` +
"<p id='p2'>p2</p>" +
"<script>window.resolvers.resolve();<" + "/script>"
);
await writer.close();
await window.resolvers.promise;
const ids = Array.from(placeholder.children).map(c => c.id);
assert_array_equals(ids, ["p1", "", "from-script", "p2", ""]);
}, "DOM mutations inside streamed parser-blocking script maintain correct insertion order");
promise_test(async (t) => {
window.events = [];
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writable = placeholder.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
await writer.write("<script src='resources/slow.js?pipe=trickle(d0.2)&abort_test=1'><" + "/script>");
await writer.abort();
// Wait 300ms to ensure the network response for slow.js arrived
await new Promise(r => t.step_timeout(r, 300));
assert_array_equals(window.events, [], "Aborted parser-blocking script must not execute after abort");
}, "streamHTMLUnsafe with parser-blocking script should not execute after abort");
promise_test(async (t) => {
window.events = [];
window.resolvers = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writable = placeholder.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
// Set up window.onerror listener for the expected script error
let got_error = false;
const error_listener = (e) => {
got_error = true;
e.preventDefault();
};
window.addEventListener("error", error_listener);
t.add_cleanup(() => window.removeEventListener("error", error_listener));
await writer.write(
"<script>window.events.push('before-err'); throw new Error('expected test error');<" + "/script>" +
"<p id='after-err'>after</p>" +
"<script>window.events.push('after-err'); window.resolvers.resolve();<" + "/script>"
);
await writer.close();
await window.resolvers.promise;
assert_true(got_error, "window.onerror should receive the thrown error");
assert_array_equals(window.events, ["before-err", "after-err"]);
assert_equals(placeholder.querySelector("#after-err").textContent, "after");
}, "streamHTMLUnsafe continues parsing after runtime script error");
promise_test(async (t) => {
window.detached_script_ran = false;
const detachedDiv = document.createElement("div");
const writable = detachedDiv.streamHTMLUnsafe({ runScripts: true });
const writer = writable.getWriter();
await writer.write("<script>window.detached_script_ran = true;<" + "/script><p>text</p>");
await writer.close();
assert_false(window.detached_script_ran, "Scripts in detached element should not execute");
assert_equals(detachedDiv.querySelector("p").textContent, "text");
}, "streamHTMLUnsafe on detached element should parse markup without executing scripts");
// D1: write() doesn't wait for a blocking script
promise_test(async (t) => {
window.events = [];
window.resolvers = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
// slow.js's response is delayed by this much (pipe=trickle(d0.3)).
const slowScriptDelayMs = 300;
const start = performance.now();
await writer.write(
"<script src='resources/slow.js?pipe=trickle(d0.3)&d1=1'><" + "/script>" +
"<p id='after-blocking'>after</p>" +
"<script>window.resolvers.resolve();<" + "/script>"
);
// Immediately after await writer.write(), slow.js has not finished loading.
// Only check this if its delay can't have elapsed yet, to avoid flakiness
// on slow machines.
if (performance.now() - start < slowScriptDelayMs) {
assert_array_equals(window.events, [], "Blocking script has not executed yet when write() resolves");
assert_equals(placeholder.querySelector("#after-blocking"), null, "Markup after blocking script is not parsed yet when write() resolves");
}
await writer.close();
await window.resolvers.promise;
assert_array_equals(window.events, ["slow"]);
assert_not_equals(placeholder.querySelector("#after-blocking"), null);
}, "write() resolves without waiting for a parser-blocking script to finish loading");
// D2: Chunks written while blocked are parsed after the script, in order;
// writes from inside the script go after the queued chunks.
promise_test(async (t) => {
window.events = [];
window.resolvers = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
window.on_slow = () => {
writer.write("<span id='from-inside-blocking'>inside</span><script>window.resolvers.resolve();<" + "/script>");
};
t.add_cleanup(() => { delete window.on_slow; });
await writer.write(`<span id="before">1</span><script src="resources/slow.js?pipe=trickle(d0.2)&d2=1"><` + `/script><span id="same-chunk">2</span>`);
await writer.write(`<span id="queued-chunk">3</span>`);
await window.resolvers.promise;
await writer.close();
const ids = Array.from(placeholder.querySelectorAll("span")).map(s => s.id);
assert_array_equals(ids, ["before", "same-chunk", "queued-chunk", "from-inside-blocking"]);
}, "Chunks written while blocked are parsed in order before writes initiated from inside the blocking script");
// D3: An inline script writing to its own stream: its text goes after the rest of the current chunk
promise_test(async (t) => {
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
let nestedWritePromise;
window.write_from_inline = () => {
nestedWritePromise = writer.write("<span id='nested'>nested</span>");
};
t.add_cleanup(() => { delete window.write_from_inline; });
await writer.write(
"<span id='first'>first</span>" +
"<script>window.write_from_inline();<" + "/script>" +
"<span id='rest-of-chunk'>rest</span>"
);
await nestedWritePromise;
await writer.close();
const ids = Array.from(placeholder.querySelectorAll("span")).map(s => s.id);
assert_array_equals(ids, ["first", "rest-of-chunk", "nested"]);
}, "Inline script writing to its own stream appends after the remainder of the current chunk");
// D4: close() while blocked waits for the blocking script and remaining input,
// but resolves before defer scripts run
promise_test(async (t) => {
window.events = [];
window.defer_done = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
const deferSrc = "data:text/javascript,window.events.push('defer');window.defer_done.resolve();";
await writer.write(
`<script defer src="${deferSrc}"><` + `/script>` +
`<script src="resources/slow.js?pipe=trickle(d0.3)&d4=1"><` + `/script>` +
`<p id="d4-after">after</p>` +
`<script>window.events.push('after-blocking');<` + `/script>`
);
const closePromise = writer.close();
t.add_cleanup(() => window.defer_done.promise);
assert_array_equals(window.events, [], "close() does not run the blocked parser synchronously");
assert_equals(placeholder.querySelector("#d4-after"), null);
await closePromise;
assert_not_equals(placeholder.querySelector("#d4-after"), null, "remaining tokens are parsed before close() resolves");
assert_array_equals(window.events, ["slow", "after-blocking"], "close() resolves after the blocking script and remaining inline scripts, before defer scripts");
await window.defer_done.promise;
assert_array_equals(window.events, ["slow", "after-blocking", "defer"]);
}, "close() while blocked waits for the blocking script and remaining input, then defer scripts run");
// D5: Unclosed <script> at close never runs; element is in the DOM and marked already started
promise_test(async (t) => {
window.unclosed_close_ran = false;
t.add_cleanup(() => { delete window.unclosed_close_ran; });
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer.write("<script id='unclosed-script'>window.unclosed_close_ran = true;");
await writer.close();
const script = placeholder.querySelector("#unclosed-script");
assert_not_equals(script, null, "Unclosed <script> element is in the DOM");
assert_false(window.unclosed_close_ran, "Unclosed <script> must not execute on close()");
// Because it is marked already started at EOF, modifying it or re-inserting it must not execute it.
script.appendChild(document.createTextNode("window.unclosed_close_ran = true;"));
assert_false(window.unclosed_close_ran, "Appending text to unclosed <script> after close() must not execute it");
}, "Unclosed <script> at close() never runs, stays in the DOM, and is marked already started");
// D6: A rejected write aborts the parser so a pending blocking script never runs and buffered markup is not inserted
for (const rejectKind of ["symbol", "toStringThrow", "displacedRef"]) {
promise_test(async (t) => {
window.events = [];
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const ref = document.createElement("span");
placeholder.appendChild(ref);
const writer = ref.streamBeforeHTMLUnsafe({ runScripts: true }).getWriter();
await writer.write(
"<script src='resources/slow.js?pipe=trickle(d0.2)&d6=" + rejectKind + "'><" + "/script>" +
"<p id='buffered-after-blocking'>buffered</p>"
);
if (rejectKind === "symbol") {
await promise_rejects_js(t, TypeError, writer.write(Symbol("bad")));
} else if (rejectKind === "toStringThrow") {
await promise_rejects_js(t, Error, writer.write({ toString() { throw new Error("boom"); } }));
} else {
ref.remove();
await promise_rejects_dom(t, "HierarchyRequestError", writer.write("more"));
}
await new Promise(r => t.step_timeout(r, 350));
assert_array_equals(window.events, [], `Pending blocking script must not execute after rejected write (${rejectKind})`);
assert_equals(placeholder.querySelector("#buffered-after-blocking"), null, `Buffered markup after blocking script must not be inserted after rejected write (${rejectKind})`);
}, `Rejected write (${rejectKind}) stops the parser and prevents pending blocking script and buffered markup from running`);
}
// D7: Abort with an unclosed <script> marks it already started so later DOM mutation does not execute it
promise_test(async (t) => {
window.aborted_script_ran = false;
t.add_cleanup(() => { delete window.aborted_script_ran; });
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer.write("<script id='aborted-open'>window.aborted_script_ran = true;");
await writer.abort();
const script = placeholder.querySelector("#aborted-open");
assert_not_equals(script, null);
assert_false(window.aborted_script_ran);
script.appendChild(document.createTextNode("window.aborted_script_ran = true;"));
assert_false(window.aborted_script_ran, "Aborted unclosed <script> is marked already started");
}, "Abort with an unclosed <script> marks it already started");
// F4: A blocking script whose src 404s fires error and parsing continues;
// close() still resolves once the remaining input has been parsed
promise_test(async (t) => {
window.events = [];
window.resolvers = Promise.withResolvers();
t.add_cleanup(() => window.resolvers.promise);
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer.write(
"<script src='resources/nonexistent-404.js' onerror=\"window.events.push('error')\"><" + "/script>" +
"<p id='after-404'>continued</p>" +
"<script>window.events.push('after-404'); window.resolvers.resolve();<" + "/script>"
);
await writer.close();
assert_array_equals(window.events, ["error", "after-404"]);
assert_equals(placeholder.querySelector("#after-404").textContent, "continued");
}, "Blocking script whose src 404s fires error event, parsing continues, and close() resolves");
// F5: Live document style sheets don't block streamed inline, blocking, or defer scripts
promise_test(async (t) => {
window.events = [];
window.ext_done = Promise.withResolvers();
window.defer_done = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "resources/slow.css?pipe=trickle(d2)&f5=1";
document.head.appendChild(link);
t.add_cleanup(() => link.remove());
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer.write(
"<script defer src=\"data:text/javascript,window.events.push('defer');window.defer_done.resolve();\"><" + "/script>" +
"<script>window.events.push('inline');<" + "/script>" +
"<script src=\"data:text/javascript,window.events.push('external');window.ext_done.resolve();\"><" + "/script>"
);
assert_true(window.events.includes("inline"), "Streamed inline script is not blocked by pending stylesheet in live document");
await window.ext_done.promise;
assert_array_equals(window.events, ["inline", "external"]);
await writer.close();
await window.defer_done.promise;
assert_array_equals(window.events, ["inline", "external", "defer"]);
}, "Live document script-blocking stylesheets do not block streamed inline, external, or defer scripts");
// F6: document.currentScript is the <script> element in light DOM and null in a ShadowRoot
promise_test(async (t) => {
window.captured_current_script = undefined;
t.add_cleanup(() => { delete window.captured_current_script; });
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer.write("<script id='cs-light'>window.captured_current_script = document.currentScript;<" + "/script>");
await writer.close();
assert_equals(window.captured_current_script, placeholder.querySelector("#cs-light"));
const host = document.createElement("div");
placeholder.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
const shadowWriter = shadow.streamHTMLUnsafe({ runScripts: true }).getWriter();
await shadowWriter.write("<script id='cs-shadow'>window.captured_current_script = document.currentScript;<" + "/script>");
await shadowWriter.close();
assert_equals(window.captured_current_script, null, "document.currentScript must be null inside a ShadowRoot");
}, "document.currentScript is the script element in light DOM and null in a ShadowRoot");
// F7: document.write() from a streamed script
promise_test(async (t) => {
const iframe = document.createElement("iframe");
t.add_cleanup(() => iframe.remove());
iframe.srcdoc = "<!doctype html><body><div id='target'>initial</div></body>";
await new Promise(resolve => {
iframe.addEventListener("load", resolve, { once: true });
document.body.appendChild(iframe);
});
const target = iframe.contentDocument.getElementById("target");
const extDone = Promise.withResolvers();
iframe.contentWindow.extDone = extDone;
// External streamed script calling document.write() is ignored because ignore-destructive-writes counter > 0
const writer1 = target.streamHTMLUnsafe({ runScripts: true }).getWriter();
const extCode = "document.write('<p id=destroyed>destroyed</p>'); window.extDone.resolve();";
await writer1.write(`<script src="data:text/javascript,${encodeURIComponent(extCode)}"><` + `/script><span id="kept">kept</span>`);
await writer1.close();
await extDone.promise;
assert_equals(iframe.contentDocument.getElementById("destroyed"), null, "External script document.write is ignored");
assert_not_equals(iframe.contentDocument.getElementById("kept"), null);
// Inline streamed script calling document.write() on a closed document performs an implicit document.open()
const writer2 = target.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer2.write("<script>document.write('<p id=overwritten>overwritten</p>'); document.close();<" + "/script>");
await writer2.close();
assert_not_equals(iframe.contentDocument.getElementById("overwritten"), null, "Inline script document.write implicitly opens document");
}, "document.write() from external streamed script is ignored; from inline streamed script implicitly opens document");
// F8: Two streams block independently
promise_test(async (t) => {
window.events = [];
window.stream1_done = Promise.withResolvers();
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const target1 = document.createElement("div");
const target2 = document.createElement("div");
placeholder.append(target1, target2);
const writer1 = target1.streamHTMLUnsafe({ runScripts: true }).getWriter();
const writer2 = target2.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer1.write(
"<script src='resources/slow.js?pipe=trickle(d0.3)&f8=1'><" + "/script>" +
"<script>window.events.push('stream1-after'); window.stream1_done.resolve();<" + "/script>"
);
// While writer1 is blocked on slow.js, writer2 should parse and execute its scripts immediately:
await writer2.write("<p id='t2-p'>t2</p><script>window.events.push('stream2-inline');<" + "/script>");
assert_not_equals(target2.querySelector("#t2-p"), null);
assert_array_equals(window.events, ["stream2-inline"], "Second stream is not blocked by first stream");
await writer2.close();
await writer1.close();
await window.stream1_done.promise;
assert_array_equals(window.events, ["stream2-inline", "slow", "stream1-after"]);
}, "Two concurrent streams block independently");
// F12: Iframe removed while blocked: no execution, no crash
promise_test(async (t) => {
window.iframe_blocked_ran = false;
t.add_cleanup(() => { delete window.iframe_blocked_ran; });
const iframe = document.createElement("iframe");
iframe.srcdoc = "<!doctype html><body><div id='target'></div></body>";
await new Promise(resolve => {
iframe.addEventListener("load", resolve, { once: true });
document.body.appendChild(iframe);
});
const target = iframe.contentDocument.getElementById("target");
const writer = target.streamHTMLUnsafe({ runScripts: true }).getWriter();
await writer.write(
"<script src='resources/slow.js?pipe=trickle(d0.2)&f12=1'><" + "/script>" +
"<script>parent.iframe_blocked_ran = true;<" + "/script>"
);
iframe.remove();
await new Promise(r => t.step_timeout(r, 350));
assert_false(window.iframe_blocked_ran, "Scripts in removed iframe must not execute");
}, "Removing iframe while stream is blocked on external script does not crash or execute scripts");
// The write promise does not wait for a pending parser-blocking script,
// whereas the close promise waits until the parser stops parsing (after any
// pending parser-blocking script has executed and any remaining input has
// been parsed). Once closing has started, the stream can no longer be
// aborted.
for (const method of ["streamHTMLUnsafe", "streamAppendHTMLUnsafe", "streamPrependHTMLUnsafe"]) {
promise_test(async (t) => {
window.events = [];
const done = Promise.withResolvers();
window.resolve_second = done.resolve;
t.add_cleanup(() => done.promise.then(() => { delete window.resolve_second; }));
const placeholder = document.getElementById("placeholder");
placeholder.replaceChildren();
const writer = placeholder[method]({ runScripts: true }).getWriter();
const slowScriptDelayMs = 500;
const start = performance.now();
await writer.write(
"<p id='before-blocking'>before</p>" +
"<script src='resources/slow.js?pipe=trickle(d0.5)&close=1'></" + "script>" +
"<p id='after-blocking'>after</p>"
);
assert_array_equals(window.events, [], "the write promise does not wait for the parser-blocking script");
assert_not_equals(placeholder.querySelector("#before-blocking"), null);
assert_equals(placeholder.querySelector("#after-blocking"), null, "parsing is blocked on the script");
await writer.write("<p id='second-chunk'>second</p><script>window.resolve_second();</" + "script>");
assert_array_equals(window.events, [], "a subsequent write promise does not wait for the parser-blocking script either");
assert_equals(placeholder.querySelector("#second-chunk"), null);
const closePromise = writer.close();
let closeSettled = false;
closePromise.then(() => { closeSettled = true; });
await Promise.resolve();
if (performance.now() - start < slowScriptDelayMs) {
assert_false(closeSettled, "the close promise has not settled while the parser-blocking script is still pending");
assert_array_equals(window.events, []);
assert_equals(placeholder.querySelector("#after-blocking"), null);
}
// Once closing has started, abort() is a no-op and write() rejects.
await writer.abort("too late");
await promise_rejects_js(t, TypeError, writer.write("<p id='after-close'>x</p>"));
await closePromise;
assert_array_equals(window.events, ["slow"], "the pending parser-blocking script has executed when close() resolves");
assert_not_equals(placeholder.querySelector("#after-blocking"), null, "markup after the blocking script is inserted before close() resolves");
assert_not_equals(placeholder.querySelector("#second-chunk"), null, "subsequent chunks are parsed before close() resolves");
assert_equals(placeholder.querySelector("#after-close"), null);
}, `${method}: close() waits for a pending parser-blocking script and remaining input`);
}
</script>