@hyperframes/studio 0.8.60 → 0.8.62

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.
Files changed (53) hide show
  1. package/dist/assets/{hyperframes-player-Di7GyCOP.js → hyperframes-player-BMHU9-3w.js} +1 -1
  2. package/dist/assets/{index-Dx_pt9m4.js → index-Ce1SRT2i.js} +1 -1
  3. package/dist/assets/{index-Dpq2lkOm.js → index-DLYk_vu_.js} +186 -186
  4. package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
  5. package/dist/assets/{index-jAjhQTrB.js → index-Dg0Vjj_i.js} +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3308 -2895
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +2 -0
  13. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
  14. package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
  15. package/src/hooks/useRenderClipContent.test.ts +33 -4
  16. package/src/hooks/useRenderClipContent.ts +7 -3
  17. package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
  18. package/src/hooks/useSdkSession.ts +84 -14
  19. package/src/hooks/useStudioSdkSessions.ts +8 -1
  20. package/src/player/components/AudioWaveform.test.tsx +53 -1
  21. package/src/player/components/AudioWaveform.tsx +113 -63
  22. package/src/player/components/TimelineClipFades.tsx +64 -31
  23. package/src/player/components/TimelineLanes.tsx +15 -8
  24. package/src/player/components/TimelineTransitionBadge.tsx +59 -0
  25. package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
  26. package/src/player/components/VideoThumbnail.test.tsx +23 -12
  27. package/src/player/components/VideoThumbnail.tsx +83 -27
  28. package/src/player/components/audioClipLink.test.ts +34 -0
  29. package/src/player/components/audioClipLink.ts +33 -0
  30. package/src/player/components/audioWaveformPeaks.test.ts +32 -0
  31. package/src/player/components/audioWaveformPeaks.ts +46 -0
  32. package/src/player/components/thumbnailUtils.test.ts +17 -0
  33. package/src/player/components/thumbnailUtils.ts +7 -0
  34. package/src/player/components/timelineTheme.ts +6 -0
  35. package/src/player/components/timelineTransitionSeams.test.ts +48 -0
  36. package/src/player/components/timelineTransitionSeams.ts +36 -0
  37. package/src/player/components/timelineZones.test.ts +18 -0
  38. package/src/player/components/timelineZones.ts +4 -1
  39. package/src/player/hooks/useShadowPreviewReload.ts +41 -6
  40. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +53 -0
  41. package/src/player/lib/playbackTypes.ts +2 -0
  42. package/src/player/lib/timelineDOM.test.ts +28 -0
  43. package/src/player/lib/timelineDOM.ts +24 -2
  44. package/src/player/lib/timelineIframeHelpers.ts +7 -0
  45. package/src/player/lib/timelineTransitionMetadata.ts +52 -0
  46. package/src/player/lib/timelineViewportBudgets.ts +2 -1
  47. package/src/player/store/timelineElement.ts +1 -0
  48. package/src/styles/hex-baseline.json +4 -4
  49. package/src/styles/studio.css +84 -0
  50. package/src/styles/theme.css +10 -2
  51. package/src/utils/sdkCutover.test.ts +102 -1
  52. package/src/utils/sdkCutover.ts +16 -21
  53. package/src/utils/sdkEditTransaction.ts +50 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.60",
3
+ "version": "0.8.62",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.60",
55
- "@hyperframes/player": "0.8.60",
56
- "@hyperframes/sdk": "0.8.60",
57
- "@hyperframes/parsers": "0.8.60",
58
- "@hyperframes/studio-server": "0.8.60"
54
+ "@hyperframes/core": "0.8.62",
55
+ "@hyperframes/parsers": "0.8.62",
56
+ "@hyperframes/sdk": "0.8.62",
57
+ "@hyperframes/player": "0.8.62",
58
+ "@hyperframes/studio-server": "0.8.62"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.60"
74
+ "@hyperframes/producer": "0.8.62"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
package/src/App.tsx CHANGED
@@ -130,6 +130,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
130
130
  projectId,
131
131
  activeCompPath,
132
132
  masterCompPath,
133
+ fileManager.fileTree,
134
+ fileManager.fileTreeLoaded,
133
135
  );
134
136
  const activeCompPathRef = useRef(activeCompPath);
135
137
  activeCompPathRef.current = activeCompPath;
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
  import { act } from "react";
3
4
  import { createRoot } from "react-dom/client";
@@ -376,4 +377,59 @@ describe("external file change coordinator", () => {
376
377
  expect(reloadPreview).not.toHaveBeenCalled();
377
378
  });
378
379
  });
380
+
381
+ // `/api/events` is one connection per SERVER (CLI host), not per project — a
382
+ // tab left open from a `preview` run whose port was later reused by a
383
+ // different project's `preview` shares this stream with it. Both projects
384
+ // commonly use the same default composition path, so without the filter a
385
+ // stale tab reloads its preview and re-reads its own composition on every
386
+ // save the OTHER project makes.
387
+ describe("cross-project deliveries on a shared connection", () => {
388
+ it("ignores a delivery whose projectId does not match this tab's", async () => {
389
+ const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
390
+ const reloadPreview = vi.fn();
391
+ const reloadSdkSession = vi.fn();
392
+ await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
393
+
394
+ await act(async () =>
395
+ handler?.({
396
+ path: "index.html",
397
+ content: "other project",
398
+ version: "v9",
399
+ projectId: "project-b",
400
+ }),
401
+ );
402
+
403
+ expect(drainPendingChanges).not.toHaveBeenCalled();
404
+ expect(reloadPreview).not.toHaveBeenCalled();
405
+ expect(reloadSdkSession).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it("still reloads for a delivery whose projectId matches this tab's", async () => {
409
+ const reloadPreview = vi.fn();
410
+ await mountCoordinator({ reloadPreview });
411
+
412
+ await act(async () =>
413
+ handler?.({
414
+ path: "index.html",
415
+ content: "same project",
416
+ version: "v9",
417
+ projectId: "project-a",
418
+ }),
419
+ );
420
+
421
+ expect(reloadPreview).toHaveBeenCalledOnce();
422
+ });
423
+
424
+ it("still reloads when projectId is absent (older server, one release of skew)", async () => {
425
+ const reloadPreview = vi.fn();
426
+ await mountCoordinator({ reloadPreview });
427
+
428
+ await act(async () =>
429
+ handler?.({ path: "index.html", content: "no project id", version: "v9" }),
430
+ );
431
+
432
+ expect(reloadPreview).toHaveBeenCalledOnce();
433
+ });
434
+ });
379
435
  });
@@ -349,6 +349,19 @@ export function useExternalFileChangeCoordinator({
349
349
  logReload("file-change", { path: null, why: path ? "no project" : "no path in payload" });
350
350
  return;
351
351
  }
352
+ // `/api/events` is one connection per SERVER, not per project: under the
353
+ // CLI host, a tab left open from a `preview` run that has since exited
354
+ // shares this stream with whatever project now runs on that port. Both
355
+ // commonly use the same default composition path, so without this check
356
+ // a stale tab reloads its preview (which then 404s — the CLI host is
357
+ // single-project, so it can't resolve the OTHER project it's serving)
358
+ // and re-reads its own composition on every save the CURRENT project
359
+ // makes. Absent field (older server, one release of skew) still passes.
360
+ const deliveredProjectId = readFileChangeField(payload, "projectId");
361
+ if (deliveredProjectId && deliveredProjectId !== projectId) {
362
+ logReload("suppressed", { path, why: "other project" });
363
+ return;
364
+ }
352
365
  pendingTimelineEditPathRef.current.delete(path);
353
366
 
354
367
  const content = readFileChangeContent(payload);
@@ -13,7 +13,7 @@ import { useRenderClipContent } from "./useRenderClipContent";
13
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
14
 
15
15
  afterEach(() => {
16
- usePlayerStore.setState({ thumbnailMode: "hidden" });
16
+ usePlayerStore.setState({ thumbnailMode: "hidden", elements: [] });
17
17
  document.body.innerHTML = "";
18
18
  });
19
19
 
@@ -144,6 +144,36 @@ describe("useRenderClipContent", () => {
144
144
  }
145
145
  });
146
146
 
147
+ it("marks audio linked when a video clip uses the same file, and muted when hidden", () => {
148
+ usePlayerStore.setState({
149
+ thumbnailMode: "hidden",
150
+ elements: [
151
+ {
152
+ id: "picture",
153
+ tag: "video",
154
+ start: 0,
155
+ duration: 4,
156
+ track: 0,
157
+ src: "assets/clip.mp4",
158
+ },
159
+ ],
160
+ });
161
+ const linked = renderClipContent({
162
+ id: "bed",
163
+ tag: "audio",
164
+ start: 0,
165
+ duration: 4,
166
+ track: 1,
167
+ src: "assets/clip.mp4",
168
+ hidden: true,
169
+ });
170
+ expect(isValidElement<{ linked: boolean; muted: boolean }>(linked)).toBe(true);
171
+ if (isValidElement<{ linked: boolean; muted: boolean }>(linked)) {
172
+ expect(linked.props.linked).toBe(true);
173
+ expect(linked.props.muted).toBe(true);
174
+ }
175
+ });
176
+
147
177
  it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
148
178
  usePlayerStore.setState({ thumbnailMode: "adaptive" });
149
179
 
@@ -208,7 +238,7 @@ describe("useRenderClipContent", () => {
208
238
  }
209
239
  });
210
240
 
211
- it("forwards the viewport priority and interaction detail to media work", () => {
241
+ it("forwards the viewport priority to video media work", () => {
212
242
  usePlayerStore.setState({ thumbnailMode: "adaptive", timelineSessionEpoch: 7 });
213
243
 
214
244
  const content = renderClipContent(
@@ -229,7 +259,6 @@ describe("useRenderClipContent", () => {
229
259
  projectId: string;
230
260
  sessionEpoch: number;
231
261
  priority: string;
232
- rich: boolean;
233
262
  }>(content),
234
263
  ).toBe(true);
235
264
  if (isValidElement(content)) {
@@ -237,8 +266,8 @@ describe("useRenderClipContent", () => {
237
266
  projectId: "my-project",
238
267
  sessionEpoch: 7,
239
268
  priority: "interaction",
240
- rich: true,
241
269
  });
270
+ expect(content.props).not.toHaveProperty("rich");
242
271
  }
243
272
  });
244
273
 
@@ -4,6 +4,7 @@ import { createElement } from "react";
4
4
  import { CompositionThumbnail, VideoThumbnail } from "../player";
5
5
  import type { TimelineElement } from "../player";
6
6
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
7
+ import { audioPillFlags } from "../player/components/audioClipLink";
7
8
  import { AudioWaveform } from "../player/components/AudioWaveform";
8
9
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
9
10
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
@@ -70,6 +71,7 @@ function renderAudioClip(
70
71
  sessionEpoch: number,
71
72
  labelColor: string,
72
73
  context: TimelineClipRenderContext,
74
+ elements: readonly TimelineElement[],
73
75
  ): ReactNode {
74
76
  const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
75
77
  const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
@@ -91,6 +93,7 @@ function renderAudioClip(
91
93
  projectId: pid,
92
94
  sessionEpoch,
93
95
  priority: context.priority,
96
+ ...audioPillFlags(el, elements),
94
97
  });
95
98
  }
96
99
 
@@ -112,6 +115,7 @@ export function useRenderClipContent({
112
115
  const effectiveMode = effectiveThumbnailMode(thumbnailMode);
113
116
  const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
114
117
  const contentRevision = usePlayerStore((s) => s.thumbnailContentRevision);
118
+ const elements = usePlayerStore((s) => s.elements);
115
119
  return useCallback(
116
120
  // Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
117
121
  // fallow-ignore-next-line complexity
@@ -127,7 +131,7 @@ export function useRenderClipContent({
127
131
  // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
128
132
  if (effectiveMode === "hidden") {
129
133
  return el.tag === "audio"
130
- ? renderAudioClip(el, pid, sessionEpoch, style.label, context)
134
+ ? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
131
135
  : null;
132
136
  }
133
137
 
@@ -165,7 +169,7 @@ export function useRenderClipContent({
165
169
  // activePreviewUrl thumbnail branch; audio rows need waveform data, not a
166
170
  // captured frame from the currently drilled composition preview.
167
171
  if (el.tag === "audio") {
168
- return renderAudioClip(el, pid, sessionEpoch, style.label, context);
172
+ return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
169
173
  }
170
174
 
171
175
  // When drilled into a composition, render all inner elements via
@@ -220,7 +224,6 @@ export function useRenderClipContent({
220
224
  projectId: pid,
221
225
  sessionEpoch,
222
226
  priority: context.priority,
223
- rich: context.rich,
224
227
  });
225
228
  }
226
229
 
@@ -252,6 +255,7 @@ export function useRenderClipContent({
252
255
  effectiveMode,
253
256
  sessionEpoch,
254
257
  contentRevision,
258
+ elements,
255
259
  ],
256
260
  );
257
261
  }
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
  import { act } from "react";
3
4
  import { createRoot } from "react-dom/client";
@@ -263,6 +264,64 @@ describe("useSdkSession unavailable telemetry", () => {
263
264
  stage: "read",
264
265
  reason: "http_error",
265
266
  status: 404,
267
+ why: undefined,
268
+ });
269
+ await act(async () => root.unmount());
270
+ });
271
+
272
+ // `why` distinguishes the studio-server route's own 403/404 causes (the
273
+ // project's folder having been renamed or deleted out from under a running
274
+ // server, vs. a NUL byte, vs. a path escaping the project) from a bare
275
+ // status code. Optional and best-effort: an older server or a non-JSON body
276
+ // just omits it, which must not crash the read.
277
+ it("carries the server's why when the error body has one", async () => {
278
+ vi.stubGlobal(
279
+ "fetch",
280
+ vi.fn(
281
+ async () =>
282
+ ({
283
+ ok: false,
284
+ status: 404,
285
+ json: async () => ({ error: "not found", why: "project_dir_missing" }),
286
+ }) as Response,
287
+ ),
288
+ );
289
+ const root = createRoot(document.createElement("div"));
290
+ await act(async () => root.render(<Probe projectId="project-a" />));
291
+ await flushAsyncEffects();
292
+
293
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
294
+ stage: "read",
295
+ reason: "http_error",
296
+ status: 404,
297
+ why: "project_dir_missing",
298
+ });
299
+ await act(async () => root.unmount());
300
+ });
301
+
302
+ it("stays quiet about why when the response body cannot be parsed", async () => {
303
+ vi.stubGlobal(
304
+ "fetch",
305
+ vi.fn(
306
+ async () =>
307
+ ({
308
+ ok: false,
309
+ status: 500,
310
+ json: async () => {
311
+ throw new Error("not json");
312
+ },
313
+ }) as unknown as Response,
314
+ ),
315
+ );
316
+ const root = createRoot(document.createElement("div"));
317
+ await act(async () => root.render(<Probe projectId="project-a" />));
318
+ await flushAsyncEffects();
319
+
320
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
321
+ stage: "read",
322
+ reason: "http_error",
323
+ status: 500,
324
+ why: undefined,
266
325
  });
267
326
  await act(async () => root.unmount());
268
327
  });
@@ -287,6 +346,8 @@ describe("useSdkSession unavailable telemetry", () => {
287
346
  expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
288
347
  stage: "read",
289
348
  reason: "network",
349
+ elapsed_ms: expect.any(Number),
350
+ hidden: expect.any(Boolean),
290
351
  });
291
352
  expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", {
292
353
  stage: "open",
@@ -295,6 +356,70 @@ describe("useSdkSession unavailable telemetry", () => {
295
356
  await act(async () => root.unmount());
296
357
  });
297
358
 
359
+ // `elapsed_ms` is the discriminator this event exists for: a policy block
360
+ // (CSP, PNA, an extension rewriting fetch) rejects near-instantly; a dropped
361
+ // connection (the tab or `preview` server going away mid-flight) rejects
362
+ // after a real delay. Both look identical without the timing.
363
+ it("times the network rejection from fetch start to reject", async () => {
364
+ // Fake only the clock `performance.now` reads, not timers: other code in
365
+ // this tree (React's own scheduler included) also calls `performance.now`,
366
+ // so pinning return values by call order (`mockReturnValueOnce`) is
367
+ // unreliable — a real run showed React consuming the queued values first.
368
+ // A fake clock that only advances when we say so sidesteps that entirely.
369
+ vi.useFakeTimers({ toFake: ["performance"] });
370
+ let rejectFetch: ((error: unknown) => void) | undefined;
371
+ vi.stubGlobal(
372
+ "fetch",
373
+ vi.fn(
374
+ () =>
375
+ new Promise((_resolve, reject) => {
376
+ rejectFetch = reject;
377
+ }),
378
+ ),
379
+ );
380
+ const root = createRoot(document.createElement("div"));
381
+ // The effect runs synchronously up to `await fetch(...)`, capturing
382
+ // `fetchStarted` at the current (fake) clock value before this returns.
383
+ await act(async () => root.render(<Probe projectId="project-a" />));
384
+ await vi.advanceTimersByTimeAsync(3_200);
385
+ await act(async () => rejectFetch?.(new TypeError("Failed to fetch")));
386
+
387
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
388
+ stage: "read",
389
+ reason: "network",
390
+ elapsed_ms: 3_200,
391
+ hidden: false,
392
+ });
393
+ vi.useRealTimers();
394
+ await act(async () => root.unmount());
395
+ });
396
+
397
+ it("records the page as hidden when the rejection lands after the tab is backgrounded", async () => {
398
+ const originalDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
399
+ Object.defineProperty(document, "visibilityState", {
400
+ configurable: true,
401
+ get: () => "hidden",
402
+ });
403
+ vi.stubGlobal(
404
+ "fetch",
405
+ vi.fn(async () => {
406
+ throw new TypeError("Failed to fetch");
407
+ }),
408
+ );
409
+ const root = createRoot(document.createElement("div"));
410
+ await act(async () => root.render(<Probe projectId="project-a" />));
411
+ await flushAsyncEffects();
412
+
413
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
414
+ stage: "read",
415
+ reason: "network",
416
+ elapsed_ms: expect.any(Number),
417
+ hidden: true,
418
+ });
419
+ await act(async () => root.unmount());
420
+ if (originalDescriptor) Object.defineProperty(document, "visibilityState", originalDescriptor);
421
+ });
422
+
298
423
  it("separates an unexpected response shape from a failed request", async () => {
299
424
  vi.stubGlobal(
300
425
  "fetch",
@@ -324,13 +449,67 @@ describe("useSdkSession unavailable telemetry", () => {
324
449
  await act(async () => root.render(<Probe projectId="project-a" />));
325
450
  await flushAsyncEffects();
326
451
 
452
+ // No fileTree passed (this Probe doesn't have one) — path_in_tree stays
453
+ // null, same as before the tree loads. See the tree-aware tests below.
327
454
  expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
328
455
  stage: "read",
329
456
  reason: "absent_or_empty",
457
+ path_in_tree: null,
330
458
  });
331
459
  await act(async () => root.unmount());
332
460
  });
333
461
 
462
+ // The graveyard-refuted fix's proposed next step: instrument, don't act.
463
+ // `path_in_tree` separates "genuinely not in the loaded tree" from
464
+ // "concurrent/duplicate open" without deciding anything on Studio's behalf.
465
+ // Two separate mounts (not a re-render of one): fileTree/fileTreeLoaded are
466
+ // deliberately outside the open effect's deps — re-running the whole
467
+ // open/dispose cycle on every tree refresh would drop a perfectly good
468
+ // session far more often than the tree actually changes — so a prop-only
469
+ // change on an already-mounted probe would never re-fire the read this
470
+ // event comes from.
471
+ it("reports path_in_tree against the caller's loaded file tree", async () => {
472
+ vi.stubGlobal(
473
+ "fetch",
474
+ vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
475
+ );
476
+ function TreeProbe({
477
+ fileTree,
478
+ fileTreeLoaded,
479
+ }: {
480
+ fileTree: string[];
481
+ fileTreeLoaded: boolean;
482
+ }) {
483
+ useSdkSession("project-a", "index.html", fileTree, fileTreeLoaded);
484
+ return null;
485
+ }
486
+
487
+ const rootA = createRoot(document.createElement("div"));
488
+ await act(async () =>
489
+ rootA.render(<TreeProbe fileTree={["other.html"]} fileTreeLoaded={true} />),
490
+ );
491
+ await flushAsyncEffects();
492
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
493
+ stage: "read",
494
+ reason: "absent_or_empty",
495
+ path_in_tree: false,
496
+ });
497
+ await act(async () => rootA.unmount());
498
+
499
+ trackMock.mockClear();
500
+ const rootB = createRoot(document.createElement("div"));
501
+ await act(async () =>
502
+ rootB.render(<TreeProbe fileTree={["index.html"]} fileTreeLoaded={true} />),
503
+ );
504
+ await flushAsyncEffects();
505
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
506
+ stage: "read",
507
+ reason: "absent_or_empty",
508
+ path_in_tree: true,
509
+ });
510
+ await act(async () => rootB.unmount());
511
+ });
512
+
334
513
  it("reports a parse failure with its message", async () => {
335
514
  vi.stubGlobal(
336
515
  "fetch",
@@ -26,13 +26,22 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
26
26
  * "Failed to fetch", "Load failed", "NetworkError when attempting to fetch
27
27
  * resource." — so the class was a network problem filed under a parser one, and
28
28
  * unaddressable in that bucket.
29
+ *
30
+ * `network` carries `elapsedMs` (time from fetch start to rejection) and
31
+ * `hidden` (`document.visibilityState` at the moment of rejection) because
32
+ * "the fetch rejected" alone conflates two very different situations: a
33
+ * closing tab or a server that exited under a live one (rejects after a real
34
+ * delay, `hidden` often true by the time it lands) versus a request blocked
35
+ * before it left the browser — CSP `connect-src`, Private Network Access, an
36
+ * extension rewriting `fetch` (rejects near-instantly, tab stays visible and
37
+ * alive). Telemetry before this change could not tell the two apart.
29
38
  */
30
39
  type ProjectFileReadFailure =
31
40
  | { ok: false; reason: "unsafe_path" }
32
- | { ok: false; reason: "http_error"; status: number }
41
+ | { ok: false; reason: "http_error"; status: number; why?: string }
33
42
  | { ok: false; reason: "missing_content" }
34
43
  | { ok: false; reason: "absent_or_empty" }
35
- | { ok: false; reason: "network" };
44
+ | { ok: false; reason: "network"; elapsedMs: number; hidden: boolean };
36
45
 
37
46
  type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
38
47
 
@@ -54,16 +63,61 @@ type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFail
54
63
  * called from is a long pre-existing async body already near the complexity
55
64
  * threshold, and this branch is one coherent unit.
56
65
  */
57
- function reportReadFailure(read: ProjectFileReadFailure, projectId: string): string | null {
66
+ function reportReadFailure(
67
+ read: ProjectFileReadFailure,
68
+ projectId: string,
69
+ pathInTree: boolean | null,
70
+ ): string | null {
58
71
  trackStudioEvent("sdk_session_unavailable", {
59
72
  stage: "read",
60
73
  reason: read.reason,
61
- ...(read.reason === "http_error" ? { status: read.status } : {}),
74
+ ...(read.reason === "http_error" ? { status: read.status, why: read.why } : {}),
75
+ ...(read.reason === "network" ? { elapsed_ms: read.elapsedMs, hidden: read.hidden } : {}),
76
+ // Only meaningful for `absent_or_empty` — the graveyard-refuted "clear
77
+ // activeCompPath when it's not in the tree" fix's proposed next step,
78
+ // scoped to instrumentation only. `null` while the tree hasn't loaded
79
+ // yet: a false-negative there would read as "genuinely absent" when it is
80
+ // really "haven't looked".
81
+ ...(read.reason === "absent_or_empty" ? { path_in_tree: pathInTree } : {}),
62
82
  });
63
83
  if (read.reason !== "http_error") return null;
64
84
  return read.status === 404 ? projectId : null;
65
85
  }
66
86
 
87
+ // The request never produced a response: offline, the dev server gone, a
88
+ // CSP/Private-Network-Access block, or an extension rewriting fetch. Called
89
+ // from the fetch's own catch so it is reported as a READ failure — left to
90
+ // propagate, the effect's outer catch reported it as `stage: "open"`, which
91
+ // claims the composition failed to parse. Every `stage: open` event before
92
+ // this change was this branch, so the label made the largest class
93
+ // unaddressable.
94
+ //
95
+ // `elapsedMs` and `hidden` (read synchronously, right at the moment of
96
+ // rejection, so they survive whenever the event itself does) are the
97
+ // discriminator between "blocked before send" (near-zero elapsed) and "the
98
+ // tab/server went away mid-flight" (real elapsed, often already hidden) —
99
+ // see the type's doc comment.
100
+ function networkReadFailure(
101
+ fetchStarted: number,
102
+ ): Extract<ProjectFileReadFailure, { reason: "network" }> {
103
+ return {
104
+ ok: false,
105
+ reason: "network",
106
+ elapsedMs: Math.round(performance.now() - fetchStarted),
107
+ hidden: typeof document !== "undefined" && document.visibilityState === "hidden",
108
+ };
109
+ }
110
+
111
+ // `why` distinguishes the studio-server route's own 403/404 causes (a NUL
112
+ // byte, a path escaping the project, or — the one that used to read as a
113
+ // plain path-traversal 403 — this project's folder having been renamed or
114
+ // deleted out from under a still-running server) that a bare status code
115
+ // cannot. Best-effort: an older server or a non-JSON error body just omits it.
116
+ async function httpReadFailureWhy(res: Response): Promise<string | undefined> {
117
+ const body = (await res.json().catch(() => null)) as { why?: unknown } | null;
118
+ return typeof body?.why === "string" ? body.why : undefined;
119
+ }
120
+
67
121
  /**
68
122
  * Read a project file's content (optional read — a missing file is not an
69
123
  * error). Replaces the removed SDK http adapter's `read()` — the only thing
@@ -80,20 +134,18 @@ async function readProjectFileOptional(
80
134
  // already confines both values to single segments of this same-origin URL.
81
135
  if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
82
136
  let res: Response;
137
+ const fetchStarted = performance.now();
83
138
  try {
84
139
  res = await fetch(
85
140
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
86
141
  );
87
142
  } catch {
88
- // The request never produced a response: offline, the dev server gone, a
89
- // CSP/Private-Network-Access block, or an extension rewriting fetch. Caught
90
- // here so it is reported as a READ failure — left to propagate, the effect's
91
- // outer catch reported it as `stage: "open"`, which claims the composition
92
- // failed to parse. Every `stage: open` event before this change was this
93
- // branch, so the label made the largest class unaddressable.
94
- return { ok: false, reason: "network" };
143
+ return networkReadFailure(fetchStarted);
144
+ }
145
+ if (!res.ok) {
146
+ const why = await httpReadFailureWhy(res);
147
+ return { ok: false, reason: "http_error", status: res.status, ...(why ? { why } : {}) };
95
148
  }
96
- if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
97
149
  const data = (await res.json()) as { content?: string };
98
150
  // `optional=1` answers a missing file with 200 + `content: ""`, so a
99
151
  // non-string here means a response shape we did not expect, not absence.
@@ -224,6 +276,13 @@ function disposeSdkSession(session: Composition): void {
224
276
  export function useSdkSession(
225
277
  projectId: string | null,
226
278
  activeCompPath: string | null,
279
+ // Optional: only the app's primary session (App.tsx, wired to
280
+ // useFileManager's tree) can answer `path_in_tree` on an `absent_or_empty`
281
+ // read. A secondary session opened for a promote/bind target
282
+ // (DesignPanelPromoteProvider) has no tree of its own to check against and
283
+ // reports `null`, same as before the tree loads.
284
+ fileTree: readonly string[] = [],
285
+ fileTreeLoaded = false,
227
286
  ): SdkSessionHandle {
228
287
  const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
229
288
  const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
@@ -276,7 +335,8 @@ export function useSdkSession(
276
335
  .then(async (read) => {
277
336
  if (cancelled) return;
278
337
  if (!read.ok) {
279
- setUnreachableProject(reportReadFailure(read, projectId));
338
+ const pathInTree = fileTreeLoaded ? fileTree.includes(activeCompPath) : null;
339
+ setUnreachableProject(reportReadFailure(read, projectId, pathInTree));
280
340
  return;
281
341
  }
282
342
  setUnreachableProject(null);
@@ -305,7 +365,11 @@ export function useSdkSession(
305
365
  )
306
366
  ) {
307
367
  disposeSdkSession(comp);
308
- trackStudioEvent("sdk_session_unavailable", { stage: "ownership" });
368
+ // Not a failure: project/path/reloadToken moved on while this open was
369
+ // in flight, and the effect that owns the new identity already opened
370
+ // (or is opening) its own session. Emitting this as `sdk_session_unavailable`
371
+ // counted a benign race as a broken precondition on the cutover dashboard.
372
+ trackStudioEvent("sdk_session_superseded", {});
309
373
  return;
310
374
  }
311
375
  const displaced = ownedSessionRef.current;
@@ -343,6 +407,12 @@ export function useSdkSession(
343
407
  disposeSdkSession(owned.session);
344
408
  }
345
409
  };
410
+ // fileTree/fileTreeLoaded deliberately excluded: they only annotate a
411
+ // read-failure event fired from this same effect run (the diagnostic
412
+ // "was the path in the last-loaded tree" snapshot), and re-running the
413
+ // whole open/dispose cycle on every tree refresh would drop and reopen a
414
+ // perfectly good session far more often than the tree actually changes.
415
+ // eslint-disable-next-line react-hooks/exhaustive-deps
346
416
  }, [projectId, activeCompPath, reloadToken]);
347
417
 
348
418
  const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
@@ -18,12 +18,19 @@ export function useStudioSdkSessions(
18
18
  projectId: string | null,
19
19
  activeCompPath: string | null,
20
20
  masterCompPath: string | null,
21
+ fileTree: readonly string[] = [],
22
+ fileTreeLoaded = false,
21
23
  ) {
22
24
  // On the master view (no explicit comp) the schema panels target the project's
23
25
  // resolved main composition — the first `.html` in the tree, not a hardcoded
24
26
  // "index.html" that may not exist. `null` when the project has no composition
25
27
  // yet, which correctly leaves the session (and the panels) empty.
26
- const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
28
+ const sdkHandle = useSdkSession(
29
+ projectId,
30
+ activeCompPath ?? masterCompPath,
31
+ fileTree,
32
+ fileTreeLoaded,
33
+ );
27
34
  const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
28
35
  useEffect(() => {
29
36
  usePreviewVariablesStore.getState().setValues(null);