@hyperframes/studio 0.7.58 → 0.7.60
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-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
- package/dist/assets/index-DXbu6IPT.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
- package/dist/assets/index-cH6NfVV_.js +426 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13755 -8627
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +42 -53
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +31 -177
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
- package/src/hooks/useFileManager.ts +151 -65
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +18 -10
- package/src/hooks/useTimelineEditingTypes.ts +5 -1
- package/src/hooks/useTimelineGroupEditing.ts +17 -3
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +511 -62
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +293 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +34 -29
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +38 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- package/dist/assets/index-uahwWkgw.css +0 -1
|
@@ -16,7 +16,8 @@ interface UseRazorSplitOptions {
|
|
|
16
16
|
projectId: string | null;
|
|
17
17
|
activeCompPath: string | null;
|
|
18
18
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
19
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
19
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
20
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
20
21
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
21
22
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
22
23
|
reloadPreview: () => void;
|
|
@@ -48,7 +49,7 @@ async function splitHtmlElement(
|
|
|
48
49
|
newId: string,
|
|
49
50
|
elementStart: number,
|
|
50
51
|
elementDuration: number,
|
|
51
|
-
): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
|
|
52
|
+
): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
|
|
52
53
|
const response = await fetch(
|
|
53
54
|
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
|
54
55
|
{
|
|
@@ -64,9 +65,18 @@ async function splitHtmlElement(
|
|
|
64
65
|
},
|
|
65
66
|
);
|
|
66
67
|
if (!response.ok) throw new Error("Split request failed");
|
|
67
|
-
|
|
68
|
+
const data = (await response.json()) as {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
changed?: boolean;
|
|
71
|
+
content?: string;
|
|
72
|
+
version?: string;
|
|
73
|
+
};
|
|
74
|
+
const version = data.version ?? response.headers.get("etag");
|
|
75
|
+
if (!version) throw new Error("Split response did not include a content version");
|
|
76
|
+
return { ...data, version };
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
// fallow-ignore-next-line complexity
|
|
70
80
|
async function splitGsapAnimations(
|
|
71
81
|
projectId: string,
|
|
72
82
|
targetPath: string,
|
|
@@ -75,7 +85,7 @@ async function splitGsapAnimations(
|
|
|
75
85
|
splitTime: number,
|
|
76
86
|
elementStart: number,
|
|
77
87
|
elementDuration: number,
|
|
78
|
-
): Promise<{ content: string | null; skippedSelectors?: string[] }> {
|
|
88
|
+
): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
|
|
79
89
|
const response = await fetch(
|
|
80
90
|
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
|
|
81
91
|
{
|
|
@@ -101,10 +111,12 @@ async function splitGsapAnimations(
|
|
|
101
111
|
const data = (await response.json()) as {
|
|
102
112
|
ok?: boolean;
|
|
103
113
|
after?: string;
|
|
114
|
+
version?: string;
|
|
104
115
|
skippedSelectors?: string[];
|
|
105
116
|
};
|
|
106
117
|
return {
|
|
107
118
|
content: data.ok && data.after ? data.after : null,
|
|
119
|
+
version: data.version ?? response.headers.get("etag") ?? undefined,
|
|
108
120
|
skippedSelectors: data.skippedSelectors,
|
|
109
121
|
};
|
|
110
122
|
}
|
|
@@ -119,11 +131,11 @@ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string
|
|
|
119
131
|
|
|
120
132
|
async function restoreFilesToOriginal(
|
|
121
133
|
originals: ReadonlyMap<string, string>,
|
|
122
|
-
|
|
123
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
134
|
+
snapshots: ReadonlyMap<string, { before: string; after: string }>,
|
|
135
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
124
136
|
): Promise<void> {
|
|
125
|
-
for (const path of
|
|
126
|
-
await writeProjectFile(path, getOriginalContent(originals, path));
|
|
137
|
+
for (const [path, snapshot] of snapshots) {
|
|
138
|
+
await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
|
|
127
139
|
}
|
|
128
140
|
}
|
|
129
141
|
|
|
@@ -149,17 +161,25 @@ async function splitElementsAtTime(
|
|
|
149
161
|
activeCompPath: string | null,
|
|
150
162
|
originals: ReadonlyMap<string, string>,
|
|
151
163
|
snapshots: Map<string, { before: string; after: string }>,
|
|
152
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
164
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
165
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
153
166
|
): Promise<number> {
|
|
154
167
|
let count = 0;
|
|
155
168
|
for (const element of elements) {
|
|
156
|
-
const result = await executeSplit(
|
|
169
|
+
const result = await executeSplit(
|
|
170
|
+
pid,
|
|
171
|
+
element,
|
|
172
|
+
splitTime,
|
|
173
|
+
activeCompPath,
|
|
174
|
+
writeProjectFile,
|
|
175
|
+
observeProjectFileVersion,
|
|
176
|
+
);
|
|
157
177
|
if (!result.changed) continue;
|
|
158
178
|
snapshots.set(result.targetPath, {
|
|
159
179
|
before: getOriginalContent(originals, result.targetPath),
|
|
160
180
|
after: result.patchedContent,
|
|
161
181
|
});
|
|
162
|
-
await writeProjectFile(result.targetPath, result.patchedContent);
|
|
182
|
+
await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
|
|
163
183
|
count++;
|
|
164
184
|
}
|
|
165
185
|
return count;
|
|
@@ -171,7 +191,8 @@ async function executeSplit(
|
|
|
171
191
|
element: TimelineElement,
|
|
172
192
|
splitTime: number,
|
|
173
193
|
activeCompPath: string | null,
|
|
174
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
194
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
195
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
175
196
|
): Promise<{
|
|
176
197
|
targetPath: string;
|
|
177
198
|
originalContent: string;
|
|
@@ -209,6 +230,7 @@ async function executeSplit(
|
|
|
209
230
|
if (!splitResult.changed) {
|
|
210
231
|
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
|
211
232
|
}
|
|
233
|
+
observeProjectFileVersion?.(targetPath, splitResult.version);
|
|
212
234
|
|
|
213
235
|
let patchedContent =
|
|
214
236
|
typeof splitResult.content === "string" ? splitResult.content : originalContent;
|
|
@@ -226,11 +248,12 @@ async function executeSplit(
|
|
|
226
248
|
element.duration,
|
|
227
249
|
);
|
|
228
250
|
if (gsapResult.content) patchedContent = gsapResult.content;
|
|
251
|
+
if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
|
|
229
252
|
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
|
|
230
253
|
} catch (gsapError) {
|
|
231
254
|
// GSAP mutation failed — the HTML split already wrote to disk.
|
|
232
255
|
// Restore the original content to avoid a corrupt half-split state.
|
|
233
|
-
await writeProjectFile(targetPath, originalContent);
|
|
256
|
+
await writeProjectFile(targetPath, originalContent, patchedContent);
|
|
234
257
|
throw gsapError;
|
|
235
258
|
}
|
|
236
259
|
}
|
|
@@ -243,6 +266,7 @@ export function useRazorSplit({
|
|
|
243
266
|
activeCompPath,
|
|
244
267
|
showToast,
|
|
245
268
|
writeProjectFile,
|
|
269
|
+
observeProjectFileVersion,
|
|
246
270
|
recordEdit,
|
|
247
271
|
domEditSaveTimestampRef,
|
|
248
272
|
reloadPreview,
|
|
@@ -265,7 +289,14 @@ export function useRazorSplit({
|
|
|
265
289
|
|
|
266
290
|
try {
|
|
267
291
|
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
|
|
268
|
-
await executeSplit(
|
|
292
|
+
await executeSplit(
|
|
293
|
+
pid,
|
|
294
|
+
element,
|
|
295
|
+
splitTime,
|
|
296
|
+
activeCompPath,
|
|
297
|
+
writeProjectFile,
|
|
298
|
+
observeProjectFileVersion,
|
|
299
|
+
);
|
|
269
300
|
|
|
270
301
|
if (!changed) {
|
|
271
302
|
showToast("Failed to split clip — playhead may be outside the clip", "error");
|
|
@@ -305,6 +336,7 @@ export function useRazorSplit({
|
|
|
305
336
|
recordEdit,
|
|
306
337
|
showToast,
|
|
307
338
|
writeProjectFile,
|
|
339
|
+
observeProjectFileVersion,
|
|
308
340
|
domEditSaveTimestampRef,
|
|
309
341
|
reloadPreview,
|
|
310
342
|
forceReloadSdkSession,
|
|
@@ -312,8 +344,8 @@ export function useRazorSplit({
|
|
|
312
344
|
],
|
|
313
345
|
);
|
|
314
346
|
|
|
315
|
-
// fallow-ignore-next-line complexity
|
|
316
347
|
const handleRazorSplitAll = useCallback(
|
|
348
|
+
// fallow-ignore-next-line complexity
|
|
317
349
|
async (splitTime: number) => {
|
|
318
350
|
if (isRecordingRef?.current) {
|
|
319
351
|
showToast("Cannot edit timeline while recording", "error");
|
|
@@ -338,6 +370,7 @@ export function useRazorSplit({
|
|
|
338
370
|
originals,
|
|
339
371
|
finalSnapshots,
|
|
340
372
|
writeProjectFile,
|
|
373
|
+
observeProjectFileVersion,
|
|
341
374
|
);
|
|
342
375
|
if (splitCount === 0) return;
|
|
343
376
|
|
|
@@ -358,7 +391,7 @@ export function useRazorSplit({
|
|
|
358
391
|
// Best-effort rollback — a failing restore write must not swallow the
|
|
359
392
|
// original error's toast, which is what tells the user the split failed.
|
|
360
393
|
try {
|
|
361
|
-
await restoreFilesToOriginal(originals, finalSnapshots
|
|
394
|
+
await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
|
|
362
395
|
} catch {
|
|
363
396
|
/* leave disk as-is; the original failure is reported below */
|
|
364
397
|
}
|
|
@@ -371,6 +404,7 @@ export function useRazorSplit({
|
|
|
371
404
|
recordEdit,
|
|
372
405
|
showToast,
|
|
373
406
|
writeProjectFile,
|
|
407
|
+
observeProjectFileVersion,
|
|
374
408
|
domEditSaveTimestampRef,
|
|
375
409
|
reloadPreview,
|
|
376
410
|
forceReloadSdkSession,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const openComposition = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock("@hyperframes/sdk", () => ({
|
|
9
|
+
openComposition: (...args: unknown[]) => openComposition(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
13
|
+
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
|
|
14
|
+
|
|
15
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
|
|
17
|
+
function fakeSession(): Composition {
|
|
18
|
+
return { dispose: vi.fn() } as unknown as Composition;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function response(content: string): Response {
|
|
22
|
+
return { ok: true, json: async () => ({ content }) } as Response;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function flushAsyncEffects(): Promise<void> {
|
|
26
|
+
await act(async () => {
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("useSdkSession ownership", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
openComposition.mockReset();
|
|
34
|
+
class FakeEventSource {
|
|
35
|
+
addEventListener(): void {}
|
|
36
|
+
close(): void {}
|
|
37
|
+
}
|
|
38
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("hides project A immediately while project B with the same path is still opening", async () => {
|
|
46
|
+
const sessionA = fakeSession();
|
|
47
|
+
const publishedA = fakeSession();
|
|
48
|
+
const sessionB = fakeSession();
|
|
49
|
+
let resolveProjectB: ((value: Response) => void) | undefined;
|
|
50
|
+
const projectBResponse = new Promise<Response>((resolve) => {
|
|
51
|
+
resolveProjectB = resolve;
|
|
52
|
+
});
|
|
53
|
+
vi.stubGlobal(
|
|
54
|
+
"fetch",
|
|
55
|
+
vi.fn((url: string) =>
|
|
56
|
+
url.includes("project-b") ? projectBResponse : Promise.resolve(response("PROJECT_A")),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
openComposition.mockImplementation(async (content: string) =>
|
|
60
|
+
content === "PROJECT_A" ? sessionA : sessionB,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
|
64
|
+
function Probe({ projectId }: { projectId: string }) {
|
|
65
|
+
captured.handle = useSdkSession(projectId, "index.html");
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const root = createRoot(document.createElement("div"));
|
|
70
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
71
|
+
await flushAsyncEffects();
|
|
72
|
+
expect(captured.handle?.session).toBe(sessionA);
|
|
73
|
+
|
|
74
|
+
let publication: ReturnType<SdkSessionHandle["publish"]> | undefined;
|
|
75
|
+
await act(async () => {
|
|
76
|
+
publication = captured.handle?.publish({
|
|
77
|
+
candidate: publishedA,
|
|
78
|
+
expectedSession: sessionA,
|
|
79
|
+
targetPath: "index.html",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
expect(publication).toBe("published");
|
|
83
|
+
expect(captured.handle?.session).toBe(publishedA);
|
|
84
|
+
|
|
85
|
+
await act(async () => root.render(<Probe projectId="project-b" />));
|
|
86
|
+
expect(captured.handle?.session).toBeNull();
|
|
87
|
+
expect(publishedA.dispose).toHaveBeenCalledOnce();
|
|
88
|
+
expect(
|
|
89
|
+
captured.handle?.publish({
|
|
90
|
+
candidate: fakeSession(),
|
|
91
|
+
expectedSession: publishedA,
|
|
92
|
+
targetPath: "index.html",
|
|
93
|
+
}),
|
|
94
|
+
).toBe("rejected-inactive-target");
|
|
95
|
+
|
|
96
|
+
resolveProjectB?.(response("PROJECT_B"));
|
|
97
|
+
await flushAsyncEffects();
|
|
98
|
+
expect(captured.handle?.session).toBe(sessionB);
|
|
99
|
+
|
|
100
|
+
await act(async () => root.unmount());
|
|
101
|
+
expect(sessionB.dispose).toHaveBeenCalledOnce();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("disposes the currently published candidate when its owner unmounts", async () => {
|
|
105
|
+
const opened = fakeSession();
|
|
106
|
+
const published = fakeSession();
|
|
107
|
+
vi.stubGlobal(
|
|
108
|
+
"fetch",
|
|
109
|
+
vi.fn(async () => response("PROJECT_A")),
|
|
110
|
+
);
|
|
111
|
+
openComposition.mockResolvedValue(opened);
|
|
112
|
+
|
|
113
|
+
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
|
114
|
+
function Probe() {
|
|
115
|
+
captured.handle = useSdkSession("project-a", "index.html");
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const root = createRoot(document.createElement("div"));
|
|
120
|
+
await act(async () => root.render(<Probe />));
|
|
121
|
+
await flushAsyncEffects();
|
|
122
|
+
expect(captured.handle?.session).toBe(opened);
|
|
123
|
+
let publication: ReturnType<SdkSessionHandle["publish"]> | undefined;
|
|
124
|
+
await act(async () => {
|
|
125
|
+
publication = captured.handle?.publish({
|
|
126
|
+
candidate: published,
|
|
127
|
+
expectedSession: opened,
|
|
128
|
+
targetPath: "index.html",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
expect(publication).toBe("published");
|
|
132
|
+
expect(opened.dispose).toHaveBeenCalledOnce();
|
|
133
|
+
|
|
134
|
+
await act(async () => root.unmount());
|
|
135
|
+
expect(published.dispose).toHaveBeenCalledOnce();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import type { MutableRefObject } from "react";
|
|
3
3
|
import { openComposition } from "@hyperframes/sdk";
|
|
4
4
|
import type { Composition } from "@hyperframes/sdk";
|
|
5
5
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
6
6
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
7
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
|
+
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Read a project file's content, or undefined on a non-2xx (optional read).
|
|
@@ -82,6 +84,8 @@ export function shouldReloadOnFileChange(
|
|
|
82
84
|
|
|
83
85
|
export interface SdkSessionHandle {
|
|
84
86
|
session: Composition | null;
|
|
87
|
+
/** Atomically publish a fully persisted candidate session. */
|
|
88
|
+
publish: PublishSdkSession;
|
|
85
89
|
/**
|
|
86
90
|
* Force a session reload immediately, bypassing the self-write suppress
|
|
87
91
|
* window. Call after undo/redo writes the active composition file so the
|
|
@@ -90,13 +94,82 @@ export interface SdkSessionHandle {
|
|
|
90
94
|
forceReload: () => void;
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
interface SdkSessionOwner {
|
|
98
|
+
projectId: string;
|
|
99
|
+
path: string;
|
|
100
|
+
reloadToken: number;
|
|
101
|
+
generation: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface OwnedSdkSession extends SdkSessionOwner {
|
|
105
|
+
session: Composition;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isSessionOwnerActive(
|
|
109
|
+
owner: SdkSessionOwner | undefined,
|
|
110
|
+
projectId: string | null,
|
|
111
|
+
path: string | null,
|
|
112
|
+
targetPath: string,
|
|
113
|
+
): owner is SdkSessionOwner {
|
|
114
|
+
if (!owner) return false;
|
|
115
|
+
return owner.projectId === projectId && owner.path === path && owner.path === targetPath;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isSessionOwnerCurrent(
|
|
119
|
+
owner: SdkSessionOwner,
|
|
120
|
+
generation: number,
|
|
121
|
+
projectId: string | null,
|
|
122
|
+
path: string | null,
|
|
123
|
+
reloadToken: number,
|
|
124
|
+
): boolean {
|
|
125
|
+
return (
|
|
126
|
+
owner.generation === generation &&
|
|
127
|
+
owner.projectId === projectId &&
|
|
128
|
+
owner.path === path &&
|
|
129
|
+
owner.reloadToken === reloadToken
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ownsExpectedSession(
|
|
134
|
+
current: OwnedSdkSession | null,
|
|
135
|
+
expectedOwner: SdkSessionOwner,
|
|
136
|
+
expectedSession: Composition,
|
|
137
|
+
reloadToken: number,
|
|
138
|
+
): current is OwnedSdkSession {
|
|
139
|
+
if (!current) return false;
|
|
140
|
+
return (
|
|
141
|
+
current.session === expectedSession &&
|
|
142
|
+
current.generation === expectedOwner.generation &&
|
|
143
|
+
current.reloadToken === reloadToken
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function disposeSdkSession(session: Composition): void {
|
|
148
|
+
try {
|
|
149
|
+
session.dispose();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
trackStudioEvent("sdk_session_dispose_failed", {
|
|
152
|
+
error: error instanceof Error ? error.message : String(error),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
93
157
|
export function useSdkSession(
|
|
94
158
|
projectId: string | null,
|
|
95
159
|
activeCompPath: string | null,
|
|
96
160
|
domEditSaveTimestampRef?: MutableRefObject<number>,
|
|
97
161
|
): SdkSessionHandle {
|
|
98
|
-
const [
|
|
162
|
+
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
|
163
|
+
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
|
164
|
+
const sessionOwnersRef = useRef(new WeakMap<Composition, SdkSessionOwner>());
|
|
165
|
+
const generationRef = useRef(0);
|
|
166
|
+
const projectIdRef = useRef(projectId);
|
|
167
|
+
projectIdRef.current = projectId;
|
|
168
|
+
const activeCompPathRef = useRef(activeCompPath);
|
|
169
|
+
activeCompPathRef.current = activeCompPath;
|
|
99
170
|
const [reloadToken, setReloadToken] = useState(0);
|
|
171
|
+
const reloadTokenRef = useRef(reloadToken);
|
|
172
|
+
reloadTokenRef.current = reloadToken;
|
|
100
173
|
|
|
101
174
|
// ── Re-open on external change to the active composition ──
|
|
102
175
|
useEffect(() => {
|
|
@@ -135,13 +208,28 @@ export function useSdkSession(
|
|
|
135
208
|
|
|
136
209
|
// ── Open / re-open the session ──
|
|
137
210
|
useEffect(() => {
|
|
211
|
+
const generation = ++generationRef.current;
|
|
212
|
+
let cancelled = false;
|
|
213
|
+
|
|
214
|
+
// The preceding effect normally released its generation first. Clear any
|
|
215
|
+
// remaining owner defensively so an invalid project/path cannot retain it.
|
|
216
|
+
const previous = ownedSessionRef.current;
|
|
217
|
+
ownedSessionRef.current = null;
|
|
218
|
+
setOwnedSession(null);
|
|
219
|
+
if (previous) disposeSdkSession(previous.session);
|
|
220
|
+
|
|
138
221
|
if (!projectId || !activeCompPath) {
|
|
139
|
-
|
|
140
|
-
|
|
222
|
+
return () => {
|
|
223
|
+
cancelled = true;
|
|
224
|
+
};
|
|
141
225
|
}
|
|
142
226
|
|
|
143
|
-
|
|
144
|
-
|
|
227
|
+
const owner: SdkSessionOwner = {
|
|
228
|
+
projectId,
|
|
229
|
+
path: activeCompPath,
|
|
230
|
+
reloadToken,
|
|
231
|
+
generation,
|
|
232
|
+
};
|
|
145
233
|
|
|
146
234
|
readProjectFileOptional(projectId, activeCompPath)
|
|
147
235
|
.then(async (content) => {
|
|
@@ -157,24 +245,77 @@ export function useSdkSession(
|
|
|
157
245
|
const comp = await openComposition(content, { history: false });
|
|
158
246
|
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
|
159
247
|
if (cancelled) {
|
|
160
|
-
comp
|
|
248
|
+
disposeSdkSession(comp);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
!isSessionOwnerCurrent(
|
|
253
|
+
owner,
|
|
254
|
+
generationRef.current,
|
|
255
|
+
projectIdRef.current,
|
|
256
|
+
activeCompPathRef.current,
|
|
257
|
+
reloadTokenRef.current,
|
|
258
|
+
)
|
|
259
|
+
) {
|
|
260
|
+
disposeSdkSession(comp);
|
|
161
261
|
return;
|
|
162
262
|
}
|
|
163
|
-
|
|
164
|
-
|
|
263
|
+
const displaced = ownedSessionRef.current;
|
|
264
|
+
const installed = { ...owner, session: comp };
|
|
265
|
+
sessionOwnersRef.current.set(comp, owner);
|
|
266
|
+
ownedSessionRef.current = installed;
|
|
267
|
+
setOwnedSession(installed);
|
|
268
|
+
if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
|
|
165
269
|
})
|
|
166
270
|
.catch(() => {
|
|
167
|
-
if (!cancelled)
|
|
271
|
+
if (!cancelled && generationRef.current === generation) setOwnedSession(null);
|
|
168
272
|
});
|
|
169
273
|
|
|
170
274
|
return () => {
|
|
171
275
|
cancelled = true;
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
276
|
+
// Publication preserves this generation, so cleanup releases whichever
|
|
277
|
+
// session it currently owns (the initially opened one or its candidate).
|
|
278
|
+
const owned = ownedSessionRef.current;
|
|
279
|
+
if (owned?.generation === generation) {
|
|
280
|
+
ownedSessionRef.current = null;
|
|
281
|
+
disposeSdkSession(owned.session);
|
|
282
|
+
}
|
|
175
283
|
};
|
|
176
284
|
}, [projectId, activeCompPath, reloadToken]);
|
|
177
285
|
|
|
178
286
|
const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
|
|
179
|
-
|
|
287
|
+
const publish = useCallback<PublishSdkSession>(({ candidate, expectedSession, targetPath }) => {
|
|
288
|
+
const expectedOwner = sessionOwnersRef.current.get(expectedSession);
|
|
289
|
+
const current = ownedSessionRef.current;
|
|
290
|
+
if (
|
|
291
|
+
!isSessionOwnerActive(
|
|
292
|
+
expectedOwner,
|
|
293
|
+
projectIdRef.current,
|
|
294
|
+
activeCompPathRef.current,
|
|
295
|
+
targetPath,
|
|
296
|
+
)
|
|
297
|
+
) {
|
|
298
|
+
return "rejected-inactive-target";
|
|
299
|
+
}
|
|
300
|
+
if (!ownsExpectedSession(current, expectedOwner, expectedSession, reloadTokenRef.current)) {
|
|
301
|
+
// The durable write won, but another session was installed for this same
|
|
302
|
+
// path before publication. Its self-write echo will be suppressed, so
|
|
303
|
+
// explicitly re-open it from disk instead of leaving it stale.
|
|
304
|
+
setReloadToken((t) => t + 1);
|
|
305
|
+
return "rejected-active-target";
|
|
306
|
+
}
|
|
307
|
+
const next: OwnedSdkSession = { ...current, session: candidate };
|
|
308
|
+
sessionOwnersRef.current.set(candidate, current);
|
|
309
|
+
ownedSessionRef.current = next;
|
|
310
|
+
setOwnedSession(next);
|
|
311
|
+
if (current.session !== candidate) disposeSdkSession(current.session);
|
|
312
|
+
return "published";
|
|
313
|
+
}, []);
|
|
314
|
+
const session =
|
|
315
|
+
ownedSession?.projectId === projectId &&
|
|
316
|
+
ownedSession.path === activeCompPath &&
|
|
317
|
+
ownedSession.reloadToken === reloadToken
|
|
318
|
+
? ownedSession.session
|
|
319
|
+
: null;
|
|
320
|
+
return { session, publish, forceReload };
|
|
180
321
|
}
|
|
@@ -2,6 +2,7 @@ import { useCallback, type MutableRefObject } from "react";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
5
6
|
import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
|
|
6
7
|
|
|
7
8
|
export interface UseSlideshowPersistParams {
|
|
@@ -16,6 +17,8 @@ export interface UseSlideshowPersistParams {
|
|
|
16
17
|
}) => Promise<void>;
|
|
17
18
|
reloadPreview: () => void;
|
|
18
19
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
20
|
+
/** Publish a fully persisted candidate SDK session. */
|
|
21
|
+
publishSdkSession?: PublishSdkSession;
|
|
19
22
|
/**
|
|
20
23
|
* When provided, rapid writes with the same key coalesce through the
|
|
21
24
|
* save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
|
|
@@ -33,6 +36,7 @@ export function useSlideshowPersist({
|
|
|
33
36
|
recordEdit,
|
|
34
37
|
reloadPreview,
|
|
35
38
|
domEditSaveTimestampRef,
|
|
39
|
+
publishSdkSession,
|
|
36
40
|
coalesceKey,
|
|
37
41
|
}: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
|
|
38
42
|
return useCallback(
|
|
@@ -42,7 +46,6 @@ export function useSlideshowPersist({
|
|
|
42
46
|
const originalContent = await readProjectFile(path);
|
|
43
47
|
await persistSlideshowManifest({
|
|
44
48
|
manifest,
|
|
45
|
-
sdkSession,
|
|
46
49
|
originalContent,
|
|
47
50
|
targetPath: path,
|
|
48
51
|
deps: {
|
|
@@ -50,6 +53,8 @@ export function useSlideshowPersist({
|
|
|
50
53
|
writeProjectFile,
|
|
51
54
|
reloadPreview,
|
|
52
55
|
domEditSaveTimestampRef,
|
|
56
|
+
readProjectFile,
|
|
57
|
+
publishSession: publishSdkSession,
|
|
53
58
|
},
|
|
54
59
|
coalesceKey,
|
|
55
60
|
});
|
|
@@ -62,6 +67,7 @@ export function useSlideshowPersist({
|
|
|
62
67
|
recordEdit,
|
|
63
68
|
reloadPreview,
|
|
64
69
|
domEditSaveTimestampRef,
|
|
70
|
+
publishSdkSession,
|
|
65
71
|
coalesceKey,
|
|
66
72
|
],
|
|
67
73
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import type {
|
|
3
3
|
StoryboardFrame,
|
|
4
4
|
StoryboardGlobals,
|
|
@@ -27,6 +27,8 @@ export interface StoryboardResponse {
|
|
|
27
27
|
frames: StoryboardFrameView[];
|
|
28
28
|
warnings: StoryboardWarning[];
|
|
29
29
|
script?: StoryboardScript;
|
|
30
|
+
/** Project content signature this payload was derived from (poster cache-bust + change poll). */
|
|
31
|
+
signature?: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface UseStoryboardResult {
|
|
@@ -39,19 +41,30 @@ export interface UseStoryboardResult {
|
|
|
39
41
|
/**
|
|
40
42
|
* Load the parsed storyboard manifest for a project. Markdown stays canonical on
|
|
41
43
|
* disk; this fetches the server-derived JSON the storyboard view renders.
|
|
44
|
+
*
|
|
45
|
+
* `reload()` refetches in place: the blocking spinner only shows on first load
|
|
46
|
+
* and project switches, so background refreshes (file-change polling, post-save
|
|
47
|
+
* reloads) swap data without flashing the board.
|
|
42
48
|
*/
|
|
43
49
|
export function useStoryboard(projectId: string | null): UseStoryboardResult {
|
|
44
50
|
const [data, setData] = useState<StoryboardResponse | null>(null);
|
|
45
51
|
const [loading, setLoading] = useState(true);
|
|
46
52
|
const [error, setError] = useState<string | null>(null);
|
|
47
53
|
const [reloadKey, setReloadKey] = useState(0);
|
|
54
|
+
const hasDataRef = useRef(false);
|
|
55
|
+
const lastProjectRef = useRef<string | null>(null);
|
|
48
56
|
|
|
49
57
|
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
50
58
|
|
|
51
59
|
useEffect(() => {
|
|
52
60
|
if (!projectId) return;
|
|
53
61
|
let cancelled = false;
|
|
54
|
-
|
|
62
|
+
if (lastProjectRef.current !== projectId) {
|
|
63
|
+
lastProjectRef.current = projectId;
|
|
64
|
+
hasDataRef.current = false;
|
|
65
|
+
setData(null);
|
|
66
|
+
}
|
|
67
|
+
if (!hasDataRef.current) setLoading(true);
|
|
55
68
|
setError(null);
|
|
56
69
|
|
|
57
70
|
// Route through buildProjectApiPath so the (URL-derived) projectId is encoded
|
|
@@ -62,10 +75,16 @@ export function useStoryboard(projectId: string | null): UseStoryboardResult {
|
|
|
62
75
|
return res.json() as Promise<StoryboardResponse>;
|
|
63
76
|
})
|
|
64
77
|
.then((json) => {
|
|
65
|
-
if (
|
|
78
|
+
if (cancelled) return;
|
|
79
|
+
hasDataRef.current = true;
|
|
80
|
+
setData(json);
|
|
66
81
|
})
|
|
67
82
|
.catch((err: unknown) => {
|
|
68
|
-
if (
|
|
83
|
+
if (cancelled) return;
|
|
84
|
+
// A failed background refresh keeps the last good board on screen;
|
|
85
|
+
// the blocking error state is only for loads with nothing to show.
|
|
86
|
+
if (hasDataRef.current) return;
|
|
87
|
+
setError(err instanceof Error ? err.message : "failed to load storyboard");
|
|
69
88
|
})
|
|
70
89
|
.finally(() => {
|
|
71
90
|
if (!cancelled) setLoading(false);
|