Skip to content

Commit 0a9f66a

Browse files
vanceingallsclaude
andcommitted
fix(sdk,studio): R5 cutover review fixes — fromTo dest, timing sync, parity
Confirmed correctness findings from the R5 review of the SDK cutover stack, applied on top of #1539: - fromTo add via cutover dropped its destination: handleAddGsapTween read only `toProperties`; now falls back to `properties` like every other method. - handleSetTiming GSAP sync: a clip with no data-start skipped the shift (now treats start as 0, matching the server path) and a blank/non-numeric data-start wrote position: NaN (now sanitized). - handleSetTiming no longer appends an absolute position to an auto-sequenced (implicit-position) tween, which collapsed staggers. - handleSetTiming keeps data-end in sync when a clip carries BOTH data-duration and data-end (a stale data-end inverted the clip). - string/relative tween positions ("+=0.5", "<") documented as a known ceiling. - opacity/autoAlpha property seed no longer falsy-zero (`|| 1`): an element at opacity 0 seeds 0, not 1. - optimistic add-keyframe cache tolerance aligned to the writer's PCT_TOLERANCE (2%) so a near-neighbour keyframe no longer shows then vanishes on reload. - DOM-patch finiteness validation runs before the SDK cutover path. - attribute ops mapping to a reserved data-* name decline the cutover up front instead of throwing inside dispatch. Regression tests added for each SDK-side fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6c2d668 commit 0a9f66a

7 files changed

Lines changed: 158 additions & 19 deletions

File tree

packages/sdk/src/engine/mutate.gsap.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,26 @@ describe("addGsapTween", () => {
184184
expect(newScript).toContain("opacity: 0");
185185
expect(newScript).toContain("opacity: 1");
186186
});
187+
188+
it("R5 #1: fromTo destination supplied via `properties` (Studio add path) is not dropped", () => {
189+
const parsed = fresh();
190+
const result = applyOp(parsed, {
191+
type: "addGsapTween",
192+
target: "hf-box",
193+
tween: {
194+
method: "fromTo",
195+
duration: 0.5,
196+
fromProperties: { opacity: 0 },
197+
// Studio's add path puts the destination in `properties`, not `toProperties`.
198+
properties: { x: 400, opacity: 1 },
199+
},
200+
});
201+
const newScript = String(result.forward[0]?.value ?? "");
202+
expect(newScript).toContain("fromTo(");
203+
// Regression: fromTo previously read only `toProperties` and wrote empty
204+
// to-vars, so the destination vanished.
205+
expect(newScript).toContain("x: 400");
206+
});
187207
});
188208

189209
// ─── Tween op test helpers ────────────────────────────────────────────────────
@@ -1027,4 +1047,49 @@ describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => {
10271047
// tween duration scaled 4 → 8 (ratio 2).
10281048
expect(getScript(parsed)).toContain("duration: 8");
10291049
});
1050+
1051+
it("R5 #3: a start-less clip (no data-start) still shifts its tween (implicit start 0)", () => {
1052+
const parsed = timingDoc(`data-duration="4"`, `tl.to("#box", { x: 100, duration: 1 }, 2);`);
1053+
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 3 });
1054+
// oldStart defaults to 0, so position remaps 2 → 3 + (2 − 0) = 5.
1055+
// The bug skipped the whole sync block when data-start was absent.
1056+
expect(getScript(parsed)).toMatch(/tl\.to\("#box",[^)]*\}, 5\)/);
1057+
});
1058+
1059+
it("R5 #3: a malformed data-start never writes position: NaN", () => {
1060+
const parsed = timingDoc(
1061+
`data-start="" data-duration="4"`,
1062+
`tl.to("#box", { x: 100, duration: 1 }, 2);`,
1063+
);
1064+
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 3 });
1065+
const script = getScript(parsed);
1066+
expect(script).not.toContain("NaN");
1067+
expect(script).toMatch(/tl\.to\("#box",[^)]*\}, 5\)/);
1068+
});
1069+
1070+
it("R5 #2: an implicit-position tween is not collapsed to an absolute position on move", () => {
1071+
const parsed = timingDoc(
1072+
`data-start="2" data-end="5"`,
1073+
`tl.to("#box", { x: 100, duration: 1 });`,
1074+
);
1075+
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 5 });
1076+
const script = getScript(parsed);
1077+
// The tween had no position arg (auto-sequenced); it must stay that way —
1078+
// appending an absolute position would collapse the stagger.
1079+
expect(script).toContain('tl.to("#box", { x: 100, duration: 1 })');
1080+
expect(script).not.toMatch(/tl\.to\("#box",[^)]*\}, \d/);
1081+
});
1082+
1083+
it("R5 #7: a clip with BOTH data-duration and data-end keeps data-end in sync on move", () => {
1084+
const parsed = timingDoc(
1085+
`data-start="1" data-duration="2" data-end="3"`,
1086+
`tl.to("#box", { x: 1, duration: 2 }, 1);`,
1087+
);
1088+
applyOp(parsed, { type: "setTiming", target: "hf-box", start: 5 });
1089+
const el = parsed.document.querySelector('[data-hf-id="hf-box"]');
1090+
expect(el?.getAttribute("data-start")).toBe("5");
1091+
expect(el?.getAttribute("data-duration")).toBe("2");
1092+
// data-end recomputed (5 + 2); the bug left it stale at 3 → inverted clip.
1093+
expect(el?.getAttribute("data-end")).toBe("7");
1094+
});
10301095
});

packages/sdk/src/engine/mutate.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,17 @@ function handleSetTiming(
437437
result.inverse.push(p.inverse);
438438
el.setAttribute("data-duration", String(newDuration));
439439
}
440+
// A clip carrying BOTH data-duration and data-end must keep data-end in
441+
// sync (end = start + duration) on any start/duration change, else the
442+
// stale data-end inverts the clip (end < start) for runtimes that read it.
443+
if (oldEndStr !== null && newStart !== null && newDuration !== null) {
444+
const newEnd = newStart + newDuration;
445+
const endPath = timingPath(id, "end");
446+
const ep = scalarChange(endPath, oldEnd, newEnd);
447+
result.forward.push(ep.forward);
448+
result.inverse.push(ep.inverse);
449+
el.setAttribute("data-end", String(newEnd));
450+
}
440451
} else if (
441452
(timing.duration !== undefined || timing.start !== undefined) &&
442453
newStart !== null &&
@@ -470,7 +481,12 @@ function handleSetTiming(
470481
// those clips left their tweens unsynced.
471482
const matchHfId = el.getAttribute("data-hf-id") ?? id;
472483
const matchDomId = el.getAttribute("id");
473-
if (parsedGsap && currentScript && oldStart !== null) {
484+
if (parsedGsap && currentScript) {
485+
// A missing data-start means an implicit start of 0 (matching the server
486+
// shiftGsapPositions path); a malformed attr parses to NaN. Sanitize to a
487+
// finite number so a start-less/blank clip still shifts and never feeds
488+
// NaN into the tween positions.
489+
const oldStartNum = oldStart !== null && Number.isFinite(oldStart) ? oldStart : 0;
474490
// Per-tween shift/scale (mirrors shiftGsapPositions/scaleGsapPositions): a
475491
// multi-tween stagger maps each tween's own intra-clip position by the
476492
// start DELTA and scales its duration by the clip-duration RATIO. Writing
@@ -482,16 +498,25 @@ function handleSetTiming(
482498
durChanged && oldDuration !== null && oldDuration > 0 && newDuration !== null
483499
? newDuration / oldDuration
484500
: 1;
485-
const remapStart = startChanged && newStart !== null ? newStart : oldStart;
501+
const remapStart = startChanged && newStart !== null ? newStart : oldStartNum;
486502
for (const { id: animId, animation } of parsedGsap.located) {
487503
const matches =
488504
selectorMatchesId(animation.targetSelector, matchHfId) ||
489505
(matchDomId !== null && selectorMatchesId(animation.targetSelector, matchDomId));
490506
if (!matches) continue;
507+
// Skip tweens whose position is a label or relative string ("+=0.5",
508+
// "<", ">"): relative positions already track their neighbours, and a
509+
// string position can't be safely shifted by the clip delta here.
510+
// ponytail: known ceiling — string positions are not re-synced on
511+
// move/resize; numeric positions only.
491512
if (typeof animation.position !== "number") continue;
492513
const updates: Partial<GsapAnimation> = {};
493-
if (startChanged || durChanged) {
494-
const shifted = remapStart + (animation.position - oldStart) * ratio;
514+
// Don't write an absolute position onto an auto-sequenced tween (no
515+
// explicit position arg → parsed as implicitPosition): the writer would
516+
// APPEND a position arg, collapsing the stagger onto one point. Duration
517+
// still scales below.
518+
if ((startChanged || durChanged) && animation.implicitPosition !== true) {
519+
const shifted = remapStart + (animation.position - oldStartNum) * ratio;
495520
updates.position = Math.max(0, Math.round(shifted * 1000) / 1000);
496521
}
497522
if (durChanged && typeof animation.duration === "number" && animation.duration > 0) {
@@ -771,10 +796,11 @@ function handleAddGsapTween(
771796
if (tween.yoyo !== undefined) extras.yoyo = tween.yoyo;
772797
if (tween.stagger !== undefined) extras.stagger = tween.stagger;
773798

774-
const toProps =
775-
tween.method === "fromTo"
776-
? ((tween.toProperties ?? {}) as Record<string, number | string>)
777-
: ((tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>);
799+
// A fromTo's destination may arrive as either `toProperties` or `properties`
800+
// (the Studio add path sets `properties`). Fall back the same way for every
801+
// method — the old fromTo-only branch read `toProperties` alone and wrote an
802+
// empty to-vars object, so fromTo animations added via cutover animated to {}.
803+
const toProps = (tween.toProperties ?? tween.properties ?? {}) as Record<string, number | string>;
778804

779805
// Scoped ids like "hf-host/hf-leaf" must use the bare leaf id in the GSAP
780806
// selector — only the leaf part is written as data-hf-id on the DOM element.

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,20 @@ export function useDomEditCommits({
154154
}
155155

156156
if (options?.shouldSave && !options.shouldSave()) return;
157+
158+
// Validate layout values BEFORE any persist path runs. The SDK cutover
159+
// path (onTrySdkPersist) returns early on success, so leaving this check
160+
// after it let invalid numeric values bypass the guard whenever the
161+
// cutover flag was on.
162+
const patchTarget = buildDomEditPatchTarget(selection);
163+
const patchBody = { target: patchTarget, operations };
164+
const unsafeFields = findUnsafeDomPatchValues(patchBody);
165+
if (unsafeFields.length > 0) {
166+
const fields = formatUnsafeFieldList(unsafeFields);
167+
showToast("Couldn't save edit because it contains invalid layout values", "error");
168+
throw new Error(`DOM patch contains unsafe values: ${fields}`);
169+
}
170+
157171
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
158172
// for a custom font): sdkCutoverPersist serializes only the patched DOM
159173
// and would drop the injected content. Let the server path run prepareContent.
@@ -170,14 +184,6 @@ export function useDomEditCommits({
170184
// forceReload (that would echo-reload the session we just wrote).
171185
return;
172186
}
173-
const patchTarget = buildDomEditPatchTarget(selection);
174-
const patchBody = { target: patchTarget, operations };
175-
const unsafeFields = findUnsafeDomPatchValues(patchBody);
176-
if (unsafeFields.length > 0) {
177-
const fields = formatUnsafeFieldList(unsafeFields);
178-
showToast("Couldn't save edit because it contains invalid layout values", "error");
179-
throw new Error(`DOM patch contains unsafe values: ${fields}`);
180-
}
181187

182188
// Mark the save timestamp before the file write so the SSE file-change
183189
// handler suppresses the reload even if the event arrives before the

packages/studio/src/hooks/useGsapKeyframeOps.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,12 @@ export function useGsapKeyframeOps({
8080
// appending a duplicate — matches addKeyframeToScript, which writes one
8181
// keyframe per percentage (merging properties).
8282
apply: (prev) => {
83+
// Match addKeyframeToScript's merge tolerance (PCT_TOLERANCE = 2 in
84+
// gsapWriterAcorn): a keyframe added within 2% of an existing one
85+
// merges on disk, so the optimistic cache must merge it too — else the
86+
// UI shows a phantom keyframe that vanishes on the next reload.
8387
const idx = prev.keyframes.findIndex(
84-
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) < 0.001,
88+
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) <= 2,
8589
);
8690
if (idx >= 0) {
8791
const keyframes = prev.keyframes.slice();

packages/studio/src/hooks/useGsapPropertyDebounce.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ export function useGsapPropertyDebounce(
136136
defaultValue = Math.round(property === "width" ? rect.width : rect.height);
137137
} else if (property === "opacity" || property === "autoAlpha") {
138138
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
139-
defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1;
139+
// Use `|| 1` only as a non-finite fallback, not a falsy fallback: an
140+
// element currently at opacity 0 must seed 0, not 1.
141+
const current = cs ? Number.parseFloat(cs.opacity) : Number.NaN;
142+
defaultValue = Number.isFinite(current) ? current : 1;
140143
}
141144
const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
142145
if (sdkSession && sdkDeps) {

packages/studio/src/utils/sdkCutover.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ describe("shouldUseSdkCutover", () => {
7777
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true);
7878
});
7979

80+
it("returns false for an attribute op that maps to a reserved data-* name", () => {
81+
// {type:'attribute', property:'end'} → 'data-end', which the SDK's
82+
// validateSetAttribute rejects. Decline the batch so it takes the server
83+
// path cleanly instead of throwing inside dispatch and falling back per op.
84+
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("end", "2")])).toBe(false);
85+
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-start", "1")])).toBe(false);
86+
});
87+
8088
it("returns true when ops mix all supported types", () => {
8189
expect(
8290
shouldUseSdkCutover(true, true, "hf-abc", [

packages/studio/src/utils/sdkCutover.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,32 @@ const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
1414
"html-attribute",
1515
]);
1616

17+
// Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is
18+
// force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK
19+
// rejects with a throw. Detect that up front and decline the whole batch so it
20+
// takes the server path cleanly, instead of throwing inside the dispatch and
21+
// silently falling back per op.
22+
// ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new
23+
// op for it just reverts to the (working) throw→fallback path until synced.
24+
const RESERVED_CUTOVER_ATTRS = new Set<string>([
25+
"data-hf-id",
26+
"data-composition-id",
27+
"data-width",
28+
"data-height",
29+
"data-start",
30+
"data-end",
31+
"data-track-index",
32+
"data-hold-start",
33+
"data-hold-end",
34+
"data-hold-fill",
35+
]);
36+
37+
function mapsToReservedAttr(op: PatchOperation): boolean {
38+
if (op.type !== "attribute") return false;
39+
const name = op.property.startsWith("data-") ? op.property : `data-${op.property}`;
40+
return RESERVED_CUTOVER_ATTRS.has(name);
41+
}
42+
1743
/**
1844
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
1945
*
@@ -61,7 +87,8 @@ export function shouldUseSdkCutover(
6187
hasSession &&
6288
!!hfId &&
6389
ops.length > 0 &&
64-
ops.every((o) => CUTOVER_OP_TYPES.has(o.type))
90+
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
91+
!ops.some(mapsToReservedAttr)
6592
);
6693
}
6794

0 commit comments

Comments
 (0)