From 12d8df37f4db8ccf019da32c97cc1352f8db9652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 16 Oct 2026 23:16:08 +0000 Subject: [PATCH] perf(core): a preview's composition scripts give the page a turn every 50 ms, not one long task (#5713) --- packages/core/src/runtime/afterFonts.ts | 24 ++++++++++++++ .../core/src/runtime/entry.afterFonts.test.ts | 33 ++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/afterFonts.ts b/packages/core/src/runtime/afterFonts.ts index 08a0bccff4..389b75d91e 100644 --- a/packages/core/src/runtime/afterFonts.ts +++ b/packages/core/src/runtime/afterFonts.ts @@ -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 { + const scheduler = (globalThis as { scheduler?: { yield?: () => Promise } }).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(); diff --git a/packages/core/src/runtime/entry.afterFonts.test.ts b/packages/core/src/runtime/entry.afterFonts.test.ts index 471d11ea63..01c76bb46a 100644 --- a/packages/core/src/runtime/entry.afterFonts.test.ts +++ b/packages/core/src/runtime/entry.afterFonts.test.ts @@ -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 =