fix(web): stop Sidebar re-render loop from cache-event subscriber

setNow(Date.now()) was called on every query-cache event, including
events dispatched synchronously during ServerWorkspaceRows' render.
On CI, consecutive Date.now() calls cross millisecond boundaries so
each call returns a new value — React always re-renders Sidebar, which
re-renders the layout, which re-renders ServerWorkspaceRows, which
fires more cache events. After ~25 cycles React throws "Maximum update
depth exceeded."

Fix: remove setNow from the cache subscriber. setNow now fires only
from the 30s interval timer, where it correctly refreshes the "X ago"
display text without triggering a render loop.

Also adds a deterministic CI-repro test that mocks Date.now to return
incrementing values and asserts the filter-change path completes without
looping.
This commit is contained in:
Offending Commit
2026-06-03 18:11:30 -05:00
parent 3b88a41afd
commit 7e529c8c44
2 changed files with 23 additions and 2 deletions

View File

@@ -60,7 +60,9 @@ function useLastDataUpdate(): string {
useEffect(() => { useEffect(() => {
function refresh() { function refresh() {
setNow(Date.now()); // No setNow here — calling setNow on every cache event causes a render loop on
// CI (each Date.now() call crosses a ms boundary → new value → React re-renders
// Sidebar → cache events fire again → loop). setNow belongs only in the interval.
const latest = queryClient const latest = queryClient
.getQueryCache() .getQueryCache()
.getAll() .getAll()
@@ -70,7 +72,10 @@ function useLastDataUpdate(): string {
refresh(); refresh();
const unsubscribe = queryClient.getQueryCache().subscribe(refresh); const unsubscribe = queryClient.getQueryCache().subscribe(refresh);
const interval = window.setInterval(refresh, 30_000); const interval = window.setInterval(() => {
setNow(Date.now()); // refresh relative-time display ("X ago") every 30s
refresh();
}, 30_000);
return () => { return () => {
unsubscribe(); unsubscribe();
window.clearInterval(interval); window.clearInterval(interval);

View File

@@ -59,6 +59,22 @@ function renderDashboard() {
describe("Dashboard — unified server-aware view", () => { describe("Dashboard — unified server-aware view", () => {
afterEach(() => localStorage.clear()); afterEach(() => localStorage.clear());
it("does not loop when Date.now advances on each call (CI render-loop repro)", async () => {
// On CI, consecutive Date.now() calls cross millisecond boundaries, so setNow(Date.now())
// in the cache-event subscriber always produces a new value → React keeps re-rendering
// Sidebar → hits the 25-cycle "Maximum update depth exceeded" limit.
// This test forces that CI condition locally to catch regressions.
let t = 1_000_000;
const spy = vi.spyOn(Date, "now").mockImplementation(() => t++);
saveStore({ instances: [neo, iris], activeId: "neo" });
renderDashboard();
await waitFor(() => {
expect(screen.getByText("(Neo)")).toBeInTheDocument();
expect(screen.getByText("(Iris)")).toBeInTheDocument();
});
spy.mockRestore();
});
it("lists each server's workspaces labelled with the server name", async () => { it("lists each server's workspaces labelled with the server name", async () => {
saveStore({ instances: [neo, iris], activeId: "neo" }); saveStore({ instances: [neo, iris], activeId: "neo" });
renderDashboard(); renderDashboard();