Просмотр исходного кода

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 3 недель назад
Родитель
Сommit
bd0b221cb9

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

@@ -321,10 +321,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates archives on print_complete message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -363,10 +359,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates archives on archive_created message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -404,10 +396,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates archives on archive_updated message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -444,10 +432,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates inventory queries on inventory_changed message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -479,10 +463,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('handles missing_spool_assignment message without error', async () => {
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       renderHook(() => useWebSocket(), {
@@ -511,10 +491,6 @@ describe('useWebSocket hook', () => {
     });
 
     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');
 
       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', () => {
     it('sends JSON message when connected', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');

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

@@ -69,16 +69,16 @@ export function useWebSocket() {
     const processNext = () => {
       const message = messageQueueRef.current.shift();
       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 {
         processingRef.current = false;
       }
@@ -194,7 +194,17 @@ export function useWebSocket() {
     wsRef.current = ws;
   }, [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>) => {
     // Merge with any pending data for this printer
     const existing = pendingPrinterStatus.current.get(printerId) || {};
@@ -208,19 +218,17 @@ export function useWebSocket() {
         printerStatusTimeoutRef.current = null;
 
         // 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
     }
@@ -241,13 +249,14 @@ export function useWebSocket() {
       pendingInvalidations.current.clear();
       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;
       keys.forEach((key) => {
         setTimeout(() => {
-          requestAnimationFrame(() => {
-            queryClient.invalidateQueries({ queryKey: [key] });
-          });
+          queryClient.invalidateQueries({ queryKey: [key] });
         }, delay);
         delay += 500; // 500ms between each invalidation
       });

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BClzVk8U.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <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">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов