Explorar o código

Keep live updates flowing while the tab is in the background (#2754)

Printer status, query invalidations and the message queue all ran their
work inside requestAnimationFrame. A hidden tab gets no rendering
opportunities, so the browser holds those callbacks instead of merely
throttling them: the socket stayed open, messages kept arriving, and
every cache write parked in a pending frame until the tab was shown
again — at which point they all ran at once. The tab-title progress
reads ['printerStatus', id] and nothing else, so it simply froze.

The frames came in with the print-completion freeze fix, where the
load-bearing part was the coalescing (100ms throttle, 3s debounce,
500ms stagger). That is untouched; the frames only deferred each write
by ~16ms and are gone. Not made visibility-aware on purpose — a frame
scheduled just before hiding would fire after the writes that took the
hidden path and clobber newer status with older.

The six rAF stubs in the tests ran frames synchronously, which is why
nothing caught this. Replaced with coverage that stubs rAF to never
fire, as a hidden tab does.
maziggy hai 1 mes
pai
achega
c28e053126

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Fixed
 ### Fixed
+- **Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd)** — The progress percentage in the tab title froze whenever you switched to another tab and jumped straight to the current value the moment you switched back, which defeats the point of putting it in the title. The cause was not in the tab-title feature: every printer status arriving over the WebSocket was written into the browser's cache from inside an animation-frame callback, and a browser gives a hidden tab no frames at all. Those callbacks are not slowed down, they are held — so the connection stayed up, the messages kept arriving, and every one of them parked in a queue that only ran when the tab was shown again. The same applied to the archive, inventory and spool refreshes, and to the queue that carries every non-status message, which stalled completely and accumulated messages until the tab came back. The animation frames were added alongside the real fix for a browser freeze on print completion — that fix was the batching, which is untouched; the frames only ever deferred each write by about a sixteenth of a second and are gone. One limit is worth knowing about and is the browser's rather than ours: browsers deliberately slow down timers in tabs you are not looking at, to roughly once a second, and to about once a minute once a tab has been hidden for five minutes. So the title keeps moving in the background, but on a tab left alone for a long time it steps rather than ticks. Covered by frontend tests that reproduce a hidden tab.
 - **The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman)** — On a phone the floating red button sits on top of whatever else is in that corner, which turns out to be most things: the scroll-to-top button on Profiles was ~83% underneath it and, since both sit at the same stacking level, which one you could actually tap came down to the order they happened to render in. The floating camera window parks there, as do the Group Edit save bar, the bulk-selection toolbars, and — because the button is pinned to the viewport rather than the page — the per-card action buttons on File Manager and Archives simply scroll underneath it. The reporter asked for a switch to hide the button, but it is the only way into the report form, and that form is not just a text box: it runs the printer connection diagnostic, scans your logs against the known-issue catalog, optionally captures five minutes of debug logging and attaches a support bundle. Hiding it doesn't produce smaller reports, it produces reports with nothing attached. So the button moves instead of disappearing. Once the window is narrow enough that the sidebar collapses into a menu button, the bug icon moves into that top bar and the corner is left alone; above that width nothing changes. That threshold is the one the layout already switches on, so there is no new breakpoint and no third state to reason about, and it covers tablets and half-width desktop windows rather than only phones. The report form itself is now a proper bottom sheet on phones, which also fixes it hanging 16 pixels off the left edge of the screen — it was sized to the full viewport width and then inset from the right, so a strip of the form was simply unreachable on anything under about 460 pixels wide. The scroll-to-top button on Profiles has been nudged clear of the corner as well, for the wide layouts where the floating button stays. Wiki updated. Covered by frontend tests.
 - **The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman)** — On a phone the floating red button sits on top of whatever else is in that corner, which turns out to be most things: the scroll-to-top button on Profiles was ~83% underneath it and, since both sit at the same stacking level, which one you could actually tap came down to the order they happened to render in. The floating camera window parks there, as do the Group Edit save bar, the bulk-selection toolbars, and — because the button is pinned to the viewport rather than the page — the per-card action buttons on File Manager and Archives simply scroll underneath it. The reporter asked for a switch to hide the button, but it is the only way into the report form, and that form is not just a text box: it runs the printer connection diagnostic, scans your logs against the known-issue catalog, optionally captures five minutes of debug logging and attaches a support bundle. Hiding it doesn't produce smaller reports, it produces reports with nothing attached. So the button moves instead of disappearing. Once the window is narrow enough that the sidebar collapses into a menu button, the bug icon moves into that top bar and the corner is left alone; above that width nothing changes. That threshold is the one the layout already switches on, so there is no new breakpoint and no third state to reason about, and it covers tablets and half-width desktop windows rather than only phones. The report form itself is now a proper bottom sheet on phones, which also fixes it hanging 16 pixels off the left edge of the screen — it was sized to the full viewport width and then inset from the right, so a strip of the form was simply unreachable on anything under about 460 pixels wide. The scroll-to-top button on Profiles has been nudged clear of the corner as well, for the wide layouts where the floating button stays. Wiki updated. Covered by frontend tests.
 - **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests.
 - **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests.
 - **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test.
 - **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test.

+ 94 - 24
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -321,10 +321,6 @@ describe('useWebSocket hook', () => {
 
 
     it('invalidates archives on print_complete message', async () => {
     it('invalidates archives on print_complete message', async () => {
       vi.useFakeTimers();
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -363,10 +359,6 @@ describe('useWebSocket hook', () => {
 
 
     it('invalidates archives on archive_created message', async () => {
     it('invalidates archives on archive_created message', async () => {
       vi.useFakeTimers();
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -404,10 +396,6 @@ describe('useWebSocket hook', () => {
 
 
     it('invalidates archives on archive_updated message', async () => {
     it('invalidates archives on archive_updated message', async () => {
       vi.useFakeTimers();
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -444,10 +432,6 @@ describe('useWebSocket hook', () => {
 
 
     it('invalidates inventory queries on inventory_changed message', async () => {
     it('invalidates inventory queries on inventory_changed message', async () => {
       vi.useFakeTimers();
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -479,10 +463,6 @@ describe('useWebSocket hook', () => {
     });
     });
 
 
     it('handles missing_spool_assignment message without error', async () => {
     it('handles missing_spool_assignment message without error', async () => {
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       renderHook(() => useWebSocket(), {
       renderHook(() => useWebSocket(), {
@@ -511,10 +491,6 @@ describe('useWebSocket hook', () => {
     });
     });
 
 
     it('handles spool_assignment_verified messages (success and failure) without error', async () => {
     it('handles spool_assignment_verified messages (success and failure) without error', async () => {
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 
       renderHook(() => useWebSocket(), {
       renderHook(() => useWebSocket(), {
@@ -645,6 +621,100 @@ describe('useWebSocket hook', () => {
     });
     });
   });
   });
 
 
+  /**
+   * #2754 (reporter @mic4rd): live updates froze whenever the tab wasn't in
+   * front, and caught up all at once on switching back. The cache writes ran
+   * inside requestAnimationFrame, and a hidden tab gets no rendering
+   * opportunities — so the browser holds queued frame callbacks indefinitely
+   * rather than merely throttling them.
+   *
+   * The stub below is what makes these tests meaningful: it hands back a
+   * handle and never invokes the callback, which is what a real hidden tab
+   * does. `document.hidden` is set alongside it to name the scenario, but the
+   * production code doesn't branch on visibility — it simply no longer defers
+   * to a frame. Reintroduce a rAF wrapper on either path and these fail.
+   */
+  describe('hidden tab (#2754)', () => {
+    let rafSpy: ReturnType<typeof vi.fn>;
+
+    beforeEach(() => {
+      // The shared test client sets gcTime: 0, which collects a query the
+      // moment it has no observers — advancing timers past the 100ms
+      // coalescing window would drop the entry we just wrote before we could
+      // read it back. Nothing observes ['printerStatus', 1] here, so this
+      // block needs a client that keeps unobserved data.
+      queryClient = new QueryClient({
+        defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+      });
+      Object.defineProperty(document, 'hidden', { configurable: true, value: true });
+      // Order matters: vi.useFakeTimers() fakes requestAnimationFrame as well
+      // (backing it with the mock clock, so advanceTimersByTime would run it
+      // and hide the very defect under test). Stub it afterwards so the
+      // never-firing version is the one the hook sees.
+      vi.useFakeTimers();
+      rafSpy = vi.fn(() => 1);
+      vi.stubGlobal('requestAnimationFrame', rafSpy);
+    });
+
+    afterEach(() => {
+      vi.useRealTimers();
+      Object.defineProperty(document, 'hidden', { configurable: true, value: false });
+    });
+
+    it('applies printer status to the query cache', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'printer_status',
+          printer_id: 1,
+          data: { state: 'RUNNING', progress: 42 },
+        });
+      });
+
+      // Past the 100ms coalescing window.
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+
+      // This is the key the tab-title/favicon progress reads
+      // (usePrintProgressTitle) and nothing else.
+      expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
+        state: 'RUNNING',
+        progress: 42,
+      });
+      expect(rafSpy).not.toHaveBeenCalled();
+    });
+
+    it('drains queued messages instead of wedging the queue', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      // Everything other than printer_status goes through the message queue,
+      // which used to stall with processingRef stuck true — messages then
+      // piled up unbounded until the tab was shown again.
+      act(() => {
+        ws.simulateMessage({ type: 'print_complete', printer_id: 1, data: {} });
+      });
+
+      // 3s debounce, then the 500ms-apart stagger.
+      await act(async () => {
+        vi.advanceTimersByTime(4000);
+      });
+
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
+      expect(rafSpy).not.toHaveBeenCalled();
+    });
+  });
+
   describe('sendMessage', () => {
   describe('sendMessage', () => {
     it('sends JSON message when connected', async () => {
     it('sends JSON message when connected', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');

+ 36 - 27
frontend/src/hooks/useWebSocket.ts

@@ -69,16 +69,16 @@ export function useWebSocket() {
     const processNext = () => {
     const processNext = () => {
       const message = messageQueueRef.current.shift();
       const message = messageQueueRef.current.shift();
       if (message) {
       if (message) {
-        // Use requestAnimationFrame to yield to the browser
-        requestAnimationFrame(() => {
-          handleMessageRef.current(message);
-          // Small delay between messages to prevent overwhelming the browser
-          if (messageQueueRef.current.length > 0) {
-            setTimeout(processNext, 16); // ~60fps
-          } else {
-            processingRef.current = false;
-          }
-        });
+        handleMessageRef.current(message);
+        // Small delay between messages to prevent overwhelming the browser.
+        // This setTimeout is the yield; a requestAnimationFrame around the
+        // handler used to sit here too, which stalled the whole queue in a
+        // hidden tab (see the note on the rAF removal below).
+        if (messageQueueRef.current.length > 0) {
+          setTimeout(processNext, 16); // ~60fps
+        } else {
+          processingRef.current = false;
+        }
       } else {
       } else {
         processingRef.current = false;
         processingRef.current = false;
       }
       }
@@ -194,7 +194,17 @@ export function useWebSocket() {
     wsRef.current = ws;
     wsRef.current = ws;
   }, [processMessageQueue]);
   }, [processMessageQueue]);
 
 
-  // Throttled printer status update - coalesces rapid updates per printer
+  // Throttled printer status update - coalesces rapid updates per printer.
+  //
+  // #2754: these cache writes used to happen inside a requestAnimationFrame.
+  // A hidden tab gets no rendering opportunities, so the browser *holds*
+  // queued frame callbacks rather than throttling them — every status update
+  // parked in a pending frame and nothing reached the query cache until the
+  // tab was shown again, at which point they all ran at once. That froze the
+  // tab-title progress (usePrintProgressTitle reads this key and nothing
+  // else) and stalled every other live view. The 100ms coalescing below is
+  // what prevented the original render cascade; the frame callback only ever
+  // deferred the write by a frame, so it is gone.
   const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
   const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
     // Merge with any pending data for this printer
     // Merge with any pending data for this printer
     const existing = pendingPrinterStatus.current.get(printerId) || {};
     const existing = pendingPrinterStatus.current.get(printerId) || {};
@@ -208,19 +218,17 @@ export function useWebSocket() {
         printerStatusTimeoutRef.current = null;
         printerStatusTimeoutRef.current = null;
 
 
         // Apply all pending updates
         // Apply all pending updates
-        requestAnimationFrame(() => {
-          updates.forEach((statusData, id) => {
-            queryClient.setQueryData(
-              ['printerStatus', id],
-              (old: Record<string, unknown> | undefined) => {
-                const merged = { ...old, ...statusData };
-                if (merged.wifi_signal == null && old?.wifi_signal != null) {
-                  merged.wifi_signal = old.wifi_signal;
-                }
-                return merged;
+        updates.forEach((statusData, id) => {
+          queryClient.setQueryData(
+            ['printerStatus', id],
+            (old: Record<string, unknown> | undefined) => {
+              const merged = { ...old, ...statusData };
+              if (merged.wifi_signal == null && old?.wifi_signal != null) {
+                merged.wifi_signal = old.wifi_signal;
               }
               }
-            );
-          });
+              return merged;
+            }
+          );
         });
         });
       }, 100); // Update at most every 100ms
       }, 100); // Update at most every 100ms
     }
     }
@@ -241,13 +249,14 @@ export function useWebSocket() {
       pendingInvalidations.current.clear();
       pendingInvalidations.current.clear();
       invalidationTimeoutRef.current = null;
       invalidationTimeoutRef.current = null;
 
 
-      // Invalidate queries one at a time with delays to prevent freeze
+      // Invalidate queries one at a time with delays to prevent freeze.
+      // The 500ms stagger is the anti-cascade measure; a frame callback around
+      // each invalidation used to sit inside it and stalled these refreshes in
+      // a hidden tab for the same reason as the status writes above (#2754).
       let delay = 0;
       let delay = 0;
       keys.forEach((key) => {
       keys.forEach((key) => {
         setTimeout(() => {
         setTimeout(() => {
-          requestAnimationFrame(() => {
-            queryClient.invalidateQueries({ queryKey: [key] });
-          });
+          queryClient.invalidateQueries({ queryKey: [key] });
         }, delay);
         }, delay);
         delay += 500; // 500ms between each invalidation
         delay += 500; // 500ms between each invalidation
       });
       });

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BClzVk8U.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-TuCPjeGc.js"></script>
+    <script type="module" crossorigin src="/assets/index-BClzVk8U.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-GBTQ2eaA.css">
     <link rel="stylesheet" crossorigin href="/assets/index-GBTQ2eaA.css">
   </head>
   </head>
   <body>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio