@hyperframes/studio 0.8.180 → 0.8.181

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/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  document.documentElement.dataset.theme = "paper";
15
15
  }
16
16
  </script>
17
- <script type="module" crossorigin src="/assets/index-mdtW0Ug-.js"></script>
17
+ <script type="module" crossorigin src="/assets/index-CFrZAwKo.js"></script>
18
18
  <link rel="stylesheet" crossorigin href="/assets/index-CESvLzz0.css">
19
19
  </head>
20
20
  <body>
package/dist/index.js CHANGED
@@ -31726,8 +31726,19 @@ function useShadowPreviewReload({
31726
31726
  (gen) => {
31727
31727
  clearTimeout(readyTimerRef.current);
31728
31728
  if (isDocumentHidden()) return;
31729
+ let drew = false;
31730
+ let starved = false;
31731
+ requestAnimationFrame(() => {
31732
+ if (!starved) drew = true;
31733
+ else if (budgetGenRef.current === gen) armReadyTimerRef.current(gen);
31734
+ });
31729
31735
  readyTimerRef.current = setTimeout(() => {
31730
31736
  if (gen !== shadowGenRef.current) return;
31737
+ if (!drew) {
31738
+ starved = true;
31739
+ logReload("shadow-starved", { gen });
31740
+ return;
31741
+ }
31731
31742
  budgetsSpentRef.current += 1;
31732
31743
  if (budgetsSpentRef.current < SHADOW_READY_BUDGETS) {
31733
31744
  logReload("shadow-slow", { budgetsSpent: budgetsSpentRef.current });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.180",
3
+ "version": "0.8.181",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,12 +59,12 @@
59
59
  "dockview-react": "8.3.1",
60
60
  "gsap": "^3.13.0",
61
61
  "mediabunny": "^1.45.3",
62
- "@hyperframes/core": "0.8.180",
63
- "@hyperframes/sdk": "0.8.180",
64
- "@hyperframes/parsers": "0.8.180",
65
- "@hyperframes/shader-transitions": "0.8.180",
66
- "@hyperframes/player": "0.8.180",
67
- "@hyperframes/studio-server": "0.8.180"
62
+ "@hyperframes/core": "0.8.181",
63
+ "@hyperframes/parsers": "0.8.181",
64
+ "@hyperframes/player": "0.8.181",
65
+ "@hyperframes/sdk": "0.8.181",
66
+ "@hyperframes/shader-transitions": "0.8.181",
67
+ "@hyperframes/studio-server": "0.8.181"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.180"
84
+ "@hyperframes/producer": "0.8.181"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -25,7 +25,7 @@ import { usePlayerStore } from "../store/playerStore";
25
25
 
26
26
  // One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
27
27
  // leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
28
- // It only runs while the tab is visible: readiness is frame-driven, and a hidden tab renders none.
28
+ // Spent only while frames are drawn (readiness needs them); a hidden tab or a covered Linux window draws none.
29
29
  export const SHADOW_READY_TIMEOUT_MS = 15_000;
30
30
  // A busy machine can need more than one budget; the shadow keeps loading for this many before it is dropped.
31
31
  export const SHADOW_READY_BUDGETS = 3;
@@ -276,8 +276,19 @@ export function useShadowPreviewReload({
276
276
  (gen: number) => {
277
277
  clearTimeout(readyTimerRef.current);
278
278
  if (isDocumentHidden()) return;
279
+ let drew = false;
280
+ let starved = false;
281
+ requestAnimationFrame(() => {
282
+ if (!starved) drew = true;
283
+ else if (budgetGenRef.current === gen) armReadyTimerRef.current(gen);
284
+ });
279
285
  readyTimerRef.current = setTimeout(() => {
280
286
  if (gen !== shadowGenRef.current) return;
287
+ if (!drew) {
288
+ starved = true;
289
+ logReload("shadow-starved", { gen });
290
+ return;
291
+ }
281
292
  budgetsSpentRef.current += 1;
282
293
  if (budgetsSpentRef.current < SHADOW_READY_BUDGETS) {
283
294
  logReload("shadow-slow", { budgetsSpent: budgetsSpentRef.current });
@@ -545,6 +545,60 @@ describe("shadow reload readiness and failure", () => {
545
545
  unmount(root);
546
546
  });
547
547
 
548
+ // A window behind others on some Linux desktops draws no frames yet reports visible; readiness needs frames.
549
+ it("does not spend the budget while the window draws no frames, and promotes once they return", () => {
550
+ vi.useFakeTimers();
551
+ const frames = holdFrames();
552
+ const onPreviewReloadFailed = vi.fn();
553
+ const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
554
+ const shadow = makeShadowWithSpies();
555
+ act(() => {
556
+ getApi().setShadowIframeNode(shadow.iframe);
557
+ getApi().onShadowIframeLoad(gen);
558
+ });
559
+
560
+ act(() => void vi.advanceTimersByTime(FULL_WAIT_MS * 2));
561
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
562
+ expect(getApi().previewSlots).toHaveLength(2);
563
+
564
+ act(() => frames.resume());
565
+ act(() => getApi().onShadowReadyChange(gen, true));
566
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
567
+ unmount(root);
568
+ });
569
+
570
+ it("gives a shadow its full budget once frames return, then fails one that stays silent", () => {
571
+ vi.useFakeTimers();
572
+ vi.spyOn(console, "error").mockImplementation(() => {});
573
+ const frames = holdFrames();
574
+ const onPreviewReloadFailed = vi.fn();
575
+ const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
576
+
577
+ act(() => void vi.advanceTimersByTime(FULL_WAIT_MS));
578
+ act(() => frames.resume());
579
+ act(() => void vi.advanceTimersByTime(FULL_WAIT_MS - 1));
580
+ expect(onPreviewReloadFailed).not.toHaveBeenCalled();
581
+ act(() => void vi.advanceTimersByTime(1));
582
+ expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
583
+ expect(getApi().iframeRef.current).toBe(live);
584
+ unmount(root);
585
+ });
586
+
587
+ it("keeps a reload's own budget when the frames a replaced one waited on return", () => {
588
+ vi.useFakeTimers();
589
+ vi.spyOn(console, "error").mockImplementation(() => {});
590
+ const frames = holdFrames();
591
+ const onPreviewReloadFailed = vi.fn();
592
+ const { getApi, root } = beginReload({ onPreviewReloadFailed });
593
+ act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS + 1000));
594
+
595
+ act(() => getApi().refreshPlayer());
596
+ act(() => frames.resume());
597
+ act(() => void vi.advanceTimersByTime(FULL_WAIT_MS));
598
+ expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
599
+ unmount(root);
600
+ });
601
+
548
602
  it("still drops and reports a shadow whose window throws on access", () => {
549
603
  vi.spyOn(console, "error").mockImplementation(() => {});
550
604
  const onPreviewReloadFailed = vi.fn();
@@ -806,6 +860,25 @@ function unmount(root: ReturnType<typeof createRoot>) {
806
860
  });
807
861
  }
808
862
 
863
+ /** A window that draws no frames although it reports visible: frames are held until `resume`, then one per 16 ms. */
864
+ function holdFrames() {
865
+ let held: FrameRequestCallback[] = [];
866
+ let flowing = false;
867
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
868
+ if (flowing) return Number(setTimeout(() => callback(performance.now()), 16));
869
+ held.push(callback);
870
+ return held.length;
871
+ });
872
+ return {
873
+ resume: () => {
874
+ flowing = true;
875
+ const waiting = held;
876
+ held = [];
877
+ for (const callback of waiting) callback(performance.now());
878
+ },
879
+ };
880
+ }
881
+
809
882
  function stubVisibility(initial: DocumentVisibilityState) {
810
883
  let state = initial;
811
884
  vi.spyOn(document, "visibilityState", "get").mockImplementation(() => state);