fix(desktop): share one timer across same-interval useNow consumers (#5861)

## Summary

Every `useNow(1000)` consumer owned its own `setInterval`. With dozens
of "agent working" surfaces mounted (sidebar channel badges, tray menu,
agent session panels, managed-agent rows), each ticked on its own
unaligned 1 s timer — a render/composite pass per consumer per second.
On a machine running ~23 agent sessions this pinned a sustained **~25%
of a core** in `com.apple.WebKit.WebContent` while the app sat idle.

This PR makes same-interval `useNow` consumers share one timer: all of
them tick in a single `setInterval` callback, so React batches the state
updates into one render pass. The last unsubscriber tears the timer
down; the visibility gate (pause while hidden, snap fresh on return) is
unchanged.

Attribution receipts (live dev build, 23 acp sessions): the shimmer was
the original suspect from `sample` stacks, but probing `animation: none`
left CPU flat (~25%), while clamping `useNow` intervals dropped it
immediately. Repeated A/B with this exact change: **~25% → ~3–9%**
webview CPU under the same agent load (ambient variance from live agent
activity; the delta reproduced across three alternations).

### Related issue

None found — follow-up to the presence-firehose investigation (#5830
fixed the subscription side; this is the remaining local render cost).

### Testing

- `pnpm test` — 4792/4792 pass, including a new test asserting N
same-interval consumers create exactly one timer and the last unmount
releases it
- `pnpm typecheck`, `biome check` — clean
- Live-local per TESTING.md: hot-patched into a running dev desktop with
23 active acp sessions; webview CPU dropped from ~25% sustained to ~3–9%
(A/B/A alternation, `ps` sampling over 30 s windows)

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
Tyler
2026-08-14 10:16:10 -04:00
committed by GitHub
parent ea0960f8d0
commit 8b8445f5ef
2 changed files with 97 additions and 4 deletions

View File

@@ -197,6 +197,60 @@ describe("visibility-gated hooks", () => {
dom.window.close();
});
it("useNow consumers with the same interval share one timer", async () => {
mock.timers.enable({ apis: ["Date", "setInterval"], now: 1_000 });
const dom = new JSDOM(
"<!doctype html><html><body><div id='root'></div></body></html>",
);
Object.defineProperty(dom.window.document, "visibilityState", {
configurable: true,
value: "visible",
});
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
const setIntervalSpy = mock.method(globalThis, "setInterval");
const observed = [];
function Clock({ id }) {
const now = useNow(1_000);
React.useEffect(() => {
observed.push([id, now]);
}, [id, now]);
return null;
}
const root = createRoot(document.getElementById("root"));
await act(async () =>
root.render(
React.createElement(
React.Fragment,
null,
React.createElement(Clock, { id: "a" }),
React.createElement(Clock, { id: "b" }),
React.createElement(Clock, { id: "c" }),
),
),
);
assert.equal(setIntervalSpy.mock.callCount(), 1);
await act(async () => mock.timers.tick(1_000));
assert.deepEqual(observed.filter(([, now]) => now === 2_000).length, 3);
// Last unmount releases the shared timer; a fresh mount recreates it.
await act(async () => root.unmount());
const secondRoot = createRoot(document.getElementById("root"));
await act(async () =>
secondRoot.render(React.createElement(Clock, { id: "d" })),
);
assert.equal(setIntervalSpy.mock.callCount(), 2);
await act(async () => secondRoot.unmount());
dom.window.close();
});
it("focused polling pauses on blur and resumes after activation yields", async () => {
const dom = new JSDOM(
"<!doctype html><html><body><div id='root'></div></body></html>",

View File

@@ -2,10 +2,50 @@ import * as React from "react";
import { useDocumentVisible } from "@/shared/lib/useDocumentVisible";
type SharedTicker = {
listeners: Set<() => void>;
intervalId: ReturnType<typeof setInterval>;
};
const tickers = new Map<number, SharedTicker>();
/**
* One `setInterval` per distinct interval, shared by every subscriber. All
* same-cadence consumers tick in a single timer callback, so React batches
* their state updates into one render/composite pass instead of N unaligned
* passes per interval. With dozens of "working" badges mounted, per-consumer
* timers pinned a sustained ~25% of a core in the WebKit process.
*/
function subscribeToSharedTick(
intervalMs: number,
listener: () => void,
): () => void {
let ticker = tickers.get(intervalMs);
if (!ticker) {
const created: SharedTicker = {
listeners: new Set(),
intervalId: setInterval(() => {
for (const tick of created.listeners) tick();
}, intervalMs),
};
tickers.set(intervalMs, created);
ticker = created;
}
ticker.listeners.add(listener);
return () => {
ticker.listeners.delete(listener);
if (ticker.listeners.size === 0) {
clearInterval(ticker.intervalId);
tickers.delete(intervalMs);
}
};
}
/**
* Returns `Date.now()`, re-rendering the calling component every `intervalMs`.
* Each consumer owns one `setInterval` cleaned up on unmount — mount the hook
* only where a live clock is actually displayed so idle components never tick.
* Consumers with the same interval share one timer — mount the hook only where
* a live clock is actually displayed so idle components never tick.
*/
export function useNow(intervalMs: number): number {
const [now, setNow] = React.useState(() => Date.now());
@@ -15,8 +55,7 @@ export function useNow(intervalMs: number): number {
if (!documentVisible) return;
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
return subscribeToSharedTick(intervalMs, () => setNow(Date.now()));
}, [documentVisible, intervalMs]);
return now;