From 5cd488445248cd6d9b21ba566ce31364e77313f3 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Sun, 18 Oct 2026 03:47:32 +0300 Subject: [PATCH 1/3] VC-216: adopt react/globals, react/purity and react/use-memo Re-enable three of the ten React Compiler rules oxlint 1.79 split out and fix their findings on main: - purity: read the clock or RNG once per mount through a lazy useState initializer (sidebar skeleton width, streak window, usage popover's initial askedAt, MessageDelivery's wait, two lab scratches). - purity: chat-plane's pendingSince first-seen stamp keeps a commented disable; the stamp is idempotent per id and moves with the ref-held ledger when react/refs is adopted. - use-memo: inline the lab model-access factories (model-tiers holds its client in state so Reset needs no unused memo dependency). - globals: test probe components keep capturing hook values and counting renders through a test-file override; the chat-performance scratch's render counter keeps a commented disable. The other seven compiler rules stay off for their own follow-ups. --- .../lab/scratches/chat-performance.tsx | 1 + .../lab/scratches/host-connection.tsx | 8 +++++--- .../renderer/lab/scratches/model-tiers.tsx | 13 ++++++++++--- .../src/renderer/lab/scratches/split-tabs.tsx | 4 ++-- .../renderer/lab/scratches/ticket-kickoff.tsx | 2 +- .../src/components/chat/chat-plane.tsx | 11 ++++++++++- .../components/chat/empty/streak-visual.tsx | 2 +- .../renderer/src/components/ui/sidebar.tsx | 6 ++---- .../usage-limits/usage-limits-popover.tsx | 3 ++- vite.config.ts | 19 +++++++++++++++---- 10 files changed, 49 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx b/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx index 5a59b3090..ccf9fb876 100644 --- a/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx +++ b/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx @@ -1279,6 +1279,7 @@ function useStableList( * 3000 of them cost more than 3000 memo compares. */ const CountedTurn = React.memo(function CountedTurn(props: React.ComponentProps) { + // oxlint-disable-next-line react/globals -- counting renders is this probe's measurement turnRenders += 1; return ; }); diff --git a/apps/desktop/src/renderer/lab/scratches/host-connection.tsx b/apps/desktop/src/renderer/lab/scratches/host-connection.tsx index 03365c51c..afd5b9396 100644 --- a/apps/desktop/src/renderer/lab/scratches/host-connection.tsx +++ b/apps/desktop/src/renderer/lab/scratches/host-connection.tsx @@ -176,6 +176,8 @@ export default function HostConnectionScratch() { const retryRef = React.useRef(retrySucceeds); retryRef.current = retrySucceeds; const timers = React.useRef([]); + // The seeded hosts went offline relative to when the scratch opened. + const [openedAt] = React.useState(() => Date.now()); const source = React.useMemo(() => { const later = (ms: number, act: () => void) => { @@ -225,17 +227,17 @@ export default function HostConnectionScratch() { return createFakeHostSource( hostSnapshot( [ - remoteHost(HETZNER, "hetzner-1", hetzner("offline", Date.now())), + remoteHost(HETZNER, "hetzner-1", hetzner("offline", openedAt)), remoteHost(MINI, "mac-mini", { os: "macos", - link: { status: "offline", since: Date.now() - 2 * 3_600_000, retryAt: null }, + link: { status: "offline", since: openedAt - 2 * 3_600_000, retryAt: null }, }), ], { "prj-voltaic": HETZNER, "prj-harbor": MINI }, ), onCall, ); - }, []); + }, [openedAt]); React.useEffect(() => { const local = createFakeHostSource( diff --git a/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx b/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx index bd451fa21..44e53b08b 100644 --- a/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx +++ b/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx @@ -303,14 +303,21 @@ function labModelAccess(): ModelAccessClient { /* ------------------------------------------------------------------ scratch */ export default function ModelTiersScratch() { - const [generation, setGeneration] = React.useState(0); - const client = React.useMemo(labModelAccess, [generation]); + // Reset swaps in a fresh client and remounts the provider around it. + const [{ generation, client }, setLab] = React.useState(() => ({ + generation: 0, + client: labModelAccess(), + })); return (
- diff --git a/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx b/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx index 249823b15..37218c510 100644 --- a/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx +++ b/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx @@ -407,7 +407,7 @@ function PaneBody({ onFocus, }: PaneBodyProps): React.JSX.Element { const token = React.useRef({}); - const started = React.useRef(Date.now()); + const [started] = React.useState(() => Date.now()); React.useEffect(() => { onInstance(pane.id, token.current); @@ -477,7 +477,7 @@ function PaneBody({ className="font-mono text-label tabular-nums text-muted-foreground" title="How long this pane has been running. Back to 00:00 means it was killed and rebuilt." > - {elapsed(now - started.current)} + {elapsed(now - started)} labModelAccess(), []); const [empty, setEmpty] = React.useState(false); // The board's own store, emptied and refilled in place: `BoardEmpty` keys off diff --git a/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx b/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx index 41ff41407..858ae982d 100644 --- a/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx +++ b/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx @@ -1056,6 +1056,11 @@ export function ChatPlane({ .map((message) => message.id), ); for (const [id, label] of deliveryLabels) if (label === "Pending") ids.add(id); + // Each id is stamped with the time it was first seen pending and keeps + // that stamp on every later pass, so re-running this memo cannot move a + // clock; only a new id reads the time. This read moves with the ref-held + // ledger when `react/refs` is adopted. + // oxlint-disable-next-line react/purity -- first-seen stamp, idempotent per id const now = Date.now(); const next = new Map([...ids].map((id) => [id, waitTimes.current.get(id) ?? now])); waitTimes.current = next; @@ -3007,7 +3012,11 @@ export const ChatTurn = React.memo(function ChatTurn({ * Refusals remain immediate; ordinary round trips never show waiting copy. */ function MessageDelivery({ label, since }: { label: string; since: number | undefined }) { const failed = label === "Not delivered" || label === "Delivery unconfirmed"; - const remaining = since === undefined ? 600 : Math.max(0, 600 - (Date.now() - since)); + // The clock is read once, at mount, so the wait does not restart per render; + // a stamp newer than the mount still waits the full 600ms from `since`. + const [mountedAt] = React.useState(() => Date.now()); + const remaining = + since === undefined ? 600 : Math.min(600, Math.max(0, 600 - (mountedAt - since))); const waited = useDelayedPresence(!failed, remaining, remaining === 0); if (!failed && !waited) return null; const Icon = failed ? WarningIcon : ClockIcon; diff --git a/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx b/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx index a4376127d..98d06d59a 100644 --- a/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx +++ b/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx @@ -36,7 +36,7 @@ export function StreakVisual() { // The window is a fact about now, so it is taken once per mount rather than // per render — a grid whose first cell slid while the pointer was over it // would be a different chart under the same tooltip. - const now = React.useMemo(() => Date.now(), []); + const [now] = React.useState(() => Date.now()); React.useEffect(() => { void refresh(streakWindowStart(now)); }, [now, refresh]); diff --git a/apps/desktop/src/renderer/src/components/ui/sidebar.tsx b/apps/desktop/src/renderer/src/components/ui/sidebar.tsx index 77b99eea5..b0f602076 100644 --- a/apps/desktop/src/renderer/src/components/ui/sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/ui/sidebar.tsx @@ -491,10 +491,8 @@ function SidebarMenuSkeleton({ }: React.ComponentProps<"div"> & { showIcon?: boolean; }) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + // Random width between 50 to 90%, drawn once when the skeleton mounts. + const [width] = React.useState(() => `${Math.floor(Math.random() * 40) + 50}%`); return (
Date.now()); + const askedAt = React.useRef(mountedAt); React.useEffect( () => () => { requestId.current += 1; diff --git a/vite.config.ts b/vite.config.ts index 19a1f4a9d..5a30d2ab2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -79,7 +79,8 @@ export default defineConfig({ // React Compiler rules remain individually disabled pending adoption // (VC-216, VC-211). oxlint 1.79 split the inactive compiler rule into - // these ten rules (oxc #25500, #25830, #25840). Keep the React plugin and + // ten rules (oxc #25500, #25830, #25840); `globals`, `purity` and + // `use-memo` are adopted, the seven below are not yet. Keep the React plugin and // error categories enabled so other rules and newly added rules still // enforce. Remove each override only with its corresponding behavior // fixes and tests; `vp lint` reports the current findings. @@ -94,10 +95,20 @@ export default defineConfig({ "react/immutability": "off", "react/memo-dependencies": "off", "react/preserve-manual-memoization": "off", - "react/globals": "off", - "react/purity": "off", - "react/use-memo": "off", }, + overrides: [ + { + // A test's probe component exists to hand render-time values to the + // assertions around it: `function Probe() { handle = useThing(); }` + // captures a hook's result, and `renders += 1` counts renders because + // the render count IS what the test measures. Writing an outer + // variable during render is the point of those probes, not a hazard, + // and no test is compiled by React Compiler. Product code keeps the + // rule at error. + files: ["**/*.test.ts", "**/*.test.tsx"], + rules: { "react/globals": "off" }, + }, + ], }, staged: { // `vp fmt` (Oxfmt) aborts with "Expected at least one target file" when From 79d5fc4f6049b226bf9413265655fde1c5c28721 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Sun, 18 Oct 2026 05:19:05 +0300 Subject: [PATCH 2/3] VC-216: cover MessageDelivery's wait clock and describe it exactly - Regression tests: a held -> durable handoff part-way through the wait still ends it 600ms from the hold, and a pending stamp that arrives after the tab mounted never holds the wait past 600ms after mount (the clamp). - MessageDelivery's comment states the late-stamp case precisely; the pendingSince comment describes behaviour only. - ticket-kickoff scratch holds its client in useState like model-tiers. - vite.config.ts names the follow-up (VC-784) and rewraps its comment. --- .../renderer/lab/scratches/ticket-kickoff.tsx | 2 +- .../src/components/chat/chat-plane.tsx | 9 +-- .../chat/chat-plane.window.test.tsx | 68 +++++++++++++++++++ vite.config.ts | 6 +- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/lab/scratches/ticket-kickoff.tsx b/apps/desktop/src/renderer/lab/scratches/ticket-kickoff.tsx index c93efa8b0..f06800bdd 100644 --- a/apps/desktop/src/renderer/lab/scratches/ticket-kickoff.tsx +++ b/apps/desktop/src/renderer/lab/scratches/ticket-kickoff.tsx @@ -253,7 +253,7 @@ function labModelAccess(): ModelAccessClient { export default function TicketKickoffScratch({ initialTickets = tickets, }: { initialTickets?: typeof tickets } = {}) { - const client = React.useMemo(() => labModelAccess(), []); + const [client] = React.useState(labModelAccess); const [empty, setEmpty] = React.useState(false); // The board's own store, emptied and refilled in place: `BoardEmpty` keys off diff --git a/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx b/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx index 858ae982d..d3300e13e 100644 --- a/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx +++ b/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx @@ -1058,8 +1058,7 @@ export function ChatPlane({ for (const [id, label] of deliveryLabels) if (label === "Pending") ids.add(id); // Each id is stamped with the time it was first seen pending and keeps // that stamp on every later pass, so re-running this memo cannot move a - // clock; only a new id reads the time. This read moves with the ref-held - // ledger when `react/refs` is adopted. + // clock; only a new id reads the time. // oxlint-disable-next-line react/purity -- first-seen stamp, idempotent per id const now = Date.now(); const next = new Map([...ids].map((id) => [id, waitTimes.current.get(id) ?? now])); @@ -3012,8 +3011,10 @@ export const ChatTurn = React.memo(function ChatTurn({ * Refusals remain immediate; ordinary round trips never show waiting copy. */ function MessageDelivery({ label, since }: { label: string; since: number | undefined }) { const failed = label === "Not delivered" || label === "Delivery unconfirmed"; - // The clock is read once, at mount, so the wait does not restart per render; - // a stamp newer than the mount still waits the full 600ms from `since`. + // The clock is read once, at mount, so the wait does not restart per render. + // A stamp present at mount ends the wait at `since + 600`. A stamp that + // arrives later can end it earlier than that, at 600ms after mount, but never + // sooner than 600ms after the tab mounted. const [mountedAt] = React.useState(() => Date.now()); const remaining = since === undefined ? 600 : Math.min(600, Math.max(0, 600 - (mountedAt - since))); diff --git a/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx b/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx index df1d06480..003d0af97 100644 --- a/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx +++ b/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx @@ -2522,6 +2522,74 @@ describe("VC-769 one progress indicator through the live ChatPlane projection", ); }); + const submitted = (sequence: number, commandId: string, message: UIMessage) => + frame( + sequence, + { + kind: "command.recorded", + command: { + id: commandId, + sessionId: SESSION, + createdAt: 0, + route: null, + intent: { + kind: "message.submit", + reference: { id: `artifact-${sequence}`, mediaType: null, digest: null }, + }, + }, + }, + message, + ); + + it("keeps the wait's clock from the hold when ownership hands off part-way through it", async () => { + vi.useFakeTimers(); + const store = chatStore([]); + const message: UIMessage = { + id: "handoff-mid-wait", + role: "user", + parts: [{ type: "text", text: "Halfway there" }], + }; + useChatDraftsStore + .getState() + .holdMessage(SESSION, { id: message.id, text: "Halfway there", delivery: "send" }); + await mountPlane(store); + await advanceProgressTime(300); + await ingest(store, [submitted(1, "held-command", message)]); + expect(container?.querySelectorAll(".is-user")).toHaveLength(1); + await advanceProgressTime(299); + expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull(); + await advanceProgressTime(1); + expect(container?.querySelector('[data-slot="message-delivery"]')?.textContent).toBe( + "Sending…", + ); + }); + + it("never holds a wait longer than 600ms after the tab mounted when its stamp arrives late", async () => { + vi.useFakeTimers(); + const store = chatStore([]); + const drafts = useChatDraftsStore.getState(); + drafts.holdMessage(SESSION, { id: "late-stamp", text: "Line up", delivery: "queue" }); + // Accepted before the tab mounts: "Queued", with no pending stamp yet. + drafts.amendHeldMessage(SESSION, "late-stamp", (held) => ({ ...held, deliveryAccepted: true })); + await mountPlane(store); + expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull(); + await advanceProgressTime(300); + // Pending again, so the message is stamped now — 300ms after the tab mounted. + await act(async () => + drafts.amendHeldMessage(SESSION, "late-stamp", (held) => { + const next = { ...held }; + delete next.deliveryAccepted; + return next; + }), + ); + await advanceProgressTime(299); + expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull(); + await advanceProgressTime(1); + expect(container?.querySelector('[data-slot="message-delivery"]')?.textContent).toBe( + "Queuing…", + ); + }); + it.each(["reasoning", "text", "tool"] as const)( "restores Working during a transport retry with stale %s output", async (kind) => { diff --git a/vite.config.ts b/vite.config.ts index 5a30d2ab2..cd113f796 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -80,9 +80,9 @@ export default defineConfig({ // React Compiler rules remain individually disabled pending adoption // (VC-216, VC-211). oxlint 1.79 split the inactive compiler rule into // ten rules (oxc #25500, #25830, #25840); `globals`, `purity` and - // `use-memo` are adopted, the seven below are not yet. Keep the React plugin and - // error categories enabled so other rules and newly added rules still - // enforce. Remove each override only with its corresponding behavior + // `use-memo` are adopted, the seven below are not yet (VC-784). Keep the + // React plugin and error categories enabled so other rules and newly + // added rules still enforce. Remove each override only with its corresponding behavior // fixes and tests; `vp lint` reports the current findings. // Keep the override set scoped to compiler adoption (VC-216); preserve // existing error categories for unrelated rules (VC-211). Each enabled From 62f20777899cd7ad5c67756c2721a45c5c19ef46 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Sun, 18 Oct 2026 05:19:19 +0300 Subject: [PATCH 3/3] VC-216: rewrap the compiler-rules comment and tighten the MessageDelivery note --- apps/desktop/src/renderer/src/components/chat/chat-plane.tsx | 4 ++-- vite.config.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx b/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx index d3300e13e..4e49df1a6 100644 --- a/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx +++ b/apps/desktop/src/renderer/src/components/chat/chat-plane.tsx @@ -3013,8 +3013,8 @@ function MessageDelivery({ label, since }: { label: string; since: number | unde const failed = label === "Not delivered" || label === "Delivery unconfirmed"; // The clock is read once, at mount, so the wait does not restart per render. // A stamp present at mount ends the wait at `since + 600`. A stamp that - // arrives later can end it earlier than that, at 600ms after mount, but never - // sooner than 600ms after the tab mounted. + // arrives later can end it before `since + 600`, but never sooner than + // 600ms after the tab mounted. const [mountedAt] = React.useState(() => Date.now()); const remaining = since === undefined ? 600 : Math.min(600, Math.max(0, 600 - (mountedAt - since))); diff --git a/vite.config.ts b/vite.config.ts index cd113f796..13d2fe12a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -82,8 +82,9 @@ export default defineConfig({ // ten rules (oxc #25500, #25830, #25840); `globals`, `purity` and // `use-memo` are adopted, the seven below are not yet (VC-784). Keep the // React plugin and error categories enabled so other rules and newly - // added rules still enforce. Remove each override only with its corresponding behavior - // fixes and tests; `vp lint` reports the current findings. + // added rules still enforce. Remove each override only with its + // corresponding behavior fixes and tests; `vp lint` reports the current + // findings. // Keep the override set scoped to compiler adoption (VC-216); preserve // existing error categories for unrelated rules (VC-211). Each enabled // rule needs its behavior regression coverage (VC-216). Remove obsolete