@hyperframes/studio 0.8.35 → 0.8.36

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 (78) hide show
  1. package/dist/assets/{hyperframes-player-lPOqAC3l.js → hyperframes-player-BIpJmC69.js} +1 -1
  2. package/dist/assets/index-BmPeyP0k.js +441 -0
  3. package/dist/assets/{index-ywAULfGT.js → index-Df3VgdgL.js} +1 -1
  4. package/dist/assets/{index-RPEcu_bT.js → index-z2_QKypm.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +61 -195
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +0 -7
  10. package/src/components/StudioRightPanel.tsx +0 -7
  11. package/src/components/StudioRightPanel.types.ts +0 -2
  12. package/src/components/editor/SnapToolbar.test.tsx +0 -2
  13. package/src/components/editor/manualEdits.test.ts +3 -3
  14. package/src/components/editor/manualEdits.ts +1 -1
  15. package/src/components/editor/manualEditsParsing.ts +12 -21
  16. package/src/components/panels/VariablesOtherCompositions.tsx +1 -4
  17. package/src/components/panels/VariablesPanel.tsx +1 -5
  18. package/src/components/studioColorGradingScope.ts +0 -4
  19. package/src/hooks/elementLifecycleOpsTestUtils.ts +0 -1
  20. package/src/hooks/externalFileChangeOwnership.test.ts +40 -1
  21. package/src/hooks/gsapScriptCommitTypes.ts +0 -1
  22. package/src/hooks/sdkSelfWriteRegistry.ts +1 -1
  23. package/src/hooks/timelineAudioGroupCreate.ts +0 -7
  24. package/src/hooks/timelineAudioGroupVolume.test.ts +0 -1
  25. package/src/hooks/timelineAudioGroupVolume.ts +0 -6
  26. package/src/hooks/timelineEditingHelpers.test.ts +0 -3
  27. package/src/hooks/timelineEditingHelpers.ts +0 -10
  28. package/src/hooks/timelineElementFxAttribute.ts +0 -6
  29. package/src/hooks/timelineTrackVisibility.test.ts +0 -17
  30. package/src/hooks/timelineTrackVisibility.ts +0 -15
  31. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -1
  32. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -2
  33. package/src/hooks/useAppHotkeys.ts +1 -7
  34. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +0 -1
  35. package/src/hooks/useClipboard.ts +1 -11
  36. package/src/hooks/useDomEditCommits.test.tsx +0 -2
  37. package/src/hooks/useDomEditCommits.ts +2 -21
  38. package/src/hooks/useDomEditSession.test.tsx +0 -1
  39. package/src/hooks/useDomEditSession.ts +0 -7
  40. package/src/hooks/useEditorSave.test.tsx +0 -1
  41. package/src/hooks/useEditorSave.ts +2 -6
  42. package/src/hooks/useElementLifecycleOps.ts +0 -3
  43. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +54 -0
  44. package/src/hooks/useExternalFileChangeCoordinator.ts +38 -22
  45. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  46. package/src/hooks/useFileManager.ts +0 -3
  47. package/src/hooks/useGroupCommits.ts +0 -2
  48. package/src/hooks/useGsapScriptCommits.test.tsx +0 -2
  49. package/src/hooks/useGsapScriptCommits.ts +2 -5
  50. package/src/hooks/usePreviewInteraction.test.ts +14 -5
  51. package/src/hooks/usePreviewInteraction.ts +3 -3
  52. package/src/hooks/useProjectCompositionVariables.ts +3 -6
  53. package/src/hooks/useRazorSplit.history.test.tsx +0 -2
  54. package/src/hooks/useRazorSplit.test.ts +0 -2
  55. package/src/hooks/useRazorSplit.test.tsx +0 -1
  56. package/src/hooks/useRazorSplit.ts +0 -6
  57. package/src/hooks/useSdkSession.ts +3 -11
  58. package/src/hooks/useSlideshowPersist.ts +1 -5
  59. package/src/hooks/useTimelineAssetDropOps.ts +0 -6
  60. package/src/hooks/useTimelineDeleteOps.ts +0 -4
  61. package/src/hooks/useTimelineEditing.test.tsx +0 -3
  62. package/src/hooks/useTimelineEditing.ts +0 -16
  63. package/src/hooks/useTimelineEditingTypes.ts +0 -1
  64. package/src/hooks/useTimelineGroupEditing.ts +0 -6
  65. package/src/hooks/useVariablesPersist.ts +0 -3
  66. package/src/player/components/Player.tsx +18 -5
  67. package/src/player/components/playerModuleRetry.test.ts +41 -0
  68. package/src/player/lib/playbackTypes.ts +7 -0
  69. package/src/player/lib/timelineIframeHelpers.test.ts +30 -0
  70. package/src/player/lib/timelineIframeHelpers.ts +16 -0
  71. package/src/utils/sdkCutover.familyGate.test.ts +0 -1
  72. package/src/utils/sdkCutover.gate.test.ts +0 -1
  73. package/src/utils/sdkCutover.test.ts +0 -27
  74. package/src/utils/sdkEditTransaction.ts +0 -4
  75. package/src/utils/setSlideshowManifest.test.ts +0 -1
  76. package/src/utils/studioFileHistory.ts +0 -1
  77. package/dist/assets/index-BRr1JoHX.js +0 -441
  78. package/src/hooks/useSdkSession.test.ts +0 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.35",
3
+ "version": "0.8.36",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.35",
52
- "@hyperframes/parsers": "0.8.35",
53
- "@hyperframes/player": "0.8.35",
54
- "@hyperframes/studio-server": "0.8.35",
55
- "@hyperframes/sdk": "0.8.35"
51
+ "@hyperframes/core": "0.8.36",
52
+ "@hyperframes/player": "0.8.36",
53
+ "@hyperframes/parsers": "0.8.36",
54
+ "@hyperframes/studio-server": "0.8.36",
55
+ "@hyperframes/sdk": "0.8.36"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^4.1.11",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.35"
72
+ "@hyperframes/producer": "0.8.36"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
package/src/App.tsx CHANGED
@@ -104,7 +104,6 @@ export function StudioApp() {
104
104
  rightPanelTab: initialUrlStateRef.current.rightPanelTab,
105
105
  });
106
106
  const editHistory = usePersistentEditHistory({ projectId });
107
- const domEditSaveTimestampRef = useRef(0);
108
107
  const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
109
108
  const pendingTimelineEditPathRef = useRef(new Set<string>());
110
109
  const isGestureRecordingRef = useRef(false);
@@ -113,7 +112,6 @@ export function StudioApp() {
113
112
  projectId,
114
113
  showToast,
115
114
  recordEdit: editHistory.recordEdit,
116
- domEditSaveTimestampRef,
117
115
  setRefreshKey,
118
116
  });
119
117
  const masterCompPath = useMemo(
@@ -161,7 +159,6 @@ export function StudioApp() {
161
159
  writeProjectFile: fileManager.writeProjectFile,
162
160
  observeProjectFileVersion: fileManager.observeProjectFileVersion,
163
161
  recordEdit: editHistory.recordEdit,
164
- domEditSaveTimestampRef,
165
162
  reloadPreview,
166
163
  previewIframeRef,
167
164
  pendingTimelineEditPathRef,
@@ -224,7 +221,6 @@ export function StudioApp() {
224
221
  showToast,
225
222
  writeProjectFile: fileManager.writeProjectFile,
226
223
  recordEdit: editHistory.recordEdit,
227
- domEditSaveTimestampRef,
228
224
  reloadPreview,
229
225
  handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
230
226
  handleDomEditElementDelete: domEditDeleteBridge,
@@ -240,7 +236,6 @@ export function StudioApp() {
240
236
  readOptionalProjectFile: fileManager.readOptionalProjectFile,
241
237
  readProjectFile: fileManager.readProjectFile,
242
238
  writeProjectFile: fileManager.writeProjectFile,
243
- domEditSaveTimestampRef,
244
239
  showToast,
245
240
  syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
246
241
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
@@ -281,7 +276,6 @@ export function StudioApp() {
281
276
  readProjectFile: fileManager.readProjectFile,
282
277
  writeProjectFile: fileManager.writeProjectFile,
283
278
  updateEditingFileContent: fileManager.updateEditingFileContent,
284
- domEditSaveTimestampRef,
285
279
  editHistory: { recordEdit: editHistory.recordEdit },
286
280
  fileTree: fileManager.fileTree,
287
281
  importedFontAssetsRef: fileManager.importedFontAssetsRef,
@@ -526,7 +520,6 @@ export function StudioApp() {
526
520
  publishSdkSession={sdkHandle.publish}
527
521
  forceReloadSdkSession={sdkHandle.forceReload}
528
522
  reloadPreview={reloadPreview}
529
- domEditSaveTimestampRef={domEditSaveTimestampRef}
530
523
  recordEdit={editHistory.recordEdit}
531
524
  onToggleElementHidden={timelineEditing.handleToggleElementHidden}
532
525
  onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
@@ -42,7 +42,6 @@ export function StudioRightPanel({
42
42
  publishSdkSession,
43
43
  forceReloadSdkSession,
44
44
  reloadPreview,
45
- domEditSaveTimestampRef,
46
45
  recordEdit,
47
46
  onToggleElementHidden,
48
47
  onAutoGroupCarveSources,
@@ -139,7 +138,6 @@ export function StudioRightPanel({
139
138
  writeProjectFile,
140
139
  recordEdit,
141
140
  reloadPreview,
142
- domEditSaveTimestampRef,
143
141
  publishSdkSession,
144
142
  });
145
143
 
@@ -152,7 +150,6 @@ export function StudioRightPanel({
152
150
  writeProjectFile,
153
151
  recordEdit,
154
152
  reloadPreview,
155
- domEditSaveTimestampRef,
156
153
  publishSdkSession,
157
154
  coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes",
158
155
  });
@@ -201,7 +198,6 @@ export function StudioRightPanel({
201
198
  selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
202
199
  fileTree,
203
200
  projectId,
204
- domEditSaveTimestampRef,
205
201
  waitForPendingDomEditSaves,
206
202
  readProjectFile,
207
203
  writeProjectFile,
@@ -217,7 +213,6 @@ export function StudioRightPanel({
217
213
  }),
218
214
  [
219
215
  activeCompPath,
220
- domEditSaveTimestampRef,
221
216
  domEditSelection?.sourceFile,
222
217
  fileTree,
223
218
  projectId,
@@ -272,7 +267,6 @@ export function StudioRightPanel({
272
267
  writeProjectFile={writeProjectFile}
273
268
  recordEdit={recordEdit}
274
269
  reloadPreview={reloadPreview}
275
- domEditSaveTimestampRef={domEditSaveTimestampRef}
276
270
  forceReloadSharedSdkSession={forceReloadSdkSession}
277
271
  >
278
272
  <PropertyPanel
@@ -436,7 +430,6 @@ export function StudioRightPanel({
436
430
  sdkSession={sdkSession}
437
431
  publishSdkSession={publishSdkSession}
438
432
  reloadPreview={reloadPreview}
439
- domEditSaveTimestampRef={domEditSaveTimestampRef}
440
433
  recordEdit={recordEdit}
441
434
  />
442
435
  ) : layersPaneOpen && designPaneOpen && !STUDIO_FLAT_INSPECTOR_ENABLED ? (
@@ -6,7 +6,6 @@
6
6
  * component's own diffs small and readable.
7
7
  */
8
8
 
9
- import type { MutableRefObject } from "react";
10
9
  import type { StudioEditPersistenceProps } from "./panels/VariablesPanel";
11
10
  import type { BlockParam } from "@hyperframes/core/registry";
12
11
  import type { Composition } from "@hyperframes/sdk";
@@ -44,7 +43,6 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
44
43
  */
45
44
  forceReloadSdkSession?: () => void;
46
45
  reloadPreview: () => void;
47
- domEditSaveTimestampRef: MutableRefObject<number>;
48
46
  recordEdit: (entry: {
49
47
  label: string;
50
48
  kind: EditHistoryKind;
@@ -30,7 +30,6 @@ function renderToolbar(onSnapChange = vi.fn()) {
30
30
  function AppHotkeyHarness() {
31
31
  const domEditSelectionRef = useRef<DomEditSelection | null>(null);
32
32
  const clearDomSelectionRef = useRef<() => void>(() => undefined);
33
- const domEditSaveTimestampRef = useRef(0);
34
33
  const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
35
34
 
36
35
  useAppHotkeys({
@@ -47,7 +46,6 @@ function AppHotkeyHarness() {
47
46
  readOptionalProjectFile: vi.fn(async () => ""),
48
47
  readProjectFile: vi.fn(async () => ""),
49
48
  writeProjectFile: vi.fn(async () => undefined),
50
- domEditSaveTimestampRef,
51
49
  showToast: vi.fn(),
52
50
  syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
53
51
  waitForPendingDomEditSaves: vi.fn(async () => undefined),
@@ -62,9 +62,9 @@ describe("studio manual edits", () => {
62
62
  expect(readStudioFileChangePath({ path: ".hyperframes/studio-manual-edits.json" })).toBe(
63
63
  ".hyperframes/studio-manual-edits.json",
64
64
  );
65
- expect(readStudioFileChangePath({ data: '{"path":"nested/file.html"}' })).toBe(
66
- "nested/file.html",
67
- );
65
+ expect(readStudioFileChangePath({ filePath: "/nested/file.html" })).toBe("nested/file.html");
66
+ // A still-wrapped delivery is a decoding bug upstream, never a path.
67
+ expect(readStudioFileChangePath({ data: { path: "nested/file.html" } })).toBeNull();
68
68
  });
69
69
 
70
70
  it("applies offsets through CSS translate longhand", () => {
@@ -9,7 +9,7 @@ export {
9
9
  type StudioPathOffsetSnapshot,
10
10
  } from "./manualEditsTypes";
11
11
 
12
- export { readStudioFileChangePath } from "./manualEditsParsing";
12
+ export { readFileChangeField, readStudioFileChangePath } from "./manualEditsParsing";
13
13
 
14
14
  export {
15
15
  beginStudioManualEditGesture,
@@ -15,28 +15,19 @@ function normalizeStudioFileChangePath(path: string): string {
15
15
  .replace(/^\.?\//, "");
16
16
  }
17
17
 
18
- function readStudioFileChangePathFromValue(value: unknown): string | null {
19
- if (typeof value === "string") {
20
- const trimmed = value.trim();
21
- if (!trimmed) return null;
22
- if (trimmed.startsWith("{")) {
23
- try {
24
- return readStudioFileChangePathFromValue(JSON.parse(trimmed) as unknown);
25
- } catch {
26
- return normalizeStudioFileChangePath(trimmed);
27
- }
28
- }
29
- return normalizeStudioFileChangePath(trimmed);
30
- }
31
-
32
- if (!value || typeof value !== "object") return null;
33
- const record = value as Record<string, unknown>;
34
- if (typeof record.path === "string") return normalizeStudioFileChangePath(record.path);
35
- if (typeof record.filePath === "string") return normalizeStudioFileChangePath(record.filePath);
36
- if ("data" in record) return readStudioFileChangePathFromValue(record.data);
37
- return null;
18
+ /**
19
+ * Read one string field out of an ALREADY-DECODED file-change payload. Every
20
+ * reader of that payload goes through here, so no reader can disagree with its
21
+ * siblings about the shape. Decoding a raw delivery is the transport's job.
22
+ */
23
+ export function readFileChangeField(payload: unknown, key: string): string | null {
24
+ if (!payload || typeof payload !== "object") return null;
25
+ const record = payload as Record<string, unknown>;
26
+ const value = record[key];
27
+ return typeof value === "string" ? value : null;
38
28
  }
39
29
 
40
30
  export function readStudioFileChangePath(payload: unknown): string | null {
41
- return readStudioFileChangePathFromValue(payload);
31
+ const path = readFileChangeField(payload, "path") ?? readFileChangeField(payload, "filePath");
32
+ return path === null ? null : normalizeStudioFileChangePath(path);
42
33
  }
@@ -6,7 +6,7 @@
6
6
  * follow-up (values are per-composition-scope), so no preview control is shown.
7
7
  */
8
8
 
9
- import { useCallback, useState, type MutableRefObject } from "react";
9
+ import { useCallback, useState } from "react";
10
10
  import type { Composition, CompositionVariable } from "@hyperframes/sdk";
11
11
  import {
12
12
  useEditVariablesInFile,
@@ -92,7 +92,6 @@ export function VariablesOtherCompositions({
92
92
  writeProjectFile,
93
93
  recordEdit,
94
94
  reloadPreview,
95
- domEditSaveTimestampRef,
96
95
  }: {
97
96
  fileTree: string[];
98
97
  excludePath: string;
@@ -101,7 +100,6 @@ export function VariablesOtherCompositions({
101
100
  writeProjectFile: (path: string, content: string) => Promise<void>;
102
101
  recordEdit: RecordEditFn;
103
102
  reloadPreview: () => void;
104
- domEditSaveTimestampRef: MutableRefObject<number>;
105
103
  }) {
106
104
  const [selfRefresh, setSelfRefresh] = useState(0);
107
105
  const groups = useProjectCompositionVariables(
@@ -115,7 +113,6 @@ export function VariablesOtherCompositions({
115
113
  writeProjectFile,
116
114
  recordEdit,
117
115
  reloadPreview,
118
- domEditSaveTimestampRef,
119
116
  });
120
117
  const [editingKey, setEditingKey] = useState<string | null>(null);
121
118
 
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
1
+ import { memo, useCallback, useEffect, useMemo, useState } from "react";
2
2
  import type {
3
3
  Composition,
4
4
  CompositionVariable,
@@ -35,7 +35,6 @@ export interface StudioEditPersistenceProps {
35
35
  sdkSession: Composition | null;
36
36
  publishSdkSession: PublishSdkSession;
37
37
  reloadPreview: () => void;
38
- domEditSaveTimestampRef: MutableRefObject<number>;
39
38
  recordEdit: (entry: {
40
39
  label: string;
41
40
  kind: EditHistoryKind;
@@ -253,7 +252,6 @@ export const VariablesPanel = memo(function VariablesPanel({
253
252
  sdkSession,
254
253
  publishSdkSession,
255
254
  reloadPreview,
256
- domEditSaveTimestampRef,
257
255
  recordEdit,
258
256
  }: VariablesPanelProps) {
259
257
  const { activeCompPath, showToast } = useStudioShellContext();
@@ -289,7 +287,6 @@ export const VariablesPanel = memo(function VariablesPanel({
289
287
  writeProjectFile,
290
288
  recordEdit,
291
289
  reloadPreview,
292
- domEditSaveTimestampRef,
293
290
  publishSdkSession,
294
291
  });
295
292
 
@@ -552,7 +549,6 @@ export const VariablesPanel = memo(function VariablesPanel({
552
549
  writeProjectFile={writeProjectFile}
553
550
  recordEdit={recordEdit}
554
551
  reloadPreview={reloadPreview}
555
- domEditSaveTimestampRef={domEditSaveTimestampRef}
556
552
  />
557
553
  </div>
558
554
  </div>
@@ -1,4 +1,3 @@
1
- import type { MutableRefObject } from "react";
2
1
  import type { EditHistoryKind } from "../utils/editHistory";
3
2
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
4
3
  import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch";
@@ -27,7 +26,6 @@ interface ApplyColorGradingScopeOptions {
27
26
  selectedSourceFile: string;
28
27
  fileTree: string[];
29
28
  projectId: string;
30
- domEditSaveTimestampRef: MutableRefObject<number>;
31
29
  waitForPendingDomEditSaves: () => Promise<void>;
32
30
  readProjectFile: ProjectFileReader;
33
31
  writeProjectFile: ProjectFileWriter;
@@ -78,7 +76,6 @@ export async function applyColorGradingScopeUpdate({
78
76
  selectedSourceFile,
79
77
  fileTree,
80
78
  projectId,
81
- domEditSaveTimestampRef,
82
79
  waitForPendingDomEditSaves,
83
80
  readProjectFile,
84
81
  writeProjectFile,
@@ -105,7 +102,6 @@ export async function applyColorGradingScopeUpdate({
105
102
  return EMPTY_COLOR_GRADING_SCOPE_RESULT;
106
103
  }
107
104
 
108
- domEditSaveTimestampRef.current = Date.now();
109
105
  const changedPaths = await saveProjectFilesWithHistory({
110
106
  projectId,
111
107
  label: value ? "Apply color grading" : "Clear color grading",
@@ -15,7 +15,6 @@ export function makeLifecycleOpsParams(
15
15
  activeCompPath: "index.html",
16
16
  showToast: vi.fn(),
17
17
  writeProjectFile: vi.fn(async () => {}),
18
- domEditSaveTimestampRef: { current: 0 },
19
18
  editHistory: { recordEdit: vi.fn(async () => {}) },
20
19
  projectIdRef: { current: null },
21
20
  reloadPreview: vi.fn(),
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { sseFileChangeChannel } from "./useExternalFileChangeCoordinator";
3
4
 
4
5
  describe("external file-change subscription ownership", () => {
5
6
  it("has one subscriber instead of independent Preview and SDK listeners", () => {
@@ -14,4 +15,42 @@ describe("external file-change subscription ownership", () => {
14
15
  expect(sdk).not.toContain('hot.on("hf:file-change"');
15
16
  expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
16
17
  });
18
+
19
+ it("selects the SSE channel for production and hands it the shared handler", () => {
20
+ const coordinator = readFileSync(
21
+ new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
22
+ "utf8",
23
+ );
24
+ // vitest defines `import.meta.hot`, so the selection is the one claim left to
25
+ // the source; the channel's own behaviour is exercised below.
26
+ expect(coordinator.match(/sseFileChangeChannel\(handler\)/g)).toHaveLength(1);
27
+ });
28
+
29
+ it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => {
30
+ const listeners = new Map<string, (event: unknown) => void>();
31
+ const close = vi.fn();
32
+ const opened: string[] = [];
33
+ class FakeEventSource {
34
+ constructor(url: string) {
35
+ opened.push(url);
36
+ }
37
+ addEventListener(type: string, listener: (event: unknown) => void) {
38
+ listeners.set(type, listener);
39
+ }
40
+ close = close;
41
+ }
42
+ vi.stubGlobal("EventSource", FakeEventSource);
43
+ try {
44
+ const onDelivery = vi.fn();
45
+ const stop = sseFileChangeChannel(onDelivery);
46
+ const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) });
47
+ listeners.get("file-change")?.(event);
48
+ expect(opened).toEqual(["/api/events"]);
49
+ expect(onDelivery).toHaveBeenCalledWith(event);
50
+ stop();
51
+ expect(close).toHaveBeenCalledTimes(1);
52
+ } finally {
53
+ vi.unstubAllGlobals();
54
+ }
55
+ });
17
56
  });
@@ -102,7 +102,6 @@ export interface GsapScriptCommitsParams {
102
102
  files: Record<string, { before: string; after: string }>;
103
103
  }) => Promise<void>;
104
104
  };
105
- domEditSaveTimestampRef: React.MutableRefObject<number>;
106
105
  reloadPreview: () => void;
107
106
  onCacheInvalidate: () => void;
108
107
  onFileContentChanged?: (path: string, content: string) => void;
@@ -3,7 +3,7 @@
3
3
  * external write (notably undo/redo) in the file-change reload-suppression path.
4
4
  *
5
5
  * The old suppression was purely time-based: any file-change within 2 s of the
6
- * shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover
6
+ * shared save timestamp was swallowed. But BOTH an SDK cutover
7
7
  * self-write AND an undo write set that same timestamp, so the window could not
8
8
  * tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes
9
9
  * an undo just wrote" (must reload). An undo that landed inside the window was
@@ -143,7 +143,6 @@ interface CreateAudioGroupAndAssignMembersInput {
143
143
  previewIframe: HTMLIFrameElement | null;
144
144
  writeProjectFile: (path: string, content: string) => Promise<void>;
145
145
  recordEdit: (input: RecordEditInput) => Promise<void>;
146
- domEditSaveTimestampRef: MutableRef<number>;
147
146
  pendingTimelineEditPathRef: MutableRef<Set<string>>;
148
147
  }
149
148
 
@@ -164,7 +163,6 @@ export async function createAudioGroupAndAssignMembers({
164
163
  previewIframe,
165
164
  writeProjectFile,
166
165
  recordEdit,
167
- domEditSaveTimestampRef,
168
166
  pendingTimelineEditPathRef,
169
167
  }: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
170
168
  // Throws rather than returning empty: the carve's auto-group awaits this and
@@ -222,7 +220,6 @@ export async function createAudioGroupAndAssignMembers({
222
220
  pendingTimelineEditPathRef.current.add(groupPath);
223
221
  }
224
222
 
225
- domEditSaveTimestampRef.current = Date.now();
226
223
  const changedPaths = await saveProjectFilesWithHistory({
227
224
  projectId,
228
225
  label: groupLabel
@@ -238,7 +235,6 @@ export async function createAudioGroupAndAssignMembers({
238
235
  writeFile: writeProjectFile,
239
236
  recordEdit,
240
237
  });
241
- domEditSaveTimestampRef.current = Date.now();
242
238
  for (const element of elements) {
243
239
  usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId });
244
240
  }
@@ -268,7 +264,6 @@ export function useAudioGroupCarveAssignment({
268
264
  showToast,
269
265
  writeProjectFile,
270
266
  recordEdit,
271
- domEditSaveTimestampRef,
272
267
  previewIframeRef,
273
268
  pendingTimelineEditPathRef,
274
269
  isRecordingRef,
@@ -314,7 +309,6 @@ export function useAudioGroupCarveAssignment({
314
309
  previewIframe: previewIframeRef.current,
315
310
  writeProjectFile,
316
311
  recordEdit,
317
- domEditSaveTimestampRef,
318
312
  pendingTimelineEditPathRef,
319
313
  });
320
314
  } catch (error) {
@@ -335,7 +329,6 @@ export function useAudioGroupCarveAssignment({
335
329
  previewIframeRef,
336
330
  writeProjectFile,
337
331
  recordEdit,
338
- domEditSaveTimestampRef,
339
332
  pendingTimelineEditPathRef,
340
333
  isRecordingRef,
341
334
  showToast,
@@ -45,7 +45,6 @@ function makeSetter() {
45
45
  showToast: () => {},
46
46
  writeProjectFile: async () => {},
47
47
  recordEdit: async () => {},
48
- domEditSaveTimestampRef: { current: 0 },
49
48
  pendingTimelineEditPathRef: { current: new Set<string>() },
50
49
  previewIframeRef: { current: null },
51
50
  };
@@ -143,7 +143,6 @@ interface SetAudioGroupAttributeInput {
143
143
  previewIframe: HTMLIFrameElement | null;
144
144
  writeProjectFile: (path: string, content: string) => Promise<void>;
145
145
  recordEdit: (input: RecordEditInput) => Promise<void>;
146
- domEditSaveTimestampRef: MutableRef<number>;
147
146
  pendingTimelineEditPathRef: MutableRef<Set<string>>;
148
147
  }
149
148
 
@@ -163,7 +162,6 @@ async function setAudioGroupAttribute({
163
162
  previewIframe,
164
163
  writeProjectFile,
165
164
  recordEdit,
166
- domEditSaveTimestampRef,
167
165
  pendingTimelineEditPathRef,
168
166
  }: SetAudioGroupAttributeInput): Promise<string[]> {
169
167
  // The file that actually CONTAINS the group element, not just the active
@@ -188,7 +186,6 @@ async function setAudioGroupAttribute({
188
186
  label,
189
187
  writeProjectFile,
190
188
  recordEdit,
191
- domEditSaveTimestampRef,
192
189
  pendingTimelineEditPathRef,
193
190
  patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
194
191
  });
@@ -207,7 +204,6 @@ export function useSetAudioGroupAttribute({
207
204
  showToast,
208
205
  writeProjectFile,
209
206
  recordEdit,
210
- domEditSaveTimestampRef,
211
207
  previewIframeRef,
212
208
  pendingTimelineEditPathRef,
213
209
  isRecordingRef,
@@ -244,7 +240,6 @@ export function useSetAudioGroupAttribute({
244
240
  previewIframe: previewIframeRef.current,
245
241
  writeProjectFile,
246
242
  recordEdit,
247
- domEditSaveTimestampRef,
248
243
  pendingTimelineEditPathRef,
249
244
  });
250
245
  syncStoredGroupAttribute(groupId, attr, value);
@@ -269,7 +264,6 @@ export function useSetAudioGroupAttribute({
269
264
  previewIframeRef,
270
265
  writeProjectFile,
271
266
  recordEdit,
272
- domEditSaveTimestampRef,
273
267
  pendingTimelineEditPathRef,
274
268
  isRecordingRef,
275
269
  showToast,
@@ -243,7 +243,6 @@ describe("persistTimelineBatchEdit", () => {
243
243
  writes.push([path, content]);
244
244
  },
245
245
  recordEdit: async () => {},
246
- domEditSaveTimestampRef: { current: 0 },
247
246
  pendingTimelineEditPathRef: { current: new Set<string>() },
248
247
  };
249
248
  }
@@ -452,7 +451,6 @@ describe("persistElementAttribute", () => {
452
451
  label: "Set volume",
453
452
  writeProjectFile,
454
453
  recordEdit: vi.fn(),
455
- domEditSaveTimestampRef: { current: 0 },
456
454
  pendingTimelineEditPathRef: { current: new Set() },
457
455
  patchLive,
458
456
  }),
@@ -496,7 +494,6 @@ describe("persistElementAttribute — unwind value", () => {
496
494
  label: "Set volume",
497
495
  writeProjectFile,
498
496
  recordEdit: vi.fn(),
499
- domEditSaveTimestampRef: { current: 0 },
500
497
  pendingTimelineEditPathRef: { current: new Set() },
501
498
  // The live DOM is ALREADY at the new value when the commit runs — that
502
499
  // is what `setLive` does on every drag frame.
@@ -285,7 +285,6 @@ export interface PersistTimelineEditInput {
285
285
  buildPatches: (original: string, target: PatchTarget) => string;
286
286
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
287
287
  recordEdit: (input: RecordEditInput) => Promise<void>;
288
- domEditSaveTimestampRef: React.MutableRefObject<number>;
289
288
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
290
289
  coalesceKey?: string;
291
290
  }
@@ -305,7 +304,6 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
305
304
  }
306
305
 
307
306
  input.pendingTimelineEditPathRef.current.add(targetPath);
308
- input.domEditSaveTimestampRef.current = Date.now();
309
307
  await saveProjectFilesWithHistory({
310
308
  projectId: input.projectId,
311
309
  label: input.label,
@@ -316,7 +314,6 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
316
314
  writeFile: input.writeProjectFile,
317
315
  recordEdit: input.recordEdit,
318
316
  });
319
- input.domEditSaveTimestampRef.current = Date.now();
320
317
  }
321
318
 
322
319
  export interface PersistTimelineBatchChange {
@@ -331,7 +328,6 @@ export interface PersistTimelineBatchEditInput {
331
328
  changes: PersistTimelineBatchChange[];
332
329
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
333
330
  recordEdit: (input: RecordEditInput) => Promise<void>;
334
- domEditSaveTimestampRef: React.MutableRefObject<number>;
335
331
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
336
332
  coalesceKey?: string;
337
333
  /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
@@ -378,7 +374,6 @@ export async function persistTimelineBatchEdit(
378
374
  for (const targetPath of Object.keys(files)) {
379
375
  input.pendingTimelineEditPathRef.current.add(targetPath);
380
376
  }
381
- input.domEditSaveTimestampRef.current = Date.now();
382
377
  await saveProjectFilesWithHistory({
383
378
  projectId: input.projectId,
384
379
  label: input.label,
@@ -390,7 +385,6 @@ export async function persistTimelineBatchEdit(
390
385
  writeFile: input.writeProjectFile,
391
386
  recordEdit: input.recordEdit,
392
387
  });
393
- input.domEditSaveTimestampRef.current = Date.now();
394
388
  }
395
389
 
396
390
  export { applyPatchByTarget, formatTimelineAttributeNumber };
@@ -406,7 +400,6 @@ export interface PersistElementAttributeInput {
406
400
  label: string;
407
401
  writeProjectFile: (path: string, content: string) => Promise<void>;
408
402
  recordEdit: (input: RecordEditInput) => Promise<void>;
409
- domEditSaveTimestampRef: { current: number };
410
403
  pendingTimelineEditPathRef: { current: Set<string> };
411
404
  /** Write the attribute directly on the live preview DOM node. */
412
405
  patchLive: (value: string | null) => void;
@@ -429,7 +422,6 @@ export async function persistElementAttribute({
429
422
  label,
430
423
  writeProjectFile,
431
424
  recordEdit,
432
- domEditSaveTimestampRef,
433
425
  pendingTimelineEditPathRef,
434
426
  patchLive,
435
427
  }: PersistElementAttributeInput): Promise<string[]> {
@@ -459,7 +451,6 @@ export async function persistElementAttribute({
459
451
  const patched = applyPatchByTarget(before, patchTarget, operation);
460
452
 
461
453
  pendingTimelineEditPathRef.current.add(targetPath);
462
- domEditSaveTimestampRef.current = Date.now();
463
454
  try {
464
455
  const changedPaths = await saveProjectFilesWithHistory({
465
456
  projectId,
@@ -470,7 +461,6 @@ export async function persistElementAttribute({
470
461
  writeFile: writeProjectFile,
471
462
  recordEdit,
472
463
  });
473
- domEditSaveTimestampRef.current = Date.now();
474
464
  return changedPaths;
475
465
  } catch (error) {
476
466
  // The optimistic live write already ran; unwind it on a save failure so