feat(studio): instant, flicker-free manual editing (#1605)

* chore(producer): shim __filename/__dirname in the CJS banner

Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.

* chore(producer): use a template literal for the CJS banner (review nit)

* feat(core): add GSAP keyframe + motion-path source mutations

Array-form keyframe removal in both the recast and acorn writers, plus
update/add/remove-motion-path-point and add-motion-path. Exclude _auto and
data from tween property-group classification.

* fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks

- Regression test for the `data` GSAP-key exclusion (parallel to _auto).
- splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the
  from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests.
- addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated.
- Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring
  removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug —
  documented as it.skip with a ready assertion (acorn cutover follow-up).

* feat(core): route motion-path mutations through studio-api + fix clip stamping

Wire the new mutations into the file save route. Only authored clips suppress
descendant stamping, so auto-stamped animated scenes can inline-expand.

Hide in-flow timed clips with `display:none` only when they are LEAF clips (no
nested timed clips). `display:none` on a container removes its whole subtree,
hiding descendants that are still inside their own visibility window — e.g. an
in-flow composition root whose effective window clamps to the timeline end would
black out a child video that should still show (the hdr-hlg regression).
Containers keep `visibility:hidden`, which a visible descendant can override; only
leaves leave the flow, which is all the split-overlap case needs.

* feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule

A position or rotation add/set mutation makes the GSAP timeline the single source of
truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS
var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both
channels, and the add-strip fires for the position AND rotation property groups.

Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio
cannot save drag/resize edits to elements in a registered timeline — the exact premise
the single-source work inverts (the timeline is now the edit target). Removed the rule,
its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests.

* fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel

- HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript
  authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta,
  shift-positions, scale-positions, split-animations. (add stays out: flat tweens
  only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.)
- init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change;
  visible/hidden branches both go through isTimedClipInFlow (was .get() by accident).
- keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set
  strips the stale --hf-studio-rotation channel.

* feat(studio): GSAP runtime read layer + shared helpers

* fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests

- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
  (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
  ease is stripped without shifting the other keyframes' percentages.

* feat(studio): GSAP drag/commit/bridge editing infra

* fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore

BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the
commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the
server save, fixing both consumers (useEnableKeyframes + useGestureCommit's
showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation
return type widened void→Promise<void> (fire-and-forget consumers ignore it).
- stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live).
- commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek,
  so a failed commit leaves the dropped pose, not a cleared element.

* feat(studio): motion-path geometry + commit helpers

* docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics

* feat(studio): on-canvas motion-path overlay

* fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold

* feat(studio): keyframes flag, gesture recording + timeline/selection refinements

* fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens

- useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's
  empty parse isn't overridden by a stale selectedGsapAnimations cache.
- useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick).
- useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent).
- playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV).
- useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per
  group, so a mixed gesture no longer yields an untagged legacy tween.

* feat(studio): single-source manual offset + rotation via the GSAP timeline

Dragging or rotating an element writes into the GSAP timeline (the single source of
truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static
elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and
the live preview moves via gsap.set so what you see equals what is written and renders.
Removes the dual-channel CSS-var/transform reconciliation behind the
fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position
and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/
keyframe commit, dropped the handleDom*Commit CSS fallbacks).

Subcompositions edit the same single-source way, which surfaced and fixes:
- resolve a subcomp element's source file via the composition-id map (the runtime drops
  the source linkage when inlining the subcomposition);
- a selected element's selection box AND motion path use basic visibility, not the
  occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover);
- soft reload rebuilds ONLY the committed composition's timeline, leaving other
  compositions' timelines intact (no cross-composition revert);
- read keyframes from the element's OWN composition timeline (scan all timelines, not
  the first unstable key);
- delete-all uses a soft reload too, so editing no longer hard-reloads the iframe.

* fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures

- DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only
  position/rotation channel; the false branch silently killed drag+rotate (and let GSAP
  elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests.
- gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller
  full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script.
- gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops
  partial reads (no phantom identity) and re-applies the drag override in finally.
- MotionPathOverlay: park-timer cleanup keyed on animId change.
- useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review).

* feat(studio): patchRuntimeTweenInPlace — update a tween's values in place

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.

* fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.

* feat(studio): instantPatch fast path in runCommit

A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on
success the preview updates in place with NO reload (instant), on false it falls back
to the existing soft reload. Extracts the preview-sync tail into a testable
applyPreviewSync helper. No behavior change when instantPatch is absent.

* feat(studio): route static position/rotation set drags through instantPatch

Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).

* fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)

* feat(studio): no full iframe remount for soft-reloadable edits

A softReload edit (and the SDK single-script refresh) no longer escalates to a full
reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set
already shows the value, and a remount is the worst flash + re-inlines subcomps
(reverting their keyframes). verifyTimelinesPopulated now checks the expected target
keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty.
Full reload stays only for the structural (no-softReload) and ambiguous-script paths.

* feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash

ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout
onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the
timeline. So when a user adds a motion path to a composition that didn't originally
use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN
async path (the flash). Idempotent + defensive; the existing async fallback stays for
genuine cold-start/CDN-failure.

* fix(studio): don't re-save + reload when source editor syncs externally

The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged —
including the programmatic dispatch that syncs external content (e.g. a manual-edit
commit writing the source back into the open editor). That made the editor re-save the
file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe
edit — defeating the in-place instant patch and causing the flash. Annotate the
programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save.

* fix(core): inject MotionPathPlugin into preview when a composition uses motionPath

A studio-created motion path writes a gsap motionPath tween into the single-source
timeline, but the preview HTML only loaded gsap core — so the first render threw
"Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject
MotionPathPlugin right after the composition's gsap script, version-matched to it.

* fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact)

* fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard

BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' |
'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full
reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script,
or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the
TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome
(origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted.
- SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an
  in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused).
- Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append).

* fix(core): align __clipTree and __clipManifest ids via stableClipId

Timeline inline expansion was dead for nested children inside index.html:
the tree keyed id-less elements by a synthetic __clip-N while the manifest
keyed them null, so parent<->child never joined. Both now resolve identity
through stableClipId (id || data-hf-id), which every generated element has.

* fix(core): strip baked runtime + tag comp root in preview assembly

Comps that ship a baked inline runtime were double-loaded (preview injects
its own) and the baked copy failed to parse inline (Unexpected token '<').
Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also
tag the comp root with data-composition-file so the studio resolves a comp's
top-level elements to the right source file instead of defaulting to
index.html (which made the GSAP panel parse the wrong, multi-timeline file).

* feat(studio): set motion-path destination from a toolbar toggle

Replaces the double-click-on-canvas UX (which painted text over the preview)
with a 'Set motion destination' toggle next to Snap/Grid, shown only when the
selected element can take a path. While armed, one canvas press places the
destination. Also removes the dead TimelinePropertyRows component.

* fix(studio): center timeline keyframe diamonds on their percentage

Dropped clampDiamondLeft, which forced boundary keyframes fully inside the
clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's
midpoint now sits exactly on its % (the clip is overflow-visible).

* fix(studio): resize static elements via tl.set, not a single-stop keyframes tween

Resizing an element with no size animation wrote keyframes:{ <playhead%>:
{width,height} } — one mid-point stop GSAP can't interpolate, so it rendered
NaN/0 dimensions at every other frame and the element vanished (worst off 0%).
Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize
now writes tl.set({width,height}), held at all frames; re-resizing updates it
in place.

* fix(studio): negative-cache failed media probes

Only successful probes were cached, so CORS/404 cross-origin media was
re-probed every rAF-driven timeline re-derive, flooding the console. Remember
failed URLs and skip them.

* fix(studio): type window.setTimeout handle as number

ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is
present and clashes with the DOM number the call returns. Type it number.

* fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps

- Fix soft-reload clearProps destroying element inline styles — save cssText,
  clear, restore, strip only transform
- Fix resize no-op on re-resize: delete+add instead of two update-property
- Route set tweens through static resize path (convertToKeyframes skips sets)
- Re-fetch animation ID before drag commit to prevent stale-ID duplicates
- Guard editDebugLog for Node test environments
- Fix NLELayout setState-during-render (move reset to useEffect)
- Stop SnapToolbar pointer events propagating to canvas deselect handler
- Enable click-to-add waypoints on cubic motion paths
- Add whole-path drag offset (Alt+drag shifts all keyframes together)
- Add Canvas shortcuts section to ShortcutsPanel
- Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance)
- Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
This commit is contained in:
Miguel Ángel
2026-06-22 01:21:18 -04:00
committed by GitHub
parent e0822c6e85
commit 091137e3c3
103 changed files with 8012 additions and 1217 deletions
@@ -0,0 +1,462 @@
import { describe, expect, it, vi } from "vitest";
import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch";
/**
* The helper patches ONE tween's values in `window.__timelines[compKey]` in place,
* `invalidate()`s it, and re-seeks via `__player.seek(currentTime)` so a value-only
* edit is reflected without re-running the composition. It must return `false`
* (caller falls back to a soft reload) whenever it can't confidently apply.
*
* The fixtures below mimic the runtime timeline shape the reader scans:
* a timeline with `getChildren(deep)`, child tweens with `vars`/`targets`/
* `duration`/`startTime`/`invalidate`, and an `__player` with `getTime`/`seek`.
*/
type TweenSpec = {
vars: Record<string, unknown>;
targetIds: string[];
duration: number;
startTime?: number;
};
function makeTween(spec: TweenSpec, el: { id: string }) {
const invalidate = vi.fn();
return {
vars: spec.vars,
targets: () => spec.targetIds.map((id) => (id === el.id ? el : { id })),
duration: () => spec.duration,
startTime: () => spec.startTime ?? 0,
invalidate,
};
}
// A preview iframe whose runtime timeline holds the given tweens under `compKey`,
// resolves `#<el.id>`, and exposes a `__player` clock that re-renders the timeline
// when `seek` is called (so we can assert the post-seek interpolated value).
function fakeIframe(
el: { id: string },
tweens: ReturnType<typeof makeTween>[],
opts: {
compKey?: string;
extraTimelines?: Record<string, unknown>;
now?: number;
onSeek?: (t: number) => void;
} = {},
): {
iframe: HTMLIFrameElement;
seek: ReturnType<typeof vi.fn>;
timeline: { getChildren: () => unknown[] };
} {
const compKey = opts.compKey ?? "index.html";
const now = opts.now ?? 0;
const timeline = {
getChildren: () => tweens,
duration: () => 14.6,
time: () => now,
};
const seek = vi.fn((t: number) => opts.onSeek?.(t));
const iframe = {
contentWindow: {
__timelines: { [compKey]: timeline, ...(opts.extraTimelines ?? {}) },
__player: { getTime: () => now, seek },
},
contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) },
} as unknown as HTMLIFrameElement;
return { iframe, seek, timeline };
}
describe("patchRuntimeTweenInPlace — set tweens", () => {
it("patches a tl.set x/y; a simulated re-seek reflects the NEW x/y (not the old)", () => {
const el = { id: "box" };
// Model the runtime applying the set's vars to the element on seek.
const rendered: { x: number; y: number } = { x: 0, y: 0 };
const setTween = makeTween(
{ vars: { x: 0, y: 0 }, targetIds: ["box"], duration: 0, startTime: 0 },
el,
);
const { iframe, seek } = fakeIframe(el, [setTween], {
onSeek: () => {
rendered.x = setTween.vars.x as number;
rendered.y = setTween.vars.y as number;
},
});
const ok = patchRuntimeTweenInPlace(iframe, "#box", {
kind: "set",
props: { x: 120, y: -40 },
});
expect(ok).toBe(true);
expect(setTween.vars.x).toBe(120);
expect(setTween.vars.y).toBe(-40);
expect(setTween.invalidate).toHaveBeenCalled();
expect(seek).toHaveBeenCalledTimes(1);
expect(rendered).toEqual({ x: 120, y: -40 });
});
it("patches only the rotation channel, leaving x/y untouched", () => {
const el = { id: "knob" };
const setTween = makeTween(
{ vars: { x: 10, y: 20, rotation: 0 }, targetIds: ["knob"], duration: 0 },
el,
);
const { iframe } = fakeIframe(el, [setTween]);
const ok = patchRuntimeTweenInPlace(iframe, "#knob", {
kind: "set",
props: { rotation: 45 },
});
expect(ok).toBe(true);
expect(setTween.vars.rotation).toBe(45);
expect(setTween.vars.x).toBe(10);
expect(setTween.vars.y).toBe(20);
});
it("patches only the scale channels", () => {
const el = { id: "card" };
const setTween = makeTween(
{ vars: { scaleX: 1, scaleY: 1, opacity: 1 }, targetIds: ["card"], duration: 0 },
el,
);
const { iframe } = fakeIframe(el, [setTween]);
const ok = patchRuntimeTweenInPlace(iframe, "#card", {
kind: "set",
props: { scaleX: 2, scaleY: 1.5 },
});
expect(ok).toBe(true);
expect(setTween.vars.scaleX).toBe(2);
expect(setTween.vars.scaleY).toBe(1.5);
expect(setTween.vars.opacity).toBe(1);
});
});
describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => {
it("patches the {x,y} set, not a co-located rotation-only set", () => {
const el = { id: "dual" };
const posSet = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["dual"], duration: 0 }, el);
const rotSet = makeTween({ vars: { rotation: 0 }, targetIds: ["dual"], duration: 0 }, el);
// rotation set listed FIRST — channel-blind resolution would grab it.
const { iframe } = fakeIframe(el, [rotSet, posSet]);
const ok = patchRuntimeTweenInPlace(iframe, "#dual", {
kind: "set",
props: { x: 33, y: 44 },
});
expect(ok).toBe(true);
expect(posSet.vars).toMatchObject({ x: 33, y: 44 });
// The rotation set must be untouched (no x/y written into it).
expect(rotSet.vars).toEqual({ rotation: 0 });
expect(rotSet.invalidate).not.toHaveBeenCalled();
expect(posSet.invalidate).toHaveBeenCalled();
});
it("patches the rotation set, not a co-located {x,y} set", () => {
const el = { id: "dual2" };
const posSet = makeTween({ vars: { x: 5, y: 6 }, targetIds: ["dual2"], duration: 0 }, el);
const rotSet = makeTween({ vars: { rotation: 0 }, targetIds: ["dual2"], duration: 0 }, el);
// position set listed FIRST.
const { iframe } = fakeIframe(el, [posSet, rotSet]);
const ok = patchRuntimeTweenInPlace(iframe, "#dual2", {
kind: "set",
props: { rotation: 90 },
});
expect(ok).toBe(true);
expect(rotSet.vars).toMatchObject({ rotation: 90 });
expect(posSet.vars).toEqual({ x: 5, y: 6 });
expect(posSet.invalidate).not.toHaveBeenCalled();
expect(rotSet.invalidate).toHaveBeenCalled();
});
it("falls back to the only set when none carries the requested channel", () => {
// Back-compat: a single {x,y} set, patched with {x,y} that obviously matches,
// plus a set lacking the channel entirely still resolves to a match. Here the
// only set carries opacity; patching opacity must still land on it.
const el = { id: "solo" };
const set = makeTween({ vars: { opacity: 1 }, targetIds: ["solo"], duration: 0 }, el);
const { iframe } = fakeIframe(el, [set]);
const ok = patchRuntimeTweenInPlace(iframe, "#solo", {
kind: "set",
props: { opacity: 0.5 },
});
expect(ok).toBe(true);
expect(set.vars).toMatchObject({ opacity: 0.5 });
});
});
describe("patchRuntimeTweenInPlace — keyframe tweens", () => {
it("rebuilds the keyframes; a moved keyframe updates, others unchanged", () => {
const el = { id: "puck" };
const kfTween = makeTween(
{
vars: {
keyframes: [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
{ x: 200, y: 0 },
],
duration: 3,
ease: "power1.inOut",
},
targetIds: ["puck"],
duration: 3,
startTime: 1,
},
el,
);
const { iframe, seek } = fakeIframe(el, [kfTween], { now: 2 });
const ok = patchRuntimeTweenInPlace(iframe, "#puck", {
kind: "keyframes",
keyframes: [
{ x: 0, y: 0 },
{ x: 140, y: 90 },
{ x: 200, y: 0 },
],
});
expect(ok).toBe(true);
const kfs = kfTween.vars.keyframes as Array<Record<string, number>>;
expect(kfs[1]).toEqual({ x: 140, y: 90 });
expect(kfs[0]).toEqual({ x: 0, y: 0 });
expect(kfs[2]).toEqual({ x: 200, y: 0 });
expect(kfTween.invalidate).toHaveBeenCalled();
expect(seek).toHaveBeenCalledTimes(1);
});
it("preserves the existing ease when rebuilding keyframes", () => {
const el = { id: "puck2" };
const kfTween = makeTween(
{
vars: {
keyframes: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
duration: 2,
ease: "back.out",
},
targetIds: ["puck2"],
duration: 2,
startTime: 0,
},
el,
);
const { iframe } = fakeIframe(el, [kfTween], { now: 1 });
const ok = patchRuntimeTweenInPlace(iframe, "#puck2", {
kind: "keyframes",
keyframes: [
{ x: 0, y: 0 },
{ x: 250, y: 10 },
],
});
expect(ok).toBe(true);
expect(kfTween.vars.ease).toBe("back.out");
});
});
describe("patchRuntimeTweenInPlace — defensive false returns", () => {
it("returns false when the selector has no matching tween", () => {
const el = { id: "lonely" };
const otherTween = makeTween(
{ vars: { x: 0 }, targetIds: ["someone-else"], duration: 0 },
{ id: "someone-else" },
);
const { iframe, seek } = fakeIframe(el, [otherTween]);
const ok = patchRuntimeTweenInPlace(iframe, "#lonely", { kind: "set", props: { x: 50 } });
expect(ok).toBe(false);
expect(seek).not.toHaveBeenCalled();
});
it("returns false when the selector resolves to no element", () => {
const el = { id: "present" };
const setTween = makeTween({ vars: { x: 0 }, targetIds: ["present"], duration: 0 }, el);
const { iframe } = fakeIframe(el, [setTween]);
const ok = patchRuntimeTweenInPlace(iframe, "#missing", { kind: "set", props: { x: 50 } });
expect(ok).toBe(false);
});
it("returns false for a motionPath arc tween (defers to soft reload)", () => {
const el = { id: "flyer" };
const arcTween = makeTween(
{
vars: {
motionPath: {
path: [
{ x: 0, y: 0 },
{ x: 100, y: -50 },
{ x: 200, y: 0 },
],
curviness: 1.5,
},
duration: 4,
},
targetIds: ["flyer"],
duration: 4,
startTime: 0,
},
el,
);
const { iframe, seek } = fakeIframe(el, [arcTween], { now: 1 });
const ok = patchRuntimeTweenInPlace(iframe, "#flyer", {
kind: "keyframes",
keyframes: [
{ x: 0, y: 0 },
{ x: 120, y: -30 },
],
});
expect(ok).toBe(false);
expect(arcTween.invalidate).not.toHaveBeenCalled();
expect(seek).not.toHaveBeenCalled();
});
it("returns false for a dynamic/computed keyframe value (string expression)", () => {
const el = { id: "dyn" };
const kfTween = makeTween(
{
vars: {
keyframes: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
duration: 2,
},
targetIds: ["dyn"],
duration: 2,
startTime: 0,
},
el,
);
const { iframe } = fakeIframe(el, [kfTween], { now: 1 });
// A non-finite/string value in the requested change can't be safely expressed
// as a static keyframe → defer to soft reload.
const ok = patchRuntimeTweenInPlace(iframe, "#dyn", {
kind: "keyframes",
keyframes: [
{ x: 0, y: 0 },
// @ts-expect-error — intentionally dynamic/computed value
{ x: "+=random(50,100)", y: 0 },
],
});
expect(ok).toBe(false);
});
it("returns false for a keyframes change against a set-only tween (shape mismatch)", () => {
const el = { id: "static" };
const setTween = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["static"], duration: 0 }, el);
const { iframe } = fakeIframe(el, [setTween]);
const ok = patchRuntimeTweenInPlace(iframe, "#static", {
kind: "keyframes",
keyframes: [
{ x: 0, y: 0 },
{ x: 50, y: 0 },
],
});
expect(ok).toBe(false);
});
it("returns false rather than overwriting a dynamic string set value", () => {
// The existing set value is a computed GSAP expression ("+=100"). Patching it
// with a plain number would silently drop the dynamic intent → defer.
const el = { id: "expr" };
const setTween = makeTween(
{ vars: { x: "+=100", y: 0 }, targetIds: ["expr"], duration: 0 },
el,
);
const { iframe, seek } = fakeIframe(el, [setTween]);
const ok = patchRuntimeTweenInPlace(iframe, "#expr", {
kind: "set",
props: { x: 50, y: 10 },
});
expect(ok).toBe(false);
// Declined → the dynamic expression survives, untouched.
expect(setTween.vars.x).toBe("+=100");
expect(setTween.invalidate).not.toHaveBeenCalled();
expect(seek).not.toHaveBeenCalled();
});
it("never throws — returns false on internal error", () => {
const el = { id: "boom" };
const explodingTween = {
get vars() {
throw new Error("boom");
},
targets: () => [el],
duration: () => 0,
startTime: () => 0,
invalidate: vi.fn(),
};
const timeline = {
getChildren: () => {
throw new Error("kaboom");
},
duration: () => 1,
time: () => 0,
};
const iframe = {
contentWindow: {
__timelines: { "index.html": timeline },
__player: { getTime: () => 0, seek: vi.fn() },
},
contentDocument: { querySelector: (sel: string) => (sel === "#boom" ? el : null) },
} as unknown as HTMLIFrameElement;
void explodingTween;
expect(() =>
patchRuntimeTweenInPlace(iframe, "#boom", { kind: "set", props: { x: 1 } }),
).not.toThrow();
expect(patchRuntimeTweenInPlace(iframe, "#boom", { kind: "set", props: { x: 1 } })).toBe(false);
});
});
describe("patchRuntimeTweenInPlace — composition isolation", () => {
it("patches only the tween in the element's owning timeline, not others", () => {
const el = { id: "owned" };
const ownTween = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["owned"], duration: 0 }, el);
// Another composition's timeline holds a tween for a DIFFERENT element with the
// same channel — it must be left untouched.
const otherTween = makeTween(
{ vars: { x: 999, y: 999 }, targetIds: ["someone-else"], duration: 0 },
{ id: "someone-else" },
);
const otherTimeline = {
getChildren: () => [otherTween],
duration: () => 5,
time: () => 0,
};
const { iframe } = fakeIframe(el, [ownTween], {
compKey: "subscene",
extraTimelines: { playground: otherTimeline, __proxied: true },
});
const ok = patchRuntimeTweenInPlace(iframe, "#owned", {
kind: "set",
props: { x: 7, y: 8 },
});
expect(ok).toBe(true);
expect(ownTween.vars).toMatchObject({ x: 7, y: 8 });
expect(otherTween.vars).toMatchObject({ x: 999, y: 999 });
expect(otherTween.invalidate).not.toHaveBeenCalled();
});
});