diff --git a/packages/core/src/runtime/init.timelineHidesClip.test.ts b/packages/core/src/runtime/init.timelineHidesClip.test.ts
index 1eb2d8ce8b0..b7c154e6008 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,57 @@ 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"]);
+ });
+ }
+
+ 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");
});
}
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..a905d55efad 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") !== "hidden")
+ el.style.setProperty("visibility", "hidden", "important");
+ }
for (const putMarkBack of putMarksBack) putMarkBack();
};
}