fix(core,sdk): rebuild acorn GSAP keyframe writer for recast parity (#1520)

The acorn addKeyframeToScript mixed ms.overwrite + ms.appendLeft on the
same _auto endpoint node, crashing MagicString ("Cannot split a chunk
that has already been edited") whenever an interior keyframe adjacent to
an _auto 0/100 endpoint introduced a new backfilled prop — the common SDK
path. It also replaced (not merged) existing keyframes, dropped ease and
_auto markers, corrupted commas on multi-prop backfill into empty {},
used a <0.001 percentage tolerance instead of recast's PCT_TOLERANCE=2,
and silently no-op'd on flat (non-keyframe) tweens.

Rebuild the node model to mirror recast: compute the FINAL property record
for every changed keyframe value node (target merge, _auto endpoint sync,
backfilled siblings) against the original AST, then emit exactly one
ms.overwrite per changed node (one insert for a brand-new key). No node is
ever both overwritten and appended into, so splices can never overlap.

- Merge: re-touching an existing keyframe merges new props over the
  existing record, preserving untouched props, existing ease, and _auto.
- Convert-flat: first keyframe-add on a flat to()/from()/fromTo() tween
  rebuilds its vars object to percentage keyframes (ease->easeEach,
  ease:"none", from/fromTo->to) matching recast, then re-locates via the
  -from-/-fromTo- -> -to- id fallback.
- Tolerance: PCT_TOLERANCE=2 for existing-keyframe detection.
- Shared serializeValue/safeJsKey for keyframe values (recast parity); the
  tween-statement path keeps its local serializer for object/boolean extras.
- keyframeBackfill: only backfill props with a real numeric default; skip
  unknown/string props so color:0 / filter:0 are never emitted.
- setGsapKeyframe move-path threads the same backfill defaults as the add
  path so both entry points behave identically.

Differential tests (acorn vs recast parsed keyframe arrays) cover the
crash (2-endpoint + 0/25/100), empty-{} multi-prop backfill, merge with
extra props + ease, flat to()/fromTo() convert, "50.0%" non-byte-equal
key, near-% tolerance, and _auto-marker preservation onto an endpoint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-16 17:54:46 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0ba52fc130
commit b5ad518957
5 changed files with 720 additions and 46 deletions
@@ -0,0 +1,45 @@
/**
* Backfill defaults for add-keyframe ops.
*
* When an add-keyframe op introduces a property absent from the other keyframes,
* the writer needs a rest value to seed those keyframes with so GSAP interpolates
* instead of snapping. The SDK derives the numeric-default set here so the acorn
* writer matches the recast writer the server uses.
*
* Only props with a real numeric default get a backfill value. Defaulting an
* unknown or string-valued prop to 0 (e.g. `color: 0`, `filter: 0`) emits invalid
* GSAP, so such props are SKIPPED — the writer then leaves them out of the other
* keyframes (GSAP reads the rest value from the DOM), matching recast (which skips
* any prop whose default is null).
*/
// Numeric rest values for editable transform/style props. Props absent here have
// no safe static default and are intentionally omitted from the backfill set.
//
// KEEP IN SYNC WITH packages/studio/src/hooks/gsapShared.ts:PROPERTY_DEFAULTS —
// the studio (recast) and SDK (acorn) paths must derive the same defaults or
// SDK-written keyframes drift from server-written ones (the exact bug this fixes).
// TODO: lift the canonical table into @hyperframes/core and import from both.
const KEYFRAME_PROPERTY_DEFAULTS: Record<string, number> = {
opacity: 1,
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotation: 0,
width: 100,
height: 100,
};
/** Derive the backfillDefaults for an add-keyframe op (numeric-default props only). */
export function deriveKeyframeBackfillDefaults(
value: Record<string, number | string>,
): Record<string, number | string> {
const defaults: Record<string, number | string> = {};
for (const key of Object.keys(value)) {
const def = KEYFRAME_PROPERTY_DEFAULTS[key];
if (def !== undefined) defaults[key] = def;
}
return defaults;
}
@@ -288,6 +288,38 @@ describe("addGsapKeyframe", () => {
expect(newScript).toContain('"25%"');
expect(newScript).toContain("opacity: 0.3");
});
it("backfills a NEW property into the other keyframes, matching the recast writer", async () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, {
type: "addGsapKeyframe",
animationId: animId,
position: 25,
// `x` is brand-new to this keyframe set: it must be backfilled into the
// existing keyframes so GSAP interpolates rather than snaps.
value: { opacity: 0.3, x: 120 },
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
// Parse the SDK-written script and compare against the recast writer fed the
// same backfillDefaults the studio always sends (`PROPERTY_DEFAULTS[k] ?? 0`).
const { parseGsapScript, addKeyframeToScript } = await import("@hyperframes/core/gsap-parser");
const recast = addKeyframeToScript(KF_SCRIPT, animId, 25, { opacity: 0.3, x: 120 }, undefined, {
opacity: 1,
x: 0,
});
const kfOf = (s: string) =>
parseGsapScript(s)
.animations[0]?.keyframes?.keyframes?.slice()
.sort((a, b) => a.percentage - b.percentage)
.map((k) => ({ percentage: k.percentage, properties: k.properties }));
expect(kfOf(newScript)).toEqual(kfOf(recast));
// Every keyframe carries `x` (the new prop backfilled at its default 0).
expect(newScript).toContain("x: 0");
});
});
describe("setGsapKeyframe", () => {
@@ -333,6 +365,33 @@ describe("setGsapKeyframe", () => {
expect(newScript).toContain("opacity: 0.7");
});
it("move with a new prop threads backfill defaults into sibling keyframes (matches add path)", async () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
// Move the 50% keyframe to 60% while introducing a NEW prop `x`. The move
// path (remove + re-add) must seed `x` into the other keyframes with its
// default, exactly like the add path does.
const result = applyOp(parsed, {
type: "setGsapKeyframe",
animationId: animId,
keyframeIndex: 1,
position: 60,
value: { opacity: 0.5, x: 120 },
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
// The 0% and 100% keyframes should now carry `x` backfilled at its default 0.
const { parseGsapScript } = await import("@hyperframes/core/gsap-parser");
const kfs = parseGsapScript(newScript)
.animations[0]?.keyframes?.keyframes?.slice()
.sort((a, b) => a.percentage - b.percentage);
expect(kfs?.map((k) => k.percentage)).toEqual([0, 60, 100]);
expect(kfs?.find((k) => k.percentage === 0)?.properties.x).toBe(0);
expect(kfs?.find((k) => k.percentage === 100)?.properties.x).toBe(0);
expect(kfs?.find((k) => k.percentage === 60)?.properties.x).toBe(120);
});
it("ease-only update (same position, no value) does not corrupt keyframe", () => {
const kfWithEase = KF_SCRIPT.replace(
'"0%": { opacity: 0 }',
+16 -2
View File
@@ -52,6 +52,7 @@ import {
addLabelToScript,
removeLabelFromScript,
} from "@hyperframes/core/gsap-writer-acorn";
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
export interface MutationResult {
forward: JsonPatchOp[];
@@ -699,7 +700,17 @@ function handleSetGsapKeyframe(
let newScript = script;
if (targetPct !== currentPct) {
newScript = removeKeyframeFromScript(newScript, animationId, currentPct);
newScript = addKeyframeToScript(newScript, animationId, targetPct, props, resolvedEase);
// Thread the same backfill defaults the add path uses so a move (remove +
// re-add at a new percentage) seeds new props into sibling keyframes the same
// way, keeping both entry points behaviorally identical.
newScript = addKeyframeToScript(
newScript,
animationId,
targetPct,
props,
resolvedEase,
deriveKeyframeBackfillDefaults(props),
);
} else {
newScript = updateKeyframeInScript(newScript, animationId, currentPct, props, resolvedEase);
}
@@ -717,11 +728,14 @@ function handleAddGsapKeyframe(
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const props = value as Record<string, number | string>;
const newScript = addKeyframeToScript(
script,
animationId,
percentage,
value as Record<string, number | string>,
props,
undefined,
deriveKeyframeBackfillDefaults(props),
);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);