From a4b94df43615b071581f6a0a3a500c969a1a19dc Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 17:22:50 -0400 Subject: [PATCH 1/3] fix(core): a timeline set that hides a timed clip hides it in preview and render --- .../runtime/init.timelineHidesClip.test.ts | 58 +++++++++++++++++++ packages/core/src/runtime/init.ts | 8 ++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/runtime/init.timelineHidesClip.test.ts diff --git a/packages/core/src/runtime/init.timelineHidesClip.test.ts b/packages/core/src/runtime/init.timelineHidesClip.test.ts new file mode 100644 index 00000000000..c581bffb542 --- /dev/null +++ b/packages/core/src/runtime/init.timelineHidesClip.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it } from "vitest"; +import gsap from "gsap"; +import { initSandboxRuntimeModular } from "./init"; +import type { RuntimeTimelineLike } from "./types"; + +// Importing gsap installs it on window; a test opts in by setting window.gsap itself. +delete window.gsap; + +function mountClipHiddenByTimeline() { + document.body.innerHTML = + `
` + + `
` + + `
` + + `
`; + const tl = gsap.timeline({ paused: true }); + tl.set("#clip", { visibility: "hidden" }, 2.67); + tl.set("#late", { visibility: "hidden" }, 2.67); + window.__timelines = { main: tl as unknown as RuntimeTimelineLike }; + const parent = window.parent; + Object.defineProperty(window, "parent", { configurable: true, value: {} }); + try { + initSandboxRuntimeModular(); + } finally { + Object.defineProperty(window, "parent", { configurable: true, value: parent }); + } + return { clip: document.getElementById("clip")!, late: document.getElementById("late")! }; +} + +describe("a timeline set that hides a timed clip", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + const paths = { + preview: (t: number) => window.__player?.seek(t), + render: (t: number) => window.__player?.renderSeek(t), + }; + + for (const [name, seek] of Object.entries(paths)) { + it(`hides the clip from its position on the ${name} path`, () => { + const { clip, late } = mountClipHiddenByTimeline(); + const seen: string[] = []; + for (const t of [0.5, 1.5, 2.6, 2.7, 4, 2.6, 1.5]) { + seek(t); + seen.push(`${clip.style.visibility}/${late.style.visibility}`); + } + expect(seen).toEqual([ + "visible/hidden", + "visible/visible", + "visible/visible", + "hidden/hidden", + "hidden/hidden", + "visible/visible", + "visible/visible", + ]); + }); + } +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 1b034cf9e9c..99b717470f2 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3200,9 +3200,15 @@ export function initSandboxRuntimeModular(): void { const isVisibleNow = visibleAt(rawNode, currentTime) && clipChainVisibleAt(rawNode.parentElement, currentTime, visibleAt, rootComp); + // A script that hid a clip this pass showed keeps it hidden until the clip's window flips. + const hiddenByScript = + isVisibleNow && + visibilityDecided.get(rawNode) === true && + rawNode.style.visibility === "hidden"; visibilityDecided.set(rawNode, isVisibleNow); const isRuntimeStamped = isDiscoveryStamped(rawNode); - if (!isRuntimeStamped) rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; + if (!isRuntimeStamped && !hiddenByScript) + rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; rawNode.toggleAttribute(CLIP_HIDDEN_ATTR, !isRuntimeStamped && !isVisibleNow); if (!isRuntimeStamped && !isVisibleNow && !clipsStyledVisible.has(rawNode)) clipsNeverStyledVisible.add(rawNode); From 443152a88db85fc77196e48254ecc53ec3d73a20 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 17:32:54 -0400 Subject: [PATCH 2/3] fix(core): tell a script's hidden clip from the runtime's by the declaration's priority --- .../runtime/init.timelineHidesClip.test.ts | 18 ++++++++----- packages/core/src/runtime/init.ts | 25 +++++++++++++------ packages/core/src/runtime/timedClipHide.ts | 2 +- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/core/src/runtime/init.timelineHidesClip.test.ts b/packages/core/src/runtime/init.timelineHidesClip.test.ts index c581bffb542..25cfa902a14 100644 --- a/packages/core/src/runtime/init.timelineHidesClip.test.ts +++ b/packages/core/src/runtime/init.timelineHidesClip.test.ts @@ -37,14 +37,16 @@ describe("a timeline set that hides a timed clip", () => { }; for (const [name, seek] of Object.entries(paths)) { - it(`hides the clip from its position on the ${name} path`, () => { + const visibilityAt = (times: number[]) => { const { clip, late } = mountClipHiddenByTimeline(); - const seen: string[] = []; - for (const t of [0.5, 1.5, 2.6, 2.7, 4, 2.6, 1.5]) { + return times.map((t) => { seek(t); - seen.push(`${clip.style.visibility}/${late.style.visibility}`); - } - expect(seen).toEqual([ + return `${clip.style.visibility}/${late.style.visibility}`; + }); + }; + + it(`hides the clip from its position on the ${name} path`, () => { + expect(visibilityAt([0.5, 1.5, 2.6, 2.7, 4, 2.6, 1.5])).toEqual([ "visible/hidden", "visible/visible", "visible/visible", @@ -54,5 +56,9 @@ describe("a timeline set that hides a timed clip", () => { "visible/visible", ]); }); + + it(`hides the clip on a first seek past its position on the ${name} path`, () => { + expect(visibilityAt([3]).map((seen) => seen.split("/")[0])).toEqual(["hidden"]); + }); } }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 99b717470f2..21035e82623 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3079,6 +3079,14 @@ export function initSandboxRuntimeModular(): void { dueAtLoopStart(el, visibleAt); const visibilityDecided = new WeakMap(); + // The pass hides with `!important`; a script's write replaces the declaration and drops it. So a + // plain inline hidden on a clip in its window is the script's, except on a clip authored that way. + const clipsSeen = new WeakSet(); + const authoredInlineHidden = new WeakSet(); + const hiddenByScript = (el: HTMLElement): boolean => + el.style.visibility === "hidden" && + el.style.getPropertyPriority("visibility") !== "important" && + !authoredInlineHidden.has(el); // Every time at which an element's visibility, upcoming or preload decision can flip: its own window // edges, the look-ahead and release edges around them, and the window edges of each timed ancestor. const decisionEdges = new Map(); @@ -3200,15 +3208,18 @@ export function initSandboxRuntimeModular(): void { const isVisibleNow = visibleAt(rawNode, currentTime) && clipChainVisibleAt(rawNode.parentElement, currentTime, visibleAt, rootComp); - // A script that hid a clip this pass showed keeps it hidden until the clip's window flips. - const hiddenByScript = - isVisibleNow && - visibilityDecided.get(rawNode) === true && - rawNode.style.visibility === "hidden"; visibilityDecided.set(rawNode, isVisibleNow); const isRuntimeStamped = isDiscoveryStamped(rawNode); - if (!isRuntimeStamped && !hiddenByScript) - rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; + if (!clipsSeen.has(rawNode)) { + clipsSeen.add(rawNode); + if (rawNode.style.visibility === "hidden") authoredInlineHidden.add(rawNode); + } + if (!isRuntimeStamped && !(isVisibleNow && hiddenByScript(rawNode))) + rawNode.style.setProperty( + "visibility", + isVisibleNow ? "visible" : "hidden", + isVisibleNow ? "" : "important", + ); rawNode.toggleAttribute(CLIP_HIDDEN_ATTR, !isRuntimeStamped && !isVisibleNow); if (!isRuntimeStamped && !isVisibleNow && !clipsStyledVisible.has(rawNode)) clipsNeverStyledVisible.add(rawNode); diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 84f8de6aea3..18958418964 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -39,7 +39,7 @@ export function showHiddenClips(only: (el: HTMLElement) => boolean = () => true) export function showClips(hidden: Array): () => void { const putMarksBack = hidden.map((el) => revealClip(el)); return () => { - for (const el of hidden) el.style.visibility = "hidden"; + for (const el of hidden) el.style.setProperty("visibility", "hidden", "important"); for (const putMarkBack of putMarksBack) putMarkBack(); }; } From 1a42c25668afd3e36c0bba3b2561d5646cf4089f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 18:07:50 -0400 Subject: [PATCH 3/3] fix(core): a scene a script hides takes its timed children with it --- .../runtime/init.timelineHidesClip.test.ts | 29 +++++++++++++++++++ packages/core/src/runtime/init.ts | 29 +++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/init.timelineHidesClip.test.ts b/packages/core/src/runtime/init.timelineHidesClip.test.ts index 25cfa902a14..1eb2d8ce8b0 100644 --- a/packages/core/src/runtime/init.timelineHidesClip.test.ts +++ b/packages/core/src/runtime/init.timelineHidesClip.test.ts @@ -61,4 +61,33 @@ describe("a timeline set that hides a timed clip", () => { expect(visibilityAt([3]).map((seen) => seen.split("/")[0])).toEqual(["hidden"]); }); } + + for (const [name, seek] of Object.entries(paths)) { + it(`hides the timed children of a scene a script hides on the ${name} path`, () => { + document.body.innerHTML = + `
` + + `
` + + `
`; + const tl = gsap.timeline({ paused: true }); + tl.set("#scene", { visibility: "hidden" }, 2.67); + window.__timelines = { main: tl as unknown as RuntimeTimelineLike }; + const parent = window.parent; + Object.defineProperty(window, "parent", { configurable: true, value: {} }); + try { + initSandboxRuntimeModular(); + } finally { + Object.defineProperty(window, "parent", { configurable: true, value: parent }); + } + const computed = () => + ["scene", "kid", "plain"].map( + (id) => getComputedStyle(document.getElementById(id)!).visibility[0], + ); + const seen: string[] = []; + for (const t of [1, 3, 3.5, 1]) { + seek(t); + seen.push(computed().join("")); + } + expect(seen).toEqual(["vvv", "hhh", "hhh", "vvv"]); + }); + } }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 21035e82623..ea07f579c3c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3083,10 +3083,17 @@ export function initSandboxRuntimeModular(): void { // plain inline hidden on a clip in its window is the script's, except on a clip authored that way. const clipsSeen = new WeakSet(); const authoredInlineHidden = new WeakSet(); - const hiddenByScript = (el: HTMLElement): boolean => + const hiddenByScript = (el: StylableElement): boolean => el.style.visibility === "hidden" && el.style.getPropertyPriority("visibility") !== "important" && !authoredInlineHidden.has(el); + const hiddenUnderScript = new WeakMap(); + const underScriptHiddenClip = (el: Element, rootComp: Element | null): boolean => { + for (let up = el.parentElement; up && up !== rootComp; up = up.parentElement) { + if (up.hasAttribute("data-start") && isStylableElement(up) && hiddenByScript(up)) return true; + } + return false; + }; // Every time at which an element's visibility, upcoming or preload decision can flip: its own window // edges, the look-ahead and release edges around them, and the window edges of each timed ancestor. const decisionEdges = new Map(); @@ -3143,6 +3150,8 @@ export function initSandboxRuntimeModular(): void { (node as HTMLElement).style.visibility !== (decided ? "visible" : "hidden") ) return false; + if (underScriptHiddenClip(node, rootComp) !== (hiddenUnderScript.get(node) ?? false)) + return false; let edges = decisionEdges.get(node); if (edges === undefined) { edges = decisionEdgesOf(node, rootComp, windowOf); @@ -3205,9 +3214,12 @@ export function initSandboxRuntimeModular(): void { // Descendants must not override a hidden ancestor clip. CSS visibility can // otherwise leak child pixels through inactive scenes because a descendant // with visibility:visible escapes an ancestor's visibility:hidden. + const underScript = underScriptHiddenClip(rawNode, rootComp); + hiddenUnderScript.set(rawNode, underScript); const isVisibleNow = visibleAt(rawNode, currentTime) && - clipChainVisibleAt(rawNode.parentElement, currentTime, visibleAt, rootComp); + clipChainVisibleAt(rawNode.parentElement, currentTime, visibleAt, rootComp) && + !underScript; visibilityDecided.set(rawNode, isVisibleNow); const isRuntimeStamped = isDiscoveryStamped(rawNode); if (!clipsSeen.has(rawNode)) { @@ -3265,6 +3277,18 @@ export function initSandboxRuntimeModular(): void { hiddenAudioDirty = false; }; + // The timeline writes after the pass on a preview seek, so clips under a clip it just hid or showed + // are decided again. + const decideClipsUnderScriptHidden = (currentTime: number) => { + const rootComp = resolveRootCompositionElement(); + const stale = queryForTransport("[data-start]").filter( + (el) => + visibilityDecided.has(el) && + underScriptHiddenClip(el, rootComp) !== (hiddenUnderScript.get(el) ?? false), + ); + if (stale.length > 0) syncTimedElementVisibility(currentTime, stale); + }; + // Scope 2 of 3 (see `withTimingResolver`). One resolver for the whole // visibility pass: every node costs 2 x (1 + ancestor depth) resolves, and // ancestor chains are shared between siblings. Its `el.load()` calls reach only clips timed by an @@ -4588,6 +4612,7 @@ export function initSandboxRuntimeModular(): void { const tl = state.capturedTimeline; pauseTimelineIfPossible(tl); const pageAnimations = seekTimelineAndAdapters(state.currentTime); + if (!keepPlaying) decideClipsUnderScriptHidden(state.currentTime); runAdapters("pause", 0, pageAnimations); if (keepPlaying) { transport.play();