@hyperframes/studio 0.8.56 → 0.8.58
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.
- package/dist/assets/hyperframes-player-zGyOcwwh.js +464 -0
- package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
- package/dist/index.d.ts +124 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15686 -14882
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +48 -6
- package/src/components/nle/NLEPreview.test.ts +67 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/hooks/useCompositionDimensions.test.tsx +37 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
- package/src/hooks/useSdkSession.ts +77 -14
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +21 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +175 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/player/index.ts +7 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
|
@@ -1,105 +1,25 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { act } from "react";
|
|
5
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { useTimelinePlayer } from "./useTimelinePlayer";
|
|
5
|
+
import type { useTimelinePlayer } from "./useTimelinePlayer";
|
|
6
|
+
import {
|
|
7
|
+
attachIframeAdapter,
|
|
8
|
+
attachIframeWindow,
|
|
9
|
+
renderTimelinePlayerHarness,
|
|
10
|
+
resetPlayerStore,
|
|
11
|
+
} from "./timelinePlayerTestHarness";
|
|
7
12
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
8
13
|
import { setTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
|
9
14
|
|
|
10
15
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
16
|
|
|
12
|
-
function resetPlayerStore() {
|
|
13
|
-
usePlayerStore.getState().reset();
|
|
14
|
-
usePlayerStore.setState({ requestedSeekTime: null });
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function TimelinePlayerHarness({
|
|
18
|
-
onValue,
|
|
19
|
-
}: {
|
|
20
|
-
onValue: (value: ReturnType<typeof useTimelinePlayer>) => void;
|
|
21
|
-
}) {
|
|
22
|
-
const value = useTimelinePlayer();
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
onValue(value);
|
|
25
|
-
}, [onValue, value]);
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function renderTimelinePlayerHarness() {
|
|
30
|
-
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
|
31
|
-
const host = document.createElement("div");
|
|
32
|
-
document.body.append(host);
|
|
33
|
-
const root = createRoot(host);
|
|
34
|
-
|
|
35
|
-
act(() => {
|
|
36
|
-
root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
if (!api) throw new Error("useTimelinePlayer did not mount");
|
|
40
|
-
return { api, root };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
17
|
afterEach(() => {
|
|
44
18
|
setTimelinePerformanceFixtureLease(false);
|
|
45
19
|
document.body.innerHTML = "";
|
|
46
20
|
resetPlayerStore();
|
|
47
21
|
});
|
|
48
22
|
|
|
49
|
-
function attachIframeWindow(
|
|
50
|
-
api: ReturnType<typeof useTimelinePlayer>,
|
|
51
|
-
iframeWindow: Record<string, unknown>,
|
|
52
|
-
): void {
|
|
53
|
-
const iframe = document.createElement("iframe");
|
|
54
|
-
Object.defineProperty(iframe, "contentWindow", {
|
|
55
|
-
value: iframeWindow,
|
|
56
|
-
configurable: true,
|
|
57
|
-
});
|
|
58
|
-
Object.defineProperty(iframe, "contentDocument", {
|
|
59
|
-
value: document.implementation.createHTMLDocument("preview"),
|
|
60
|
-
configurable: true,
|
|
61
|
-
});
|
|
62
|
-
act(() => {
|
|
63
|
-
api.iframeRef.current = iframe;
|
|
64
|
-
api.onIframeLoad();
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function attachIframeAdapter(
|
|
69
|
-
api: ReturnType<typeof useTimelinePlayer>,
|
|
70
|
-
options: {
|
|
71
|
-
postMessage?: (message: unknown, targetOrigin: string) => void;
|
|
72
|
-
timelines?: Record<string, unknown>;
|
|
73
|
-
duration?: number;
|
|
74
|
-
} = {},
|
|
75
|
-
) {
|
|
76
|
-
let currentTime = 0;
|
|
77
|
-
let playing = false;
|
|
78
|
-
const adapter = {
|
|
79
|
-
play: vi.fn(() => {
|
|
80
|
-
playing = true;
|
|
81
|
-
}),
|
|
82
|
-
pause: vi.fn(() => {
|
|
83
|
-
playing = false;
|
|
84
|
-
}),
|
|
85
|
-
seek: (time: number) => {
|
|
86
|
-
currentTime = time;
|
|
87
|
-
},
|
|
88
|
-
getTime: () => currentTime,
|
|
89
|
-
getDuration: () => options.duration ?? 30,
|
|
90
|
-
isPlaying: () => playing,
|
|
91
|
-
};
|
|
92
|
-
attachIframeWindow(api, {
|
|
93
|
-
__player: adapter,
|
|
94
|
-
__timelines: options.timelines,
|
|
95
|
-
postMessage: options.postMessage ?? (() => {}),
|
|
96
|
-
scrollTo: () => {},
|
|
97
|
-
addEventListener: () => {},
|
|
98
|
-
removeEventListener: () => {},
|
|
99
|
-
});
|
|
100
|
-
return adapter;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
23
|
function renderAttachedTimelinePlayer() {
|
|
104
24
|
const { api, root } = renderTimelinePlayerHarness();
|
|
105
25
|
const adapter = attachIframeAdapter(api);
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// A full-reload edit must never hide the live iframe until the shadow has painted.
|
|
3
|
+
|
|
4
|
+
import React, { act } from "react";
|
|
5
|
+
import { createRoot } from "react-dom/client";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
|
|
10
|
+
import {
|
|
11
|
+
makeAdapterWindow,
|
|
12
|
+
makeFakeIframe,
|
|
13
|
+
renderTimelinePlayerHarness,
|
|
14
|
+
resetPlayerStore,
|
|
15
|
+
} from "./timelinePlayerTestHarness";
|
|
16
|
+
|
|
17
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
|
|
19
|
+
vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
|
|
20
|
+
...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
|
|
21
|
+
ensureMotionPathPluginLoaded: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
resetPlayerStore();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeLiveIframe(): HTMLIFrameElement {
|
|
32
|
+
const iframe = makeFakeIframe(makeAdapterWindow().win);
|
|
33
|
+
iframe.src = "http://localhost/api/projects/demo/preview";
|
|
34
|
+
return iframe;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeShadowWithSpies() {
|
|
38
|
+
const { adapter, win } = makeAdapterWindow();
|
|
39
|
+
adapter.seek = vi.fn(adapter.seek);
|
|
40
|
+
const iframe = makeFakeIframe(win);
|
|
41
|
+
iframe.src = "http://localhost/api/projects/demo/preview?_t=1";
|
|
42
|
+
return { adapter, iframe };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("useTimelinePlayer shadow reload", () => {
|
|
46
|
+
it("never hides or removes the live iframe before the shadow signals it has painted", () => {
|
|
47
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
48
|
+
const liveIframe = makeLiveIframe();
|
|
49
|
+
|
|
50
|
+
act(() => {
|
|
51
|
+
getApi().iframeRef.current = liveIframe;
|
|
52
|
+
getApi().onIframeLoad();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
|
|
56
|
+
expect(liveIframe.style.visibility).toBe("");
|
|
57
|
+
|
|
58
|
+
// A lane-move drop persists, then bumps refreshKey -> refreshPlayer().
|
|
59
|
+
act(() => {
|
|
60
|
+
getApi().refreshPlayer();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// The live iframe must be untouched: still the ref target, still visible,
|
|
64
|
+
// still in the slot list as "live". A shadow slot appears alongside it —
|
|
65
|
+
// this is the queued background reload, not a swap.
|
|
66
|
+
expect(getApi().iframeRef.current).toBe(liveIframe);
|
|
67
|
+
expect(liveIframe.style.visibility).toBe("");
|
|
68
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
69
|
+
const liveSlot = getApi().previewSlots.find((s) => s.role === "live");
|
|
70
|
+
const shadowSlot = getApi().previewSlots.find((s) => s.role === "shadow");
|
|
71
|
+
expect(liveSlot).toEqual({ gen: 0, role: "live" });
|
|
72
|
+
expect(shadowSlot?.role).toBe("shadow");
|
|
73
|
+
expect(shadowSlot?.url).toBeTruthy();
|
|
74
|
+
|
|
75
|
+
// The shadow iframe loads and its restore-seek finds a ready adapter —
|
|
76
|
+
// the readiness signal this whole mechanism hangs off.
|
|
77
|
+
const shadowIframe = makeLiveIframe();
|
|
78
|
+
act(() => {
|
|
79
|
+
getApi().setShadowIframeNode(shadowIframe);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Still untouched right up to the instant before the ready signal.
|
|
83
|
+
expect(getApi().iframeRef.current).toBe(liveIframe);
|
|
84
|
+
expect(liveIframe.style.visibility).toBe("");
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
getApi().onShadowIframeLoad(shadowSlot!.gen);
|
|
88
|
+
getApi().onShadowReadyChange(shadowSlot!.gen, true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Promotion: a single atomic swap. Exactly one live slot, pointing at the
|
|
92
|
+
// shadow's iframe; the old live slot is gone, not merely hidden.
|
|
93
|
+
expect(getApi().previewSlots).toEqual([
|
|
94
|
+
{ gen: shadowSlot!.gen, role: "live", url: shadowSlot!.url },
|
|
95
|
+
]);
|
|
96
|
+
expect(getApi().iframeRef.current).toBe(shadowIframe);
|
|
97
|
+
|
|
98
|
+
unmount(root);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("replaces a superseded shadow rather than accumulating extra slots (repeated lane-moves)", () => {
|
|
102
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
103
|
+
const liveIframe = makeLiveIframe();
|
|
104
|
+
act(() => {
|
|
105
|
+
getApi().iframeRef.current = liveIframe;
|
|
106
|
+
getApi().onIframeLoad();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
getApi().refreshPlayer();
|
|
111
|
+
});
|
|
112
|
+
const firstShadowGen = getApi().previewSlots.find((s) => s.role === "shadow")?.gen;
|
|
113
|
+
expect(firstShadowGen).toBeDefined();
|
|
114
|
+
|
|
115
|
+
// The first shadow's iframe finishes loading and is fully adapter-ready
|
|
116
|
+
// (a real "would have painted correctly" candidate) before it is
|
|
117
|
+
// superseded.
|
|
118
|
+
const firstShadow = makeShadowWithSpies();
|
|
119
|
+
act(() => {
|
|
120
|
+
getApi().setShadowIframeNode(firstShadow.iframe);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// A second edit lands before the first shadow's readiness was consumed.
|
|
124
|
+
act(() => {
|
|
125
|
+
getApi().refreshPlayer();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Never more than one extra (shadow) slot alive at a time.
|
|
129
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
130
|
+
const secondShadow = getApi().previewSlots.find((s) => s.role === "shadow");
|
|
131
|
+
expect(secondShadow?.gen).not.toBe(firstShadowGen);
|
|
132
|
+
|
|
133
|
+
// The first shadow's readiness signal now arrives late (its own iframe is
|
|
134
|
+
// still referenced, fully ready). It must NOT promote — that content was
|
|
135
|
+
// replaced before it ever painted, and gen no longer matches the current
|
|
136
|
+
// pending shadow.
|
|
137
|
+
act(() => {
|
|
138
|
+
getApi().onShadowIframeLoad(firstShadowGen!);
|
|
139
|
+
getApi().onShadowReadyChange(firstShadowGen!, true);
|
|
140
|
+
});
|
|
141
|
+
expect(getApi().previewSlots.find((s) => s.role === "live")).toEqual({ gen: 0, role: "live" });
|
|
142
|
+
expect(getApi().iframeRef.current).toBe(liveIframe);
|
|
143
|
+
expect(firstShadow.adapter.seek).not.toHaveBeenCalled();
|
|
144
|
+
|
|
145
|
+
unmount(root);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("drop that removes a covered clip (reloadPreview -> refreshKey bump)", () => {
|
|
150
|
+
it("swaps in the reloaded document without ever hiding the live iframe", async () => {
|
|
151
|
+
vi.stubGlobal(
|
|
152
|
+
"fetch",
|
|
153
|
+
vi.fn(async () => new Response("{}", { status: 404 })),
|
|
154
|
+
);
|
|
155
|
+
let ctx: NLEContextValue | null = null;
|
|
156
|
+
const Probe = () => {
|
|
157
|
+
ctx = useNLEContext();
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
160
|
+
const host = document.createElement("div");
|
|
161
|
+
document.body.append(host);
|
|
162
|
+
const root = createRoot(host);
|
|
163
|
+
const render = (refreshKey: number) =>
|
|
164
|
+
act(async () => {
|
|
165
|
+
root.render(
|
|
166
|
+
React.createElement(
|
|
167
|
+
NLEProvider,
|
|
168
|
+
{ projectId: "demo", refreshKey },
|
|
169
|
+
React.createElement(Probe),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
});
|
|
174
|
+
await render(0);
|
|
175
|
+
const live = makeLiveIframe();
|
|
176
|
+
await act(async () => {
|
|
177
|
+
ctx!.iframeRef.current = live;
|
|
178
|
+
ctx!.onIframeLoad();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// runPlacementSteps ends in exactly one reloadPreview: App bumps refreshKey.
|
|
182
|
+
await render(1);
|
|
183
|
+
expect(live.style.visibility).toBe("");
|
|
184
|
+
expect(ctx!.iframeRef.current).toBe(live);
|
|
185
|
+
const shadow = ctx!.previewSlots.find((s) => s.role === "shadow");
|
|
186
|
+
expect(shadow).toBeDefined();
|
|
187
|
+
expect(ctx!.previewSlots).toHaveLength(2);
|
|
188
|
+
|
|
189
|
+
const shadowIframe = makeLiveIframe();
|
|
190
|
+
await act(async () => {
|
|
191
|
+
ctx!.setShadowIframeNode(shadowIframe);
|
|
192
|
+
ctx!.onShadowIframeLoad(shadow!.gen);
|
|
193
|
+
ctx!.onShadowReadyChange(shadow!.gen, true);
|
|
194
|
+
});
|
|
195
|
+
expect(ctx!.previewSlots).toEqual([{ ...shadow!, role: "live" }]);
|
|
196
|
+
expect(ctx!.iframeRef.current).toBe(shadowIframe);
|
|
197
|
+
|
|
198
|
+
await act(async () => root.unmount());
|
|
199
|
+
vi.unstubAllGlobals();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("shadow reload readiness and failure", () => {
|
|
204
|
+
function beginReload(options: Parameters<typeof renderTimelinePlayerHarness>[0] = {}) {
|
|
205
|
+
const harness = renderTimelinePlayerHarness(options);
|
|
206
|
+
const { adapter, win } = makeAdapterWindow();
|
|
207
|
+
adapter.seek = vi.fn(adapter.seek);
|
|
208
|
+
const live = makeFakeIframe(win);
|
|
209
|
+
act(() => {
|
|
210
|
+
harness.getApi().iframeRef.current = live;
|
|
211
|
+
harness.getApi().onIframeLoad();
|
|
212
|
+
});
|
|
213
|
+
adapter.pause.mockClear();
|
|
214
|
+
act(() => harness.getApi().refreshPlayer());
|
|
215
|
+
const gen = harness.getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
216
|
+
return { ...harness, live, liveAdapter: adapter, gen };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
it("pauses the live adapter when a reload starts", () => {
|
|
220
|
+
const { liveAdapter, root } = beginReload();
|
|
221
|
+
expect(liveAdapter.pause).toHaveBeenCalled();
|
|
222
|
+
unmount(root);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("does not promote a shadow whose loader is still up, and promotes once it clears", () => {
|
|
226
|
+
const { getApi, live, gen, root } = beginReload();
|
|
227
|
+
const shadow = makeShadowWithSpies();
|
|
228
|
+
act(() => {
|
|
229
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
230
|
+
getApi().onShadowIframeLoad(gen);
|
|
231
|
+
});
|
|
232
|
+
expect(getApi().iframeRef.current).toBe(live);
|
|
233
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
234
|
+
|
|
235
|
+
act(() => getApi().onShadowReadyChange(gen, true));
|
|
236
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
237
|
+
expect(getApi().previewSlots).toEqual([{ gen, role: "live", url: expect.any(String) }]);
|
|
238
|
+
unmount(root);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("promotes when the loader clears before the adapter is ready", () => {
|
|
242
|
+
const { getApi, gen, root } = beginReload();
|
|
243
|
+
const shadow = makeShadowWithSpies();
|
|
244
|
+
act(() => {
|
|
245
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
246
|
+
getApi().onShadowReadyChange(gen, true);
|
|
247
|
+
});
|
|
248
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
249
|
+
act(() => getApi().onShadowIframeLoad(gen));
|
|
250
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
251
|
+
unmount(root);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("drops a shadow that never becomes ready, keeps the live frame and reports why", () => {
|
|
255
|
+
vi.useFakeTimers();
|
|
256
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
257
|
+
const onPreviewReloadFailed = vi.fn();
|
|
258
|
+
const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
|
|
259
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
260
|
+
|
|
261
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
|
|
262
|
+
|
|
263
|
+
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
|
|
264
|
+
expect(getApi().iframeRef.current).toBe(live);
|
|
265
|
+
expect(live.style.visibility).toBe("");
|
|
266
|
+
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
|
|
267
|
+
expect(consoleError).toHaveBeenCalled();
|
|
268
|
+
unmount(root);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("keeps a slow shadow pending past 5s and fails it only at the single budget", () => {
|
|
272
|
+
vi.useFakeTimers();
|
|
273
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
274
|
+
const onPreviewReloadFailed = vi.fn();
|
|
275
|
+
const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
|
|
276
|
+
const silent = makeFakeIframe({});
|
|
277
|
+
act(() => {
|
|
278
|
+
getApi().setShadowIframeNode(silent);
|
|
279
|
+
getApi().onShadowIframeLoad(gen);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
act(() => void vi.advanceTimersByTime(5001));
|
|
283
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
284
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
285
|
+
|
|
286
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
|
|
287
|
+
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
|
|
288
|
+
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
|
|
289
|
+
unmount(root);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("drops a shadow whose document reports an error and reports the cause", () => {
|
|
293
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
294
|
+
const onPreviewReloadFailed = vi.fn();
|
|
295
|
+
const { getApi, live, gen, root } = beginReload({ onPreviewReloadFailed });
|
|
296
|
+
|
|
297
|
+
act(() => getApi().onShadowError(gen, "Composition timeline not found after 8s"));
|
|
298
|
+
|
|
299
|
+
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
|
|
300
|
+
expect(getApi().iframeRef.current).toBe(live);
|
|
301
|
+
expect(onPreviewReloadFailed).toHaveBeenCalledWith(
|
|
302
|
+
expect.stringContaining("Composition timeline not found after 8s"),
|
|
303
|
+
);
|
|
304
|
+
unmount(root);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("runs no load side effects for a superseded shadow", () => {
|
|
308
|
+
const { getApi, gen, root } = beginReload();
|
|
309
|
+
usePlayerStore.getState().setCurrentTime(7);
|
|
310
|
+
const stale = makeShadowWithSpies();
|
|
311
|
+
act(() => {
|
|
312
|
+
getApi().setShadowIframeNode(stale.iframe);
|
|
313
|
+
getApi().refreshPlayer();
|
|
314
|
+
});
|
|
315
|
+
act(() => getApi().onShadowIframeLoad(gen));
|
|
316
|
+
expect(stale.adapter.seek).not.toHaveBeenCalled();
|
|
317
|
+
unmount(root);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("shadow reload store ownership and readiness", () => {
|
|
322
|
+
function setup(options?: Parameters<typeof renderTimelinePlayerHarness>[0]) {
|
|
323
|
+
const harness = renderTimelinePlayerHarness(options);
|
|
324
|
+
const live = makeAdapterWindow({ duration: 10 });
|
|
325
|
+
const liveIframe = makeFakeIframe(live.win);
|
|
326
|
+
act(() => {
|
|
327
|
+
harness.getApi().iframeRef.current = liveIframe;
|
|
328
|
+
harness.getApi().onIframeLoad();
|
|
329
|
+
});
|
|
330
|
+
act(() => harness.getApi().refreshPlayer());
|
|
331
|
+
const gen = harness.getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
332
|
+
const shadow = makeAdapterWindow({ duration: 42 });
|
|
333
|
+
const shadowIframe = makeFakeIframe(shadow.win);
|
|
334
|
+
act(() => harness.getApi().setShadowIframeNode(shadowIframe));
|
|
335
|
+
return { ...harness, gen, shadowIframe, liveIframe };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
it("does not promote when the loader re-raises after it had cleared", () => {
|
|
339
|
+
const { getApi, gen, liveIframe, root } = setup();
|
|
340
|
+
act(() => {
|
|
341
|
+
getApi().onShadowReadyChange(gen, true);
|
|
342
|
+
getApi().onShadowReadyChange(gen, false);
|
|
343
|
+
getApi().onShadowIframeLoad(gen);
|
|
344
|
+
});
|
|
345
|
+
expect(getApi().iframeRef.current).toBe(liveIframe);
|
|
346
|
+
act(() => getApi().onShadowReadyChange(gen, true));
|
|
347
|
+
expect(getApi().iframeRef.current).not.toBe(liveIframe);
|
|
348
|
+
unmount(root);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("keeps the old timeline in the store until the shadow is promoted", () => {
|
|
352
|
+
const { getApi, gen, root } = setup();
|
|
353
|
+
const before = usePlayerStore.getState().duration;
|
|
354
|
+
act(() => getApi().onShadowIframeLoad(gen));
|
|
355
|
+
expect(usePlayerStore.getState().duration).toBe(before);
|
|
356
|
+
act(() => getApi().onShadowReadyChange(gen, true));
|
|
357
|
+
expect(usePlayerStore.getState().duration).toBe(42);
|
|
358
|
+
unmount(root);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("never writes a failed shadow's timeline into the store", () => {
|
|
362
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
363
|
+
const { getApi, gen, root } = setup();
|
|
364
|
+
const before = usePlayerStore.getState().duration;
|
|
365
|
+
act(() => getApi().onShadowIframeLoad(gen));
|
|
366
|
+
act(() => getApi().onShadowError(gen, "boom"));
|
|
367
|
+
expect(usePlayerStore.getState().duration).toBe(before);
|
|
368
|
+
unmount(root);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("a composition switch clears the refresh flags so the next load restores the store playhead", () => {
|
|
372
|
+
const { getApi, liveIframe, root } = setup();
|
|
373
|
+
const seek = vi.fn();
|
|
374
|
+
(liveIframe.contentWindow as unknown as { __player: { seek: unknown } }).__player.seek = seek;
|
|
375
|
+
usePlayerStore.getState().setCurrentTime(7);
|
|
376
|
+
act(() => getApi().resetPreviewSlots());
|
|
377
|
+
act(() => getApi().onIframeLoad());
|
|
378
|
+
expect(seek).toHaveBeenCalledWith(7);
|
|
379
|
+
unmount(root);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("uses the latest failure callback for a timer armed before it changed", () => {
|
|
383
|
+
vi.useFakeTimers();
|
|
384
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
385
|
+
const first = vi.fn();
|
|
386
|
+
const latest = vi.fn();
|
|
387
|
+
const { getApi, rerender, root } = setup({ onPreviewReloadFailed: first });
|
|
388
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
389
|
+
rerender({ onPreviewReloadFailed: latest });
|
|
390
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
|
|
391
|
+
expect(latest).toHaveBeenCalled();
|
|
392
|
+
expect(first).not.toHaveBeenCalled();
|
|
393
|
+
unmount(root);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("NLEProvider iframe ref notifications", () => {
|
|
398
|
+
it("tells the consumer about the reloaded iframe once, on promotion", async () => {
|
|
399
|
+
vi.stubGlobal(
|
|
400
|
+
"fetch",
|
|
401
|
+
vi.fn(async () => new Response("{}", { status: 404 })),
|
|
402
|
+
);
|
|
403
|
+
const { ensureMotionPathPluginLoaded } = await import("../../utils/gsapSoftReload");
|
|
404
|
+
const onIframeRef = vi.fn();
|
|
405
|
+
let ctx: NLEContextValue | null = null;
|
|
406
|
+
const Probe = () => {
|
|
407
|
+
ctx = useNLEContext();
|
|
408
|
+
return null;
|
|
409
|
+
};
|
|
410
|
+
const host = document.createElement("div");
|
|
411
|
+
document.body.append(host);
|
|
412
|
+
const root = createRoot(host);
|
|
413
|
+
const render = (refreshKey: number) =>
|
|
414
|
+
act(async () => {
|
|
415
|
+
root.render(
|
|
416
|
+
React.createElement(
|
|
417
|
+
NLEProvider,
|
|
418
|
+
{ projectId: "demo", refreshKey, onIframeRef },
|
|
419
|
+
React.createElement(Probe),
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
await Promise.resolve();
|
|
423
|
+
});
|
|
424
|
+
await render(0);
|
|
425
|
+
const live = makeLiveIframe();
|
|
426
|
+
await act(async () => {
|
|
427
|
+
ctx!.iframeRef.current = live;
|
|
428
|
+
ctx!.onIframeLoad();
|
|
429
|
+
});
|
|
430
|
+
onIframeRef.mockClear();
|
|
431
|
+
vi.mocked(ensureMotionPathPluginLoaded).mockClear();
|
|
432
|
+
|
|
433
|
+
await render(1);
|
|
434
|
+
const callsAtBegin = onIframeRef.mock.calls.length;
|
|
435
|
+
expect(onIframeRef.mock.calls.every(([iframe]) => iframe === live)).toBe(true);
|
|
436
|
+
|
|
437
|
+
const shadow = ctx!.previewSlots.find((s) => s.role === "shadow")!;
|
|
438
|
+
const shadowIframe = makeLiveIframe();
|
|
439
|
+
await act(async () => {
|
|
440
|
+
ctx!.setShadowIframeNode(shadowIframe);
|
|
441
|
+
ctx!.onShadowIframeLoad(shadow.gen);
|
|
442
|
+
ctx!.onShadowReadyChange(shadow.gen, true);
|
|
443
|
+
});
|
|
444
|
+
expect(onIframeRef).toHaveBeenCalledTimes(callsAtBegin + 1);
|
|
445
|
+
expect(onIframeRef).toHaveBeenLastCalledWith(shadowIframe);
|
|
446
|
+
expect(ensureMotionPathPluginLoaded).toHaveBeenCalledWith(shadowIframe);
|
|
447
|
+
|
|
448
|
+
await act(async () => root.unmount());
|
|
449
|
+
vi.unstubAllGlobals();
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
function unmount(root: ReturnType<typeof createRoot>) {
|
|
454
|
+
act(() => {
|
|
455
|
+
root.unmount();
|
|
456
|
+
});
|
|
457
|
+
}
|