fix(core): auto-detect three.js asset readiness via adapter contract (#1543)

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.
This commit is contained in:
James Russo
2026-06-17 18:47:48 -07:00
committed by GitHub
parent e57e75b9b4
commit bb5f5f8c5c
6 changed files with 273 additions and 3 deletions
+99 -1
View File
@@ -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<void> | 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<void>((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;
},
};
}
+51
View File
@@ -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");
+69
View File
@@ -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<unknown> | null = null;
let trackedAdapterReadySettled = true;
const collectAdapterReadyPromises = (): PromiseLike<unknown>[] => {
const promises: PromiseLike<unknown>[] = [];
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<unknown> =
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();
};
+19
View File
@@ -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<unknown> | null;
};
export type RuntimeGsapSetTarget = string | Element | Element[] | null;