@hyperframes/studio 0.6.110 → 0.6.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/index-B7S86vK1.js +370 -0
  2. package/dist/assets/index-DP8pPIk2.css +1 -0
  3. package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
  4. package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +8 -5
  7. package/src/App.tsx +133 -141
  8. package/src/components/StudioHeader.tsx +44 -0
  9. package/src/components/StudioOverlays.tsx +70 -0
  10. package/src/components/editor/SourceEditor.tsx +19 -16
  11. package/src/components/editor/manualEditingAvailability.ts +25 -7
  12. package/src/components/storyboard/FramePoster.tsx +56 -0
  13. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  14. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  15. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  16. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  17. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  18. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  19. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  20. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  21. package/src/components/storyboard/StoryboardView.tsx +78 -0
  22. package/src/components/storyboard/frameStatus.ts +36 -0
  23. package/src/contexts/ViewModeContext.tsx +98 -0
  24. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  25. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  26. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  27. package/src/hooks/timelineEditingHelpers.ts +2 -0
  28. package/src/hooks/useAppHotkeys.ts +20 -0
  29. package/src/hooks/useDomEditCommits.ts +43 -14
  30. package/src/hooks/useDomEditSession.test.ts +41 -0
  31. package/src/hooks/useDomEditSession.ts +38 -6
  32. package/src/hooks/useDomGeometryCommits.ts +5 -9
  33. package/src/hooks/useElementLifecycleOps.ts +30 -0
  34. package/src/hooks/useGsapAnimationOps.ts +78 -51
  35. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  36. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  37. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  38. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  39. package/src/hooks/useGsapScriptCommits.ts +95 -40
  40. package/src/hooks/useSdkSession.test.ts +12 -0
  41. package/src/hooks/useSdkSession.ts +91 -25
  42. package/src/hooks/useStoryboard.ts +80 -0
  43. package/src/hooks/useTimelineEditing.ts +163 -68
  44. package/src/utils/gsapSoftReload.ts +14 -0
  45. package/src/utils/sdkCutover.gate.test.ts +61 -0
  46. package/src/utils/sdkCutover.test.ts +839 -0
  47. package/src/utils/sdkCutover.ts +465 -0
  48. package/src/utils/sdkOpMapping.ts +43 -0
  49. package/src/utils/sdkResolverShadow.test.ts +366 -0
  50. package/src/utils/sdkResolverShadow.ts +313 -0
  51. package/dist/assets/index-1C8oFiPi.css +0 -1
  52. package/dist/assets/index-D-3sGz65.js +0 -296
  53. package/src/utils/sdkShadow.test.ts +0 -606
  54. package/src/utils/sdkShadow.ts +0 -517
  55. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  56. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  57. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  58. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -1,11 +1,56 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
+ import type { Composition } from "@hyperframes/sdk";
3
+ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
2
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
+ import {
6
+ sdkGsapTweenPersist,
7
+ sdkGsapRemovePropertyPersist,
8
+ type CutoverDeps,
9
+ } from "../utils/sdkCutover";
10
+ import { extractGsapScriptText } from "../utils/gsapSoftReload";
3
11
  import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers";
4
12
  import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
5
13
 
6
14
  const DEBOUNCE_MS = 150;
7
15
 
8
- export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMutation) {
16
+ /**
17
+ * The SDK `setGsapTween` 'set' path REPLACES a tween's editable property set
18
+ * (engine `handleSetGsapTween` → `updateAnimationInScript`), so sending only the
19
+ * single edited key would silently drop the tween's other animated props. Mirror
20
+ * the legacy server path (`{ ...anim.properties, [property]: val }`): read the
21
+ * tween's CURRENT properties from the in-memory SDK doc and merge the one edit in,
22
+ * so REPLACE semantics preserve siblings. Returns the single-key map unchanged
23
+ * when the tween/script can't be found (best-effort; before===after then falls
24
+ * back to the server path).
25
+ */
26
+ export function mergeTweenProperties(
27
+ sdkSession: Composition,
28
+ animationId: string,
29
+ edited: Record<string, number | string>,
30
+ kind: "to" | "from",
31
+ ): Record<string, number | string> {
32
+ try {
33
+ const script = extractGsapScriptText(sdkSession.serialize());
34
+ if (!script) return { ...edited };
35
+ const anim = parseGsapScriptAcorn(script).animations.find((a) => a.id === animationId);
36
+ if (!anim) return { ...edited };
37
+ const existing = kind === "from" ? (anim.fromProperties ?? {}) : anim.properties;
38
+ return { ...existing, ...edited };
39
+ } catch {
40
+ return { ...edited };
41
+ }
42
+ }
43
+
44
+ interface SdkPropertyDeps {
45
+ sdkSession?: Composition | null;
46
+ sdkDeps?: CutoverDeps | null;
47
+ activeCompPath?: string | null;
48
+ }
49
+
50
+ export function useGsapPropertyDebounce(
51
+ commitMutationSafely: SafeGsapCommitMutation,
52
+ sdk?: SdkPropertyDeps,
53
+ ) {
9
54
  const pendingPropertyEditRef = useRef<{
10
55
  selection: DomEditSelection;
11
56
  animationId: string;
@@ -14,11 +59,38 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
14
59
  } | null>(null);
15
60
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
16
61
 
17
- const flushPendingPropertyEdit = useCallback(() => {
62
+ // The caller passes `sdk` as a fresh object literal every render. Keying any
63
+ // callback's deps on it (esp. flushPendingPropertyEdit, whose identity drives
64
+ // the unmount-flush cleanup effect) re-fires the cleanup on EVERY parent
65
+ // re-render — so a playhead tick mid-slider-drag would flush + record an undo
66
+ // entry per render. Hold the latest value in a ref instead so every callback
67
+ // reads current deps without re-subscribing on identity churn.
68
+ const sdkRef = useRef(sdk);
69
+ sdkRef.current = sdk;
70
+
71
+ const flushPendingPropertyEdit = useCallback(async () => {
18
72
  const pending = pendingPropertyEditRef.current;
19
73
  if (!pending) return;
20
74
  pendingPropertyEditRef.current = null;
21
75
  const { selection, animationId, property, value } = pending;
76
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
77
+ if (sdkSession && sdkDeps) {
78
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
79
+ const handled = await sdkGsapTweenPersist(
80
+ targetPath,
81
+ {
82
+ kind: "set",
83
+ animationId,
84
+ properties: {
85
+ properties: mergeTweenProperties(sdkSession, animationId, { [property]: value }, "to"),
86
+ },
87
+ },
88
+ sdkSession,
89
+ sdkDeps,
90
+ { label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` },
91
+ );
92
+ if (handled) return;
93
+ }
22
94
  commitMutationSafely(
23
95
  selection,
24
96
  { type: "update-property", animationId, property, value },
@@ -39,7 +111,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
39
111
  ) => {
40
112
  pendingPropertyEditRef.current = { selection, animationId, property, value };
41
113
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
42
- debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS);
114
+ debounceTimerRef.current = setTimeout(() => {
115
+ void flushPendingPropertyEdit();
116
+ }, DEBOUNCE_MS);
43
117
  },
44
118
  [flushPendingPropertyEdit],
45
119
  );
@@ -47,12 +121,14 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
47
121
  useEffect(() => {
48
122
  return () => {
49
123
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
50
- flushPendingPropertyEdit();
124
+ void flushPendingPropertyEdit();
51
125
  };
52
126
  }, [flushPendingPropertyEdit]);
53
127
 
128
+ // fallow-ignore-next-line complexity
54
129
  const addGsapProperty = useCallback(
55
- (selection: DomEditSelection, animationId: string, property: string) => {
130
+ // fallow-ignore-next-line complexity
131
+ async (selection: DomEditSelection, animationId: string, property: string) => {
56
132
  let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
57
133
  const el = selection.element;
58
134
  if (property === "width" || property === "height") {
@@ -60,7 +136,33 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
60
136
  defaultValue = Math.round(property === "width" ? rect.width : rect.height);
61
137
  } else if (property === "opacity" || property === "autoAlpha") {
62
138
  const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
63
- 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;
143
+ }
144
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
145
+ if (sdkSession && sdkDeps) {
146
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
147
+ const handled = await sdkGsapTweenPersist(
148
+ targetPath,
149
+ {
150
+ kind: "set",
151
+ animationId,
152
+ properties: {
153
+ properties: mergeTweenProperties(
154
+ sdkSession,
155
+ animationId,
156
+ { [property]: defaultValue },
157
+ "to",
158
+ ),
159
+ },
160
+ },
161
+ sdkSession,
162
+ sdkDeps,
163
+ { label: `Add GSAP ${property}` },
164
+ );
165
+ if (handled) return;
64
166
  }
65
167
  commitMutationSafely(
66
168
  selection,
@@ -71,24 +173,82 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
71
173
  [commitMutationSafely],
72
174
  );
73
175
 
74
- const removeGsapProperty = useCallback(
75
- (selection: DomEditSelection, animationId: string, property: string) => {
76
- commitMutationSafely(
77
- selection,
78
- { type: "remove-property", animationId, property },
79
- { label: `Remove GSAP ${property}` },
80
- );
176
+ const removeProperty = useCallback(
177
+ async (selection: DomEditSelection, animationId: string, property: string, from: boolean) => {
178
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
179
+ if (sdkSession && sdkDeps) {
180
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
181
+ const handled = await sdkGsapRemovePropertyPersist(
182
+ targetPath,
183
+ animationId,
184
+ property,
185
+ from,
186
+ sdkSession,
187
+ sdkDeps,
188
+ { label: `Remove GSAP ${from ? `from-${property}` : property}` },
189
+ );
190
+ if (handled) return;
191
+ }
192
+ if (from) {
193
+ commitMutationSafely(
194
+ selection,
195
+ { type: "remove-from-property", animationId, property },
196
+ {
197
+ label: `Remove GSAP from-${property}`,
198
+ },
199
+ );
200
+ } else {
201
+ commitMutationSafely(
202
+ selection,
203
+ { type: "remove-property", animationId, property },
204
+ {
205
+ label: `Remove GSAP ${property}`,
206
+ },
207
+ );
208
+ }
81
209
  },
82
210
  [commitMutationSafely],
83
211
  );
84
212
 
213
+ const removeGsapProperty = useCallback(
214
+ (selection: DomEditSelection, animationId: string, property: string) =>
215
+ removeProperty(selection, animationId, property, false),
216
+ [removeProperty],
217
+ );
218
+
85
219
  const updateGsapFromProperty = useCallback(
86
- (
220
+ async (
87
221
  selection: DomEditSelection,
88
222
  animationId: string,
89
223
  property: string,
90
224
  value: number | string,
91
225
  ) => {
226
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
227
+ if (sdkSession && sdkDeps) {
228
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
229
+ const handled = await sdkGsapTweenPersist(
230
+ targetPath,
231
+ {
232
+ kind: "set",
233
+ animationId,
234
+ properties: {
235
+ fromProperties: mergeTweenProperties(
236
+ sdkSession,
237
+ animationId,
238
+ { [property]: value },
239
+ "from",
240
+ ),
241
+ },
242
+ },
243
+ sdkSession,
244
+ sdkDeps,
245
+ {
246
+ label: `Edit GSAP from-${property}`,
247
+ coalesceKey: `gsap:${animationId}:from:${property}`,
248
+ },
249
+ );
250
+ if (handled) return;
251
+ }
92
252
  commitMutationSafely(
93
253
  selection,
94
254
  { type: "update-from-property", animationId, property, value },
@@ -102,8 +262,31 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
102
262
  );
103
263
 
104
264
  const addGsapFromProperty = useCallback(
105
- (selection: DomEditSelection, animationId: string, property: string) => {
265
+ async (selection: DomEditSelection, animationId: string, property: string) => {
106
266
  const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
267
+ const { sdkSession, sdkDeps, activeCompPath } = sdkRef.current ?? {};
268
+ if (sdkSession && sdkDeps) {
269
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
270
+ const handled = await sdkGsapTweenPersist(
271
+ targetPath,
272
+ {
273
+ kind: "set",
274
+ animationId,
275
+ properties: {
276
+ fromProperties: mergeTweenProperties(
277
+ sdkSession,
278
+ animationId,
279
+ { [property]: defaultValue },
280
+ "from",
281
+ ),
282
+ },
283
+ },
284
+ sdkSession,
285
+ sdkDeps,
286
+ { label: `Add GSAP from-${property}` },
287
+ );
288
+ if (handled) return;
289
+ }
107
290
  commitMutationSafely(
108
291
  selection,
109
292
  { type: "add-from-property", animationId, property, defaultValue },
@@ -114,14 +297,9 @@ export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMuta
114
297
  );
115
298
 
116
299
  const removeGsapFromProperty = useCallback(
117
- (selection: DomEditSelection, animationId: string, property: string) => {
118
- commitMutationSafely(
119
- selection,
120
- { type: "remove-from-property", animationId, property },
121
- { label: `Remove GSAP from-${property}` },
122
- );
123
- },
124
- [commitMutationSafely],
300
+ (selection: DomEditSelection, animationId: string, property: string) =>
301
+ removeProperty(selection, animationId, property, true),
302
+ [removeProperty],
125
303
  );
126
304
 
127
305
  return {
@@ -0,0 +1,85 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, useState } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+
8
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ // The SDK path is gated on STUDIO_SDK_CUTOVER_ENABLED; keep it OFF so the flush
11
+ // routes through commitMutationSafely (the spy we count), keeping the test about
12
+ // flush TIMING, not the SDK write path.
13
+ vi.mock("../components/editor/manualEditingAvailability", () => ({
14
+ STUDIO_SDK_CUTOVER_ENABLED: false,
15
+ }));
16
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
17
+
18
+ const selection = { sourceFile: "index.html" } as unknown as DomEditSelection;
19
+
20
+ describe("useGsapPropertyDebounce flush stability (finding #7)", () => {
21
+ let container: HTMLDivElement;
22
+ beforeEach(() => {
23
+ vi.useFakeTimers();
24
+ container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ });
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ container.remove();
30
+ });
31
+
32
+ it("re-rendering the parent while an edit is pending does NOT flush early or duplicate commits", () => {
33
+ const commitMutationSafely = vi.fn();
34
+ let queueEdit: (() => void) | null = null;
35
+ let forceRerender: (() => void) | null = null;
36
+
37
+ function Harness() {
38
+ const [tick, setTick] = useState(0);
39
+ forceRerender = () => setTick((t) => t + 1);
40
+ // A FRESH sdk wrapper literal every render — the exact churn that, before
41
+ // the ref-stabilization fix, re-fired the unmount-flush cleanup effect.
42
+ const ops = useGsapPropertyDebounce(commitMutationSafely, {
43
+ sdkSession: null,
44
+ sdkDeps: null,
45
+ activeCompPath: "index.html",
46
+ });
47
+ queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", tick + 1);
48
+ return React.createElement("div", null, String(tick));
49
+ }
50
+
51
+ const root = createRoot(container);
52
+ act(() => {
53
+ root.render(React.createElement(Harness));
54
+ });
55
+
56
+ // Queue one pending edit.
57
+ act(() => {
58
+ queueEdit?.();
59
+ });
60
+ expect(commitMutationSafely).not.toHaveBeenCalled();
61
+
62
+ // Re-render the parent several times BEFORE the debounce elapses. The bug
63
+ // flushed (and recorded a commit) on every re-render via the cleanup effect.
64
+ act(() => {
65
+ forceRerender?.();
66
+ });
67
+ act(() => {
68
+ forceRerender?.();
69
+ });
70
+ act(() => {
71
+ forceRerender?.();
72
+ });
73
+ expect(commitMutationSafely).not.toHaveBeenCalled();
74
+
75
+ // The debounce fires exactly once.
76
+ act(() => {
77
+ vi.advanceTimersByTime(200);
78
+ });
79
+ expect(commitMutationSafely).toHaveBeenCalledTimes(1);
80
+
81
+ act(() => {
82
+ root.unmount();
83
+ });
84
+ });
85
+ });
@@ -1,9 +1,8 @@
1
- import { useCallback, useRef } from "react";
1
+ import { useCallback, useMemo, useRef } from "react";
2
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
- import { applySoftReload } from "../utils/gsapSoftReload";
5
- import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
6
- import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
4
+ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
5
+ import type { CutoverDeps } from "../utils/sdkCutover";
7
6
  import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
8
7
  import { createKeyedSerializer } from "./serializeByKey";
9
8
  import {
@@ -46,15 +45,12 @@ async function mutateGsapScript(
46
45
 
47
46
  // oxfmt-ignore
48
47
  // fallow-ignore-next-line complexity
49
- export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
48
+ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
50
49
  // Serializer for per-key commits (options.serializeKey). Keyed by
51
50
  // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
52
- // the same animationId so their POSTs can't interleave which is what made
53
- // the shadow fidelity diff pair an op with a stale server result and report
54
- // false ease mismatches. Held in a ref so the chain survives re-renders.
51
+ // the same animationId so their POSTs can't interleave. Held in a ref so the
52
+ // chain survives re-renders.
55
53
  const serializerRef = useRef(createKeyedSerializer());
56
- // Pre-existing complexity (server mutate + history + reload branches); this PR
57
- // adds only a guarded shadow-fidelity dispatch.
58
54
  // fallow-ignore-next-line complexity
59
55
  const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
60
56
  const pid = projectIdRef.current;
@@ -76,32 +72,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
76
72
  }
77
73
  if (result.changed === false) return;
78
74
  domEditSaveTimestampRef.current = Date.now();
79
- // Shadow value fidelity: diff the SDK's GSAP writer output against the
80
- // server's, from the same pre-op file. Fire-and-forget; server authoritative.
81
- // Meta-level ops carry shadowGsapOp (add / update-meta / delete via
82
- // useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
83
- // useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
84
- // handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
85
- // scriptText is null when the composition has no GSAP script; nothing to diff.
86
- const fidelityArgs = resolveGsapFidelityArgs(
87
- sdkSession,
88
- options.shadowGsapOp,
89
- result.before,
90
- result.scriptText,
91
- );
92
- if (fidelityArgs) {
93
- void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
94
- }
95
- // Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
96
- // the SDK has no keyframe reader so there is no live-existence path — the diff
97
- // is the only signal. Guarded on a live session + both scripts to diff.
98
- if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
99
- void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
100
- }
101
75
  if (result.before != null && result.after != null) {
102
76
  await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
103
77
  }
104
78
  if (result.after != null) onFileContentChanged?.(targetPath, result.after);
79
+ // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
80
+ // later SDK-routed edit doesn't serialize the pre-write doc and revert this.
81
+ forceReloadSdkSession?.();
105
82
  if (options.skipReload) return;
106
83
  if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
107
84
  options.beforeReload?.();
@@ -111,12 +88,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
111
88
  reloadPreview();
112
89
  }
113
90
  onCacheInvalidate();
114
- }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
91
+ }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
115
92
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
116
- // commits to the SAME file (any op type, any animation) interleave server-side
117
- // and make the shadow fidelity diff pair an op with a stale server result —
118
- // the false ease/value mismatches this serializer exists to prevent. So
119
- // serialize per target file by default; an explicit serializeKey overrides.
93
+ // commits to the SAME file (any op type, any animation) interleave server-side,
94
+ // so serialize per target file by default; an explicit serializeKey overrides.
120
95
  const commitMutation = useCallback(
121
96
  (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
122
97
  const file = selection.sourceFile || activeCompPath || "index.html";
@@ -127,9 +102,89 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
127
102
  );
128
103
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
129
104
  const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
130
- const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
131
- const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession });
132
- const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure });
105
+
106
+ // One stable SDK-deps object shared by all GSAP child hooks. Memoized so the
107
+ // hooks' callbacks keep a stable identity (an inline literal here re-fired the
108
+ // property-debounce flush on every render). refresh() soft-reloads (preserving
109
+ // the playhead) and invalidates the panel cache, matching the server path.
110
+ const sdkRefresh = useCallback(
111
+ (after: string) => {
112
+ const script = extractGsapScriptText(after);
113
+ if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview();
114
+ onCacheInvalidate();
115
+ },
116
+ [previewIframeRef, reloadPreview, onCacheInvalidate],
117
+ );
118
+ // Reuse the SAME per-file serializer the legacy commitMutation path uses, so
119
+ // SDK gsap-write flushes serialize against legacy commits AND each other —
120
+ // overlapping same-file read-modify-writes can't interleave and lose an edit.
121
+ const serializeByFile = useCallback(
122
+ <T>(key: string, task: () => Promise<T>): Promise<T> => serializerRef.current(key, task),
123
+ [],
124
+ );
125
+ // Read the on-disk bytes of targetPath so the SDK GSAP persist captures the
126
+ // exact prior content as its undo `before` (matching the style/delete paths),
127
+ // instead of a normalized full-DOM re-emit that would reformat the whole file.
128
+ const readProjectFileContent = useCallback(
129
+ async (path: string): Promise<string> => {
130
+ const pid = projectIdRef.current;
131
+ if (!pid) throw new Error("No active project");
132
+ const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
133
+ if (!res.ok) throw new Error(`Failed to read ${path}`);
134
+ const data = (await res.json()) as { content?: string };
135
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
136
+ return data.content;
137
+ },
138
+ [projectIdRef],
139
+ );
140
+ const sdkDeps = useMemo<CutoverDeps | null>(
141
+ () =>
142
+ writeProjectFile
143
+ ? {
144
+ editHistory: { recordEdit: editHistory.recordEdit },
145
+ writeProjectFile,
146
+ reloadPreview,
147
+ domEditSaveTimestampRef,
148
+ refresh: sdkRefresh,
149
+ compositionPath: activeCompPath,
150
+ serialize: serializeByFile,
151
+ readProjectFile: readProjectFileContent,
152
+ }
153
+ : null,
154
+ [
155
+ editHistory.recordEdit,
156
+ writeProjectFile,
157
+ reloadPreview,
158
+ domEditSaveTimestampRef,
159
+ sdkRefresh,
160
+ activeCompPath,
161
+ serializeByFile,
162
+ readProjectFileContent,
163
+ ],
164
+ );
165
+
166
+ const propertyOps = useGsapPropertyDebounce(commitMutationSafely, {
167
+ sdkSession,
168
+ sdkDeps,
169
+ activeCompPath,
170
+ });
171
+ const animationOps = useGsapAnimationOps({
172
+ projectIdRef,
173
+ activeCompPath,
174
+ commitMutation,
175
+ commitMutationSafely,
176
+ showToast,
177
+ sdkSession,
178
+ sdkDeps,
179
+ });
180
+ const keyframeOps = useGsapKeyframeOps({
181
+ activeCompPath,
182
+ commitMutation,
183
+ commitMutationSafely,
184
+ trackGsapSaveFailure,
185
+ sdkSession,
186
+ sdkDeps,
187
+ });
133
188
  const arcPathOps = useGsapArcPathOps(commitMutationSafely);
134
189
  return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
135
190
  }
@@ -1,6 +1,18 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { shouldReloadSdkSession } from "./useSdkSession";
3
3
 
4
+ // ── undo-sync contract ────────────────────────────────────────────────────────
5
+ // useSdkSession exposes forceReload() so callers can bypass the 2 s self-write
6
+ // suppress window. useAppHotkeys calls forceReload() after a successful
7
+ // undo/redo that wrote the active composition path. Without it, the suppress
8
+ // window swallows the file-change event and the SDK session stays stale.
9
+ //
10
+ // The React hook internals (useState / useEffect) cannot be unit-tested without
11
+ // a full render environment; the correctness of the suppress-bypass path is
12
+ // covered by the integration tests in usePersistentEditHistory.test.ts
13
+ // (which verify undo writes the correct before-content to disk).
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
4
16
  describe("shouldReloadSdkSession", () => {
5
17
  it("reloads when the changed file is the active composition", () => {
6
18
  expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);