fix(studio): publish keyframe cache refresh atomically

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-29 03:44:30 +02:00
parent 23ab104aff
commit 6b11d37433
4 changed files with 257 additions and 126 deletions
@@ -3,8 +3,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
import { import {
clearKeyframeCacheForElement, clearKeyframeCacheForElement,
clearKeyframeCacheForFile,
pruneKeyframeCacheToFiles, pruneKeyframeCacheToFiles,
replaceKeyframeCacheForFile,
updateKeyframeCacheFromParsed, updateKeyframeCacheFromParsed,
} from "./gsapKeyframeCacheHelpers"; } from "./gsapKeyframeCacheHelpers";
@@ -70,48 +70,6 @@ describe("clearKeyframeCacheForElement", () => {
}); });
}); });
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);
}
});
// Several composition files re-scan concurrently, so a clear that walked the
// index.html alias would delete rows a sibling file had just written.
it("leaves an index.html-owned element alone when another file re-scans", () => {
seed("index.html#title");
seed("title");
seed("comp.html#a");
clearKeyframeCacheForFile("comp.html");
expect(cache().has("index.html#title")).toBe(true);
expect(cache().has("title")).toBe(true);
expect(cache().has("comp.html#a")).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("pruneKeyframeCacheToFiles", () => { describe("pruneKeyframeCacheToFiles", () => {
// Switching composition leaves the previous comp's elements cached with no // Switching composition leaves the previous comp's elements cached with no
// owner left to clear them: each file only ever clears its own entries. // owner left to clear them: each file only ever clears its own entries.
@@ -152,6 +110,99 @@ describe("pruneKeyframeCacheToFiles", () => {
expect(cache().has("index.html#hero")).toBe(true); expect(cache().has("index.html#hero")).toBe(true);
expect(cache().has("comp.html#a")).toBe(true); expect(cache().has("comp.html#a")).toBe(true);
}); });
// The prune runs immediately before the atomic repopulate on every
// composition switch. One notification per stale element put every subscriber
// through a cache that was progressively emptier, which is the flash the
// atomic repopulate exists to avoid.
it("drops every stale file in one notification", () => {
seed("old.html#a");
seed("old.html#b");
seed("older.html#c");
seed("kept.html#k");
let notifications = 0;
const unsubscribe = usePlayerStore.subscribe(() => {
notifications += 1;
});
pruneKeyframeCacheToFiles(["kept.html"]);
unsubscribe();
expect(notifications).toBe(1);
expect([...cache().keys()]).toEqual(["kept.html#k"]);
});
// A prune that finds nothing stale must not hand subscribers a fresh Map
// identity: every keyframe consumer re-renders on cache identity alone.
it("does not publish when nothing is stale", () => {
seed("kept.html#k");
const before = cache();
let notifications = 0;
const unsubscribe = usePlayerStore.subscribe(() => {
notifications += 1;
});
pruneKeyframeCacheToFiles(["kept.html"]);
unsubscribe();
expect(notifications).toBe(0);
expect(cache()).toBe(before);
});
});
describe("replaceKeyframeCacheForFile", () => {
it("publishes complete keyframe and animation maps in one notification", () => {
const staleEntry = entry();
const otherEntry = entry();
const staleAnimation = animWithKeyframes("stale");
const freshAnimation = animWithKeyframes("fresh");
usePlayerStore.setState({
keyframeCache: new Map([
["scene.html#stale", staleEntry],
["index.html#stale", staleEntry],
["stale", staleEntry],
["other.html#other", otherEntry],
["other", otherEntry],
]),
gsapAnimations: new Map([
["scene.html#stale", [staleAnimation]],
["index.html#stale", [staleAnimation]],
["stale", [staleAnimation]],
["other.html#other", [staleAnimation]],
["other", [staleAnimation]],
]),
});
const snapshots: Array<{ cacheKeys: string[]; animationKeys: string[] }> = [];
const unsubscribe = usePlayerStore.subscribe((state) => {
snapshots.push({
cacheKeys: [...state.keyframeCache.keys()].sort(),
animationKeys: [...state.gsapAnimations.keys()].sort(),
});
});
replaceKeyframeCacheForFile(
"scene.html",
new Map([["fresh", entry()]]),
new Map([["fresh", [freshAnimation]]]),
);
unsubscribe();
expect(snapshots).toEqual([
{
cacheKeys: ["fresh", "index.html#fresh", "other", "other.html#other", "scene.html#fresh"],
animationKeys: [
"fresh",
"index.html#fresh",
"other",
"other.html#other",
"scene.html#fresh",
],
},
]);
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#fresh")).toEqual([
freshAnimation,
]);
});
}); });
describe("updateKeyframeCacheFromParsed", () => { describe("updateKeyframeCacheFromParsed", () => {
@@ -11,6 +11,47 @@ import {
type MergeableKeyframe, type MergeableKeyframe,
} from "./gsapTweenSynth"; } from "./gsapTweenSynth";
/** Both cache maps mid-edit, before the single store publish. */
export interface KeyframeCacheDraft {
keyframeCache: Map<string, KeyframeCacheEntry>;
gsapAnimations: Map<string, GsapAnimation[]>;
}
function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
if (before.size !== after.size) return false;
for (const [key, value] of before) {
if (after.get(key) !== value) return false;
}
return true;
}
/**
* Every multi-key cache writer publishes through here: clone both maps once,
* let the caller edit the drafts, publish once. The per-key store setters turned
* one logical refresh into N notifications, and every subscriber that rendered
* in between saw a cache that was half the old file and half the new one. The
* identity check keeps an edit that changed nothing from allocating fresh maps
* and re-rendering every subscriber, which is the guard the per-key setters used
* to carry individually.
*/
export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void): void {
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
const draft: KeyframeCacheDraft = {
keyframeCache: new Map(keyframeCache),
gsapAnimations: new Map(gsapAnimations),
};
edit(draft);
if (
sameEntries(keyframeCache, draft.keyframeCache) &&
sameEntries(gsapAnimations, draft.gsapAnimations)
)
return;
usePlayerStore.setState({
keyframeCache: draft.keyframeCache,
gsapAnimations: draft.gsapAnimations,
});
}
export function updateKeyframeCacheFromParsed( export function updateKeyframeCacheFromParsed(
animations: GsapAnimation[], animations: GsapAnimation[],
targetPath: string, targetPath: string,
@@ -18,7 +59,7 @@ export function updateKeyframeCacheFromParsed(
mutation: Record<string, unknown>, mutation: Record<string, unknown>,
doc?: Document | null, doc?: Document | null,
): void { ): void {
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState(); const { elements, domClipChildren } = usePlayerStore.getState();
const idsWithKeyframes = new Set<string>(); const idsWithKeyframes = new Set<string>();
// Attributed keyframes only: everything in here came from a parsed tween via // Attributed keyframes only: everything in here came from a parsed tween via
// toClipKeyframes, so the merge can rely on the source identity. It widens // toClipKeyframes, so the merge can rely on the source identity. It widens
@@ -70,15 +111,41 @@ export function updateKeyframeCacheFromParsed(
} }
} }
} }
for (const [id, entry] of merged) {
for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
}
const mutationSelector = (mutation as { targetSelector?: string }).targetSelector; const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : []; const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : []; const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
for (const targetId of targetIds) { publishKeyframeCache((draft) => {
if (!idsWithKeyframes.has(targetId)) clearKeyframeCacheForElement(targetPath, targetId); for (const [id, entry] of merged) {
writeElementIntoDraft(draft, targetPath, id, entry, sourceAnimations.get(id));
}
for (const targetId of targetIds) {
if (!idsWithKeyframes.has(targetId)) deleteElementFromDraft(draft, targetPath, targetId);
}
});
}
function writeElementIntoDraft(
draft: KeyframeCacheDraft,
sourceFile: string,
elementId: string,
entry: KeyframeCacheEntry,
animations: GsapAnimation[] | undefined,
): void {
for (const key of elementCacheKeys(sourceFile, elementId)) {
draft.keyframeCache.set(key, entry);
if (animations) draft.gsapAnimations.set(key, animations);
else draft.gsapAnimations.delete(key);
}
}
function deleteElementFromDraft(
draft: KeyframeCacheDraft,
sourceFile: string,
elementId: string,
): void {
for (const key of elementCacheKeys(sourceFile, elementId)) {
draft.keyframeCache.delete(key);
draft.gsapAnimations.delete(key);
} }
} }
@@ -91,50 +158,37 @@ export function updateKeyframeCacheFromParsed(
* preview overlay) fall back to the bare id when an element has no * 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 * 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 * 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 * longer has. publishKeyframeCache skips the store write entirely when none of
* a new cache map and re-render every subscriber. * the keys were present, so an absent element doesn't re-render every
* subscriber.
*/ */
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void { export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } = publishKeyframeCache((draft) => deleteElementFromDraft(draft, sourceFile, elementId));
usePlayerStore.getState();
const keys = elementCacheKeys(sourceFile, elementId);
for (const key of keys) {
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
}
} }
/** function cachedElementIdsForFile(
* Clear every cached element of `sourceFile` before a full re-scan repopulates sourceFile: string,
* it. Only the file's OWN prefixed keys name the ids to clear: every write sets keyframeCache: ReadonlyMap<string, KeyframeCacheEntry>,
* the prefixed key (see elementCacheKeys), so the file's elements are all gsapAnimations: ReadonlyMap<string, GsapAnimation[]>,
* reachable that way, and clearKeyframeCacheForElement then takes the ): Set<string> {
* index.html alias and the bare key with them — an element whose keyframes were
* removed (and so is absent from the re-scan) leaves no stale bare entry
* behind. Reading the alias prefix here instead would collect ids owned by
* OTHER files, and several files re-scan concurrently, so this file's clear
* would wipe the entries a sibling file had just written.
*/
export function clearKeyframeCacheForFile(sourceFile: string): void {
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
const sfPrefix = `${sourceFile}#`; const sfPrefix = `${sourceFile}#`;
const ids = new Set<string>(); const ids = new Set<string>();
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) { for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
if (!key.startsWith(sfPrefix)) continue; if (!key.startsWith(sfPrefix)) continue;
ids.add(key.slice(sfPrefix.length)); ids.add(key.slice(sfPrefix.length));
} }
for (const id of ids) { return ids;
clearKeyframeCacheForElement(sourceFile, id);
}
} }
/** /**
* Drop every cached element owned by a file that is no longer on screen. Each * Drop every cached element owned by a file that is no longer on screen. Each
* file only ever clears its OWN entries (see clearKeyframeCacheForFile), so * file only ever writes its OWN prefixed entries (see elementCacheKeys), so
* switching composition left the previous composition's elements cached forever * switching composition left the previous composition's elements cached forever
* 240 entries per switch on a 120-clip comp, in both keyframeCache and * (240 entries per switch on a 120-clip comp, in both keyframeCache and
* gsapAnimations, with nothing to evict them. Called once before a re-scan, with * gsapAnimations, with nothing to evict them). Called once before a re-scan,
* the full set of files that scan covers. * with the full set of files that scan covers, and it publishes once: the prune
* runs immediately before the atomic repopulate, so a per-element publish here
* would reintroduce the empty-cache flash that repopulate was made to avoid.
*/ */
export function pruneKeyframeCacheToFiles(files: readonly string[]): void { export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
const keep = new Set(files); const keep = new Set(files);
@@ -142,8 +196,8 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
const stale = new Map<string, Set<string>>(); const stale = new Map<string, Set<string>>();
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) { for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
const hash = key.indexOf("#"); const hash = key.indexOf("#");
// Bare-id aliases carry no owner; clearKeyframeCacheForElement takes them // Bare-id aliases carry no owner; deleteElementFromDraft takes them with
// with their prefixed key, so skipping them here loses nothing. // their prefixed key, so skipping them here loses nothing.
if (hash < 0) continue; if (hash < 0) continue;
const sourceFile = key.slice(0, hash); const sourceFile = key.slice(0, hash);
if (keep.has(sourceFile)) continue; if (keep.has(sourceFile)) continue;
@@ -151,9 +205,11 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
ids.add(key.slice(hash + 1)); ids.add(key.slice(hash + 1));
stale.set(sourceFile, ids); stale.set(sourceFile, ids);
} }
for (const [sourceFile, ids] of stale) { publishKeyframeCache((draft) => {
for (const id of ids) clearKeyframeCacheForElement(sourceFile, id); for (const [sourceFile, ids] of stale) {
} for (const id of ids) deleteElementFromDraft(draft, sourceFile, id);
}
});
} }
/** /**
@@ -177,15 +233,37 @@ export function elementCacheKeys(sourceFile: string, elementId: string): string[
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId]; : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
} }
/** Replace one file's complete cache snapshot with one atomic store publish. */
export function replaceKeyframeCacheForFile(
sourceFile: string,
entries: ReadonlyMap<string, KeyframeCacheEntry>,
animationsByElement: ReadonlyMap<string, GsapAnimation[]>,
): void {
publishKeyframeCache((draft) => {
for (const id of cachedElementIdsForFile(
sourceFile,
draft.keyframeCache,
draft.gsapAnimations,
)) {
deleteElementFromDraft(draft, sourceFile, id);
}
for (const [id, entry] of entries) {
writeElementIntoDraft(draft, sourceFile, id, entry, animationsByElement.get(id));
}
});
}
export function writeGsapAnimationsForElement( export function writeGsapAnimationsForElement(
sourceFile: string, sourceFile: string,
elementId: string, elementId: string,
animations: GsapAnimation[] | undefined, animations: GsapAnimation[] | undefined,
): void { ): void {
const { setGsapAnimations } = usePlayerStore.getState(); publishKeyframeCache((draft) => {
for (const key of elementCacheKeys(sourceFile, elementId)) { for (const key of elementCacheKeys(sourceFile, elementId)) {
setGsapAnimations(key, animations); if (animations) draft.gsapAnimations.set(key, animations);
} else draft.gsapAnimations.delete(key);
}
});
} }
function buildCacheKey(sourceFile: string, elementId: string): string { function buildCacheKey(sourceFile: string, elementId: string): string {
@@ -6,11 +6,7 @@
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser"; import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore"; import { usePlayerStore } from "../player/store/playerStore";
import { import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
clearKeyframeCacheForFile,
elementCacheKeys,
writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers";
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared"; import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
import { import {
deduplicateKeyframes, deduplicateKeyframes,
@@ -81,8 +77,6 @@ export async function populateKeyframeCacheFromAst(
): Promise<void> { ): Promise<void> {
const parsed = await fetchParsedAnimations(projectId, sf); const parsed = await fetchParsedAnimations(projectId, sf);
if (!parsed) return; if (!parsed) return;
const { setKeyframeCache } = usePlayerStore.getState();
clearKeyframeCacheForFile(sf);
const { elements, domClipChildren } = usePlayerStore.getState(); const { elements, domClipChildren } = usePlayerStore.getState();
const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>(); const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
const sourceByElement = new Map<string, GsapAnimation[]>(); const sourceByElement = new Map<string, GsapAnimation[]>();
@@ -109,8 +103,5 @@ export async function populateKeyframeCacheFromAst(
} }
} }
} }
for (const [id, kfData] of mergedByElement) { replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
}
} }
+38 -27
View File
@@ -5,6 +5,7 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBrid
import { import {
clearKeyframeCacheForElement, clearKeyframeCacheForElement,
pruneKeyframeCacheToFiles, pruneKeyframeCacheToFiles,
publishKeyframeCache,
writeGsapAnimationsForElement, writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers"; } from "./gsapKeyframeCacheHelpers";
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared"; import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
@@ -313,11 +314,15 @@ export function useGsapAnimationsForElement(
...(ease ? { ease } : {}), ...(ease ? { ease } : {}),
...(easeEach ? { easeEach } : {}), ...(easeEach ? { easeEach } : {}),
}; };
const { setKeyframeCache } = usePlayerStore.getState(); // PropertyPanel reads the cache by bare elementId (without sourceFile
setKeyframeCache(`${sourceFile}#${elementId}`, merged); // prefix), so the same entry is written under the bare key for
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix), // cross-component lookups. Both keys land in one publish: a reader that woke
// so write a duplicate entry under the bare key for cross-component lookups. // between two separate writes saw the prefixed key updated and the bare one
setKeyframeCache(elementId, merged); // still stale.
publishKeyframeCache((draft) => {
draft.keyframeCache.set(`${sourceFile}#${elementId}`, merged);
draft.keyframeCache.set(elementId, merged);
});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementId, sourceFile, animations, domClipChildrenKey]); }, [elementId, sourceFile, animations, domClipChildrenKey]);
@@ -416,29 +421,35 @@ export function usePopulateKeyframeCacheForFile(
} }
const scanned = scanAllRuntimeKeyframes(iframe, clipById); const scanned = scanAllRuntimeKeyframes(iframe, clipById);
if (scanned.size === 0) return false; if (scanned.size === 0) return false;
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState(); // One publish for the whole scan: a scan of a 120-clip composition used to
for (const [id, data] of scanned) { // emit up to three store notifications per element, and every subscriber
const cacheKey = `${sf}#${id}`; // in between re-rendered against a cache only partly filled in.
const fallbackKey = `index.html#${id}`; publishKeyframeCache((draft) => {
const alreadyCached = for (const [id, data] of scanned) {
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id); const cacheKey = `${sf}#${id}`;
if (alreadyCached) continue; const fallbackKey = `index.html#${id}`;
// Skip position-only set tweens from runtime too — same filter as AST path const alreadyCached =
const isPosOnly = draft.keyframeCache.has(cacheKey) ||
data.keyframes.length === 1 && draft.keyframeCache.has(fallbackKey) ||
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y"); draft.keyframeCache.has(id);
if (isPosOnly) { if (alreadyCached) continue;
continue; // Skip position-only set tweens from runtime too, same filter as AST path
const isPosOnly =
data.keyframes.length === 1 &&
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
if (isPosOnly) {
continue;
}
const entry = {
format: "percentage" as const,
keyframes: data.keyframes,
...(data.easeEach ? { easeEach: data.easeEach } : {}),
};
draft.keyframeCache.set(cacheKey, entry);
if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
draft.keyframeCache.set(id, entry);
} }
const entry = { });
format: "percentage" as const,
keyframes: data.keyframes,
...(data.easeEach ? { easeEach: data.easeEach } : {}),
};
setKeyframeCache(cacheKey, entry);
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
setKeyframeCache(id, entry);
}
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`; runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
return true; return true;
}; };