@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
@@ -322,4 +322,58 @@ describe("external file change coordinator", () => {
322
322
  expect(reloadPreview).toHaveBeenCalledTimes(2);
323
323
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
324
324
  });
325
+
326
+ // `hyperframes preview` serves file-change over SSE, where the delivery is a
327
+ // MessageEvent whose `data` is a JSON STRING. Driven through the test adapter
328
+ // because vitest defines `import.meta.hot`, so the EventSource rung is
329
+ // unreachable here, which is exactly why decoding is shared by all rungs.
330
+ describe("SSE-shaped deliveries", () => {
331
+ const sseDelivery = (payload: unknown) =>
332
+ new MessageEvent("file-change", { data: JSON.stringify(payload) });
333
+
334
+ it("suppresses every reload for Studio's own write", async () => {
335
+ const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
336
+ const reloadPreview = vi.fn();
337
+ const onAcceptedPersistedFileChange = vi.fn();
338
+ await mountCoordinator({ drainPendingChanges, reloadPreview, onAcceptedPersistedFileChange });
339
+ markStudioWriteToken("studio-write-1");
340
+
341
+ await act(async () =>
342
+ handler?.(sseDelivery({ path: "index.html", version: "v2", writeToken: "studio-write-1" })),
343
+ );
344
+
345
+ expect(drainPendingChanges).not.toHaveBeenCalled();
346
+ expect(reloadPreview).not.toHaveBeenCalled();
347
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
348
+ });
349
+
350
+ it("reloads once when one watcher event reaches two subscribers", async () => {
351
+ const reloadPreview = vi.fn();
352
+ await mountCoordinator({ reloadPreview });
353
+
354
+ const external = { path: "index.html", version: "v2" };
355
+ await act(async () => handler?.(sseDelivery(external)));
356
+ await act(async () => handler?.(sseDelivery(external)));
357
+
358
+ expect(reloadPreview).toHaveBeenCalledOnce();
359
+ });
360
+
361
+ it("still reloads for a genuinely external write", async () => {
362
+ const reloadPreview = vi.fn();
363
+ await mountCoordinator({ reloadPreview });
364
+
365
+ await act(async () => handler?.(sseDelivery({ path: "index.html", version: "v9" })));
366
+
367
+ expect(reloadPreview).toHaveBeenCalledOnce();
368
+ });
369
+
370
+ it("drops an unparseable delivery instead of throwing", async () => {
371
+ const reloadPreview = vi.fn();
372
+ await mountCoordinator({ reloadPreview });
373
+
374
+ await act(async () => handler?.(new MessageEvent("file-change", { data: "not json" })));
375
+
376
+ expect(reloadPreview).not.toHaveBeenCalled();
377
+ });
378
+ });
325
379
  });
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
- import { readStudioFileChangePath } from "../components/editor/manualEdits";
2
+ import { readFileChangeField, readStudioFileChangePath } from "../components/editor/manualEdits";
3
3
  import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
4
4
  import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
5
5
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
@@ -81,25 +81,39 @@ function testHotAdapter(): HotTestAdapter | null {
81
81
  : null;
82
82
  }
83
83
 
84
- function readFileChangeContent(payload: unknown): string | null {
85
- if (!payload || typeof payload !== "object") return null;
86
- const record = payload as Record<string, unknown>;
87
- if (typeof record.content === "string") return record.content;
88
- return "data" in record ? readFileChangeContent(record.data) : null;
89
- }
84
+ const readFileChangeContent = (payload: unknown) => readFileChangeField(payload, "content");
85
+ const readFileChangeVersion = (payload: unknown) => readFileChangeField(payload, "version");
86
+ const readFileChangeWriteToken = (payload: unknown) => readFileChangeField(payload, "writeToken");
90
87
 
91
- function readFileChangeVersion(payload: unknown): string | null {
92
- if (!payload || typeof payload !== "object") return null;
93
- const record = payload as Record<string, unknown>;
94
- if (typeof record.version === "string") return record.version;
95
- return "data" in record ? readFileChangeVersion(record.data) : null;
88
+ /**
89
+ * Decode one delivery into the payload shape every reader above assumes.
90
+ *
91
+ * SSE delivers a `MessageEvent` whose `data` is a JSON string; the hot-reload
92
+ * transports deliver the payload object. This is the only place that difference
93
+ * exists: a reader handed an undecoded envelope reports every field as absent,
94
+ * which is indistinguishable from a field that is genuinely unset.
95
+ */
96
+ function decodeFileChange(delivery: unknown): unknown {
97
+ // Structural, not `instanceof`: a polyfilled or cross-realm event must still decode.
98
+ const data = (delivery as { data?: unknown } | null)?.data;
99
+ if (typeof data !== "string") return delivery;
100
+ try {
101
+ return JSON.parse(data);
102
+ } catch {
103
+ logReload("file-change", { path: null, why: "unparseable payload" });
104
+ return null;
105
+ }
96
106
  }
97
107
 
98
- function readFileChangeWriteToken(payload: unknown): string | null {
99
- if (!payload || typeof payload !== "object") return null;
100
- const record = payload as Record<string, unknown>;
101
- if (typeof record.writeToken === "string") return record.writeToken;
102
- return "data" in record ? readFileChangeWriteToken(record.data) : null;
108
+ /**
109
+ * The production transport. vitest defines `import.meta.hot`, so the selection
110
+ * below never reaches this rung under test; the rung itself is exported so a test
111
+ * can drive a real `MessageEvent` through the listener it registers.
112
+ */
113
+ export function sseFileChangeChannel(onDelivery: (delivery: unknown) => void): () => void {
114
+ const eventSource = new EventSource("/api/events");
115
+ eventSource.addEventListener("file-change", onDelivery);
116
+ return () => eventSource.close();
103
117
  }
104
118
 
105
119
  function eventIdentity(path: string, payload: unknown): string | null {
@@ -331,7 +345,10 @@ export function useExternalFileChangeCoordinator({
331
345
  // fallow-ignore-next-line complexity
332
346
  (payload: unknown) => {
333
347
  const path = readStudioFileChangePath(payload);
334
- if (!path || !projectId) return;
348
+ if (!path || !projectId) {
349
+ logReload("file-change", { path: null, why: path ? "no project" : "no path in payload" });
350
+ return;
351
+ }
335
352
  pendingTimelineEditPathRef.current.delete(path);
336
353
 
337
354
  const content = readFileChangeContent(payload);
@@ -362,7 +379,8 @@ export function useExternalFileChangeCoordinator({
362
379
  );
363
380
 
364
381
  useEffect(() => {
365
- const handler = (payload?: unknown) => processChange(payload);
382
+ // One decoder for all three transports; the rungs only choose the channel.
383
+ const handler = (delivery?: unknown) => processChange(decodeFileChange(delivery));
366
384
  const adapter = testHotAdapter();
367
385
  if (adapter) {
368
386
  adapter.on("hf:file-change", handler);
@@ -372,9 +390,7 @@ export function useExternalFileChangeCoordinator({
372
390
  import.meta.hot.on("hf:file-change", handler);
373
391
  return () => import.meta.hot?.off?.("hf:file-change", handler);
374
392
  }
375
- const eventSource = new EventSource("/api/events");
376
- eventSource.addEventListener("file-change", handler);
377
- return () => eventSource.close();
393
+ return sseFileChangeChannel(handler);
378
394
  }, [processChange]);
379
395
 
380
396
  const retry = useCallback(async () => {
@@ -37,7 +37,6 @@ function useTestFileManager(projectId: string) {
37
37
  projectId,
38
38
  showToast: vi.fn(),
39
39
  recordEdit: vi.fn(async () => {}),
40
- domEditSaveTimestampRef: { current: 0 },
41
40
  setRefreshKey: vi.fn(),
42
41
  });
43
42
  }
@@ -27,7 +27,6 @@ interface UseFileManagerOptions {
27
27
  projectId: string | null;
28
28
  showToast: (message: string, tone?: "error" | "info") => void;
29
29
  recordEdit: (input: RecordEditInput) => Promise<void>;
30
- domEditSaveTimestampRef: React.MutableRefObject<number>;
31
30
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
32
31
  }
33
32
 
@@ -37,7 +36,6 @@ export function useFileManager({
37
36
  projectId,
38
37
  showToast,
39
38
  recordEdit,
40
- domEditSaveTimestampRef,
41
39
  setRefreshKey,
42
40
  }: UseFileManagerOptions) {
43
41
  // ── Shared refs ──
@@ -198,7 +196,6 @@ export function useFileManager({
198
196
  readProjectFile,
199
197
  writeProjectFile,
200
198
  recordEdit,
201
- domEditSaveTimestampRef,
202
199
  setRefreshKey,
203
200
  showToast,
204
201
  });
@@ -64,7 +64,6 @@ async function commitStructuralMutation(
64
64
  UseGroupCommitsParams,
65
65
  | "writeProjectFile"
66
66
  | "editHistory"
67
- | "domEditSaveTimestampRef"
68
67
  | "clearDomSelection"
69
68
  | "forceReloadSdkSession"
70
69
  | "reloadPreview"
@@ -72,7 +71,6 @@ async function commitStructuralMutation(
72
71
  ): Promise<{ content?: string; groupId?: string }> {
73
72
  const originalContent = await readProjectFileContent(pid, targetPath);
74
73
 
75
- deps.domEditSaveTimestampRef.current = Date.now();
76
74
  const mutateResponse = await fetch(
77
75
  buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
78
76
  {
@@ -379,7 +379,6 @@ function renderCommitHook(
379
379
  activeCompPath: "index.html",
380
380
  previewIframeRef: { current: FAKE_IFRAME },
381
381
  editHistory: { recordEdit },
382
- domEditSaveTimestampRef: { current: 0 },
383
382
  reloadPreview,
384
383
  onCacheInvalidate,
385
384
  onFileContentChanged,
@@ -669,7 +668,6 @@ describe("runCommit — instantPatch wiring", () => {
669
668
  writeProjectFile,
670
669
  readProjectFile: vi.fn(async () => "BEFORE"),
671
670
  reloadPreview: deps.reloadPreview,
672
- domEditSaveTimestampRef: { current: 0 },
673
671
  });
674
672
  await sdkWriteStarted;
675
673
 
@@ -313,7 +313,7 @@ function instantPatchesFor(
313
313
 
314
314
  // oxfmt-ignore
315
315
  // fallow-ignore-next-line complexity
316
- export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
316
+ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
317
317
  const activeProjectId = projectIdRef.current;
318
318
  const activeCompPathRef = useRef(activeCompPath);
319
319
  activeCompPathRef.current = activeCompPath;
@@ -348,7 +348,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
348
348
  }
349
349
  return;
350
350
  }
351
- if (previewIsActive) domEditSaveTimestampRef.current = Date.now();
352
351
  await recordMutationEdit(targetPath, result, options);
353
352
  // The durable mutation belongs to the project captured when it was queued.
354
353
  // A later project must never receive its file state or preview refresh.
@@ -365,7 +364,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
365
364
  reloadPreview,
366
365
  onCacheInvalidate,
367
366
  });
368
- }, [projectIdRef, previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
367
+ }, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
369
368
 
370
369
  const runCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
371
370
  const result = await runMutationRequest([mutation], options, showToast, () =>
@@ -494,7 +493,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
494
493
  editHistory: { recordEdit: editHistory.recordEdit },
495
494
  writeProjectFile,
496
495
  reloadPreview,
497
- domEditSaveTimestampRef,
498
496
  refresh: sdkRefresh,
499
497
  compositionPath: activeCompPath,
500
498
  readProjectFile: readProjectFileContent,
@@ -505,7 +503,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
505
503
  editHistory.recordEdit,
506
504
  writeProjectFile,
507
505
  reloadPreview,
508
- domEditSaveTimestampRef,
509
506
  sdkRefresh,
510
507
  activeCompPath,
511
508
  readProjectFileContent,
@@ -224,12 +224,17 @@ describe("usePreviewInteraction", () => {
224
224
  cleanup();
225
225
  });
226
226
 
227
- it("resumes playback when a click resolves to nothing (dead-zone / deselect)", async () => {
228
- usePlayerStore.setState({ isPlaying: true });
227
+ // The deselect resume asks the PLAYER to play; it must never move the store's
228
+ // flag on its own. The runtime really was paused (playerPause below), so a bare
229
+ // setIsPlaying(true) leaves the button reading "playing" over a stopped runtime
230
+ // with nothing able to reconcile the two.
231
+ it("requests a real resume when a click resolves to nothing (dead-zone / deselect)", async () => {
232
+ usePlayerStore.setState({ isPlaying: true, playbackRequest: null });
233
+ const playerPause = vi.fn();
229
234
  const applyDomSelection = vi.fn();
230
235
  const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
231
236
  const { canvas, cleanup } = renderHarness({
232
- previewIframe: createPreviewIframe(vi.fn()),
237
+ previewIframe: createPreviewIframe(playerPause),
233
238
  resolveDomSelectionFromPreviewPoint,
234
239
  applyDomSelection,
235
240
  });
@@ -237,12 +242,15 @@ describe("usePreviewInteraction", () => {
237
242
  await dispatchMouseDown(canvas, {});
238
243
 
239
244
  expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
240
- expect(usePlayerStore.getState().isPlaying).toBe(true);
245
+ expect(playerPause).toHaveBeenCalled();
246
+ expect(usePlayerStore.getState().playbackRequest).toMatchObject({ playing: true });
247
+ // Only the player's own play() may raise this, once the runtime is running.
248
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
241
249
  cleanup();
242
250
  });
243
251
 
244
252
  it("does not resume playback on deselect when it was already paused", async () => {
245
- usePlayerStore.setState({ isPlaying: false });
253
+ usePlayerStore.setState({ isPlaying: false, playbackRequest: null });
246
254
  const applyDomSelection = vi.fn();
247
255
  const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
248
256
  const { canvas, cleanup } = renderHarness({
@@ -255,6 +263,7 @@ describe("usePreviewInteraction", () => {
255
263
 
256
264
  expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
257
265
  expect(usePlayerStore.getState().isPlaying).toBe(false);
266
+ expect(usePlayerStore.getState().playbackRequest).toBeNull();
258
267
  cleanup();
259
268
  });
260
269
  });
@@ -101,10 +101,10 @@ export function usePreviewInteraction({
101
101
  const wasPlaying = usePlayerStore.getState().isPlaying;
102
102
  pausePreviewPlayback();
103
103
  // A click that resolves to nothing (dead-zone / deselect) shouldn't leave
104
- // playback paused pausing before sampling only exists to keep the hit
105
- // target stable while resolving; resume if nothing was selected.
104
+ // playback paused; the pause only keeps the hit target stable while resolving.
105
+ // Resume through requestPlayback so adapter, rAF loop and flag move together.
106
106
  const resumeIfNothingSelected = () => {
107
- if (wasPlaying) usePlayerStore.getState().setIsPlaying(true);
107
+ if (wasPlaying) usePlayerStore.getState().requestPlayback(true);
108
108
  };
109
109
 
110
110
  // Double-click a group → drill into it and select the child under the
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState, type MutableRefObject } from "react";
1
+ import { useCallback, useEffect, useState } from "react";
2
2
  import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk";
3
3
  import { persistSdkSerialize } from "../utils/sdkCutover";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
@@ -87,7 +87,6 @@ interface EditVariablesDeps {
87
87
  writeProjectFile: (path: string, content: string) => Promise<void>;
88
88
  recordEdit: RecordEditFn;
89
89
  reloadPreview: () => void;
90
- domEditSaveTimestampRef: MutableRefObject<number>;
91
90
  }
92
91
 
93
92
  /**
@@ -98,8 +97,7 @@ interface EditVariablesDeps {
98
97
  * but keyed on `path` rather than a live session.
99
98
  */
100
99
  export function useEditVariablesInFile(deps: EditVariablesDeps) {
101
- const { readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef } =
102
- deps;
100
+ const { readProjectFile, writeProjectFile, recordEdit, reloadPreview } = deps;
103
101
  return useCallback(
104
102
  async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
105
103
  const originalContent = await readProjectFile(path);
@@ -119,13 +117,12 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
119
117
  editHistory: { recordEdit },
120
118
  writeProjectFile,
121
119
  reloadPreview,
122
- domEditSaveTimestampRef,
123
120
  compositionPath: path,
124
121
  readProjectFile,
125
122
  },
126
123
  { label },
127
124
  );
128
125
  },
129
- [readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef],
126
+ [readProjectFile, writeProjectFile, recordEdit, reloadPreview],
130
127
  );
131
128
  }
@@ -117,7 +117,6 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
117
117
  disk[path] = content;
118
118
  },
119
119
  recordEdit: store.recordEdit,
120
- domEditSaveTimestampRef: { current: 0 },
121
120
  reloadPreview: () => {
122
121
  if (!opts.previewStamp) return;
123
122
  const stamped = ensureHfIds(disk["index.html"]);
@@ -258,7 +257,6 @@ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
258
257
  disk[path] = content;
259
258
  },
260
259
  recordEdit: store.recordEdit,
261
- domEditSaveTimestampRef: { current: 0 },
262
260
  reloadPreview: () => {},
263
261
  });
264
262
  splitAllRef.current = handleRazorSplitAll;
@@ -88,7 +88,6 @@ function mountRazorSplit(): Harness {
88
88
  disk[path] = content;
89
89
  },
90
90
  recordEdit: async () => {},
91
- domEditSaveTimestampRef: { current: 0 },
92
91
  reloadPreview: () => {},
93
92
  });
94
93
  singleRef.current = handleRazorSplit;
@@ -211,7 +210,6 @@ function mountRazorSplitWithHistory(): UndoHarness {
211
210
  disk[path] = content;
212
211
  },
213
212
  recordEdit: (input) => store.recordEdit(input),
214
- domEditSaveTimestampRef: { current: 0 },
215
213
  reloadPreview: () => {},
216
214
  forceReloadSdkSession,
217
215
  });
@@ -66,7 +66,6 @@ describe("useRazorSplit mutation versions", () => {
66
66
  writeProjectFile,
67
67
  observeProjectFileVersion,
68
68
  recordEdit,
69
- domEditSaveTimestampRef: { current: 0 },
70
69
  reloadPreview: vi.fn(),
71
70
  }).handleRazorSplit;
72
71
  return null;
@@ -15,7 +15,6 @@ interface UseRazorSplitOptions {
15
15
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
16
16
  observeProjectFileVersion?: (path: string, version: string | null) => void;
17
17
  recordEdit: (input: RecordEditInput) => Promise<void>;
18
- domEditSaveTimestampRef: React.MutableRefObject<number>;
19
18
  reloadPreview: () => void;
20
19
  forceReloadSdkSession?: () => void;
21
20
  isRecordingRef?: React.RefObject<boolean>;
@@ -28,7 +27,6 @@ export function useRazorSplit({
28
27
  writeProjectFile,
29
28
  observeProjectFileVersion,
30
29
  recordEdit,
31
- domEditSaveTimestampRef,
32
30
  reloadPreview,
33
31
  forceReloadSdkSession,
34
32
  isRecordingRef,
@@ -62,9 +60,6 @@ export function useRazorSplit({
62
60
  ? "Split timeline clip"
63
61
  : `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`;
64
62
 
65
- // Server writes arrive through the watcher before React can refresh. Keep
66
- // the existing short self-write window active for this owned transaction.
67
- domEditSaveTimestampRef.current = Date.now();
68
63
  const result = await runAtomicCutTransaction({
69
64
  projectId: pid,
70
65
  intents,
@@ -91,7 +86,6 @@ export function useRazorSplit({
91
86
  },
92
87
  [
93
88
  activeCompPath,
94
- domEditSaveTimestampRef,
95
89
  observeProjectFileVersion,
96
90
  recordEdit,
97
91
  showToast,
@@ -1,7 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { openComposition } from "@hyperframes/sdk";
3
3
  import type { Composition } from "@hyperframes/sdk";
4
- import { readStudioFileChangePath } from "../components/editor/manualEdits";
5
4
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
6
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
@@ -29,15 +28,6 @@ async function readProjectFileOptional(
29
28
  return typeof data.content === "string" ? data.content : undefined;
30
29
  }
31
30
 
32
- /**
33
- * True when an external file-change payload targets the active composition and
34
- * the SDK session must be re-opened to pick up the new content.
35
- */
36
- export function shouldReloadSdkSession(payload: unknown, activeCompPath: string | null): boolean {
37
- if (!activeCompPath) return false;
38
- return readStudioFileChangePath(payload) === activeCompPath;
39
- }
40
-
41
31
  /**
42
32
  * Stage 7 Step 3a — SDK session wired to the active composition.
43
33
  *
@@ -73,7 +63,9 @@ export interface SdkSessionHandle {
73
63
  /**
74
64
  * Force a session reload immediately, bypassing the self-write suppress
75
65
  * window. Call after undo/redo writes the active composition file so the
76
- * SDK in-memory document reflects the reverted content.
66
+ * SDK in-memory document reflects the reverted content. Without it the
67
+ * window swallows the file-change and the session stays stale; the write
68
+ * side of that path is covered by usePersistentEditHistory.test.ts.
77
69
  */
78
70
  forceReload: () => void;
79
71
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, type MutableRefObject } from "react";
1
+ import { useCallback } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { SlideshowManifest } from "@hyperframes/core/slideshow";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
@@ -16,7 +16,6 @@ export interface UseSlideshowPersistParams {
16
16
  files: Record<string, { before: string; after: string }>;
17
17
  }) => Promise<void>;
18
18
  reloadPreview: () => void;
19
- domEditSaveTimestampRef: MutableRefObject<number>;
20
19
  /** Publish a fully persisted candidate SDK session. */
21
20
  publishSdkSession?: PublishSdkSession;
22
21
  /**
@@ -35,7 +34,6 @@ export function useSlideshowPersist({
35
34
  writeProjectFile,
36
35
  recordEdit,
37
36
  reloadPreview,
38
- domEditSaveTimestampRef,
39
37
  publishSdkSession,
40
38
  coalesceKey,
41
39
  }: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
@@ -52,7 +50,6 @@ export function useSlideshowPersist({
52
50
  editHistory: { recordEdit },
53
51
  writeProjectFile,
54
52
  reloadPreview,
55
- domEditSaveTimestampRef,
56
53
  readProjectFile,
57
54
  publishSession: publishSdkSession,
58
55
  },
@@ -66,7 +63,6 @@ export function useSlideshowPersist({
66
63
  writeProjectFile,
67
64
  recordEdit,
68
65
  reloadPreview,
69
- domEditSaveTimestampRef,
70
66
  publishSdkSession,
71
67
  coalesceKey,
72
68
  ],
@@ -29,7 +29,6 @@ interface UseTimelineAssetDropOpsOptions {
29
29
  showToast: (message: string, tone?: "error" | "info") => void;
30
30
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
31
31
  recordEdit: (input: RecordEditInput) => Promise<void>;
32
- domEditSaveTimestampRef: MutableRefObject<number>;
33
32
  reloadPreview: () => void;
34
33
  uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
35
34
  isRecordingRef?: RefObject<boolean>;
@@ -44,7 +43,6 @@ export function useTimelineAssetDropOps({
44
43
  showToast,
45
44
  writeProjectFile,
46
45
  recordEdit,
47
- domEditSaveTimestampRef,
48
46
  reloadPreview,
49
47
  uploadProjectFiles,
50
48
  isRecordingRef,
@@ -109,7 +107,6 @@ export function useTimelineAssetDropOps({
109
107
  }),
110
108
  );
111
109
 
112
- domEditSaveTimestampRef.current = Date.now();
113
110
  await saveProjectFilesWithHistory({
114
111
  projectId: pid,
115
112
  label: "Add timeline asset",
@@ -135,7 +132,6 @@ export function useTimelineAssetDropOps({
135
132
  showToast,
136
133
  timelineElements,
137
134
  writeProjectFile,
138
- domEditSaveTimestampRef,
139
135
  reloadPreview,
140
136
  isRecordingRef,
141
137
  forceReloadSdkSession,
@@ -198,7 +194,6 @@ export function useTimelineAssetDropOps({
198
194
  resync: forceReloadSdkSession,
199
195
  refresh: reloadPreview,
200
196
  });
201
- domEditSaveTimestampRef.current = Date.now();
202
197
  showToast("Composition added to the timeline.", "info");
203
198
  } catch (error) {
204
199
  showToast(
@@ -209,7 +204,6 @@ export function useTimelineAssetDropOps({
209
204
  },
210
205
  [
211
206
  activeCompPath,
212
- domEditSaveTimestampRef,
213
207
  forceReloadSdkSession,
214
208
  isRecordingRef,
215
209
  observeProjectFileVersion,
@@ -20,7 +20,6 @@ interface UseTimelineDeleteOpsOptions {
20
20
  showToast: (message: string, tone?: "error" | "info") => void;
21
21
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
22
22
  recordEdit: (input: RecordEditInput) => Promise<void>;
23
- domEditSaveTimestampRef: MutableRefObject<number>;
24
23
  reloadPreview: () => void;
25
24
  isRecordingRef?: MutableRefObject<boolean>;
26
25
  forceReloadSdkSession?: () => void;
@@ -34,7 +33,6 @@ export function useTimelineDeleteOps({
34
33
  showToast,
35
34
  writeProjectFile,
36
35
  recordEdit,
37
- domEditSaveTimestampRef,
38
36
  reloadPreview,
39
37
  isRecordingRef,
40
38
  forceReloadSdkSession,
@@ -111,7 +109,6 @@ export function useTimelineDeleteOps({
111
109
  usePlayerStore.getState().setDuration(deleteContentEnd);
112
110
  }
113
111
 
114
- domEditSaveTimestampRef.current = Date.now();
115
112
  try {
116
113
  await saveProjectFilesWithHistory({
117
114
  projectId: pid,
@@ -154,7 +151,6 @@ export function useTimelineDeleteOps({
154
151
  showToast,
155
152
  timelineElements,
156
153
  writeProjectFile,
157
- domEditSaveTimestampRef,
158
154
  reloadPreview,
159
155
  isRecordingRef,
160
156
  forceReloadSdkSession,
@@ -147,7 +147,6 @@ function renderTimelineEditingHook(input: {
147
147
  showToast: input.showToast ?? vi.fn(),
148
148
  writeProjectFile: input.writeProjectFile ?? vi.fn(),
149
149
  recordEdit: input.recordEdit ?? vi.fn(),
150
- domEditSaveTimestampRef: { current: 0 },
151
150
  reloadPreview: input.reloadPreview ?? vi.fn(),
152
151
  previewIframeRef: { current: input.iframe },
153
152
  pendingTimelineEditPathRef: { current: new Set<string>() },
@@ -201,7 +200,6 @@ function renderTimelineEditingHookWithLifecycle(input: {
201
200
  activeCompPath: "index.html",
202
201
  showToast: vi.fn(),
203
202
  writeProjectFile: vi.fn(),
204
- domEditSaveTimestampRef: { current: 0 },
205
203
  editHistory: { recordEdit: vi.fn() },
206
204
  projectIdRef: { current: "p1" },
207
205
  reloadPreview: vi.fn(),
@@ -217,7 +215,6 @@ function renderTimelineEditingHookWithLifecycle(input: {
217
215
  showToast: vi.fn(),
218
216
  writeProjectFile: vi.fn(),
219
217
  recordEdit: vi.fn(),
220
- domEditSaveTimestampRef: { current: 0 },
221
218
  reloadPreview: vi.fn(),
222
219
  previewIframeRef: { current: input.iframe },
223
220
  pendingTimelineEditPathRef: { current: new Set<string>() },