@hyperframes/studio 0.7.103 → 0.7.105

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-DUB-xtR5.js → hyperframes-player-Jx07OQgw.js} +1 -1
  2. package/dist/assets/{index-DHc-xHLb.js → index-CryZ0PGi.js} +1 -1
  3. package/dist/assets/{index-Cez_yvk5.js → index-Dqi2GmOp.js} +1 -1
  4. package/dist/assets/{index-CxC2vn2-.js → index-DuxKGsHZ.js} +214 -214
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +779 -294
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +6 -2
  10. package/src/captions/hooks/useCaptionSync.ts +2 -1
  11. package/src/components/editor/DomEditOverlay.test.ts +89 -2
  12. package/src/components/editor/DomEditOverlay.tsx +40 -23
  13. package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
  14. package/src/components/editor/domEditOverlayGeometry.ts +59 -2
  15. package/src/components/editor/domEditOverlayGestures.ts +42 -0
  16. package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
  17. package/src/components/editor/groupDragMove.ts +110 -0
  18. package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
  19. package/src/components/editor/manualEditsDom.ts +15 -13
  20. package/src/components/editor/manualOffsetDrag.test.ts +92 -5
  21. package/src/components/editor/manualOffsetDrag.ts +22 -17
  22. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
  23. package/src/components/editor/snapEngageTravel.test.ts +65 -0
  24. package/src/components/editor/snapEngine.ts +21 -1
  25. package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
  26. package/src/components/editor/useDomEditOverlayRects.ts +4 -2
  27. package/src/hooks/domSelectionTimelineMirror.ts +73 -0
  28. package/src/hooks/gestureTransaction.test.ts +7 -12
  29. package/src/hooks/gestureTransaction.ts +7 -20
  30. package/src/hooks/gsapRuntimePatch.test.ts +43 -0
  31. package/src/hooks/gsapRuntimePatch.ts +11 -1
  32. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  33. package/src/hooks/gsapScriptCommitTypes.ts +20 -0
  34. package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
  35. package/src/hooks/keyframeCacheAstLoad.ts +29 -1
  36. package/src/hooks/timelineTimingSync.ts +4 -1
  37. package/src/hooks/useDomEditCommits.ts +2 -1
  38. package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
  39. package/src/hooks/useDomEditPreviewSync.ts +23 -0
  40. package/src/hooks/useDomEditSession.test.tsx +43 -62
  41. package/src/hooks/useDomEditSession.ts +10 -2
  42. package/src/hooks/useDomEditWiring.ts +6 -0
  43. package/src/hooks/useDomSelection.test.ts +133 -3
  44. package/src/hooks/useDomSelection.ts +67 -40
  45. package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
  46. package/src/hooks/useElementLifecycleOps.ts +2 -1
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
  48. package/src/hooks/useFileManager.ts +2 -8
  49. package/src/hooks/useGroupCommits.ts +2 -1
  50. package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
  52. package/src/hooks/useGsapAwareEditing.ts +88 -15
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
  54. package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
  55. package/src/hooks/useGsapScriptCommits.ts +52 -15
  56. package/src/hooks/useStudioUrlState.ts +130 -7
  57. package/src/hooks/useTimelineEditing.ts +2 -1
  58. package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
  59. package/src/player/hooks/useTimelinePlayer.ts +2 -0
  60. package/src/utils/dragDebug.ts +98 -0
  61. package/src/utils/razorSplitTransaction.test.ts +15 -3
  62. package/src/utils/razorSplitTransaction.ts +2 -0
  63. package/src/utils/reloadDebug.ts +10 -0
  64. package/src/utils/resizeDebug.ts +19 -29
  65. package/src/utils/selectDebug.ts +9 -0
  66. package/src/utils/studioDebug.test.ts +32 -0
  67. package/src/utils/studioDebug.ts +30 -0
  68. package/src/utils/studioFileVersion.ts +16 -1
  69. package/src/utils/studioUrlState.test.ts +163 -1
  70. package/src/utils/studioUrlState.ts +60 -3
  71. package/src/utils/timelineCompositionInsert.ts +2 -1
@@ -4,11 +4,7 @@ import {
4
4
  getAllPreviewTargetsFromPointer,
5
5
  getPreviewTargetFromPointer,
6
6
  } from "../utils/studioPreviewHelpers";
7
- import {
8
- findMatchingTimelineElementId,
9
- findTimelineIdByAncestor,
10
- type RightPanelTab,
11
- } from "../utils/studioHelpers";
7
+ import { type RightPanelTab } from "../utils/studioHelpers";
12
8
  import {
13
9
  domEditSelectionsTargetSame,
14
10
  domEditSelectionInGroup,
@@ -24,6 +20,8 @@ import {
24
20
  } from "../components/editor/domEditing";
25
21
  import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
26
22
  import { useStudioTestHooks } from "./useStudioTestHooks";
23
+ import { logSelect } from "../utils/selectDebug";
24
+ import { announceTimelineSelection as announceSelectionToTimeline } from "./domSelectionTimelineMirror";
27
25
 
28
26
  // ── Types ──
29
27
 
@@ -47,7 +45,10 @@ export interface UseDomSelectionParams {
47
45
  captionEditMode: boolean;
48
46
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
49
47
  timelineElements: TimelineElement[];
48
+ getTimelineSelectionSet: () => ReadonlySet<string>;
50
49
  setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
50
+ /** Publishes a whole multi-selection to the timeline; the anchor is set separately. */
51
+ setTimelineSelectionSet: (ids: Set<string>) => void;
51
52
  setRightCollapsed: (collapsed: boolean) => void;
52
53
  setRightPanelTab: (tab: RightPanelTab) => void;
53
54
  previewIframe: HTMLIFrameElement | null;
@@ -109,7 +110,9 @@ export function useDomSelection({
109
110
  captionEditMode,
110
111
  previewIframeRef,
111
112
  timelineElements,
113
+ getTimelineSelectionSet,
112
114
  setSelectedTimelineElementId,
115
+ setTimelineSelectionSet,
113
116
  setRightCollapsed,
114
117
  setRightPanelTab,
115
118
  previewIframe,
@@ -145,6 +148,26 @@ export function useDomSelection({
145
148
 
146
149
  // ── Callbacks ──
147
150
 
151
+ const announceTimelineSelection = useCallback(
152
+ (group: DomEditSelection[], primary: DomEditSelection | null) =>
153
+ announceSelectionToTimeline(
154
+ {
155
+ timelineElements,
156
+ getTimelineSelectionSet,
157
+ setSelectedTimelineElementId,
158
+ setTimelineSelectionSet,
159
+ },
160
+ group,
161
+ primary,
162
+ ),
163
+ [
164
+ getTimelineSelectionSet,
165
+ setSelectedTimelineElementId,
166
+ setTimelineSelectionSet,
167
+ timelineElements,
168
+ ],
169
+ );
170
+
148
171
  const applyDomSelection = useCallback(
149
172
  // fallow-ignore-next-line complexity
150
173
  (
@@ -156,11 +179,12 @@ export function useDomSelection({
156
179
  },
157
180
  ) => {
158
181
  if (!selection) {
182
+ logSelect("clear", { hadGroup: domEditGroupSelectionsRef.current.length });
159
183
  domEditSelectionRef.current = null;
160
184
  domEditGroupSelectionsRef.current = [];
161
185
  setDomEditSelection(null);
162
186
  setDomEditGroupSelections([]);
163
- setSelectedTimelineElementId(null);
187
+ announceTimelineSelection([], null);
164
188
  return;
165
189
  }
166
190
 
@@ -186,6 +210,13 @@ export function useDomSelection({
186
210
  : (nextGroup[0] ?? null)
187
211
  : selection;
188
212
 
213
+ logSelect("apply", {
214
+ additive: isAdditiveSelection,
215
+ target: selection.selector ?? selection.id ?? null,
216
+ wasInGroup,
217
+ prevGroup: previousGroup.length,
218
+ nextGroup: nextGroup.length,
219
+ });
189
220
  domEditSelectionRef.current = nextSelection;
190
221
  domEditGroupSelectionsRef.current = nextGroup;
191
222
  setDomEditSelection(nextSelection);
@@ -208,21 +239,13 @@ export function useDomSelection({
208
239
  setRightPanelTab("design");
209
240
  }
210
241
  }
211
- const nextSelectedTimelineId =
212
- findMatchingTimelineElementId(nextSelection, timelineElements) ??
213
- findTimelineIdByAncestor(
214
- nextSelection.element,
215
- timelineElements,
216
- nextSelection.sourceFile || "index.html",
217
- );
218
- // Late marquee notify: a primary already in the live set must not collapse it.
219
- setSelectedTimelineElementId(nextSelectedTimelineId, { preserveSet: true });
242
+ announceTimelineSelection(nextGroup, nextSelection);
220
243
  return;
221
244
  }
222
245
 
223
- setSelectedTimelineElementId(null);
246
+ announceTimelineSelection([], null);
224
247
  },
225
- [setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
248
+ [announceTimelineSelection, setRightCollapsed, setRightPanelTab],
226
249
  );
227
250
 
228
251
  const clearDomSelection = useCallback(() => {
@@ -375,6 +398,13 @@ export function useDomSelection({
375
398
  [applyDomSelection, buildDomSelectionForTimelineElement],
376
399
  );
377
400
 
401
+ // Forward handle to the group refresher defined below: the single-selection
402
+ // refresher falls back to it when the primary is gone, and a ref keeps that from
403
+ // forcing either callback to be declared in the other's dependency list.
404
+ const refreshDomEditGroupSelectionsFromPreviewRef = useRef<
405
+ (selections: DomEditSelection[]) => Promise<void>
406
+ >(async () => {});
407
+
378
408
  const refreshDomEditSelectionFromPreview = useCallback(
379
409
  // fallow-ignore-next-line complexity
380
410
  async (selection: DomEditSelection) => {
@@ -389,6 +419,17 @@ export function useDomSelection({
389
419
 
390
420
  const element = findElementForSelection(doc, selection, activeCompPath);
391
421
  if (!element) {
422
+ // Losing the primary is not losing the selection. When a group is live,
423
+ // re-resolve it and keep whoever still exists rather than wiping the lot.
424
+ const group = domEditGroupSelectionsRef.current;
425
+ logSelect("refresh-lost", {
426
+ target: selection.selector ?? selection.id ?? null,
427
+ group: group.length,
428
+ });
429
+ if (group.length > 1) {
430
+ await refreshDomEditGroupSelectionsFromPreviewRef.current(group);
431
+ return;
432
+ }
392
433
  applyDomSelection(null, { revealPanel: false });
393
434
  return;
394
435
  }
@@ -436,25 +477,17 @@ export function useDomSelection({
436
477
  setDomEditSelection(nextSelection);
437
478
  setDomEditGroupSelections(nextGroup);
438
479
 
439
- if (nextSelection) {
440
- setSelectedTimelineElementId(
441
- findMatchingTimelineElementId(nextSelection, timelineElements),
442
- );
443
- } else {
444
- setSelectedTimelineElementId(null);
445
- }
480
+ announceTimelineSelection(nextGroup, nextSelection);
446
481
  },
447
- [
448
- activeCompPath,
449
- buildDomSelectionFromTarget,
450
- setSelectedTimelineElementId,
451
- timelineElements,
452
- previewIframeRef,
453
- ],
482
+ [activeCompPath, announceTimelineSelection, buildDomSelectionFromTarget, previewIframeRef],
454
483
  );
455
484
 
456
485
  // ── Effects ──
457
486
 
487
+ useEffect(() => {
488
+ refreshDomEditGroupSelectionsFromPreviewRef.current = refreshDomEditGroupSelectionsFromPreview;
489
+ }, [refreshDomEditGroupSelectionsFromPreview]);
490
+
458
491
  // Clear hover unconditionally on composition/project/preview change
459
492
  // eslint-disable-next-line no-restricted-syntax
460
493
  useEffect(() => {
@@ -503,6 +536,7 @@ export function useDomSelection({
503
536
  const applyMarqueeSelection = useCallback(
504
537
  // fallow-ignore-next-line complexity
505
538
  (selections: DomEditSelection[], additive: boolean) => {
539
+ logSelect("marquee", { hits: selections.length, additive });
506
540
  if (selections.length === 0) {
507
541
  if (!additive) applyDomSelection(null, { revealPanel: false });
508
542
  return;
@@ -527,16 +561,9 @@ export function useDomSelection({
527
561
  domEditGroupSelectionsRef.current = nextGroup;
528
562
  setDomEditSelection(nextSelection);
529
563
  setDomEditGroupSelections(nextGroup);
530
- const nextTimelineId =
531
- findMatchingTimelineElementId(nextSelection, timelineElements) ??
532
- findTimelineIdByAncestor(
533
- nextSelection.element,
534
- timelineElements,
535
- nextSelection.sourceFile || "index.html",
536
- );
537
- setSelectedTimelineElementId(nextTimelineId);
564
+ announceTimelineSelection(nextGroup, nextSelection);
538
565
  },
539
- [applyDomSelection, timelineElements, setSelectedTimelineElementId],
566
+ [applyDomSelection, announceTimelineSelection],
540
567
  );
541
568
 
542
569
  return {
@@ -49,6 +49,7 @@ interface HarnessProps {
49
49
  iframe: HTMLIFrameElement | null;
50
50
  timelineElements: TimelineElement[];
51
51
  setSelectedTimelineElementId?: (id: string | null, options?: SelectElementOptions) => void;
52
+ setTimelineSelectionSet?: (ids: Set<string>) => void;
52
53
  }
53
54
 
54
55
  function renderHarness(props: HarnessProps) {
@@ -66,7 +67,10 @@ function renderHarness(props: HarnessProps) {
66
67
  captionEditMode: false,
67
68
  previewIframeRef: { current: props.iframe },
68
69
  timelineElements: props.timelineElements,
70
+ getTimelineSelectionSet: () => usePlayerStore.getState().selectedElementIds,
69
71
  setSelectedTimelineElementId: props.setSelectedTimelineElementId ?? vi.fn(),
72
+ setTimelineSelectionSet:
73
+ props.setTimelineSelectionSet ?? usePlayerStore.getState().setSelectedElementIds,
70
74
  setRightCollapsed: vi.fn(),
71
75
  setRightPanelTab: props.setRightPanelTab,
72
76
  previewIframe: props.iframe,
@@ -236,7 +240,7 @@ describe("useDomSelection — marquee multi-select survives the late async prima
236
240
  iframe.remove();
237
241
  });
238
242
 
239
- it("collapses the set when a late primary-set targets a non-member (fresh click)", async () => {
243
+ it("collapses the set to a fresh single-click target instead of publishing an empty set", async () => {
240
244
  const iframe = document.createElement("iframe");
241
245
  document.body.append(iframe);
242
246
  const doc = iframe.contentDocument!;
@@ -268,7 +272,7 @@ describe("useDomSelection — marquee multi-select survives the late async prima
268
272
  await pending;
269
273
  });
270
274
 
271
- expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
275
+ expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["d"]);
272
276
  expect(usePlayerStore.getState().selectedElementId).toBe("d");
273
277
  harness.cleanup();
274
278
  iframe.remove();
@@ -21,6 +21,7 @@ import {
21
21
  } from "../components/editor/useLayerRevealOverride";
22
22
  import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
23
23
  import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
24
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
24
25
 
25
26
  interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
26
27
  /** Route delete through SDK when session resolves the hf-id. */
@@ -115,7 +116,7 @@ export function useElementLifecycleOps({
115
116
  `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
116
117
  {
117
118
  method: "POST",
118
- headers: { "Content-Type": "application/json" },
119
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
119
120
  body: JSON.stringify({ target: patchTarget }),
120
121
  },
121
122
  );
@@ -4,6 +4,7 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
4
4
  import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
5
5
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
6
6
  import { consumeStudioWriteToken } from "../utils/studioFileVersion";
7
+ import { logReload } from "../utils/reloadDebug";
7
8
 
8
9
  type ExternalChangeDrainResult =
9
10
  | { status: "clean" }
@@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({
194
195
 
195
196
  const reloadAcceptedGeneration = useCallback(
196
197
  (path: string) => {
198
+ logReload("reload", { path, by: "external-change coordinator" });
197
199
  reloadPreview();
198
200
  reloadSdkSession(path);
199
201
  },
@@ -221,11 +223,22 @@ export function useExternalFileChangeCoordinator({
221
223
  pendingTimelinePaths.delete(path);
222
224
 
223
225
  const content = readFileChangeContent(payload);
224
- if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return;
225
- if (content != null && isSelfWriteEcho(path, content)) return;
226
+ const token = readFileChangeWriteToken(payload);
227
+ logReload("file-change", { path, token: token ?? null, hasContent: content != null });
228
+ if (consumeStudioWriteToken(token)) {
229
+ logReload("suppressed", { path, why: "own write token" });
230
+ return;
231
+ }
232
+ if (content != null && isSelfWriteEcho(path, content)) {
233
+ logReload("suppressed", { path, why: "own content echo" });
234
+ return;
235
+ }
226
236
 
227
237
  const identity = eventIdentity(path, payload);
228
- if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return;
238
+ if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
239
+ logReload("suppressed", { path, why: "duplicate event" });
240
+ return;
241
+ }
229
242
  lastEventIdentityRef.current = identity;
230
243
  const generation = ++generationRef.current;
231
244
  const result = await drainPendingChanges();
@@ -10,11 +10,7 @@ import {
10
10
  StudioFileConflictError,
11
11
  StudioSaveNetworkError,
12
12
  } from "../utils/studioSaveDiagnostics";
13
- import {
14
- createStudioWriteToken,
15
- markStudioWriteToken,
16
- studioExpectedFileVersion,
17
- } from "../utils/studioFileVersion";
13
+ import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
18
14
  import { useFileTree } from "./useFileTree";
19
15
  import { useEditorSave } from "./useEditorSave";
20
16
 
@@ -124,8 +120,6 @@ export function useFileManager({
124
120
  await retryStudioSave(async () => {
125
121
  // Each request gets its own receipt identity. If a committed request loses its response,
126
122
  // the retry can produce a second filesystem receipt that must be suppressed independently.
127
- const writeToken = createStudioWriteToken();
128
- markStudioWriteToken(writeToken);
129
123
  let response: Response;
130
124
  try {
131
125
  response = await fetch(
@@ -134,7 +128,7 @@ export function useFileManager({
134
128
  method: "PUT",
135
129
  headers: {
136
130
  "Content-Type": "text/plain",
137
- "X-Hyperframes-Write-Token": writeToken,
131
+ ...studioWriteHeaders(),
138
132
  ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
139
133
  },
140
134
  body: content,
@@ -5,6 +5,7 @@ import {
5
5
  type DomEditCommitBaseParams,
6
6
  } from "../utils/studioFileHistory";
7
7
  import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
8
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
8
9
 
9
10
  interface UseGroupCommitsParams extends DomEditCommitBaseParams {
10
11
  /** Resync the SDK session after a server-side write (the wrapper/unwrap changes
@@ -75,7 +76,7 @@ async function commitStructuralMutation(
75
76
  `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
76
77
  {
77
78
  method: "POST",
78
- headers: { "Content-Type": "application/json" },
79
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
79
80
  body: JSON.stringify(body),
80
81
  },
81
82
  );
@@ -33,6 +33,8 @@ export type ElementAnimationsOutcome =
33
33
  export interface GsapAnimationFetchOptions {
34
34
  /** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
35
35
  failOnFetchError?: boolean;
36
+ /** Ignore an overlapping pre-write parse and read the source after a durable write. */
37
+ fresh?: boolean;
36
38
  }
37
39
 
38
40
  /**
@@ -56,11 +58,13 @@ async function fetchElementAnimationsWithRetry(
56
58
  gsapSourceFile: string,
57
59
  target: { id: string | null; selector: string | null },
58
60
  failOnFetchError: boolean,
61
+ fresh: boolean,
59
62
  ): Promise<GsapAnimation[]> {
60
63
  let coldAttempts = 0;
61
64
  let errorAttempts = 0;
62
65
  for (;;) {
63
- const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
66
+ const parsed = await fetchParsedAnimations(projectId, gsapSourceFile, { fresh });
67
+ fresh = false;
64
68
  const outcome = selectElementAnimationsOrRetry(parsed, target);
65
69
  if (outcome.kind === "resolved") return outcome.animations;
66
70
  if (outcome.kind === "fetch-error") {
@@ -89,6 +93,7 @@ export function useGsapAnimationFetchFallback(projectId: string | null, gsapSour
89
93
  gsapSourceFile,
90
94
  target,
91
95
  options?.failOnFetchError === true,
96
+ options?.fresh === true,
92
97
  );
93
98
  },
94
99
  [projectId, gsapSourceFile],
@@ -301,6 +301,42 @@ describe("useGsapAwareEditing anchored resize", () => {
301
301
  act(() => root.unmount());
302
302
  });
303
303
 
304
+ it("reports only the first group preflight failure in input order", async () => {
305
+ const failures = [new Error("first blocked"), new Error("second blocked")];
306
+ const trackGsapInteractionFailure = vi.fn();
307
+ const priorDragImplementation = mocks.drag.getMockImplementation();
308
+ mocks.drag.mockImplementation(async (selection) => {
309
+ throw selection.id === "a" ? failures[0] : failures[1];
310
+ });
311
+ const { groupCommit, root } = mountGroupHandler({
312
+ gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
313
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
314
+ trackGsapInteractionFailure,
315
+ });
316
+ const updates = [
317
+ {
318
+ selection: { element: document.createElement("div"), id: "a", selector: "#a" },
319
+ next: { x: 10, y: 10 },
320
+ },
321
+ {
322
+ selection: { element: document.createElement("div"), id: "b", selector: "#b" },
323
+ next: { x: 20, y: 20 },
324
+ },
325
+ ] as unknown as DomEditGroupPathOffsetCommit[];
326
+
327
+ await expect(groupCommit(updates)).rejects.toBe(failures[0]);
328
+ expect(trackGsapInteractionFailure).toHaveBeenCalledOnce();
329
+ expect(trackGsapInteractionFailure).toHaveBeenCalledWith(
330
+ failures[0],
331
+ updates[0]?.selection,
332
+ "drag",
333
+ "Move animated layer (group)",
334
+ );
335
+ mocks.drag.mockReset();
336
+ if (priorDragImplementation) mocks.drag.mockImplementation(priorDragImplementation);
337
+ act(() => root.unmount());
338
+ });
339
+
304
340
  it("restores once when resize persistence fails", async () => {
305
341
  const error = new Error("resize failed");
306
342
  const restore = vi.fn();
@@ -24,7 +24,11 @@ import {
24
24
  useGsapSaveFailureTelemetry,
25
25
  useSafeGsapCommitMutation,
26
26
  } from "./useSafeGsapCommitMutation";
27
- import type { CommitMutation } from "./gsapScriptCommitTypes";
27
+ import type {
28
+ CommitMutation,
29
+ CommitMutationCall,
30
+ CommitMutationOptions,
31
+ } from "./gsapScriptCommitTypes";
28
32
  import { setElementGsapPosition } from "../utils/elementGsap";
29
33
  import { logResize, logResizeSettle } from "../utils/resizeDebug";
30
34
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
@@ -37,6 +41,18 @@ import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback"
37
41
  // into one another's undo entry (module-local counter, not Date.now()).
38
42
  let groupDragCommitCounter = 0;
39
43
 
44
+ function firstPreflightFailure(
45
+ results: PromiseSettledResult<void>[],
46
+ updates: DomEditGroupPathOffsetCommit[],
47
+ ): { error: unknown; selection: DomEditSelection } | null {
48
+ for (const [index, result] of results.entries()) {
49
+ if (result.status !== "rejected") continue;
50
+ const selection = updates[index]?.selection;
51
+ if (selection) return { error: result.reason, selection };
52
+ }
53
+ return null;
54
+ }
55
+
40
56
  export interface UseGsapAwareEditingParams {
41
57
  domEditSelection: DomEditSelection | null;
42
58
  selectedGsapAnimations: GsapAnimation[];
@@ -50,7 +66,7 @@ export interface UseGsapAwareEditingParams {
50
66
  ) => () => Promise<GsapAnimation[]>;
51
67
  trackGsapInteractionFailure: (
52
68
  error: unknown,
53
- selection: DomEditSelection,
69
+ selection: DomEditSelection | null,
54
70
  mutationType: string,
55
71
  label: string,
56
72
  ) => void;
@@ -155,19 +171,54 @@ export function useGsapAwareEditing({
155
171
  // it survives the N sequential server round-trips) onto each commit —
156
172
  // otherwise each member records its own entry and it takes N presses to undo.
157
173
  const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
158
- const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) =>
159
- gsapCommitMutation(selection, mutation, {
160
- ...options,
161
- coalesceKey,
162
- coalesceMs: Number.POSITIVE_INFINITY,
174
+ // Members are written one at a time, and a write that re-renders the preview
175
+ // re-runs the whole script — which still holds the OLD position of every
176
+ // member not yet written. Those members snap back to where they started and
177
+ // stay there until their own write lands, which is the single element seen
178
+ // jumping mid-commit while the rest of the group sat still. The drafted
179
+ // positions are already on screen, so holding the render until the last
180
+ // member has been written costs nothing and never shows a half-moved group.
181
+ let renderOnCommit = false;
182
+ const previewFallbackLatch = { pending: false };
183
+ const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
184
+ ...options,
185
+ coalesceKey,
186
+ coalesceMs: Number.POSITIVE_INFINITY,
187
+ deferPreviewSync: !renderOnCommit,
188
+ previewFallbackLatch,
189
+ });
190
+ // Every member writes the same file. Queue their mutations and send them as
191
+ // ONE request instead of one round trip per member: the server reads, parses
192
+ // and writes the composition once, and the preview patches once.
193
+ const queued: CommitMutationCall[] = [];
194
+ const flushQueued = async () => {
195
+ if (queued.length === 0) return;
196
+ const calls = queued.splice(0, queued.length);
197
+ if (!gsapCommitMutation.batch) {
198
+ for (const call of calls) {
199
+ await gsapCommitMutation(call.selection, call.mutation, call.options);
200
+ }
201
+ return;
202
+ }
203
+ await gsapCommitMutation.batch(calls, {
204
+ ...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
205
+ label: "Move animated layer (group)",
163
206
  });
207
+ };
208
+ const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
209
+ queued.push({ selection, mutation, options: withGroupOptions(options) });
210
+ return Promise.resolve();
211
+ };
164
212
  const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
165
213
  // Editability is user-atomic: prove every member can be written before
166
214
  // the first source mutation. Network failures after this point retain the
167
215
  // existing multi-request semantics, but a blocked member can never leave
168
216
  // earlier siblings partially moved.
169
- for (const { selection } of updates) {
170
- try {
217
+ // Every member reads the same file, and a preflight writes nothing — so run
218
+ // them together. The parse layer shares one in-flight request per file, which
219
+ // turns N sequential round trips into one.
220
+ const preflightResults = await Promise.allSettled(
221
+ updates.map(async ({ selection }) => {
171
222
  const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
172
223
  preflightAnimations.set(selection, animations);
173
224
  const outcome = await tryGsapDragIntercept(
@@ -180,12 +231,20 @@ export function useGsapAwareEditing({
180
231
  { preflightOnly: true },
181
232
  );
182
233
  assertGsapEditPersisted(outcome);
183
- } catch (error) {
184
- trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
185
- throw error;
186
- }
234
+ }),
235
+ );
236
+ const preflightFailure = firstPreflightFailure(preflightResults, updates);
237
+ if (preflightFailure) {
238
+ trackGsapInteractionFailure(
239
+ preflightFailure.error,
240
+ preflightFailure.selection,
241
+ "drag",
242
+ "Move animated layer (group)",
243
+ );
244
+ throw preflightFailure.error;
187
245
  }
188
- for (const { selection, next } of updates) {
246
+ for (const [index, { selection, next }] of updates.entries()) {
247
+ renderOnCommit = index === updates.length - 1;
189
248
  try {
190
249
  const outcome = await tryGsapDragIntercept(
191
250
  selection,
@@ -193,7 +252,13 @@ export function useGsapAwareEditing({
193
252
  preflightAnimations.get(selection) ?? [],
194
253
  previewIframeRef.current,
195
254
  coalescedCommit,
196
- makeFetchFallback(selection),
255
+ // The intercept re-reads the file to resolve a stale or shared tween.
256
+ // Anything already queued has to be on disk before that read, or it
257
+ // resolves against a file missing writes it is about to build on.
258
+ async () => {
259
+ await flushQueued();
260
+ return makeFetchFallback(selection, { fresh: true })();
261
+ },
197
262
  { preflightPassed: true },
198
263
  );
199
264
  assertGsapEditPersisted(outcome);
@@ -202,6 +267,14 @@ export function useGsapAwareEditing({
202
267
  throw error;
203
268
  }
204
269
  }
270
+ try {
271
+ await flushQueued();
272
+ } catch (error) {
273
+ // The aggregate write has no uniquely failing member; do not misattribute
274
+ // its telemetry to whichever member happened to be last in the array.
275
+ trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
276
+ throw error;
277
+ }
205
278
  },
206
279
  [gsapCommitMutation, previewIframeRef, makeFetchFallback, trackGsapInteractionFailure],
207
280
  );
@@ -8,16 +8,16 @@ export function useGsapInteractionFailureTelemetry(
8
8
  showToast: (message: string, tone?: "error" | "info") => void,
9
9
  ) {
10
10
  return useCallback(
11
- (error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
11
+ (error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
12
12
  trackStudioSaveFailure({
13
13
  source: "gsap_commit",
14
14
  error,
15
- filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
15
+ filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",
16
16
  mutationType,
17
17
  label,
18
- targetId: selection.id,
19
- targetSelector: selection.selector,
20
- targetSourceFile: selection.sourceFile,
18
+ targetId: selection?.id,
19
+ targetSelector: selection?.selector,
20
+ targetSourceFile: selection?.sourceFile,
21
21
  });
22
22
  showToast(
23
23
  isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",