@hyperframes/studio 0.8.55 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
- package/dist/index.d.ts +111 -17
- package/dist/index.html +2 -2
- package/dist/index.js +21534 -20683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +18 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +62 -51
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -55
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +26 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +19 -24
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +129 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -573
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -25,6 +25,34 @@ type ProjectFileReadFailure =
|
|
|
25
25
|
|
|
26
26
|
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Record a read that produced no usable content, and answer which project — if
|
|
30
|
+
* any — the failure identifies as unreachable.
|
|
31
|
+
*
|
|
32
|
+
* No SDK session follows a failed read, so EVERY cutover chokepoint takes the
|
|
33
|
+
* server path and emits nothing — the shadow never runs either. A broken read
|
|
34
|
+
* would otherwise be a silent, total SDK bypass, which is why this is recorded
|
|
35
|
+
* at all.
|
|
36
|
+
*
|
|
37
|
+
* Only a 404 identifies the *project*: the server answered, and its answer was
|
|
38
|
+
* that it does not serve this id. A 5xx, a dropped request, an unexpected body
|
|
39
|
+
* and an empty file all say nothing about which project the server serves, so
|
|
40
|
+
* none of them claim it. See `unreachableProject` on the handle.
|
|
41
|
+
*
|
|
42
|
+
* A function rather than three more conditions inline: the read callback it is
|
|
43
|
+
* called from is a long pre-existing async body already near the complexity
|
|
44
|
+
* threshold, and this branch is one coherent unit.
|
|
45
|
+
*/
|
|
46
|
+
function reportReadFailure(read: ProjectFileReadFailure, projectId: string): string | null {
|
|
47
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
48
|
+
stage: "read",
|
|
49
|
+
reason: read.reason,
|
|
50
|
+
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
51
|
+
});
|
|
52
|
+
if (read.reason !== "http_error") return null;
|
|
53
|
+
return read.status === 404 ? projectId : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
/**
|
|
29
57
|
* Read a project file's content (optional read — a missing file is not an
|
|
30
58
|
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
@@ -96,6 +124,19 @@ export interface SdkSessionHandle {
|
|
|
96
124
|
* side of that path is covered by usePersistentEditHistory.test.ts.
|
|
97
125
|
*/
|
|
98
126
|
forceReload: () => void;
|
|
127
|
+
/**
|
|
128
|
+
* Set when this server answered the composition read with a 404 for the
|
|
129
|
+
* project it was asked to open. In the CLI-embedded host — the only host
|
|
130
|
+
* that reports telemetry at all (`telemetry/policy.ts` suppresses Vite dev)
|
|
131
|
+
* — that does not mean the project is gone. It means this Studio is serving
|
|
132
|
+
* a *different* one; the project is untouched on disk. Either way every edit
|
|
133
|
+
* in this tab fails, and until now it failed silently.
|
|
134
|
+
*
|
|
135
|
+
* `null` for every other failure: a 500 or a dropped request says nothing
|
|
136
|
+
* about which project the server serves, and `absent_or_empty` /
|
|
137
|
+
* `missing_content` say nothing about the project at all.
|
|
138
|
+
*/
|
|
139
|
+
unreachableProject: string | null;
|
|
99
140
|
}
|
|
100
141
|
|
|
101
142
|
interface SdkSessionOwner {
|
|
@@ -173,6 +214,7 @@ export function useSdkSession(
|
|
|
173
214
|
const [reloadToken, setReloadToken] = useState(0);
|
|
174
215
|
const reloadTokenRef = useRef(reloadToken);
|
|
175
216
|
reloadTokenRef.current = reloadToken;
|
|
217
|
+
const [unreachableProject, setUnreachableProject] = useState<string | null>(null);
|
|
176
218
|
|
|
177
219
|
useEffect(
|
|
178
220
|
() =>
|
|
@@ -195,6 +237,7 @@ export function useSdkSession(
|
|
|
195
237
|
if (previous) disposeSdkSession(previous.session);
|
|
196
238
|
|
|
197
239
|
if (!projectId || !activeCompPath) {
|
|
240
|
+
setUnreachableProject(null);
|
|
198
241
|
return () => {
|
|
199
242
|
cancelled = true;
|
|
200
243
|
};
|
|
@@ -211,17 +254,10 @@ export function useSdkSession(
|
|
|
211
254
|
.then(async (read) => {
|
|
212
255
|
if (cancelled) return;
|
|
213
256
|
if (!read.ok) {
|
|
214
|
-
|
|
215
|
-
// server path and emits nothing — the shadow never runs either. This
|
|
216
|
-
// is the only place a missing session can originate, so a broken read
|
|
217
|
-
// would otherwise be a silent, total SDK bypass.
|
|
218
|
-
trackStudioEvent("sdk_session_unavailable", {
|
|
219
|
-
stage: "read",
|
|
220
|
-
reason: read.reason,
|
|
221
|
-
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
222
|
-
});
|
|
257
|
+
setUnreachableProject(reportReadFailure(read, projectId));
|
|
223
258
|
return;
|
|
224
259
|
}
|
|
260
|
+
setUnreachableProject(null);
|
|
225
261
|
const content = read.content;
|
|
226
262
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
227
263
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
@@ -316,5 +352,5 @@ export function useSdkSession(
|
|
|
316
352
|
ownedSession.reloadToken === reloadToken
|
|
317
353
|
? ownedSession.session
|
|
318
354
|
: null;
|
|
319
|
-
return { session, publish, forceReload };
|
|
355
|
+
return { session, publish, forceReload, unreachableProject };
|
|
320
356
|
}
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
6
|
import { useServerConnection } from "./useServerConnection";
|
|
6
7
|
|
|
8
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
9
|
+
|
|
7
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
11
|
|
|
9
12
|
const captured: { projectId: string | null } = { projectId: null };
|
|
@@ -55,6 +58,7 @@ describe("useServerConnection hash project id", () => {
|
|
|
55
58
|
|
|
56
59
|
afterEach(() => {
|
|
57
60
|
vi.unstubAllGlobals();
|
|
61
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
58
62
|
});
|
|
59
63
|
|
|
60
64
|
it("keeps a hash id the server can resolve", async () => {
|
|
@@ -92,3 +96,302 @@ describe("useServerConnection hash project id", () => {
|
|
|
92
96
|
await act(async () => root.unmount());
|
|
93
97
|
});
|
|
94
98
|
});
|
|
99
|
+
|
|
100
|
+
/** Per-project status, so one test can hold two projects in different states. */
|
|
101
|
+
function stubFetchByProject(statuses: Record<string, number | "reject">) {
|
|
102
|
+
vi.stubGlobal(
|
|
103
|
+
"fetch",
|
|
104
|
+
vi.fn(async (url: string) => {
|
|
105
|
+
if (url === "/api/projects") {
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => ({ projects: [{ id: "real-project" }] }),
|
|
109
|
+
} as Response;
|
|
110
|
+
}
|
|
111
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
112
|
+
const status = statuses[id];
|
|
113
|
+
if (status === "reject") throw new TypeError("Failed to fetch");
|
|
114
|
+
if (status === undefined) return { ok: false, status: 404 } as Response;
|
|
115
|
+
return { ok: status === 200, status } as Response;
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function changeHash(hash: string) {
|
|
121
|
+
await act(async () => {
|
|
122
|
+
window.location.hash = hash;
|
|
123
|
+
});
|
|
124
|
+
await flush();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Change the hash WITHOUT the test itself pushing or firing anything.
|
|
129
|
+
*
|
|
130
|
+
* `location.hash =` does both, which makes "did the CODE push?" unanswerable —
|
|
131
|
+
* the test's own navigation moves history.length either way. replaceState does
|
|
132
|
+
* neither, so the dispatch is explicit and any growth after it is the hook's.
|
|
133
|
+
*/
|
|
134
|
+
async function replaceHashAndDispatch(hash: string) {
|
|
135
|
+
await act(async () => {
|
|
136
|
+
window.history.replaceState(null, "", hash);
|
|
137
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
138
|
+
});
|
|
139
|
+
await flush();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The hashchange path took any id the URL offered, with no validation — so the
|
|
144
|
+
* mount fix from v0.8.52 could be walked straight around by changing projects.
|
|
145
|
+
* These pin the second door shut.
|
|
146
|
+
*/
|
|
147
|
+
describe("useServerConnection hashchange validation", () => {
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
captured.projectId = null;
|
|
150
|
+
window.location.hash = "";
|
|
151
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
vi.unstubAllGlobals();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("adopts a hash id the server can resolve", async () => {
|
|
159
|
+
stubFetchByProject({ first: 200, second: 200 });
|
|
160
|
+
const root = await renderWithHash("#project/first");
|
|
161
|
+
expect(captured.projectId).toBe("first");
|
|
162
|
+
|
|
163
|
+
await changeHash("#project/second");
|
|
164
|
+
expect(captured.projectId).toBe("second");
|
|
165
|
+
await act(async () => root.unmount());
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to the first project when the new hash id is gone", async () => {
|
|
169
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
170
|
+
const root = await renderWithHash("#project/first");
|
|
171
|
+
expect(captured.projectId).toBe("first");
|
|
172
|
+
|
|
173
|
+
await changeHash("#project/deleted");
|
|
174
|
+
expect(captured.projectId).toBe("real-project");
|
|
175
|
+
expect(window.location.hash).toContain("real-project");
|
|
176
|
+
await act(async () => root.unmount());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("keeps the new hash id when the check itself fails", async () => {
|
|
180
|
+
// Same transient-tolerance as the mount path: a blip must not rewrite the
|
|
181
|
+
// user's hash out from under a project that is actually fine.
|
|
182
|
+
stubFetchByProject({ first: 200, flaky: "reject" });
|
|
183
|
+
const root = await renderWithHash("#project/first");
|
|
184
|
+
|
|
185
|
+
await changeHash("#project/flaky");
|
|
186
|
+
expect(captured.projectId).toBe("flaky");
|
|
187
|
+
await act(async () => root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("reports the hashchange verdict with its stage", async () => {
|
|
191
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
192
|
+
const root = await renderWithHash("#project/first");
|
|
193
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
194
|
+
|
|
195
|
+
await changeHash("#project/deleted");
|
|
196
|
+
const verdicts = vi
|
|
197
|
+
.mocked(trackStudioEvent)
|
|
198
|
+
.mock.calls.filter(([name]) => name === "project_hash_validated");
|
|
199
|
+
expect(verdicts).toEqual([
|
|
200
|
+
["project_hash_validated", { stage: "hashchange", outcome: "missing", status: 404 }],
|
|
201
|
+
]);
|
|
202
|
+
await act(async () => root.unmount());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("ignores a verdict that lands after the user moved on", async () => {
|
|
206
|
+
// Two changes in quick succession. The first check resolves late; if it were
|
|
207
|
+
// trusted, it would adopt a project the user already navigated away from.
|
|
208
|
+
let releaseFirst: (() => void) | null = null;
|
|
209
|
+
vi.stubGlobal(
|
|
210
|
+
"fetch",
|
|
211
|
+
vi.fn(async (url: string) => {
|
|
212
|
+
if (url === "/api/projects") {
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
json: async () => ({ projects: [{ id: "real-project" }] }),
|
|
216
|
+
} as Response;
|
|
217
|
+
}
|
|
218
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
219
|
+
if (id === "slow") {
|
|
220
|
+
await new Promise<void>((resolve) => {
|
|
221
|
+
releaseFirst = resolve;
|
|
222
|
+
});
|
|
223
|
+
return { ok: true, status: 200 } as Response;
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, status: 200 } as Response;
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const root = await renderWithHash("#project/slow");
|
|
230
|
+
await flush();
|
|
231
|
+
// Move on before the slow validation answers.
|
|
232
|
+
await act(async () => {
|
|
233
|
+
window.location.hash = "#project/fast";
|
|
234
|
+
});
|
|
235
|
+
await flush();
|
|
236
|
+
expect(captured.projectId).toBe("fast");
|
|
237
|
+
|
|
238
|
+
// Now let the stale check resolve. It must not win.
|
|
239
|
+
await act(async () => {
|
|
240
|
+
releaseFirst?.();
|
|
241
|
+
});
|
|
242
|
+
await flush();
|
|
243
|
+
expect(captured.projectId).toBe("fast");
|
|
244
|
+
await act(async () => root.unmount());
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("falls back to the CURRENT project list, not the one from mount", async () => {
|
|
248
|
+
// The mount fetch is a snapshot. A long-lived tab is exactly the case this
|
|
249
|
+
// path exists for, so re-reading the list is the difference between landing
|
|
250
|
+
// on a project that exists and landing on the one that was first an hour ago.
|
|
251
|
+
let list = [{ id: "old-first" }];
|
|
252
|
+
vi.stubGlobal(
|
|
253
|
+
"fetch",
|
|
254
|
+
vi.fn(async (url: string) => {
|
|
255
|
+
if (url === "/api/projects") {
|
|
256
|
+
return { ok: true, json: async () => ({ projects: list }) } as Response;
|
|
257
|
+
}
|
|
258
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
259
|
+
return { ok: id !== "deleted", status: id === "deleted" ? 404 : 200 } as Response;
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const root = await renderWithHash("#project/old-first");
|
|
264
|
+
expect(captured.projectId).toBe("old-first");
|
|
265
|
+
|
|
266
|
+
list = [{ id: "new-first" }];
|
|
267
|
+
await changeHash("#project/deleted");
|
|
268
|
+
expect(captured.projectId).toBe("new-first");
|
|
269
|
+
await act(async () => root.unmount());
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("re-reads the list on a second dead hash rather than reusing the first answer", async () => {
|
|
273
|
+
// The caching version is invisible with one dead hash — firstProjectId runs
|
|
274
|
+
// once, so a memo and a fresh fetch look identical. Two dead hashes with the
|
|
275
|
+
// list changing in between is what separates them.
|
|
276
|
+
let list = [{ id: "first-a" }];
|
|
277
|
+
vi.stubGlobal(
|
|
278
|
+
"fetch",
|
|
279
|
+
vi.fn(async (url: string) => {
|
|
280
|
+
if (url === "/api/projects") {
|
|
281
|
+
return { ok: true, json: async () => ({ projects: list }) } as Response;
|
|
282
|
+
}
|
|
283
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
284
|
+
return { ok: id === "alive", status: id === "alive" ? 200 : 404 } as Response;
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const root = await renderWithHash("#project/alive");
|
|
289
|
+
await changeHash("#project/dead-one");
|
|
290
|
+
expect(captured.projectId).toBe("first-a");
|
|
291
|
+
|
|
292
|
+
list = [{ id: "first-b" }];
|
|
293
|
+
await changeHash("#project/dead-two");
|
|
294
|
+
expect(captured.projectId).toBe("first-b");
|
|
295
|
+
await act(async () => root.unmount());
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rewrites a dead hash without adding a history entry", async () => {
|
|
299
|
+
// `location.hash =` pushes, which would leave the dead id one Back away —
|
|
300
|
+
// and Back would then re-validate, fall back and push again, forever.
|
|
301
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
302
|
+
const root = await renderWithHash("#project/first");
|
|
303
|
+
|
|
304
|
+
const before = window.history.length;
|
|
305
|
+
await replaceHashAndDispatch("#project/deleted");
|
|
306
|
+
expect(captured.projectId).toBe("real-project");
|
|
307
|
+
expect(window.history.length).toBe(before);
|
|
308
|
+
await act(async () => root.unmount());
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("rewrites the mount fallback without adding a history entry", async () => {
|
|
312
|
+
// The mount path pushed until now — the same bug from the other end: Back
|
|
313
|
+
// returned to the dead id, which re-validates and pushes again.
|
|
314
|
+
stubFetchByProject({ deleted: 404 });
|
|
315
|
+
window.history.replaceState(null, "", "#project/deleted");
|
|
316
|
+
const before = window.history.length;
|
|
317
|
+
|
|
318
|
+
const root = await renderWithHash("#project/deleted");
|
|
319
|
+
expect(captured.projectId).toBe("real-project");
|
|
320
|
+
expect(window.history.length).toBe(before);
|
|
321
|
+
await act(async () => root.unmount());
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The verdict event. Until it existed, a tab that kept its hash because the
|
|
327
|
+
* check itself failed was indistinguishable in production from one that never
|
|
328
|
+
* ran the check — different causes, different fixes, one silent bucket.
|
|
329
|
+
*/
|
|
330
|
+
describe("project_hash_validated", () => {
|
|
331
|
+
// Mount only: assigning window.location.hash is what happy-dom turns into a
|
|
332
|
+
// hashchange, so the listener reports too. This describe is about the mount
|
|
333
|
+
// path; the hashchange verdicts have their own.
|
|
334
|
+
const verdictCalls = () =>
|
|
335
|
+
vi
|
|
336
|
+
.mocked(trackStudioEvent)
|
|
337
|
+
.mock.calls.filter(
|
|
338
|
+
([name, props]) =>
|
|
339
|
+
name === "project_hash_validated" &&
|
|
340
|
+
(props as { stage?: string } | undefined)?.stage === "mount",
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
beforeEach(() => {
|
|
344
|
+
captured.projectId = null;
|
|
345
|
+
window.location.hash = "";
|
|
346
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
afterEach(() => {
|
|
350
|
+
vi.unstubAllGlobals();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("reports an ok verdict with its status", async () => {
|
|
354
|
+
stubFetch({ status: 200 });
|
|
355
|
+
const root = await renderWithHash("#project/stale-or-not");
|
|
356
|
+
expect(verdictCalls()).toEqual([
|
|
357
|
+
["project_hash_validated", { stage: "mount", outcome: "ok", status: 200 }],
|
|
358
|
+
]);
|
|
359
|
+
await act(async () => root.unmount());
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports a missing verdict with the 404", async () => {
|
|
363
|
+
stubFetch({ status: 404 });
|
|
364
|
+
const root = await renderWithHash("#project/deleted-project");
|
|
365
|
+
expect(verdictCalls()).toEqual([
|
|
366
|
+
["project_hash_validated", { stage: "mount", outcome: "missing", status: 404 }],
|
|
367
|
+
]);
|
|
368
|
+
await act(async () => root.unmount());
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("reports unknown with no status when the check is rejected", async () => {
|
|
372
|
+
// The point of the event: this case LOOKS like success from outside (the
|
|
373
|
+
// hash is kept) but is a failed check. Only the verdict says so.
|
|
374
|
+
stubFetch("reject");
|
|
375
|
+
const root = await renderWithHash("#project/unreachable-check");
|
|
376
|
+
expect(verdictCalls()).toEqual([
|
|
377
|
+
["project_hash_validated", { stage: "mount", outcome: "unknown" }],
|
|
378
|
+
]);
|
|
379
|
+
await act(async () => root.unmount());
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("reports unknown — not missing — for a 5xx", async () => {
|
|
383
|
+
stubFetch({ status: 500 });
|
|
384
|
+
const root = await renderWithHash("#project/server-erroring");
|
|
385
|
+
expect(verdictCalls()).toEqual([
|
|
386
|
+
["project_hash_validated", { stage: "mount", outcome: "unknown", status: 500 }],
|
|
387
|
+
]);
|
|
388
|
+
await act(async () => root.unmount());
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("emits nothing when there is no hash id to validate", async () => {
|
|
392
|
+
stubFetch({ status: 200 });
|
|
393
|
+
const root = await renderWithHash("");
|
|
394
|
+
expect(verdictCalls()).toEqual([]);
|
|
395
|
+
await act(async () => root.unmount());
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
|
|
3
3
|
import { useMountEffect } from "./useMountEffect";
|
|
4
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
5
|
|
|
5
6
|
interface ServerConnectionState {
|
|
6
7
|
projectId: string | null;
|
|
@@ -28,16 +29,65 @@ interface ServerConnectionState {
|
|
|
28
29
|
* `/api/projects` list would be wrong twice over: that list omits session ids
|
|
29
30
|
* (which resolve fine) and skips project dirs without an `index.html`.
|
|
30
31
|
*/
|
|
31
|
-
|
|
32
|
+
type ProjectHashVerdict = {
|
|
33
|
+
outcome: "ok" | "missing" | "unknown";
|
|
34
|
+
/** HTTP status when the server answered; absent on a rejected request. */
|
|
35
|
+
status?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function resolveHashProject(id: string): Promise<ProjectHashVerdict> {
|
|
32
39
|
try {
|
|
33
40
|
const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
|
34
|
-
if (res.ok) return "ok";
|
|
35
|
-
return res.status === 404 ? "missing" : "unknown";
|
|
41
|
+
if (res.ok) return { outcome: "ok", status: res.status };
|
|
42
|
+
return { outcome: res.status === 404 ? "missing" : "unknown", status: res.status };
|
|
36
43
|
} catch {
|
|
37
|
-
return "unknown";
|
|
44
|
+
return { outcome: "unknown" };
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What the hash validation decided. Without this, a tab that kept a hash
|
|
50
|
+
* because the check itself failed ("unknown") is indistinguishable from one
|
|
51
|
+
* that never ran the check — the two have different fixes and, until this
|
|
52
|
+
* event, no way to tell them apart in production.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite the project hash WITHOUT pushing a history entry.
|
|
56
|
+
*
|
|
57
|
+
* `location.hash =` pushes. That leaves the dead id as the previous entry, so
|
|
58
|
+
* Back returns to it — and now that hashchange validates, Back would re-run the
|
|
59
|
+
* whole fallback and push again, leaving a trail of dead ids the user cannot
|
|
60
|
+
* escape with the Back button.
|
|
61
|
+
*/
|
|
62
|
+
function replaceProjectHash(projectId: string): void {
|
|
63
|
+
window.history.replaceState(null, "", buildProjectHash(projectId));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The first project the server offers, for a hash that resolves to nothing.
|
|
68
|
+
*
|
|
69
|
+
* Fetched here rather than cached from mount: the mount fetch is a snapshot,
|
|
70
|
+
* and the scenario this exists for is exactly the one where the project list
|
|
71
|
+
* changed underneath a long-lived tab.
|
|
72
|
+
*/
|
|
73
|
+
async function firstProjectId(): Promise<string | null> {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch("/api/projects");
|
|
76
|
+
const data = (await res.json()) as { projects?: Array<{ id?: string }> };
|
|
77
|
+
return data.projects?.[0]?.id ?? null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reportHashVerdict(stage: "mount" | "hashchange", verdict: ProjectHashVerdict): void {
|
|
84
|
+
trackStudioEvent("project_hash_validated", {
|
|
85
|
+
stage,
|
|
86
|
+
outcome: verdict.outcome,
|
|
87
|
+
...(verdict.status === undefined ? {} : { status: verdict.status }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
41
91
|
export function useServerConnection(): ServerConnectionState {
|
|
42
92
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
43
93
|
const [resolving, setResolving] = useState(true);
|
|
@@ -69,11 +119,12 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
69
119
|
// Telemetry after the read-reason split: 120 http_error/404 reads
|
|
70
120
|
// across 5 users in 24h, ~24 each, never recovering.
|
|
71
121
|
if (hashProjectId) {
|
|
72
|
-
const
|
|
122
|
+
const verdict = await resolveHashProject(hashProjectId);
|
|
73
123
|
if (cancelled) return;
|
|
124
|
+
reportHashVerdict("mount", verdict);
|
|
74
125
|
// "unknown" keeps the old behaviour: a transient failure must not
|
|
75
126
|
// rewrite the user's hash out from under a valid project.
|
|
76
|
-
if (
|
|
127
|
+
if (verdict.outcome !== "missing") {
|
|
77
128
|
setProjectId(hashProjectId);
|
|
78
129
|
setWaitingForServer(false);
|
|
79
130
|
return;
|
|
@@ -83,7 +134,7 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
83
134
|
if (first) {
|
|
84
135
|
setProjectId(first.id);
|
|
85
136
|
setWaitingForServer(false);
|
|
86
|
-
|
|
137
|
+
replaceProjectHash(first.id);
|
|
87
138
|
} else {
|
|
88
139
|
scheduleRetry();
|
|
89
140
|
}
|
|
@@ -107,7 +158,29 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
107
158
|
useEffect(() => {
|
|
108
159
|
const onHashChange = () => {
|
|
109
160
|
const next = parseProjectIdFromHash(window.location.hash);
|
|
110
|
-
if (next
|
|
161
|
+
if (!next || next === projectId) return;
|
|
162
|
+
// Validated, not adopted. The mount path has checked its hash since
|
|
163
|
+
// v0.8.52; this path took any id the URL offered, so changing projects
|
|
164
|
+
// re-armed the same dead-tab failure the mount check exists to prevent.
|
|
165
|
+
void (async () => {
|
|
166
|
+
const verdict = await resolveHashProject(next);
|
|
167
|
+
// Ground truth, not a captured flag. This effect re-registers on every
|
|
168
|
+
// projectId change, so a generation counter in this closure would reset
|
|
169
|
+
// and an in-flight verdict from the previous registration would read as
|
|
170
|
+
// current — adopting a project the user has already navigated away from.
|
|
171
|
+
// The hash itself cannot go stale.
|
|
172
|
+
if (parseProjectIdFromHash(window.location.hash) !== next) return;
|
|
173
|
+
reportHashVerdict("hashchange", verdict);
|
|
174
|
+
if (verdict.outcome !== "missing") {
|
|
175
|
+
setProjectId(next);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const fallbackId = await firstProjectId();
|
|
179
|
+
if (parseProjectIdFromHash(window.location.hash) !== next) return;
|
|
180
|
+
if (!fallbackId) return;
|
|
181
|
+
setProjectId(fallbackId);
|
|
182
|
+
replaceProjectHash(fallbackId);
|
|
183
|
+
})();
|
|
111
184
|
};
|
|
112
185
|
window.addEventListener("hashchange", onHashChange);
|
|
113
186
|
return () => window.removeEventListener("hashchange", onHashChange);
|
|
@@ -103,7 +103,7 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
103
103
|
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("measures the insert row against
|
|
106
|
+
it("measures the insert row against every manifest timeline row", async () => {
|
|
107
107
|
const clip = (id: string, track: number): TimelineElement => ({
|
|
108
108
|
id,
|
|
109
109
|
key: id,
|
|
@@ -123,7 +123,6 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
123
123
|
}),
|
|
124
124
|
"</main>",
|
|
125
125
|
].join("\n");
|
|
126
|
-
usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
|
|
127
126
|
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
128
127
|
const getDrop = renderDropHook(source, writeProjectFile, [
|
|
129
128
|
clip("a", 0),
|
|
@@ -136,8 +135,8 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
136
135
|
});
|
|
137
136
|
|
|
138
137
|
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
139
|
-
expect(written).toContain('id="b" data-start="0" data-track-index="
|
|
140
|
-
expect(written).toContain('id="hidden" data-start="0" data-track-index="
|
|
138
|
+
expect(written).toContain('id="b" data-start="0" data-track-index="3"');
|
|
139
|
+
expect(written).toContain('id="hidden" data-start="0" data-track-index="2"');
|
|
141
140
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
142
141
|
});
|
|
143
142
|
|
|
@@ -25,8 +25,6 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
|
|
|
25
25
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
26
26
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
27
27
|
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
28
|
-
import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
|
-
import { usePlayerStore } from "../player/store/playerStore";
|
|
30
28
|
|
|
31
29
|
/** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
|
|
32
30
|
function fileDropPlacement(
|
|
@@ -127,10 +125,7 @@ export function useTimelineAssetDropOps({
|
|
|
127
125
|
const { source: sourceWithRoom, track } = resolveDropTrack({
|
|
128
126
|
source: originalContent,
|
|
129
127
|
// insertRow counts the rows the timeline shows, so plan against those.
|
|
130
|
-
elements:
|
|
131
|
-
relevantElements,
|
|
132
|
-
usePlayerStore.getState().topLevelIds,
|
|
133
|
-
),
|
|
128
|
+
elements: relevantElements,
|
|
134
129
|
placement,
|
|
135
130
|
dropped: {
|
|
136
131
|
id: newId,
|
|
@@ -1561,21 +1561,24 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1561
1561
|
unmount();
|
|
1562
1562
|
});
|
|
1563
1563
|
|
|
1564
|
-
it("resolves toggle-track-hidden against the
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1564
|
+
it("resolves toggle-track-hidden against the store-owned timeline rows", async () => {
|
|
1565
|
+
// The visibility hook reads the single store-owned row source. The editing
|
|
1566
|
+
// guard must receive that same source so a blocked row cannot fall through
|
|
1567
|
+
// to the write path.
|
|
1568
1568
|
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
1569
1569
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1570
1570
|
usePlayerStore.getState().setElements([clip]);
|
|
1571
1571
|
const showToast = vi.fn();
|
|
1572
1572
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1573
|
+
const canEdit = vi.fn((element: TimelineElement) =>
|
|
1574
|
+
element.id === "clip" ? { blocked: true as const, reason: "Reserved by an agent" } : true,
|
|
1575
|
+
);
|
|
1573
1576
|
let hook: ReturnType<typeof useTimelineEditing> | null = null;
|
|
1574
1577
|
function Harness() {
|
|
1575
1578
|
hook = useTimelineEditing({
|
|
1576
1579
|
projectId: "p1",
|
|
1577
1580
|
activeCompPath: "index.html",
|
|
1578
|
-
timelineElements: [],
|
|
1581
|
+
timelineElements: [clip],
|
|
1579
1582
|
showToast,
|
|
1580
1583
|
writeProjectFile,
|
|
1581
1584
|
recordEdit: vi.fn(),
|
|
@@ -1583,8 +1586,7 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1583
1586
|
previewIframeRef: { current: iframe },
|
|
1584
1587
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
1585
1588
|
uploadProjectFiles: vi.fn(),
|
|
1586
|
-
canEdit
|
|
1587
|
-
element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
|
|
1589
|
+
canEdit,
|
|
1588
1590
|
});
|
|
1589
1591
|
return null;
|
|
1590
1592
|
}
|
|
@@ -1596,6 +1598,7 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1596
1598
|
await flushAsyncWork();
|
|
1597
1599
|
});
|
|
1598
1600
|
|
|
1601
|
+
expect(canEdit).toHaveBeenCalledWith(clip);
|
|
1599
1602
|
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1600
1603
|
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1601
1604
|
unmount();
|