perf(core): a preview's composition scripts give the page a turn every 50 ms, not one long task (#5713)

This commit is contained in:
Miguel Ángel
2026-10-16 23:16:08 +00:00
committed by GitHub
parent b1cf6e3c51
commit 12d8df37f4
2 changed files with 56 additions and 1 deletions
+24
View File
@@ -82,6 +82,24 @@ function runInPlace(el: Element): HTMLScriptElement | undefined {
return script;
}
export const PREVIEW_SCRIPT_SLICE_MS = 50;
/** A render's virtual clock holds every timer until it advances, so only a preview gives the page turns. */
const givesTurns = () => !(window as { __HF_VIRTUAL_TIME__?: unknown }).__HF_VIRTUAL_TIME__;
function nextUnclampedTask(): Promise<void> {
const scheduler = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
if (typeof scheduler?.yield === "function") return scheduler.yield();
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
channel.port1.close();
resolve();
};
channel.port2.postMessage(null);
});
}
const loaded = (script: HTMLScriptElement) =>
new Promise((resolve) => {
script.addEventListener("load", resolve);
@@ -113,7 +131,13 @@ export async function runScriptsAfterFonts(
const isLate = (el: Element) => typeAfterFonts(el) === "module" || el.matches(DEFERRED_FILE);
const held: HeldListener[] = [];
const release = [document, window].map((target) => holdPassedLoadEvents(target, held));
const turns = givesTurns();
let slice = performance.now();
for (const el of [...scripts.filter((el) => !isLate(el)), ...scripts.filter(isLate)]) {
if (turns && performance.now() - slice > PREVIEW_SCRIPT_SLICE_MS) {
await nextUnclampedTask();
slice = performance.now();
}
const script = runInPlace(el);
if (script?.hasAttribute("src") && !script.async && !script.noModule) await loaded(script);
else if (typeAfterFonts(el) === "module") await afterQueuedModules();
@@ -4,7 +4,7 @@ import {
AFTER_FONTS_SCRIPT_TYPE,
deferScriptsUntilFonts,
} from "../compiler/scriptRuns";
import { FONT_WAIT_TIMEOUT_MS } from "./afterFonts";
import { FONT_WAIT_TIMEOUT_MS, PREVIEW_SCRIPT_SLICE_MS } from "./afterFonts";
type Face = { family: string; status: FontFaceLoadStatus };
@@ -89,6 +89,37 @@ describe("runtime entry: composition scripts after web fonts", () => {
expect(document.querySelectorAll(`script[type="${AFTER_FONTS_SCRIPT_TYPE}"]`)).toHaveLength(0);
});
it.each([
[
"a preview gives the page a turn once a slice of scripts has run",
false,
"a in-place turn b ready ",
],
[
"a render's virtual clock gets no turns, so its scripts run back to back",
true,
"a in-place b ready ",
],
])("%s", async (_name, rendering, expected) => {
const clock = [0, 0, PREVIEW_SCRIPT_SLICE_MS + 1];
vi.spyOn(performance, "now").mockImplementation(
() => clock.shift() ?? PREVIEW_SCRIPT_SLICE_MS + 1,
);
const turn = vi.fn(async () => {
document.getElementById("log")!.textContent += "turn ";
});
vi.stubGlobal("scheduler", { yield: turn });
if (rendering) vi.stubGlobal("__HF_VIRTUAL_TIME__", {});
serveFonts(Promise.resolve(), []);
mountDeferredScripts();
await parseThenLoad();
await vi.waitFor(() => expect(window.__player).toBeDefined());
expect(log()).toBe(expected);
expect(turn).toHaveBeenCalledTimes(rendering ? 0 : 1);
vi.unstubAllGlobals();
});
it("keeps an addEventListener wrapper a deferred script installs while its load events are held", async () => {
serveFonts(Promise.resolve(), []);
document.body.innerHTML =