@hyperframes/studio 0.7.92 → 0.7.93

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 (108) hide show
  1. package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +20 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7055 -5321
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  16. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  19. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  20. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  21. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  22. package/src/components/editor/propertyPanelTypes.ts +9 -3
  23. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  24. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  25. package/src/components/sidebar/AssetCard.tsx +4 -3
  26. package/src/components/sidebar/AudioRow.tsx +4 -3
  27. package/src/contexts/FileManagerContext.tsx +12 -0
  28. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  29. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  30. package/src/hooks/externalFileReloadBus.ts +18 -0
  31. package/src/hooks/gsapDragCommit.test.ts +39 -1
  32. package/src/hooks/gsapDragCommit.ts +12 -40
  33. package/src/hooks/gsapEditOutcome.ts +72 -0
  34. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  35. package/src/hooks/gsapResizeIntercept.ts +44 -16
  36. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  37. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  38. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  39. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  40. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  42. package/src/hooks/useDomGeometryCommits.ts +20 -0
  43. package/src/hooks/useEditorSave.test.tsx +131 -0
  44. package/src/hooks/useEditorSave.ts +113 -39
  45. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  47. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  48. package/src/hooks/useFileManager.ts +32 -3
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  50. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  51. package/src/hooks/useGsapAwareEditing.ts +88 -27
  52. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  54. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  55. package/src/hooks/usePreviewPersistence.ts +22 -58
  56. package/src/hooks/useSdkSession.ts +8 -52
  57. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  58. package/src/hooks/useStudioSdkSessions.ts +2 -7
  59. package/src/hooks/useStudioSessionStart.ts +15 -0
  60. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  61. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  62. package/src/player/components/Timeline.test.ts +24 -5
  63. package/src/player/components/Timeline.tsx +40 -40
  64. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  65. package/src/player/components/TimelineClip.test.tsx +17 -2
  66. package/src/player/components/TimelineClip.tsx +15 -3
  67. package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
  68. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  69. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  70. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  71. package/src/player/components/TimelineLanes.test.tsx +64 -6
  72. package/src/player/components/TimelineLanes.tsx +81 -64
  73. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  74. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  75. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  77. package/src/player/components/TimelineTrackRow.tsx +58 -7
  78. package/src/player/components/timelineDiamondTypes.ts +14 -7
  79. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  80. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  81. package/src/player/components/timelineLaneProps.ts +3 -0
  82. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  83. package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
  84. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  85. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  86. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  87. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  88. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  89. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  90. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  91. package/src/player/components/useTimelineRowVirtualization.ts +16 -26
  92. package/src/player/store/liveTime.ts +11 -0
  93. package/src/player/store/playerStore.test.ts +29 -22
  94. package/src/player/store/playerStore.ts +24 -29
  95. package/src/player/store/timelineFocusState.ts +15 -0
  96. package/src/utils/domEditSaveQueue.test.ts +37 -1
  97. package/src/utils/domEditSaveQueue.ts +22 -4
  98. package/src/utils/externalConflictStorage.test.ts +95 -0
  99. package/src/utils/externalConflictStorage.ts +172 -0
  100. package/src/utils/studioFileVersion.test.ts +28 -1
  101. package/src/utils/studioFileVersion.ts +30 -0
  102. package/src/utils/studioPendingEdits.test.ts +108 -1
  103. package/src/utils/studioPendingEdits.ts +39 -2
  104. package/src/utils/studioSaveDiagnostics.ts +5 -0
  105. package/dist/assets/index-BXkzp_OU.css +0 -1
  106. package/dist/assets/index-DIntkrQl.js +0 -428
  107. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  108. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -30,6 +30,8 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
30
30
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
31
31
  import { runGestureTransaction } from "./gestureTransaction";
32
32
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
33
+ import { assertGsapEditPersisted } from "./gsapEditOutcome";
34
+ import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
33
35
 
34
36
  // Distinct coalesceKey per group drag so consecutive group drags don't fold
35
37
  // into one another's undo entry (module-local counter, not Date.now()).
@@ -42,7 +44,10 @@ export interface UseGsapAwareEditingParams {
42
44
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
43
45
  showToast: (message: string, tone?: "error" | "info") => void;
44
46
  bumpGsapCache: () => void;
45
- makeFetchFallback: (selection: DomEditSelection) => () => Promise<GsapAnimation[]>;
47
+ makeFetchFallback: (
48
+ selection: DomEditSelection,
49
+ options?: GsapAnimationFetchOptions,
50
+ ) => () => Promise<GsapAnimation[]>;
46
51
  trackGsapInteractionFailure: (
47
52
  error: unknown,
48
53
  selection: DomEditSelection,
@@ -112,7 +117,7 @@ export function useGsapAwareEditing({
112
117
  ) => {
113
118
  if (gsapCommitMutation) {
114
119
  try {
115
- await tryGsapDragIntercept(
120
+ const outcome = await tryGsapDragIntercept(
116
121
  selection,
117
122
  next,
118
123
  selectedGsapAnimations,
@@ -121,6 +126,7 @@ export function useGsapAwareEditing({
121
126
  makeFetchFallback(selection),
122
127
  modifiers,
123
128
  );
129
+ assertGsapEditPersisted(outcome);
124
130
  } catch (error) {
125
131
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
126
132
  throw error;
@@ -155,16 +161,42 @@ export function useGsapAwareEditing({
155
161
  coalesceKey,
156
162
  coalesceMs: Number.POSITIVE_INFINITY,
157
163
  });
164
+ const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
165
+ // Editability is user-atomic: prove every member can be written before
166
+ // the first source mutation. Network failures after this point retain the
167
+ // existing multi-request semantics, but a blocked member can never leave
168
+ // earlier siblings partially moved.
169
+ for (const { selection } of updates) {
170
+ try {
171
+ const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
172
+ preflightAnimations.set(selection, animations);
173
+ const outcome = await tryGsapDragIntercept(
174
+ selection,
175
+ { x: 0, y: 0 },
176
+ animations,
177
+ previewIframeRef.current,
178
+ coalescedCommit,
179
+ undefined,
180
+ { preflightOnly: true },
181
+ );
182
+ assertGsapEditPersisted(outcome);
183
+ } catch (error) {
184
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
185
+ throw error;
186
+ }
187
+ }
158
188
  for (const { selection, next } of updates) {
159
189
  try {
160
- await tryGsapDragIntercept(
190
+ const outcome = await tryGsapDragIntercept(
161
191
  selection,
162
192
  next,
163
- [],
193
+ preflightAnimations.get(selection) ?? [],
164
194
  previewIframeRef.current,
165
195
  coalescedCommit,
166
196
  makeFetchFallback(selection),
197
+ { preflightPassed: true },
167
198
  );
199
+ assertGsapEditPersisted(outcome);
168
200
  } catch (error) {
169
201
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
170
202
  throw error;
@@ -217,7 +249,7 @@ export function useGsapAwareEditing({
217
249
  if (gsapCommitMutation) {
218
250
  const commitMutation = commit(gsapCommitMutation);
219
251
  try {
220
- const handled = await tryGsapResizeIntercept(
252
+ const outcome = await tryGsapResizeIntercept(
221
253
  selection,
222
254
  next,
223
255
  selectedGsapAnimations,
@@ -225,26 +257,26 @@ export function useGsapAwareEditing({
225
257
  commitMutation,
226
258
  makeFetchFallback(selection),
227
259
  );
228
- if (handled) {
229
- logResize("intercept-handled", {
230
- scaleRoute,
231
- willForwardOffset: !!(offset && !scaleRoute),
232
- });
233
- // Scale-route resize persists its residual position internally.
234
- // Width/height persists the already-settled anchor through drag.
235
- if (offset && !scaleRoute) {
236
- await tryGsapDragIntercept(
237
- selection,
238
- offset,
239
- selectedGsapAnimations,
240
- previewIframeRef.current,
241
- commitMutation,
242
- makeFetchFallback(selection),
243
- );
244
- }
245
- logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
246
- return;
260
+ assertGsapEditPersisted(outcome);
261
+ logResize("intercept-handled", {
262
+ scaleRoute,
263
+ willForwardOffset: !!(offset && !scaleRoute),
264
+ });
265
+ // Scale-route resize persists its residual position internally.
266
+ // Width/height persists the already-settled anchor through drag.
267
+ if (offset && !scaleRoute) {
268
+ const dragOutcome = await tryGsapDragIntercept(
269
+ selection,
270
+ offset,
271
+ selectedGsapAnimations,
272
+ previewIframeRef.current,
273
+ commitMutation,
274
+ makeFetchFallback(selection),
275
+ );
276
+ assertGsapEditPersisted(dragOutcome);
247
277
  }
278
+ logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
279
+ return;
248
280
  } catch (error) {
249
281
  trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
250
282
  throw error;
@@ -278,8 +310,9 @@ export function useGsapAwareEditing({
278
310
  try {
279
311
  // Single source of truth for rotation too: tryGsapRotationIntercept handles
280
312
  // tweened elements (keyframes) and static ones (a tl.set), so there's no
281
- // CSS-var fallback. It returns false only for a selectorless element (no-op).
282
- await tryGsapRotationIntercept(
313
+ // CSS-var fallback. Selectorless/computed source rejects so the gesture
314
+ // transaction can restore its draft instead of reporting a false success.
315
+ const outcome = await tryGsapRotationIntercept(
283
316
  selection,
284
317
  next.angle,
285
318
  selectedGsapAnimations,
@@ -287,6 +320,7 @@ export function useGsapAwareEditing({
287
320
  gsapCommitMutation,
288
321
  makeFetchFallback(selection),
289
322
  );
323
+ assertGsapEditPersisted(outcome);
290
324
  } catch (error) {
291
325
  trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
292
326
  throw error;
@@ -304,7 +338,10 @@ export function useGsapAwareEditing({
304
338
 
305
339
  // ── Animated property commit ──
306
340
 
307
- const { commitAnimatedProperty, commitAnimatedProperties } = useAnimatedPropertyCommit({
341
+ const {
342
+ commitAnimatedProperty: commitAnimatedPropertyRaw,
343
+ commitAnimatedProperties: commitAnimatedPropertiesRaw,
344
+ } = useAnimatedPropertyCommit({
308
345
  selectedGsapAnimations,
309
346
  gsapCommitMutation,
310
347
  addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
@@ -313,6 +350,30 @@ export function useGsapAwareEditing({
313
350
  bumpGsapCache,
314
351
  });
315
352
 
353
+ const commitAnimatedProperties = useCallback(
354
+ async (selection: DomEditSelection, properties: Record<string, number | string>) => {
355
+ try {
356
+ await commitAnimatedPropertiesRaw(selection, properties);
357
+ } catch (error) {
358
+ trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
359
+ throw error;
360
+ }
361
+ },
362
+ [commitAnimatedPropertiesRaw, trackGsapInteractionFailure],
363
+ );
364
+
365
+ const commitAnimatedProperty = useCallback(
366
+ async (selection: DomEditSelection, property: string, value: number | string) => {
367
+ try {
368
+ await commitAnimatedPropertyRaw(selection, property, value);
369
+ } catch (error) {
370
+ trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
371
+ throw error;
372
+ }
373
+ },
374
+ [commitAnimatedPropertyRaw, trackGsapInteractionFailure],
375
+ );
376
+
316
377
  // ── Arc path wrappers ──
317
378
 
318
379
  const handleSetArcPath = useCallback(
@@ -0,0 +1,42 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
+ import { mountReactHarness } from "./domSelectionTestHarness";
7
+ import { GsapEditBlockedError } from "./gsapEditOutcome";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
12
+ vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
13
+
14
+ import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
15
+
16
+ describe("useGsapInteractionFailureTelemetry", () => {
17
+ it("surfaces the blocked reason instead of a generic save failure", () => {
18
+ const showToast = vi.fn();
19
+ const selection = {
20
+ id: "clip",
21
+ selector: "#clip",
22
+ element: document.createElement("div"),
23
+ } as unknown as DomEditSelection;
24
+ let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
25
+ function Harness() {
26
+ report = useGsapInteractionFailureTelemetry("index.html", showToast);
27
+ return null;
28
+ }
29
+ const root = mountReactHarness(<Harness />);
30
+
31
+ act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
32
+
33
+ expect(showToast).toHaveBeenCalledWith(
34
+ "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
35
+ "error",
36
+ );
37
+ expect(trackStudioSaveFailure).toHaveBeenCalledWith(
38
+ expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
39
+ );
40
+ act(() => root.unmount());
41
+ });
42
+ });
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
+ import { isGsapEditBlockedError } from "./gsapEditOutcome";
4
5
 
5
6
  export function useGsapInteractionFailureTelemetry(
6
7
  activeCompPath: string | null,
@@ -18,7 +19,10 @@ export function useGsapInteractionFailureTelemetry(
18
19
  targetSelector: selection.selector,
19
20
  targetSourceFile: selection.sourceFile,
20
21
  });
21
- showToast("Failed to save animated edit.", "error");
22
+ showToast(
23
+ isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",
24
+ "error",
25
+ );
22
26
  },
23
27
  [activeCompPath, showToast],
24
28
  );
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
3
+ import { drainStudioSaveQueues } from "./usePreviewPersistence";
4
+
5
+ describe("drainStudioSaveQueues", () => {
6
+ it("does not erase a pending-field conflict with a clean DOM queue", async () => {
7
+ const conflict = new StudioFileConflictError({
8
+ filePath: "index.html",
9
+ currentVersion: "v2",
10
+ currentContent: "external",
11
+ attemptedContent: "studio",
12
+ });
13
+ const waitForDomQueue = vi.fn(async () => ({ status: "clean" as const }));
14
+
15
+ await expect(
16
+ drainStudioSaveQueues(
17
+ async () => ({ status: "conflict" as const, error: conflict }),
18
+ waitForDomQueue,
19
+ ),
20
+ ).resolves.toEqual({ status: "conflict", error: conflict });
21
+ expect(waitForDomQueue).not.toHaveBeenCalled();
22
+ });
23
+ });
@@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
3
3
  import {
4
4
  installStudioManualEditSeekReapply,
5
5
  reapplyPositionEditsAfterSeek,
6
- readStudioFileChangePath,
7
6
  } from "../components/editor/manualEdits";
8
7
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
9
8
  import type { EditHistoryKind } from "../utils/editHistory";
10
- import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
11
- import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
9
+ import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
10
+ import {
11
+ flushStudioPendingEdits,
12
+ type StudioPendingEditsDrainResult,
13
+ } from "../utils/studioPendingEdits";
12
14
  import { trackStudioEvent } from "../utils/studioTelemetry";
13
15
  import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
14
16
  import { usePlayerStore } from "../player";
@@ -29,19 +31,12 @@ interface RecordEditInput {
29
31
  }
30
32
 
31
33
  interface UsePreviewPersistenceParams {
32
- projectId: string | null;
33
34
  showToast: (message: string, tone?: "error" | "info") => void;
34
35
  readOptionalProjectFile: (path: string) => Promise<string>;
35
36
  writeProjectFile: (path: string, content: string) => Promise<void>;
36
37
  recordEdit: (entry: RecordEditInput) => Promise<void>;
37
38
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
38
39
  activeCompPathRef: React.MutableRefObject<string | null>;
39
- /** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
40
- * Used to suppress file-change echoes so we don't reload after our own saves. */
41
- domEditSaveTimestampRef: React.MutableRefObject<number>;
42
- /** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
43
- * events for these paths are always suppressed since the preview is already up-to-date. */
44
- pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
45
40
  /** Called to reload the preview after undo/redo or external file changes. */
46
41
  reloadPreview: () => void;
47
42
  }
@@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
71
66
  }
72
67
  }
73
68
 
74
- function shouldReloadForStudioFileChange(
75
- payload: unknown,
76
- pendingTimelineEditPathRef: React.MutableRefObject<Set<string>> | undefined,
77
- domEditSaveTimestampRef: React.MutableRefObject<number>,
78
- ): boolean {
79
- const changedPath = readStudioFileChangePath(payload);
80
- if (!changedPath) return false;
81
- const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
82
- if (pendingTimelinePaths?.has(changedPath)) {
83
- pendingTimelinePaths.delete(changedPath);
84
- return false;
85
- }
86
- return Date.now() - domEditSaveTimestampRef.current >= 4000;
69
+ export async function drainStudioSaveQueues(
70
+ flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
71
+ waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
72
+ ): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
73
+ const pending = await flushPendingFields();
74
+ if (pending.status !== "clean") return pending;
75
+ return waitForDomQueue();
87
76
  }
88
77
 
89
78
  // fallow-ignore-next-line complexity
@@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
107
96
  // ── Hook ──
108
97
 
109
98
  export function usePreviewPersistence({
110
- projectId,
111
99
  showToast,
112
100
  readOptionalProjectFile: _readOptionalProjectFile,
113
101
  writeProjectFile: _writeProjectFile,
114
102
  recordEdit: _recordEdit,
115
103
  previewIframeRef,
116
104
  activeCompPathRef,
117
- domEditSaveTimestampRef,
118
105
  reloadPreview,
119
- pendingTimelineEditPathRef,
120
106
  }: UsePreviewPersistenceParams) {
121
107
  void _recordEdit;
122
108
 
@@ -152,22 +138,23 @@ export function usePreviewPersistence({
152
138
  });
153
139
  }
154
140
 
155
- // Keep a ref to the latest projectId so async save callbacks always read the
156
- // current value, even when the callback was captured in a stale closure.
157
- const projectIdRef = useRef(projectId);
158
- projectIdRef.current = projectId;
159
-
160
141
  // ── Queue / drain helpers ──
161
142
 
162
143
  const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
163
144
  return domEditSaveQueueRef.current?.enqueue(save) ?? save();
164
145
  }, []);
165
146
 
166
- const waitForPendingDomEditSaves = useCallback(async () => {
167
- await flushStudioPendingEdits();
168
- await domEditSaveQueueRef.current?.waitForIdle();
147
+ const drainPendingDomEditSaves = useCallback(async () => {
148
+ return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
149
+ return (await domEditSaveQueueRef.current?.waitForIdle()) ?? { status: "clean" as const };
150
+ });
169
151
  }, []);
170
152
 
153
+ const waitForPendingDomEditSaves = useCallback(async (): Promise<void> => {
154
+ const result = await drainPendingDomEditSaves();
155
+ if (result.status !== "clean") throw result.error;
156
+ }, [drainPendingDomEditSaves]);
157
+
171
158
  const resetDomEditSaveQueueBreaker = useCallback(() => {
172
159
  domEditSaveQueueRef.current?.reset();
173
160
  setDomEditSaveQueuePaused(null);
@@ -235,35 +222,12 @@ export function usePreviewPersistence({
235
222
  void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
236
223
  });
237
224
 
238
- // ── Listen for external file changes (HMR / SSE) ──
239
- useMountEffect(() => {
240
- const handler = (payload?: unknown) => {
241
- if (
242
- shouldReloadForStudioFileChange(
243
- payload,
244
- pendingTimelineEditPathRef,
245
- domEditSaveTimestampRef,
246
- )
247
- ) {
248
- // fallow-ignore-next-line code-duplication
249
- reloadPreview();
250
- }
251
- };
252
- if (import.meta.hot) {
253
- import.meta.hot.on("hf:file-change", handler);
254
- return () => import.meta.hot?.off?.("hf:file-change", handler);
255
- }
256
- // SSE fallback for embedded studio server
257
- const es = new EventSource("/api/events");
258
- es.addEventListener("file-change", handler);
259
- return () => es.close();
260
- });
261
-
262
225
  return {
263
226
  domTextCommitVersionRef,
264
227
  domEditSaveQueueRef,
265
228
  applyStudioManualEditsToPreviewRef,
266
229
  queueDomEditSave,
230
+ drainPendingDomEditSaves,
267
231
  waitForPendingDomEditSaves,
268
232
  domEditSaveQueuePaused,
269
233
  resetDomEditSaveQueueBreaker,
@@ -1,11 +1,11 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
- import type { MutableRefObject } from "react";
3
2
  import { openComposition } from "@hyperframes/sdk";
4
3
  import type { Composition } from "@hyperframes/sdk";
5
4
  import { readStudioFileChangePath } from "../components/editor/manualEdits";
6
5
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
7
6
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
7
  import type { PublishSdkSession } from "../utils/sdkCutover";
8
+ import { addExternalFileReloadListener } from "./externalFileReloadBus";
9
9
 
10
10
  /**
11
11
  * Read a project file's content, or undefined on a non-2xx (optional read).
@@ -48,22 +48,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
48
48
  * stale. The session has NO persist queue — Studio is the sole file writer; see
49
49
  * the open effect below.
50
50
  */
51
- // Reload-suppression baseline: a file-change within this window of our own SDK
52
- // cutover write is a CANDIDATE echo, but the decision is content-identity based
53
- // (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
54
- // still reloads (its reverted bytes were never registered as a self-write). The
55
- // window only bounds how long a registered self-write stays suppressible.
56
- const SELF_WRITE_SUPPRESS_MS = 2000;
57
-
58
- /** Best-effort read of the changed file's content from a file-change payload. */
59
- function readFileChangeContent(payload: unknown): string | null {
60
- if (!payload || typeof payload !== "object") return null;
61
- const record = payload as Record<string, unknown>;
62
- if (typeof record.content === "string") return record.content;
63
- if ("data" in record) return readFileChangeContent(record.data);
64
- return null;
65
- }
66
-
67
51
  /**
68
52
  * Decide whether a file-change for the active composition should reload the SDK
69
53
  * session. `content` is the new on-disk bytes (from the payload or a re-read);
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
157
141
  export function useSdkSession(
158
142
  projectId: string | null,
159
143
  activeCompPath: string | null,
160
- domEditSaveTimestampRef?: MutableRefObject<number>,
161
144
  ): SdkSessionHandle {
162
145
  const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
163
146
  const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
@@ -171,40 +154,13 @@ export function useSdkSession(
171
154
  const reloadTokenRef = useRef(reloadToken);
172
155
  reloadTokenRef.current = reloadToken;
173
156
 
174
- // ── Re-open on external change to the active composition ──
175
- useEffect(() => {
176
- if (!activeCompPath) return;
177
- const compPath = activeCompPath;
178
- const readProjectId = projectId ?? null;
179
- const handler = (payload?: unknown) => {
180
- if (!shouldReloadSdkSession(payload, compPath)) return;
181
- const withinWindow =
182
- !!domEditSaveTimestampRef &&
183
- Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
184
- const decide = (content: string | null) => {
185
- if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
186
- };
187
- const payloadContent = readFileChangeContent(payload);
188
- // Prefer payload content; otherwise re-read so the decision is by IDENTITY
189
- // (an undo's reverted bytes won't match a registered self-write → reload).
190
- if (payloadContent != null || readProjectId == null) {
191
- decide(payloadContent);
192
- return;
193
- }
194
- readProjectFileOptional(readProjectId, compPath)
195
- .then((c) => decide(c ?? null))
196
- .catch(() => decide(null));
197
- };
198
- if (import.meta.hot) {
199
- import.meta.hot.on("hf:file-change", handler);
200
- return () => import.meta.hot?.off?.("hf:file-change", handler);
201
- }
202
- // SSE fallback for the embedded studio server.
203
- const es = new EventSource("/api/events");
204
- es.addEventListener("file-change", handler);
205
- return () => es.close();
206
- // eslint-disable-next-line react-hooks/exhaustive-deps
207
- }, [activeCompPath, projectId]);
157
+ useEffect(
158
+ () =>
159
+ addExternalFileReloadListener((changedPath) => {
160
+ if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
161
+ }),
162
+ [],
163
+ );
208
164
 
209
165
  // ── Open / re-open the session ──
210
166
  useEffect(() => {
@@ -0,0 +1,81 @@
1
+ import { useCallback, type MutableRefObject } from "react";
2
+ import {
3
+ deleteExternalConflictSnapshot,
4
+ loadExternalConflictSnapshot,
5
+ persistExternalConflictSnapshot,
6
+ persistExternalFailureSnapshot,
7
+ } from "../utils/externalConflictStorage";
8
+ import { notifyExternalFileReload } from "./externalFileReloadBus";
9
+ import { useExternalFileChangeCoordinator } from "./useExternalFileChangeCoordinator";
10
+ import type { useFileManager } from "./useFileManager";
11
+ import type { usePreviewPersistence } from "./usePreviewPersistence";
12
+
13
+ type FileManager = Pick<
14
+ ReturnType<typeof useFileManager>,
15
+ | "editingFile"
16
+ | "flushPendingSourceSave"
17
+ | "discardPendingSourceSave"
18
+ | "getPendingSourceCandidate"
19
+ | "overwriteExternalConflict"
20
+ | "readProjectFile"
21
+ | "updateEditingFileContent"
22
+ >;
23
+
24
+ type PreviewPersistence = Pick<
25
+ ReturnType<typeof usePreviewPersistence>,
26
+ "drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker"
27
+ >;
28
+
29
+ interface UseStudioExternalFileChangesOptions {
30
+ projectId: string | null;
31
+ activeCompPath: string | null;
32
+ masterCompPath: string | null;
33
+ fileManager: FileManager;
34
+ previewPersistence: PreviewPersistence;
35
+ pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
36
+ reloadPreview: () => void;
37
+ }
38
+
39
+ /** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
40
+ export function useStudioExternalFileChanges({
41
+ projectId,
42
+ activeCompPath,
43
+ masterCompPath,
44
+ fileManager,
45
+ previewPersistence,
46
+ pendingTimelineEditPathRef,
47
+ reloadPreview,
48
+ }: UseStudioExternalFileChangesOptions) {
49
+ const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
50
+ const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
51
+ const drainPendingChanges = useCallback(async () => {
52
+ const source = await flushPendingSourceSave();
53
+ if (source.status !== "clean") return source;
54
+ return drainPendingDomEditSaves();
55
+ }, [drainPendingDomEditSaves, flushPendingSourceSave]);
56
+
57
+ const discardPendingChanges = useCallback(() => {
58
+ discardPendingSourceSave();
59
+ resetDomEditSaveQueueBreaker();
60
+ }, [discardPendingSourceSave, resetDomEditSaveQueueBreaker]);
61
+
62
+ return useExternalFileChangeCoordinator({
63
+ projectId,
64
+ activeCompPath,
65
+ recoveryFilePath: fileManager.editingFile?.path ?? activeCompPath ?? masterCompPath,
66
+ pendingTimelineEditPathRef,
67
+ drainPendingChanges,
68
+ getPendingCandidate: fileManager.getPendingSourceCandidate,
69
+ discardPendingChanges,
70
+ reloadPreview,
71
+ reloadSdkSession: notifyExternalFileReload,
72
+ persistConflictSnapshot: persistExternalConflictSnapshot,
73
+ persistFailureSnapshot: persistExternalFailureSnapshot,
74
+ loadConflictSnapshot: loadExternalConflictSnapshot,
75
+ deleteConflictSnapshot: deleteExternalConflictSnapshot,
76
+ overwriteConflict: fileManager.overwriteExternalConflict,
77
+ readProjectFile: fileManager.readProjectFile,
78
+ onUseExternalFile: fileManager.updateEditingFileContent,
79
+ resetSaveQueues: resetDomEditSaveQueueBreaker,
80
+ });
81
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, type MutableRefObject } from "react";
1
+ import { useEffect } from "react";
2
2
  import { useSdkSession } from "./useSdkSession";
3
3
  import { usePreviewVariablesStore } from "./previewVariablesStore";
4
4
 
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
17
17
  export function useStudioSdkSessions(
18
18
  projectId: string | null,
19
19
  activeCompPath: string | null,
20
- domEditSaveTimestampRef: MutableRefObject<number>,
21
20
  masterCompPath: string | null,
22
21
  ) {
23
22
  // On the master view (no explicit comp) the schema panels target the project's
24
23
  // resolved main composition — the first `.html` in the tree, not a hardcoded
25
24
  // "index.html" that may not exist. `null` when the project has no composition
26
25
  // yet, which correctly leaves the session (and the panels) empty.
27
- const sdkHandle = useSdkSession(
28
- projectId,
29
- activeCompPath ?? masterCompPath,
30
- domEditSaveTimestampRef,
31
- );
26
+ const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
32
27
  const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
33
28
  useEffect(() => {
34
29
  usePreviewVariablesStore.getState().setValues(null);
@@ -0,0 +1,15 @@
1
+ import { useEffect } from "react";
2
+ import { hasFiredSessionStart, markSessionStartFired } from "../telemetry/config";
3
+ import { trackStudioSessionStart } from "../telemetry/events";
4
+
5
+ export function useStudioSessionStart(
6
+ projectId: string | null,
7
+ resolving: boolean,
8
+ waitingForServer: boolean,
9
+ ): void {
10
+ useEffect(() => {
11
+ if (resolving || waitingForServer || hasFiredSessionStart()) return;
12
+ markSessionStartFired();
13
+ trackStudioSessionStart({ has_project: projectId != null });
14
+ }, [projectId, resolving, waitingForServer]);
15
+ }