@hyperframes/studio 0.7.100 → 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-D0x4bQn-.js → hyperframes-player-BxSY8bs4.js} +1 -1
- package/dist/assets/{index-Cy1eXOyE.js → index-BlRPDw0A.js} +1 -1
- package/dist/assets/{index-CVAZ9AJE.js → index-DF06yGPO.js} +117 -117
- package/dist/assets/{index-F8iVj3EF.js → index-Dkz5RbFX.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +33 -15
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/sidebar/LeftSidebar.storage.test.ts +28 -0
- package/src/components/sidebar/LeftSidebar.tsx +18 -3
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +47 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +31 -3
- package/src/hooks/useGsapTweenCache.ts +16 -19
- 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/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/core": "0.7.
|
|
51
|
-
"@hyperframes/player": "0.7.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
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",
|
|
@@ -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
|
|
|
@@ -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. */
|
|
@@ -4,6 +4,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
4
4
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
5
5
|
import {
|
|
6
6
|
clearKeyframeCacheForElement,
|
|
7
|
+
elementCacheKeys,
|
|
7
8
|
pruneKeyframeCacheToFiles,
|
|
8
9
|
publishKeyframeCache,
|
|
9
10
|
writeGsapAnimationsForElement,
|
|
@@ -302,8 +303,9 @@ export function useGsapAnimationsForElement(
|
|
|
302
303
|
// scan already cached. Only clear when no source cached this element —
|
|
303
304
|
// otherwise selecting it would wipe its diamonds.
|
|
304
305
|
const { keyframeCache } = usePlayerStore.getState();
|
|
305
|
-
const hasCached =
|
|
306
|
-
keyframeCache.has(
|
|
306
|
+
const hasCached = elementCacheKeys(sourceFile, elementId).some((key) =>
|
|
307
|
+
keyframeCache.has(key),
|
|
308
|
+
);
|
|
307
309
|
if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
|
|
308
310
|
return;
|
|
309
311
|
}
|
|
@@ -314,14 +316,16 @@ export function useGsapAnimationsForElement(
|
|
|
314
316
|
...(ease ? { ease } : {}),
|
|
315
317
|
...(easeEach ? { easeEach } : {}),
|
|
316
318
|
};
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
319
|
+
// elementCacheKeys owns the key-variant list every writer sets (prefixed,
|
|
320
|
+
// index.html fallback, bare id). Building it by hand here is what let this
|
|
321
|
+
// site drift: it omitted the fallback key, and it wrote the bare id without
|
|
322
|
+
// the string coercion that keeps prune from throwing on a non-string. All
|
|
323
|
+
// keys land in one publish: a reader that woke between two separate writes
|
|
324
|
+
// saw the prefixed key updated and the bare one still stale.
|
|
322
325
|
publishKeyframeCache((draft) => {
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
|
327
|
+
draft.keyframeCache.set(key, merged);
|
|
328
|
+
}
|
|
325
329
|
});
|
|
326
330
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
327
331
|
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
|
@@ -426,13 +430,8 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
426
430
|
// in between re-rendered against a cache only partly filled in.
|
|
427
431
|
publishKeyframeCache((draft) => {
|
|
428
432
|
for (const [id, data] of scanned) {
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
const alreadyCached =
|
|
432
|
-
draft.keyframeCache.has(cacheKey) ||
|
|
433
|
-
draft.keyframeCache.has(fallbackKey) ||
|
|
434
|
-
draft.keyframeCache.has(id);
|
|
435
|
-
if (alreadyCached) continue;
|
|
433
|
+
const keys = elementCacheKeys(sf, id);
|
|
434
|
+
if (keys.some((key) => draft.keyframeCache.has(key))) continue;
|
|
436
435
|
// Skip position-only set tweens from runtime too, same filter as AST path
|
|
437
436
|
const isPosOnly =
|
|
438
437
|
data.keyframes.length === 1 &&
|
|
@@ -445,9 +444,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
445
444
|
keyframes: data.keyframes,
|
|
446
445
|
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
447
446
|
};
|
|
448
|
-
draft.keyframeCache.set(
|
|
449
|
-
if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
|
|
450
|
-
draft.keyframeCache.set(id, entry);
|
|
447
|
+
for (const key of keys) draft.keyframeCache.set(key, entry);
|
|
451
448
|
}
|
|
452
449
|
});
|
|
453
450
|
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
|
@@ -100,6 +100,19 @@ describe("preview errors", () => {
|
|
|
100
100
|
);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("unmounts cleanly when the player element is already detached", async () => {
|
|
104
|
+
const { player } = await mountPlayer();
|
|
105
|
+
|
|
106
|
+
// A container re-render, a crossfade swap, or a page-translation extension
|
|
107
|
+
// can detach the element before React tears the Player down. Cleanup must
|
|
108
|
+
// not throw NotFoundError — the error boundary turns that into a
|
|
109
|
+
// full-screen "Something went wrong".
|
|
110
|
+
player.remove();
|
|
111
|
+
|
|
112
|
+
expect(() => act(() => root?.unmount())).not.toThrow();
|
|
113
|
+
root = null;
|
|
114
|
+
});
|
|
115
|
+
|
|
103
116
|
it("attaches lifecycle listeners before navigating the player", async () => {
|
|
104
117
|
await mountPlayer();
|
|
105
118
|
const srcIndex = lifecycleLog.indexOf("src");
|
|
@@ -295,7 +295,13 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
295
295
|
player.removeEventListener("error", handleError);
|
|
296
296
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
297
297
|
assetPollRef.current = null;
|
|
298
|
-
container.removeChild(player)
|
|
298
|
+
// `remove()` rather than `container.removeChild(player)`: by the time
|
|
299
|
+
// this cleanup runs the element may already be detached — React can
|
|
300
|
+
// re-render the container, a crossfade refresh can swap it, or a
|
|
301
|
+
// translation/extension can reparent it. `removeChild` then throws
|
|
302
|
+
// NotFoundError, which the error boundary turns into a full-screen
|
|
303
|
+
// "Something went wrong". `remove()` is a no-op when already detached.
|
|
304
|
+
player.remove();
|
|
299
305
|
if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
|
|
300
306
|
// Clear the forwarded ref only if it still points to THIS iframe.
|
|
301
307
|
// During crossfade refreshes the retiring Player unmounts after the
|