@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.
@@ -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 () => {
@@ -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: { files?: string[]; compositions?: string[] };
103
+ let data: TreeListing;
88
104
  try {
89
- const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
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
- throw err;
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(new Error("superseded by a newer edit"));
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(new Error("superseded by a newer edit"));
87
- await expect(sceneSwapFor(iframe)!("/preview", () => true, cancel.signal)).rejects.toThrow(
88
- "superseded",
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
  });
@@ -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
+ }