@hyperframes/studio 0.7.99 → 0.7.101
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-BY5RLMBA.js → hyperframes-player-BxSY8bs4.js} +1 -1
- package/dist/assets/{index-CQ07_DLG.js → index-BlRPDw0A.js} +1 -1
- package/dist/assets/{index-DMSqmZM6.js → index-DF06yGPO.js} +200 -200
- package/dist/assets/{index-DeXLAktv.js → index-Dkz5RbFX.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +473 -359
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -2
- package/src/components/StudioHeader.test.ts +28 -0
- package/src/components/StudioHeader.tsx +21 -2
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/nle/NLEContext.tsx +16 -7
- package/src/components/nle/TimelineResizeDivider.tsx +4 -11
- package/src/components/sidebar/LeftSidebar.storage.test.ts +28 -0
- package/src/components/sidebar/LeftSidebar.tsx +18 -3
- package/src/contexts/PanelLayoutContext.tsx +6 -3
- package/src/hooks/gsapEditOutcome.ts +19 -1
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +47 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +31 -3
- package/src/hooks/gsapResizeGeometrySweep.test.ts +289 -0
- package/src/hooks/gsapResizeIntercept.test.ts +2 -2
- package/src/hooks/gsapResizeIntercept.ts +37 -14
- package/src/hooks/gsapResizeMixedTween.test.ts +168 -0
- package/src/hooks/gsapResizeSweep.test.ts +274 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +19 -2
- package/src/hooks/useGsapAwareEditing.ts +13 -5
- package/src/hooks/useGsapTweenCache.ts +16 -19
- package/src/hooks/usePanelLayout.test.ts +117 -0
- package/src/hooks/usePanelLayout.ts +121 -39
- package/src/player/components/Player.test.ts +13 -0
- package/src/player/components/Player.tsx +7 -1
- package/src/utils/clipboard.ts +1 -1
- package/src/utils/fitPanels.test.ts +164 -0
- package/src/utils/fitPanels.ts +151 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.101",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/studio-server": "0.7.
|
|
50
|
+
"@hyperframes/core": "0.7.101",
|
|
51
|
+
"@hyperframes/player": "0.7.101",
|
|
52
|
+
"@hyperframes/parsers": "0.7.101",
|
|
53
|
+
"@hyperframes/sdk": "0.7.101",
|
|
54
|
+
"@hyperframes/studio-server": "0.7.101"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"vite": "^6.4.2",
|
|
68
68
|
"vitest": "^3.2.4",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@hyperframes/producer": "0.7.
|
|
70
|
+
"@hyperframes/producer": "0.7.101"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -399,7 +399,7 @@ export function StudioApp() {
|
|
|
399
399
|
} = useInspectorState(
|
|
400
400
|
panelLayout.rightPanelTab,
|
|
401
401
|
panelLayout.rightInspectorPanes,
|
|
402
|
-
panelLayout.
|
|
402
|
+
panelLayout.effectiveRightCollapsed,
|
|
403
403
|
isPlaying,
|
|
404
404
|
domEditSession.domEditSelection,
|
|
405
405
|
gestureState === "recording",
|
|
@@ -512,7 +512,7 @@ export function StudioApp() {
|
|
|
512
512
|
/>
|
|
513
513
|
}
|
|
514
514
|
right={
|
|
515
|
-
panelLayout.
|
|
515
|
+
panelLayout.effectiveRightCollapsed ? null : (
|
|
516
516
|
<StudioRightPanel
|
|
517
517
|
designPanelActive={designPanelActive}
|
|
518
518
|
activeBlockParams={activeBlockParams}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldOpenInspector } from "./StudioHeader";
|
|
3
|
+
|
|
4
|
+
describe("shouldOpenInspector", () => {
|
|
5
|
+
it("opens when the panel is hidden", () => {
|
|
6
|
+
expect(shouldOpenInspector(true, false)).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("opens when a non-inspector tab is showing", () => {
|
|
10
|
+
expect(shouldOpenInspector(false, false)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("closes when the inspector is genuinely on screen", () => {
|
|
14
|
+
expect(shouldOpenInspector(false, true)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("opens when the window railed the panel away", () => {
|
|
18
|
+
// The regression this guards: the button used to branch on the raw
|
|
19
|
+
// rightCollapsed intent, which is still `false` while the window has the
|
|
20
|
+
// panel railed. That took the close branch, wrote rightCollapsed=true, and
|
|
21
|
+
// since that value is synced into the shareable Studio URL, a click that
|
|
22
|
+
// did nothing visible rewrote the link.
|
|
23
|
+
const userIntentIsOpen = false;
|
|
24
|
+
const windowRailedItAway = true;
|
|
25
|
+
expect(shouldOpenInspector(windowRailedItAway, true)).toBe(true);
|
|
26
|
+
expect(shouldOpenInspector(userIntentIsOpen, true)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -196,6 +196,21 @@ export function ViewModeToggle() {
|
|
|
196
196
|
);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Does the header's Inspector button open the panel, or close it?
|
|
201
|
+
*
|
|
202
|
+
* Takes the EFFECTIVE collapse state, so a panel the window has railed away
|
|
203
|
+
* counts as closed even though the user's stored intent still says open. The
|
|
204
|
+
* argument name is the guard: passing raw intent here is the bug this exists
|
|
205
|
+
* to keep out.
|
|
206
|
+
*/
|
|
207
|
+
export function shouldOpenInspector(
|
|
208
|
+
effectiveRightCollapsed: boolean,
|
|
209
|
+
inspectorPanelActive: boolean,
|
|
210
|
+
): boolean {
|
|
211
|
+
return effectiveRightCollapsed || !inspectorPanelActive;
|
|
212
|
+
}
|
|
213
|
+
|
|
199
214
|
// fallow-ignore-next-line complexity
|
|
200
215
|
export function StudioHeader({
|
|
201
216
|
captureFrameHref,
|
|
@@ -208,7 +223,11 @@ export function StudioHeader({
|
|
|
208
223
|
onExport,
|
|
209
224
|
}: StudioHeaderProps) {
|
|
210
225
|
const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
|
|
211
|
-
|
|
226
|
+
// effectiveRightCollapsed, not the raw intent: in the auto-railed state the
|
|
227
|
+
// intent is still "open" while the panel is hidden, so branching on intent
|
|
228
|
+
// made this button write rightCollapsed=true — and that value is synced into
|
|
229
|
+
// the shareable Studio URL, so a dead click would rewrite a link.
|
|
230
|
+
const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
|
212
231
|
const isRendering = renderQueue.isRendering;
|
|
213
232
|
|
|
214
233
|
return (
|
|
@@ -328,7 +347,7 @@ export function StudioHeader({
|
|
|
328
347
|
<button
|
|
329
348
|
type="button"
|
|
330
349
|
onClick={() => {
|
|
331
|
-
if (
|
|
350
|
+
if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
|
|
332
351
|
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
|
333
352
|
setRightPanelTab("design");
|
|
334
353
|
setRightCollapsed(false);
|
|
@@ -36,7 +36,7 @@ export function StudioLeftSidebar({
|
|
|
36
36
|
onAddCompositionToTimeline,
|
|
37
37
|
}: StudioLeftSidebarProps) {
|
|
38
38
|
const {
|
|
39
|
-
|
|
39
|
+
effectiveLeftCollapsed,
|
|
40
40
|
leftWidth,
|
|
41
41
|
adjustPanelWidth,
|
|
42
42
|
toggleLeftSidebar,
|
|
@@ -71,7 +71,7 @@ export function StudioLeftSidebar({
|
|
|
71
71
|
[renderQueue, waitForPendingDomEditSaves],
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
-
if (
|
|
74
|
+
if (effectiveLeftCollapsed) {
|
|
75
75
|
return (
|
|
76
76
|
<div className="mr-0.5 flex w-10 flex-shrink-0 flex-col items-center rounded-lg border border-neutral-800/50 bg-neutral-950 pt-1">
|
|
77
77
|
<button
|
|
@@ -11,7 +11,7 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
|
|
|
11
11
|
import type { TimelineElement } from "../../player";
|
|
12
12
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
13
13
|
import { useCompositionStack } from "./useCompositionStack";
|
|
14
|
-
import { MIN_TIMELINE_H,
|
|
14
|
+
import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
|
15
15
|
import { setCompositionSourceMap } from "../editor/domEditingDom";
|
|
16
16
|
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
|
17
17
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
@@ -273,13 +273,22 @@ export function NLEProvider({
|
|
|
273
273
|
}, []);
|
|
274
274
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
275
275
|
// A height persisted on a tall window can exceed this window's container and
|
|
276
|
-
// collapse the flex-1 preview to 0px
|
|
277
|
-
//
|
|
276
|
+
// collapse the flex-1 preview to 0px. Observing the container rather than
|
|
277
|
+
// clamping once at mount is what makes a window RESIZED after load behave the
|
|
278
|
+
// same as one loaded at that size: dragging 760 -> 520 tall used to leave the
|
|
279
|
+
// timeline at its stored 429px and the preview at 47px.
|
|
278
280
|
useEffect(() => {
|
|
279
|
-
const
|
|
280
|
-
if (!
|
|
281
|
-
const
|
|
282
|
-
|
|
281
|
+
const element = containerRef.current;
|
|
282
|
+
if (!element || typeof ResizeObserver === "undefined") return;
|
|
283
|
+
const reconcile = () => {
|
|
284
|
+
const containerH = element.getBoundingClientRect().height;
|
|
285
|
+
if (!containerH) return;
|
|
286
|
+
setTimelineH((prev) => fitTimelineHeight(containerH, prev));
|
|
287
|
+
};
|
|
288
|
+
reconcile();
|
|
289
|
+
const observer = new ResizeObserver(reconcile);
|
|
290
|
+
observer.observe(element);
|
|
291
|
+
return () => observer.disconnect();
|
|
283
292
|
}, []);
|
|
284
293
|
|
|
285
294
|
const hasLoadedOnceRef = useRef(false);
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
|
|
3
|
-
export const MIN_TIMELINE_H = 100;
|
|
4
|
-
export const MIN_PREVIEW_H = 120;
|
|
2
|
+
import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Horizontal drag/keyboard-resizable divider between the preview and the
|
|
@@ -41,12 +39,7 @@ export function TimelineResizeDivider({
|
|
|
41
39
|
if (!isDragging.current || !containerRef.current) return;
|
|
42
40
|
const rect = containerRef.current.getBoundingClientRect();
|
|
43
41
|
const mouseY = e.clientY - rect.top;
|
|
44
|
-
|
|
45
|
-
const newTimelineH = Math.max(
|
|
46
|
-
MIN_TIMELINE_H,
|
|
47
|
-
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
48
|
-
);
|
|
49
|
-
setTimelineH(newTimelineH);
|
|
42
|
+
setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
|
|
50
43
|
},
|
|
51
44
|
[disabled, containerRef, setTimelineH],
|
|
52
45
|
);
|
|
@@ -61,10 +54,10 @@ export function TimelineResizeDivider({
|
|
|
61
54
|
if (disabled) return;
|
|
62
55
|
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
63
56
|
e.preventDefault();
|
|
64
|
-
const containerH = containerRef.current?.getBoundingClientRect().height ??
|
|
57
|
+
const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
|
|
65
58
|
const delta = e.key === "ArrowUp" ? 16 : -16;
|
|
66
59
|
setTimelineH((prev) => {
|
|
67
|
-
const next =
|
|
60
|
+
const next = fitTimelineHeight(containerH, prev + delta);
|
|
68
61
|
persistTimelineH(next);
|
|
69
62
|
return next;
|
|
70
63
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { getPersistedTab } from "./LeftSidebar";
|
|
5
|
+
|
|
6
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (originalDescriptor) Object.defineProperty(globalThis, "localStorage", originalDescriptor);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("falls back to the default tab when localStorage is blocked", () => {
|
|
15
|
+
// Chrome throws on the property read itself when site data is blocked for
|
|
16
|
+
// the document. This runs as a useState initializer, so throwing here takes
|
|
17
|
+
// Studio to the crash boundary.
|
|
18
|
+
Object.defineProperty(globalThis, "localStorage", {
|
|
19
|
+
configurable: true,
|
|
20
|
+
get() {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"Failed to read the 'localStorage' property from 'Window': Access is denied for this document.",
|
|
23
|
+
);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(getPersistedTab()).toBe("compositions");
|
|
28
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { CompositionsTab } from "./CompositionsTab";
|
|
11
11
|
import { AssetsTab } from "./AssetsTab";
|
|
12
12
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
13
|
+
import { safeLocalStorage } from "../../utils/safeStorage";
|
|
13
14
|
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
|
|
14
15
|
import { FileTree } from "../editor/FileTree";
|
|
15
16
|
import { Tooltip } from "../ui";
|
|
@@ -23,8 +24,18 @@ export interface LeftSidebarHandle {
|
|
|
23
24
|
|
|
24
25
|
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
// Both the `localStorage` reference and `getItem` itself can throw when the
|
|
28
|
+
// browsing context is partitioned or site data is blocked — the same case
|
|
29
|
+
// telemetry/config.ts documents. This runs as a `useState` initializer, so an
|
|
30
|
+
// unguarded throw here takes the whole editor to the crash boundary rather
|
|
31
|
+
// than losing one remembered tab.
|
|
32
|
+
export function getPersistedTab(): SidebarTab {
|
|
33
|
+
let stored: string | null = null;
|
|
34
|
+
try {
|
|
35
|
+
stored = safeLocalStorage()?.getItem(STORAGE_KEY) ?? null;
|
|
36
|
+
} catch {
|
|
37
|
+
/* storage unavailable — fall back to the default tab */
|
|
38
|
+
}
|
|
28
39
|
if (stored === "assets") return "assets";
|
|
29
40
|
if (stored === "code") return "code";
|
|
30
41
|
if (stored === "blocks") return "blocks";
|
|
@@ -104,7 +115,11 @@ export const LeftSidebar = memo(
|
|
|
104
115
|
|
|
105
116
|
const selectTab = useCallback((t: SidebarTab) => {
|
|
106
117
|
setTab(t);
|
|
107
|
-
|
|
118
|
+
try {
|
|
119
|
+
safeLocalStorage()?.setItem(STORAGE_KEY, t);
|
|
120
|
+
} catch {
|
|
121
|
+
/* storage unavailable — the tab just won't be remembered */
|
|
122
|
+
}
|
|
108
123
|
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
|
|
109
124
|
}, []);
|
|
110
125
|
|
|
@@ -17,9 +17,10 @@ export function PanelLayoutProvider({
|
|
|
17
17
|
rightWidth,
|
|
18
18
|
adjustPanelWidth,
|
|
19
19
|
leftCollapsed,
|
|
20
|
-
setLeftCollapsed,
|
|
21
20
|
rightCollapsed,
|
|
22
21
|
setRightCollapsed,
|
|
22
|
+
effectiveLeftCollapsed,
|
|
23
|
+
effectiveRightCollapsed,
|
|
23
24
|
rightPanelTab,
|
|
24
25
|
setRightPanelTab,
|
|
25
26
|
rightInspectorPanes,
|
|
@@ -41,9 +42,10 @@ export function PanelLayoutProvider({
|
|
|
41
42
|
rightWidth,
|
|
42
43
|
adjustPanelWidth,
|
|
43
44
|
leftCollapsed,
|
|
44
|
-
setLeftCollapsed,
|
|
45
45
|
rightCollapsed,
|
|
46
46
|
setRightCollapsed,
|
|
47
|
+
effectiveLeftCollapsed,
|
|
48
|
+
effectiveRightCollapsed,
|
|
47
49
|
rightPanelTab,
|
|
48
50
|
setRightPanelTab,
|
|
49
51
|
rightInspectorPanes,
|
|
@@ -59,9 +61,10 @@ export function PanelLayoutProvider({
|
|
|
59
61
|
rightWidth,
|
|
60
62
|
adjustPanelWidth,
|
|
61
63
|
leftCollapsed,
|
|
62
|
-
setLeftCollapsed,
|
|
63
64
|
rightCollapsed,
|
|
64
65
|
setRightCollapsed,
|
|
66
|
+
effectiveLeftCollapsed,
|
|
67
|
+
effectiveRightCollapsed,
|
|
65
68
|
rightPanelTab,
|
|
66
69
|
setRightPanelTab,
|
|
67
70
|
rightInspectorPanes,
|
|
@@ -3,7 +3,25 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
|
|
|
3
3
|
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
|
|
4
4
|
|
|
5
5
|
export type GsapEditOutcome =
|
|
6
|
-
| {
|
|
6
|
+
| {
|
|
7
|
+
status: "persisted";
|
|
8
|
+
/**
|
|
9
|
+
* Whether this edit already accounted for where the gesture left the
|
|
10
|
+
* element, so the caller must not persist the drag offset on top.
|
|
11
|
+
*
|
|
12
|
+
* The scale route needs it: a committed scale renders around the element
|
|
13
|
+
* centre rather than the dragged corner, so it measures the difference
|
|
14
|
+
* and writes the position itself. Every other route moves nothing the
|
|
15
|
+
* caller has not already been told about, and the caller owns the offset.
|
|
16
|
+
*
|
|
17
|
+
* It has to be reported rather than inferred. The caller used to guess
|
|
18
|
+
* from "does this element have a scale-group tween", which is true for an
|
|
19
|
+
* element whose scale is an instant hold — but that resize commits
|
|
20
|
+
* width/height, not scale, so the guess withheld an offset nobody wrote
|
|
21
|
+
* and the element snapped back to its authored position on every drag.
|
|
22
|
+
*/
|
|
23
|
+
ownsDragOffset?: boolean;
|
|
24
|
+
}
|
|
7
25
|
| { status: "blocked"; reason: GsapEditBlockReason };
|
|
8
26
|
|
|
9
27
|
const COPY: Record<GsapEditBlockReason, string> = {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
|
4
4
|
import {
|
|
5
5
|
clearKeyframeCacheForElement,
|
|
6
|
+
elementCacheKeys,
|
|
6
7
|
pruneKeyframeCacheToFiles,
|
|
7
8
|
replaceKeyframeCacheForFile,
|
|
8
9
|
updateKeyframeCacheFromParsed,
|
|
9
10
|
} from "./gsapKeyframeCacheHelpers";
|
|
11
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
12
|
+
|
|
13
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
10
14
|
|
|
11
15
|
const entry = (): KeyframeCacheEntry => ({
|
|
12
16
|
format: "percentage",
|
|
@@ -30,6 +34,48 @@ const animWithKeyframes = (id: string): GsapAnimation => ({
|
|
|
30
34
|
|
|
31
35
|
beforeEach(() => {
|
|
32
36
|
usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] });
|
|
37
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("non-string cache keys", () => {
|
|
41
|
+
// `s.indexOf is not a function` in pruneKeyframeCacheToFiles, decoded from the
|
|
42
|
+
// released 0.7.90 bundle. Some producer reaches elementCacheKeys with a
|
|
43
|
+
// non-string id; the bare-id key was written through raw, so both maps ended
|
|
44
|
+
// up holding a key that prune's `key.indexOf("#")` cannot handle.
|
|
45
|
+
const badId = 42 as unknown as string;
|
|
46
|
+
|
|
47
|
+
it("keeps every written key a string", () => {
|
|
48
|
+
expect(elementCacheKeys("comp.html", badId).every((k) => typeof k === "string")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reports the offending value instead of swallowing it", () => {
|
|
52
|
+
elementCacheKeys("comp.html", badId);
|
|
53
|
+
|
|
54
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
55
|
+
"cache_key_non_string",
|
|
56
|
+
expect.objectContaining({
|
|
57
|
+
value_type: "number",
|
|
58
|
+
constructor_name: "Number",
|
|
59
|
+
source_file: "comp.html",
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("stays silent on the normal string path", () => {
|
|
65
|
+
elementCacheKeys("comp.html", "box");
|
|
66
|
+
|
|
67
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("survives a prune after a write with a non-string id", () => {
|
|
71
|
+
replaceKeyframeCacheForFile(
|
|
72
|
+
"stale.html",
|
|
73
|
+
new Map([[badId, entry()]]),
|
|
74
|
+
new Map([[badId, [animWithKeyframes("box")]]]),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(() => pruneKeyframeCacheToFiles(["kept.html"])).not.toThrow();
|
|
78
|
+
});
|
|
33
79
|
});
|
|
34
80
|
|
|
35
81
|
describe("clearKeyframeCacheForElement", () => {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
|
7
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
8
|
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
|
8
9
|
import {
|
|
9
10
|
deduplicateKeyframes,
|
|
@@ -226,11 +227,38 @@ export function scopedElementKey(element: {
|
|
|
226
227
|
return `${element.sourceFile || "index.html"}#${element.id}`;
|
|
227
228
|
}
|
|
228
229
|
|
|
229
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* The one gate every cache write passes through, so it is also the one place
|
|
232
|
+
* that can guarantee `keyframeCache` / `gsapAnimations` really are keyed by
|
|
233
|
+
* string the way their types claim.
|
|
234
|
+
*
|
|
235
|
+
* Two of the three keys are template literals, which coerce on their own. The
|
|
236
|
+
* bare-id key was passed through raw, so a non-string `elementId` reaching here
|
|
237
|
+
* put a non-string key in both maps — and `pruneKeyframeCacheToFiles` then threw
|
|
238
|
+
* `s.indexOf is not a function` on it, taking Studio to the crash boundary.
|
|
239
|
+
*
|
|
240
|
+
* Which caller supplies a non-string id is still unknown: every writer traced
|
|
241
|
+
* from here produces a string. So this coerces rather than guesses, and reports
|
|
242
|
+
* the offending value's shape instead of swallowing it — the next occurrence
|
|
243
|
+
* names its own producer.
|
|
244
|
+
*/
|
|
230
245
|
export function elementCacheKeys(sourceFile: string, elementId: string): string[] {
|
|
246
|
+
const id = typeof elementId === "string" ? elementId : coerceCacheKeyId(elementId, sourceFile);
|
|
231
247
|
return sourceFile === "index.html"
|
|
232
|
-
? [`index.html#${
|
|
233
|
-
: [`${sourceFile}#${
|
|
248
|
+
? [`index.html#${id}`, id]
|
|
249
|
+
: [`${sourceFile}#${id}`, `index.html#${id}`, id];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function coerceCacheKeyId(elementId: unknown, sourceFile: string): string {
|
|
253
|
+
trackStudioEvent("cache_key_non_string", {
|
|
254
|
+
value_type: typeof elementId,
|
|
255
|
+
// An Element lands here as "HTMLDivElement", a boxed id as "Number" — enough
|
|
256
|
+
// to name the producer without shipping user content to telemetry.
|
|
257
|
+
constructor_name: (elementId as { constructor?: { name?: string } })?.constructor?.name ?? null,
|
|
258
|
+
is_array: Array.isArray(elementId),
|
|
259
|
+
source_file: sourceFile,
|
|
260
|
+
});
|
|
261
|
+
return String(elementId);
|
|
234
262
|
}
|
|
235
263
|
|
|
236
264
|
/** Replace one file's complete cache snapshot with one atomic store publish. */
|