From 84b910ad0669900b9207217f7e9be6a3fb17bd09 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 17:40:53 -0400 Subject: [PATCH 1/2] fix(core): a script's hidden clip stays hidden after a jump or a re-entry --- .../runtime/init.timelineHidesClip.test.ts | 71 ++++++++++++++----- packages/core/src/runtime/init.ts | 17 +++-- packages/core/src/runtime/timedClipHide.ts | 5 +- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/packages/core/src/runtime/init.timelineHidesClip.test.ts b/packages/core/src/runtime/init.timelineHidesClip.test.ts index 1eb2d8ce8b0..a12d944e711 100644 --- a/packages/core/src/runtime/init.timelineHidesClip.test.ts +++ b/packages/core/src/runtime/init.timelineHidesClip.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import gsap from "gsap"; import { initSandboxRuntimeModular } from "./init"; import type { RuntimeTimelineLike } from "./types"; @@ -6,15 +6,41 @@ import type { RuntimeTimelineLike } from "./types"; // Importing gsap installs it on window; a test opts in by setting window.gsap itself. delete window.gsap; +// jsdom keeps a declaration's !important when a value is assigned over it; browsers drop it. +const styleProto = Object.getPrototypeOf( + document.createElement("div").style, +) as CSSStyleDeclaration; +const visibilityAccessor = Object.getOwnPropertyDescriptor(styleProto, "visibility")!; +const setProperty = styleProto.setProperty; +beforeAll(() => { + Object.defineProperty(styleProto, "visibility", { + ...visibilityAccessor, + set(this: CSSStyleDeclaration, value: string) { + this.removeProperty("visibility"); + visibilityAccessor.set!.call(this, value); + }, + }); + styleProto.setProperty = function (this: CSSStyleDeclaration, name, value, priority) { + if (!priority) this.removeProperty(name); + setProperty.call(this, name, value, priority ?? ""); + }; +}); +afterAll(() => { + Object.defineProperty(styleProto, "visibility", visibilityAccessor); + styleProto.setProperty = setProperty; +}); + 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); + tl.set("#short", { visibility: "hidden" }, 2.67); window.__timelines = { main: tl as unknown as RuntimeTimelineLike }; const parent = window.parent; Object.defineProperty(window, "parent", { configurable: true, value: {} }); @@ -23,7 +49,11 @@ function mountClipHiddenByTimeline() { } finally { Object.defineProperty(window, "parent", { configurable: true, value: parent }); } - return { clip: document.getElementById("clip")!, late: document.getElementById("late")! }; + return { + clip: document.getElementById("clip")!, + late: document.getElementById("late")!, + short: document.getElementById("short")!, + }; } describe("a timeline set that hides a timed clip", () => { @@ -37,28 +67,37 @@ describe("a timeline set that hides a timed clip", () => { }; for (const [name, seek] of Object.entries(paths)) { - const visibilityAt = (times: number[]) => { - const { clip, late } = mountClipHiddenByTimeline(); + // Each clip's visibility after each seek: clip (starts at 0), late (starts at 1), short (ends at 4). + const seenAt = (times: number[]) => { + const { clip, late, short } = mountClipHiddenByTimeline(); return times.map((t) => { seek(t); - return `${clip.style.visibility}/${late.style.visibility}`; + return [clip, late, short].map((el) => el.style.visibility[0]).join(""); }); }; - 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", - "hidden/hidden", - "hidden/hidden", - "visible/visible", - "visible/visible", + it(`hides each clip from its position on the ${name} path`, () => { + expect(seenAt([0.5, 1.5, 2.6, 2.7, 4, 2.6, 1.5])).toEqual([ + "vhv", + "vvv", + "vvv", + "hhh", + "hhh", + "vvv", + "vvv", ]); }); - 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"]); + it(`hides each clip on a first seek past its position on the ${name} path`, () => { + expect(seenAt([3])).toEqual(["hhh"]); + }); + + it(`hides a clip after a seek from before its window straight past its position on the ${name} path`, () => { + expect(seenAt([0.5, 3, 1.5])).toEqual(["vhv", "hhh", "vvv"]); + }); + + it(`hides a clip when its window is left and re-entered past its position on the ${name} path`, () => { + expect(seenAt([2, 4.5, 3])).toEqual(["vvv", "hhh", "hhh"]); }); } diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 6780f5ff832..698b1442f9f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3088,6 +3088,7 @@ 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 scriptVisibility = new WeakMap(); const hiddenByScript = (el: StylableElement): boolean => el.style.visibility === "hidden" && el.style.getPropertyPriority("visibility") !== "important" && @@ -3231,12 +3232,16 @@ export function initSandboxRuntimeModular(): void { 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", - ); + if (!isRuntimeStamped && !(isVisibleNow && hiddenByScript(rawNode))) { + const style = rawNode.style; + const passHid = style.getPropertyPriority("visibility") === "important"; + if (!isVisibleNow && !passHid && !authoredInlineHidden.has(rawNode)) + scriptVisibility.set(rawNode, style.getPropertyValue("visibility")); + const shown = isVisibleNow + ? (passHid && scriptVisibility.get(rawNode)) || "visible" + : "hidden"; + style.setProperty("visibility", shown, 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 18958418964..5089aa5b51c 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -39,7 +39,10 @@ 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.setProperty("visibility", "hidden", "important"); + for (const el of hidden) { + if (el.style.getPropertyValue("visibility") === "visible") + el.style.setProperty("visibility", "hidden", "important"); + } for (const putMarkBack of putMarksBack) putMarkBack(); }; } From cb7c9ebe1319404bcadbc91ae3017ee7924dec66 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 19:34:29 -0400 Subject: [PATCH 2/2] fix(core): re-hide an ended clip unless the tween hid it --- .../runtime/init.timelineHidesClip.test.ts | 20 +++++++++++++++++++ packages/core/src/runtime/timedClipHide.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.timelineHidesClip.test.ts b/packages/core/src/runtime/init.timelineHidesClip.test.ts index a12d944e711..b7c154e6008 100644 --- a/packages/core/src/runtime/init.timelineHidesClip.test.ts +++ b/packages/core/src/runtime/init.timelineHidesClip.test.ts @@ -101,6 +101,26 @@ describe("a timeline set that hides a timed clip", () => { }); } + for (const [name, seek] of Object.entries(paths)) { + it(`hides a clip a tween left at inherit once its window has ended on the ${name} path`, () => { + document.body.innerHTML = + `
` + + `
`; + const tl = gsap.timeline({ paused: true }); + tl.to("#c", { autoAlpha: 0.5, duration: 0.5 }, 1); + 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 }); + } + seek(3); + expect(document.getElementById("c")!.style.visibility).toBe("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 = diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 5089aa5b51c..a905d55efad 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -40,7 +40,7 @@ export function showClips(hidden: Array): () => const putMarksBack = hidden.map((el) => revealClip(el)); return () => { for (const el of hidden) { - if (el.style.getPropertyValue("visibility") === "visible") + if (el.style.getPropertyValue("visibility") !== "hidden") el.style.setProperty("visibility", "hidden", "important"); } for (const putMarkBack of putMarksBack) putMarkBack();