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
+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();
};