@hyperframes/studio 0.8.176 → 0.8.178

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.
@@ -1,4 +1,4 @@
1
- import{n as Qi}from"./index-BkDF4Z-5.js";/*!
1
+ import{n as Qi}from"./index-Bi6yVTq9.js";/*!
2
2
  * Copyright (c) 2026-present, Vanilagy and contributors
3
3
  *
4
4
  * This Source Code Form is subject to the terms of the Mozilla Public
@@ -1010,6 +1010,7 @@ function markScenesStale(iframe, files) {
1010
1010
  }
1011
1011
  meta.content = JSON.stringify(parts);
1012
1012
  }
1013
+ var supersededByNewerEdit = () => new DOMException("superseded by a newer edit", "AbortError");
1013
1014
  function sceneSwapFor(iframe) {
1014
1015
  const win = iframe.contentWindow;
1015
1016
  const swap = win?.__hfSwapScenes;
@@ -1535,6 +1536,7 @@ export {
1535
1536
  announcePreviewPromoted,
1536
1537
  onPreviewPromoted,
1537
1538
  markScenesStale,
1539
+ supersededByNewerEdit,
1538
1540
  sceneSwapFor,
1539
1541
  Player
1540
1542
  };
package/dist/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  document.documentElement.dataset.theme = "paper";
15
15
  }
16
16
  </script>
17
- <script type="module" crossorigin src="/assets/index-BkDF4Z-5.js"></script>
17
+ <script type="module" crossorigin src="/assets/index-Bi6yVTq9.js"></script>
18
18
  <link rel="stylesheet" crossorigin href="/assets/index-CESvLzz0.css">
19
19
  </head>
20
20
  <body>
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  sceneSwapFor,
40
40
  secondsToFrame,
41
41
  stepFrameTime,
42
+ supersededByNewerEdit,
42
43
  thumbnailRevisionOf,
43
44
  timelineSliderToZoomPercent,
44
45
  timelineZoomPercentToSlider,
@@ -47,7 +48,7 @@ import {
47
48
  usePreviewVariablesStore,
48
49
  whenPreviewBooted,
49
50
  writeStudioUiPreferences
50
- } from "./chunk-BLXESG6S.js";
51
+ } from "./chunk-563JPIL4.js";
51
52
  import {
52
53
  Button,
53
54
  ContextMenu,
@@ -32628,7 +32629,7 @@ function useTimelinePlayer({
32628
32629
  const refreshPlayer = useCallback41(() => {
32629
32630
  const iframe = iframeRef.current;
32630
32631
  if (!iframe) return;
32631
- swapCancelRef.current?.abort(new Error("superseded by a newer edit"));
32632
+ swapCancelRef.current?.abort(supersededByNewerEdit());
32632
32633
  const cancel = new AbortController();
32633
32634
  swapCancelRef.current = cancel;
32634
32635
  logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
@@ -75094,8 +75095,31 @@ import { useState as useState117, useCallback as useCallback104, useRef as useRe
75094
75095
 
75095
75096
  // src/hooks/useFileTree.ts
75096
75097
  import { useState as useState116, useCallback as useCallback101, useEffect as useEffect112, useMemo as useMemo71, useRef as useRef128 } from "react";
75098
+
75099
+ // src/utils/rejectionTelemetry.ts
75100
+ function errorProps(value) {
75101
+ if (value instanceof Error) {
75102
+ return {
75103
+ error_message: value.message,
75104
+ error_name: value.name,
75105
+ stack_trace: value.stack?.slice(0, 4e3) ?? null
75106
+ };
75107
+ }
75108
+ return { error_message: String(value), error_name: null, stack_trace: null };
75109
+ }
75110
+
75111
+ // src/hooks/useFileTree.ts
75097
75112
  import { encodeUrlPath as encodeUrlPath4 } from "@hyperframes/parsers";
75098
75113
  var EMPTY_FILE_LIST = [];
75114
+ async function readTreeListing(projectId, signal) {
75115
+ const res = await studioApiFetch(buildProjectApiPath(projectId), { signal });
75116
+ if (!res.ok) throw new Error(`tree fetch failed: ${res.status}`);
75117
+ return res.json();
75118
+ }
75119
+ function reportRefreshFailure(err) {
75120
+ const { error_name, error_message } = errorProps(err);
75121
+ trackStudioEvent("file_tree_refresh_failed", { error_name, error_message });
75122
+ }
75099
75123
  function useFileTree({ projectId, projectIdRef }) {
75100
75124
  const [fetched, setFetched] = useState116(null);
75101
75125
  const current = fetched?.projectId === projectId ? fetched : null;
@@ -75131,6 +75155,7 @@ function useFileTree({ projectId, projectIdRef }) {
75131
75155
  }, [projectId]);
75132
75156
  const refreshRequestRef = useRef128(0);
75133
75157
  const refreshAbortRef = useRef128(null);
75158
+ const failureReportedRef = useRef128(false);
75134
75159
  const refreshFileTree = useCallback101(async () => {
75135
75160
  const pid = projectIdRef.current;
75136
75161
  if (!pid) return;
@@ -75140,12 +75165,14 @@ function useFileTree({ projectId, projectIdRef }) {
75140
75165
  refreshAbortRef.current = controller;
75141
75166
  let data;
75142
75167
  try {
75143
- const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
75144
- data = await res.json();
75168
+ data = await readTreeListing(pid, controller.signal);
75145
75169
  } catch (err) {
75146
75170
  if (err instanceof DOMException && err.name === "AbortError") return;
75147
- throw err;
75171
+ if (!failureReportedRef.current) reportRefreshFailure(err);
75172
+ failureReportedRef.current = true;
75173
+ return;
75148
75174
  }
75175
+ failureReportedRef.current = false;
75149
75176
  if (data.files && requestId === refreshRequestRef.current) {
75150
75177
  setFetched(
75151
75178
  (prev) => prev?.projectId === pid ? {
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-BLXESG6S.js";
3
+ } from "./chunk-563JPIL4.js";
4
4
  import "./chunk-FF5DM7BQ.js";
5
5
  import "./chunk-T43DM7Q6.js";
6
6
  import "./chunk-MLKGABMK.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.176",
3
+ "version": "0.8.178",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,12 +59,12 @@
59
59
  "dockview-react": "8.3.1",
60
60
  "gsap": "^3.13.0",
61
61
  "mediabunny": "^1.45.3",
62
- "@hyperframes/core": "0.8.176",
63
- "@hyperframes/parsers": "0.8.176",
64
- "@hyperframes/player": "0.8.176",
65
- "@hyperframes/sdk": "0.8.176",
66
- "@hyperframes/shader-transitions": "0.8.176",
67
- "@hyperframes/studio-server": "0.8.176"
62
+ "@hyperframes/core": "0.8.178",
63
+ "@hyperframes/parsers": "0.8.178",
64
+ "@hyperframes/sdk": "0.8.178",
65
+ "@hyperframes/player": "0.8.178",
66
+ "@hyperframes/studio-server": "0.8.178",
67
+ "@hyperframes/shader-transitions": "0.8.178"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.176"
84
+ "@hyperframes/producer": "0.8.178"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -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
+ }