@hyperframes/studio 0.7.100 → 0.7.102

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.100",
3
+ "version": "0.7.102",
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.100",
51
- "@hyperframes/player": "0.7.100",
52
- "@hyperframes/sdk": "0.7.100",
53
- "@hyperframes/studio-server": "0.7.100",
54
- "@hyperframes/parsers": "0.7.100"
50
+ "@hyperframes/core": "0.7.102",
51
+ "@hyperframes/parsers": "0.7.102",
52
+ "@hyperframes/sdk": "0.7.102",
53
+ "@hyperframes/player": "0.7.102",
54
+ "@hyperframes/studio-server": "0.7.102"
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.100"
70
+ "@hyperframes/producer": "0.7.102"
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
- function getPersistedTab(): SidebarTab {
27
- const stored = localStorage.getItem(STORAGE_KEY);
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
- localStorage.setItem(STORAGE_KEY, t);
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
- /** Every cache key a write for this element sets, in read-preference order. */
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#${elementId}`, elementId]
233
- : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
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(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
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
- // PropertyPanel reads the cache by bare elementId (without sourceFile
318
- // prefix), so the same entry is written under the bare key for
319
- // cross-component lookups. Both keys land in one publish: a reader that woke
320
- // between two separate writes saw the prefixed key updated and the bare one
321
- // still stale.
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
- draft.keyframeCache.set(`${sourceFile}#${elementId}`, merged);
324
- draft.keyframeCache.set(elementId, merged);
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 cacheKey = `${sf}#${id}`;
430
- const fallbackKey = `index.html#${id}`;
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(cacheKey, entry);
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
@@ -26,7 +26,7 @@ function copyWithSelection(text: string): boolean {
26
26
  } catch {
27
27
  return false;
28
28
  } finally {
29
- document.body.removeChild(textarea);
29
+ textarea.remove();
30
30
  }
31
31
  }
32
32