mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): clear the bare keyframe-cache key when an element loses its keyframes (#1482)
The keyframe cache writes three key variants per element: the source-prefixed key (sourceFile#id), the index.html fallback (index.html#id), and the bare element id (id). The clear paths only dropped the prefixed variants, leaving the bare entry behind. PropertyPanel reads the bare key and gives it precedence over live data (cacheEntry?.keyframes ?? gsapKeyframes), so after an element's keyframes are removed the inspector kept rendering the deleted keyframes. Consumers that fall back to the bare id (timeline diamonds, preview overlay) saw the same stale entry. Add clearKeyframeCacheForElement and clearKeyframeCacheForFile and route the three clear sites through them so the bare key is dropped alongside the prefixed ones. Each delete is guarded by has to avoid reallocating the cache map for an absent key. Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This commit is contained in:
co-authored by
Carlos Alcaraz
parent
78cce00c50
commit
e812fc8895
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||||
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||||
|
import {
|
||||||
|
clearKeyframeCacheForElement,
|
||||||
|
clearKeyframeCacheForFile,
|
||||||
|
updateKeyframeCacheFromParsed,
|
||||||
|
} from "./gsapKeyframeCacheHelpers";
|
||||||
|
|
||||||
|
const entry = (): KeyframeCacheEntry => ({
|
||||||
|
format: "percentage",
|
||||||
|
keyframes: [{ percentage: 0, properties: { x: 0 } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const seed = (key: string) => usePlayerStore.getState().setKeyframeCache(key, entry());
|
||||||
|
const cache = () => usePlayerStore.getState().keyframeCache;
|
||||||
|
|
||||||
|
const animWithKeyframes = (id: string): GsapAnimation => ({
|
||||||
|
id,
|
||||||
|
targetSelector: `#${id}`,
|
||||||
|
method: "to",
|
||||||
|
position: 0,
|
||||||
|
properties: {},
|
||||||
|
duration: 1,
|
||||||
|
resolvedStart: 0,
|
||||||
|
propertyGroup: "position",
|
||||||
|
keyframes: { format: "percentage", keyframes: [{ percentage: 50, properties: { x: 100 } }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearKeyframeCacheForElement", () => {
|
||||||
|
it("drops the prefixed, index.html fallback, and bare key for a non-index source", () => {
|
||||||
|
seed("comp.html#box");
|
||||||
|
seed("index.html#box");
|
||||||
|
seed("box");
|
||||||
|
|
||||||
|
clearKeyframeCacheForElement("comp.html", "box");
|
||||||
|
|
||||||
|
expect(cache().has("comp.html#box")).toBe(false);
|
||||||
|
expect(cache().has("index.html#box")).toBe(false);
|
||||||
|
// The bare key is what PropertyPanel's keyframe nav reads (element.id), so
|
||||||
|
// it must be cleared too, not just the prefixed variants.
|
||||||
|
expect(cache().has("box")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the prefixed and bare key for an index.html source", () => {
|
||||||
|
seed("index.html#hero");
|
||||||
|
seed("hero");
|
||||||
|
|
||||||
|
clearKeyframeCacheForElement("index.html", "hero");
|
||||||
|
|
||||||
|
expect(cache().has("index.html#hero")).toBe(false);
|
||||||
|
expect(cache().has("hero")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves other elements' keys untouched", () => {
|
||||||
|
seed("index.html#box");
|
||||||
|
seed("box");
|
||||||
|
seed("index.html#other");
|
||||||
|
seed("other");
|
||||||
|
|
||||||
|
clearKeyframeCacheForElement("index.html", "box");
|
||||||
|
|
||||||
|
expect(cache().has("index.html#other")).toBe(true);
|
||||||
|
expect(cache().has("other")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearKeyframeCacheForFile", () => {
|
||||||
|
it("clears the prefixed, fallback, and bare keys for every element of the file", () => {
|
||||||
|
seed("comp.html#a");
|
||||||
|
seed("index.html#a");
|
||||||
|
seed("a");
|
||||||
|
seed("comp.html#b");
|
||||||
|
seed("b");
|
||||||
|
|
||||||
|
clearKeyframeCacheForFile("comp.html");
|
||||||
|
|
||||||
|
for (const key of ["comp.html#a", "index.html#a", "a", "comp.html#b", "b"]) {
|
||||||
|
expect(cache().has(key)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves entries that belong to a different source file", () => {
|
||||||
|
seed("comp.html#a");
|
||||||
|
seed("a");
|
||||||
|
seed("other.html#z");
|
||||||
|
seed("z");
|
||||||
|
|
||||||
|
clearKeyframeCacheForFile("comp.html");
|
||||||
|
|
||||||
|
expect(cache().has("other.html#z")).toBe(true);
|
||||||
|
expect(cache().has("z")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateKeyframeCacheFromParsed", () => {
|
||||||
|
it("clears the bare key when the selected element no longer has keyframes", () => {
|
||||||
|
// Element previously had keyframes, so a bare entry exists (writes set both).
|
||||||
|
seed("index.html#box");
|
||||||
|
seed("box");
|
||||||
|
|
||||||
|
// A mutation leaves #box without any keyframes in the parsed animations.
|
||||||
|
updateKeyframeCacheFromParsed([], "index.html", "box", {});
|
||||||
|
|
||||||
|
expect(cache().has("index.html#box")).toBe(false);
|
||||||
|
// Without the bare-key clear this assertion fails: the stale entry survives
|
||||||
|
// and PropertyPanel keeps rendering the removed keyframes.
|
||||||
|
expect(cache().has("box")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still writes the bare key for elements that have keyframes", () => {
|
||||||
|
updateKeyframeCacheFromParsed([animWithKeyframes("hero")], "index.html", "hero", {});
|
||||||
|
|
||||||
|
expect(cache().has("index.html#hero")).toBe(true);
|
||||||
|
expect(cache().has("hero")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,8 +67,54 @@ export function updateKeyframeCacheFromParsed(
|
|||||||
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
||||||
selectionId;
|
selectionId;
|
||||||
if (targetId && !idsWithKeyframes.has(targetId)) {
|
if (targetId && !idsWithKeyframes.has(targetId)) {
|
||||||
setKeyframeCache(`${targetPath}#${targetId}`, undefined);
|
clearKeyframeCacheForElement(targetPath, targetId);
|
||||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear every keyframe-cache key variant written for an element: the
|
||||||
|
* source-prefixed key, the index.html fallback, and the bare element id.
|
||||||
|
* Writes set all three (see updateKeyframeCacheFromParsed and
|
||||||
|
* usePopulateKeyframeCacheForFile). PropertyPanel's keyframe nav reads the bare
|
||||||
|
* id directly (`element.id`), and other consumers (timeline diamonds, the
|
||||||
|
* preview overlay) fall back to the bare id when an element has no
|
||||||
|
* source-prefixed key — so a clear that drops only the prefixed keys leaves the
|
||||||
|
* bare entry behind and those readers keep showing keyframes the element no
|
||||||
|
* longer has. Each delete is guarded by `has` so an absent key doesn't allocate
|
||||||
|
* a new cache map and re-render every subscriber.
|
||||||
|
*/
|
||||||
|
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
|
||||||
|
const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
|
||||||
|
const keys =
|
||||||
|
sourceFile === "index.html"
|
||||||
|
? [`index.html#${elementId}`, elementId]
|
||||||
|
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear every cached element of `sourceFile` before a full re-scan repopulates
|
||||||
|
* it. Collects the element ids that currently have a prefixed or index.html
|
||||||
|
* fallback key for the file and drops each through clearKeyframeCacheForElement
|
||||||
|
* so the bare key goes too — an element whose keyframes were removed (and so is
|
||||||
|
* absent from the re-scan) leaves no stale bare entry behind.
|
||||||
|
*/
|
||||||
|
export function clearKeyframeCacheForFile(sourceFile: string): void {
|
||||||
|
const { keyframeCache } = usePlayerStore.getState();
|
||||||
|
const sfPrefix = `${sourceFile}#`;
|
||||||
|
const fallbackPrefix = "index.html#";
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const key of keyframeCache.keys()) {
|
||||||
|
const matchesFile =
|
||||||
|
key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
|
||||||
|
if (!matchesFile) continue;
|
||||||
|
const hashIdx = key.indexOf("#");
|
||||||
|
if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1));
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
clearKeyframeCacheForElement(sourceFile, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/
|
|||||||
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||||
import { usePlayerStore } from "../player/store/playerStore";
|
import { usePlayerStore } from "../player/store/playerStore";
|
||||||
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
||||||
|
import {
|
||||||
|
clearKeyframeCacheForElement,
|
||||||
|
clearKeyframeCacheForFile,
|
||||||
|
} from "./gsapKeyframeCacheHelpers";
|
||||||
import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
|
import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
|
||||||
|
|
||||||
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
||||||
@@ -301,10 +305,7 @@ export function useGsapAnimationsForElement(
|
|||||||
if (kf.easeEach) easeEach = kf.easeEach;
|
if (kf.easeEach) easeEach = kf.easeEach;
|
||||||
}
|
}
|
||||||
if (allKeyframes.length === 0) {
|
if (allKeyframes.length === 0) {
|
||||||
const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
|
clearKeyframeCacheForElement(sourceFile, elementId);
|
||||||
if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
|
|
||||||
setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
||||||
@@ -358,14 +359,11 @@ export function usePopulateKeyframeCacheForFile(
|
|||||||
const sf = sourceFile;
|
const sf = sourceFile;
|
||||||
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
const { setKeyframeCache } = usePlayerStore.getState();
|
||||||
const sfPrefix = `${sf}#`;
|
// Drop the file's stale entries (including the bare keys consumers read)
|
||||||
const fallbackPrefix = "index.html#";
|
// before repopulating, so an element whose keyframes were removed and is
|
||||||
for (const key of keyframeCache.keys()) {
|
// absent from this scan doesn't keep showing diamonds.
|
||||||
if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
|
clearKeyframeCacheForFile(sf);
|
||||||
setKeyframeCache(key, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { elements } = usePlayerStore.getState();
|
const { elements } = usePlayerStore.getState();
|
||||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
||||||
for (const anim of parsed.animations) {
|
for (const anim of parsed.animations) {
|
||||||
|
|||||||
Reference in New Issue
Block a user