@hyperframes/studio 0.8.143 → 0.8.145

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 (72) hide show
  1. package/dist/assets/{hyperframes-player-BXSuksFh.js → hyperframes-player-DZ5JrMDg.js} +3 -3
  2. package/dist/assets/{index-FgyO_5Kv.js → index-BOs6C0aZ.js} +1 -1
  3. package/dist/assets/{index-BgOVCqjo.js → index-BqFRhNZa.js} +1 -1
  4. package/dist/assets/{index-J7jpL4pj.js → index-D8W0a0Z9.js} +149 -149
  5. package/dist/{chunk-TFLU2MGI.js → chunk-6JY3MTIX.js} +108 -2
  6. package/dist/chunk-6JY3MTIX.js.map +1 -0
  7. package/dist/{chunk-H2DUOXTF.js → chunk-NZPJUZKT.js} +100 -4
  8. package/dist/chunk-NZPJUZKT.js.map +1 -0
  9. package/dist/{chunk-UYWON5AX.js → chunk-TP6D3U37.js} +7 -100
  10. package/dist/chunk-TP6D3U37.js.map +1 -0
  11. package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
  12. package/dist/chunk-Y2OHHNM2.js.map +1 -0
  13. package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PTP2ICGM.js} +3 -3
  14. package/dist/index.d.ts +8 -5
  15. package/dist/index.html +1 -1
  16. package/dist/index.js +545 -495
  17. package/dist/index.js.map +1 -1
  18. package/dist/player.js +3 -3
  19. package/dist/ui.d.ts +4 -5
  20. package/dist/ui.js +1 -1
  21. package/package.json +8 -8
  22. package/src/components/StudioLeftPanels.tsx +2 -2
  23. package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
  24. package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
  25. package/src/components/editor/LayersPanel.test.ts +4 -4
  26. package/src/components/editor/LayersPanel.tsx +2 -2
  27. package/src/components/editor/TopologyLens.test.tsx +5 -4
  28. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
  29. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  30. package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
  31. package/src/components/editor/domEditResizeLocal.test.ts +81 -1
  32. package/src/components/editor/domEditResizeLocal.ts +27 -3
  33. package/src/components/editor/domEditingLayers.ts +4 -1
  34. package/src/components/editor/domEditingTextFields.test.ts +18 -0
  35. package/src/components/editor/manualEditsDom.ts +12 -12
  36. package/src/components/editor/previewGestureStarts.ts +14 -5
  37. package/src/components/editor/resizeDraft.ts +13 -4
  38. package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
  39. package/src/components/renders/GpuEncodingToggle.tsx +28 -0
  40. package/src/components/renders/RenderQueue.test.tsx +20 -2
  41. package/src/components/renders/RenderQueue.tsx +13 -1
  42. package/src/components/renders/RenderQueuePanel.tsx +2 -1
  43. package/src/components/renders/renderSettings.ts +5 -2
  44. package/src/components/renders/useRenderQueue.ts +3 -0
  45. package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
  46. package/src/components/ui/Menu.test.tsx +67 -5
  47. package/src/components/ui/Menu.tsx +21 -4
  48. package/src/components/ui/Popover.tsx +11 -5
  49. package/src/hooks/gsapResizeIntercept.test.ts +46 -11
  50. package/src/hooks/gsapResizeIntercept.ts +25 -20
  51. package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
  52. package/src/hooks/useConsoleErrorCapture.ts +3 -2
  53. package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
  54. package/src/hooks/useThumbnailStripSize.ts +19 -8
  55. package/src/index.ts +1 -0
  56. package/src/player/components/CompositionThumbnail.test.ts +3 -0
  57. package/src/player/components/ImageThumbnail.test.tsx +4 -1
  58. package/src/player/components/Player.test.ts +44 -0
  59. package/src/player/components/Player.tsx +11 -0
  60. package/src/player/components/VideoThumbnail.test.tsx +13 -2
  61. package/src/player/hooks/timelineSyncHydration.ts +2 -1
  62. package/src/player/lib/playbackTypes.ts +1 -0
  63. package/src/player/sceneSwap.test.ts +27 -1
  64. package/src/player/sceneSwap.ts +16 -2
  65. package/src/utils/authoredSource.test.ts +25 -0
  66. package/src/utils/authoredSource.ts +3 -5
  67. package/src/utils/rounding.ts +7 -0
  68. package/dist/chunk-H2DUOXTF.js.map +0 -1
  69. package/dist/chunk-LOVBLSMR.js.map +0 -1
  70. package/dist/chunk-TFLU2MGI.js.map +0 -1
  71. package/dist/chunk-UYWON5AX.js.map +0 -1
  72. /package/dist/{domEditingLayers-KBTPQKGA.js.map → domEditingLayers-PTP2ICGM.js.map} +0 -0
@@ -455,10 +455,31 @@ it("does not mix the scale shorthand into a tween that speaks longhands", async
455
455
  * The fixture models the geometry the browser reported: a 630x252 element
456
456
  * dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
457
457
  * a committed scale of 0.837. Scaling about the centre puts it back on the drop
458
- * point at its pre-gesture position, so the correct persisted correction is
459
- * NONE.
458
+ * point at its pre-gesture position, so the persisted correction is only the
459
+ * drop's sub-pixel offset.
460
460
  */
461
- it("does not move a statically positioned element when a scale resize lands", async () => {
461
+ it.each([
462
+ {
463
+ name: "does not move a statically positioned element when a scale resize lands",
464
+ shift: { x: 0, y: 0 },
465
+ },
466
+ {
467
+ name: "puts a statically positioned element on a sub-pixel drop point",
468
+ shift: { x: 0.3, y: -0.2 },
469
+ writes: true,
470
+ },
471
+ {
472
+ name: "leaves a static position alone when the drop is off by less than the file's precision",
473
+ shift: { x: 0.0003, y: 0 },
474
+ writes: false,
475
+ },
476
+ {
477
+ name: "leaves an animated position alone for a sub-pixel drop point",
478
+ shift: { x: 0.3, y: -0.2 },
479
+ animated: true,
480
+ writes: false,
481
+ },
482
+ ])("$name", async ({ shift, animated, writes }) => {
462
483
  document.body.innerHTML = "";
463
484
  const el = document.createElement("div");
464
485
  el.id = "clip";
@@ -476,7 +497,7 @@ it("does not move a statically positioned element when a scale resize lands", as
476
497
  el.style.height = "128px";
477
498
  document.body.append(el);
478
499
 
479
- const pos = { x: 587, y: 235 };
500
+ const pos = { x: 587 + shift.x, y: 235 + shift.y };
480
501
  const scale = { x: 1.648, y: 1.648 };
481
502
  const [LEFT, TOP] = [120, 520];
482
503
  el.getBoundingClientRect = () => {
@@ -517,6 +538,15 @@ it("does not move a statically positioned element when a scale resize lands", as
517
538
  duration: 0,
518
539
  global: true,
519
540
  } as unknown as GsapAnimation;
541
+ const positionTween = {
542
+ ...positionHold,
543
+ id: "#clip-to-0-position",
544
+ method: "to",
545
+ properties: { x: 500 },
546
+ duration: 4,
547
+ global: false,
548
+ } as unknown as GsapAnimation;
549
+ const position = animated ? positionTween : positionHold;
520
550
  const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
521
551
  usePlayerStore.setState({ currentTime: 0.5 });
522
552
  const commitMutation = vi.fn();
@@ -524,22 +554,27 @@ it("does not move a statically positioned element when a scale resize lands", as
524
554
  await tryGsapResizeIntercept(
525
555
  selection,
526
556
  { width: 320, height: 128 },
527
- [keyframedScaleFixture(), positionHold],
557
+ [keyframedScaleFixture(), position],
528
558
  iframe,
529
559
  commitMutation,
530
- async () => [keyframedScaleFixture(), positionHold],
560
+ async () => [keyframedScaleFixture(), position],
531
561
  );
532
562
 
533
563
  const positionWrites = commitMutation.mock.calls
534
564
  .map((call) => call[1] as { properties?: Record<string, number> })
535
565
  .filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
536
- // Either it left the position alone, or it rewrote the same value.
566
+ // No position write, or one that moves the hold by exactly the drop's sub-pixel offset.
567
+ if (writes === false) expect(positionWrites).toEqual([]);
568
+ if (writes) expect(positionWrites.length).toBeGreaterThan(0);
537
569
  for (const write of positionWrites) {
538
- expect(write.properties?.x).toBe(432);
539
- expect(write.properties?.y).toBe(173);
570
+ expect(write.properties?.x).toBeCloseTo(432 + shift.x, 3);
571
+ expect(write.properties?.y).toBeCloseTo(173 + shift.y, 3);
540
572
  }
541
- // And the live element ends on the drop point, not a drag away from it.
542
- expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
573
+ // And the live box is centred where it was dropped (left 120 + 587 + 160, top 520 + 235 + 64).
574
+ const landed = animated ? { x: 0, y: 0 } : shift;
575
+ const box = el.getBoundingClientRect();
576
+ expect(box.x + box.width / 2).toBeCloseTo(867 + landed.x, 3);
577
+ expect(box.y + box.height / 2).toBeCloseTo(819 + landed.y, 3);
543
578
  });
544
579
 
545
580
  function titleSelection(): DomEditSelection {
@@ -170,6 +170,11 @@ function tweenUsesScaleLonghands(anim: GsapAnimation | null): boolean {
170
170
 
171
171
  // ── Resize intercept ──────────────────────────────────────────────────────
172
172
 
173
+ // The resize is centre-anchored, so a scale that cannot reproduce the dragged size splits the gap evenly.
174
+ function rectCentre(rect: DOMRect): { x: number; y: number } {
175
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
176
+ }
177
+
173
178
  // fallow-ignore-next-line complexity
174
179
  export async function tryGsapResizeIntercept(
175
180
  selection: DomEditSelection,
@@ -371,15 +376,10 @@ export async function tryGsapResizeIntercept(
371
376
  committedScale = useScaleLonghands
372
377
  ? { x: newScaleX, y: newScaleY }
373
378
  : { x: newScaleX, y: newScaleX };
374
- // Where the user DROPPED the box: the draft (anchor-pinned to the
375
- // gesture-start top-left) is still applied here, so this rect is exactly
376
- // what the preview showed at release. The committed scale renders around
377
- // the element CENTER instead — the finalize step below measures that
378
- // difference and compensates, so release matches the drop pixel-for-pixel
379
- // regardless of live scale or repeat resizes.
379
+ // Where the user DROPPED the box: the draft is still applied, so this is what the preview showed
380
+ // at release. The finalize step below moves the committed scale's box onto it.
380
381
  if (el) {
381
- const dropRect = draw(() => el.getBoundingClientRect());
382
- scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
382
+ scaleDraftDropPoint = draw(() => rectCentre(el.getBoundingClientRect()));
383
383
  }
384
384
  } else {
385
385
  resizeProps = {
@@ -396,8 +396,8 @@ export async function tryGsapResizeIntercept(
396
396
  // position (a `tl.set` hold or none) — a keyframed position path has no
397
397
  // single anchor to preserve, so it keeps the plain center-scale behavior.
398
398
  // The size route hands its draft to GSAP itself (handOverDraftSize).
399
- // ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
400
- // approximate rather than corner-exact.
399
+ // ponytail: the centre of an AABB is exact for any 2D transform; under
400
+ // perspective it is approximate.
401
401
  // fallow-ignore-next-line complexity
402
402
  const finalizeScaleResizeCommit = async (): Promise<boolean> => {
403
403
  // Only the scale route captures the element, so a null draft means this
@@ -428,15 +428,9 @@ export async function tryGsapResizeIntercept(
428
428
  );
429
429
  const base = { x: baseGsapX, y: baseGsapY };
430
430
  setElementGsapPosition(draftEl, base.x, base.y);
431
- const post = draftEl.getBoundingClientRect();
431
+ const post = rectCentre(draftEl.getBoundingClientRect());
432
432
  const residual = { x: dropPoint.x - post.x, y: dropPoint.y - post.y };
433
433
  if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return null;
434
- if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
435
- logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
436
- // Settled, with nothing to write. Still ours: forwarding the drag offset
437
- // on top would move the box off the point it is already sitting on.
438
- return "settled" as const;
439
- }
440
434
  // The ONE corrected position — rounded once so the live runtime and the
441
435
  // persisted file agree exactly (commitStaticGsapPosition composes the same
442
436
  // rounded value from this delta).
@@ -444,9 +438,15 @@ export async function tryGsapResizeIntercept(
444
438
  x: roundTo3(base.x + residual.x),
445
439
  y: roundTo3(base.y + residual.y),
446
440
  };
441
+ if (corrected.x === roundTo3(base.x) && corrected.y === roundTo3(base.y)) {
442
+ logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
443
+ // Settled, with nothing to write. Still ours: forwarding the drag offset
444
+ // on top would move the box off the point it is already sitting on.
445
+ return "settled" as const;
446
+ }
447
447
  logResize("scale-finalize", {
448
448
  dropPoint,
449
- post: { x: post.x, y: post.y },
449
+ post,
450
450
  residual,
451
451
  gsapPos,
452
452
  base,
@@ -455,11 +455,11 @@ export async function tryGsapResizeIntercept(
455
455
  // Correct the live box in the same task as the measurement, so no frame shows it off the drop
456
456
  // point while the position write is in flight.
457
457
  setElementGsapPosition(draftEl, corrected.x, corrected.y);
458
- return { base, corrected };
458
+ return { base, corrected, residual };
459
459
  });
460
460
  if (measured === null) return false;
461
461
  if (measured === "settled") return true;
462
- const { base, corrected } = measured;
462
+ const { base, corrected, residual } = measured;
463
463
  // Re-fetch: the scale commit above just rewrote the script, so the caller's
464
464
  // animation list (and its ids) may be stale for the position lookup.
465
465
  const currentAnimations = fetchFallbackAnimations
@@ -482,6 +482,11 @@ export async function tryGsapResizeIntercept(
482
482
  moment.time,
483
483
  );
484
484
  if (positionTween) {
485
+ // A sub-pixel correction is not worth a keyframe in an authored tween.
486
+ if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
487
+ draw(() => setElementGsapPosition(draftEl, base.x, base.y));
488
+ return true;
489
+ }
485
490
  logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
486
491
  assertGsapEditPersisted(
487
492
  await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, expect, it } from "vitest";
6
6
  import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
7
+ import { announcePreviewDocumentLoaded } from "../player/sceneSwap";
7
8
  import { useConsoleErrorCapture } from "./useConsoleErrorCapture";
8
9
 
9
10
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -36,7 +37,7 @@ it("shows the errors a preview raised before its load, once each", async () => {
36
37
  root = createRoot(document.createElement("div"));
37
38
  await act(async () => root?.render(React.createElement(Harness, { iframe })));
38
39
  Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
39
- await act(async () => iframe.dispatchEvent(new Event("load")));
40
+ await act(async () => announcePreviewDocumentLoaded(iframe));
40
41
 
41
42
  expect(shown()).toEqual([raisedBeforeLoad]);
42
43
  });
@@ -79,9 +80,27 @@ it("clears a document's errors when its load replaces it, and when the preview g
79
80
  previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
80
81
  );
81
82
  Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
82
- await act(async () => iframe.dispatchEvent(new Event("load")));
83
+ await act(async () => announcePreviewDocumentLoaded(iframe));
83
84
  expect(shown()).toEqual(["Uncaught Error: new"]);
84
85
 
85
86
  await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
86
87
  expect(capture.consoleErrors).toBeNull();
88
+ await act(async () => announcePreviewDocumentLoaded(iframe));
89
+ expect(capture.consoleErrors).toBeNull();
90
+ });
91
+
92
+ it("keeps a page's errors through its own late load, and clears them for the next announced page", async () => {
93
+ const { iframe, previewWindow } = previewFrame();
94
+ root = createRoot(document.createElement("div"));
95
+ await act(async () => root?.render(React.createElement(Harness, { iframe })));
96
+ await act(async () =>
97
+ previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: kept" })),
98
+ );
99
+
100
+ await act(async () => iframe.dispatchEvent(new Event("load")));
101
+ expect(shown()).toEqual(["Uncaught Error: kept"]);
102
+
103
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new page"] });
104
+ await act(async () => announcePreviewDocumentLoaded(iframe));
105
+ expect(shown()).toEqual(["Uncaught Error: new page"]);
87
106
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
3
+ import { onPreviewDocumentLoaded } from "../player/sceneSwap";
3
4
  import type { LintFinding } from "../components/LintModal";
4
5
 
5
6
  /**
@@ -73,9 +74,9 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
73
74
  };
74
75
 
75
76
  attachErrorCapture();
76
- previewIframe.addEventListener("load", attachErrorCapture);
77
+ const stopLoaded = onPreviewDocumentLoaded(previewIframe, attachErrorCapture);
77
78
  return () => {
78
- previewIframe.removeEventListener("load", attachErrorCapture);
79
+ stopLoaded();
79
80
  detachErrorCapture();
80
81
  };
81
82
  }, [previewIframe, resetErrors]);
@@ -18,9 +18,125 @@ vi.mock("../player/components/timelineZoomInput", () => ({
18
18
 
19
19
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
20
20
 
21
+ /** Runs the frames the hook asked for, as the browser does after the observer's delivery. */
22
+ function nextFrames() {
23
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
24
+ return () => act(() => vi.advanceTimersToNextFrame());
25
+ }
26
+
27
+ // A strip that re-rendered inside the observer's delivery resized the timeline Chromium had already measured that
28
+ // frame, which it reported as a ResizeObserver loop on every frame of a trim.
29
+ it("applies a reported size on the next frame, never inside the observer's delivery", () => {
30
+ const originalResizeObserver = globalThis.ResizeObserver;
31
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
32
+ const runFrames = nextFrames();
33
+ const host = document.createElement("div");
34
+ document.body.append(host);
35
+ Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
36
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
37
+ const root = createRoot(host);
38
+ function Harness() {
39
+ const [size, ref] = useThumbnailStripSize();
40
+ return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
41
+ }
42
+ try {
43
+ act(() => root.render(<Harness />));
44
+ reportResize(310, 40);
45
+ reportResize(320, 40);
46
+ expect(vi.getTimerCount()).toBe(1);
47
+ expect(host.textContent).toBe("300x40");
48
+ runFrames();
49
+ expect(host.textContent).toBe("320x40");
50
+ } finally {
51
+ act(() => root.unmount());
52
+ host.remove();
53
+ vi.useRealTimers();
54
+ vi.restoreAllMocks();
55
+ globalThis.ResizeObserver = originalResizeObserver;
56
+ }
57
+ });
58
+
59
+ it("cancels a pending resize frame when the last strip unmounts", () => {
60
+ const originalResizeObserver = globalThis.ResizeObserver;
61
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
62
+ const runFrames = nextFrames();
63
+ const host = document.createElement("div");
64
+ document.body.append(host);
65
+ const root = createRoot(host);
66
+ function Harness() {
67
+ const [, ref] = useThumbnailStripSize();
68
+ return <div ref={ref} />;
69
+ }
70
+ try {
71
+ act(() => root.render(<Harness />));
72
+ reportResize(320, 40);
73
+ expect(vi.getTimerCount()).toBe(1);
74
+ act(() => root.unmount());
75
+ expect(vi.getTimerCount()).toBe(0);
76
+ runFrames();
77
+ } finally {
78
+ act(() => root.unmount());
79
+ host.remove();
80
+ vi.useRealTimers();
81
+ vi.restoreAllMocks();
82
+ globalThis.ResizeObserver = originalResizeObserver;
83
+ }
84
+ });
85
+
86
+ it("drops a pending size when its strip unmounts, so a strip remounted on that box keeps its own", () => {
87
+ const originalResizeObserver = globalThis.ResizeObserver;
88
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
89
+ const runFrames = nextFrames();
90
+ const host = document.createElement("div");
91
+ document.body.append(host);
92
+ const root = createRoot(host);
93
+ function Strip() {
94
+ const [size, ref] = useThumbnailStripSize();
95
+ return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
96
+ }
97
+ function Box({ id }: { id: string }) {
98
+ const box = (element: HTMLDivElement | null) => {
99
+ if (!element) return;
100
+ Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
101
+ Object.defineProperty(element, "clientHeight", { configurable: true, value: 40 });
102
+ };
103
+ return (
104
+ <div ref={box} data-testid="box">
105
+ <Strip key={id} />
106
+ </div>
107
+ );
108
+ }
109
+ // The other strip keeps the shared observer alive across the remount.
110
+ const render = (id: string) =>
111
+ act(() =>
112
+ root.render(
113
+ <>
114
+ <div>
115
+ <Strip />
116
+ </div>
117
+ <Box id={id} />
118
+ </>,
119
+ ),
120
+ );
121
+ try {
122
+ render("a");
123
+ reportResize(320, 40);
124
+ render("b");
125
+ runFrames();
126
+ expect(host.querySelector('[data-testid="box"]')?.textContent).toBe("300x40");
127
+ } finally {
128
+ act(() => root.unmount());
129
+ host.remove();
130
+ vi.useRealTimers();
131
+ vi.restoreAllMocks();
132
+ globalThis.ResizeObserver = originalResizeObserver;
133
+ }
134
+ });
135
+
21
136
  it("does not re-render the strip when the observer reports the size it already holds", () => {
22
137
  const originalResizeObserver = globalThis.ResizeObserver;
23
138
  globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
139
+ const runFrames = nextFrames();
24
140
  const host = document.createElement("div");
25
141
  document.body.append(host);
26
142
  Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
@@ -45,14 +161,18 @@ it("does not re-render the strip when the observer reports the size it already h
45
161
  const settled = stripRenders;
46
162
 
47
163
  act(() => reportResize(300, 40));
164
+ runFrames();
48
165
  expect(stripRenders).toBe(settled);
49
166
 
50
167
  act(() => reportResize(320, 40));
168
+ runFrames();
51
169
  expect(stripRenders).toBe(settled + 1);
52
170
  expect(host.textContent).toBe("320x40");
53
171
  } finally {
54
172
  act(() => root.unmount());
55
173
  host.remove();
174
+ vi.useRealTimers();
175
+ vi.restoreAllMocks();
56
176
  globalThis.ResizeObserver = originalResizeObserver;
57
177
  }
58
178
  });
@@ -60,6 +180,7 @@ it("does not re-render the strip when the observer reports the size it already h
60
180
  it("measures a strip once a zoom preview ends, not while the preview scales it", () => {
61
181
  const originalResizeObserver = globalThis.ResizeObserver;
62
182
  globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
183
+ const runFrames = nextFrames();
63
184
  const host = document.createElement("div");
64
185
  document.body.append(host);
65
186
  let width = 300;
@@ -75,6 +196,7 @@ it("measures a strip once a zoom preview ends, not while the preview scales it",
75
196
  zoom.previewing = true;
76
197
  width = 600;
77
198
  act(() => reportResize(600, 40));
199
+ runFrames();
78
200
  expect(host.textContent).toBe("300x40");
79
201
  zoom.previewing = false;
80
202
  act(() => zoom.listeners.forEach((listener) => listener()));
@@ -83,6 +205,7 @@ it("measures a strip once a zoom preview ends, not while the preview scales it",
83
205
  zoom.previewing = false;
84
206
  act(() => root.unmount());
85
207
  host.remove();
208
+ vi.useRealTimers();
86
209
  globalThis.ResizeObserver = originalResizeObserver;
87
210
  }
88
211
  });
@@ -152,13 +152,20 @@ const measure = (entries: { target: Element; size?: { width: number; height: num
152
152
  const onPresence = (entries: IntersectionObserverEntry[]) =>
153
153
  measure(entries.filter((entry) => entry.isIntersecting));
154
154
 
155
- const onResize = (entries: ResizeObserverEntry[]) =>
156
- measure(
157
- entries.map(({ target, contentRect: { width, height } }) => ({
158
- target,
159
- size: { width, height },
160
- })),
161
- );
155
+ const pendingSizes = new Map<Element, { width: number; height: number }>();
156
+ let pendingSizesFrame = 0;
157
+ const applyPendingSizes = () => {
158
+ pendingSizesFrame = 0;
159
+ const entries = [...pendingSizes].map(([target, size]) => ({ target, size }));
160
+ pendingSizes.clear();
161
+ measure(entries);
162
+ };
163
+
164
+ const applyResizesNextFrame = (entries: ResizeObserverEntry[]) => {
165
+ for (const { target, contentRect } of entries)
166
+ pendingSizes.set(target, { width: contentRect.width, height: contentRect.height });
167
+ pendingSizesFrame ||= requestAnimationFrame(applyPendingSizes);
168
+ };
162
169
 
163
170
  const observeIntersections = (callback: IntersectionObserverCallback) =>
164
171
  typeof IntersectionObserver === "undefined"
@@ -168,7 +175,7 @@ const observeIntersections = (callback: IntersectionObserverCallback) =>
168
175
  function acquire() {
169
176
  if (users++ === 0) {
170
177
  shared = {
171
- resize: new ResizeObserver(onResize),
178
+ resize: new ResizeObserver(applyResizesNextFrame),
172
179
  presence: observeIntersections(onPresence),
173
180
  gaps: observeIntersections(
174
181
  (entries) => entries.some((entry) => entry.isIntersecting) && scheduleRefresh(),
@@ -190,6 +197,9 @@ function release() {
190
197
  window.removeEventListener("scroll", scheduleRefresh, { capture: true });
191
198
  cancelAnimationFrame(frame);
192
199
  frame = 0;
200
+ cancelAnimationFrame(pendingSizesFrame);
201
+ pendingSizesFrame = 0;
202
+ pendingSizes.clear();
193
203
  }
194
204
 
195
205
  const watchGap = (gap: HTMLDivElement | null) => {
@@ -234,6 +244,7 @@ export function useThumbnailStripSize() {
234
244
  resize.unobserve(target);
235
245
  presence?.unobserve(target);
236
246
  strips.delete(target);
247
+ pendingSizes.delete(target);
237
248
  release();
238
249
  };
239
250
  }, []);
package/src/index.ts CHANGED
@@ -202,6 +202,7 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
202
202
  export { useDomEditSession } from "./hooks/useDomEditSession";
203
203
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
204
204
  export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
205
+ export { announcePreviewDocumentLoaded } from "./player/sceneSwap";
205
206
  export type { PreviewPromotion } from "./player/sceneSwap";
206
207
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
207
208
  export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
@@ -47,6 +47,7 @@ beforeEach(() => {
47
47
  afterEach(() => {
48
48
  act(() => root?.unmount());
49
49
  root = null;
50
+ vi.useRealTimers();
50
51
  thumbnailScheduler.invalidateProject("/api/projects/demo/preview");
51
52
  globalThis.ResizeObserver = originalResizeObserver;
52
53
  globalThis.Image = originalImage;
@@ -175,7 +176,9 @@ describe("CompositionThumbnail", () => {
175
176
  await new Promise((resolve) => setTimeout(resolve, 0));
176
177
  });
177
178
 
179
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
178
180
  act(() => reportResize(500, 40));
181
+ act(() => vi.advanceTimersToNextFrame());
179
182
 
180
183
  expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
181
184
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
7
7
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
8
8
  import { ImageThumbnail } from "./ImageThumbnail";
@@ -64,6 +64,7 @@ beforeEach(() => {
64
64
  afterEach(() => {
65
65
  act(() => root?.unmount());
66
66
  root = null;
67
+ vi.useRealTimers();
67
68
  thumbnailScheduler.invalidateProject("p");
68
69
  globalThis.IntersectionObserver = originalIO;
69
70
  globalThis.ResizeObserver = originalRO;
@@ -187,7 +188,9 @@ describe("ImageThumbnail", () => {
187
188
  probe.onload?.();
188
189
  });
189
190
 
191
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
190
192
  act(() => reportResize(500, 40));
193
+ act(() => vi.advanceTimersToNextFrame());
191
194
 
192
195
  expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
193
196
  });
@@ -10,6 +10,7 @@ import {
10
10
  shouldShowCompositionLoadingOverlay,
11
11
  } from "./Player";
12
12
  import { usePlayerStore } from "../store/playerStore";
13
+ import { onPreviewDocumentLoaded } from "../sceneSwap";
13
14
 
14
15
  vi.mock("@hyperframes/player", () => ({}));
15
16
 
@@ -196,6 +197,49 @@ describe("callbacks after the player is already mounted", () => {
196
197
  expect(first).not.toHaveBeenCalled();
197
198
  });
198
199
 
200
+ it("loads the preview on the runtime's ready while a stalled font holds the window's load", async () => {
201
+ const onLoad = vi.fn();
202
+ const { player } = await mountPlayer({ onLoad });
203
+ const iframe = (player as TestHyperframesPlayer).iframeElement;
204
+ let page = {};
205
+ Object.defineProperty(iframe, "contentDocument", { get: () => page });
206
+ Object.defineProperty(iframe, "contentWindow", { get: () => ({ __playerReady: true }) });
207
+ const announced = vi.fn();
208
+ onPreviewDocumentLoaded(iframe, announced);
209
+
210
+ act(() => void player.dispatchEvent(new Event("ready")));
211
+ expect(onLoad).toHaveBeenCalledTimes(1);
212
+
213
+ act(() => void iframe.dispatchEvent(new Event("load")));
214
+ act(() => void player.dispatchEvent(new Event("ready")));
215
+ expect(onLoad).toHaveBeenCalledTimes(1);
216
+
217
+ page = {};
218
+ act(() => void player.dispatchEvent(new Event("ready")));
219
+ expect(onLoad).toHaveBeenCalledTimes(2);
220
+ expect(announced).toHaveBeenCalledTimes(2);
221
+ });
222
+
223
+ it("leaves a ready that arrives before the page's runtime boots to the page's own load", async () => {
224
+ const onLoad = vi.fn();
225
+ const { player } = await mountPlayer({ onLoad });
226
+ const iframe = (player as TestHyperframesPlayer).iframeElement;
227
+ const page = {};
228
+ let runtime: { __playerReady?: boolean } = {};
229
+ Object.defineProperty(iframe, "contentDocument", { get: () => page });
230
+ Object.defineProperty(iframe, "contentWindow", { get: () => runtime });
231
+
232
+ act(() => void player.dispatchEvent(new Event("ready")));
233
+ expect(onLoad).not.toHaveBeenCalled();
234
+
235
+ act(() => void iframe.dispatchEvent(new Event("load")));
236
+ expect(onLoad).toHaveBeenCalledTimes(1);
237
+
238
+ runtime = { __playerReady: true };
239
+ act(() => void player.dispatchEvent(new Event("ready")));
240
+ expect(onLoad).toHaveBeenCalledTimes(1);
241
+ });
242
+
199
243
  it("reports the preview error cause", async () => {
200
244
  const onPreviewError = vi.fn();
201
245
  const { player } = await mountPlayer({ onPreviewError });
@@ -5,6 +5,8 @@ import { useMountEffect } from "../../hooks/useMountEffect";
5
5
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
6
6
  import { HyperframesLoader } from "../../components/ui";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
+ import { announcePreviewDocumentLoaded } from "../sceneSwap";
9
+ import type { IframeWindow } from "../lib/playbackTypes";
8
10
  // Importing "@hyperframes/player" registers a class extending HTMLElement at
9
11
  // module load, which throws under SSR, hence the dynamic import behind a
10
12
  // `typeof window` guard. Kicking it here rather than in the mount effect puts
@@ -128,6 +130,9 @@ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean
128
130
  }
129
131
  }
130
132
 
133
+ const runtimeBootedIn = (iframe: HTMLIFrameElement) =>
134
+ (iframe.contentWindow as IframeWindow | null)?.__playerReady === true;
135
+
131
136
  /**
132
137
  * Renders a composition preview using the <hyperframes-player> web component.
133
138
  *
@@ -228,9 +233,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
228
233
  const loading = getShaderTransitionLoading(event);
229
234
  if (loading !== null) setShaderTransitionLoading(loading);
230
235
  };
236
+ let loadedDocument: Document | null = null;
231
237
  const handleReady = () => {
232
238
  setPreviewError(null);
233
239
  setCompositionLoading(false);
240
+ if (iframe.contentDocument && runtimeBootedIn(iframe)) handleLoad();
234
241
  };
235
242
  const handlePainted = () => {
236
243
  setPainted(true);
@@ -243,6 +250,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
243
250
  setCompositionLoading(false);
244
251
  };
245
252
  const handleLoad = () => {
253
+ const doc = iframe.contentDocument;
254
+ if (doc && doc === loadedDocument) return;
255
+ loadedDocument = doc;
246
256
  loadCountRef.current++;
247
257
  setLoaded(true);
248
258
  setPreviewError(null);
@@ -258,6 +268,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
258
268
  container.addEventListener("animationend", onEnd, { once: true });
259
269
  }
260
270
  onLoadRef.current();
271
+ announcePreviewDocumentLoaded(iframe);
261
272
 
262
273
  // Show a loading overlay until every `<video>`/`<audio>` and Lottie
263
274
  // asset is ready. Without this users can click play before audio has