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:
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user