@hyperframes/studio 0.7.94 → 0.7.95

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 (59) hide show
  1. package/dist/assets/{hyperframes-player-D4mryRja.js → hyperframes-player-C1o3TZ1w.js} +1 -1
  2. package/dist/assets/{index-CH_dyqrx.js → index-4KcC7fDo.js} +195 -195
  3. package/dist/assets/{index-BWh5m2-P.js → index-Bl6x228Z.js} +1 -1
  4. package/dist/assets/{index-B0NWErIq.js → index-DR4Dsw8D.js} +1 -1
  5. package/dist/assets/index-tBPidglp.css +1 -0
  6. package/dist/index.d.ts +27 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +2644 -1837
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/EditorShell.selectionSync.test.tsx +109 -0
  12. package/src/components/EditorShell.tsx +31 -5
  13. package/src/components/TimelineToolbar.test.tsx +20 -1
  14. package/src/components/TimelineToolbar.tsx +29 -1
  15. package/src/hooks/useRenderClipContent.test.ts +77 -2
  16. package/src/hooks/useRenderClipContent.ts +78 -14
  17. package/src/hooks/useThumbnailLease.test.tsx +150 -0
  18. package/src/hooks/useThumbnailLease.ts +44 -0
  19. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
  20. package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
  21. package/src/player/components/AudioWaveform.test.tsx +53 -0
  22. package/src/player/components/AudioWaveform.tsx +137 -140
  23. package/src/player/components/CompositionThumbnail.test.ts +33 -10
  24. package/src/player/components/CompositionThumbnail.tsx +78 -61
  25. package/src/player/components/ImageThumbnail.test.tsx +26 -14
  26. package/src/player/components/ImageThumbnail.tsx +58 -101
  27. package/src/player/components/Timeline.tsx +20 -19
  28. package/src/player/components/TimelineGestureOverlay.tsx +3 -0
  29. package/src/player/components/TimelineLanes.test.tsx +24 -2
  30. package/src/player/components/TimelineLanes.tsx +12 -12
  31. package/src/player/components/TimelineTypes.ts +6 -0
  32. package/src/player/components/VideoThumbnail.test.tsx +46 -100
  33. package/src/player/components/VideoThumbnail.tsx +74 -156
  34. package/src/player/components/thumbnailUtils.test.ts +35 -0
  35. package/src/player/components/thumbnailUtils.ts +86 -8
  36. package/src/player/components/timelineClipChildren.test.ts +31 -0
  37. package/src/player/components/timelineClipChildren.tsx +21 -2
  38. package/src/player/components/timelineLaneProps.ts +3 -0
  39. package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
  40. package/src/player/hooks/useTimelinePlayer.ts +14 -9
  41. package/src/player/lib/mediaProbe.test.ts +143 -0
  42. package/src/player/lib/mediaProbe.ts +117 -25
  43. package/src/player/lib/thumbnailPolicy.test.ts +14 -0
  44. package/src/player/lib/thumbnailPolicy.ts +24 -0
  45. package/src/player/lib/thumbnailScheduler.test.ts +388 -0
  46. package/src/player/lib/thumbnailScheduler.ts +483 -0
  47. package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
  48. package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
  49. package/src/player/lib/timelineViewportBudgets.ts +2 -0
  50. package/src/player/store/playerStore.ts +3 -1
  51. package/src/player/store/thumbnailSlice.ts +18 -0
  52. package/src/utils/frameCapture.test.ts +1 -1
  53. package/src/utils/frameCapture.ts +1 -0
  54. package/src/utils/projectRouting.test.ts +1 -1
  55. package/src/utils/studioSelectionSnapshot.test.ts +1 -1
  56. package/src/utils/studioSelectionSnapshot.ts +1 -0
  57. package/src/utils/studioUiPreferences.test.ts +14 -0
  58. package/src/utils/studioUiPreferences.ts +6 -0
  59. package/dist/assets/index-D78KEjgB.css +0 -1
@@ -0,0 +1,150 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { ThumbnailScheduler, type ThumbnailRequest } from "../player/lib/thumbnailScheduler";
7
+ import { useThumbnailLease } from "./useThumbnailLease";
8
+
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ describe("useThumbnailLease", () => {
16
+ it("subscribes once, publishes the result, and releases on unmount", async () => {
17
+ const scheduler = new ThumbnailScheduler();
18
+ const load = vi.fn(async () => ({
19
+ value: { kind: "image" as const, url: "blob:poster", aspect: 16 / 9 },
20
+ weight: 10,
21
+ }));
22
+ const request: ThumbnailRequest = {
23
+ key: "poster",
24
+ projectId: "demo",
25
+ sessionEpoch: 1,
26
+ kind: "image",
27
+ priority: "visible",
28
+ load,
29
+ };
30
+ let status = "missing";
31
+
32
+ function Probe() {
33
+ status = useThumbnailLease(request, scheduler).status;
34
+ return null;
35
+ }
36
+
37
+ const root = createRoot(document.createElement("div"));
38
+ await act(async () => {
39
+ root.render(React.createElement(Probe));
40
+ await Promise.resolve();
41
+ });
42
+ expect(load).toHaveBeenCalledTimes(1);
43
+ expect(status).toBe("ready");
44
+ expect(scheduler.getDiagnostics().leases).toBe(1);
45
+
46
+ act(() => root.unmount());
47
+ expect(scheduler.getDiagnostics().leases).toBe(0);
48
+ });
49
+
50
+ it("does not acquire work for a null request", () => {
51
+ const scheduler = new ThumbnailScheduler();
52
+ let status = "missing";
53
+ function Probe() {
54
+ status = useThumbnailLease(null, scheduler).status;
55
+ return null;
56
+ }
57
+ const root = createRoot(document.createElement("div"));
58
+ act(() => root.render(React.createElement(Probe)));
59
+ expect(status).toBe("idle");
60
+ expect(scheduler.getDiagnostics().leases).toBe(0);
61
+ act(() => root.unmount());
62
+ });
63
+
64
+ it("updates priority without restarting the active request", async () => {
65
+ const scheduler = new ThumbnailScheduler();
66
+ let resolve!: (value: {
67
+ value: { kind: "image"; url: string; aspect: number };
68
+ weight: number;
69
+ }) => void;
70
+ const pending = new Promise<{
71
+ value: { kind: "image"; url: string; aspect: number };
72
+ weight: number;
73
+ }>((accept) => {
74
+ resolve = accept;
75
+ });
76
+ const load = vi.fn(() => pending);
77
+ let priority: ThumbnailRequest["priority"] = "overscan";
78
+ function Probe() {
79
+ useThumbnailLease(
80
+ {
81
+ key: "same-content",
82
+ projectId: "demo",
83
+ sessionEpoch: 1,
84
+ kind: "image",
85
+ priority,
86
+ load,
87
+ },
88
+ scheduler,
89
+ );
90
+ return null;
91
+ }
92
+ const root = createRoot(document.createElement("div"));
93
+ act(() => root.render(React.createElement(Probe)));
94
+ priority = "interaction";
95
+ act(() => root.render(React.createElement(Probe)));
96
+ expect(load).toHaveBeenCalledTimes(1);
97
+
98
+ await act(async () => {
99
+ resolve({ value: { kind: "image", url: "blob:done", aspect: 1 }, weight: 1 });
100
+ await pending;
101
+ });
102
+ act(() => root.unmount());
103
+ });
104
+
105
+ it("resubscribes when the request work shape changes", async () => {
106
+ const scheduler = new ThumbnailScheduler();
107
+ const imageLoad = vi.fn(async () => ({
108
+ value: { kind: "image" as const, url: "blob:image", aspect: 1 },
109
+ weight: 1,
110
+ }));
111
+ const videoLoad = vi.fn(async () => ({
112
+ value: { kind: "image" as const, url: "blob:video", aspect: 1 },
113
+ weight: 1,
114
+ }));
115
+ let kind: ThumbnailRequest["kind"] = "image";
116
+ let rich = false;
117
+ function Probe() {
118
+ useThumbnailLease(
119
+ {
120
+ key: "same-content",
121
+ projectId: "demo",
122
+ sessionEpoch: 1,
123
+ kind,
124
+ rich,
125
+ priority: "visible",
126
+ load: kind === "image" ? imageLoad : videoLoad,
127
+ },
128
+ scheduler,
129
+ );
130
+ return null;
131
+ }
132
+ const root = createRoot(document.createElement("div"));
133
+ await act(async () => {
134
+ root.render(React.createElement(Probe));
135
+ await Promise.resolve();
136
+ });
137
+
138
+ kind = "video";
139
+ rich = true;
140
+ await act(async () => {
141
+ root.render(React.createElement(Probe));
142
+ await Promise.resolve();
143
+ });
144
+
145
+ expect(imageLoad).toHaveBeenCalledTimes(1);
146
+ expect(videoLoad).toHaveBeenCalledTimes(1);
147
+ expect(scheduler.getDiagnostics().leases).toBe(1);
148
+ act(() => root.unmount());
149
+ });
150
+ });
@@ -0,0 +1,44 @@
1
+ import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
2
+ import {
3
+ createThumbnailRequestIdentity,
4
+ thumbnailScheduler,
5
+ type ThumbnailRequest,
6
+ type ThumbnailScheduler,
7
+ type ThumbnailSnapshot,
8
+ } from "../player/lib/thumbnailScheduler";
9
+
10
+ const IDLE: ThumbnailSnapshot = Object.freeze({ status: "idle" });
11
+
12
+ export function useThumbnailLease(
13
+ request: ThumbnailRequest | null,
14
+ scheduler: ThumbnailScheduler = thumbnailScheduler,
15
+ ): ThumbnailSnapshot {
16
+ const requestRef = useRef(request);
17
+ requestRef.current = request;
18
+ const leaseRef = useRef<ReturnType<ThumbnailScheduler["acquire"]> | null>(null);
19
+ const identity = request ? createThumbnailRequestIdentity(request) : null;
20
+ const priority = request?.priority;
21
+ const subscribe = useCallback(
22
+ (listener: () => void) => {
23
+ const current = requestRef.current;
24
+ if (!current || identity === null) return () => {};
25
+ const lease = scheduler.acquire(current, listener);
26
+ leaseRef.current = lease;
27
+ return () => {
28
+ if (leaseRef.current === lease) leaseRef.current = null;
29
+ lease.release();
30
+ };
31
+ },
32
+ [identity, scheduler],
33
+ );
34
+ const getSnapshot = useCallback(() => {
35
+ const current = requestRef.current;
36
+ return current && identity !== null ? scheduler.getSnapshot(current) : IDLE;
37
+ }, [identity, scheduler]);
38
+
39
+ useLayoutEffect(() => {
40
+ if (priority) leaseRef.current?.updatePriority(priority);
41
+ }, [priority]);
42
+
43
+ return useSyncExternalStore(subscribe, getSnapshot, () => IDLE);
44
+ }
@@ -24,6 +24,7 @@ interface HarnessProps {
24
24
  options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
25
25
  ) => void;
26
26
  applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
27
+ onSelectionNotFound: () => void;
27
28
  }
28
29
 
29
30
  afterEach(() => {
@@ -67,8 +68,8 @@ function makeSyncFixture() {
67
68
  const firstSelection = makeSelection("First", firstElement);
68
69
  const secondSelection = makeSelection("Second", secondElement);
69
70
  const timelineElements: TimelineElement[] = [
70
- { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
71
- { id: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
71
+ { id: "clip-1", domId: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
72
+ { id: "clip-2", domId: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
72
73
  ];
73
74
  const selectionById = new Map([
74
75
  ["clip-1", firstSelection],
@@ -96,6 +97,7 @@ describe("useTimelineSelectionPreviewSync", () => {
96
97
  buildDomSelectionForTimelineElement,
97
98
  applyDomSelection,
98
99
  applyMarqueeSelection,
100
+ onSelectionNotFound: vi.fn(),
99
101
  });
100
102
 
101
103
  expect(applyMarqueeSelection).toHaveBeenCalledWith([secondSelection, firstSelection], false);
@@ -108,6 +110,21 @@ describe("useTimelineSelectionPreviewSync", () => {
108
110
  const applyDomSelection = vi.fn();
109
111
  const applyMarqueeSelection = vi.fn();
110
112
  const harness = renderHarness();
113
+ const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
114
+ return selectionById.get(element.id) ?? null;
115
+ });
116
+
117
+ await harness.rerender({
118
+ selectedElementId: "clip-1",
119
+ selectedElementIds: new Set(["clip-1"]),
120
+ timelineElements,
121
+ domEditSelection: firstSelection,
122
+ domEditGroupSelections: [firstSelection],
123
+ buildDomSelectionForTimelineElement,
124
+ applyDomSelection,
125
+ applyMarqueeSelection,
126
+ onSelectionNotFound: vi.fn(),
127
+ });
111
128
 
112
129
  await harness.rerender({
113
130
  selectedElementId: null,
@@ -115,15 +132,72 @@ describe("useTimelineSelectionPreviewSync", () => {
115
132
  timelineElements,
116
133
  domEditSelection: firstSelection,
117
134
  domEditGroupSelections: [firstSelection],
118
- buildDomSelectionForTimelineElement: vi.fn(async (element: TimelineElement) => {
119
- return selectionById.get(element.id) ?? null;
120
- }),
135
+ buildDomSelectionForTimelineElement,
121
136
  applyDomSelection,
122
137
  applyMarqueeSelection,
138
+ onSelectionNotFound: vi.fn(),
123
139
  });
124
140
 
125
141
  expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
126
142
  expect(applyMarqueeSelection).not.toHaveBeenCalled();
127
143
  harness.cleanup();
128
144
  });
145
+
146
+ it("warns once while retrying a timeline selection after preview refreshes", async () => {
147
+ const { secondSelection, timelineElements } = makeSyncFixture();
148
+ const applyDomSelection = vi.fn();
149
+ const applyMarqueeSelection = vi.fn();
150
+ const onSelectionNotFound = vi.fn();
151
+ let previewReady = false;
152
+ const buildDomSelectionForTimelineElement = vi.fn(async () =>
153
+ previewReady ? secondSelection : null,
154
+ );
155
+ const selectedElementIds = new Set(["clip-2"]);
156
+ const harness = renderHarness();
157
+
158
+ await harness.rerender({
159
+ selectedElementId: "clip-2",
160
+ selectedElementIds,
161
+ timelineElements,
162
+ domEditSelection: null,
163
+ domEditGroupSelections: [],
164
+ buildDomSelectionForTimelineElement,
165
+ applyDomSelection,
166
+ applyMarqueeSelection,
167
+ onSelectionNotFound,
168
+ });
169
+
170
+ expect(onSelectionNotFound).toHaveBeenCalledOnce();
171
+ expect(applyDomSelection).not.toHaveBeenCalled();
172
+
173
+ await harness.rerender({
174
+ selectedElementId: "clip-2",
175
+ selectedElementIds,
176
+ timelineElements: [...timelineElements],
177
+ domEditSelection: null,
178
+ domEditGroupSelections: [],
179
+ buildDomSelectionForTimelineElement,
180
+ applyDomSelection,
181
+ applyMarqueeSelection,
182
+ onSelectionNotFound,
183
+ });
184
+
185
+ expect(onSelectionNotFound).toHaveBeenCalledOnce();
186
+
187
+ previewReady = true;
188
+ await harness.rerender({
189
+ selectedElementId: "clip-2",
190
+ selectedElementIds,
191
+ timelineElements: [...timelineElements],
192
+ domEditSelection: null,
193
+ domEditGroupSelections: [],
194
+ buildDomSelectionForTimelineElement,
195
+ applyDomSelection,
196
+ applyMarqueeSelection,
197
+ onSelectionNotFound,
198
+ });
199
+
200
+ expect(applyDomSelection).toHaveBeenCalledWith(secondSelection);
201
+ harness.cleanup();
202
+ });
129
203
  });
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo } from "react";
1
+ import { useEffect, useMemo, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
@@ -18,6 +18,7 @@ interface UseTimelineSelectionPreviewSyncParams {
18
18
  options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
19
19
  ) => void;
20
20
  applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
21
+ onSelectionNotFound: () => void;
21
22
  }
22
23
 
23
24
  function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
@@ -56,34 +57,51 @@ export function useTimelineSelectionPreviewSync({
56
57
  buildDomSelectionForTimelineElement,
57
58
  applyDomSelection,
58
59
  applyMarqueeSelection,
60
+ onSelectionNotFound,
59
61
  }: UseTimelineSelectionPreviewSyncParams): void {
60
62
  const selectedIds = useMemo(
61
63
  () => orderSelectedIds(selectedElementIds, selectedElementId),
62
64
  [selectedElementId, selectedElementIds],
63
65
  );
64
66
  const selectedKey = selectedIds.join("\0");
67
+ const domEditSelectionRef = useRef(domEditSelection);
68
+ const domEditGroupSelectionsRef = useRef(domEditGroupSelections);
69
+ const lastSyncedSelectedKeyRef = useRef("");
70
+ const missingSelectionKeyRef = useRef("");
71
+ domEditSelectionRef.current = domEditSelection;
72
+ domEditGroupSelectionsRef.current = domEditGroupSelections;
65
73
 
66
74
  useEffect(() => {
75
+ const previousSelectedKey = lastSyncedSelectedKeyRef.current;
76
+ lastSyncedSelectedKeyRef.current = selectedKey;
77
+ const currentDomEditSelection = domEditSelectionRef.current;
78
+ const currentDomEditGroupSelections = domEditGroupSelectionsRef.current;
67
79
  const currentSelections =
68
- domEditGroupSelections.length > 1
69
- ? domEditGroupSelections
70
- : domEditSelection
71
- ? [domEditSelection]
80
+ currentDomEditGroupSelections.length > 1
81
+ ? currentDomEditGroupSelections
82
+ : currentDomEditSelection
83
+ ? [currentDomEditSelection]
72
84
  : [];
73
85
  const currentIds = currentSelections
74
86
  .map((selection) =>
75
87
  resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
76
88
  )
77
89
  .filter((id): id is string => Boolean(id));
78
- const currentAnchor = domEditSelection
79
- ? resolveTimelineIdForSelection(domEditSelection, timelineElements, activeCompPath)
90
+ const currentAnchor = currentDomEditSelection
91
+ ? resolveTimelineIdForSelection(currentDomEditSelection, timelineElements, activeCompPath)
80
92
  : null;
81
93
 
82
94
  if (selectedIds.length === 0) {
83
- if (currentSelections.length > 0) applyDomSelection(null, { revealPanel: false });
95
+ missingSelectionKeyRef.current = "";
96
+ if (previousSelectedKey.length > 0 && currentIds.length > 0) {
97
+ applyDomSelection(null, { revealPanel: false });
98
+ }
99
+ return;
100
+ }
101
+ if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) {
102
+ missingSelectionKeyRef.current = "";
84
103
  return;
85
104
  }
86
- if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return;
87
105
 
88
106
  let cancelled = false;
89
107
  const syncSelection = async () => {
@@ -101,11 +119,18 @@ export function useTimelineSelectionPreviewSync({
101
119
  // shrunk set back and silently drop the members whose DOM node was not ready.
102
120
  // Bail instead; a later effect run (on timelineElements/DOM change) applies the
103
121
  // full set once every resolvable member has a live node.
104
- if (selections.length < resolvableCount) return;
122
+ if (selections.length < resolvableCount) {
123
+ if (missingSelectionKeyRef.current !== selectedKey) {
124
+ missingSelectionKeyRef.current = selectedKey;
125
+ onSelectionNotFound();
126
+ }
127
+ return;
128
+ }
129
+ missingSelectionKeyRef.current = "";
105
130
  if (selections.length === 0) {
106
131
  applyDomSelection(null, { revealPanel: false });
107
132
  } else if (selections.length === 1) {
108
- applyDomSelection(selections[0], { revealPanel: false });
133
+ applyDomSelection(selections[0]);
109
134
  } else {
110
135
  applyMarqueeSelection(selections, false);
111
136
  }
@@ -115,13 +140,15 @@ export function useTimelineSelectionPreviewSync({
115
140
  return () => {
116
141
  cancelled = true;
117
142
  };
143
+ // DOM selection changes are read through refs. Depending on them directly
144
+ // would let the preview-to-timeline echo cancel an in-flight timeline click.
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
146
  }, [
119
147
  activeCompPath,
120
148
  applyDomSelection,
121
149
  applyMarqueeSelection,
122
150
  buildDomSelectionForTimelineElement,
123
- domEditGroupSelections,
124
- domEditSelection,
151
+ onSelectionNotFound,
125
152
  selectedElementId,
126
153
  selectedIds,
127
154
  selectedKey,
@@ -0,0 +1,53 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ const { leaseSpy } = vi.hoisted(() => ({
7
+ leaseSpy: vi.fn((_request: unknown) => ({ status: "loading" as const })),
8
+ }));
9
+
10
+ vi.mock("../../hooks/useThumbnailLease", () => ({
11
+ useThumbnailLease: leaseSpy,
12
+ }));
13
+
14
+ import { AudioWaveform } from "./AudioWaveform";
15
+
16
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17
+
18
+ afterEach(() => {
19
+ leaseSpy.mockClear();
20
+ document.body.innerHTML = "";
21
+ });
22
+
23
+ describe("AudioWaveform", () => {
24
+ it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
25
+ const host = document.createElement("div");
26
+ document.body.append(host);
27
+ const root = createRoot(host);
28
+
29
+ act(() => {
30
+ root.render(
31
+ <AudioWaveform
32
+ audioUrl="/media/voice.wav"
33
+ label=""
34
+ labelColor="#fff"
35
+ projectId="project-a"
36
+ sessionEpoch={9}
37
+ priority="interaction"
38
+ />,
39
+ );
40
+ });
41
+
42
+ expect(leaseSpy).toHaveBeenCalled();
43
+ expect(leaseSpy.mock.calls.at(-1)?.[0]).toMatchObject({
44
+ projectId: "project-a",
45
+ sessionEpoch: 9,
46
+ kind: "waveform",
47
+ priority: "interaction",
48
+ rich: false,
49
+ });
50
+
51
+ act(() => root.unmount());
52
+ });
53
+ });