From 0d725fdf8d7a905084410db3ef5659d1039e4844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 7 Jun 2026 03:33:46 +0000 Subject: [PATCH 1/7] fix: batch GSAP timeline construction to prevent main-thread hang (#1231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compositions with thousands of tl.to() calls (e.g. 8,562 in the reported case) block Chrome's main thread synchronously during HTML parsing, preventing DOMContentLoaded from firing before Puppeteer's navigation timeout. This caused render jobs to hang indefinitely at 'Initializing calibration session...' with no error message. Root cause: GSAP's timeline API is synchronous — each tl.to() call registers a tween immediately on the main thread. A script with 8k+ calls holds the thread for seconds, starving the browser event loop and delaying DCL past the navigation timeout window. Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected at the top of , before GSAP or user scripts load). When GSAP assigns itself to window.gsap, the setter intercepts the real gsap object and wraps gsap.timeline() to return a proxy that queues tween descriptors (to/from/fromTo/set) instead of calling them synchronously. A requestAnimationFrame-based flush loop drains 100 tweens per frame, yielding the main thread between batches so DCL can fire. When the queue is drained, the stub sets window.__hfTimelinesBuilding = false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks this flag at DOMContentLoaded time; if building is still in progress it defers bindRootTimelineIfAvailable() until the event fires, then sets window.__renderReady = true as normal. pollHfReady continues to gate on both __renderReady and window.__hf.duration > 0, so the render pipeline does not start until the full timeline is bound. - Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale) - Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0)) - Determinism: 'hf-timelines-built' event guarantees sequencing - Proxy forwards: pause/seek/totalTime/time/duration/add/paused/ timeScale/play delegate to the real timeline immediately - No GSAP package changes; no navigation timeout increase Fixes #1231 --- packages/core/src/runtime/init.ts | 21 ++ packages/core/src/runtime/window.d.ts | 11 + packages/producer/package.json | 5 +- .../producer/scripts/build-hf-early-stub.ts | 73 +++++ .../src/generated/hf-early-stub-inline.ts | 11 + packages/producer/src/services/fileServer.ts | 17 +- packages/producer/stubs/hf-early-stub.ts | 268 ++++++++++++++++++ 7 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 packages/producer/scripts/build-hf-early-stub.ts create mode 100644 packages/producer/src/generated/hf-early-stub-inline.ts create mode 100644 packages/producer/stubs/hf-early-stub.ts diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 6b62d3cfb6..1a1bff21e8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1717,6 +1717,27 @@ export function initSandboxRuntimeModular(): void { // fileServer.ts sets this immediately (no timeline to bind in its runtime). window.__renderReady = true; + // When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is + // active, composition scripts queue tl.to() calls instead of executing them + // synchronously. The queue drains via requestAnimationFrame after DCL — so + // window.__timelines may contain proxy timelines that have no tweens yet. + // Wait for the "hf-timelines-built" event before rebinding to ensure the real + // GSAP timeline has its full tween sequence attached. + if (window.__hfTimelinesBuilding) { + const onTimelinesBuilt = () => { + window.removeEventListener("hf-timelines-built", onTimelinesBuilt); + const prevTimeline = state.capturedTimeline; + if (bindRootTimelineIfAvailable() && state.capturedTimeline !== prevTimeline) { + player._timeline = state.capturedTimeline; + } + runAdapters("discover", state.currentTime); + window.__renderReady = true; + postTimeline(); + postState(true); + }; + window.addEventListener("hf-timelines-built", onTimelinesBuilt); + } + // When the bundler inlines compositions, data-composition-src is removed so // loadExternalCompositions() is skipped. But inline scripts registering child // timelines in __timelines haven't executed yet (they run in the browser's next diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index c43782c530..fdd920ec1b 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -105,6 +105,17 @@ declare global { * resolved values for the instance currently executing. */ __hfVariablesByComp?: Record>; + /** + * Set to `true` while the GSAP tween-batching interceptor (injected via + * HF_EARLY_STUB in fileServer.ts) is still draining queued tween calls + * through requestAnimationFrame batches. Cleared and the "hf-timelines-built" + * CustomEvent is dispatched when all queues are empty. + * + * init.ts uses this to decide whether to defer `bindRootTimelineIfAvailable`: + * if true at DOMContentLoaded time, it adds a one-shot event listener and + * rebinds after the event fires. + */ + __hfTimelinesBuilding?: boolean; } } diff --git a/packages/producer/package.json b/packages/producer/package.json index 1b3358ae34..52e4e59c3d 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -31,8 +31,9 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "build": "bun run build:fonts && bun run --cwd ../.. build:hyperframes-runtime:modular && node build.mjs", - "build:fonts": "node scripts/build-fonts.mjs", + "build": "bun run build:fonts && bun run build:hf-early-stub && bun run --cwd ../.. build:hyperframes-runtime:modular && node build.mjs", + "build:fonts": "node scripts/build-fonts.mjs", + "build:hf-early-stub": "bun run scripts/build-hf-early-stub.ts", "typecheck": "tsc --noEmit", "parity:check": "tsx src/parity-harness.ts", "parity:fixtures": "tsx src/parity-fixtures.ts", diff --git a/packages/producer/scripts/build-hf-early-stub.ts b/packages/producer/scripts/build-hf-early-stub.ts new file mode 100644 index 0000000000..b1f0f07e04 --- /dev/null +++ b/packages/producer/scripts/build-hf-early-stub.ts @@ -0,0 +1,73 @@ +/** + * Build script: compile stubs/hf-early-stub.ts → src/generated/hf-early-stub-inline.ts + * + * Run via: bun run scripts/build-hf-early-stub.ts + * (also called automatically as part of `bun run build`) + * + * Output format mirrors packages/core/scripts/build-hyperframes-runtime-artifact.ts: + * a TypeScript module exporting a single string-constant getter that is + * compiled by tsc into dist/ — no esbuild, no file I/O, no dynamic paths at + * runtime. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildSync } from "esbuild"; + +const thisDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(thisDir, ".."); + +const stubEntry = resolve(repoRoot, "stubs/hf-early-stub.ts"); +const generatedDir = resolve(repoRoot, "src/generated"); +const outPath = resolve(generatedDir, "hf-early-stub-inline.ts"); + +// ── Compile the stub to a self-contained IIFE ───────────────────────────────── +const result = buildSync({ + entryPoints: [stubEntry], + bundle: true, + write: false, + platform: "browser", + format: "iife", + target: ["es2020"], + // Minify for production — the stub is injected on every page load. + minify: true, + legalComments: "none", +}); + +const iife = result.outputFiles[0]?.text ?? ""; +if (!iife) { + throw new Error("esbuild produced no output for hf-early-stub.ts"); +} + +// ── Write the generated module ──────────────────────────────────────────────── +mkdirSync(generatedDir, { recursive: true }); + +const escaped = JSON.stringify(iife); +writeFileSync( + outPath, + [ + "// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit", + `const HF_EARLY_STUB_IIFE: string = ${escaped};`, + "", + "/**", + " * Returns the pre-built HyperFrames early stub IIFE as a string constant.", + " * Inject into before any other scripts so the GSAP batching", + " * interceptor is in place when user composition scripts run.", + " */", + "export function getHfEarlyStub(): string {", + " return HF_EARLY_STUB_IIFE;", + "}", + "", + ].join("\n"), + "utf8", +); + +console.log( + JSON.stringify({ + event: "hf_early_stub_generated", + stubEntry, + outPath, + bytes: Buffer.byteLength(iife, "utf8"), + }), +); diff --git a/packages/producer/src/generated/hf-early-stub-inline.ts b/packages/producer/src/generated/hf-early-stub-inline.ts new file mode 100644 index 0000000000..06bee5728f --- /dev/null +++ b/packages/producer/src/generated/hf-early-stub-inline.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit +const HF_EARLY_STUB_IIFE: string = "\"use strict\";(()=>{var l=100,a=[],t=!1;function w(){t=!1;let o=!1;for(let e of a){if(e.__hfQueue.length===0)continue;let n=e.__hfQueue.splice(0,l),i=e.__hfReal;for(let r of n){let s=i[r.method];typeof s==\"function\"&&s.call(i,...r.args)}e.__hfQueue.length>0&&(o=!0)}if(o)t=!0,requestAnimationFrame(w);else{window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent(\"hf-timelines-built\"))}catch{}}}function u(){t||(t=!0,window.__hfTimelinesBuilding=!0,requestAnimationFrame(w))}function f(o){let e={__hfReal:o,__hfQueue:[],to(...n){return e.__hfQueue.push({method:\"to\",args:n}),u(),e},from(...n){return e.__hfQueue.push({method:\"from\",args:n}),u(),e},fromTo(...n){return e.__hfQueue.push({method:\"fromTo\",args:n}),u(),e},set(...n){return e.__hfQueue.push({method:\"set\",args:n}),u(),e},pause(...n){return o.pause(...n),e},play(...n){return o.play(...n),e},seek(...n){return o.seek(...n),e},add(...n){return o.add(...n),e},totalTime(...n){return o.totalTime(...n)},time(...n){return o.time(...n)},duration(...n){return o.duration(...n)},paused(...n){return o.paused(...n)},timeScale(...n){return o.timeScale(...n)},kill(){o.kill()}};return a.push(e),e}if(typeof window<\"u\"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;let o=null;try{Object.defineProperty(window,\"gsap\",{configurable:!0,enumerable:!0,get(){return o},set(e){if(o=e,!e||typeof e.timeline!=\"function\")return;let n=e.timeline.bind(e);e.timeline=i=>f(n(i))}})}catch{}}})();\n"; + +/** + * Returns the pre-built HyperFrames early stub IIFE as a string constant. + * Inject into before any other scripts so the GSAP batching + * interceptor is in place when user composition scripts run. + */ +export function getHfEarlyStub(): string { + return HF_EARLY_STUB_IIFE; +} diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 17c383a985..be083ccd4d 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -14,6 +14,7 @@ import { readFileSync, existsSync, realpathSync, statSync } from "node:fs"; import { join, extname, resolve, sep } from "node:path"; import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; +import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js"; export { injectScriptsAtHeadStart }; @@ -415,20 +416,18 @@ const RENDER_MODE_SCRIPT = `(function() { waitForPlayer(); })();`; + /** * Early stub: ensures `window.__hf` exists *before* any user `