@hyperframes/studio 0.6.95 → 0.6.97
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-Daj5djxa.js +418 -0
- package/dist/assets/index-B0twsRu0.css +1 -0
- package/dist/assets/index-Cfye9xzo.js +251 -0
- package/dist/assets/{index-CAANLw9Q.js → index-HveJ0MuV.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +10 -5
- package/src/components/SaveQueuePausedBanner.tsx +23 -0
- package/src/components/StudioPreviewArea.tsx +7 -0
- package/src/components/StudioRightPanel.tsx +1 -38
- package/src/components/editor/DomEditOverlay.test.ts +169 -29
- package/src/components/editor/DomEditOverlay.tsx +13 -23
- package/src/components/editor/GestureRecordControl.tsx +98 -0
- package/src/components/editor/PropertyPanel.tsx +22 -38
- package/src/components/editor/domEditing.test.ts +84 -0
- package/src/components/editor/domEditingLayers.ts +19 -0
- package/src/components/editor/domEditingRootLayer.ts +64 -0
- package/src/components/editor/manualEditingAvailability.test.ts +1 -2
- package/src/components/editor/manualEditingAvailability.ts +0 -7
- package/src/contexts/DomEditContext.tsx +1 -6
- package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
- package/src/hooks/useDomEditCommits.ts +97 -123
- package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
- package/src/hooks/useDomEditSession.ts +59 -65
- package/src/hooks/useFileManager.ts +19 -5
- package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
- package/src/hooks/useGsapScriptCommits.ts +152 -140
- package/src/hooks/useGsapSelectionHandlers.ts +38 -8
- package/src/hooks/usePreviewPersistence.ts +90 -51
- package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
- package/src/hooks/useStudioContextValue.ts +3 -19
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/lib/playbackAdapter.test.ts +86 -1
- package/src/player/lib/playbackAdapter.ts +62 -0
- package/src/utils/domEditSaveQueue.test.ts +117 -0
- package/src/utils/domEditSaveQueue.ts +87 -0
- package/src/utils/studioHelpers.ts +1 -1
- package/src/utils/studioSaveDiagnostics.test.ts +127 -0
- package/src/utils/studioSaveDiagnostics.ts +200 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/studioUrlState.ts +2 -8
- package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
- package/dist/assets/index-DujOjou6.js +0 -251
- package/dist/assets/index-rm9tn9nH.css +0 -1
- package/src/components/editor/EaseCurveEditor.tsx +0 -221
- package/src/components/editor/MotionPanel.tsx +0 -277
- package/src/components/editor/MotionPanelFields.tsx +0 -185
- package/src/components/editor/MotionPathOverlay.tsx +0 -146
- package/src/components/editor/SpringEaseEditor.tsx +0 -256
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createDomEditSaveQueue } from "./domEditSaveQueue";
|
|
3
|
+
import { StudioSaveHttpError } from "./studioSaveDiagnostics";
|
|
4
|
+
|
|
5
|
+
describe("dom edit save queue", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("opens the breaker after consecutive failures and rejects new work until reset", async () => {
|
|
11
|
+
const onOpen = vi.fn();
|
|
12
|
+
const onReset = vi.fn();
|
|
13
|
+
const queue = createDomEditSaveQueue({
|
|
14
|
+
failureThreshold: 2,
|
|
15
|
+
onOpen,
|
|
16
|
+
onReset,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
await expect(
|
|
20
|
+
queue.enqueue(async () => {
|
|
21
|
+
throw new StudioSaveHttpError("Server down", 503);
|
|
22
|
+
}),
|
|
23
|
+
).rejects.toThrow("Server down");
|
|
24
|
+
await expect(
|
|
25
|
+
queue.enqueue(async () => {
|
|
26
|
+
throw new StudioSaveHttpError("Still down", 503);
|
|
27
|
+
}),
|
|
28
|
+
).rejects.toThrow("Still down");
|
|
29
|
+
|
|
30
|
+
expect(onOpen).toHaveBeenCalledWith({
|
|
31
|
+
consecutiveFailures: 2,
|
|
32
|
+
errorMessage: "Still down",
|
|
33
|
+
statusCode: 503,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let thirdRan = false;
|
|
37
|
+
await expect(
|
|
38
|
+
queue.enqueue(async () => {
|
|
39
|
+
thirdRan = true;
|
|
40
|
+
}),
|
|
41
|
+
).rejects.toThrow("Auto-save is paused");
|
|
42
|
+
expect(thirdRan).toBe(false);
|
|
43
|
+
|
|
44
|
+
queue.reset();
|
|
45
|
+
expect(onReset).toHaveBeenCalledOnce();
|
|
46
|
+
|
|
47
|
+
await queue.enqueue(async () => {
|
|
48
|
+
thirdRan = true;
|
|
49
|
+
});
|
|
50
|
+
expect(thirdRan).toBe(true);
|
|
51
|
+
queue.destroy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps an open breaker paused even when already queued work succeeds", async () => {
|
|
55
|
+
const onOpen = vi.fn();
|
|
56
|
+
const onReset = vi.fn();
|
|
57
|
+
const queue = createDomEditSaveQueue({
|
|
58
|
+
failureThreshold: 1,
|
|
59
|
+
onOpen,
|
|
60
|
+
onReset,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let rejectFirst: ((error: Error) => void) | null = null;
|
|
64
|
+
let resolveFirstStarted: (() => void) | null = null;
|
|
65
|
+
const firstStarted = new Promise<void>((resolve) => {
|
|
66
|
+
resolveFirstStarted = resolve;
|
|
67
|
+
});
|
|
68
|
+
const first = queue.enqueue(
|
|
69
|
+
() =>
|
|
70
|
+
new Promise<void>((_resolve, reject) => {
|
|
71
|
+
rejectFirst = reject;
|
|
72
|
+
resolveFirstStarted?.();
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
const second = queue.enqueue(async () => {});
|
|
76
|
+
|
|
77
|
+
await firstStarted;
|
|
78
|
+
expect(rejectFirst).toBeTypeOf("function");
|
|
79
|
+
rejectFirst?.(new StudioSaveHttpError("Server down", 503));
|
|
80
|
+
await expect(first).rejects.toThrow("Server down");
|
|
81
|
+
await expect(second).resolves.toBeUndefined();
|
|
82
|
+
|
|
83
|
+
expect(onOpen).toHaveBeenCalledOnce();
|
|
84
|
+
expect(onReset).not.toHaveBeenCalled();
|
|
85
|
+
|
|
86
|
+
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
|
|
87
|
+
|
|
88
|
+
queue.reset();
|
|
89
|
+
expect(onReset).toHaveBeenCalledOnce();
|
|
90
|
+
queue.destroy();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("resets consecutive failures after a successful save", async () => {
|
|
94
|
+
const onOpen = vi.fn();
|
|
95
|
+
const queue = createDomEditSaveQueue({
|
|
96
|
+
failureThreshold: 2,
|
|
97
|
+
onOpen,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await expect(
|
|
101
|
+
queue.enqueue(async () => {
|
|
102
|
+
throw new Error("first failure");
|
|
103
|
+
}),
|
|
104
|
+
).rejects.toThrow("first failure");
|
|
105
|
+
|
|
106
|
+
await queue.enqueue(async () => {});
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
queue.enqueue(async () => {
|
|
110
|
+
throw new Error("second failure after success");
|
|
111
|
+
}),
|
|
112
|
+
).rejects.toThrow("second failure after success");
|
|
113
|
+
|
|
114
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
115
|
+
queue.destroy();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getStudioSaveErrorMessage, getStudioSaveStatusCode } from "./studioSaveDiagnostics";
|
|
2
|
+
|
|
3
|
+
interface DomEditSaveQueueOpenEvent {
|
|
4
|
+
consecutiveFailures: number;
|
|
5
|
+
errorMessage: string;
|
|
6
|
+
statusCode: number | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DomEditSaveQueueOptions {
|
|
10
|
+
failureThreshold?: number;
|
|
11
|
+
onOpen?: (event: DomEditSaveQueueOpenEvent) => void;
|
|
12
|
+
onReset?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DomEditSaveQueue {
|
|
16
|
+
enqueue: (save: () => Promise<void>) => Promise<void>;
|
|
17
|
+
waitForIdle: () => Promise<void>;
|
|
18
|
+
reset: () => void;
|
|
19
|
+
destroy: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_FAILURE_THRESHOLD = 5;
|
|
23
|
+
|
|
24
|
+
export class DomEditSaveQueueOpenError extends Error {
|
|
25
|
+
constructor() {
|
|
26
|
+
super("Auto-save is paused. Dismiss the warning to retry DOM edits.");
|
|
27
|
+
this.name = "DomEditSaveQueueOpenError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): DomEditSaveQueue {
|
|
32
|
+
const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
|
33
|
+
|
|
34
|
+
let tail = Promise.resolve();
|
|
35
|
+
let consecutiveFailures = 0;
|
|
36
|
+
let breakerOpen = false;
|
|
37
|
+
|
|
38
|
+
const reset = (notify = true) => {
|
|
39
|
+
const wasOpen = breakerOpen;
|
|
40
|
+
consecutiveFailures = 0;
|
|
41
|
+
breakerOpen = false;
|
|
42
|
+
if (notify && wasOpen) options.onReset?.();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const open = (error: unknown) => {
|
|
46
|
+
if (breakerOpen) return;
|
|
47
|
+
breakerOpen = true;
|
|
48
|
+
options.onOpen?.({
|
|
49
|
+
consecutiveFailures,
|
|
50
|
+
errorMessage: getStudioSaveErrorMessage(error),
|
|
51
|
+
statusCode: getStudioSaveStatusCode(error) ?? null,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const run = async (save: () => Promise<void>) => {
|
|
56
|
+
try {
|
|
57
|
+
await save();
|
|
58
|
+
if (!breakerOpen) consecutiveFailures = 0;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
consecutiveFailures += 1;
|
|
61
|
+
if (consecutiveFailures >= failureThreshold) open(error);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
enqueue(save) {
|
|
68
|
+
if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
|
|
69
|
+
const queued = tail.catch(() => undefined).then(() => run(save));
|
|
70
|
+
tail = queued.then(
|
|
71
|
+
() => undefined,
|
|
72
|
+
() => undefined,
|
|
73
|
+
);
|
|
74
|
+
return queued;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async waitForIdle() {
|
|
78
|
+
await tail.catch(() => undefined);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
reset,
|
|
82
|
+
|
|
83
|
+
destroy() {
|
|
84
|
+
reset(false);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -12,7 +12,7 @@ export interface AppToast {
|
|
|
12
12
|
tone: "error" | "info";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export type RightPanelTab = "layers" | "design" | "
|
|
15
|
+
export type RightPanelTab = "layers" | "design" | "renders" | "block-params";
|
|
16
16
|
|
|
17
17
|
export interface AgentModalAnchorPoint {
|
|
18
18
|
x: number;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
StudioSaveHttpError,
|
|
4
|
+
StudioSaveNetworkError,
|
|
5
|
+
buildStudioSaveFailureProperties,
|
|
6
|
+
getStudioSaveStatusCode,
|
|
7
|
+
retryStudioSave,
|
|
8
|
+
} from "./studioSaveDiagnostics";
|
|
9
|
+
|
|
10
|
+
describe("studio save diagnostics", () => {
|
|
11
|
+
it("builds save_failure properties with stable diagnostics", () => {
|
|
12
|
+
const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
|
|
13
|
+
|
|
14
|
+
expect(
|
|
15
|
+
buildStudioSaveFailureProperties({
|
|
16
|
+
source: "code_editor",
|
|
17
|
+
error,
|
|
18
|
+
filePath: "index.html",
|
|
19
|
+
mutationType: "put",
|
|
20
|
+
attempt: 3,
|
|
21
|
+
}),
|
|
22
|
+
).toEqual({
|
|
23
|
+
source: "code_editor",
|
|
24
|
+
error_message: "Failed to save index.html (503)",
|
|
25
|
+
status_code: 503,
|
|
26
|
+
file_path: "index.html",
|
|
27
|
+
mutation_type: "put",
|
|
28
|
+
attempt: 3,
|
|
29
|
+
label: undefined,
|
|
30
|
+
target_id: undefined,
|
|
31
|
+
target_selector: undefined,
|
|
32
|
+
target_source_file: undefined,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reads nested status codes from error causes", () => {
|
|
37
|
+
const cause = new StudioSaveHttpError("Too many requests", 429);
|
|
38
|
+
const error = new Error("retry wrapper") as Error & { cause?: unknown };
|
|
39
|
+
error.cause = cause;
|
|
40
|
+
|
|
41
|
+
expect(getStudioSaveStatusCode(error)).toBe(429);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("retries transient save failures with exponential backoff and jitter", async () => {
|
|
45
|
+
const sleeps: number[] = [];
|
|
46
|
+
const operation = vi
|
|
47
|
+
.fn<(attempt: number) => Promise<string>>()
|
|
48
|
+
.mockRejectedValueOnce(new StudioSaveHttpError("Server restarting", 503))
|
|
49
|
+
.mockRejectedValueOnce(new StudioSaveHttpError("Still restarting", 503))
|
|
50
|
+
.mockRejectedValueOnce(new StudioSaveHttpError("Almost ready", 503))
|
|
51
|
+
.mockResolvedValue("saved");
|
|
52
|
+
|
|
53
|
+
await expect(
|
|
54
|
+
retryStudioSave(operation, {
|
|
55
|
+
random: () => 0.5,
|
|
56
|
+
sleep: async (delayMs) => {
|
|
57
|
+
sleeps.push(delayMs);
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
).resolves.toBe("saved");
|
|
61
|
+
|
|
62
|
+
expect(operation).toHaveBeenCalledTimes(4);
|
|
63
|
+
expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3, 4]);
|
|
64
|
+
expect(sleeps).toEqual([500, 1000, 2000]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not retry non-transient client failures", async () => {
|
|
68
|
+
const operation = vi
|
|
69
|
+
.fn<(attempt: number) => Promise<string>>()
|
|
70
|
+
.mockRejectedValue(new StudioSaveHttpError("Too large", 413));
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
retryStudioSave(operation, {
|
|
74
|
+
sleep: async () => {},
|
|
75
|
+
}),
|
|
76
|
+
).rejects.toThrow("Too large");
|
|
77
|
+
|
|
78
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("retries typed network failures", async () => {
|
|
82
|
+
const operation = vi
|
|
83
|
+
.fn<(attempt: number) => Promise<string>>()
|
|
84
|
+
.mockRejectedValueOnce(new StudioSaveNetworkError("network dropped"))
|
|
85
|
+
.mockResolvedValue("saved");
|
|
86
|
+
|
|
87
|
+
await expect(
|
|
88
|
+
retryStudioSave(operation, {
|
|
89
|
+
sleep: async () => {},
|
|
90
|
+
}),
|
|
91
|
+
).resolves.toBe("saved");
|
|
92
|
+
|
|
93
|
+
expect(operation).toHaveBeenCalledTimes(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not retry plain JavaScript errors", async () => {
|
|
97
|
+
const operation = vi
|
|
98
|
+
.fn<(attempt: number) => Promise<string>>()
|
|
99
|
+
.mockRejectedValue(new Error("local assertion failed"));
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
retryStudioSave(operation, {
|
|
103
|
+
sleep: async () => {},
|
|
104
|
+
}),
|
|
105
|
+
).rejects.toThrow("local assertion failed");
|
|
106
|
+
|
|
107
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("aborts while waiting between retry attempts", async () => {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
const operation = vi
|
|
113
|
+
.fn<(attempt: number) => Promise<string>>()
|
|
114
|
+
.mockRejectedValue(new StudioSaveHttpError("Server restarting", 503));
|
|
115
|
+
|
|
116
|
+
const pending = retryStudioSave(operation, {
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
sleep: async (_delayMs, signal) => {
|
|
119
|
+
controller.abort();
|
|
120
|
+
if (signal?.aborted) throw new DOMException("Save aborted", "AbortError");
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
|
125
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
2
|
+
|
|
3
|
+
type StudioTelemetryValue = string | number | boolean | null | undefined;
|
|
4
|
+
const STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
|
|
5
|
+
|
|
6
|
+
export interface StudioSaveFailureInput {
|
|
7
|
+
source: string;
|
|
8
|
+
error: unknown;
|
|
9
|
+
statusCode?: number | null;
|
|
10
|
+
filePath?: string | null;
|
|
11
|
+
mutationType?: string | null;
|
|
12
|
+
attempt?: number | null;
|
|
13
|
+
label?: string | null;
|
|
14
|
+
targetId?: string | null;
|
|
15
|
+
targetSelector?: string | null;
|
|
16
|
+
targetSourceFile?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class StudioSaveHttpError extends Error {
|
|
20
|
+
readonly statusCode: number;
|
|
21
|
+
|
|
22
|
+
constructor(message: string, statusCode: number) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "StudioSaveHttpError";
|
|
25
|
+
this.statusCode = statusCode;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class StudioSaveNetworkError extends Error {
|
|
30
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
31
|
+
super(message, options);
|
|
32
|
+
this.name = "StudioSaveNetworkError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readNumericProperty(value: object, key: string): number | undefined {
|
|
37
|
+
const record = value as Record<string, unknown>;
|
|
38
|
+
const property = record[key];
|
|
39
|
+
return typeof property === "number" && Number.isFinite(property) ? property : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createStudioSaveAbortError(): Error {
|
|
43
|
+
if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
|
|
44
|
+
const error = new Error("Save aborted");
|
|
45
|
+
error.name = "AbortError";
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function throwIfStudioSaveAborted(signal?: AbortSignal): void {
|
|
50
|
+
if (signal?.aborted) throw createStudioSaveAbortError();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function attachStudioSaveAttempt(error: unknown, attempt: number): unknown {
|
|
54
|
+
if (!error || typeof error !== "object") return error;
|
|
55
|
+
try {
|
|
56
|
+
Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
|
|
57
|
+
value: attempt,
|
|
58
|
+
configurable: true,
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
// Best-effort diagnostic only.
|
|
62
|
+
}
|
|
63
|
+
return error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getStudioSaveErrorMessage(error: unknown): string {
|
|
67
|
+
if (error instanceof Error && error.message) return error.message;
|
|
68
|
+
if (typeof error === "string" && error.trim()) return error;
|
|
69
|
+
return "Unknown save failure";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getStudioSaveStatusCode(error: unknown): number | undefined {
|
|
73
|
+
if (!error || typeof error !== "object") return undefined;
|
|
74
|
+
const direct =
|
|
75
|
+
readNumericProperty(error, "statusCode") ??
|
|
76
|
+
readNumericProperty(error, "status") ??
|
|
77
|
+
readNumericProperty(error, "status_code");
|
|
78
|
+
if (direct != null) return direct;
|
|
79
|
+
|
|
80
|
+
const cause = (error as { cause?: unknown }).cause;
|
|
81
|
+
if (cause && cause !== error) return getStudioSaveStatusCode(cause);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getStudioSaveAttempt(error: unknown): number | undefined {
|
|
86
|
+
if (!error || typeof error !== "object") return undefined;
|
|
87
|
+
const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
|
|
88
|
+
if (direct != null) return direct;
|
|
89
|
+
|
|
90
|
+
const cause = (error as { cause?: unknown }).cause;
|
|
91
|
+
if (cause && cause !== error) return getStudioSaveAttempt(cause);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isStudioSaveAbortError(error: unknown): boolean {
|
|
96
|
+
return error instanceof Error && error.name === "AbortError";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isRetryableStudioSaveError(error: unknown): boolean {
|
|
100
|
+
if (isStudioSaveAbortError(error)) return false;
|
|
101
|
+
if (error instanceof StudioSaveNetworkError) return true;
|
|
102
|
+
const statusCode = getStudioSaveStatusCode(error);
|
|
103
|
+
if (statusCode == null) return false;
|
|
104
|
+
return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildStudioSaveFailureProperties(
|
|
108
|
+
input: StudioSaveFailureInput,
|
|
109
|
+
): Record<string, StudioTelemetryValue> {
|
|
110
|
+
const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
|
|
111
|
+
const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? undefined;
|
|
112
|
+
return {
|
|
113
|
+
source: input.source,
|
|
114
|
+
error_message: getStudioSaveErrorMessage(input.error),
|
|
115
|
+
status_code: statusCode,
|
|
116
|
+
file_path: input.filePath ?? input.targetSourceFile ?? undefined,
|
|
117
|
+
mutation_type: input.mutationType ?? undefined,
|
|
118
|
+
attempt,
|
|
119
|
+
label: input.label ?? undefined,
|
|
120
|
+
target_id: input.targetId ?? undefined,
|
|
121
|
+
target_selector: input.targetSelector ?? undefined,
|
|
122
|
+
target_source_file: input.targetSourceFile ?? undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
|
|
127
|
+
trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function createStudioSaveHttpError(
|
|
131
|
+
response: Response,
|
|
132
|
+
fallbackMessage: string,
|
|
133
|
+
): Promise<StudioSaveHttpError> {
|
|
134
|
+
let body = "";
|
|
135
|
+
try {
|
|
136
|
+
body = await response.text();
|
|
137
|
+
} catch {
|
|
138
|
+
body = "";
|
|
139
|
+
}
|
|
140
|
+
const detail = body.trim().slice(0, 300);
|
|
141
|
+
const message = detail
|
|
142
|
+
? `${fallbackMessage} (${response.status}): ${detail}`
|
|
143
|
+
: `${fallbackMessage} (${response.status})`;
|
|
144
|
+
return new StudioSaveHttpError(message, response.status);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function retryStudioSave<T>(
|
|
148
|
+
operation: (attempt: number) => Promise<T>,
|
|
149
|
+
options: {
|
|
150
|
+
retries?: number;
|
|
151
|
+
baseDelayMs?: number;
|
|
152
|
+
maxDelayMs?: number;
|
|
153
|
+
jitterRatio?: number;
|
|
154
|
+
random?: () => number;
|
|
155
|
+
signal?: AbortSignal;
|
|
156
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
157
|
+
sleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
158
|
+
} = {},
|
|
159
|
+
): Promise<T> {
|
|
160
|
+
const retries = options.retries ?? 3;
|
|
161
|
+
const baseDelayMs = options.baseDelayMs ?? 500;
|
|
162
|
+
const maxDelayMs = options.maxDelayMs ?? 8000;
|
|
163
|
+
const jitterRatio = options.jitterRatio ?? 0.25;
|
|
164
|
+
const random = options.random ?? Math.random;
|
|
165
|
+
const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
|
|
166
|
+
const sleep =
|
|
167
|
+
options.sleep ??
|
|
168
|
+
((delayMs: number, signal?: AbortSignal) =>
|
|
169
|
+
new Promise<void>((resolve, reject) => {
|
|
170
|
+
throwIfStudioSaveAborted(signal);
|
|
171
|
+
const onAbort = () => {
|
|
172
|
+
globalThis.clearTimeout(timeout);
|
|
173
|
+
reject(createStudioSaveAbortError());
|
|
174
|
+
};
|
|
175
|
+
const timeout = globalThis.setTimeout(() => {
|
|
176
|
+
signal?.removeEventListener("abort", onAbort);
|
|
177
|
+
resolve();
|
|
178
|
+
}, delayMs);
|
|
179
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
180
|
+
}));
|
|
181
|
+
const maxAttempts = retries + 1;
|
|
182
|
+
|
|
183
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
throwIfStudioSaveAborted(options.signal);
|
|
186
|
+
return await operation(attempt);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const failure = attachStudioSaveAttempt(error, attempt);
|
|
189
|
+
if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
|
|
190
|
+
const retryIndex = attempt - 1;
|
|
191
|
+
const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
|
|
192
|
+
const jitterSpan = exponentialDelay * jitterRatio;
|
|
193
|
+
const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
|
|
194
|
+
const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
|
|
195
|
+
await sleep(delayMs, options.signal);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw new Error("Save retry loop exited unexpectedly");
|
|
200
|
+
}
|
|
@@ -159,7 +159,6 @@ describe("studio url state", () => {
|
|
|
159
159
|
it("normalizes url tabs against feature flags", () => {
|
|
160
160
|
expect(normalizeStudioUrlPanelTab("renders")).toBe("renders");
|
|
161
161
|
expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders");
|
|
162
|
-
expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design");
|
|
163
162
|
});
|
|
164
163
|
|
|
165
164
|
it("hydrates seek first, preserves the initial url state, then restores selection", async () => {
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import type { RightPanelTab } from "./studioHelpers";
|
|
2
2
|
import { buildProjectHash, parseProjectHashRoute } from "./projectRouting";
|
|
3
|
-
import {
|
|
4
|
-
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
5
|
-
STUDIO_MOTION_PANEL_ENABLED,
|
|
6
|
-
} from "../components/editor/manualEditingAvailability";
|
|
3
|
+
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
7
4
|
|
|
8
5
|
export interface StudioUrlSelectionState {
|
|
9
6
|
sourceFile?: string;
|
|
@@ -21,22 +18,19 @@ export interface StudioUrlState {
|
|
|
21
18
|
selection: StudioUrlSelectionState | null;
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
const VALID_TABS: RightPanelTab[] = ["layers", "design", "
|
|
21
|
+
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders"];
|
|
25
22
|
|
|
26
23
|
export function normalizeStudioUrlPanelTab(
|
|
27
24
|
tab: RightPanelTab | null,
|
|
28
25
|
options: {
|
|
29
26
|
inspectorPanelsEnabled?: boolean;
|
|
30
|
-
motionPanelEnabled?: boolean;
|
|
31
27
|
} = {},
|
|
32
28
|
): RightPanelTab | null {
|
|
33
29
|
if (!tab) return null;
|
|
34
30
|
if (!VALID_TABS.includes(tab)) return null;
|
|
35
31
|
const inspectorPanelsEnabled = options.inspectorPanelsEnabled ?? STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
36
|
-
const motionPanelEnabled = options.motionPanelEnabled ?? STUDIO_MOTION_PANEL_ENABLED;
|
|
37
32
|
|
|
38
33
|
if (!inspectorPanelsEnabled && tab !== "renders") return "renders";
|
|
39
|
-
if (tab === "motion" && !motionPanelEnabled) return "design";
|
|
40
34
|
return tab;
|
|
41
35
|
}
|
|
42
36
|
|