Files
hyperframes/packages/studio/src/hooks/useGsapTweenCache.ts
T
Miguel Angel Simon Sierra 3c7400af89 fix(studio): close the review findings in this PR instead of at the stack tip
The R1/R3 residuals on this PR were fixed at the top of the stack, so they
only cleared once every branch above landed. They belong here, next to the
code they correct:

- `idFromSelector` inverts `idSelector` for both regex readers, so the
  post-commit cache refresh stops skipping the CSS-unsafe ids `idSelector`
  exists to support.
- `deduplicateKeyframes` drops `ease` when it is ambiguous; the flag was the
  only honest answer and the last-writer-wins curve belonged to an arbitrary
  colliding tween.
- `isStaticPositionHold` is now the single owner of the hold skip. The
  `sourceAnimations` filter and the `allKeyframes` filter had diverged on
  whether `immediateRender` counts as a property.
- The keyframe-cache setters no-op when the write changes nothing, instead of
  handing every subscriber a fresh Map.
- `reset()` clears `focusedEaseSegment`.
- The test hook `delete`s its window key rather than setting it to undefined,
  so feature detection still works.
- The `toClipKeyframes` fixture uses `as unknown as T` with the justification
  CONTRIBUTING.md asks for.
2026-07-27 19:02:21 +02:00

562 lines
22 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
import {
clearKeyframeCacheForElement,
clearKeyframeCacheForFile,
writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers";
import { toAbsoluteTime, toClipPercentage, toClipKeyframes } from "./gsapShared";
import {
deduplicateKeyframes,
isStaticPositionHold,
synthesizeFlatTweenKeyframes,
} from "./gsapTweenSynth";
function extractIdFromSelector(selector: string): string | null {
const match = selector.match(/^#([\w-]+)/);
return match ? match[1] : null;
}
/**
* Resolve a tween's target selector to the ids of the element(s) it animates.
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
* element they animate — not just one parsed from the string. Falls back to a
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
*/
// fallow-ignore-next-line complexity
export function resolveSelectorElementIds(
selector: string,
doc: Document | null | undefined,
): string[] {
const bareId = selector.match(/^#([\w-]+)$/);
if (bareId) return [bareId[1]];
if (!doc) {
const lead = extractIdFromSelector(selector);
return lead ? [lead] : [];
}
const ids = new Set<string>();
for (const part of selector.split(",")) {
const sel = part.trim();
if (!sel) continue;
try {
for (const el of Array.from(doc.querySelectorAll(sel))) {
if (el.id) ids.add(el.id);
}
} catch {
const lead = extractIdFromSelector(sel);
if (lead) ids.add(lead);
}
}
return Array.from(ids);
}
/** The selected element's identity for matching tweens to it. */
export interface GsapElementTarget {
id?: string | null;
selector?: string | null;
}
/**
* A tween belongs to the selected element when its target selector addresses
* that element — by id (`#id`), by the exact CSS selector the element was
* selected through (`.kicker`), or as one member of a group selector
* (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
* compositions target tweens by class via `querySelector`, so id-only matching
* misses them.
*
* When the live DOM `element` is supplied, each comma-part of a tween's selector
* is also tested with `element.matches(part)` — true CSS semantics — so a
* class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`)
* is attributed to *every* matching element, not just the one whose exact
* selector string happens to equal the tween's.
*/
export function getAnimationsForElement(
animations: GsapAnimation[],
target: GsapElementTarget,
element?: Element | null,
): GsapAnimation[] {
const matchers = new Set<string>();
if (target.id) matchers.add(`#${target.id}`);
if (target.selector) matchers.add(target.selector);
if (matchers.size === 0 && !element) return [];
return animations.filter((a) =>
a.targetSelector.split(",").some((part) => {
const trimmed = part.trim();
if (!trimmed) return false;
if (matchers.has(trimmed)) return true;
const lastSimple = trimmed.split(/\s+/).pop();
if (lastSimple && matchers.has(lastSimple)) return true;
if (element) {
try {
if (element.matches(trimmed)) return true;
} catch {
/* tween selector isn't a valid CSS selector for matches() — skip */
}
}
return false;
}),
);
}
export async function fetchParsedAnimations(
projectId: string,
sourceFile: string,
): Promise<ParsedGsap | null> {
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
// Always re-read the freshly-parsed source; no per-call timestamp (which
// would defeat caching forever and is a deterministic-render no-no).
{ cache: "no-store" },
);
if (!res.ok) return null;
const parsed = (await res.json()) as ParsedGsap;
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
// hold an element's first keyframe before its tween). They must not surface as
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
} catch {
return null;
}
}
/**
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
* inside a scene) aren't timeline clips themselves — they're derived at expand time
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
* data-composition-src is stripped in the rendered DOM, so we can't query it).
*/
export function resolveClipTimingBasis(
elementId: string,
sourceFile: string,
elements: ReadonlyArray<{
domId?: string;
key?: string;
id: string;
start: number;
duration: number;
}>,
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
): { elStart: number; elDuration: number } {
const direct = elements.find(
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
);
if (direct) return { elStart: direct.start, elDuration: direct.duration };
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
const host = hostId
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
: undefined;
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
}
export function useGsapAnimationsForElement(
projectId: string | null,
sourceFile: string,
target: GsapElementTarget | null,
version: number,
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): {
animations: GsapAnimation[];
multipleTimelines: boolean;
unsupportedTimelinePattern: boolean;
} {
const [allAnimations, setAllAnimations] = useState<GsapAnimation[]>([]);
const [multipleTimelines, setMultipleTimelines] = useState(false);
const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
const lastFetchKeyRef = useRef("");
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Re-run the per-element cache populate when sub-comp DOM children appear, so a
// sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
const domClipChildrenKey = usePlayerStore((s) =>
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
);
useEffect(() => {
const targetKey = target?.id ?? target?.selector ?? "";
const fetchKey = `${projectId}:${sourceFile}:${version}:${targetKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (!projectId) {
setAllAnimations([]);
setMultipleTimelines(false);
setUnsupportedTimelinePattern(false);
return;
}
let cancelled = false;
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
if (cancelled) {
return;
}
if (!parsed) {
setAllAnimations([]);
setMultipleTimelines(false);
setUnsupportedTimelinePattern(false);
return;
}
setAllAnimations(parsed.animations);
setMultipleTimelines(parsed.multipleTimelines === true);
setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
// Retry once if initial fetch returned 0 animations — handles
// cold-load race where the sourceFile isn't resolved yet.
if (parsed.animations.length === 0 && targetKey) {
retryTimerRef.current = setTimeout(() => {
if (cancelled) return;
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
if (cancelled) return;
if (retryParsed && retryParsed.animations.length > 0) {
setAllAnimations(retryParsed.animations);
}
});
}, 800);
}
});
return () => {
cancelled = true;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
}, [projectId, sourceFile, version, target?.id, target?.selector]);
const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
const rawAnimations = useMemo(() => {
if (!targetId && !targetSelector) return [];
// Resolve the live element so class / descendant tweens (e.g.
// gsap.from(".dot", {stagger})) attribute to every matching element, not
// just the one whose exact selector equals the tween's. `version` re-runs
// this after composition reloads.
let element: Element | null = null;
const doc = iframeRef?.current?.contentDocument;
if (doc) {
try {
element =
(targetId ? doc.getElementById(targetId) : null) ??
(targetSelector ? doc.querySelector(targetSelector) : null);
} catch {
element = null;
}
}
return getAnimationsForElement(
allAnimations,
{ id: targetId, selector: targetSelector },
element,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allAnimations, targetId, targetSelector, version, iframeRef]);
// fallow-ignore-next-line complexity
const animations = useMemo(() => {
const iframe = iframeRef?.current;
let result = rawAnimations;
// Enrich animations with unresolved keyframes from runtime
if (iframe) {
result = result.map((anim) => {
if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim;
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
if (!runtime) return anim;
return {
...anim,
keyframes: {
format: "percentage" as const,
keyframes: runtime.keyframes,
...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
},
...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
};
});
}
// Match unresolved-selector animations from the parser to runtime tweens
// targeting this element. This handles fully dynamic code (loop with variable selector).
if (iframe && targetId && result.length === 0) {
const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector);
if (unresolvedAnims.length > 0) {
const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`);
if (runtimeData) {
const scanned = scanAllRuntimeKeyframes(iframe);
const runtimeEntry = scanned.get(targetId);
if (runtimeEntry) {
// Find which unresolved animation index matches this element
// by correlating parser order with runtime tween order
const runtimeIds = Array.from(scanned.keys());
const runtimeIndex = runtimeIds.indexOf(targetId);
const matchedAnim =
runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length
? unresolvedAnims[runtimeIndex]
: unresolvedAnims[0];
if (matchedAnim) {
result = [
{
...matchedAnim,
targetSelector: `#${targetId}`,
keyframes: {
format: "percentage" as const,
keyframes: runtimeEntry.keyframes,
...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
},
...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
},
];
}
}
}
}
}
return result;
}, [rawAnimations, allAnimations, iframeRef, targetId]);
// Populate keyframe cache for the selected element.
// Key format must match timeline element keys: "sourceFile#domId".
// Merges keyframes from ALL animations targeting this element and synthesizes
// flat tweens so the cache is never downgraded vs the bulk populate.
const elementId = target?.id ?? null;
// fallow-ignore-next-line complexity
useEffect(() => {
if (!elementId) return;
// No property-group filter: ungrouped tweens are recorded here as well.
const sourceAnimations = animations.filter(
(animation) => animation.keyframes || synthesizeFlatTweenKeyframes(animation),
);
if (sourceAnimations.length > 0)
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
// Resolve the element's time range from the player store so we can
// convert tween-relative keyframe percentages to clip-relative ones.
const { elements, domClipChildren } = usePlayerStore.getState();
const { elStart, elDuration } = resolveClipTimingBasis(
elementId,
sourceFile,
elements,
domClipChildren,
);
const allKeyframes: Array<
GsapKeyframesData["keyframes"][0] & {
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
}
> = [];
let format: GsapKeyframesData["format"] = "percentage";
let ease: string | undefined;
let easeEach: string | undefined;
for (const anim of animations) {
if (isStaticPositionHold(anim)) continue;
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kf) continue;
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip.
const tweenPos =
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? elDuration;
for (const k of kf.keyframes) {
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
allKeyframes.push({
...k,
percentage: clipPct,
tweenPercentage: k.percentage,
propertyGroup: anim.propertyGroup,
animationId: anim.id,
});
}
format = kf.format;
if (kf.ease) ease = kf.ease;
if (kf.easeEach) easeEach = kf.easeEach;
}
if (allKeyframes.length === 0) {
// The per-element parsed-animation match can transiently miss class /
// selector tweens (e.g. `.dot`) that the file-wide populate or runtime
// scan already cached. Only clear when no source cached this element —
// otherwise selecting it would wipe its diamonds.
const { keyframeCache } = usePlayerStore.getState();
const hasCached =
keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
return;
}
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
const merged: GsapKeyframesData = {
format,
keyframes: dedupedKeyframes,
...(ease ? { ease } : {}),
...(easeEach ? { easeEach } : {}),
};
const { setKeyframeCache } = usePlayerStore.getState();
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
// so write a duplicate entry under the bare key for cross-component lookups.
setKeyframeCache(elementId, merged);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementId, sourceFile, animations, domClipChildrenKey]);
return { animations, multipleTimelines, unsupportedTimelinePattern };
}
export function useGsapCacheVersion() {
const [version, setVersion] = useState(0);
const bump = useCallback(() => setVersion((v) => v + 1), []);
return { version, bump };
}
/**
* Fetch GSAP animations for a file and populate the keyframe cache for all
* elements. Called from the Timeline component so diamonds show without
* requiring a selection.
*/
export function usePopulateKeyframeCacheForFile(
projectId: string | null,
sourceFile: string,
version: number,
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): void {
const elementCount = usePlayerStore((s) => s.elements.length);
// Re-run when sub-comp DOM children appear (they supply the host bounds the
// clip-relative keyframe percentages are computed against; without this the
// cache is computed once before they exist and the percentages stay wrong).
const domClipChildrenKey = usePlayerStore((s) =>
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
);
const lastFetchKeyRef = useRef("");
const runtimeScanDoneRef = useRef("");
const astFetchDoneRef = useRef("");
useEffect(() => {
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
runtimeScanDoneRef.current = "";
astFetchDoneRef.current = "";
if (!projectId) return;
const sf = sourceFile;
// fallow-ignore-next-line complexity
fetchParsedAnimations(projectId, sf).then((parsed) => {
if (!parsed) return;
const { setKeyframeCache } = usePlayerStore.getState();
clearKeyframeCacheForFile(sf);
const { elements, domClipChildren } = usePlayerStore.getState();
const doc = iframeRef?.current?.contentDocument;
const mergedByElement = new Map<string, GsapKeyframesData>();
const sourceByElement = new Map<string, GsapAnimation[]>();
for (const anim of parsed.animations) {
if (anim.hasUnresolvedKeyframes) continue;
if (isStaticPositionHold(anim)) continue;
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
// Attribute the tween to every element it animates (handles class /
// group / descendant selectors, not just `#id`).
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
// kfData is already resolved (real keyframes OR a synthesized flat
// tween), so a flat tween joins the store like a keyframed one. No
// property-group filter: this map must cover every tween the cache
// below records, or expanded lanes have nothing to render.
sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
const clipKeyframes = toClipKeyframes(kfData.keyframes, anim, elStart, elDuration);
const existing = mergedByElement.get(id);
if (existing) {
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
}
}
}
for (const [id, kfData] of mergedByElement) {
setKeyframeCache(`${sf}#${id}`, kfData);
setKeyframeCache(id, kfData);
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
}
astFetchDoneRef.current = fetchKey;
});
// elementCount is in the deps because new timeline elements (e.g. after a
// sub-composition expand) need their keyframe cache populated immediately;
// without it the effect won't re-run when elements appear/disappear.
// iframeRef is read for DOM selector resolution but intentionally not a dep
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);
// Separate effect for runtime keyframe discovery — polls until the iframe
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
useEffect(() => {
if (!projectId) return;
const sf = sourceFile;
let attempts = 0;
const maxAttempts = 10;
// fallow-ignore-next-line complexity
const tryRuntimeScan = () => {
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
const iframe =
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
if (!iframe) return false;
// Clip dims per element so the scan converts tween-relative keyframes to
// clip-relative (matching the static path) instead of timeline-relative.
const clipById = new Map<string, { start: number; duration: number }>();
for (const el of usePlayerStore.getState().elements) {
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
}
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
if (scanned.size === 0) return false;
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
for (const [id, data] of scanned) {
const cacheKey = `${sf}#${id}`;
const fallbackKey = `index.html#${id}`;
const alreadyCached =
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
if (alreadyCached) 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 } : {}),
};
setKeyframeCache(cacheKey, entry);
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
setKeyframeCache(id, entry);
}
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
return true;
};
if (tryRuntimeScan()) return;
const interval = setInterval(() => {
attempts++;
if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval);
}, 500);
return () => clearInterval(interval);
}, [projectId, sourceFile, version, iframeRef]);
}