@hyperframes/studio 0.8.61 → 0.8.62

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.8.61",
3
+ "version": "0.8.62",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/parsers": "0.8.61",
55
- "@hyperframes/sdk": "0.8.61",
56
- "@hyperframes/player": "0.8.61",
57
- "@hyperframes/studio-server": "0.8.61",
58
- "@hyperframes/core": "0.8.61"
54
+ "@hyperframes/core": "0.8.62",
55
+ "@hyperframes/parsers": "0.8.62",
56
+ "@hyperframes/sdk": "0.8.62",
57
+ "@hyperframes/player": "0.8.62",
58
+ "@hyperframes/studio-server": "0.8.62"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.61"
74
+ "@hyperframes/producer": "0.8.62"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -16,8 +16,13 @@ import type { PlaybackAdapter } from "../lib/playbackTypes";
16
16
 
17
17
  // The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
18
18
  // leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
19
+ // It only runs while the tab is visible: readiness is frame-driven, and a hidden tab renders none.
19
20
  export const SHADOW_READY_TIMEOUT_MS = 15_000;
20
21
 
22
+ function isDocumentHidden(): boolean {
23
+ return typeof document !== "undefined" && document.visibilityState === "hidden";
24
+ }
25
+
21
26
  type UseShadowPreviewReloadParams = Omit<
22
27
  UseTimelineSyncCallbacksParams,
23
28
  "probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
@@ -55,11 +60,14 @@ export function useShadowPreviewReload({
55
60
  const onReloadFailedRef = useRef(onReloadFailed);
56
61
  onReloadFailedRef.current = onReloadFailed;
57
62
  const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
63
+ // The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
64
+ const budgetGenRef = useRef<number | null>(null);
58
65
  const cancelPendingLoadRef = useRef<() => void>(() => {});
59
66
  const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
60
67
 
61
68
  const stopPendingShadow = useCallback(() => {
62
69
  clearTimeout(readyTimerRef.current);
70
+ budgetGenRef.current = null;
63
71
  cancelPendingLoadRef.current();
64
72
  pendingCommitRef.current = null;
65
73
  visuallyReadyGenRef.current = null;
@@ -145,18 +153,30 @@ export function useShadowPreviewReload({
145
153
  shadowIframeRef.current = node;
146
154
  }, []);
147
155
 
156
+ const armReadyTimer = useCallback(
157
+ (gen: number) => {
158
+ clearTimeout(readyTimerRef.current);
159
+ if (isDocumentHidden()) return;
160
+ readyTimerRef.current = setTimeout(
161
+ () => failShadow(gen, "it took too long to load"),
162
+ SHADOW_READY_TIMEOUT_MS,
163
+ );
164
+ },
165
+ [failShadow],
166
+ );
167
+ const armReadyTimerRef = useRef(armReadyTimer);
168
+ armReadyTimerRef.current = armReadyTimer;
169
+
148
170
  const beginShadowReload = useCallback(
149
171
  (url: string) => {
150
172
  shadowGenRef.current += 1;
151
173
  const gen = shadowGenRef.current;
152
174
  stopPendingShadow();
153
- readyTimerRef.current = setTimeout(
154
- () => failShadow(gen, "it took too long to load"),
155
- SHADOW_READY_TIMEOUT_MS,
156
- );
175
+ budgetGenRef.current = gen;
176
+ armReadyTimer(gen);
157
177
  setPreviewSlots((prev) => planShadowReload(prev, gen, url));
158
178
  },
159
- [stopPendingShadow, failShadow],
179
+ [stopPendingShadow, armReadyTimer],
160
180
  );
161
181
 
162
182
  // Composition switch (not an edit reload): drop any in-flight shadow.
@@ -169,7 +189,22 @@ export function useShadowPreviewReload({
169
189
  setPreviewSlots(planShadowDiscard);
170
190
  }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
171
191
 
172
- useMountEffect(() => stopPendingShadow);
192
+ // Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow.
193
+ useMountEffect(() => {
194
+ const onVisibilityChange = () => {
195
+ if (isDocumentHidden()) {
196
+ clearTimeout(readyTimerRef.current);
197
+ return;
198
+ }
199
+ const gen = budgetGenRef.current;
200
+ if (gen != null && gen === shadowGenRef.current) armReadyTimerRef.current(gen);
201
+ };
202
+ document.addEventListener("visibilitychange", onVisibilityChange);
203
+ return () => {
204
+ document.removeEventListener("visibilitychange", onVisibilityChange);
205
+ stopPendingShadow();
206
+ };
207
+ });
173
208
 
174
209
  return {
175
210
  previewSlots,
@@ -289,6 +289,50 @@ describe("shadow reload readiness and failure", () => {
289
289
  unmount(root);
290
290
  });
291
291
 
292
+ it("does not spend the budget while the tab is hidden, and promotes once it is visible", () => {
293
+ vi.useFakeTimers();
294
+ const onPreviewReloadFailed = vi.fn();
295
+ const setVisibility = stubVisibility("hidden");
296
+ const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
297
+ const shadow = makeShadowWithSpies();
298
+ act(() => {
299
+ getApi().setShadowIframeNode(shadow.iframe);
300
+ getApi().onShadowIframeLoad(gen);
301
+ });
302
+
303
+ // A hidden tab renders no frames, so readiness cannot arrive; the wall clock must not fail it.
304
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS * 3));
305
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
306
+ expect(getApi().previewSlots).toHaveLength(2);
307
+
308
+ act(() => setVisibility("visible"));
309
+ act(() => getApi().onShadowReadyChange(gen, true));
310
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
311
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
312
+ unmount(root);
313
+ });
314
+
315
+ it("restarts the full budget when a hidden tab becomes visible, then fails a shadow that stays silent", () => {
316
+ vi.useFakeTimers();
317
+ vi.spyOn(console, "error").mockImplementation(() => {});
318
+ const onPreviewReloadFailed = vi.fn();
319
+ const setVisibility = stubVisibility("visible");
320
+ const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
321
+
322
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1000));
323
+ act(() => setVisibility("hidden"));
324
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
325
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
326
+
327
+ act(() => setVisibility("visible"));
328
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1));
329
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
330
+ act(() => void vi.advanceTimersByTime(1));
331
+ expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
332
+ expect(getApi().iframeRef.current).toBe(live);
333
+ unmount(root);
334
+ });
335
+
292
336
  it("drops a shadow whose document reports an error and reports the cause", () => {
293
337
  vi.spyOn(console, "error").mockImplementation(() => {});
294
338
  const onPreviewReloadFailed = vi.fn();
@@ -455,3 +499,12 @@ function unmount(root: ReturnType<typeof createRoot>) {
455
499
  root.unmount();
456
500
  });
457
501
  }
502
+
503
+ function stubVisibility(initial: DocumentVisibilityState) {
504
+ let state = initial;
505
+ vi.spyOn(document, "visibilityState", "get").mockImplementation(() => state);
506
+ return (next: DocumentVisibilityState) => {
507
+ state = next;
508
+ document.dispatchEvent(new Event("visibilitychange"));
509
+ };
510
+ }