@hyperframes/studio 0.8.116 → 0.8.117

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 (38) hide show
  1. package/dist/assets/{hyperframes-player-mGYvPVpw.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-DYNaXPVf.js → index-C5qKdpgl.js} +178 -176
  3. package/dist/assets/{index-BWHt0-ir.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-Dr3hNpvS.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/index.d.ts +28 -19
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +77 -64
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditOverlay.tsx +1 -0
  11. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  12. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  13. package/src/components/editor/manualOffsetDrag.ts +3 -3
  14. package/src/components/editor/plainTranslate.ts +5 -3
  15. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  16. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  17. package/src/components/editor/rotationDraft.ts +2 -1
  18. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  19. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  20. package/src/hooks/gsapScriptCommitTypes.ts +1 -1
  21. package/src/hooks/plainRotation.ts +5 -1
  22. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  23. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  24. package/src/hooks/useDomGeometryCommit.ts +6 -9
  25. package/src/hooks/useFreezeFrame.test.ts +39 -3
  26. package/src/hooks/useFreezeFrame.ts +4 -1
  27. package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
  28. package/src/hooks/useGsapAwareEditing.ts +12 -8
  29. package/src/hooks/useGsapAwareGroupMove.ts +2 -2
  30. package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
  31. package/src/hooks/useGsapScriptCommits.ts +9 -7
  32. package/src/hooks/usePersistentEditHistory.test.ts +25 -2
  33. package/src/hooks/usePersistentEditHistory.ts +3 -9
  34. package/src/utils/gsapSoftReload.test.ts +39 -0
  35. package/src/utils/gsapSoftReload.ts +7 -11
  36. package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
  37. package/src/utils/softReloadTargets.ts +4 -3
  38. package/src/utils/studioFileHistory.ts +1 -0
@@ -1,6 +1,9 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
1
4
  import { afterEach, describe, expect, it, vi } from "vitest";
2
5
  import type { TimelineElement } from "../player";
3
- import { requestFreezeFrame } from "./useFreezeFrame";
6
+ import { requestFreezeFrame, useFreezeFrame } from "./useFreezeFrame";
4
7
 
5
8
  afterEach(() => vi.unstubAllGlobals());
6
9
 
@@ -29,14 +32,15 @@ function stubFetch(freezeResponse: { status: number; body: unknown }) {
29
32
 
30
33
  describe("requestFreezeFrame", () => {
31
34
  it("posts the clip target, file version and an authored-time playhead", async () => {
32
- const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
35
+ const frozen = { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/t.png" };
36
+ const calls = stubFetch({ status: 200, body: frozen });
33
37
  const result = await requestFreezeFrame({
34
38
  projectId: "p",
35
39
  path: "scene.html",
36
40
  element,
37
41
  playhead: 7.5,
38
42
  });
39
- expect(result).toEqual({ before: "a", after: "b", version: "v2" });
43
+ expect(result).toEqual(frozen);
40
44
  const post = calls[1];
41
45
  expect(post?.url).toContain("/file-mutations/freeze-frame");
42
46
  expect(post?.body).toMatchObject({
@@ -54,3 +58,35 @@ describe("requestFreezeFrame", () => {
54
58
  ).rejects.toThrow("Move the playhead inside a video clip to freeze");
55
59
  });
56
60
  });
61
+
62
+ describe("useFreezeFrame", () => {
63
+ it("records the still it made with the edit, so Undo removes it", async () => {
64
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65
+ stubFetch({
66
+ status: 200,
67
+ body: { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/talk.png" },
68
+ });
69
+ const recordEdit = vi.fn(async () => {});
70
+ let freeze!: ReturnType<typeof useFreezeFrame>;
71
+ function Harness() {
72
+ freeze = useFreezeFrame({
73
+ projectId: "p",
74
+ activeCompPath: "index.html",
75
+ showToast: () => {},
76
+ writeProjectFile: async () => {},
77
+ recordEdit,
78
+ reloadPreview: () => {},
79
+ });
80
+ return null;
81
+ }
82
+ const root = createRoot(document.createElement("div"));
83
+ await act(async () => root.render(createElement(Harness)));
84
+ await act(() => freeze({ ...element, sourceFile: "index.html" }, 7.5));
85
+ expect(recordEdit).toHaveBeenCalledWith({
86
+ label: "Freeze frame",
87
+ files: { "index.html": { before: "a", after: "b" } },
88
+ created: ["assets/freeze/talk.png"],
89
+ });
90
+ act(() => root.unmount());
91
+ });
92
+ });
@@ -14,6 +14,7 @@ interface FreezeFrameResponse {
14
14
  before: string;
15
15
  after: string;
16
16
  version: string;
17
+ stillPath: string;
17
18
  }
18
19
 
19
20
  function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
@@ -22,7 +23,8 @@ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
22
23
  return (
23
24
  typeof body.before === "string" &&
24
25
  typeof body.after === "string" &&
25
- typeof body.version === "string"
26
+ typeof body.version === "string" &&
27
+ typeof body.stillPath === "string"
26
28
  );
27
29
  }
28
30
 
@@ -109,6 +111,7 @@ export function useFreezeFrame(options: UseFreezeFrameOptions) {
109
111
  await opts.recordEdit({
110
112
  label: "Freeze frame",
111
113
  files: { [path]: { before: result.before, after: result.after } },
114
+ created: [result.stillPath],
112
115
  });
113
116
  opts.observeProjectFileVersion?.(path, result.version);
114
117
  });
@@ -74,6 +74,7 @@ function mountResizeHandler(
74
74
  size: { width: number; height: number },
75
75
  offset?: { x: number; y: number },
76
76
  restore?: () => void,
77
+ route?: { plainTranslate: boolean },
77
78
  ) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
78
79
  | null = null;
79
80
  let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
@@ -234,6 +235,21 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
234
235
  });
235
236
  });
236
237
 
238
+ describe("useGsapAwareEditing keeps the box route a resize chose at press", () => {
239
+ it.each([
240
+ ["the CSS route, on an element GSAP has since taken over", true],
241
+ ["the GSAP route, on an element that now looks GSAP-free", false],
242
+ ])("%s", async (_, plainTranslate) => {
243
+ mocks.resize.mockResolvedValue({ status: "persisted" });
244
+ const h = mountResizeHandler([], [], plainTranslate);
245
+ const size = { width: 300, height: 200 };
246
+ await act(() => h.resize(h.selection, size, undefined, vi.fn(), { plainTranslate }));
247
+ expect(h.fallback).toHaveBeenCalledTimes(plainTranslate ? 1 : 0);
248
+ expect(mocks.resize).toHaveBeenCalledTimes(plainTranslate ? 0 : 1);
249
+ act(() => h.root.unmount());
250
+ });
251
+ });
252
+
237
253
  describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
238
254
  it("writes no member when one CSS-route member has been folded since the press", async () => {
239
255
  const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
@@ -296,6 +312,15 @@ describe("useGsapAwareEditing rotation routing", () => {
296
312
  act(() => h.root.unmount());
297
313
  });
298
314
 
315
+ it("saves a turn the press sent down the GSAP route there, even once the element looks GSAP-free", async () => {
316
+ vi.mocked(tryGsapRotationIntercept).mockResolvedValue({ status: "persisted" });
317
+ const h = rotate(document.createElement("div"));
318
+ await act(() => h.rotationCommit(h.box, { angle: 55, plain: null }));
319
+ expect(tryGsapRotationIntercept).toHaveBeenCalledTimes(1);
320
+ expect(h.handleDomRotationCommit).not.toHaveBeenCalled();
321
+ act(() => h.root.unmount());
322
+ });
323
+
299
324
  it("saves a turn the press drew as CSS by the CSS writer, even once a GSAP tween turns the element", async () => {
300
325
  const element = document.createElement("div");
301
326
  const h = rotate(element);
@@ -30,9 +30,7 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
30
30
  import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
31
31
  import { runGestureTransaction } from "./gestureTransaction";
32
32
  import {
33
- gsapWritesBox,
34
- gsapWritesPosition,
35
- gsapWritesRotation,
33
+ editsPlainCss,
36
34
  hasNonHoldTweenForElement,
37
35
  POSITION_CHANNELS,
38
36
  } from "./gsapRuntimeKeyframes";
@@ -151,7 +149,7 @@ export function useGsapAwareEditing({
151
149
  modifiers?: MoveCommitOptions,
152
150
  ) => {
153
151
  const writes = observeGsapGesture(gsapCommitMutation);
154
- if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) {
152
+ if (modifiers?.plainTranslate ?? editsPlainCss(selection.element, "move")) {
155
153
  const result = await stageElementPositionOffset(selection, next, true).save();
156
154
  return writes.finish(result?.changed === true);
157
155
  }
@@ -207,9 +205,10 @@ export function useGsapAwareEditing({
207
205
  next: { width: number; height: number },
208
206
  offset?: { x: number; y: number },
209
207
  restore: () => void = () => undefined,
208
+ route?: { plainTranslate: boolean },
210
209
  ) => {
211
210
  const writes = observeGsapGesture(gsapCommitMutation);
212
- if (!gsapWritesBox(selection.element)) {
211
+ if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
213
212
  const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
214
213
  return writes.finish(result?.changed === true);
215
214
  }
@@ -258,8 +257,13 @@ export function useGsapAwareEditing({
258
257
  );
259
258
  // Saved after the size, under its undo key, so the two are one step.
260
259
  await saveMove(dragOutcome, async () => {
261
- const plain = !gsapWritesPosition(selection.element);
262
- anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
260
+ const plainAfterSettle = editsPlainCss(selection.element, "move");
261
+ anchorMove = stageElementPositionOffset(
262
+ selection,
263
+ offset,
264
+ plainAfterSettle,
265
+ coalesceKey,
266
+ );
263
267
  });
264
268
  }
265
269
  };
@@ -360,7 +364,7 @@ export function useGsapAwareEditing({
360
364
  const handleGsapAwareRotationCommit = useCallback(
361
365
  async (selection: DomEditSelection, next: RotationCommit) => {
362
366
  const writes = observeGsapGesture(gsapCommitMutation);
363
- if (next.plain || !gsapWritesRotation(selection.element)) {
367
+ if (next.plain === undefined ? editsPlainCss(selection.element, "rotate") : next.plain) {
364
368
  const result = await handleDomRotationCommit(selection, next);
365
369
  return writes.finish(result?.changed === true);
366
370
  }
@@ -5,7 +5,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes";
6
6
  import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing";
7
7
  import { observeGsapGesture } from "./gsapGestureOutcome";
8
- import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
8
+ import { editsPlainCss } from "./gsapRuntimeKeyframes";
9
9
  import { refuseGsapTakeover } from "./elementOffsetStager";
10
10
  import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
11
11
  import { assertGsapEditPersisted } from "./gsapEditOutcome";
@@ -81,7 +81,7 @@ export function useGsapAwareGroupMove({
81
81
  // write nothing and share one in-flight parse per file, so they run together.
82
82
  const preflightResults = await Promise.allSettled(
83
83
  updates.map(async ({ selection, next, plainTranslate }) => {
84
- if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
84
+ if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
85
85
  refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
86
86
  return void offsetMembers.set(selection, true);
87
87
  }
@@ -30,7 +30,11 @@ vi.mock("../utils/studioTelemetry", () => ({
30
30
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
31
31
 
32
32
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
33
- import type { MutationResult } from "./gsapScriptCommitTypes";
33
+ import type {
34
+ CommitMutationCall,
35
+ CommitMutationOptions,
36
+ MutationResult,
37
+ } from "./gsapScriptCommitTypes";
34
38
  import { persistSdkSerialize } from "../utils/sdkCutover";
35
39
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
36
40
  import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
@@ -211,6 +215,34 @@ describe("applyPreviewSync", () => {
211
215
  expect(applySoftReload).toHaveBeenCalledTimes(1);
212
216
  });
213
217
 
218
+ it("carries a deferred write with no instant patch into the final batch render", () => {
219
+ const previewFallbackLatch = { pending: false };
220
+ applySoftReload.mockReturnValue("applied");
221
+ patchRuntimeTweenInPlace.mockReturnValue(true);
222
+ const group = {
223
+ label: "Move animated layer (group)",
224
+ softReload: true,
225
+ previewFallbackLatch,
226
+ };
227
+
228
+ applyPreviewSync(
229
+ FAKE_IFRAME,
230
+ result({ scriptText: "SCRIPT" }),
231
+ { ...group, deferPreviewSync: true },
232
+ vi.fn(),
233
+ );
234
+ expect(previewFallbackLatch.pending).toBe(true);
235
+ expect(applySoftReload).not.toHaveBeenCalled();
236
+
237
+ applyPreviewSync(
238
+ FAKE_IFRAME,
239
+ result({ scriptText: "SCRIPT" }),
240
+ { ...group, instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } } },
241
+ vi.fn(),
242
+ );
243
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
244
+ });
245
+
214
246
  it("falls back immediately when a deferred patch miss has no final-render latch", () => {
215
247
  patchRuntimeTweenInPlace.mockReturnValue(false);
216
248
  applySoftReload.mockReturnValue("applied");
@@ -416,6 +448,16 @@ function renderCommitHook(
416
448
 
417
449
  const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection;
418
450
 
451
+ async function commitBatch(calls: CommitMutationCall[], options: CommitMutationOptions) {
452
+ const deps = renderCommitHook();
453
+ const batch = deps.api.commitMutation.batch;
454
+ if (!batch) throw new Error("batch capability missing");
455
+ await act(async () => {
456
+ await batch(calls, options);
457
+ });
458
+ return deps;
459
+ }
460
+
419
461
  function mockFetchResult(over: Partial<MutationResult> = {}): void {
420
462
  const body: MutationResult = {
421
463
  ok: true,
@@ -553,6 +595,32 @@ describe("runCommit — instantPatch wiring", () => {
553
595
  expect(deps.reloadPreview).not.toHaveBeenCalled();
554
596
  });
555
597
 
598
+ it("batch where a write without an instant patch soft-reloads instead of patching the rest", async () => {
599
+ // A corner resize saves size (no in-place patch) and position (patched) in one batch.
600
+ patchRuntimeTweenInPlace.mockReturnValue(true);
601
+ applySoftReload.mockReturnValue("applied");
602
+ mockFetchResult({ changed: true });
603
+ const position = { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } };
604
+ const deps = await commitBatch(
605
+ [
606
+ {
607
+ selection,
608
+ mutation: { type: "update-property", property: "width", value: 300 },
609
+ options: { label: "Resize layer" },
610
+ },
611
+ {
612
+ selection,
613
+ mutation: { type: "update-property", property: "x", value: 10 },
614
+ options: { label: "Resize layer", instantPatch: position },
615
+ },
616
+ ],
617
+ { label: "Resize layer", softReload: true },
618
+ );
619
+
620
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
621
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
622
+ });
623
+
556
624
  it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
557
625
  // Server contract: gsap-mutations returns scriptText on EVERY response,
558
626
  // including changed:false — so the fallback re-runs the identical script
@@ -268,7 +268,8 @@ export function applyPreviewSync(
268
268
  nestedFiles?: Map<string, string> | null,
269
269
  ): void {
270
270
  const patches = instantPatchesFor(options);
271
- let needsFallback = options.previewFallbackLatch?.pending === true;
271
+ const writtenWithoutPatch = patches.length === 0;
272
+ let needsFallback = options.previewFallbackLatch?.pending === true || writtenWithoutPatch;
272
273
  if (patches.length > 0) {
273
274
  const deferSeek = options.deferPreviewSync === true;
274
275
  const missed = patches.find(
@@ -410,13 +411,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
410
411
  if (!result) return;
411
412
  trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
412
413
  options.onResult?.(result);
413
- // Each call brings its own fast-path patch; the batch wrote them all, so the
414
- // preview sync applies them all rather than just the last call's.
415
- const instantPatches = calls
416
- .map(({ options: callOptions }) => callOptions.instantPatch)
417
- .filter((patch) => patch !== undefined);
414
+ // The batch wrote every call, so patch in place only when every call brought a patch; one
415
+ // without (a resize's size write) needs the reload, or the preview keeps the old value.
416
+ const instantPatches = calls.flatMap(({ options: callOptions }) =>
417
+ callOptions.instantPatch ? [callOptions.instantPatch] : [],
418
+ );
418
419
  const { instantPatch: _instantPatch, ...batchOptions } = options;
419
- await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, instantPatches.length > 0 ? { ...batchOptions, instantPatches } : batchOptions);
420
+ const allPatched = instantPatches.length === calls.length;
421
+ await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions);
420
422
  }, [showToast, finalizeSuccessfulMutation]);
421
423
 
422
424
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
@@ -2,9 +2,9 @@
2
2
  // fallow-ignore-file code-duplication
3
3
  import { act, createElement } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
- import { join } from "node:path";
7
+ import { dirname, join } from "node:path";
8
8
  import { afterEach, expect, it, vi } from "vitest";
9
9
  import {
10
10
  createStudioApi,
@@ -316,6 +316,29 @@ it("refuses a second press whose edit is undone once an edit was made after the
316
316
  expect(file()).toBe("B");
317
317
  });
318
318
 
319
+ it("a file the edit made (a freeze's still) is deleted by its Undo and put back by its Redo", async () => {
320
+ const { dir, hook, save, readFile } = await studio();
321
+ const still = join(dir, "assets", "freeze", "talk.png");
322
+ mkdirSync(dirname(still), { recursive: true });
323
+ writeFileSync(still, "png");
324
+ save("B");
325
+ await act(() =>
326
+ hook().recordEdit({
327
+ label: "Freeze frame",
328
+ files: { "index.html": { before: "A", after: "B" } },
329
+ created: ["assets/freeze/talk.png"],
330
+ }),
331
+ );
332
+ await vi.waitFor(() => expect(hook().undoLabel).toBe("Freeze frame"));
333
+
334
+ await act(() => hook().undo({ readFile }));
335
+ expect(existsSync(still)).toBe(false);
336
+
337
+ await vi.waitFor(() => expect(hook().canRedo).toBe(true));
338
+ await act(() => hook().redo({ readFile }));
339
+ expect(readFileSync(still, "utf8")).toBe("png");
340
+ });
341
+
319
342
  it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
320
343
  const { hook, file, save, readFile } = await studio();
321
344
  const writes: Array<[before: string, after: string]> = [
@@ -3,13 +3,7 @@ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"
3
3
  import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4
4
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
5
5
  import { studioApiFetch } from "../utils/studioApiFetch";
6
-
7
- interface RecordEditInput {
8
- label: string;
9
- coalesceKey?: string;
10
- coalesceMs?: number;
11
- files: Record<string, { before: string; after: string }>;
12
- }
6
+ import type { RecordEditInput } from "../utils/studioFileHistory";
13
7
 
14
8
  interface ApplyCallbacks {
15
9
  readFile: (path: string) => Promise<string>;
@@ -235,9 +229,9 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
235
229
  }, [refresh, own]);
236
230
 
237
231
  const recordEdit = useCallback(
238
- async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
232
+ async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
239
233
  if (!projectId) return;
240
- const paths = Object.keys(files);
234
+ const paths = [...Object.keys(files), ...created];
241
235
  own.overtake();
242
236
  const reply = await post(historyUrl(projectId, "/claim"), {
243
237
  label,
@@ -481,6 +481,45 @@ describe("applySoftReload authored-style restore", () => {
481
481
  return el.style.getPropertyValue("opacity");
482
482
  }
483
483
 
484
+ it("flushes only elements when a tween targets a plain object (the runtime's duration filler)", () => {
485
+ const el = document.createElement("div");
486
+ // GSAP's clearProps writes target.style.cssText, which throws on a plain object.
487
+ const set = vi.fn((targets: Array<{ style: CSSStyleDeclaration }>) => {
488
+ for (const t of targets) t.style.cssText = "";
489
+ });
490
+ const { iframe } = buildIframeWithTarget(el, {
491
+ gsap: { timeline: vi.fn(), set },
492
+ __timelines: {
493
+ root: {
494
+ kill: vi.fn(),
495
+ getChildren: () => [{ targets: () => [el] }, { targets: () => [{}] }],
496
+ },
497
+ },
498
+ });
499
+
500
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
501
+ expect(set).toHaveBeenCalledWith([el], { clearProps: "all" });
502
+ });
503
+
504
+ it("falls back to a full reload, and says why, when the flush throws", () => {
505
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
506
+ const { iframe } = buildIframeWithTarget(document.createElement("div"), {
507
+ gsap: {
508
+ timeline: vi.fn(),
509
+ set: vi.fn(() => {
510
+ throw new Error("flush failed");
511
+ }),
512
+ },
513
+ });
514
+
515
+ expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
516
+ expect(error).toHaveBeenCalledWith(
517
+ expect.stringContaining("soft reload threw"),
518
+ expect.any(Error),
519
+ );
520
+ error.mockRestore();
521
+ });
522
+
484
523
  it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
485
524
  const el = document.createElement("img");
486
525
  el.setAttribute("data-hf-id", "hf-1");
@@ -152,7 +152,8 @@ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
152
152
  if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
153
153
  else reload();
154
154
  return true;
155
- } catch {
155
+ } catch (error) {
156
+ console.error("[Studio] GSAP soft reload threw; falling back to a full reload", error);
156
157
  return false;
157
158
  }
158
159
  }
@@ -369,16 +370,11 @@ export function applySoftReload(
369
370
  // HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
370
371
  const allTargets = [...targets.keys()];
371
372
  if (allTargets.length > 0 && win.gsap?.set) {
372
- const saved: Array<[HTMLElement, string]> = [];
373
- for (const el of allTargets) {
374
- // Iframe-realm node: instanceof HTMLElement fails across realms, and
375
- // gsap targets() only yields elements here — style access is duck-typed.
376
- const styled = el as HTMLElement;
377
- if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
378
- }
379
- try {
380
- win.gsap.set(allTargets, { clearProps: "all" });
381
- } catch {}
373
+ // Iframe-realm nodes: instanceof HTMLElement fails across realms, so style access is duck-typed.
374
+ const saved = allTargets.map(
375
+ (el) => [el as HTMLElement, (el as HTMLElement).style.cssText] as const,
376
+ );
377
+ win.gsap.set(allTargets, { clearProps: "all" });
382
378
  for (const [el, css] of saved) {
383
379
  const s = el.style;
384
380
  s.cssText = css;
@@ -16,11 +16,9 @@ const CROP = "inset(0px 40px 0px 0px)";
16
16
  vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
17
17
  let browser: Browser;
18
18
  let undoBundle: string;
19
+ let softReloadBundle: string;
19
20
 
20
- beforeAll(async () => {
21
- const executablePath = findSystemChrome();
22
- if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
23
- browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
21
+ async function bundle(file: string, name: string): Promise<string> {
24
22
  const out = await build({
25
23
  configFile: false,
26
24
  logLevel: "silent",
@@ -30,68 +28,85 @@ beforeAll(async () => {
30
28
  build: {
31
29
  write: false,
32
30
  minify: false,
33
- lib: {
34
- entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
35
- formats: ["iife"],
36
- name: "hfUndo",
37
- },
31
+ lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
38
32
  },
39
33
  });
40
- undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
41
- .code;
34
+ return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
35
+ }
36
+
37
+ beforeAll(async () => {
38
+ const executablePath = findSystemChrome();
39
+ if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
40
+ browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
41
+ undoBundle = await bundle("./gsapUndoRestore.ts", "hfUndo");
42
+ softReloadBundle = await bundle("./gsapSoftReload.ts", "hfSoftReload");
42
43
  });
43
44
 
44
45
  afterAll(() => browser?.close());
45
46
 
47
+ type Spec = { gsap: string; placement: string; rotation: number; time: number };
48
+
49
+ /** The bench's root crop fixture, shown at `spec.time` with the module under test loaded. */
50
+ async function openCase(spec: Spec, moduleBundle: string) {
51
+ const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
52
+ writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
53
+ const restored = readFileSync(join(dir, "index.html"), "utf8");
54
+ const page = await browser.newPage();
55
+ await page.setViewport({ width: 1920, height: 1080 });
56
+ await page.setRequestInterception(true);
57
+ // Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
58
+ page.on("request", (request) =>
59
+ request.url().endsWith("/gsap.min.js")
60
+ ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
61
+ : request.abort("blockedbyclient"),
62
+ );
63
+ await page.setContent(restored, { waitUntil: "load" });
64
+ await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
65
+ await page.waitForFunction(() => "__player" in window);
66
+ await page.evaluate(moduleBundle);
67
+ await page.evaluate(
68
+ (time) => (window as unknown as { __player: { seek(t: number): void } }).__player.seek(time),
69
+ spec.time,
70
+ );
71
+ const box = () =>
72
+ page.evaluate(() => {
73
+ const { left, top, width, height } = document
74
+ .getElementById("target")!
75
+ .getBoundingClientRect();
76
+ return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
77
+ });
78
+ return { page, restored, box };
79
+ }
80
+
46
81
  // The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
47
82
  it.each([
48
- { gsap: "hold", placement: "px", rotation: 0 },
49
- { gsap: "tween", placement: "px", rotation: 0 },
50
- { gsap: "tween", placement: "xpercent", rotation: 30 },
83
+ { gsap: "hold", placement: "px", rotation: 0, time: 1 },
84
+ { gsap: "tween", placement: "px", rotation: 0, time: 1 },
85
+ { gsap: "tween", placement: "xpercent", rotation: 30, time: 1 },
86
+ { gsap: "keys", placement: "px", rotation: 0, time: 1 },
87
+ { gsap: "keys", placement: "px", rotation: 30, time: 2 },
88
+ { gsap: "scale", placement: "px", rotation: 0, time: 1 },
89
+ { gsap: "spin", placement: "px", rotation: 30, time: 1 },
90
+ { gsap: "fromto", placement: "px", rotation: 0, time: 1 },
51
91
  ])(
52
- "an undone crop leaves a $gsap $placement r$rotation layer where it was",
92
+ "an undone crop leaves a $gsap $placement r$rotation layer at t$time where it was",
53
93
  async (spec) => {
54
- const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
55
- writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
56
- const restored = readFileSync(join(dir, "index.html"), "utf8");
94
+ const { page, restored, box } = await openCase(spec, undoBundle);
57
95
  const previous = restored.replace(
58
96
  `<div id="target"`,
59
97
  `<div id="target" style="clip-path: ${CROP}"`,
60
98
  );
61
- const page = await browser.newPage();
62
- await page.setViewport({ width: 1920, height: 1080 });
63
- await page.setRequestInterception(true);
64
- // Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
65
- page.on("request", (request) =>
66
- request.url().endsWith("/gsap.min.js")
67
- ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
68
- : request.abort("blockedbyclient"),
69
- );
70
- await page.setContent(restored, { waitUntil: "load" });
71
- await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
72
- await page.waitForFunction(() => "__player" in window);
73
- await page.evaluate(undoBundle);
74
-
75
- const box = () =>
76
- page.evaluate(() => {
77
- const { left, top, width, height } = document
78
- .getElementById("target")!
79
- .getBoundingClientRect();
80
- return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
81
- });
82
- await page.evaluate(() =>
83
- (window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
84
- );
85
99
  const before = await box();
86
100
  const outcome = await page.evaluate(
87
- (files, crop) => {
101
+ (files, crop, time) => {
88
102
  document.getElementById("target")!.style.setProperty("clip-path", crop);
89
103
  const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
90
104
  const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
91
- return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
105
+ return undo.applyUndoRestoreToPreview(iframe, "index.html", files, time, () => {});
92
106
  },
93
107
  { "index.html": { previous, restored } },
94
108
  CROP,
109
+ spec.time,
95
110
  );
96
111
 
97
112
  expect(outcome).toBe("soft");
@@ -100,3 +115,35 @@ it.each([
100
115
  },
101
116
  30_000,
102
117
  );
118
+
119
+ // A drop saves the file and soft-reloads it; the re-run must place an animated layer where it was.
120
+ it.each([
121
+ { gsap: "tween", placement: "px", rotation: 0, time: 1 },
122
+ { gsap: "keys", placement: "px", rotation: 30, time: 2 },
123
+ { gsap: "spin", placement: "px", rotation: 0, time: 1 },
124
+ ])(
125
+ "a soft reload keeps a $gsap $placement r$rotation layer at t$time where it was",
126
+ async (spec) => {
127
+ const { page, restored, box } = await openCase(spec, softReloadBundle);
128
+ const before = await box();
129
+ const outcome = await page.evaluate(
130
+ (html, time) => {
131
+ const soft = (window as unknown as { hfSoftReload: typeof import("./gsapSoftReload") })
132
+ .hfSoftReload;
133
+ const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
134
+ const script = soft.extractGsapScriptText(html)!;
135
+ return soft.applySoftReload(iframe, script, {
136
+ currentTimeOverride: time,
137
+ authoredHtml: html,
138
+ });
139
+ },
140
+ restored,
141
+ spec.time,
142
+ );
143
+
144
+ expect(outcome).toBe("applied");
145
+ expect(await box()).toEqual(before);
146
+ await page.close();
147
+ },
148
+ 30_000,
149
+ );