@hyperframes/studio 0.8.70 → 0.8.72
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-CYdwxTwz.js → hyperframes-player-jTC1EDAI.js} +1 -1
- package/dist/assets/{index-BBOktYKC.css → index-0w6iIlwZ.css} +1 -1
- package/dist/assets/{index-Egnc_kWw.js → index-BqUGSWqN.js} +176 -176
- package/dist/assets/{index-D7hgjzBq.js → index-DpVD1igA.js} +1 -1
- package/dist/assets/{index-BPafHV3q.js → index-HuYqM2Fi.js} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.html +2 -2
- package/dist/index.js +834 -780
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -7
- package/src/App.tsx +5 -0
- package/src/components/CompositionMissingBanner.test.tsx +35 -0
- package/src/components/CompositionMissingBanner.tsx +32 -0
- package/src/components/nle/NLEPreview.test.ts +19 -1
- package/src/components/nle/NLEPreview.tsx +22 -7
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +36 -1
- package/src/hooks/useExternalFileChangeCoordinator.ts +19 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +146 -0
- package/src/hooks/useSdkSession.ts +56 -8
- package/src/hooks/useStudioExternalFileChanges.ts +2 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -0
- package/src/player/components/VolumeControl.styles.test.ts +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +1 -1
- package/src/styles/componentStyles.test.ts +35 -0
- package/src/styles/components.css +843 -0
- package/src/styles/hex-baseline.json +1 -1
- package/src/styles/studio.css +1 -841
- package/src/styles/tokenGate.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.72",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"types": "./dist/styles/tailwind-preset.d.ts"
|
|
25
25
|
},
|
|
26
26
|
"./theme.css": "./src/styles/theme.css",
|
|
27
|
+
"./components.css": "./src/styles/components.css",
|
|
27
28
|
"./styles.css": "./dist/styles.css",
|
|
28
29
|
"./package.json": "./package.json"
|
|
29
30
|
},
|
|
@@ -51,11 +52,11 @@
|
|
|
51
52
|
"dockview-react": "8.3.1",
|
|
52
53
|
"gsap": "^3.13.0",
|
|
53
54
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/core": "0.8.
|
|
55
|
-
"@hyperframes/parsers": "0.8.
|
|
56
|
-
"@hyperframes/player": "0.8.
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
55
|
+
"@hyperframes/core": "0.8.72",
|
|
56
|
+
"@hyperframes/parsers": "0.8.72",
|
|
57
|
+
"@hyperframes/player": "0.8.72",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.72",
|
|
59
|
+
"@hyperframes/sdk": "0.8.72"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
62
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
"vite": "^6.4.2",
|
|
72
73
|
"vitest": "^4.1.11",
|
|
73
74
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
75
|
+
"@hyperframes/producer": "0.8.72"
|
|
75
76
|
},
|
|
76
77
|
"peerDependencies": {
|
|
77
78
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { StudioOverlays } from "./components/StudioOverlays";
|
|
|
8
8
|
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
|
9
9
|
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
|
|
10
10
|
import { ProjectUnreachableBanner } from "./components/ProjectUnreachableBanner";
|
|
11
|
+
import { CompositionMissingBanner } from "./components/CompositionMissingBanner";
|
|
11
12
|
import { useCaptionStore } from "./captions/store";
|
|
12
13
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
13
14
|
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
@@ -132,6 +133,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
132
133
|
masterCompPath,
|
|
133
134
|
fileManager.fileTree,
|
|
134
135
|
fileManager.fileTreeLoaded,
|
|
136
|
+
fileManager.refreshFileTree,
|
|
135
137
|
);
|
|
136
138
|
const activeCompPathRef = useRef(activeCompPath);
|
|
137
139
|
activeCompPathRef.current = activeCompPath;
|
|
@@ -481,6 +483,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
481
483
|
{sdkHandle.unreachableProject && (
|
|
482
484
|
<ProjectUnreachableBanner projectId={sdkHandle.unreachableProject} />
|
|
483
485
|
)}
|
|
486
|
+
{sdkHandle.compositionMissing && activeCompPath && (
|
|
487
|
+
<CompositionMissingBanner path={activeCompPath} />
|
|
488
|
+
)}
|
|
484
489
|
<EditorShell
|
|
485
490
|
readOnlyPreview={readOnlyPreview}
|
|
486
491
|
readOnlyPreviewReason={readOnlyPreviewReason}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
2
|
+
// The mount/unmount harness matches ProjectUnreachableBanner.test.tsx's — the
|
|
3
|
+
// same small React-root scaffold every banner test in this directory uses.
|
|
4
|
+
// @vitest-environment happy-dom
|
|
5
|
+
import { act } from "react";
|
|
6
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
import { CompositionMissingBanner } from "./CompositionMissingBanner";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
let root: Root | null = null;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (root) act(() => root?.unmount());
|
|
16
|
+
root = null;
|
|
17
|
+
document.body.replaceChildren();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("CompositionMissingBanner", () => {
|
|
21
|
+
it("names the missing composition and does not claim the whole project is gone", async () => {
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
document.body.append(host);
|
|
24
|
+
root = createRoot(host);
|
|
25
|
+
await act(async () => {
|
|
26
|
+
root?.render(<CompositionMissingBanner path="scenes/intro.html" />);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(host.textContent).toContain("scenes/intro.html");
|
|
30
|
+
expect(host.textContent).toContain("no longer on disk");
|
|
31
|
+
// Distinct from ProjectUnreachableBanner's wording — this is one file
|
|
32
|
+
// inside a project that resolves fine, not the whole project.
|
|
33
|
+
expect(host.textContent).not.toContain("This Studio is serving");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface CompositionMissingBannerProps {
|
|
2
|
+
/** The composition this tab has open, that the server can no longer find. */
|
|
3
|
+
path: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shown when the currently open composition's file has vanished from disk
|
|
8
|
+
* mid-session — the `absent` read reason (#4325): the file tree listed it and
|
|
9
|
+
* the read cannot get it.
|
|
10
|
+
*
|
|
11
|
+
* Distinct from `ProjectUnreachableBanner`: there the whole PROJECT fails to
|
|
12
|
+
* resolve; here the project is fine and this one file inside it is not. Proven
|
|
13
|
+
* 2026-09-23 to be a stale tree, not a placeholder or a path-encoding gap:
|
|
14
|
+
* `refreshFileTree` only ran after Studio's own file operations, so an agent
|
|
15
|
+
* removing or replacing a composition updated the preview and the SDK session
|
|
16
|
+
* but never the listing — the file stayed clickable, and every edit through
|
|
17
|
+
* it failed silently, with nothing to tell the user why. The tree is now
|
|
18
|
+
* refreshed as soon as this fires (`useSdkSession`'s `onAbsentRead`); this
|
|
19
|
+
* banner is the only thing that explains the failure while that catches up.
|
|
20
|
+
*/
|
|
21
|
+
export function CompositionMissingBanner({ path }: CompositionMissingBannerProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
role="alert"
|
|
25
|
+
className="hf-backdrop-in absolute left-1/2 top-14 z-92 flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-amber-500/30 bg-amber-950/85 px-4 py-2 text-[12px] font-medium text-amber-100 shadow-lg shadow-black/30"
|
|
26
|
+
>
|
|
27
|
+
<span>
|
|
28
|
+
<strong>{path}</strong> is no longer on disk. Select another composition to keep editing.
|
|
29
|
+
</span>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -80,6 +80,10 @@ function renderPreview(
|
|
|
80
80
|
previewSlots: Array<{ gen: number; role: "live" | "shadow"; url?: string }> = [
|
|
81
81
|
{ gen: 0, role: "live" },
|
|
82
82
|
],
|
|
83
|
+
{
|
|
84
|
+
box = { width: 800, height: 600 },
|
|
85
|
+
fillBox,
|
|
86
|
+
}: { box?: { width: number; height: number }; fillBox?: boolean } = {},
|
|
83
87
|
) {
|
|
84
88
|
resizeCallbacks = [];
|
|
85
89
|
const host = document.createElement("div");
|
|
@@ -99,6 +103,7 @@ function renderPreview(
|
|
|
99
103
|
onShadowError: () => {},
|
|
100
104
|
setShadowIframeNode: () => {},
|
|
101
105
|
resetPreviewSlots: () => {},
|
|
106
|
+
fillBox,
|
|
102
107
|
}),
|
|
103
108
|
);
|
|
104
109
|
});
|
|
@@ -108,7 +113,7 @@ function renderPreview(
|
|
|
108
113
|
expect(viewport).toBeTruthy();
|
|
109
114
|
expect(stage).toBeTruthy();
|
|
110
115
|
|
|
111
|
-
setRect(viewport,
|
|
116
|
+
setRect(viewport, box);
|
|
112
117
|
act(() => {
|
|
113
118
|
for (const fire of resizeCallbacks) fire();
|
|
114
119
|
});
|
|
@@ -251,6 +256,19 @@ describe("NLEPreview", () => {
|
|
|
251
256
|
view.cleanup();
|
|
252
257
|
});
|
|
253
258
|
|
|
259
|
+
it("insets the picture by default and fills a same-shape box when fillBox is on", () => {
|
|
260
|
+
const box = { width: 640, height: 360 };
|
|
261
|
+
const inset = renderPreview(undefined, { box });
|
|
262
|
+
expect([inset.stage.style.width, inset.stage.style.height]).toEqual(["611.5556px", "344px"]);
|
|
263
|
+
expect(parseFloat(inset.stage.parentElement!.style.inset)).toBe(8);
|
|
264
|
+
inset.cleanup();
|
|
265
|
+
|
|
266
|
+
const filled = renderPreview(undefined, { box, fillBox: true });
|
|
267
|
+
expect([filled.stage.style.width, filled.stage.style.height]).toEqual(["640px", "360px"]);
|
|
268
|
+
expect(parseFloat(filled.stage.parentElement!.style.inset)).toBe(0);
|
|
269
|
+
filled.cleanup();
|
|
270
|
+
});
|
|
271
|
+
|
|
254
272
|
it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
|
|
255
273
|
const view = renderPreview([
|
|
256
274
|
{ gen: 0, role: "live" },
|
|
@@ -36,6 +36,8 @@ interface NLEPreviewProps {
|
|
|
36
36
|
onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
|
|
37
37
|
/** Reports the authored composition size measured from the loaded preview. */
|
|
38
38
|
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
|
|
39
|
+
/** Draws the picture edge to edge in this box, without Studio's inset band. */
|
|
40
|
+
fillBox?: boolean;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function getPreviewPlayerKey({
|
|
@@ -50,7 +52,7 @@ export function getPreviewPlayerKey({
|
|
|
50
52
|
|
|
51
53
|
const ZOOM_HUD_TIMEOUT_MS = 1200;
|
|
52
54
|
const ZOOM_SETTLE_MS = 200;
|
|
53
|
-
const PREVIEW_STAGE_INSET_PX =
|
|
55
|
+
const PREVIEW_STAGE_INSET_PX = 8;
|
|
54
56
|
|
|
55
57
|
// clip-path as well as visibility: the player's loading overlay sets its own
|
|
56
58
|
// visibility:visible and would otherwise paint over the live frame.
|
|
@@ -87,9 +89,10 @@ export function resolvePreviewStageSize(
|
|
|
87
89
|
compositionSize: PreviewCompositionSize | null,
|
|
88
90
|
portrait: boolean | undefined,
|
|
89
91
|
gutterPx = 0,
|
|
92
|
+
insetPx = PREVIEW_STAGE_INSET_PX,
|
|
90
93
|
): { width: number; height: number } {
|
|
91
|
-
const availableWidth = Math.max(0, viewportWidth -
|
|
92
|
-
const availableHeight = Math.max(0, viewportHeight -
|
|
94
|
+
const availableWidth = Math.max(0, viewportWidth - 2 * (insetPx + gutterPx));
|
|
95
|
+
const availableHeight = Math.max(0, viewportHeight - 2 * (insetPx + gutterPx));
|
|
93
96
|
const aspectRatio =
|
|
94
97
|
compositionSize && compositionSize.width > 0 && compositionSize.height > 0
|
|
95
98
|
? compositionSize.width / compositionSize.height
|
|
@@ -130,6 +133,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
130
133
|
suppressLoadingOverlay,
|
|
131
134
|
onStageRef,
|
|
132
135
|
onCompositionSizeChange,
|
|
136
|
+
fillBox,
|
|
133
137
|
}: NLEPreviewProps) {
|
|
134
138
|
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
135
139
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
@@ -151,6 +155,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
151
155
|
const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
|
|
152
156
|
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
|
153
157
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
158
|
+
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
|
|
154
159
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
155
160
|
|
|
156
161
|
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
|
@@ -181,7 +186,14 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
181
186
|
const updateStageSize = () => {
|
|
182
187
|
const rect = viewport.getBoundingClientRect();
|
|
183
188
|
setStageSize(
|
|
184
|
-
resolvePreviewStageSize(
|
|
189
|
+
resolvePreviewStageSize(
|
|
190
|
+
rect.width,
|
|
191
|
+
rect.height,
|
|
192
|
+
compositionSize,
|
|
193
|
+
portrait,
|
|
194
|
+
gutterPx,
|
|
195
|
+
insetPx,
|
|
196
|
+
),
|
|
185
197
|
);
|
|
186
198
|
};
|
|
187
199
|
|
|
@@ -189,7 +201,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
189
201
|
const observer = new ResizeObserver(updateStageSize);
|
|
190
202
|
observer.observe(viewport);
|
|
191
203
|
return () => observer.disconnect();
|
|
192
|
-
}, [compositionSize, portrait, gutterPx]);
|
|
204
|
+
}, [compositionSize, portrait, gutterPx, insetPx]);
|
|
193
205
|
|
|
194
206
|
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
|
195
207
|
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|
|
@@ -467,11 +479,14 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
467
479
|
<div className="flex flex-col h-full min-h-0">
|
|
468
480
|
<div
|
|
469
481
|
ref={viewportRef}
|
|
470
|
-
className="relative flex-1 flex items-center justify-center
|
|
482
|
+
className="relative flex-1 flex items-center justify-center overflow-hidden min-h-0 outline-hidden focus:ring-1 focus:ring-studio-accent/40 bg-[var(--studio-preview-bg,var(--color-neutral-950))]"
|
|
471
483
|
tabIndex={0}
|
|
472
484
|
aria-label="Composition preview"
|
|
473
485
|
>
|
|
474
|
-
<div
|
|
486
|
+
<div
|
|
487
|
+
className="absolute flex items-center justify-center pointer-events-none"
|
|
488
|
+
style={{ inset: insetPx }}
|
|
489
|
+
>
|
|
475
490
|
<div
|
|
476
491
|
ref={stageRef}
|
|
477
492
|
className="relative shrink-0 pointer-events-auto"
|
|
@@ -74,9 +74,44 @@ describe("external file change coordinator", () => {
|
|
|
74
74
|
onAcceptedPersistedFileChange: () => order.push("thumbnail"),
|
|
75
75
|
reloadPreview: () => order.push("preview"),
|
|
76
76
|
reloadSdkSession: () => order.push("sdk"),
|
|
77
|
+
refreshFileTree: () => {
|
|
78
|
+
order.push("tree");
|
|
79
|
+
},
|
|
77
80
|
});
|
|
78
81
|
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
79
|
-
expect(order).toEqual(["drain", "thumbnail", "preview", "sdk"]);
|
|
82
|
+
expect(order).toEqual(["drain", "thumbnail", "preview", "sdk", "tree"]);
|
|
83
|
+
expect(captured.handle?.blocked).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The file tree (useFileTree) is only ever refreshed from Studio's own file
|
|
87
|
+
// operations (create/delete/rename/upload) — never on an external change.
|
|
88
|
+
// Without this call, an agent removing or replacing a composition updates
|
|
89
|
+
// the preview and the SDK session but leaves the listing stale forever.
|
|
90
|
+
it("refreshes the file tree on an accepted external change", async () => {
|
|
91
|
+
const refreshFileTree = vi.fn();
|
|
92
|
+
await mountCoordinator({ refreshFileTree });
|
|
93
|
+
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
94
|
+
expect(refreshFileTree).toHaveBeenCalledOnce();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does not refresh the tree for a suppressed self-write echo", async () => {
|
|
98
|
+
const refreshFileTree = vi.fn();
|
|
99
|
+
await mountCoordinator({ refreshFileTree });
|
|
100
|
+
markStudioWriteToken("studio-write-1");
|
|
101
|
+
await act(async () =>
|
|
102
|
+
handler?.({
|
|
103
|
+
path: "index.html",
|
|
104
|
+
content: "studio",
|
|
105
|
+
version: "v2",
|
|
106
|
+
writeToken: "studio-write-1",
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
expect(refreshFileTree).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("is optional — an accepted change with no refreshFileTree collaborator does not throw", async () => {
|
|
113
|
+
const { captured } = await mountCoordinator({ refreshFileTree: undefined });
|
|
114
|
+
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
80
115
|
expect(captured.handle?.blocked).toBeNull();
|
|
81
116
|
});
|
|
82
117
|
|
|
@@ -64,6 +64,17 @@ interface ExternalFileChangeCoordinatorOptions {
|
|
|
64
64
|
path: string,
|
|
65
65
|
affectedCompositions: readonly string[] | null,
|
|
66
66
|
) => void;
|
|
67
|
+
/**
|
|
68
|
+
* Called alongside `reloadPreview`/`reloadSdkSession` on every accepted
|
|
69
|
+
* external change. The file tree (`useFileTree`) is only ever refreshed
|
|
70
|
+
* from Studio's OWN file operations (create/delete/rename/upload) — an
|
|
71
|
+
* external change (an agent writing outside Studio) reloads the preview
|
|
72
|
+
* and the SDK session but, without this, never the listing. A composition
|
|
73
|
+
* an agent removed or replaced then stays in the tree until the user does
|
|
74
|
+
* a Studio-side file op or reloads the tab; clicking it opens a session
|
|
75
|
+
* that can never resolve (`reason: "absent"`, proven stale-tree 2026-09-23).
|
|
76
|
+
*/
|
|
77
|
+
refreshFileTree?: () => void | Promise<void>;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
export interface ExternalFileChangeCoordinatorHandle {
|
|
@@ -149,6 +160,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
149
160
|
onUseExternalFile,
|
|
150
161
|
resetSaveQueues,
|
|
151
162
|
onAcceptedPersistedFileChange,
|
|
163
|
+
refreshFileTree,
|
|
152
164
|
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
|
|
153
165
|
const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
|
|
154
166
|
const generationRef = useRef(0);
|
|
@@ -223,8 +235,14 @@ export function useExternalFileChangeCoordinator({
|
|
|
223
235
|
logReload("reload", { path, by: "external-change coordinator" });
|
|
224
236
|
reloadPreview();
|
|
225
237
|
reloadSdkSession(path);
|
|
238
|
+
// Fire-and-forget: a failed refresh leaves the tree as stale as it was,
|
|
239
|
+
// which is the status quo this exists to improve on, not a new failure
|
|
240
|
+
// mode to surface. The `absent`-triggered fallback in useSdkSession
|
|
241
|
+
// covers the case where this call is missed entirely (server restart,
|
|
242
|
+
// a watcher event the SSE never delivered).
|
|
243
|
+
void refreshFileTree?.();
|
|
226
244
|
},
|
|
227
|
-
[reloadPreview, reloadSdkSession],
|
|
245
|
+
[reloadPreview, reloadSdkSession, refreshFileTree],
|
|
228
246
|
);
|
|
229
247
|
|
|
230
248
|
const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
|
|
@@ -587,6 +587,152 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
587
587
|
await act(async () => rootB.unmount());
|
|
588
588
|
});
|
|
589
589
|
|
|
590
|
+
// `compositionMissing` and the once-per-path refresh fallback: proven
|
|
591
|
+
// 2026-09-23 that `absent` means a stale tree (refreshFileTree only runs
|
|
592
|
+
// after Studio's own file ops, never on an external change), so an `absent`
|
|
593
|
+
// read is the one signal that should make the tree self-correct even when
|
|
594
|
+
// the SSE-driven refresh in useExternalFileChangeCoordinator is missed.
|
|
595
|
+
describe("compositionMissing and the absent-read refresh fallback", () => {
|
|
596
|
+
function HandleProbe({
|
|
597
|
+
projectId,
|
|
598
|
+
path,
|
|
599
|
+
onAbsentRead,
|
|
600
|
+
}: {
|
|
601
|
+
projectId: string;
|
|
602
|
+
path: string;
|
|
603
|
+
onAbsentRead?: (path: string) => void;
|
|
604
|
+
}) {
|
|
605
|
+
captured.handle = useSdkSession(projectId, path, [], false, onAbsentRead);
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
|
609
|
+
|
|
610
|
+
it("sets compositionMissing and calls onAbsentRead once for an absent read", async () => {
|
|
611
|
+
vi.stubGlobal(
|
|
612
|
+
"fetch",
|
|
613
|
+
vi.fn(
|
|
614
|
+
async () =>
|
|
615
|
+
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
|
|
616
|
+
),
|
|
617
|
+
);
|
|
618
|
+
const onAbsentRead = vi.fn();
|
|
619
|
+
const root = createRoot(document.createElement("div"));
|
|
620
|
+
await act(async () =>
|
|
621
|
+
root.render(
|
|
622
|
+
<HandleProbe projectId="project-a" path="index.html" onAbsentRead={onAbsentRead} />,
|
|
623
|
+
),
|
|
624
|
+
);
|
|
625
|
+
await flushAsyncEffects();
|
|
626
|
+
|
|
627
|
+
expect(captured.handle?.compositionMissing).toBe(true);
|
|
628
|
+
expect(onAbsentRead).toHaveBeenCalledOnce();
|
|
629
|
+
expect(onAbsentRead).toHaveBeenCalledWith("index.html");
|
|
630
|
+
|
|
631
|
+
// A second absent read for the SAME path must not refresh again — the
|
|
632
|
+
// refresh already ran and didn't fix it (the file really is gone).
|
|
633
|
+
await act(async () => {
|
|
634
|
+
captured.handle?.forceReload();
|
|
635
|
+
});
|
|
636
|
+
await flushAsyncEffects();
|
|
637
|
+
expect(onAbsentRead).toHaveBeenCalledOnce();
|
|
638
|
+
|
|
639
|
+
await act(async () => root.unmount());
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("calls onAbsentRead again for a different path", async () => {
|
|
643
|
+
vi.stubGlobal(
|
|
644
|
+
"fetch",
|
|
645
|
+
vi.fn(
|
|
646
|
+
async () =>
|
|
647
|
+
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
|
|
648
|
+
),
|
|
649
|
+
);
|
|
650
|
+
const onAbsentRead = vi.fn();
|
|
651
|
+
const root = createRoot(document.createElement("div"));
|
|
652
|
+
await act(async () =>
|
|
653
|
+
root.render(
|
|
654
|
+
<HandleProbe projectId="project-a" path="scenes/a.html" onAbsentRead={onAbsentRead} />,
|
|
655
|
+
),
|
|
656
|
+
);
|
|
657
|
+
await flushAsyncEffects();
|
|
658
|
+
await act(async () =>
|
|
659
|
+
root.render(
|
|
660
|
+
<HandleProbe projectId="project-a" path="scenes/b.html" onAbsentRead={onAbsentRead} />,
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
await flushAsyncEffects();
|
|
664
|
+
|
|
665
|
+
expect(onAbsentRead).toHaveBeenCalledTimes(2);
|
|
666
|
+
expect(onAbsentRead).toHaveBeenNthCalledWith(1, "scenes/a.html");
|
|
667
|
+
expect(onAbsentRead).toHaveBeenNthCalledWith(2, "scenes/b.html");
|
|
668
|
+
await act(async () => root.unmount());
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("resets the guard on project change, so the same path can refresh again", async () => {
|
|
672
|
+
vi.stubGlobal(
|
|
673
|
+
"fetch",
|
|
674
|
+
vi.fn(
|
|
675
|
+
async () =>
|
|
676
|
+
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
|
|
677
|
+
),
|
|
678
|
+
);
|
|
679
|
+
const onAbsentRead = vi.fn();
|
|
680
|
+
const root = createRoot(document.createElement("div"));
|
|
681
|
+
await act(async () =>
|
|
682
|
+
root.render(
|
|
683
|
+
<HandleProbe projectId="project-a" path="index.html" onAbsentRead={onAbsentRead} />,
|
|
684
|
+
),
|
|
685
|
+
);
|
|
686
|
+
await flushAsyncEffects();
|
|
687
|
+
await act(async () =>
|
|
688
|
+
root.render(
|
|
689
|
+
<HandleProbe projectId="project-b" path="index.html" onAbsentRead={onAbsentRead} />,
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
await flushAsyncEffects();
|
|
693
|
+
|
|
694
|
+
expect(onAbsentRead).toHaveBeenCalledTimes(2);
|
|
695
|
+
await act(async () => root.unmount());
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("clears compositionMissing once a later read succeeds", async () => {
|
|
699
|
+
const fetchMock = vi.fn(
|
|
700
|
+
async () => ({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
|
|
701
|
+
);
|
|
702
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
703
|
+
openComposition.mockResolvedValue(fakeSession());
|
|
704
|
+
const root = createRoot(document.createElement("div"));
|
|
705
|
+
await act(async () => root.render(<HandleProbe projectId="project-a" path="index.html" />));
|
|
706
|
+
await flushAsyncEffects();
|
|
707
|
+
expect(captured.handle?.compositionMissing).toBe(true);
|
|
708
|
+
|
|
709
|
+
fetchMock.mockImplementation(async () => response("PROJECT_A"));
|
|
710
|
+
await act(async () => {
|
|
711
|
+
captured.handle?.forceReload();
|
|
712
|
+
});
|
|
713
|
+
await flushAsyncEffects();
|
|
714
|
+
|
|
715
|
+
expect(captured.handle?.compositionMissing).toBe(false);
|
|
716
|
+
await act(async () => root.unmount());
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("does not throw when onAbsentRead is not supplied", async () => {
|
|
720
|
+
vi.stubGlobal(
|
|
721
|
+
"fetch",
|
|
722
|
+
vi.fn(
|
|
723
|
+
async () =>
|
|
724
|
+
({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
|
|
725
|
+
),
|
|
726
|
+
);
|
|
727
|
+
const root = createRoot(document.createElement("div"));
|
|
728
|
+
await act(async () => root.render(<HandleProbe projectId="project-a" path="index.html" />));
|
|
729
|
+
await flushAsyncEffects();
|
|
730
|
+
|
|
731
|
+
expect(captured.handle?.compositionMissing).toBe(true);
|
|
732
|
+
await act(async () => root.unmount());
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
590
736
|
it("reports a parse failure with its message", async () => {
|
|
591
737
|
vi.stubGlobal(
|
|
592
738
|
"fetch",
|
|
@@ -273,6 +273,16 @@ export interface SdkSessionHandle {
|
|
|
273
273
|
* `missing_content` say nothing about the project at all.
|
|
274
274
|
*/
|
|
275
275
|
unreachableProject: string | null;
|
|
276
|
+
/**
|
|
277
|
+
* True when the most recent read of `activeCompPath` reported `reason:
|
|
278
|
+
* "absent"` — the file tree lists this path and the server cannot get it.
|
|
279
|
+
* Proven 2026-09-23 to mean a stale tree: `refreshFileTree` only runs after
|
|
280
|
+
* Studio's own file operations, so an external change (an agent removing
|
|
281
|
+
* or replacing the file) updates the preview and this session but never
|
|
282
|
+
* the listing, and the file stays clickable forever. Every edit then fails
|
|
283
|
+
* silently with nothing to tell the user why — this is that signal.
|
|
284
|
+
*/
|
|
285
|
+
compositionMissing: boolean;
|
|
276
286
|
}
|
|
277
287
|
|
|
278
288
|
interface SdkSessionOwner {
|
|
@@ -345,6 +355,12 @@ export function useSdkSession(
|
|
|
345
355
|
// reports `null`, same as before the tree loads.
|
|
346
356
|
fileTree: readonly string[] = [],
|
|
347
357
|
fileTreeLoaded = false,
|
|
358
|
+
// Fallback for the SSE-driven refresh in useExternalFileChangeCoordinator:
|
|
359
|
+
// called at most once per (projectId, path) so the tree self-corrects even
|
|
360
|
+
// when that delivery is missed (server restart, a watcher event the SSE
|
|
361
|
+
// never sent). Optional for the same reason fileTree is: a secondary
|
|
362
|
+
// session has no tree of its own to refresh.
|
|
363
|
+
onAbsentRead?: (path: string) => void,
|
|
348
364
|
): SdkSessionHandle {
|
|
349
365
|
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
|
350
366
|
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
|
@@ -358,6 +374,35 @@ export function useSdkSession(
|
|
|
358
374
|
const reloadTokenRef = useRef(reloadToken);
|
|
359
375
|
reloadTokenRef.current = reloadToken;
|
|
360
376
|
const [unreachableProject, setUnreachableProject] = useState<string | null>(null);
|
|
377
|
+
const [compositionMissing, setCompositionMissing] = useState(false);
|
|
378
|
+
// Keyed `${projectId}:${path}` so a refresh that doesn't fix it (the file
|
|
379
|
+
// really is gone) can't loop, and so it fires again for a genuinely
|
|
380
|
+
// different path or project.
|
|
381
|
+
const refreshedAbsentPathsRef = useRef<Set<string>>(new Set());
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
refreshedAbsentPathsRef.current.clear();
|
|
384
|
+
}, [projectId]);
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Update `unreachableProject`/`compositionMissing` for one failed read, and
|
|
388
|
+
* fire the once-per-path tree-refresh fallback on `absent`. Pulled out of
|
|
389
|
+
* the open effect's `.then` — the branching there was pushing that
|
|
390
|
+
* callback over the complexity threshold on its own.
|
|
391
|
+
*/
|
|
392
|
+
function handleReadFailure(
|
|
393
|
+
read: ProjectFileReadFailure,
|
|
394
|
+
forProjectId: string,
|
|
395
|
+
forPath: string,
|
|
396
|
+
): void {
|
|
397
|
+
const pathInTree = fileTreeLoaded ? fileTree.includes(forPath) : null;
|
|
398
|
+
setUnreachableProject(reportReadFailure(read, forProjectId, pathInTree));
|
|
399
|
+
setCompositionMissing(read.reason === "absent");
|
|
400
|
+
if (read.reason !== "absent") return;
|
|
401
|
+
const key = `${forProjectId}:${forPath}`;
|
|
402
|
+
if (refreshedAbsentPathsRef.current.has(key)) return;
|
|
403
|
+
refreshedAbsentPathsRef.current.add(key);
|
|
404
|
+
onAbsentRead?.(forPath);
|
|
405
|
+
}
|
|
361
406
|
|
|
362
407
|
useEffect(
|
|
363
408
|
() =>
|
|
@@ -381,6 +426,7 @@ export function useSdkSession(
|
|
|
381
426
|
|
|
382
427
|
if (!projectId || !activeCompPath) {
|
|
383
428
|
setUnreachableProject(null);
|
|
429
|
+
setCompositionMissing(false);
|
|
384
430
|
return () => {
|
|
385
431
|
cancelled = true;
|
|
386
432
|
};
|
|
@@ -397,11 +443,11 @@ export function useSdkSession(
|
|
|
397
443
|
.then(async (read) => {
|
|
398
444
|
if (cancelled) return;
|
|
399
445
|
if (!read.ok) {
|
|
400
|
-
|
|
401
|
-
setUnreachableProject(reportReadFailure(read, projectId, pathInTree));
|
|
446
|
+
handleReadFailure(read, projectId, activeCompPath);
|
|
402
447
|
return;
|
|
403
448
|
}
|
|
404
449
|
setUnreachableProject(null);
|
|
450
|
+
setCompositionMissing(false);
|
|
405
451
|
const content = read.content;
|
|
406
452
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
407
453
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
@@ -469,11 +515,13 @@ export function useSdkSession(
|
|
|
469
515
|
disposeSdkSession(owned.session);
|
|
470
516
|
}
|
|
471
517
|
};
|
|
472
|
-
// fileTree/fileTreeLoaded deliberately excluded: they only
|
|
473
|
-
// read-failure event fired from this same effect
|
|
474
|
-
// "was the path in the last-loaded tree" snapshot
|
|
475
|
-
//
|
|
476
|
-
//
|
|
518
|
+
// fileTree/fileTreeLoaded/onAbsentRead deliberately excluded: they only
|
|
519
|
+
// annotate or react to a read-failure event fired from this same effect
|
|
520
|
+
// run (the diagnostic "was the path in the last-loaded tree" snapshot,
|
|
521
|
+
// and the tree-refresh fallback), and re-running the whole open/dispose
|
|
522
|
+
// cycle on every tree refresh or on every render (callers are not
|
|
523
|
+
// required to memoize onAbsentRead) would drop and reopen a perfectly
|
|
524
|
+
// good session far more often than the tree actually changes.
|
|
477
525
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
478
526
|
}, [projectId, activeCompPath, reloadToken]);
|
|
479
527
|
|
|
@@ -511,5 +559,5 @@ export function useSdkSession(
|
|
|
511
559
|
ownedSession.reloadToken === reloadToken
|
|
512
560
|
? ownedSession.session
|
|
513
561
|
: null;
|
|
514
|
-
return { session, publish, forceReload, unreachableProject };
|
|
562
|
+
return { session, publish, forceReload, unreachableProject, compositionMissing };
|
|
515
563
|
}
|
|
@@ -20,6 +20,7 @@ type FileManager = Pick<
|
|
|
20
20
|
| "overwriteExternalConflict"
|
|
21
21
|
| "readProjectFile"
|
|
22
22
|
| "updateEditingFileContent"
|
|
23
|
+
| "refreshFileTree"
|
|
23
24
|
>;
|
|
24
25
|
|
|
25
26
|
type PreviewPersistence = Pick<
|
|
@@ -85,5 +86,6 @@ export function useStudioExternalFileChanges({
|
|
|
85
86
|
onUseExternalFile: fileManager.updateEditingFileContent,
|
|
86
87
|
resetSaveQueues: resetDomEditSaveQueueBreaker,
|
|
87
88
|
onAcceptedPersistedFileChange,
|
|
89
|
+
refreshFileTree: fileManager.refreshFileTree,
|
|
88
90
|
});
|
|
89
91
|
}
|
|
@@ -20,6 +20,7 @@ export function useStudioSdkSessions(
|
|
|
20
20
|
masterCompPath: string | null,
|
|
21
21
|
fileTree: readonly string[] = [],
|
|
22
22
|
fileTreeLoaded = false,
|
|
23
|
+
refreshFileTree?: () => void | Promise<void>,
|
|
23
24
|
) {
|
|
24
25
|
// On the master view (no explicit comp) the schema panels target the project's
|
|
25
26
|
// resolved main composition — the first `.html` in the tree, not a hardcoded
|
|
@@ -30,6 +31,7 @@ export function useStudioSdkSessions(
|
|
|
30
31
|
activeCompPath ?? masterCompPath,
|
|
31
32
|
fileTree,
|
|
32
33
|
fileTreeLoaded,
|
|
34
|
+
refreshFileTree,
|
|
33
35
|
);
|
|
34
36
|
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
|
35
37
|
useEffect(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
|
-
const studioCss = readFileSync(new URL("../../styles/
|
|
4
|
+
const studioCss = readFileSync(new URL("../../styles/components.css", import.meta.url), "utf8");
|
|
5
5
|
|
|
6
6
|
describe("preview volume range styles", () => {
|
|
7
7
|
it("uses the same compact thumb in Chromium and Firefox", () => {
|