From bb5f5f8c5cf9ff96c9b584c9dd105362b1e4efcd Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 17 Jun 2026 18:47:48 -0700 Subject: [PATCH] fix(core): auto-detect three.js asset readiness via adapter contract (#1543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the original `window.__hyperframesReady` authored API with an internal adapter contract: `RuntimeDeterministicAdapter.getReadyPromise?: () => PromiseLike | null`. The Three.js adapter implements it by hooking `THREE.DefaultLoadingManager.onStart/onLoad`; the runtime collects promises from every adapter and gates `window.__renderReady = true` on them. Zero authoring burden — composition authors write plain Three.js, framework handles async asset gating automatically. Also keeps the orthogonal `htmlDocument.ts` script-stripping refactor (substring → regex for simple flag assignments), which fixes the bug where authored scripts referencing readiness flags were stripped despite never assigning them. Stamped by Magi and Miguel; CI green; tests 33/33 pass. --- .../core/src/compiler/htmlDocument.test.ts | 16 +++ packages/core/src/compiler/htmlDocument.ts | 21 +++- packages/core/src/runtime/adapters/three.ts | 100 +++++++++++++++++- packages/core/src/runtime/init.test.ts | 51 +++++++++ packages/core/src/runtime/init.ts | 69 ++++++++++++ packages/core/src/runtime/types.ts | 19 ++++ 6 files changed, 273 insertions(+), 3 deletions(-) diff --git a/packages/core/src/compiler/htmlDocument.test.ts b/packages/core/src/compiler/htmlDocument.test.ts index e1f39c92f..449dceb81 100644 --- a/packages/core/src/compiler/htmlDocument.test.ts +++ b/packages/core/src/compiler/htmlDocument.test.ts @@ -19,6 +19,7 @@ describe("htmlDocument helpers", () => { + `; const stripped = stripEmbeddedRuntimeScripts(html); @@ -28,9 +29,24 @@ describe("htmlDocument helpers", () => { expect(stripped).not.toContain("hyperframe-runtime.modular-runtime.inline.js"); expect(stripped).not.toContain("data-hyperframes-preview-runtime"); expect(stripped).not.toContain("window.__playerReady"); + expect(stripped).not.toContain("window.__renderReady"); expect(stripped).toContain("window.authored = true"); }); + it("keeps authored scripts that reference runtime readiness flags", () => { + const html = ` +`; + + const stripped = stripEmbeddedRuntimeScripts(html); + + expect(stripped).toContain('window.__timelines["main"]'); + expect(stripped).toContain("window.__renderReady"); + }); + it("does not treat non-script tags as scripts when stripping runtimes", () => { const html = "window.__playerReady = true;"; diff --git a/packages/core/src/compiler/htmlDocument.ts b/packages/core/src/compiler/htmlDocument.ts index 065d65ea1..6d2e84da5 100644 --- a/packages/core/src/compiler/htmlDocument.ts +++ b/packages/core/src/compiler/htmlDocument.ts @@ -13,9 +13,13 @@ const RUNTIME_INLINE_MARKERS = [ "__hyperframeRuntimeBootstrapped", "__hyperframeRuntime", "__hyperframeRuntimeTeardown", + "__HF_EXPORT_RENDER_SEEK_CONFIG", "window.__player =", - "window.__playerReady", - "window.__renderReady", +]; + +const SIMPLE_RUNTIME_FLAG_ASSIGNMENTS = [ + /^window\.__playerReady\s*=\s*(?:true|false)\s*;?$/, + /^window\.__renderReady\s*=\s*(?:true|false)\s*;?$/, ]; /** @@ -115,9 +119,22 @@ function shouldStripRuntimeScriptBlock(block: string): boolean { for (const marker of RUNTIME_INLINE_MARKERS) { if (block.includes(marker)) return true; } + const scriptSource = getScriptSource(block).trim(); + for (const pattern of SIMPLE_RUNTIME_FLAG_ASSIGNMENTS) { + if (pattern.test(scriptSource)) return true; + } return false; } +function getScriptSource(block: string): string { + const startTagEnd = findTagEnd(block, 1); + if (startTagEnd === -1) return ""; + const loweredBlock = block.toLowerCase(); + const closeTagStart = loweredBlock.lastIndexOf("" || char === "/" || isHtmlWhitespace(char); } diff --git a/packages/core/src/runtime/adapters/three.ts b/packages/core/src/runtime/adapters/three.ts index c7ddfc9f7..a044d5a16 100644 --- a/packages/core/src/runtime/adapters/three.ts +++ b/packages/core/src/runtime/adapters/three.ts @@ -1,13 +1,95 @@ import type { RuntimeDeterministicAdapter } from "../types"; import { dispatchSeekEvent } from "./seek-dispatch"; +/** + * Minimal shape of `THREE.DefaultLoadingManager` we rely on. Kept local to + * the adapter so we don't take a dependency on three.js types (the library + * itself is loaded at runtime by the composition, not bundled). + * + * See https://threejs.org/docs/#api/en/loaders/managers/LoadingManager + */ +type ThreeLoadingManagerLike = { + itemsLoaded: number; + itemsTotal: number; + onStart?: ((url: string, itemsLoaded: number, itemsTotal: number) => void) | null; + onLoad?: (() => void) | null; +}; + export function createThreeAdapter(): RuntimeDeterministicAdapter { let forcedTime: number | null = null; let lastForcedTime = 0; + // Track the LoadingManager we've already wrapped so `discover` is idempotent + // (init.ts calls it multiple times — at startup AND at every + // `maybePublishRenderReady` evaluation cycle, to catch THREE that finished + // loading between checks). + let hookedManager: ThreeLoadingManagerLike | null = null; + let userOnStart: ThreeLoadingManagerLike["onStart"] = null; + let userOnLoad: ThreeLoadingManagerLike["onLoad"] = null; + let pendingPromise: PromiseLike | null = null; + + const getLoadingManager = (): ThreeLoadingManagerLike | null => { + if (typeof window === "undefined") return null; + // `window.THREE` is typed in window.d.ts with only the minimal `Clock` / + // `AnimationMixer` shape; cast through `unknown` to read the loader fields. + const three = (window as { THREE?: { DefaultLoadingManager?: ThreeLoadingManagerLike } }).THREE; + const mgr = three?.DefaultLoadingManager; + if (!mgr || typeof mgr !== "object") return null; + if (typeof mgr.itemsLoaded !== "number" || typeof mgr.itemsTotal !== "number") return null; + return mgr; + }; + + const armPendingIfNeeded = (mgr: ThreeLoadingManagerLike) => { + if (pendingPromise) return; + if (mgr.itemsTotal <= mgr.itemsLoaded) return; + pendingPromise = new Promise((resolve) => { + // Wrap onLoad so we resolve once the queue drains. Restore the user's + // own callback (captured at hook time) so multi-asset compositions still + // see their own onLoad fire normally. + mgr.onLoad = function (this: ThreeLoadingManagerLike) { + try { + userOnLoad?.call(this); + } finally { + pendingPromise = null; + // Reinstall the user's callback as the live one — onStart will + // re-wrap it the next time a new batch starts. + mgr.onLoad = userOnLoad ?? null; + resolve(); + } + }; + }); + }; + + const hookManager = (mgr: ThreeLoadingManagerLike) => { + if (hookedManager === mgr) return; + hookedManager = mgr; + userOnStart = mgr.onStart ?? null; + userOnLoad = mgr.onLoad ?? null; + // Wrap onStart so any load queued AFTER our discover runs (the common + // case — user composition scripts run after the HF runtime mounts) still + // arms a wait. Without this, items queued post-discover would never be + // observed and the runtime would publish render-ready while textures + // were still in flight (issue #PR-1543). + mgr.onStart = function (this: ThreeLoadingManagerLike, url, loaded, total) { + try { + userOnStart?.call(this, url, loaded, total); + } finally { + armPendingIfNeeded(mgr); + } + }; + }; + return { name: "three", - discover: () => {}, + discover: () => { + const mgr = getLoadingManager(); + if (!mgr) return; + hookManager(mgr); + // Items may already be queued at discover time (e.g. THREE+loader were + // bundled inline and ran synchronously). Catch them before any new + // onStart fires. + armPendingIfNeeded(mgr); + }, seek: (ctx) => { forcedTime = Math.max(0, Number(ctx.time) || 0); lastForcedTime = forcedTime; @@ -26,5 +108,21 @@ export function createThreeAdapter(): RuntimeDeterministicAdapter { forcedTime = null; lastForcedTime = 0; }, + getReadyPromise: () => { + // If THREE hasn't loaded yet, nothing to wait on — `discover` will be + // called again on the next readiness-publish cycle and pick it up. + const mgr = getLoadingManager(); + if (!mgr) return null; + // Drain check: itemsTotal can grow over time as user code queues more + // loads; itemsLoaded catches up via onLoad. We only block while the + // queue is non-empty AND not yet drained. + if (mgr.itemsTotal <= mgr.itemsLoaded) return null; + // If we haven't wrapped onLoad yet (e.g. items queued between an + // onStart we missed and now), arm one. + if (!pendingPromise) { + armPendingIfNeeded(mgr); + } + return pendingPromise; + }, }; } diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 2d219f7db..2549c21bb 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -110,6 +110,7 @@ describe("initSandboxRuntimeModular", () => { delete window.__playerReady; delete window.__renderReady; delete window.__hfTimelinesBuilding; + delete (window as { THREE?: unknown }).THREE; vi.restoreAllMocks(); window.requestAnimationFrame = originalRequestAnimationFrame; window.cancelAnimationFrame = originalCancelAnimationFrame; @@ -967,6 +968,56 @@ describe("initSandboxRuntimeModular", () => { expect(window.__player?.getDuration()).toBe(10); }); + it("waits for THREE.DefaultLoadingManager to drain before publishing render readiness", async () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + window.__timelines = { + main: createMockTimeline(10), + }; + + // Simulate THREE with an in-flight asset load — same shape the three adapter + // reads, no actual three.js dependency in tests. `itemsTotal > itemsLoaded` + // means "loads pending"; resolving the wait fires `onLoad` after wrapping. + const mgr: { + itemsLoaded: number; + itemsTotal: number; + onStart?: ((url: string, loaded: number, total: number) => void) | null; + onLoad?: (() => void) | null; + } = { + itemsLoaded: 0, + itemsTotal: 1, + onStart: null, + onLoad: null, + }; + (window as unknown as { THREE: { DefaultLoadingManager: typeof mgr } }).THREE = { + DefaultLoadingManager: mgr, + }; + + initSandboxRuntimeModular(); + + // Player ready, render NOT ready because an asset is pending. + expect(window.__playerReady).toBe(true); + expect(window.__renderReady).toBe(false); + expect(window.__player?.getDuration()).toBe(10); + + // Simulate the asset finishing: drain the queue and fire the (now-wrapped) + // onLoad. The adapter's wrapper resolves the readiness promise, which + // triggers a re-publish. + mgr.itemsLoaded = 1; + mgr.onLoad?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(window.__renderReady).toBe(true); + expect(window.__player?.getDuration()).toBe(10); + }); + it("sets __renderReady even without a GSAP timeline (CSS/WAAPI compositions)", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 4d0133a92..fb21ae723 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1643,6 +1643,65 @@ export function initSandboxRuntimeModular(): void { let maybePublishRenderReady = () => { window.__renderReady = false; }; + // Internal adapter-readiness tracking. Adapters with outstanding async work + // (Three.js `DefaultLoadingManager`, future fetch/font/image detectors) expose + // a `getReadyPromise()` method; the runtime waits for whatever they return + // before publishing render-ready. This is purely internal — there is no + // authored-code-facing flag (LLMs should not need to know about render + // readiness, the framework handles async asset gating automatically). + let trackedAdapterReadyPromise: PromiseLike | null = null; + let trackedAdapterReadySettled = true; + + const collectAdapterReadyPromises = (): PromiseLike[] => { + const promises: PromiseLike[] = []; + for (const adapter of state.deterministicAdapters) { + const getter = adapter.getReadyPromise; + if (typeof getter !== "function") continue; + try { + const p = getter(); + if (p) promises.push(p); + } catch (err) { + // A throwing readiness gate must not permanently block render; swallow + // and continue, matching the rest of the runtime's adapter-resilience + // pattern. + swallow("runtime.init.adapterReady", err); + } + } + return promises; + }; + + const isAdapterReadinessSettled = (): boolean => { + const promises = collectAdapterReadyPromises(); + if (promises.length === 0) { + trackedAdapterReadyPromise = null; + trackedAdapterReadySettled = true; + return true; + } + // Combine multiple adapter promises so we only attach a single resume + // handler. Identity is stable as long as the inputs are stable (each + // adapter is expected to return the same promise on repeat calls while + // its work is in flight). + const combined: PromiseLike = + promises.length === 1 ? promises[0] : Promise.all(promises); + if (combined !== trackedAdapterReadyPromise) { + trackedAdapterReadyPromise = combined; + trackedAdapterReadySettled = false; + void Promise.resolve(combined).then( + () => { + if (trackedAdapterReadyPromise !== combined) return; + trackedAdapterReadySettled = true; + maybePublishRenderReady(); + }, + (err) => { + if (trackedAdapterReadyPromise !== combined) return; + trackedAdapterReadySettled = true; + swallow("runtime.init.adapterReady", err); + maybePublishRenderReady(); + }, + ); + } + return trackedAdapterReadySettled; + }; if (!externalCompositionsReady) { const compositionLoaderParams = { @@ -1910,6 +1969,16 @@ export function initSandboxRuntimeModular(): void { window.__renderReady = false; return; } + // Re-run discover so adapters can refresh their state from the current + // DOM — e.g. the Three.js adapter only hooks `DefaultLoadingManager` once + // it sees `window.THREE`, which may have loaded AFTER the initial + // bootstrap discover. Discover is idempotent in every adapter, so a + // second call here is cheap. + runAdapters("discover", state.currentTime); + if (!isAdapterReadinessSettled()) { + window.__renderReady = false; + return; + } publishRenderReadyAfterTimelineBinding(); }; diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 6d6acc899..3c56977bc 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -236,6 +236,25 @@ export type RuntimeDeterministicAdapter = { pause: () => void; play?: () => void; revert?: () => void; + /** + * Optional async readiness gate. If the adapter has outstanding async work + * (e.g. Three.js's `DefaultLoadingManager` still loading models/textures), + * return a promise that settles when the work is done. The runtime waits + * for the returned promise to settle before publishing + * `window.__renderReady = true`, so the engine doesn't capture empty + * frames while assets are still loading. + * + * Return `null` (or omit the method) when nothing is pending. The runtime + * calls this on every readiness-publish evaluation and tracks promise + * identity, so returning the same promise on repeated calls is the + * expected contract — return a fresh promise only when a new wait is + * actually needed (e.g. a new batch of items has been queued). + * + * Throwing or rejecting is safe: the runtime swallows the error and + * proceeds to publish (matching the existing failure-doesn't-block-render + * convention). + */ + getReadyPromise?: () => PromiseLike | null; }; export type RuntimeGsapSetTarget = string | Element | Element[] | null;