diff --git a/.jules/bolt.md b/.jules/bolt.md index bbac0f1..7fa8365 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -8,4 +8,8 @@ **Learning:** Recreating `CanvasGradient` objects (like `createRadialGradient`) inside a 60fps render loop causes unnecessary garbage collection pressure and CPU overhead from parsing color strings (`addColorStop`). -**Action:** Cache gradients and dimension measurements at the `resize` event level rather than the `draw` level, as they only change when the window bounds change. \ No newline at end of file +**Action:** Cache gradients and dimension measurements at the `resize` event level rather than the `draw` level, as they only change when the window bounds change. +## 2024-05-18 - App-wide Re-renders from Root Timers + +**Learning:** When a React state that updates frequently (e.g., `now` updating every second) is placed at the top level of the app (`App.tsx`), it causes the entire component tree to re-render. If this state is only needed by a deeply nested component (e.g., a countdown timer), it introduces massive unnecessary overhead. +**Action:** Push frequently updating state down the component tree as close as possible to where it is used. Avoid placing intervals in root components. diff --git a/extension/webview/App.tsx b/extension/webview/App.tsx index d9d0cfd..0c40665 100644 --- a/extension/webview/App.tsx +++ b/extension/webview/App.tsx @@ -47,7 +47,6 @@ export function App() { const [projectReport, setProjectReport] = useState(); const [attestation, setAttestation] = useState(); const [notice, setNotice] = useState<{ tone: "error" | "ok"; message: string }>(); - const [now, setNow] = useState(Date.now()); const send = (message: unknown) => vscode.postMessage(message); useEffect(() => { @@ -116,12 +115,6 @@ export function App() { return () => window.removeEventListener("message", listener); }, []); - useEffect(() => { - if (state?.rep.phase !== "active") return; - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, [state?.rep.phase]); - useEffect(() => { if (!notice) return; const timeout = window.setTimeout(() => setNotice(undefined), 4500); @@ -140,13 +133,6 @@ export function App() { setMentorResult(undefined); send({ type: "mentor", mode, reasoning }); }; - const remaining = useMemo(() => { - if (!state?.rep.startedAt) return "00:00"; - const end = state.rep.startedAt + state.rep.durationMinutes * 60_000; - const seconds = Math.max(0, Math.ceil((end - now) / 1000)); - return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; - }, [state?.rep.startedAt, state?.rep.durationMinutes, now]); - if (!state) return ; return ( @@ -188,7 +174,7 @@ export function App() { send={send} /> )} - {route === "focus" && } + {route === "focus" && } {route === "monad" && } diff --git a/extension/webview/FocusDock.tsx b/extension/webview/FocusDock.tsx index 02a60cf..100521f 100644 --- a/extension/webview/FocusDock.tsx +++ b/extension/webview/FocusDock.tsx @@ -1,17 +1,16 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; import type { ClientState, Ownership } from "../src/types"; import { FocusIcon, TestIcon } from "./SidebarIcons"; interface Props { state: ClientState; - remaining: string; send(message: unknown): void; } -export function FocusDock({ state, remaining, send }: Props) { +export function FocusDock({ state, send }: Props) { const { rep } = state; if (rep.phase === "idle") return ; - if (rep.phase === "active") return ; + if (rep.phase === "active") return ; return ; } @@ -89,11 +88,28 @@ function StartFocus({ aiExtensions, send }: { aiExtensions: string[]; send(messa ); } -function ActiveFocus({ state, remaining, send }: Props) { +function ActiveFocus({ state, send }: Props) { const [hypothesis, setHypothesis] = useState(""); const [finishing, setFinishing] = useState(false); const [outcome, setOutcome] = useState(""); const [ownership, setOwnership] = useState(2); + const [now, setNow] = useState(Date.now()); + + // ⚡ Bolt: Moved 1-second interval timer and `remaining` state down from App.tsx into ActiveFocus. + // Expected impact: Eliminates forced 1-second application-wide re-renders in App.tsx when Focus Rep is active. + + useEffect(() => { + if (state?.rep.phase !== "active") return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [state?.rep.phase]); + + const remaining = useMemo(() => { + if (!state?.rep.startedAt) return "00:00"; + const end = state.rep.startedAt + state.rep.durationMinutes * 60_000; + const seconds = Math.max(0, Math.ceil((end - now) / 1000)); + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; + }, [state?.rep.startedAt, state?.rep.durationMinutes, now]); const add = (event: FormEvent) => { event.preventDefault(); send({ type: "addHypothesis", text: hypothesis });