From e21687300e8d53345b180d24f4bf3f18fa603d1d Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Sun, 8 Mar 2026 06:21:52 +0000 Subject: [PATCH 1/9] perf: debounce composer draft localStorage writes Every keystroke triggered full JSON serialization of all composer drafts (including base64 image attachments) and a synchronous localStorage write. At normal typing speed this caused 5+ writes/sec, blocking the main thread and creating noticeable input lag. Wrap the Zustand persist storage with a 300ms debounce. In-memory state updates remain immediate; only the serialization and storage write are deferred. A beforeunload handler flushes pending writes to prevent data loss. The removeItem method cancels any pending setItem to avoid resurrecting cleared drafts. Adds unit tests for the DebouncedStorage utility covering debounce timing, rapid writes, removeItem cancellation, flush, and edge cases. --- apps/web/src/composerDraftStore.test.ts | 128 +++++++++++++++++++++++- apps/web/src/composerDraftStore.ts | 62 +++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index f133d377f2..2bcd9cbbce 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,7 +1,11 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; +import { + type ComposerImageAttachment, + createDebouncedStorage, + useComposerDraftStore, +} from "./composerDraftStore"; function makeImage(input: { id: string; @@ -451,3 +455,125 @@ describe("composerDraftStore runtime and interaction settings", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// createDebouncedStorage +// --------------------------------------------------------------------------- + +function createMockStorage() { + const store = new Map(); + return { + getItem: vi.fn((name: string) => store.get(name) ?? null), + setItem: vi.fn((name: string, value: string) => { + store.set(name, value); + }), + removeItem: vi.fn((name: string) => { + store.delete(name); + }), + }; +} + +describe("createDebouncedStorage", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("delegates getItem immediately", () => { + const base = createMockStorage(); + base.getItem.mockReturnValueOnce("value"); + const storage = createDebouncedStorage(base); + + expect(storage.getItem("key")).toBe("value"); + expect(base.getItem).toHaveBeenCalledWith("key"); + }); + + it("does not write to base storage until the debounce fires", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + expect(base.setItem).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(299); + expect(base.setItem).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + }); + + it("only writes the last value when setItem is called rapidly", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.setItem("key", "v2"); + storage.setItem("key", "v3"); + + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v3"); + }); + + it("removeItem cancels a pending setItem write", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + + vi.advanceTimersByTime(300); + expect(base.setItem).not.toHaveBeenCalled(); + expect(base.removeItem).toHaveBeenCalledWith("key"); + }); + + it("flush writes the pending value immediately", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + expect(base.setItem).not.toHaveBeenCalled(); + + storage.flush(); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + + // Timer should be cancelled; no duplicate write. + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + }); + + it("flush is a no-op when nothing is pending", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.flush(); + expect(base.setItem).not.toHaveBeenCalled(); + }); + + it("flush after removeItem is a no-op", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + storage.flush(); + + expect(base.setItem).not.toHaveBeenCalled(); + }); + + it("setItem works normally after removeItem cancels a pending write", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + storage.setItem("key", "v2"); + + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v2"); + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3ed3..6be7efe464 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,11 +15,69 @@ import { type ChatImageAttachment, } from "./types"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; export type DraftThreadEnvMode = "local" | "worktree"; +const COMPOSER_PERSIST_DEBOUNCE_MS = 300; + +interface DebouncedStorage extends StateStorage { + flush: () => void; +} + +export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage { + let timer: ReturnType | null = null; + let pendingName: string | null = null; + let pendingValue: string | null = null; + return { + getItem: (name) => baseStorage.getItem(name), + setItem: (name, value) => { + pendingName = name; + pendingValue = value; + if (timer !== null) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = null; + pendingName = null; + pendingValue = null; + baseStorage.setItem(name, value); + }, COMPOSER_PERSIST_DEBOUNCE_MS); + }, + removeItem: (name) => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + pendingName = null; + pendingValue = null; + } + baseStorage.removeItem(name); + }, + flush: () => { + if (timer !== null && pendingName !== null && pendingValue !== null) { + clearTimeout(timer); + timer = null; + baseStorage.setItem(pendingName, pendingValue); + pendingName = null; + pendingValue = null; + } + }, + }; +} + +const composerDebouncedStorage: DebouncedStorage = + typeof localStorage !== "undefined" + ? createDebouncedStorage(localStorage) + : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; + +// Flush pending composer draft writes before page unload to prevent data loss. +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + composerDebouncedStorage.flush(); + }); +} + export interface PersistedComposerImageAttachment { id: string; name: string; @@ -1169,7 +1227,7 @@ export const useComposerDraftStore = create()( { name: COMPOSER_DRAFT_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => composerDebouncedStorage), partialize: (state) => { const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { From dd51d4faec3f2352ce1b43f01046aca2fa150034 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Sun, 8 Mar 2026 06:22:02 +0000 Subject: [PATCH 2/9] perf: debounce Zustand store persistence and deduplicate legacy cleanup The useStore subscriber called persistState on every state mutation, triggering JSON.stringify + localStorage.setItem synchronously. It also ran 8 localStorage.removeItem calls for legacy keys on every fire. Wrap the subscriber with a 500ms debounce so rapid state changes batch into a single write. Move legacy key cleanup behind a one-time flag so it runs only once per page load. Add a beforeunload handler to flush the final state. --- apps/web/src/store.ts | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 65c9665378..d298b663bb 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -62,6 +62,8 @@ function readPersistedState(): AppState { } } +let legacyKeysCleanedUp = false; + function persistState(state: AppState): void { if (typeof window === "undefined") return; try { @@ -73,14 +75,29 @@ function persistState(state: AppState): void { .map((project) => project.cwd), }), ); - for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { - window.localStorage.removeItem(legacyKey); + if (!legacyKeysCleanedUp) { + legacyKeysCleanedUp = true; + for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { + window.localStorage.removeItem(legacyKey); + } } } catch { // Ignore quota/storage errors to avoid breaking chat UX. } } +let persistTimer: ReturnType | null = null; + +function debouncedPersistState(state: AppState): void { + if (persistTimer !== null) { + clearTimeout(persistTimer); + } + persistTimer = setTimeout(() => { + persistTimer = null; + persistState(state); + }, 500); +} + // ── Pure helpers ────────────────────────────────────────────────────── function updateThread( @@ -394,8 +411,19 @@ export const useStore = create((set) => ({ set((state) => setThreadBranch(state, threadId, branch, worktreePath)), })); -// Persist on every state change -useStore.subscribe((state) => persistState(state)); +// Persist state changes with debouncing to avoid localStorage thrashing +useStore.subscribe((state) => debouncedPersistState(state)); + +// Flush pending writes synchronously before page unload to prevent data loss. +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + persistState(useStore.getState()); + } + }); +} export function StoreProvider({ children }: { children: ReactNode }) { useEffect(() => { From a04e3561ede1b2f955b1541e3235a1814d47a549 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Sun, 8 Mar 2026 06:22:14 +0000 Subject: [PATCH 3/9] perf: throttle domain event processing with 100ms batch window During active sessions, every domain event triggered a full syncSnapshot (IPC fetch + state rebuild + React re-render cascade) and sometimes a provider query invalidation. Events fire in rapid bursts during AI turns. Replace per-event processing with a throttle-first pattern: schedule a flush on the first event, absorb subsequent events within a 100ms window, then sync once. Provider query invalidation is batched via a flag. Since syncSnapshot fetches the complete snapshot, no events are lost by skipping intermediate syncs. --- apps/web/src/routes/__root.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index eb3eca9cbd..cfb40a4e63 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -187,15 +187,34 @@ function EventRouter() { void syncSnapshot().catch(() => undefined); + // Batch domain events: collect over a short window then sync once. + const DOMAIN_EVENT_BATCH_MS = 100; + let batchTimer: ReturnType | null = null; + let needsProviderInvalidation = false; + + const flushBatch = () => { + batchTimer = null; + if (needsProviderInvalidation) { + needsProviderInvalidation = false; + void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + } + void syncSnapshot(); + }; + const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { if (event.sequence <= latestSequence) { return; } latestSequence = event.sequence; if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { - void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + needsProviderInvalidation = true; + } + // Throttle (not debounce): schedule a flush if none is pending. + // This guarantees at most one syncSnapshot per batch window + // without starving the UI during sustained event bursts. + if (batchTimer === null) { + batchTimer = setTimeout(flushBatch, DOMAIN_EVENT_BATCH_MS); } - void syncSnapshot(); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); @@ -280,6 +299,9 @@ function EventRouter() { }); return () => { disposed = true; + if (batchTimer !== null) { + clearTimeout(batchTimer); + } unsubDomainEvent(); unsubTerminalEvent(); unsubWelcome(); From c6dc7c65245813b77dc3c156267859ca4a9e790e Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Sun, 8 Mar 2026 06:22:26 +0000 Subject: [PATCH 4/9] perf: run provider health checks in background, notify clients on ready The ProviderHealth layer blocked server startup with two sequential CLI spawns (codex --version + codex login status), each with a 4-second timeout, delaying startup by up to 8 seconds. Run health checks in the background via Effect.runPromise so the layer resolves immediately with a placeholder status. Add an onReady callback to ProviderHealthShape so wsServer can push the resolved statuses to connected clients once checks complete, preventing early-connecting clients from showing "Checking..." indefinitely. --- .../src/provider/Layers/ProviderHealth.ts | 51 ++++++++++++++++++- .../src/provider/Services/ProviderHealth.ts | 4 ++ apps/server/src/wsServer.ts | 19 +++++-- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 59f41edf81..41fc382a6e 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -312,9 +312,56 @@ export const checkCodexProviderStatus: Effect.Effect< export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const codexStatus = yield* checkCodexProviderStatus; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let cachedStatuses: ReadonlyArray = [ + { + provider: CODEX_PROVIDER, + status: "warning", + available: false, + authStatus: "unknown", + checkedAt: new Date().toISOString(), + message: "Checking Codex CLI availability...", + }, + ]; + + let readyListeners: Array<(statuses: ReadonlyArray) => void> = []; + let resolved = false; + + const notifyReady = (statuses: ReadonlyArray) => { + resolved = true; + cachedStatuses = statuses; + for (const cb of readyListeners) cb(statuses); + readyListeners = []; + }; + + // Run health checks in the background so they don't block server startup. + checkCodexProviderStatus.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.runPromise, + ).then((status) => { + notifyReady([status]); + }).catch(() => { + notifyReady([ + { + provider: CODEX_PROVIDER, + status: "error", + available: false, + authStatus: "unknown", + checkedAt: new Date().toISOString(), + message: "Failed to check Codex CLI status.", + }, + ]); + }); + return { - getStatuses: Effect.succeed([codexStatus]), + getStatuses: Effect.sync(() => cachedStatuses), + onReady: (cb) => { + if (resolved) { + cb(cachedStatuses); + } else { + readyListeners.push(cb); + } + }, } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 318d7e18d0..3cc804a0c0 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -15,6 +15,10 @@ export interface ProviderHealthShape { * Read provider health statuses computed at server startup. */ readonly getStatuses: Effect.Effect>; + /** + * Register a callback invoked once when background health checks complete. + */ + readonly onReady: (cb: (statuses: ReadonlyArray) => void) => void; } export class ProviderHealth extends ServiceMap.Service()( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d8859c2fa5..2f7ed0bc45 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -268,7 +268,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - const providerStatuses = yield* providerHealth.getStatuses; + // Read provider statuses lazily so background health checks are reflected. + const getProviderStatuses = () => Effect.runSync(providerHealth.getStatuses); const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); @@ -631,11 +632,23 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< channel: WS_CHANNELS.serverConfigUpdated, data: { issues: event.issues, - providers: providerStatuses, + providers: getProviderStatuses(), }, }), ).pipe(Effect.forkIn(subscriptionsScope)); + // Push updated provider statuses to connected clients once background health checks finish. + providerHealth.onReady((statuses) => { + broadcastPush({ + type: "push", + channel: WS_CHANNELS.serverConfigUpdated, + data: { + issues: [], + providers: statuses, + }, + }).pipe(Effect.runPromise).catch(() => {}); + }); + yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); let welcomeBootstrapProjectId: ProjectId | undefined; @@ -883,7 +896,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: providerStatuses, + providers: getProviderStatuses(), availableEditors, }; From fd9883583428c8a3eb9dac5fbdeae58b7db6cf22 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 9 Mar 2026 12:18:33 -0700 Subject: [PATCH 5/9] cleanup Co-Authored-By: Claude Opus 4.6 --- .../src/provider/Layers/ProviderHealth.ts | 55 ++----------------- apps/server/src/wsServer.ts | 37 +++++++------ 2 files changed, 26 insertions(+), 66 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 41fc382a6e..4eb6a288cb 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -13,7 +13,7 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Effect, Layer, Option, Result, Stream } from "effect"; +import { Array, Effect, Fiber, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -312,56 +312,13 @@ export const checkCodexProviderStatus: Effect.Effect< export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - let cachedStatuses: ReadonlyArray = [ - { - provider: CODEX_PROVIDER, - status: "warning", - available: false, - authStatus: "unknown", - checkedAt: new Date().toISOString(), - message: "Checking Codex CLI availability...", - }, - ]; - - let readyListeners: Array<(statuses: ReadonlyArray) => void> = []; - let resolved = false; - - const notifyReady = (statuses: ReadonlyArray) => { - resolved = true; - cachedStatuses = statuses; - for (const cb of readyListeners) cb(statuses); - readyListeners = []; - }; - - // Run health checks in the background so they don't block server startup. - checkCodexProviderStatus.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.runPromise, - ).then((status) => { - notifyReady([status]); - }).catch(() => { - notifyReady([ - { - provider: CODEX_PROVIDER, - status: "error", - available: false, - authStatus: "unknown", - checkedAt: new Date().toISOString(), - message: "Failed to check Codex CLI status.", - }, - ]); - }); + const codexStatusFiber = yield* checkCodexProviderStatus.pipe( + Effect.map(Array.of), + Effect.forkScoped, + ); return { - getStatuses: Effect.sync(() => cachedStatuses), - onReady: (cb) => { - if (resolved) { - cb(cachedStatuses); - } else { - readyListeners.push(cb); - } - }, + getStatuses: Fiber.join(codexStatusFiber), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2f7ed0bc45..aee34cc888 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -26,6 +26,7 @@ import { WebSocketRequest, WsPush, WsResponse, + ServerProviderStatus, } from "@t3tools/contracts"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { @@ -268,9 +269,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - // Read provider statuses lazily so background health checks are reflected. - const getProviderStatuses = () => Effect.runSync(providerHealth.getStatuses); - const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); @@ -618,6 +616,23 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); + // Push updated provider statuses to connected clients once background health checks finish. + let providers: ReadonlyArray = []; + yield* providerHealth.getStatuses.pipe( + Effect.flatMap((statuses) => { + providers = statuses; + return broadcastPush({ + type: "push", + channel: WS_CHANNELS.serverConfigUpdated, + data: { + issues: [], + providers: statuses, + }, + }); + }), + Effect.forkIn(subscriptionsScope), + ); + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => broadcastPush({ type: "push", @@ -632,23 +647,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< channel: WS_CHANNELS.serverConfigUpdated, data: { issues: event.issues, - providers: getProviderStatuses(), + providers, }, }), ).pipe(Effect.forkIn(subscriptionsScope)); - // Push updated provider statuses to connected clients once background health checks finish. - providerHealth.onReady((statuses) => { - broadcastPush({ - type: "push", - channel: WS_CHANNELS.serverConfigUpdated, - data: { - issues: [], - providers: statuses, - }, - }).pipe(Effect.runPromise).catch(() => {}); - }); - yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); let welcomeBootstrapProjectId: ProjectId | undefined; @@ -896,7 +899,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: getProviderStatuses(), + providers, availableEditors, }; From 75533fbd404888a1224446bc11725be1871691f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Mar 2026 12:23:33 -0700 Subject: [PATCH 6/9] Remove ProviderHealth onReady callback from service interface - Drop the unused `onReady` hook from `ProviderHealthShape` - Keep startup health status access focused on `getStatuses` --- apps/server/src/provider/Services/ProviderHealth.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 3cc804a0c0..318d7e18d0 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -15,10 +15,6 @@ export interface ProviderHealthShape { * Read provider health statuses computed at server startup. */ readonly getStatuses: Effect.Effect>; - /** - * Register a callback invoked once when background health checks complete. - */ - readonly onReady: (cb: (statuses: ReadonlyArray) => void) => void; } export class ProviderHealth extends ServiceMap.Service()( From 4babb1a859e1d073c113f86d98889172d118edae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Mar 2026 12:28:26 -0700 Subject: [PATCH 7/9] Use Debouncer for store state persistence - Replace manual timeout debounce logic with `@tanstack/react-pacer`'s `Debouncer` - Persist updates via `maybeExecute` to reduce localStorage write thrashing - Flush pending persistence on `beforeunload` to avoid losing recent state --- apps/web/src/store.ts | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index d298b663bb..aeea639019 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -14,6 +14,7 @@ import { } from "@t3tools/shared/model"; import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; +import { Debouncer } from "@tanstack/react-pacer"; // ── State ──────────────────────────────────────────────────────────── @@ -85,18 +86,7 @@ function persistState(state: AppState): void { // Ignore quota/storage errors to avoid breaking chat UX. } } - -let persistTimer: ReturnType | null = null; - -function debouncedPersistState(state: AppState): void { - if (persistTimer !== null) { - clearTimeout(persistTimer); - } - persistTimer = setTimeout(() => { - persistTimer = null; - persistState(state); - }, 500); -} +const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); // ── Pure helpers ────────────────────────────────────────────────────── @@ -412,16 +402,12 @@ export const useStore = create((set) => ({ })); // Persist state changes with debouncing to avoid localStorage thrashing -useStore.subscribe((state) => debouncedPersistState(state)); +useStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); // Flush pending writes synchronously before page unload to prevent data loss. if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { - if (persistTimer !== null) { - clearTimeout(persistTimer); - persistTimer = null; - persistState(useStore.getState()); - } + debouncedPersistState.flush(); }); } From f9dab134721fd22c6687c9fd6bd3ca1624793028 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Mar 2026 12:45:34 -0700 Subject: [PATCH 8/9] Use React Pacer throttler for domain event snapshot sync - Replace manual timeout-based domain event batching with `Throttler` - Keep provider query invalidation batched with trailing 100ms flushes - Cancel throttler and reset invalidation flag during EventRouter cleanup --- apps/web/src/routes/__root.tsx | 43 +++++++++++++++------------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index cfb40a4e63..3d7a815f09 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -8,6 +8,7 @@ import { } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; @@ -150,6 +151,7 @@ function EventRouter() { let latestSequence = 0; let syncing = false; let pending = false; + let needsProviderInvalidation = false; const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); @@ -185,21 +187,20 @@ function EventRouter() { syncing = false; }; - void syncSnapshot().catch(() => undefined); - - // Batch domain events: collect over a short window then sync once. - const DOMAIN_EVENT_BATCH_MS = 100; - let batchTimer: ReturnType | null = null; - let needsProviderInvalidation = false; - - const flushBatch = () => { - batchTimer = null; - if (needsProviderInvalidation) { - needsProviderInvalidation = false; - void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); - } - void syncSnapshot(); - }; + const domainEventFlushThrottler = new Throttler( + () => { + if (needsProviderInvalidation) { + needsProviderInvalidation = false; + void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + } + void syncSnapshot(); + }, + { + wait: 100, + leading: false, + trailing: true, + }, + ); const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { if (event.sequence <= latestSequence) { @@ -209,12 +210,7 @@ function EventRouter() { if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { needsProviderInvalidation = true; } - // Throttle (not debounce): schedule a flush if none is pending. - // This guarantees at most one syncSnapshot per batch window - // without starving the UI during sustained event bursts. - if (batchTimer === null) { - batchTimer = setTimeout(flushBatch, DOMAIN_EVENT_BATCH_MS); - } + domainEventFlushThrottler.maybeExecute(); }); const unsubTerminalEvent = api.terminal.onEvent((event) => { const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); @@ -299,9 +295,8 @@ function EventRouter() { }); return () => { disposed = true; - if (batchTimer !== null) { - clearTimeout(batchTimer); - } + needsProviderInvalidation = false; + domainEventFlushThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); unsubWelcome(); From eca89790d4d625438a8f18fccab864bbeeb04682 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Mar 2026 13:00:27 -0700 Subject: [PATCH 9/9] Use Debouncer for composer draft persistence writes - Replace manual timeout/pending-value debounce logic with `@tanstack/react-pacer` `Debouncer` - Keep `removeItem`/`flush` behavior while simplifying and standardizing persistence timing --- apps/web/src/composerDraftStore.ts | 38 +++++++++--------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 6be7efe464..0369b97735 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -14,6 +14,7 @@ import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment, } from "./types"; +import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; @@ -27,41 +28,24 @@ interface DebouncedStorage extends StateStorage { } export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage { - let timer: ReturnType | null = null; - let pendingName: string | null = null; - let pendingValue: string | null = null; + const debouncedSetItem = new Debouncer( + (name: string, value: string) => { + baseStorage.setItem(name, value); + }, + { wait: COMPOSER_PERSIST_DEBOUNCE_MS }, + ); + return { getItem: (name) => baseStorage.getItem(name), setItem: (name, value) => { - pendingName = name; - pendingValue = value; - if (timer !== null) { - clearTimeout(timer); - } - timer = setTimeout(() => { - timer = null; - pendingName = null; - pendingValue = null; - baseStorage.setItem(name, value); - }, COMPOSER_PERSIST_DEBOUNCE_MS); + debouncedSetItem.maybeExecute(name, value); }, removeItem: (name) => { - if (timer !== null) { - clearTimeout(timer); - timer = null; - pendingName = null; - pendingValue = null; - } + debouncedSetItem.cancel(); baseStorage.removeItem(name); }, flush: () => { - if (timer !== null && pendingName !== null && pendingValue !== null) { - clearTimeout(timer); - timer = null; - baseStorage.setItem(pendingName, pendingValue); - pendingName = null; - pendingValue = null; - } + debouncedSetItem.flush(); }, }; }