@hyperframes/studio 0.7.58 → 0.7.60

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 (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -28,6 +28,7 @@ vi.mock("../utils/studioTelemetry", () => ({
28
28
 
29
29
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
30
30
  import type { MutationResult } from "./gsapScriptCommitTypes";
31
+ import { persistSdkSerialize } from "../utils/sdkCutover";
31
32
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
32
33
 
33
34
  // ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
@@ -214,7 +215,11 @@ type HookApi = ReturnType<typeof useGsapScriptCommits>;
214
215
 
215
216
  let cleanup: (() => void) | null = null;
216
217
 
217
- function renderCommitHook() {
218
+ function renderCommitHook(
219
+ options: {
220
+ writeProjectFile?: (path: string, content: string) => Promise<void>;
221
+ } = {},
222
+ ) {
218
223
  const reloadPreview = vi.fn();
219
224
  const onCacheInvalidate = vi.fn();
220
225
  const onFileContentChanged = vi.fn();
@@ -235,7 +240,7 @@ function renderCommitHook() {
235
240
  onFileContentChanged,
236
241
  showToast,
237
242
  sdkSession: null,
238
- writeProjectFile: undefined,
243
+ writeProjectFile: options.writeProjectFile,
239
244
  forceReloadSdkSession,
240
245
  });
241
246
  return null;
@@ -422,4 +427,45 @@ describe("runCommit — instantPatch wiring", () => {
422
427
  expect(applySoftReload).toHaveBeenCalledTimes(1);
423
428
  expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
424
429
  });
430
+
431
+ it("serializes a legacy fallback request behind an in-flight SDK whole-file edit", async () => {
432
+ let releaseSdkWrite: (() => void) | undefined;
433
+ const sdkWriteGate = new Promise<void>((resolve) => {
434
+ releaseSdkWrite = resolve;
435
+ });
436
+ let notifySdkWriteStarted: (() => void) | undefined;
437
+ const sdkWriteStarted = new Promise<void>((resolve) => {
438
+ notifySdkWriteStarted = resolve;
439
+ });
440
+ const writeProjectFile = vi.fn(async () => {
441
+ notifySdkWriteStarted?.();
442
+ await sdkWriteGate;
443
+ });
444
+ const deps = renderCommitHook({ writeProjectFile });
445
+ const sdkEdit = persistSdkSerialize(() => "SDK_AFTER", "index.html", "BEFORE", {
446
+ editHistory: { recordEdit: deps.recordEdit },
447
+ writeProjectFile,
448
+ readProjectFile: vi.fn(async () => "BEFORE"),
449
+ reloadPreview: deps.reloadPreview,
450
+ domEditSaveTimestampRef: { current: 0 },
451
+ });
452
+ await sdkWriteStarted;
453
+
454
+ mockFetchResult();
455
+ let legacyEdit: Promise<void> | undefined;
456
+ act(() => {
457
+ legacyEdit = deps.api.commitMutation(selection, { x: 10 }, { label: "Legacy fallback" });
458
+ });
459
+ await Promise.resolve();
460
+ expect(fetch).not.toHaveBeenCalled();
461
+
462
+ releaseSdkWrite?.();
463
+ await act(async () => {
464
+ await Promise.all([sdkEdit, legacyEdit]);
465
+ });
466
+ expect(fetch).toHaveBeenCalledWith(
467
+ "/api/projects/proj-1/gsap-mutations/index.html",
468
+ expect.objectContaining({ method: "POST" }),
469
+ );
470
+ });
425
471
  });
@@ -6,6 +6,12 @@ import { usePlayerStore } from "../player/store/playerStore";
6
6
  import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
7
7
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
+ import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
10
+ import {
11
+ getStudioSaveErrorMessage,
12
+ isStudioSaveErrorAlreadyToasted,
13
+ markStudioSaveErrorAlreadyToasted,
14
+ } from "../utils/studioSaveDiagnostics";
9
15
  import type { CutoverDeps } from "../utils/sdkCutover";
10
16
  import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
11
17
  import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch";
@@ -81,15 +87,19 @@ async function runMutationRequest(
81
87
  if (unsafeFields.length > 0) {
82
88
  showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
83
89
  if (options.skipReload) return;
84
- throw new Error(
85
- `Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`,
90
+ throw markStudioSaveErrorAlreadyToasted(
91
+ new Error(
92
+ `Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`,
93
+ ),
86
94
  );
87
95
  }
88
96
  try {
89
97
  return await request();
90
98
  } catch (error) {
91
- if (error instanceof GsapMutationHttpError)
99
+ if (error instanceof GsapMutationHttpError) {
92
100
  showToast?.(formatGsapMutationRejectionToast(error), "error");
101
+ markStudioSaveErrorAlreadyToasted(error);
102
+ }
93
103
  if (options.skipReload) return;
94
104
  throw error;
95
105
  }
@@ -120,6 +130,54 @@ function refreshMutationPreview(
120
130
  onCacheInvalidate();
121
131
  }
122
132
 
133
+ function isActiveCommitTarget(
134
+ projectIdRef: { current: string | null },
135
+ activeCompPathRef: { current: string | null },
136
+ projectId: string,
137
+ compositionPath: string | null,
138
+ ): boolean {
139
+ return projectIdRef.current === projectId && activeCompPathRef.current === compositionPath;
140
+ }
141
+
142
+ function syncCommittedGsapMutation({
143
+ iframe,
144
+ selection,
145
+ mutation,
146
+ targetPath,
147
+ result,
148
+ options,
149
+ onFileContentChanged,
150
+ forceReloadSdkSession,
151
+ reloadPreview,
152
+ onCacheInvalidate,
153
+ }: {
154
+ iframe: HTMLIFrameElement | null;
155
+ selection: DomEditSelection;
156
+ mutation: Record<string, unknown>;
157
+ targetPath: string;
158
+ result: MutationResult;
159
+ options: CommitMutationOptions;
160
+ onFileContentChanged?: (path: string, content: string) => void;
161
+ forceReloadSdkSession?: () => void;
162
+ reloadPreview: () => void;
163
+ onCacheInvalidate: () => void;
164
+ }): void {
165
+ if (result.after != null) onFileContentChanged?.(targetPath, result.after);
166
+ // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
167
+ // later SDK-routed edit doesn't serialize the pre-write doc and revert this.
168
+ forceReloadSdkSession?.();
169
+ if (options.skipReload) return;
170
+ if (result.parsed?.animations) {
171
+ updateKeyframeCacheFromParsed(
172
+ result.parsed.animations,
173
+ targetPath,
174
+ selection.id ?? undefined,
175
+ mutation,
176
+ );
177
+ }
178
+ refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
179
+ }
180
+
123
181
  /**
124
182
  * Apply a soft reload and enforce the U4 invariant via the richer
125
183
  * `SoftReloadResult`, with telemetry on every non-success path so the invariant
@@ -208,7 +266,10 @@ export function applyPreviewSync(
208
266
 
209
267
  // oxfmt-ignore
210
268
  // fallow-ignore-next-line complexity
211
- export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
269
+ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
270
+ const activeProjectId = projectIdRef.current;
271
+ const activeCompPathRef = useRef(activeCompPath);
272
+ activeCompPathRef.current = activeCompPath;
212
273
  // Serializer for per-key commits (options.serializeKey). Keyed by
213
274
  // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
214
275
  // the same animationId so their POSTs can't interleave. Held in a ref so the
@@ -225,69 +286,115 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
225
286
  });
226
287
  }, [editHistory]);
227
288
 
228
- const finalizeSuccessfulMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
289
+ const finalizeSuccessfulMutation = useCallback(async (projectId: string, compositionPath: string | null, selection: DomEditSelection, mutation: Record<string, unknown>, targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
290
+ if (projectIdRef.current !== projectId) return;
291
+ const previewIsActive = isActiveCommitTarget(
292
+ projectIdRef,
293
+ activeCompPathRef,
294
+ projectId,
295
+ compositionPath,
296
+ );
229
297
  // A no-op file write may still owe the runtime a deferred instant patch.
230
- if (finishUnchangedMutation(previewIframeRef.current, result, options, reloadPreview)) return;
231
- domEditSaveTimestampRef.current = Date.now();
298
+ if (result.changed === false) {
299
+ if (previewIsActive) {
300
+ finishUnchangedMutation(previewIframeRef.current, result, options, reloadPreview);
301
+ }
302
+ return;
303
+ }
304
+ if (previewIsActive) domEditSaveTimestampRef.current = Date.now();
232
305
  await recordMutationEdit(targetPath, result, options);
233
- if (result.after != null) onFileContentChanged?.(targetPath, result.after);
234
- // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
235
- // later SDK-routed edit doesn't serialize the pre-write doc and revert this.
236
- forceReloadSdkSession?.();
237
- if (options.skipReload) return;
238
- if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
239
- refreshMutationPreview(
240
- previewIframeRef.current,
306
+ // The durable mutation belongs to the project captured when it was queued.
307
+ // A later project must never receive its file state or preview refresh.
308
+ if (!isActiveCommitTarget(projectIdRef, activeCompPathRef, projectId, compositionPath)) return;
309
+ syncCommittedGsapMutation({
310
+ iframe: previewIframeRef.current,
311
+ selection,
312
+ mutation,
313
+ targetPath,
241
314
  result,
242
315
  options,
316
+ onFileContentChanged,
317
+ forceReloadSdkSession,
243
318
  reloadPreview,
244
319
  onCacheInvalidate,
245
- );
246
- }, [previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
320
+ });
321
+ }, [projectIdRef, previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
247
322
 
248
- const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
249
- const pid = projectIdRef.current;
250
- if (!pid) return;
251
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
323
+ const runCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
252
324
  const result = await runMutationRequest([mutation], options, showToast, () =>
253
325
  mutateGsapScript(pid, targetPath, mutation),
254
326
  );
255
327
  if (!result) return;
256
- await finalizeSuccessfulMutation(selection, mutation, targetPath, result, options);
257
- }, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
328
+ await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
329
+ }, [showToast, finalizeSuccessfulMutation]);
258
330
 
259
- const runBatchCommit = useCallback(async (calls: CommitMutationCall[], options: CommitMutationOptions) => {
260
- const pid = projectIdRef.current;
331
+ const runBatchCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, calls: CommitMutationCall[], options: CommitMutationOptions) => {
261
332
  const first = calls[0];
262
333
  const last = calls.at(-1);
263
- if (!pid || !first || !last) return;
264
- const targetPath = first.selection.sourceFile || activeCompPath || "index.html";
334
+ if (!first || !last) return;
265
335
  const mutations = calls.map(({ mutation }) => mutation);
266
336
  const result = await runMutationRequest(mutations, options, showToast, () =>
267
337
  mutateGsapScriptBatch(pid, targetPath, mutations),
268
338
  );
269
339
  if (!result) return;
270
- await finalizeSuccessfulMutation(last.selection, last.mutation, targetPath, result, options);
271
- }, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
340
+ await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
341
+ }, [showToast, finalizeSuccessfulMutation]);
272
342
 
273
343
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
274
344
  // commits to the SAME file (any op type, any animation) interleave server-side,
275
- // so serialize per target file by default; an explicit serializeKey overrides.
345
+ // so every legacy request takes the same project/file lock as SDK writes. An
346
+ // explicit key adds ordering for related calls but never replaces the file lock.
276
347
  const commitMutation = useMemo<CommitMutation>(() => {
348
+ const serializeFile = <T,>(file: string, task: () => Promise<T>): Promise<T> => {
349
+ if (writeProjectFile) {
350
+ return serializeStudioFileMutation(writeProjectFile, file, task);
351
+ }
352
+ return serializerRef.current(`gsap-file:${file}`, task);
353
+ };
354
+ const serializeCommit = <T,>(
355
+ file: string,
356
+ serializeKey: string | undefined,
357
+ task: () => Promise<T>,
358
+ ): Promise<T> => {
359
+ const fileKey = `gsap-file:${file}`;
360
+ const run = () => serializeFile(file, task);
361
+ if (serializeKey && (writeProjectFile || serializeKey !== fileKey)) {
362
+ return serializerRef.current(serializeKey, run);
363
+ }
364
+ return run();
365
+ };
277
366
  const commit: CommitMutation = (selection, mutation, options) => {
367
+ if (!activeProjectId) return Promise.resolve();
278
368
  const file = selection.sourceFile || activeCompPath || "index.html";
279
- const key = options.serializeKey ?? `gsap-file:${file}`;
280
- return serializerRef.current(key, () => runCommit(selection, mutation, options));
369
+ return serializeCommit(file, options.serializeKey, () =>
370
+ runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
371
+ );
281
372
  };
282
373
  commit.batch = (calls, options) => {
374
+ if (!activeProjectId) return Promise.resolve();
283
375
  const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
284
- const key = options.serializeKey ?? `gsap-file:${file}`;
285
- return serializerRef.current(key, () => runBatchCommit(calls, options));
376
+ return serializeCommit(file, options.serializeKey, () =>
377
+ runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
378
+ );
286
379
  };
287
380
  return commit;
288
- }, [runCommit, runBatchCommit, activeCompPath]);
381
+ }, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
289
382
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
290
- const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
383
+ const handleGsapSaveFailure = useCallback(
384
+ (
385
+ error: unknown,
386
+ selection: DomEditSelection,
387
+ mutation: Record<string, unknown>,
388
+ label?: string,
389
+ ) => {
390
+ trackGsapSaveFailure(error, selection, mutation, label);
391
+ if (!isStudioSaveErrorAlreadyToasted(error)) {
392
+ showToast?.(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
393
+ }
394
+ },
395
+ [showToast, trackGsapSaveFailure],
396
+ );
397
+ const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, handleGsapSaveFailure);
291
398
 
292
399
  // One stable SDK-deps object shared by all GSAP child hooks. Memoized so the
293
400
  // hooks' callbacks keep a stable identity (an inline literal here re-fired the
@@ -315,23 +422,15 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
315
422
  },
316
423
  [previewIframeRef, reloadPreview, onCacheInvalidate],
317
424
  );
318
- // Reuse the SAME per-file serializer the legacy commitMutation path uses, so
319
- // SDK gsap-write flushes serialize against legacy commits AND each other —
320
- // overlapping same-file read-modify-writes can't interleave and lose an edit.
321
- const serializeByFile = useCallback(
322
- <T>(key: string, task: () => Promise<T>): Promise<T> => serializerRef.current(key, task),
323
- [],
324
- );
325
425
  // Read the on-disk bytes of targetPath so the SDK GSAP persist captures the
326
426
  // exact prior content as its undo `before` (matching the style/delete paths),
327
427
  // instead of a normalized full-DOM re-emit that would reformat the whole file.
328
428
  const readProjectFileContent = useCallback(
329
429
  (path: string): Promise<string> => {
330
- const pid = projectIdRef.current;
331
- if (!pid) throw new Error("No active project");
332
- return readSharedProjectFileContent(pid, path);
430
+ if (!activeProjectId) throw new Error("No active project");
431
+ return readSharedProjectFileContent(activeProjectId, path);
333
432
  },
334
- [projectIdRef],
433
+ [activeProjectId],
335
434
  );
336
435
  const sdkDeps = useMemo<CutoverDeps | null>(
337
436
  () =>
@@ -343,8 +442,8 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
343
442
  domEditSaveTimestampRef,
344
443
  refresh: sdkRefresh,
345
444
  compositionPath: activeCompPath,
346
- serialize: serializeByFile,
347
445
  readProjectFile: readProjectFileContent,
446
+ publishSession: publishSdkSession,
348
447
  }
349
448
  : null,
350
449
  [
@@ -354,8 +453,8 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
354
453
  domEditSaveTimestampRef,
355
454
  sdkRefresh,
356
455
  activeCompPath,
357
- serializeByFile,
358
456
  readProjectFileContent,
457
+ publishSdkSession,
359
458
  ],
360
459
  );
361
460
 
@@ -363,6 +462,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
363
462
  sdkSession,
364
463
  sdkDeps,
365
464
  activeCompPath,
465
+ onFlushError: handleGsapSaveFailure,
366
466
  });
367
467
  const animationOps = useGsapAnimationOps({
368
468
  projectIdRef,
@@ -377,7 +477,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
377
477
  activeCompPath,
378
478
  commitMutation,
379
479
  commitMutationSafely,
380
- trackGsapSaveFailure,
480
+ trackGsapSaveFailure: handleGsapSaveFailure,
381
481
  sdkSession,
382
482
  sdkDeps,
383
483
  });
@@ -0,0 +1,116 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
+ import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ type Params = Parameters<typeof useGsapSelectionHandlers>[0];
11
+ type Handlers = ReturnType<typeof useGsapSelectionHandlers>;
12
+
13
+ function makeSelection(): DomEditSelection {
14
+ return {
15
+ id: "box",
16
+ hfId: "hf-box",
17
+ selector: "#box",
18
+ sourceFile: "index.html",
19
+ element: document.createElement("div"),
20
+ } as unknown as DomEditSelection;
21
+ }
22
+
23
+ function makeParams(overrides: Partial<Params> = {}): Params {
24
+ const resolved = () => vi.fn().mockResolvedValue(undefined);
25
+ return {
26
+ domEditSelection: makeSelection(),
27
+ updateGsapProperty: vi.fn(),
28
+ updateGsapMeta: resolved(),
29
+ deleteGsapAnimation: resolved(),
30
+ deleteAllForSelector: resolved(),
31
+ addGsapAnimation: resolved(),
32
+ addGsapProperty: resolved(),
33
+ removeGsapProperty: resolved(),
34
+ updateGsapFromProperty: resolved(),
35
+ addGsapFromProperty: resolved(),
36
+ removeGsapFromProperty: resolved(),
37
+ addKeyframe: vi.fn(),
38
+ addKeyframeBatch: resolved(),
39
+ removeKeyframe: vi.fn(),
40
+ moveKeyframe: vi.fn(),
41
+ resizeKeyframedTween: vi.fn(),
42
+ convertToKeyframes: resolved(),
43
+ removeAllKeyframes: resolved(),
44
+ handleDomManualEditsReset: vi.fn(),
45
+ selectedGsapAnimations: [],
46
+ showToast: vi.fn(),
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ function renderHandlers(params: Params): { handlers: () => Handlers; unmount: () => void } {
52
+ let current: Handlers | undefined;
53
+ function Probe() {
54
+ current = useGsapSelectionHandlers(params);
55
+ return null;
56
+ }
57
+ const root = createRoot(document.createElement("div"));
58
+ act(() => root.render(<Probe />));
59
+ return {
60
+ handlers: () => {
61
+ if (!current) throw new Error("Hook did not render");
62
+ return current;
63
+ },
64
+ unmount: () => act(() => root.unmount()),
65
+ };
66
+ }
67
+
68
+ async function flushRejection(): Promise<void> {
69
+ await act(async () => {
70
+ await Promise.resolve();
71
+ });
72
+ }
73
+
74
+ describe("useGsapSelectionHandlers save failures", () => {
75
+ it("surfaces a rejected animation metadata save", async () => {
76
+ const error = new Error("write failed");
77
+ const showToast = vi.fn();
78
+ const rendered = renderHandlers(
79
+ makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
80
+ );
81
+
82
+ act(() => rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 }));
83
+ await flushRejection();
84
+
85
+ expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
86
+ rendered.unmount();
87
+ });
88
+
89
+ it("surfaces a rejected non-debounced property save", async () => {
90
+ const error = new Error("write failed");
91
+ const showToast = vi.fn();
92
+ const rendered = renderHandlers(
93
+ makeParams({ addGsapProperty: vi.fn().mockRejectedValue(error), showToast }),
94
+ );
95
+
96
+ act(() => rendered.handlers().handleGsapAddProperty("anim-1", "opacity"));
97
+ await flushRejection();
98
+
99
+ expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
100
+ rendered.unmount();
101
+ });
102
+
103
+ it("does not duplicate a toast already emitted by the mutation request", async () => {
104
+ const error = Object.assign(new Error("write failed"), { alreadyToasted: true });
105
+ const showToast = vi.fn();
106
+ const rendered = renderHandlers(
107
+ makeParams({ addGsapAnimation: vi.fn().mockRejectedValue(error), showToast }),
108
+ );
109
+
110
+ act(() => rendered.handlers().handleGsapAddAnimation("to"));
111
+ await flushRejection();
112
+
113
+ expect(showToast).not.toHaveBeenCalled();
114
+ rendered.unmount();
115
+ });
116
+ });