diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 8bb1b1c231a..0c473dd061a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1439,16 +1439,16 @@ export function initSandboxRuntimeModular(): void { // second cache checking for itself would be told nothing changed. let compositionTimingRevision = 0; - const queryCache = new Map(); + const queryCache = new Map(); const queryForTransport = (selector: string): Element[] => { if (!compositionTimingObserver || renderCaptureSeekStarted) return Array.from(document.querySelectorAll(selector)); if (recordsMoveTiming(compositionTimingObserver.takeRecords())) invalidateCompositionTimingCaches(); const cached = queryCache.get(selector); - if (cached?.revision === compositionTimingRevision) return cached.nodes; + if (cached) return cached; const nodes = Array.from(document.querySelectorAll(selector)); - queryCache.set(selector, { revision: compositionTimingRevision, nodes }); + queryCache.set(selector, nodes); return nodes; }; @@ -1461,6 +1461,7 @@ export function initSandboxRuntimeModular(): void { const invalidateCompositionTimingCaches = () => { compositionTimingRevision += 1; + queryCache.clear(); // The same records that stale the duration caches are what a parked // transport is waiting for: a timing attribute edited, a sub-composition // mounted, media metadata arriving. One signal, two readers. @@ -2965,10 +2966,10 @@ export function initSandboxRuntimeModular(): void { // A clip's first visible style pass is slow; playing, each hidden clip gets it once just before its start. const clipsNeverStyledVisible = new Set(); const clipsStyledVisible = new WeakSet(); - let neverStyledStarts = { revision: -1, starts: new Map() }; + let neverStyledStarts = { revision: -1, starts: new WeakMap() }; const neverStyledStart = (el: StylableElement, revision: number): number => { if (neverStyledStarts.revision !== revision) - neverStyledStarts = { revision, starts: new Map() }; + neverStyledStarts = { revision, starts: new WeakMap() }; let start = neverStyledStarts.starts.get(el); if (start === undefined) { start = resolveStartForElement(el, Number.NaN); @@ -3407,7 +3408,12 @@ export function initSandboxRuntimeModular(): void { style.setProperty("visibility", shown, isVisibleNow ? "" : "important"); } rawNode.toggleAttribute(CLIP_HIDDEN_ATTR, !isRuntimeStamped && !isVisibleNow); - if (!isRuntimeStamped && !isVisibleNow && !clipsStyledVisible.has(rawNode)) + if ( + !isRuntimeStamped && + !isVisibleNow && + !clipsStyledVisible.has(rawNode) && + rawNode.isConnected + ) clipsNeverStyledVisible.add(rawNode); const upcoming = (hiddenImagesSkipped || mediaDeferred) && @@ -4672,6 +4678,8 @@ export function initSandboxRuntimeModular(): void { stopSceneAnimations([host]); const placeholder = parkScene(name); if (placeholder) moveDisplayHide(host, placeholder); + for (const clip of clipsNeverStyledVisible) + if (!clip.isConnected) clipsNeverStyledVisible.delete(clip); parkedNames.add(name); releaseDetachedMedia(); webAudio.releaseDecodedExcept(webAudioMediaIn(document)); diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 6de55082b6a..6cecc9c46f0 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1597,20 +1597,23 @@ describe("core runtime browser contract", () => { const builtAsItRuns = (i: number) => `const tl = gsap.timeline({ paused: true }); tl.to('[data-composition-id="s${i}"] b', { opacity: 0.5, duration: 1 }); window.__timelines["s${i}"] = tl;`; it.each([ - ["its timeline built as the script runs", builtAsItRuns], + ["its timeline built as the script runs", builtAsItRuns, true], + ["its timeline built as the script runs, scrubbed while paused", builtAsItRuns, false], [ "its timeline driving one built after a font wait", (i: number) => `const tl = gsap.timeline({ paused: true }); Promise.all([document.fonts.load("16px serif")]).then(() => { const inner = gsap.timeline({ paused: true }); inner.to('[data-composition-id="s${i}"] b', { opacity: 0.5, duration: 1 }); tl.add(inner.tweenFromTo(0, 1), 0); window.__timelines["s${i}"] = tl; });`, + true, ], [ "a timeline built after a font wait to pose it once", (i: number) => `const tl = gsap.timeline({ paused: true }); window.__timelines["s${i}"] = tl; Promise.all([document.fonts.load("16px serif")]).then(() => { const pose = gsap.timeline({ paused: true }); pose.to('[data-composition-id="s${i}"] b', { opacity: 0.5, duration: 1 }); pose.time(0.5); });`, + true, ], - ])( - "lets go of each scene the preview mounts and unmounts while it plays, its captured audio included (%s)", - async (_, sceneScript) => { + ] as const)( + "lets go of each scene the preview mounts and unmounts, its captured audio included (%s)", + async (_, sceneScript, plays) => { const dir = mkdtempSync(resolve(tmpdir(), "scene-window-memory-")); // Its own browser: Web Audio only captures media where the context may start without a gesture. const audioBrowser = await puppeteer.launch({ @@ -1659,8 +1662,7 @@ describe("core runtime browser contract", () => { await page.waitForFunction(() => window.__playerReady && window.__renderReady); const cdp = await page.createCDPSession(); await cdp.send("Performance.enable"); - // DOM nodes alive but out of the page, after a full collection. - const kept = async () => { + const keptOnce = async () => { await cdp.send("HeapProfiler.collectGarbage"); const { metrics } = await cdp.send("Performance.getMetrics"); const inPage = await page.evaluate(() => { @@ -1671,19 +1673,30 @@ describe("core runtime browser contract", () => { }); return metrics.find((m) => m.name === "Nodes")!.value - inPage; }; + // DOM nodes alive but out of the page. One collection can leave a freed scene unswept; + // a held one survives every collection. + const kept = async () => { + let count = await keptOnce(); + for (let round = 0; round < 5; round++) { + const next = await keptOnce(); + if (next >= count) return next; + count = next; + } + return count; + }; const before = await kept(); - await page.evaluate(async () => { + await page.evaluate(async (plays) => { const player = window.__player as unknown as { play: () => void; seek: (time: number, options: { keepPlaying: boolean }) => void; }; - player.play(); + if (plays) player.play(); for (const time of [2.5, 4.5, 6.5, 8.5, 10.5]) { - player.seek(time, { keepPlaying: true }); + player.seek(time, { keepPlaying: plays }); await window.__hfWaitForSeekCompletion?.(); await new Promise((settle) => setTimeout(settle, 200)); } - }); + }, plays); // The window unmounts only once it has mounted what is ahead. await page.waitForFunction( () => document.querySelectorAll("[data-hf-parked-scene]").length >= 5,