@hyperframes/studio 0.8.177 → 0.8.179
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-BulZP0_1.js → hyperframes-player-C0SyMC21.js} +1 -1
- package/dist/assets/{index-CGncBUsJ.js → index-DHUassFP.js} +1 -1
- package/dist/assets/{index-B1Gypobp.js → index-ueMRyAJ2.js} +173 -172
- package/dist/{chunk-BLXESG6S.js → chunk-563JPIL4.js} +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +42 -53
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/hooks/previewPromotionSelection.test.tsx +5 -6
- package/src/hooks/useDomEditPreviewSync.test.ts +1 -5
- package/src/hooks/useDomEditPreviewSync.ts +9 -58
- package/src/hooks/useDomEditSession.ts +1 -5
- package/src/hooks/useDomEditWiring.ts +6 -16
- package/src/hooks/useDomSelection.test.ts +62 -0
- package/src/hooks/useDomSelection.ts +5 -4
- package/src/hooks/useDomSelectionTypes.ts +4 -2
- package/src/hooks/useFileTree.test.tsx +34 -0
- package/src/hooks/useFileTree.ts +22 -4
- package/src/main.tsx +7 -24
- package/src/player/hooks/useTimelinePlayer.ts +2 -2
- package/src/player/sceneSwap.test.ts +10 -3
- package/src/player/sceneSwap.ts +3 -0
- package/src/utils/rejectionTelemetry.test.ts +39 -0
- package/src/utils/rejectionTelemetry.ts +33 -0
|
@@ -9,7 +9,10 @@ vi.mock("../components/feedback/projectProvenance", () => ({
|
|
|
9
9
|
captureProjectProvenance: vi.fn(),
|
|
10
10
|
}));
|
|
11
11
|
|
|
12
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
13
|
+
|
|
12
14
|
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
|
|
15
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
13
16
|
|
|
14
17
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
18
|
|
|
@@ -244,6 +247,37 @@ describe("useFileTree.refreshFileTree", () => {
|
|
|
244
247
|
fetchSpy.mockRestore();
|
|
245
248
|
});
|
|
246
249
|
|
|
250
|
+
it("resolves when the server is gone, and reports it once per unreachable streak", async () => {
|
|
251
|
+
const track = vi.mocked(trackStudioEvent);
|
|
252
|
+
track.mockClear();
|
|
253
|
+
let serverUp = true;
|
|
254
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
255
|
+
if (!serverUp) throw new TypeError("Failed to fetch");
|
|
256
|
+
return new Response(JSON.stringify({ files: ["index.html"], compositions: ["index.html"] }));
|
|
257
|
+
});
|
|
258
|
+
const handleRef = await renderHarness("project-a");
|
|
259
|
+
|
|
260
|
+
serverUp = false;
|
|
261
|
+
await act(async () => {
|
|
262
|
+
await handleRef.current?.refresh();
|
|
263
|
+
await handleRef.current?.refresh();
|
|
264
|
+
});
|
|
265
|
+
expect(handleRef.current?.compositions).toEqual(["index.html"]);
|
|
266
|
+
expect(track).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(track).toHaveBeenCalledWith("file_tree_refresh_failed", {
|
|
268
|
+
error_name: "TypeError",
|
|
269
|
+
error_message: "Failed to fetch",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
serverUp = true;
|
|
273
|
+
await act(async () => handleRef.current?.refresh());
|
|
274
|
+
serverUp = false;
|
|
275
|
+
await act(async () => handleRef.current?.refresh());
|
|
276
|
+
expect(track).toHaveBeenCalledTimes(2);
|
|
277
|
+
|
|
278
|
+
fetchSpy.mockRestore();
|
|
279
|
+
});
|
|
280
|
+
|
|
247
281
|
it("keeps the prior compositions list when a refresh response omits the field", async () => {
|
|
248
282
|
let call = 0;
|
|
249
283
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { FONT_EXT } from "../utils/mediaTypes";
|
|
|
4
4
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
5
|
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
|
|
6
6
|
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
7
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
|
+
import { errorProps } from "../utils/rejectionTelemetry";
|
|
7
9
|
import { encodeUrlPath } from "@hyperframes/parsers";
|
|
8
10
|
|
|
9
11
|
interface UseFileTreeOptions {
|
|
@@ -23,6 +25,19 @@ interface FetchedFileTree {
|
|
|
23
25
|
// and defeat useMemo/useCallback deps downstream (e.g. the `assets` memo below).
|
|
24
26
|
const EMPTY_FILE_LIST: string[] = [];
|
|
25
27
|
|
|
28
|
+
type TreeListing = { files?: string[]; compositions?: string[] };
|
|
29
|
+
|
|
30
|
+
async function readTreeListing(projectId: string, signal: AbortSignal): Promise<TreeListing> {
|
|
31
|
+
const res = await studioApiFetch(buildProjectApiPath(projectId), { signal });
|
|
32
|
+
if (!res.ok) throw new Error(`tree fetch failed: ${res.status}`);
|
|
33
|
+
return res.json();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function reportRefreshFailure(err: unknown): void {
|
|
37
|
+
const { error_name, error_message } = errorProps(err);
|
|
38
|
+
trackStudioEvent("file_tree_refresh_failed", { error_name, error_message });
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
/**
|
|
27
42
|
* Holds the tree with the `projectId` it was fetched for; exposes empty/not-loaded
|
|
28
43
|
* whenever that id doesn't match the current one, so a switch can't read another project's tree.
|
|
@@ -74,6 +89,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
|
74
89
|
|
|
75
90
|
const refreshRequestRef = useRef(0);
|
|
76
91
|
const refreshAbortRef = useRef<AbortController | null>(null);
|
|
92
|
+
const failureReportedRef = useRef(false);
|
|
77
93
|
|
|
78
94
|
const refreshFileTree = useCallback(async () => {
|
|
79
95
|
const pid = projectIdRef.current;
|
|
@@ -84,14 +100,16 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
|
84
100
|
const requestId = ++refreshRequestRef.current;
|
|
85
101
|
const controller = new AbortController();
|
|
86
102
|
refreshAbortRef.current = controller;
|
|
87
|
-
let data:
|
|
103
|
+
let data: TreeListing;
|
|
88
104
|
try {
|
|
89
|
-
|
|
90
|
-
data = await res.json();
|
|
105
|
+
data = await readTreeListing(pid, controller.signal);
|
|
91
106
|
} catch (err) {
|
|
92
107
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
93
|
-
|
|
108
|
+
if (!failureReportedRef.current) reportRefreshFailure(err);
|
|
109
|
+
failureReportedRef.current = true;
|
|
110
|
+
return;
|
|
94
111
|
}
|
|
112
|
+
failureReportedRef.current = false;
|
|
95
113
|
if (data.files && requestId === refreshRequestRef.current) {
|
|
96
114
|
setFetched((prev) =>
|
|
97
115
|
prev?.projectId === pid
|
package/src/main.tsx
CHANGED
|
@@ -6,6 +6,11 @@ import { ShowThemeToggle } from "./components/ThemeToggle";
|
|
|
6
6
|
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
7
7
|
import { readIconTokens } from "./styles/iconTokens";
|
|
8
8
|
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
9
|
+
import {
|
|
10
|
+
errorProps,
|
|
11
|
+
isCompositionAssetError,
|
|
12
|
+
isExtensionOnlyStack,
|
|
13
|
+
} from "./utils/rejectionTelemetry";
|
|
9
14
|
import { prefetchPreviewForHash } from "./utils/previewPrefetch";
|
|
10
15
|
import { bindThumbnailPageLifecycle } from "./player/lib/thumbnailPageLifecycle";
|
|
11
16
|
import "./styles/studio.css";
|
|
@@ -17,30 +22,6 @@ prefetchPreviewForHash(window.location.hash);
|
|
|
17
22
|
window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
|
|
18
23
|
trackStudioEvent("session_start");
|
|
19
24
|
|
|
20
|
-
function errorProps(value: unknown): {
|
|
21
|
-
error_message: string;
|
|
22
|
-
error_name: string | null;
|
|
23
|
-
stack_trace: string | null;
|
|
24
|
-
} {
|
|
25
|
-
if (value instanceof Error) {
|
|
26
|
-
return {
|
|
27
|
-
error_message: value.message,
|
|
28
|
-
error_name: value.name,
|
|
29
|
-
stack_trace: value.stack?.slice(0, 4000) ?? null,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
return { error_message: String(value), error_name: null, stack_trace: null };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// fallow-ignore-next-line complexity
|
|
36
|
-
function isCompositionAssetError(msg: string, name: string | null): boolean {
|
|
37
|
-
if (msg.includes("Error fetching") && (msg.includes("404") || msg.includes("Not Found")))
|
|
38
|
-
return true;
|
|
39
|
-
if (name === "EncodingError" || msg.includes("unsupported or unrecognizable format")) return true;
|
|
40
|
-
if (msg.includes("MEDIA_ERR_SRC_NOT_SUPPORTED")) return true;
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
25
|
const ERROR_CAP = 50;
|
|
45
26
|
let errorCount = 0;
|
|
46
27
|
let rejectionCount = 0;
|
|
@@ -114,6 +95,8 @@ window.addEventListener("unhandledrejection", (event) => {
|
|
|
114
95
|
}
|
|
115
96
|
return;
|
|
116
97
|
}
|
|
98
|
+
// Not ours: an extension's own promise. Dropped before the cap so it cannot silence ours.
|
|
99
|
+
if (isExtensionOnlyStack(props.stack_trace)) return;
|
|
117
100
|
|
|
118
101
|
rejectionCount++;
|
|
119
102
|
if (rejectionCount > ERROR_CAP) {
|
|
@@ -42,7 +42,7 @@ import { isStudioManualEditGestureLiveIn } from "../../components/editor/manualE
|
|
|
42
42
|
import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
43
43
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
44
44
|
import { safeContentDocument } from "./timelineSyncHydration";
|
|
45
|
-
import { sceneSwapFor } from "../sceneSwap";
|
|
45
|
+
import { sceneSwapFor, supersededByNewerEdit } from "../sceneSwap";
|
|
46
46
|
import { whileScriptWrites } from "../previewReloading";
|
|
47
47
|
|
|
48
48
|
export interface UseTimelinePlayerOptions {
|
|
@@ -489,7 +489,7 @@ export function useTimelinePlayer({
|
|
|
489
489
|
const refreshPlayer = useCallback(() => {
|
|
490
490
|
const iframe = iframeRef.current;
|
|
491
491
|
if (!iframe) return;
|
|
492
|
-
swapCancelRef.current?.abort(
|
|
492
|
+
swapCancelRef.current?.abort(supersededByNewerEdit());
|
|
493
493
|
const cancel = new AbortController();
|
|
494
494
|
swapCancelRef.current = cancel;
|
|
495
495
|
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
announcePreviewDocumentLoaded,
|
|
6
6
|
onPreviewContentReplaced,
|
|
7
7
|
sceneSwapFor,
|
|
8
|
+
supersededByNewerEdit,
|
|
8
9
|
} from "./sceneSwap";
|
|
10
|
+
import { errorProps } from "../utils/rejectionTelemetry";
|
|
9
11
|
|
|
10
12
|
afterEach(() => {
|
|
11
13
|
vi.useRealTimers();
|
|
@@ -83,10 +85,15 @@ describe("sceneSwapFor", () => {
|
|
|
83
85
|
.mockImplementation(async () => new Response("<html></html>"));
|
|
84
86
|
const { iframe, swap } = swappableIframe();
|
|
85
87
|
const cancel = new AbortController();
|
|
86
|
-
cancel.abort(
|
|
87
|
-
await
|
|
88
|
-
|
|
88
|
+
cancel.abort(supersededByNewerEdit());
|
|
89
|
+
const rejection = await sceneSwapFor(iframe)!("/preview", () => true, cancel.signal).catch(
|
|
90
|
+
(error: unknown) => error,
|
|
89
91
|
);
|
|
92
|
+
// The rejection listener files AbortErrors as cancellations, never as unhandled failures.
|
|
93
|
+
expect(errorProps(rejection)).toMatchObject({
|
|
94
|
+
error_name: "AbortError",
|
|
95
|
+
error_message: "superseded by a newer edit",
|
|
96
|
+
});
|
|
90
97
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
91
98
|
expect(swap).not.toHaveBeenCalled();
|
|
92
99
|
});
|
package/src/player/sceneSwap.ts
CHANGED
|
@@ -71,6 +71,9 @@ export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonl
|
|
|
71
71
|
meta.content = JSON.stringify(parts);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
export const supersededByNewerEdit = () =>
|
|
75
|
+
new DOMException("superseded by a newer edit", "AbortError");
|
|
76
|
+
|
|
74
77
|
/** Null when the preview cannot swap; rejects when superseded, cancelled or a full reload is needed. */
|
|
75
78
|
export function sceneSwapFor(
|
|
76
79
|
iframe: HTMLIFrameElement,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isExtensionOnlyStack } from "./rejectionTelemetry";
|
|
3
|
+
|
|
4
|
+
describe("isExtensionOnlyStack", () => {
|
|
5
|
+
it("is true when every V8 frame is an extension script", () => {
|
|
6
|
+
const stack = [
|
|
7
|
+
"TypeError: Cannot read properties of undefined (reading 'M_ID')",
|
|
8
|
+
" at Y (chrome-extension://abcdefghijklmnop/executors/200.js:1:761)",
|
|
9
|
+
" at E (chrome-extension://abcdefghijklmnop/executors/200.js:1:1442)",
|
|
10
|
+
].join("\n");
|
|
11
|
+
expect(isExtensionOnlyStack(stack)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("is true for a wallet's single-frame connect failure", () => {
|
|
15
|
+
const stack =
|
|
16
|
+
"i: Failed to connect to MetaMask\n at Object.connect (chrome-extension://abcdefghijklmnop/scripts/inpage.js:7:84292)";
|
|
17
|
+
expect(isExtensionOnlyStack(stack)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("reads Firefox and Safari frames", () => {
|
|
21
|
+
expect(isExtensionOnlyStack("connect@moz-extension://1234-abcd/inpage.js:3:10\n")).toBe(true);
|
|
22
|
+
expect(isExtensionOnlyStack("f@safari-web-extension://1234-abcd/content.js:1:5")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is false when a Studio frame is in the stack, even under an extension's fetch wrapper", () => {
|
|
26
|
+
const stack = [
|
|
27
|
+
"TypeError: Failed to fetch",
|
|
28
|
+
" at o (chrome-extension://abcdefghijklmnop/frame_ant/frame_ant.js:2:18135)",
|
|
29
|
+
" at window.fetch (chrome-extension://abcdefghijklmnop/frame_ant/frame_ant.js:2:18200)",
|
|
30
|
+
" at Dt (http://localhost:3002/assets/index-abc.js:49:296453)",
|
|
31
|
+
].join("\n");
|
|
32
|
+
expect(isExtensionOnlyStack(stack)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is false for a stack with no frames to judge", () => {
|
|
36
|
+
expect(isExtensionOnlyStack(null)).toBe(false);
|
|
37
|
+
expect(isExtensionOnlyStack("Error: chrome-extension://abcdefghijklmnop failed")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function errorProps(value: unknown): {
|
|
2
|
+
error_message: string;
|
|
3
|
+
error_name: string | null;
|
|
4
|
+
stack_trace: string | null;
|
|
5
|
+
} {
|
|
6
|
+
if (value instanceof Error) {
|
|
7
|
+
return {
|
|
8
|
+
error_message: value.message,
|
|
9
|
+
error_name: value.name,
|
|
10
|
+
stack_trace: value.stack?.slice(0, 4000) ?? null,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return { error_message: String(value), error_name: null, stack_trace: null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// fallow-ignore-next-line complexity
|
|
17
|
+
export function isCompositionAssetError(msg: string, name: string | null): boolean {
|
|
18
|
+
if (msg.includes("Error fetching") && (msg.includes("404") || msg.includes("Not Found")))
|
|
19
|
+
return true;
|
|
20
|
+
if (name === "EncodingError" || msg.includes("unsupported or unrecognizable format")) return true;
|
|
21
|
+
if (msg.includes("MEDIA_ERR_SRC_NOT_SUPPORTED")) return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A frame is a line ending in `:line:col`, in both the V8 (`at f (url:1:2)`) and Gecko/WebKit (`f@url:1:2`) forms.
|
|
26
|
+
const STACK_FRAME = /:\d+:\d+\)?\s*$/;
|
|
27
|
+
const EXTENSION_URL = /\b(?:chrome|moz|safari|safari-web)-extension:\/\//;
|
|
28
|
+
|
|
29
|
+
/** True when every frame is an extension's script: a failure no Studio code took part in. */
|
|
30
|
+
export function isExtensionOnlyStack(stack: string | null): boolean {
|
|
31
|
+
const frames = stack?.split("\n").filter((line) => STACK_FRAME.test(line)) ?? [];
|
|
32
|
+
return frames.length > 0 && frames.every((line) => EXTENSION_URL.test(line));
|
|
33
|
+
}
|