fix(core): re-register hf-timelines-built listener in maybePublishRenderReady (#1279)

Compositions that defer gsap.timeline() registration past DOMContentLoaded
(via setTimeout, template instantiation, or dynamic script loading) hit a
race where __renderReady stays false forever:

1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips
   the hf-timelines-built listener and sets __renderReady = true
2. The deferred script runs, calls gsap.timeline().to() which sets
   __hfTimelinesBuilding = true via the batching proxy
3. The deferred maybePublishRenderReady() sees building=true, sets
   __renderReady = false, but never registers a listener to retry
4. __renderReady stays false, __hf.duration returns 0, pollHfReady
   times out with "Composition has zero duration"

Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true,
register a one-shot hf-timelines-built listener to retry — matching the
pattern already used at init time for the synchronous batching case.

Closes #1260
This commit is contained in:
Miguel Ángel
2026-06-08 16:45:40 -04:00
committed by GitHub
parent b4210f6567
commit 1bcd6ec3b3
11 changed files with 437 additions and 106 deletions
+15 -14
View File
@@ -1757,27 +1757,28 @@ export function initSandboxRuntimeModular(): void {
postState(true);
};
let buildListenerPending = false;
maybePublishRenderReady = () => {
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
if (!externalCompositionsReady) {
window.__renderReady = false;
return;
}
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
if (!buildListenerPending) {
buildListenerPending = true;
const onBuilt = () => {
buildListenerPending = false;
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onBuilt, { once: true });
}
return;
}
publishRenderReadyAfterTimelineBinding();
};
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
// active, composition scripts queue tl.to() calls instead of executing them
// synchronously. Wait for the "hf-timelines-built" event before the first
// binding attempt so the transport clock receives the finished timeline
// duration instead of permanently publishing duration=0.
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
const onTimelinesBuilt = () => {
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
}
maybePublishRenderReady();
// When the bundler inlines compositions, data-composition-src is removed so
@@ -1,6 +1,6 @@
// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit
const HF_EARLY_STUB_IIFE: string =
'"use strict";(()=>{var c=100,k=[],u=[],l=!1,s=!1;function a(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function p(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function h(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n){let e=n.proxy.__hfReal,i=e[n.method];if(typeof i=="function"){let t=n.method==="add"?n.args.map(h):n.args;i.call(e,...t)}}function r(n,e,i){let t={proxy:n,method:e,args:i};return n.__hfQueue.push(t),u.push(t),_(),n}function f(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function o(){for(;u.length>0;){let n=u.shift();n&&(f(n),m(n))}T()}function w(){s=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function T(){s||(s=!0,p(()=>{u.length===0?w():s=!1}))}function d(){l=!1;let n=u.splice(0,c);for(let e of n)f(e),m(e);u.length>0?(l=!0,a(d)):w()}function _(){l||(l=!0,window.__hfTimelinesBuilding=!0,a(d))}function y(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...i){return r(e,"to",i)},from(...i){return r(e,"from",i)},fromTo(...i){return r(e,"fromTo",i)},set(...i){return r(e,"set",i)},add(...i){return r(e,"add",i)},pause(...i){return o(),n.pause(...i),e},play(...i){return o(),n.play(...i),e},seek(...i){return o(),n.seek(...i),e},totalTime(...i){return o(),i.length>0?(n.totalTime(...i),e):n.totalTime()},time(...i){return o(),i.length>0?(n.time(...i),e):n.time()},duration(...i){return o(),i.length>0?(n.duration(...i),e):n.duration()},getChildren(...i){o();let t=n.getChildren(...i);return Array.isArray(t)?t:[]},paused(...i){return o(),i.length>0?(n.paused(...i),e):n.paused()},timeScale(...i){return o(),i.length>0?(n.timeScale(...i),e):n.timeScale()},kill(){o(),n.kill()}};return k.push(e),e}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;let n=null;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function")return;let i=e.timeline.bind(e);e.timeline=t=>y(i(t))}})}catch{}}})();\n';
'"use strict";(()=>{var k=100,T=[],u=[],a=!1,r=!1;function w(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function p(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function g(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n){let e=n.proxy.__hfReal,i=e[n.method];if(typeof i=="function"){let t=n.method==="add"?n.args.map(g):n.args;i.call(e,...t)}}function y(n,e,i){let t={proxy:n,method:e,args:i};return n.__hfQueue.push(t),u.push(t),P(),n}function c(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function f(){for(;u.length>0;){let n=u.shift();n&&(c(n),m(n))}x()}function d(){r=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function x(){r||(r=!0,p(()=>{u.length===0?d():r=!1}))}function _(){a=!1;let n=u.splice(0,k);for(let e of n)c(e),m(e);u.length>0?(a=!0,w(_)):d()}function P(){a||(a=!0,window.__hfTimelinesBuilding=!0,w(_))}var O=new Set(["to","from","fromTo","set","add"]),R=new Set(["__hfReal","__hfQueue","__hfIsProxy"]);function v(n,e,i){return(...t)=>{f();let o=e.call(n,...t);return o===n?i():o}}function I(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0},i=new Proxy(e,{get(t,o,l){if(R.has(o))return e[o];if(typeof o=="string"&&O.has(o))return(...h)=>y(i,o,h);let s=n[o];return typeof s=="function"?v(n,s,()=>l):(s!==void 0&&f(),s)},set(t,o,l){return f(),n[o]=l,!0}});return T.push(i),i}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;let n=null;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function")return;let i=e.timeline.bind(e);e.timeline=t=>I(i(t))}})}catch{}}})();\n';
/**
* Returns the pre-built HyperFrames early stub IIFE as a string constant.
+73 -91
View File
@@ -38,11 +38,12 @@
* completes (the captured timeline reference remains valid — the proxy delegates
* all non-mutating calls to the real timeline throughout).
*
* Render-mode correctness: `window.__renderReady` is intentionally not gated
* here because the bridge script's `window.__hf.duration` getter already waits
* for `window.__player.getDuration() > 0`, which only becomes true after
* `bindRootTimelineIfAvailable()` completes in `init.ts`, which happens after
* the `"hf-timelines-built"` listener fires. No separate gate is needed.
* Render-mode correctness: `init.ts` gates `__renderReady` on
* `__hfTimelinesBuilding` via `maybePublishRenderReady()`. When batching
* starts after init (setTimeout-deferred timelines), `maybePublishRenderReady`
* re-registers a `hf-timelines-built` listener to retry once the batch
* completes. The bridge's `__hf.duration` getter returns 0 until
* `__renderReady` is true, keeping `pollHfReady` waiting.
*
* Batch size: ~100 tweens per rAF budget. Each batch completes in <4 ms on a
* 2023 laptop at the 8 562-tween scale; 16 ms rAF budgets are never exhausted.
@@ -248,96 +249,77 @@ function scheduleBatch(): void {
* All methods return `proxy` so that callers who chain off the returned value
* continue to go through the proxy for the duration of the batching phase.
*/
// Methods that queue operations for batched flush (mutating tween additions).
const BATCHED_METHODS = new Set<string>(["to", "from", "fromTo", "set", "add"]);
const PROXY_STATE_KEYS = new Set(["__hfReal", "__hfQueue", "__hfIsProxy"]);
function createFlushingMethodWrapper(
real: GsapTimeline,
fn: (...a: unknown[]) => unknown,
proxyRef: () => unknown,
): (...args: unknown[]) => unknown {
return (...args: unknown[]) => {
flushPendingOperations();
const result = fn.call(real, ...args);
return result === real ? proxyRef() : result;
};
}
function wrapTimeline(real: GsapTimeline): TimelineProxy {
const proxy: TimelineProxy = {
const state = {
__hfReal: real,
__hfQueue: [],
__hfIsProxy: true,
to(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "to", args);
},
from(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "from", args);
},
fromTo(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "fromTo", args);
},
set(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "set", args);
},
add(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "add", args);
},
pause(...args: unknown[]): TimelineProxy {
flushPendingOperations();
real.pause(...args);
return proxy;
},
play(...args: unknown[]): TimelineProxy {
flushPendingOperations();
real.play(...args);
return proxy;
},
seek(...args: unknown[]): TimelineProxy {
flushPendingOperations();
real.seek(...args);
return proxy;
},
totalTime(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.totalTime(...args);
return proxy;
}
return real.totalTime();
},
time(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.time(...args);
return proxy;
}
return real.time();
},
duration(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.duration(...args);
return proxy;
}
return real.duration();
},
getChildren(...args: unknown[]): unknown[] {
flushPendingOperations();
const children = real.getChildren(...args);
return Array.isArray(children) ? children : [];
},
paused(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.paused(...args);
return proxy;
}
return real.paused();
},
timeScale(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.timeScale(...args);
return proxy;
}
return real.timeScale();
},
kill(): void {
flushPendingOperations();
real.kill();
},
__hfQueue: [] as TimelineOperation[],
__hfIsProxy: true as const,
};
activeProxies.push(proxy);
return proxy;
// Use a Proxy so that ANY method or property access on the timeline is
// forwarded to the real GSAP timeline after flushing pending operations.
// The previous plain-object approach only forwarded an explicit allowlist,
// which silently dropped calls like eventCallback(), vars, labels(),
// repeat(), and other GSAP API surface — causing black frames when
// compositions used anything outside the allowlist.
const proxy = new Proxy(state, {
// fallow-ignore-next-line complexity
get(_target, prop, receiver) {
if (PROXY_STATE_KEYS.has(prop as string)) {
return state[prop as keyof typeof state];
}
if (typeof prop === "string" && BATCHED_METHODS.has(prop)) {
return (...args: unknown[]) =>
enqueueTimelineOperation(
proxy as unknown as TimelineProxy,
prop as TimelineOperationMethod,
args,
);
}
const value = (real as Record<string | symbol, unknown>)[prop];
// Forwarded methods (getChildren, eventCallback, labels, etc.) flush
// then delegate. Return values are NOT re-proxied — getChildren()
// returns raw GSAP child timelines. This is intentional: batching
// applies to the root timeline only; child timelines returned by
// getChildren are used for enumeration/cleanup, not tween additions.
if (typeof value === "function") {
return createFlushingMethodWrapper(
real,
value as (...a: unknown[]) => unknown,
() => receiver,
);
}
// Non-function property reads also flush so post-batch reads (e.g.
// tl.vars, tl.data) see state consistent with all applied tweens.
if (value !== undefined) flushPendingOperations();
return value;
},
set(_target, prop, value) {
flushPendingOperations();
(real as Record<string | symbol, unknown>)[prop] = value;
return true;
},
});
activeProxies.push(proxy as unknown as TimelineProxy);
return proxy as unknown as TimelineProxy;
}
// ─── Entry point ─────────────────────────────────────────────────────────────
@@ -0,0 +1,16 @@
{
"name": "Distributed: Three.js WebGL boundary (setTimeout-deferred)",
"description": "Same Three.js purple cube as three-boundary, but the entire GSAP timeline and Three.js setup is deferred via setTimeout(fn, 0) — the exact pattern from issue #1260. Exercises the maybePublishRenderReady race where __hfTimelinesBuilding starts false at DOMContentLoaded then flips true after init.ts has already run. Without the hf-timelines-built re-registration fix, this composition times out with 'Composition has zero duration'.",
"tags": ["distributed", "threejs", "webgl", "deferred"],
"minPsnr": 25,
"maxFrameFailures": 0,
"minAudioCorrelation": 0.9,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 10,
"chunkSize": 10
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cc8c784064e83a62324b4968ef71d3f9c5e151ccf9f3e96a725660bb6c9bb3b9
size 22114
@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: Three.js (setTimeout-deferred)</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
width: 320px;
height: 180px;
background: #0f172a;
overflow: hidden;
}
#main-comp {
position: relative;
width: 320px;
height: 180px;
}
canvas {
display: block;
width: 320px;
height: 180px;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="320"
data-height="180"
data-start="0"
data-duration="2"
></div>
<script>
// Exercises the setTimeout-deferred timeline registration pattern from
// issue #1260. The entire GSAP timeline + Three.js setup is deferred
// past DOMContentLoaded via setTimeout(fn, 0), which triggers the
// maybePublishRenderReady race fixed in this PR.
setTimeout(function () {
var canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
document.getElementById("main-comp").appendChild(canvas);
var scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a);
var camera = new THREE.PerspectiveCamera(45, 320 / 180, 0.1, 50);
camera.position.set(0, 0, 4);
var renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
});
renderer.setSize(320, 180, false);
var geo = new THREE.BoxGeometry(1, 1, 1);
var mat = new THREE.MeshStandardMaterial({ color: 0xa855f7 });
var cube = new THREE.Mesh(geo, mat);
scene.add(cube);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
var light = new THREE.DirectionalLight(0xffffff, 0.7);
light.position.set(2, 2, 3);
scene.add(light);
window.__hfThreeTime = 0;
function renderFrame() {
var t = window.__hfThreeTime || 0;
cube.rotation.x = t * Math.PI;
cube.rotation.y = t * Math.PI * 0.5;
renderer.render(scene, camera);
}
window.addEventListener("hf-seek", function (e) {
if (e && e.detail && typeof e.detail.time === "number") {
window.__hfThreeTime = e.detail.time;
}
renderFrame();
});
renderFrame();
var dur = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = dur;
dur.to({}, { duration: 2, onUpdate: renderFrame }, 0);
}, 0);
</script>
</body>
</html>
@@ -0,0 +1,16 @@
{
"name": "Distributed: Three.js WebGL boundary",
"description": "2s composition (20 frames @ 10fps) with a Three.js purple cube on a dark background. Validates that the GSAP batching proxy correctly forwards WebGL render callbacks and that CDP screenshot capture composites WebGL canvas layers — catches the v0.6.80 regression where the plain-object proxy silently dropped eventCallback() and other non-allowlisted GSAP methods, producing black frames.",
"tags": ["distributed", "threejs", "webgl"],
"minPsnr": 25,
"maxFrameFailures": 0,
"minAudioCorrelation": 0.9,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 10,
"chunkSize": 10
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cc8c784064e83a62324b4968ef71d3f9c5e151ccf9f3e96a725660bb6c9bb3b9
size 22114