@hyperframes/studio 0.8.33 → 0.8.34

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 (85) hide show
  1. package/dist/assets/{hyperframes-player-CNhkujVy.js → hyperframes-player-D4aSRr_M.js} +1 -1
  2. package/dist/assets/{index-DOoo96Tb.js → index-CkxPQxLJ.js} +182 -172
  3. package/dist/assets/{index-DiStlnx7.js → index-aNyQBoJv.js} +1 -1
  4. package/dist/assets/{index-CslAQ3JV.js → index-hch4ktcC.js} +1 -1
  5. package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
  6. package/dist/chunk-7BP32G62.js.map +1 -0
  7. package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +354 -206
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -4
  13. package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
  14. package/src/captions/components/CaptionOverlay.tsx +1 -0
  15. package/src/captions/generator.test.ts +30 -0
  16. package/src/captions/generator.ts +5 -3
  17. package/src/captions/hooks/useCaptionSync.ts +3 -2
  18. package/src/components/MediaPreview.tsx +2 -1
  19. package/src/components/editor/AnimationCard.test.tsx +3 -3
  20. package/src/components/editor/PropertyPanel.test.tsx +18 -14
  21. package/src/components/editor/SnapGuideOverlay.tsx +2 -6
  22. package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
  23. package/src/components/editor/domEditLayerWalkCache.ts +327 -0
  24. package/src/components/editor/domEditOverlayBasis.ts +72 -0
  25. package/src/components/editor/domEditOverlayGeometry.ts +78 -83
  26. package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
  27. package/src/components/editor/domEditOverlayTransform.ts +47 -9
  28. package/src/components/editor/domEditingDom.ts +63 -14
  29. package/src/components/editor/domEditingElement.ts +56 -14
  30. package/src/components/editor/domEditingLayers.test.ts +48 -0
  31. package/src/components/editor/domEditingLayers.ts +22 -35
  32. package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
  33. package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
  34. package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
  35. package/src/components/editor/overlayFrameLoop.test.ts +246 -0
  36. package/src/components/editor/overlayFrameLoop.ts +204 -0
  37. package/src/components/editor/probeSourceElement.ts +29 -0
  38. package/src/components/editor/propertyPanelFill.tsx +2 -1
  39. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
  40. package/src/components/editor/useDomEditCompositionRect.ts +2 -4
  41. package/src/components/editor/useDomEditOverlayRects.ts +15 -9
  42. package/src/components/editor/useMotionPathData.ts +2 -4
  43. package/src/components/feedback/projectProvenance.ts +4 -1
  44. package/src/components/nle/NLEContext.tsx +2 -1
  45. package/src/components/nle/PreviewOverlays.tsx +1 -1
  46. package/src/components/nle/useCompositionStack.ts +11 -4
  47. package/src/components/renders/RenderQueue.test.tsx +2 -2
  48. package/src/components/renders/RenderQueueItem.tsx +2 -1
  49. package/src/components/renders/useRenderQueue.ts +3 -2
  50. package/src/components/sidebar/AssetsTab.tsx +2 -1
  51. package/src/components/sidebar/CompositionsTab.tsx +2 -1
  52. package/src/components/storyboard/FramePoster.tsx +2 -1
  53. package/src/hooks/useAskAgentModal.ts +2 -1
  54. package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
  55. package/src/hooks/useCaptionDetection.ts +3 -1
  56. package/src/hooks/useCompositionContentLoader.ts +3 -1
  57. package/src/hooks/useCompositionDimensions.test.tsx +47 -0
  58. package/src/hooks/useCompositionDimensions.ts +3 -2
  59. package/src/hooks/useDomEditCommits.ts +5 -4
  60. package/src/hooks/useElementLifecycleOps.ts +5 -1
  61. package/src/hooks/useFileTree.ts +5 -4
  62. package/src/hooks/useGroupCommits.ts +2 -1
  63. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
  64. package/src/hooks/useLintModal.ts +2 -1
  65. package/src/hooks/useRenderClipContent.ts +6 -5
  66. package/src/hooks/useTimelineDeleteOps.ts +5 -1
  67. package/src/player/components/Player.tsx +6 -4
  68. package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
  69. package/src/player/components/thumbnailUtils.ts +6 -2
  70. package/src/player/components/timelineDragDrop.test.tsx +3 -3
  71. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  72. package/src/player/lib/timelineIframeHelpers.ts +17 -1
  73. package/src/test-setup.ts +8 -0
  74. package/src/utils/blockInstaller.ts +2 -1
  75. package/src/utils/projectRouting.test.ts +9 -0
  76. package/src/utils/projectRouting.ts +18 -1
  77. package/src/utils/razorSplitTransaction.test.ts +3 -3
  78. package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
  79. package/src/utils/sourceScopedSelectorIndex.ts +64 -6
  80. package/src/utils/studioFileHistory.ts +2 -1
  81. package/src/utils/studioHelpers.test.ts +5 -1
  82. package/src/utils/studioHelpers.ts +3 -2
  83. package/src/utils/timelineCompositionInsert.test.ts +3 -3
  84. package/dist/chunk-A7S5SGNA.js.map +0 -1
  85. /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.33",
3
+ "version": "0.8.34",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.33",
52
- "@hyperframes/player": "0.8.33",
53
- "@hyperframes/parsers": "0.8.33",
54
- "@hyperframes/sdk": "0.8.33",
55
- "@hyperframes/studio-server": "0.8.33"
51
+ "@hyperframes/core": "0.8.34",
52
+ "@hyperframes/parsers": "0.8.34",
53
+ "@hyperframes/player": "0.8.34",
54
+ "@hyperframes/sdk": "0.8.34",
55
+ "@hyperframes/studio-server": "0.8.34"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -67,9 +67,9 @@
67
67
  "tsup": "^8.0.0",
68
68
  "typescript": "^5.0.0",
69
69
  "vite": "^6.4.2",
70
- "vitest": "^3.2.4",
70
+ "vitest": "^4.1.11",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.33"
72
+ "@hyperframes/producer": "0.8.34"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
package/src/App.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "./utils/projectRouting";
1
2
  import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
2
3
  import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
3
4
  import { useRenderQueue } from "./components/renders/useRenderQueue";
@@ -326,12 +327,13 @@ export function StudioApp() {
326
327
  const renderClipContent = useRenderClipContent({
327
328
  projectIdRef: fileManager.projectIdRef,
328
329
  compIdToSrc,
329
- activePreviewUrl: activeCompPath
330
- ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
331
- : null,
330
+ activePreviewUrl:
331
+ activeCompPath && projectId
332
+ ? buildProjectApiPath(projectId, `/preview/comp/${activeCompPath}`)
333
+ : null,
332
334
  effectiveTimelineDuration,
333
335
  });
334
- const compositionDimensions = useCompositionDimensions();
336
+ const compositionDimensions = useCompositionDimensions(previewIframeRef);
335
337
  const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal(
336
338
  projectId,
337
339
  refreshKey,
@@ -0,0 +1,47 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it, vi } from "vitest";
5
+ import { CaptionOverlay } from "./CaptionOverlay";
6
+ import { useCaptionStore } from "../store";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ it("only schedules caption layout for messages from its preview", () => {
11
+ const host = document.createElement("div");
12
+ const preview = document.createElement("iframe");
13
+ const foreign = document.createElement("iframe");
14
+ document.body.append(host, preview, foreign);
15
+ const root = createRoot(host);
16
+ const schedule = vi.fn(() => 1);
17
+ vi.stubGlobal("requestAnimationFrame", schedule);
18
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
19
+ vi.stubGlobal(
20
+ "ResizeObserver",
21
+ class {
22
+ observe() {}
23
+ disconnect() {}
24
+ },
25
+ );
26
+ useCaptionStore.getState().setEditMode(true);
27
+ const send = (source: MessageEventSource | null) =>
28
+ act(() => {
29
+ window.dispatchEvent(new MessageEvent("message", { source, data: { source: "hf-preview" } }));
30
+ });
31
+ try {
32
+ act(() => root.render(<CaptionOverlay iframeRef={{ current: preview }} />));
33
+ schedule.mockClear();
34
+ send(foreign.contentWindow);
35
+ send(null);
36
+ expect(schedule).not.toHaveBeenCalled();
37
+ send(preview.contentWindow);
38
+ expect(schedule).toHaveBeenCalledOnce();
39
+ } finally {
40
+ act(() => root.unmount());
41
+ useCaptionStore.getState().reset();
42
+ host.remove();
43
+ preview.remove();
44
+ foreign.remove();
45
+ vi.unstubAllGlobals();
46
+ }
47
+ });
@@ -116,6 +116,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
116
116
  // messages, element resize, window resize.
117
117
  const unsubPlayer = usePlayerStore.subscribe(scheduleTick);
118
118
  const handleMessage = (e: MessageEvent) => {
119
+ if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
119
120
  const data = e.data;
120
121
  if (data?.source === "hf-preview") scheduleTick();
121
122
  };
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment node
2
2
  import { describe, it, expect } from "vitest";
3
+ import { Window } from "happy-dom";
3
4
  import { generateCaptionHtml } from "./generator";
4
5
  import { buildCaptionModel, TranscriptWord } from "./parser";
5
6
 
@@ -31,6 +32,35 @@ function buildTestModel(wordsPerGroup = 5) {
31
32
  // ---------------------------------------------------------------------------
32
33
 
33
34
  describe("generateCaptionHtml", () => {
35
+ it("keeps script-closing text and word IDs as data when HTML is parsed", () => {
36
+ const text = '</ScRiPt><img src=x onerror="alert(1)"><!-- <script> & end';
37
+ const id = '</script><svg onload="alert(2)">';
38
+ const model = buildCaptionModel([{ text, id, start: 0, end: 1 }], {
39
+ width: 1920,
40
+ height: 1080,
41
+ duration: 2,
42
+ wordsPerGroup: 5,
43
+ });
44
+ const window = new Window();
45
+ const template = window.document.createElement("template");
46
+ template.innerHTML = generateCaptionHtml(model);
47
+ const nested = template.content.querySelector("template");
48
+ expect(nested).not.toBeNull();
49
+ const content = nested!.content;
50
+ expect(content.querySelector("img, svg")).toBeNull();
51
+ const scripts = content.querySelectorAll("script");
52
+ expect(scripts).toHaveLength(1);
53
+ const script = scripts[0].textContent;
54
+ const transcript = script.match(/const TRANSCRIPT = ([\s\S]*?);/);
55
+ expect(transcript).not.toBeNull();
56
+ expect(JSON.parse(transcript![1])).toEqual([{ text, id, start: 0, end: 1 }]);
57
+ const wordText = script.match(/\.textContent = (.*);/);
58
+ const wordId = script.match(/\.id = (.*);/);
59
+ expect(JSON.parse(wordText![1])).toBe(text);
60
+ expect(JSON.parse(wordId![1])).toBe(id);
61
+ window.close();
62
+ });
63
+
34
64
  describe("HTML structure", () => {
35
65
  it("wraps output in a <template id='captions-template'>", () => {
36
66
  const model = buildTestModel();
@@ -277,7 +277,7 @@ function generateJs(model: CaptionModel): string {
277
277
  }
278
278
  }
279
279
 
280
- const transcriptJson = JSON.stringify(allSegments, null, 2);
280
+ const transcriptJson = JSON.stringify(allSegments, null, 2).replace(/</g, "\\u003c");
281
281
 
282
282
  const groupBlocks: string[] = [];
283
283
 
@@ -303,9 +303,11 @@ function generateJs(model: CaptionModel): string {
303
303
 
304
304
  // Build word spans
305
305
  const wordLines: string[] = groupSegments.map((seg) => {
306
- const escaped = JSON.stringify(seg.text);
306
+ const escaped = JSON.stringify(seg.text).replace(/</g, "\\u003c");
307
307
  const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`;
308
- const idLine = seg.wordId ? `\n ${segVar}.id = ${JSON.stringify(seg.wordId)};` : "";
308
+ const idLine = seg.wordId
309
+ ? `\n ${segVar}.id = ${JSON.stringify(seg.wordId).replace(/</g, "\\u003c")};`
310
+ : "";
309
311
  return (
310
312
  ` const ${segVar} = document.createElement('span');` +
311
313
  `\n ${segVar}.className = 'word clip';` +
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { useCallback, useRef } from "react";
2
3
  import { useCaptionStore } from "../store";
3
4
  import { useMountEffect } from "../../hooks/useMountEffect";
@@ -92,7 +93,7 @@ export function useCaptionSync(projectId: string | null) {
92
93
  const seqAtSave = editSeqRef.current;
93
94
  const overrides = buildOverrides(state.model);
94
95
 
95
- fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
96
+ fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), {
96
97
  method: "PUT",
97
98
  headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
98
99
  body: JSON.stringify(overrides, null, 2),
@@ -171,7 +172,7 @@ export function useCaptionSync(projectId: string | null) {
171
172
  let data: { content?: string };
172
173
  try {
173
174
  const res = await fetch(
174
- `/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`,
175
+ buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
175
176
  );
176
177
  if (!res.ok) return; // no overrides file yet — normal
177
178
  data = await res.json();
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useState } from "react";
2
3
  import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
3
4
 
@@ -28,7 +29,7 @@ function MediaErrorPanel({ name, filePath }: { name: string; filePath: string })
28
29
  }
29
30
 
30
31
  export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
31
- const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
32
+ const serveUrl = buildProjectApiPath(projectId, `/preview/${filePath}`);
32
33
  const name = filePath.split("/").pop() ?? filePath;
33
34
  // Keyed by path so switching to another file clears a previous failure.
34
35
  const [failedPath, setFailedPath] = useState<string | null>(null);
@@ -3,7 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
- import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
7
7
  import { AnimationCard } from "./AnimationCard";
8
8
  import { EASE_PRESETS } from "./easePresetLibrary";
9
9
  import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
@@ -118,8 +118,8 @@ function renderCard({
118
118
  animation?: GsapAnimation;
119
119
  defaultExpanded?: boolean;
120
120
  flat?: boolean;
121
- onUpdateMeta?: ReturnType<typeof vi.fn>;
122
- onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
121
+ onUpdateMeta?: Mock;
122
+ onUpdateKeyframeEase?: Mock;
123
123
  onDeleteAnimation?: (id: string) => void;
124
124
  } = {}) {
125
125
  const host = document.createElement("div");
@@ -1001,20 +1001,24 @@ describe("PropertyPanel — Motion is for things that move", () => {
1001
1001
  return element;
1002
1002
  },
1003
1003
  ],
1004
- ])("recognizes %s through the shared audio predicate", async (_label, makeElement) => {
1005
- const fixture = {
1006
- ...audioClipElement(),
1007
- element: makeElement(),
1008
- tagName: "div",
1009
- };
1010
- const { host, root } = await renderPanel(true, fixture);
1011
- const titles = Array.from(
1012
- host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1013
- ).map((node) => node.textContent ?? "");
1014
- expect(titles.some((title) => title.includes("Motion"))).toBe(false);
1015
- expect(titles.some((title) => title.includes("Timing"))).toBe(true);
1016
- act(() => root.unmount());
1017
- });
1004
+ ])(
1005
+ "recognizes %s through the shared audio predicate",
1006
+ async (_label, makeElement) => {
1007
+ const fixture = {
1008
+ ...audioClipElement(),
1009
+ element: makeElement(),
1010
+ tagName: "div",
1011
+ };
1012
+ const { host, root } = await renderPanel(true, fixture);
1013
+ const titles = Array.from(
1014
+ host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1015
+ ).map((node) => node.textContent ?? "");
1016
+ expect(titles.some((title) => title.includes("Motion"))).toBe(false);
1017
+ expect(titles.some((title) => title.includes("Timing"))).toBe(true);
1018
+ act(() => root.unmount());
1019
+ },
1020
+ RENDER_TIMEOUT_MS,
1021
+ );
1018
1022
 
1019
1023
  it(
1020
1024
  "calls the section Timing on an audio clip, and offers no tween editor",
@@ -1,6 +1,7 @@
1
1
  import { memo, useRef, type RefObject } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { resolveGuideLineRect, type SnapGuide, type SpacingGuide } from "./snapEngine";
4
+ import { subscribeOverlayFrame } from "./overlayFrameLoop";
4
5
 
5
6
  export interface SnapGuidesState {
6
7
  guides: SnapGuide[];
@@ -47,12 +48,8 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
47
48
  };
48
49
 
49
50
  useMountEffect(() => {
50
- let frame = 0;
51
-
52
51
  // fallow-ignore-next-line complexity
53
52
  const update = () => {
54
- frame = requestAnimationFrame(update);
55
-
56
53
  const state = snapGuidesRef.current;
57
54
  const guides = state?.guides ?? [];
58
55
  const spacingGuides = state?.spacingGuides ?? [];
@@ -116,8 +113,7 @@ export const SnapGuideOverlay = memo(function SnapGuideOverlay({
116
113
  }
117
114
  };
118
115
 
119
- frame = requestAnimationFrame(update);
120
- return () => cancelAnimationFrame(frame);
116
+ return subscribeOverlayFrame(update);
121
117
  });
122
118
 
123
119
  return (
@@ -0,0 +1,284 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ DOM_EDIT_LAYER_OBSERVER_INIT,
5
+ type DomEditLayerWalkCache,
6
+ createDomEditLayerWalkCache,
7
+ drainPendingLayerMutations,
8
+ } from "./domEditLayerWalkCache";
9
+ import { collectDomEditLayerItems } from "./domEditingLayers";
10
+ import type { DomEditLayerItem } from "./domEditingTypes";
11
+
12
+ const opts = { activeCompositionPath: "index.html", isMasterView: true };
13
+
14
+ /** Attached, not detached: the occurrence index is resolved with a whole-document
15
+ * query, so a detached fixture would number every element as a miss. */
16
+ function mountPreview(cardCount: number): HTMLElement {
17
+ const cards = Array.from(
18
+ { length: cardCount },
19
+ (_unused, i) =>
20
+ `<div class="box"><span class="label">card ${i}</span><b class="tag">t${i}</b></div>`,
21
+ ).join("");
22
+ document.body.innerHTML = `<div data-composition-id="index.html">${cards}</div>`;
23
+ return document.body.firstElementChild as HTMLElement;
24
+ }
25
+
26
+ afterEach(() => {
27
+ document.body.innerHTML = "";
28
+ });
29
+
30
+ /** Everything a caller can observe about a walk. Compared field by field so a
31
+ * divergence names itself instead of failing on object identity. */
32
+ function shape(items: DomEditLayerItem[]): unknown {
33
+ return items.map((item) => ({
34
+ key: item.key,
35
+ label: item.label,
36
+ tagName: item.tagName,
37
+ depth: item.depth,
38
+ childCount: item.childCount,
39
+ id: item.id,
40
+ hfId: item.hfId,
41
+ selector: item.selector,
42
+ selectorIndex: item.selectorIndex,
43
+ sourceFile: item.sourceFile,
44
+ element: item.element,
45
+ }));
46
+ }
47
+
48
+ /**
49
+ * Count `getComputedStyle` calls, which is the per-element cost this cache
50
+ * exists to avoid: `isInspectableLayerElement` makes one for the element and
51
+ * `getDirectLayerChildren` one more for each direct child. A count that does not
52
+ * grow with the document is the whole claim.
53
+ */
54
+ function countingComputedStyle(): { calls: () => number; restore: () => void } {
55
+ const real = window.getComputedStyle.bind(window);
56
+ let calls = 0;
57
+ window.getComputedStyle = ((el: Element, pseudo?: string | null) => {
58
+ calls += 1;
59
+ return real(el, pseudo ?? undefined);
60
+ }) as typeof window.getComputedStyle;
61
+ return { calls: () => calls, restore: () => (window.getComputedStyle = real) };
62
+ }
63
+
64
+ interface WarmWalk {
65
+ root: HTMLElement;
66
+ cache: DomEditLayerWalkCache;
67
+ observer: MutationObserver;
68
+ }
69
+
70
+ /**
71
+ * A mounted preview whose cache has already done one full walk, with the
72
+ * observer live. Every test here needs exactly this before it can measure what
73
+ * a SECOND walk costs, so it lives in one place.
74
+ */
75
+ function withWarmWalk<T>(cardCount: number, run: (ctx: WarmWalk) => T): T {
76
+ const root = mountPreview(cardCount);
77
+ const cache = createDomEditLayerWalkCache();
78
+ const observer = new MutationObserver(() => {});
79
+ observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
80
+ try {
81
+ collectDomEditLayerItems(root, opts, undefined, cache);
82
+ return run({ root, cache, observer });
83
+ } finally {
84
+ observer.disconnect();
85
+ }
86
+ }
87
+
88
+ describe("collectDomEditLayerItems incremental rebuild", () => {
89
+ /**
90
+ * The load-bearing assertion, and it is INVARIANCE, not a threshold: a
91
+ * threshold ("under 20 style reads") passes on a small fixture and still
92
+ * degrades linearly on a real preview. Quadrupling the card count must not
93
+ * change what one style edit costs at all.
94
+ *
95
+ * Before the cache both numbers tracked the document size (every element and
96
+ * every direct child re-derived on every mutation, whatever the mutation was).
97
+ */
98
+ function styleEditRebuildCost(cardCount: number): number {
99
+ return withWarmWalk(cardCount, ({ root, cache, observer }) => {
100
+ // What animation writes on a frame: one element's transform, nothing that
101
+ // can change any element's selector, membership or label.
102
+ root.querySelector<HTMLElement>(".box")!.style.transform = "translateX(4px)";
103
+ cache.ingest(observer.takeRecords());
104
+
105
+ const probe = countingComputedStyle();
106
+ try {
107
+ collectDomEditLayerItems(root, opts, undefined, cache);
108
+ return probe.calls();
109
+ } finally {
110
+ probe.restore();
111
+ }
112
+ });
113
+ }
114
+
115
+ it("costs the same for one style edit at n cards and at 4n", () => {
116
+ const small = styleEditRebuildCost(12);
117
+ const large = styleEditRebuildCost(48);
118
+
119
+ expect(large).toBe(small);
120
+ // A ceiling as well, so a future change that reintroduces a per-element term
121
+ // fails here even if it happens to be document-size-flat. The edited element,
122
+ // its parent, and each of their direct children is the whole budget.
123
+ expect(small).toBeLessThanOrEqual(12);
124
+ });
125
+
126
+ it("performs no document query at all for an edit that touches no selector", () => {
127
+ withWarmWalk(12, ({ root, cache, observer }) => {
128
+ root.querySelector<HTMLElement>(".tag")!.style.opacity = "0.5";
129
+ cache.ingest(observer.takeRecords());
130
+
131
+ const real = document.querySelectorAll.bind(document);
132
+ let queries = 0;
133
+ Object.defineProperty(document, "querySelectorAll", {
134
+ configurable: true,
135
+ value: (selector: string) => {
136
+ queries += 1;
137
+ return real(selector);
138
+ },
139
+ });
140
+ try {
141
+ collectDomEditLayerItems(root, opts, undefined, cache);
142
+ } finally {
143
+ delete (document as Partial<Document>).querySelectorAll;
144
+ }
145
+ // The only document query in the walk resolves a selector's occurrence
146
+ // index, and no cached selector was invalidated.
147
+ expect(queries).toBe(0);
148
+ });
149
+ });
150
+
151
+ /**
152
+ * The equivalence half, and the one that would catch a wrong optimisation
153
+ * rather than a missing one. Every mutation KIND the invalidation reasons
154
+ * about is applied in turn, and after each the incremental walk must equal a
155
+ * walk with no cache at all — which is what every other caller still gets.
156
+ */
157
+ it("matches an uncached walk after every kind of mutation it reasons about", () => {
158
+ const root = mountPreview(6);
159
+ const cache = createDomEditLayerWalkCache();
160
+ const observer = new MutationObserver(() => {});
161
+ observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
162
+ // Captured up front: later cases change class and id, so re-querying `.box`
163
+ // would hand the next case a different element than it names.
164
+ const cards = Array.from(root.querySelectorAll<HTMLElement>(".box"));
165
+ const box = (i: number) => cards[i]!;
166
+
167
+ const mutations: Array<[string, () => void]> = [
168
+ ["a style write that cannot change membership", () => (box(0).style.opacity = "0.5")],
169
+ // Inherited: the subtree's computed visibility moves with it.
170
+ ["a style write that hides a subtree", () => (box(1).style.visibility = "hidden")],
171
+ ["clearing that same subtree hide", () => (box(1).style.visibility = "")],
172
+ // display:none removes the element from the list and shifts its
173
+ // descendants' depth by one, without touching their own computed display.
174
+ ["display:none on a card", () => (box(2).style.display = "none")],
175
+ // Renumbers `.box` for every card after it, and relabels this one.
176
+ ["a class change that renumbers a shared selector", () => (box(3).className = "crate")],
177
+ ["an id, which outranks the class selector", () => (box(4).id = "hero")],
178
+ ["edited text, which is a label input", () => (box(5).firstChild!.textContent = "renamed")],
179
+ // The unattributable case: the documented full-rebuild fallback.
180
+ ["a node inserted mid-document", () => box(0).append(document.createElement("i"))],
181
+ ["a node removed", () => box(0).querySelector("b")!.remove()],
182
+ ["a data attribute nothing derives from", () => box(0).setAttribute("data-x", "1")],
183
+ ];
184
+
185
+ try {
186
+ collectDomEditLayerItems(root, opts, undefined, cache);
187
+ for (const [what, mutate] of mutations) {
188
+ mutate();
189
+ cache.ingest(observer.takeRecords());
190
+ expect(
191
+ shape(collectDomEditLayerItems(root, opts, undefined, cache)),
192
+ `incremental walk diverged after ${what}`,
193
+ ).toEqual(shape(collectDomEditLayerItems(root, opts)));
194
+ }
195
+ } finally {
196
+ observer.disconnect();
197
+ }
198
+ });
199
+
200
+ /**
201
+ * The subtree drop is the one expensive branch in the ingest path, and it runs
202
+ * in the raw observer callback — not behind the rebuild throttle. GSAP's
203
+ * `autoAlpha` writes `visibility: visible` into inline style ONCE on fade-in
204
+ * and leaves it there, so a rule that fires whenever the style attribute
205
+ * mentions visibility fires on every transform tick for the rest of the clip:
206
+ * a whole-subtree walk per animation frame, for the exact elements a
207
+ * composition animates most.
208
+ */
209
+ it("does not re-walk a subtree when a faded-in element merely animates", () => {
210
+ const root = mountPreview(6);
211
+ const cache = createDomEditLayerWalkCache();
212
+ const observer = new MutationObserver(() => {});
213
+ observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
214
+ const card = root.querySelector<HTMLElement>(".box")!;
215
+ try {
216
+ collectDomEditLayerItems(root, opts, undefined, cache);
217
+ // The fade-in itself. This one legitimately invalidates the subtree.
218
+ card.style.visibility = "visible";
219
+ cache.ingest(observer.takeRecords());
220
+
221
+ let subtreeWalks = 0;
222
+ const real = Element.prototype.querySelectorAll;
223
+ Element.prototype.querySelectorAll = function (this: Element, selector: string) {
224
+ if (selector === "*") subtreeWalks += 1;
225
+ return real.call(this, selector);
226
+ } as typeof Element.prototype.querySelectorAll;
227
+ try {
228
+ // Ordinary animation frames. `visibility: visible` is still sitting in
229
+ // the attribute, but the value has not moved.
230
+ card.style.transform = "translateX(1px)";
231
+ cache.ingest(observer.takeRecords());
232
+ card.style.transform = "translateX(2px)";
233
+ cache.ingest(observer.takeRecords());
234
+ } finally {
235
+ Element.prototype.querySelectorAll = real;
236
+ }
237
+
238
+ expect(subtreeWalks).toBe(0);
239
+ } finally {
240
+ observer.disconnect();
241
+ }
242
+ });
243
+
244
+ it("drops everything when the walk is re-scoped to a different composition", () => {
245
+ const root = mountPreview(3);
246
+ const cache = createDomEditLayerWalkCache();
247
+ collectDomEditLayerItems(root, opts, undefined, cache);
248
+
249
+ const rescoped = { ...opts, activeCompositionPath: "scenes/two.html" };
250
+ expect(shape(collectDomEditLayerItems(root, rescoped, undefined, cache))).toEqual(
251
+ shape(collectDomEditLayerItems(root, rescoped)),
252
+ );
253
+ });
254
+ });
255
+
256
+ describe("drainPendingLayerMutations", () => {
257
+ it("applies records the observer has not delivered yet", () => {
258
+ withWarmWalk(6, ({ root, cache, observer }) => {
259
+ const card = root.querySelector<HTMLElement>(".box")!;
260
+ // Not delivered: the callback runs in a microtask, and this test never
261
+ // yields to one.
262
+ card.id = "renamed";
263
+
264
+ drainPendingLayerMutations(observer, cache);
265
+
266
+ // An identity attribute drops the whole cache, so the walk after the drain
267
+ // must agree with an uncached walk.
268
+ expect(shape(collectDomEditLayerItems(root, opts, undefined, cache))).toEqual(
269
+ shape(collectDomEditLayerItems(root, opts)),
270
+ );
271
+ // And the records really were consumed by the drain, not still queued.
272
+ expect(observer.takeRecords()).toHaveLength(0);
273
+ });
274
+ });
275
+
276
+ it("is a no-op when no observer is attached yet", () => {
277
+ withWarmWalk(6, ({ root, cache }) => {
278
+ expect(() => drainPendingLayerMutations(null, cache)).not.toThrow();
279
+ expect(collectDomEditLayerItems(root, opts, undefined, cache)).toHaveLength(
280
+ collectDomEditLayerItems(root, opts).length,
281
+ );
282
+ });
283
+ });
284
+ });