@hyperframes/studio 0.8.56 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
- package/dist/index.d.ts +95 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15573 -14835
- 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 +66 -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 +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -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/hooks/useCompositionDimensions.test.tsx +38 -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 +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -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/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/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.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -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/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
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
6
|
import { useServerConnection } from "./useServerConnection";
|
|
6
7
|
|
|
8
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
9
|
+
|
|
7
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
11
|
|
|
9
12
|
const captured: { projectId: string | null } = { projectId: null };
|
|
@@ -55,6 +58,7 @@ describe("useServerConnection hash project id", () => {
|
|
|
55
58
|
|
|
56
59
|
afterEach(() => {
|
|
57
60
|
vi.unstubAllGlobals();
|
|
61
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
58
62
|
});
|
|
59
63
|
|
|
60
64
|
it("keeps a hash id the server can resolve", async () => {
|
|
@@ -92,3 +96,302 @@ describe("useServerConnection hash project id", () => {
|
|
|
92
96
|
await act(async () => root.unmount());
|
|
93
97
|
});
|
|
94
98
|
});
|
|
99
|
+
|
|
100
|
+
/** Per-project status, so one test can hold two projects in different states. */
|
|
101
|
+
function stubFetchByProject(statuses: Record<string, number | "reject">) {
|
|
102
|
+
vi.stubGlobal(
|
|
103
|
+
"fetch",
|
|
104
|
+
vi.fn(async (url: string) => {
|
|
105
|
+
if (url === "/api/projects") {
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => ({ projects: [{ id: "real-project" }] }),
|
|
109
|
+
} as Response;
|
|
110
|
+
}
|
|
111
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
112
|
+
const status = statuses[id];
|
|
113
|
+
if (status === "reject") throw new TypeError("Failed to fetch");
|
|
114
|
+
if (status === undefined) return { ok: false, status: 404 } as Response;
|
|
115
|
+
return { ok: status === 200, status } as Response;
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function changeHash(hash: string) {
|
|
121
|
+
await act(async () => {
|
|
122
|
+
window.location.hash = hash;
|
|
123
|
+
});
|
|
124
|
+
await flush();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Change the hash WITHOUT the test itself pushing or firing anything.
|
|
129
|
+
*
|
|
130
|
+
* `location.hash =` does both, which makes "did the CODE push?" unanswerable —
|
|
131
|
+
* the test's own navigation moves history.length either way. replaceState does
|
|
132
|
+
* neither, so the dispatch is explicit and any growth after it is the hook's.
|
|
133
|
+
*/
|
|
134
|
+
async function replaceHashAndDispatch(hash: string) {
|
|
135
|
+
await act(async () => {
|
|
136
|
+
window.history.replaceState(null, "", hash);
|
|
137
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
138
|
+
});
|
|
139
|
+
await flush();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The hashchange path took any id the URL offered, with no validation — so the
|
|
144
|
+
* mount fix from v0.8.52 could be walked straight around by changing projects.
|
|
145
|
+
* These pin the second door shut.
|
|
146
|
+
*/
|
|
147
|
+
describe("useServerConnection hashchange validation", () => {
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
captured.projectId = null;
|
|
150
|
+
window.location.hash = "";
|
|
151
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
vi.unstubAllGlobals();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("adopts a hash id the server can resolve", async () => {
|
|
159
|
+
stubFetchByProject({ first: 200, second: 200 });
|
|
160
|
+
const root = await renderWithHash("#project/first");
|
|
161
|
+
expect(captured.projectId).toBe("first");
|
|
162
|
+
|
|
163
|
+
await changeHash("#project/second");
|
|
164
|
+
expect(captured.projectId).toBe("second");
|
|
165
|
+
await act(async () => root.unmount());
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to the first project when the new hash id is gone", async () => {
|
|
169
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
170
|
+
const root = await renderWithHash("#project/first");
|
|
171
|
+
expect(captured.projectId).toBe("first");
|
|
172
|
+
|
|
173
|
+
await changeHash("#project/deleted");
|
|
174
|
+
expect(captured.projectId).toBe("real-project");
|
|
175
|
+
expect(window.location.hash).toContain("real-project");
|
|
176
|
+
await act(async () => root.unmount());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("keeps the new hash id when the check itself fails", async () => {
|
|
180
|
+
// Same transient-tolerance as the mount path: a blip must not rewrite the
|
|
181
|
+
// user's hash out from under a project that is actually fine.
|
|
182
|
+
stubFetchByProject({ first: 200, flaky: "reject" });
|
|
183
|
+
const root = await renderWithHash("#project/first");
|
|
184
|
+
|
|
185
|
+
await changeHash("#project/flaky");
|
|
186
|
+
expect(captured.projectId).toBe("flaky");
|
|
187
|
+
await act(async () => root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("reports the hashchange verdict with its stage", async () => {
|
|
191
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
192
|
+
const root = await renderWithHash("#project/first");
|
|
193
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
194
|
+
|
|
195
|
+
await changeHash("#project/deleted");
|
|
196
|
+
const verdicts = vi
|
|
197
|
+
.mocked(trackStudioEvent)
|
|
198
|
+
.mock.calls.filter(([name]) => name === "project_hash_validated");
|
|
199
|
+
expect(verdicts).toEqual([
|
|
200
|
+
["project_hash_validated", { stage: "hashchange", outcome: "missing", status: 404 }],
|
|
201
|
+
]);
|
|
202
|
+
await act(async () => root.unmount());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("ignores a verdict that lands after the user moved on", async () => {
|
|
206
|
+
// Two changes in quick succession. The first check resolves late; if it were
|
|
207
|
+
// trusted, it would adopt a project the user already navigated away from.
|
|
208
|
+
let releaseFirst: (() => void) | null = null;
|
|
209
|
+
vi.stubGlobal(
|
|
210
|
+
"fetch",
|
|
211
|
+
vi.fn(async (url: string) => {
|
|
212
|
+
if (url === "/api/projects") {
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
json: async () => ({ projects: [{ id: "real-project" }] }),
|
|
216
|
+
} as Response;
|
|
217
|
+
}
|
|
218
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
219
|
+
if (id === "slow") {
|
|
220
|
+
await new Promise<void>((resolve) => {
|
|
221
|
+
releaseFirst = resolve;
|
|
222
|
+
});
|
|
223
|
+
return { ok: true, status: 200 } as Response;
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, status: 200 } as Response;
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const root = await renderWithHash("#project/slow");
|
|
230
|
+
await flush();
|
|
231
|
+
// Move on before the slow validation answers.
|
|
232
|
+
await act(async () => {
|
|
233
|
+
window.location.hash = "#project/fast";
|
|
234
|
+
});
|
|
235
|
+
await flush();
|
|
236
|
+
expect(captured.projectId).toBe("fast");
|
|
237
|
+
|
|
238
|
+
// Now let the stale check resolve. It must not win.
|
|
239
|
+
await act(async () => {
|
|
240
|
+
releaseFirst?.();
|
|
241
|
+
});
|
|
242
|
+
await flush();
|
|
243
|
+
expect(captured.projectId).toBe("fast");
|
|
244
|
+
await act(async () => root.unmount());
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("falls back to the CURRENT project list, not the one from mount", async () => {
|
|
248
|
+
// The mount fetch is a snapshot. A long-lived tab is exactly the case this
|
|
249
|
+
// path exists for, so re-reading the list is the difference between landing
|
|
250
|
+
// on a project that exists and landing on the one that was first an hour ago.
|
|
251
|
+
let list = [{ id: "old-first" }];
|
|
252
|
+
vi.stubGlobal(
|
|
253
|
+
"fetch",
|
|
254
|
+
vi.fn(async (url: string) => {
|
|
255
|
+
if (url === "/api/projects") {
|
|
256
|
+
return { ok: true, json: async () => ({ projects: list }) } as Response;
|
|
257
|
+
}
|
|
258
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
259
|
+
return { ok: id !== "deleted", status: id === "deleted" ? 404 : 200 } as Response;
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const root = await renderWithHash("#project/old-first");
|
|
264
|
+
expect(captured.projectId).toBe("old-first");
|
|
265
|
+
|
|
266
|
+
list = [{ id: "new-first" }];
|
|
267
|
+
await changeHash("#project/deleted");
|
|
268
|
+
expect(captured.projectId).toBe("new-first");
|
|
269
|
+
await act(async () => root.unmount());
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("re-reads the list on a second dead hash rather than reusing the first answer", async () => {
|
|
273
|
+
// The caching version is invisible with one dead hash — firstProjectId runs
|
|
274
|
+
// once, so a memo and a fresh fetch look identical. Two dead hashes with the
|
|
275
|
+
// list changing in between is what separates them.
|
|
276
|
+
let list = [{ id: "first-a" }];
|
|
277
|
+
vi.stubGlobal(
|
|
278
|
+
"fetch",
|
|
279
|
+
vi.fn(async (url: string) => {
|
|
280
|
+
if (url === "/api/projects") {
|
|
281
|
+
return { ok: true, json: async () => ({ projects: list }) } as Response;
|
|
282
|
+
}
|
|
283
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
284
|
+
return { ok: id === "alive", status: id === "alive" ? 200 : 404 } as Response;
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const root = await renderWithHash("#project/alive");
|
|
289
|
+
await changeHash("#project/dead-one");
|
|
290
|
+
expect(captured.projectId).toBe("first-a");
|
|
291
|
+
|
|
292
|
+
list = [{ id: "first-b" }];
|
|
293
|
+
await changeHash("#project/dead-two");
|
|
294
|
+
expect(captured.projectId).toBe("first-b");
|
|
295
|
+
await act(async () => root.unmount());
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rewrites a dead hash without adding a history entry", async () => {
|
|
299
|
+
// `location.hash =` pushes, which would leave the dead id one Back away —
|
|
300
|
+
// and Back would then re-validate, fall back and push again, forever.
|
|
301
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
302
|
+
const root = await renderWithHash("#project/first");
|
|
303
|
+
|
|
304
|
+
const before = window.history.length;
|
|
305
|
+
await replaceHashAndDispatch("#project/deleted");
|
|
306
|
+
expect(captured.projectId).toBe("real-project");
|
|
307
|
+
expect(window.history.length).toBe(before);
|
|
308
|
+
await act(async () => root.unmount());
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("rewrites the mount fallback without adding a history entry", async () => {
|
|
312
|
+
// The mount path pushed until now — the same bug from the other end: Back
|
|
313
|
+
// returned to the dead id, which re-validates and pushes again.
|
|
314
|
+
stubFetchByProject({ deleted: 404 });
|
|
315
|
+
window.history.replaceState(null, "", "#project/deleted");
|
|
316
|
+
const before = window.history.length;
|
|
317
|
+
|
|
318
|
+
const root = await renderWithHash("#project/deleted");
|
|
319
|
+
expect(captured.projectId).toBe("real-project");
|
|
320
|
+
expect(window.history.length).toBe(before);
|
|
321
|
+
await act(async () => root.unmount());
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The verdict event. Until it existed, a tab that kept its hash because the
|
|
327
|
+
* check itself failed was indistinguishable in production from one that never
|
|
328
|
+
* ran the check — different causes, different fixes, one silent bucket.
|
|
329
|
+
*/
|
|
330
|
+
describe("project_hash_validated", () => {
|
|
331
|
+
// Mount only: assigning window.location.hash is what happy-dom turns into a
|
|
332
|
+
// hashchange, so the listener reports too. This describe is about the mount
|
|
333
|
+
// path; the hashchange verdicts have their own.
|
|
334
|
+
const verdictCalls = () =>
|
|
335
|
+
vi
|
|
336
|
+
.mocked(trackStudioEvent)
|
|
337
|
+
.mock.calls.filter(
|
|
338
|
+
([name, props]) =>
|
|
339
|
+
name === "project_hash_validated" &&
|
|
340
|
+
(props as { stage?: string } | undefined)?.stage === "mount",
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
beforeEach(() => {
|
|
344
|
+
captured.projectId = null;
|
|
345
|
+
window.location.hash = "";
|
|
346
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
afterEach(() => {
|
|
350
|
+
vi.unstubAllGlobals();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("reports an ok verdict with its status", async () => {
|
|
354
|
+
stubFetch({ status: 200 });
|
|
355
|
+
const root = await renderWithHash("#project/stale-or-not");
|
|
356
|
+
expect(verdictCalls()).toEqual([
|
|
357
|
+
["project_hash_validated", { stage: "mount", outcome: "ok", status: 200 }],
|
|
358
|
+
]);
|
|
359
|
+
await act(async () => root.unmount());
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports a missing verdict with the 404", async () => {
|
|
363
|
+
stubFetch({ status: 404 });
|
|
364
|
+
const root = await renderWithHash("#project/deleted-project");
|
|
365
|
+
expect(verdictCalls()).toEqual([
|
|
366
|
+
["project_hash_validated", { stage: "mount", outcome: "missing", status: 404 }],
|
|
367
|
+
]);
|
|
368
|
+
await act(async () => root.unmount());
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("reports unknown with no status when the check is rejected", async () => {
|
|
372
|
+
// The point of the event: this case LOOKS like success from outside (the
|
|
373
|
+
// hash is kept) but is a failed check. Only the verdict says so.
|
|
374
|
+
stubFetch("reject");
|
|
375
|
+
const root = await renderWithHash("#project/unreachable-check");
|
|
376
|
+
expect(verdictCalls()).toEqual([
|
|
377
|
+
["project_hash_validated", { stage: "mount", outcome: "unknown" }],
|
|
378
|
+
]);
|
|
379
|
+
await act(async () => root.unmount());
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("reports unknown — not missing — for a 5xx", async () => {
|
|
383
|
+
stubFetch({ status: 500 });
|
|
384
|
+
const root = await renderWithHash("#project/server-erroring");
|
|
385
|
+
expect(verdictCalls()).toEqual([
|
|
386
|
+
["project_hash_validated", { stage: "mount", outcome: "unknown", status: 500 }],
|
|
387
|
+
]);
|
|
388
|
+
await act(async () => root.unmount());
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("emits nothing when there is no hash id to validate", async () => {
|
|
392
|
+
stubFetch({ status: 200 });
|
|
393
|
+
const root = await renderWithHash("");
|
|
394
|
+
expect(verdictCalls()).toEqual([]);
|
|
395
|
+
await act(async () => root.unmount());
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
|
|
3
3
|
import { useMountEffect } from "./useMountEffect";
|
|
4
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
5
|
|
|
5
6
|
interface ServerConnectionState {
|
|
6
7
|
projectId: string | null;
|
|
@@ -28,16 +29,65 @@ interface ServerConnectionState {
|
|
|
28
29
|
* `/api/projects` list would be wrong twice over: that list omits session ids
|
|
29
30
|
* (which resolve fine) and skips project dirs without an `index.html`.
|
|
30
31
|
*/
|
|
31
|
-
|
|
32
|
+
type ProjectHashVerdict = {
|
|
33
|
+
outcome: "ok" | "missing" | "unknown";
|
|
34
|
+
/** HTTP status when the server answered; absent on a rejected request. */
|
|
35
|
+
status?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function resolveHashProject(id: string): Promise<ProjectHashVerdict> {
|
|
32
39
|
try {
|
|
33
40
|
const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
|
34
|
-
if (res.ok) return "ok";
|
|
35
|
-
return res.status === 404 ? "missing" : "unknown";
|
|
41
|
+
if (res.ok) return { outcome: "ok", status: res.status };
|
|
42
|
+
return { outcome: res.status === 404 ? "missing" : "unknown", status: res.status };
|
|
36
43
|
} catch {
|
|
37
|
-
return "unknown";
|
|
44
|
+
return { outcome: "unknown" };
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What the hash validation decided. Without this, a tab that kept a hash
|
|
50
|
+
* because the check itself failed ("unknown") is indistinguishable from one
|
|
51
|
+
* that never ran the check — the two have different fixes and, until this
|
|
52
|
+
* event, no way to tell them apart in production.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite the project hash WITHOUT pushing a history entry.
|
|
56
|
+
*
|
|
57
|
+
* `location.hash =` pushes. That leaves the dead id as the previous entry, so
|
|
58
|
+
* Back returns to it — and now that hashchange validates, Back would re-run the
|
|
59
|
+
* whole fallback and push again, leaving a trail of dead ids the user cannot
|
|
60
|
+
* escape with the Back button.
|
|
61
|
+
*/
|
|
62
|
+
function replaceProjectHash(projectId: string): void {
|
|
63
|
+
window.history.replaceState(null, "", buildProjectHash(projectId));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The first project the server offers, for a hash that resolves to nothing.
|
|
68
|
+
*
|
|
69
|
+
* Fetched here rather than cached from mount: the mount fetch is a snapshot,
|
|
70
|
+
* and the scenario this exists for is exactly the one where the project list
|
|
71
|
+
* changed underneath a long-lived tab.
|
|
72
|
+
*/
|
|
73
|
+
async function firstProjectId(): Promise<string | null> {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch("/api/projects");
|
|
76
|
+
const data = (await res.json()) as { projects?: Array<{ id?: string }> };
|
|
77
|
+
return data.projects?.[0]?.id ?? null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reportHashVerdict(stage: "mount" | "hashchange", verdict: ProjectHashVerdict): void {
|
|
84
|
+
trackStudioEvent("project_hash_validated", {
|
|
85
|
+
stage,
|
|
86
|
+
outcome: verdict.outcome,
|
|
87
|
+
...(verdict.status === undefined ? {} : { status: verdict.status }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
41
91
|
export function useServerConnection(): ServerConnectionState {
|
|
42
92
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
43
93
|
const [resolving, setResolving] = useState(true);
|
|
@@ -69,11 +119,12 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
69
119
|
// Telemetry after the read-reason split: 120 http_error/404 reads
|
|
70
120
|
// across 5 users in 24h, ~24 each, never recovering.
|
|
71
121
|
if (hashProjectId) {
|
|
72
|
-
const
|
|
122
|
+
const verdict = await resolveHashProject(hashProjectId);
|
|
73
123
|
if (cancelled) return;
|
|
124
|
+
reportHashVerdict("mount", verdict);
|
|
74
125
|
// "unknown" keeps the old behaviour: a transient failure must not
|
|
75
126
|
// rewrite the user's hash out from under a valid project.
|
|
76
|
-
if (
|
|
127
|
+
if (verdict.outcome !== "missing") {
|
|
77
128
|
setProjectId(hashProjectId);
|
|
78
129
|
setWaitingForServer(false);
|
|
79
130
|
return;
|
|
@@ -83,7 +134,7 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
83
134
|
if (first) {
|
|
84
135
|
setProjectId(first.id);
|
|
85
136
|
setWaitingForServer(false);
|
|
86
|
-
|
|
137
|
+
replaceProjectHash(first.id);
|
|
87
138
|
} else {
|
|
88
139
|
scheduleRetry();
|
|
89
140
|
}
|
|
@@ -107,7 +158,29 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
107
158
|
useEffect(() => {
|
|
108
159
|
const onHashChange = () => {
|
|
109
160
|
const next = parseProjectIdFromHash(window.location.hash);
|
|
110
|
-
if (next
|
|
161
|
+
if (!next || next === projectId) return;
|
|
162
|
+
// Validated, not adopted. The mount path has checked its hash since
|
|
163
|
+
// v0.8.52; this path took any id the URL offered, so changing projects
|
|
164
|
+
// re-armed the same dead-tab failure the mount check exists to prevent.
|
|
165
|
+
void (async () => {
|
|
166
|
+
const verdict = await resolveHashProject(next);
|
|
167
|
+
// Ground truth, not a captured flag. This effect re-registers on every
|
|
168
|
+
// projectId change, so a generation counter in this closure would reset
|
|
169
|
+
// and an in-flight verdict from the previous registration would read as
|
|
170
|
+
// current — adopting a project the user has already navigated away from.
|
|
171
|
+
// The hash itself cannot go stale.
|
|
172
|
+
if (parseProjectIdFromHash(window.location.hash) !== next) return;
|
|
173
|
+
reportHashVerdict("hashchange", verdict);
|
|
174
|
+
if (verdict.outcome !== "missing") {
|
|
175
|
+
setProjectId(next);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const fallbackId = await firstProjectId();
|
|
179
|
+
if (parseProjectIdFromHash(window.location.hash) !== next) return;
|
|
180
|
+
if (!fallbackId) return;
|
|
181
|
+
setProjectId(fallbackId);
|
|
182
|
+
replaceProjectHash(fallbackId);
|
|
183
|
+
})();
|
|
111
184
|
};
|
|
112
185
|
window.addEventListener("hashchange", onHashChange);
|
|
113
186
|
return () => window.removeEventListener("hashchange", onHashChange);
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,18 @@ export type { TimelineElement, TimelineTimeRange } from "./player";
|
|
|
56
56
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
57
57
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
58
58
|
export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
59
|
+
export {
|
|
60
|
+
PreviewOverlayProvider,
|
|
61
|
+
usePreviewOverlayContext,
|
|
62
|
+
} from "./components/editor/PreviewOverlayProvider";
|
|
63
|
+
export type {
|
|
64
|
+
PreviewOverlayProviderProps,
|
|
65
|
+
PreviewSnapPreferences,
|
|
66
|
+
} from "./components/editor/PreviewOverlayProvider";
|
|
67
|
+
export { PreviewGuides } from "./components/editor/PreviewGuides";
|
|
68
|
+
export { GridOverlay } from "./components/editor/GridOverlay";
|
|
69
|
+
export { SnapToolbar } from "./components/editor/SnapToolbar";
|
|
70
|
+
export { usePreviewGuidesStore } from "./components/editor/previewGuidesStore";
|
|
59
71
|
|
|
60
72
|
// Editor
|
|
61
73
|
export { SourceEditor } from "./components/editor/SourceEditor";
|
|
@@ -54,26 +54,37 @@ afterEach(() => {
|
|
|
54
54
|
document.body.innerHTML = "";
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
type PlayerProps = Parameters<typeof Player>[0];
|
|
58
|
+
|
|
59
|
+
async function mountPlayer(props: Partial<PlayerProps> = {}) {
|
|
58
60
|
const host = document.createElement("div");
|
|
59
61
|
document.body.append(host);
|
|
60
62
|
root = createRoot(host);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
const render = () =>
|
|
64
|
+
act(async () => {
|
|
65
|
+
root?.render(
|
|
66
|
+
createElement(Player, {
|
|
67
|
+
directUrl: "/api/projects/demo/preview",
|
|
68
|
+
onLoad: vi.fn(),
|
|
69
|
+
suppressLoadingOverlay: true,
|
|
70
|
+
...props,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
});
|
|
75
|
+
await render();
|
|
71
76
|
|
|
72
77
|
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
|
|
73
78
|
if (!player) throw new Error("player did not mount");
|
|
74
|
-
|
|
79
|
+
const rerender = (next: Partial<PlayerProps>) => {
|
|
80
|
+
Object.assign(props, next);
|
|
81
|
+
return render();
|
|
82
|
+
};
|
|
83
|
+
return { host, player, rerender };
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
|
|
87
|
+
|
|
77
88
|
function createAudioIframe() {
|
|
78
89
|
const iframe = document.createElement("iframe");
|
|
79
90
|
document.body.appendChild(iframe);
|
|
@@ -123,6 +134,7 @@ describe("preview errors", () => {
|
|
|
123
134
|
"player:click",
|
|
124
135
|
"player:shadertransitionstate",
|
|
125
136
|
"player:ready",
|
|
137
|
+
"player:painted",
|
|
126
138
|
"player:error",
|
|
127
139
|
]) {
|
|
128
140
|
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
|
|
@@ -131,7 +143,10 @@ describe("preview errors", () => {
|
|
|
131
143
|
});
|
|
132
144
|
|
|
133
145
|
it("retries a failed preview with a fresh player URL", async () => {
|
|
134
|
-
const
|
|
146
|
+
const onPainted = vi.fn();
|
|
147
|
+
const { host, player } = await mountPlayer({ onPainted });
|
|
148
|
+
|
|
149
|
+
act(() => void player.dispatchEvent(new Event("painted")));
|
|
135
150
|
|
|
136
151
|
act(() => {
|
|
137
152
|
player.dispatchEvent(
|
|
@@ -152,6 +167,119 @@ describe("preview errors", () => {
|
|
|
152
167
|
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
|
|
153
168
|
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
|
|
154
169
|
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
|
|
170
|
+
|
|
171
|
+
act(() => void player.dispatchEvent(new Event("painted")));
|
|
172
|
+
expect(onPainted.mock.calls.map(([details]) => details.loadId)).toEqual([1, 2]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("callbacks after the player is already mounted", () => {
|
|
177
|
+
it("runs the latest onLoad on a later load, not the one captured at mount", async () => {
|
|
178
|
+
const first = vi.fn();
|
|
179
|
+
const second = vi.fn();
|
|
180
|
+
const { player, rerender } = await mountPlayer({ onLoad: first });
|
|
181
|
+
await rerender({ onLoad: second });
|
|
182
|
+
|
|
183
|
+
act(
|
|
184
|
+
() => void (player as TestHyperframesPlayer).iframeElement.dispatchEvent(new Event("load")),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(first).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reports the preview error cause", async () => {
|
|
192
|
+
const onPreviewError = vi.fn();
|
|
193
|
+
const { player } = await mountPlayer({ onPreviewError });
|
|
194
|
+
act(() => {
|
|
195
|
+
player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
|
|
196
|
+
});
|
|
197
|
+
expect(onPreviewError).toHaveBeenCalledWith("boom");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("ready to show", () => {
|
|
202
|
+
const loadAndReady = (player: TestHyperframesPlayer) =>
|
|
203
|
+
act(() => {
|
|
204
|
+
player.iframeElement.dispatchEvent(new Event("load"));
|
|
205
|
+
player.dispatchEvent(new Event("ready"));
|
|
206
|
+
});
|
|
207
|
+
const shaderState = (player: TestHyperframesPlayer, loading: boolean) =>
|
|
208
|
+
act(() => {
|
|
209
|
+
player.dispatchEvent(
|
|
210
|
+
new CustomEvent("shadertransitionstate", {
|
|
211
|
+
detail: { state: { loading, ready: !loading } },
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const painted = (player: TestHyperframesPlayer) =>
|
|
217
|
+
act(() => void player.dispatchEvent(new Event("painted")));
|
|
218
|
+
|
|
219
|
+
it("promotes only once the player reports painted, not at ready or assetsready", async () => {
|
|
220
|
+
const onReadyToShowChange = vi.fn();
|
|
221
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
222
|
+
const el = player as TestHyperframesPlayer;
|
|
223
|
+
|
|
224
|
+
loadAndReady(el);
|
|
225
|
+
await twoFrames();
|
|
226
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
227
|
+
|
|
228
|
+
act(() => void el.dispatchEvent(new Event("assetsready")));
|
|
229
|
+
await twoFrames();
|
|
230
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
231
|
+
|
|
232
|
+
painted(el);
|
|
233
|
+
await twoFrames();
|
|
234
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
235
|
+
|
|
236
|
+
act(() => void el.iframeElement.dispatchEvent(new Event("load")));
|
|
237
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("reports the document start time with the painted iframe", async () => {
|
|
241
|
+
const onPainted = vi.fn();
|
|
242
|
+
const now = vi.spyOn(performance, "now").mockReturnValue(100);
|
|
243
|
+
const { player } = await mountPlayer({ onPainted });
|
|
244
|
+
|
|
245
|
+
painted(player as TestHyperframesPlayer);
|
|
246
|
+
|
|
247
|
+
expect(onPainted).toHaveBeenCalledWith({
|
|
248
|
+
iframe: (player as TestHyperframesPlayer).iframeElement,
|
|
249
|
+
startedAt: 100,
|
|
250
|
+
loadId: 1,
|
|
251
|
+
});
|
|
252
|
+
now.mockRestore();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("holds while the shader transition loader is up and fires once it clears", async () => {
|
|
256
|
+
const onReadyToShowChange = vi.fn();
|
|
257
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
258
|
+
const el = player as TestHyperframesPlayer;
|
|
259
|
+
|
|
260
|
+
shaderState(el, true);
|
|
261
|
+
loadAndReady(el);
|
|
262
|
+
shaderState(el, true);
|
|
263
|
+
await twoFrames();
|
|
264
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
265
|
+
|
|
266
|
+
shaderState(el, false);
|
|
267
|
+
painted(el);
|
|
268
|
+
await twoFrames();
|
|
269
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
270
|
+
|
|
271
|
+
shaderState(el, true);
|
|
272
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("does not fire for a document that failed to load", async () => {
|
|
276
|
+
const onReadyToShowChange = vi.fn();
|
|
277
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
278
|
+
act(() => {
|
|
279
|
+
player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
|
|
280
|
+
});
|
|
281
|
+
await twoFrames();
|
|
282
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
155
283
|
});
|
|
156
284
|
});
|
|
157
285
|
|