-
Notifications
You must be signed in to change notification settings - Fork 897
perf(desktop): reduce polling, persistence, and render churn #2045
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2436e5b
eb38913
cf1a59f
0cd932f
b1b6c1d
13d2dd4
413338a
78990a8
23d881d
c3f0982
61d5138
c611a65
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { useEffect, useState } from "react"; | ||
|
|
||
| export function useDebouncedValue<T>(value: T, delayMs: number): T { | ||
| const [debouncedValue, setDebouncedValue] = useState(value); | ||
|
|
||
| useEffect(() => { | ||
| const timeoutId = setTimeout(() => { | ||
| setDebouncedValue(value); | ||
| }, delayMs); | ||
|
|
||
| return () => { | ||
| clearTimeout(timeoutId); | ||
| }; | ||
| }, [value, delayMs]); | ||
|
|
||
| return debouncedValue; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,37 +20,210 @@ export function setSkipNextHotkeysPersist(skip: boolean): void { | |||||||||||||||
| interface TrpcStorageConfig { | ||||||||||||||||
| get: () => Promise<unknown>; | ||||||||||||||||
| set: (input: unknown) => Promise<unknown>; | ||||||||||||||||
| writeDebounceMs?: number; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const PENDING_SNAPSHOT_TTL_MS = 5 * 60 * 1000; | ||||||||||||||||
| const LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS = 250; | ||||||||||||||||
|
|
||||||||||||||||
| function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { | ||||||||||||||||
| const debounceMs = config.writeDebounceMs ?? 0; | ||||||||||||||||
| let pendingValue: string | null = null; | ||||||||||||||||
| let lastFlushedValue: string | null = null; | ||||||||||||||||
| let flushTimer: ReturnType<typeof setTimeout> | null = null; | ||||||||||||||||
| let isFlushing = false; | ||||||||||||||||
| let pendingSnapshotValue: string | null = null; | ||||||||||||||||
| let pendingSnapshotTimer: ReturnType<typeof setTimeout> | null = null; | ||||||||||||||||
|
|
||||||||||||||||
| const getPendingSnapshotKey = (name: string) => `${name}:pending`; | ||||||||||||||||
| const getPendingSnapshotUpdatedAtKey = (name: string) => | ||||||||||||||||
| `${name}:pending:updatedAt`; | ||||||||||||||||
| const pendingSnapshotDebounceMs = | ||||||||||||||||
| debounceMs > 0 | ||||||||||||||||
| ? Math.min(debounceMs, LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS) | ||||||||||||||||
| : LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS; | ||||||||||||||||
|
|
||||||||||||||||
| const clearPendingSnapshot = (name: string, expectedValue?: string): void => { | ||||||||||||||||
| try { | ||||||||||||||||
| const pendingKey = getPendingSnapshotKey(name); | ||||||||||||||||
| if ( | ||||||||||||||||
| expectedValue !== undefined && | ||||||||||||||||
| localStorage.getItem(pendingKey) !== expectedValue | ||||||||||||||||
| ) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| localStorage.removeItem(pendingKey); | ||||||||||||||||
| localStorage.removeItem(getPendingSnapshotUpdatedAtKey(name)); | ||||||||||||||||
| } catch (error) { | ||||||||||||||||
| console.error("[trpc-storage] Failed to clear pending snapshot:", error); | ||||||||||||||||
| } | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| const schedulePendingSnapshotPersist = ( | ||||||||||||||||
| name: string, | ||||||||||||||||
| snapshot: string, | ||||||||||||||||
| ): void => { | ||||||||||||||||
| pendingSnapshotValue = snapshot; | ||||||||||||||||
|
|
||||||||||||||||
| if (pendingSnapshotTimer) { | ||||||||||||||||
| clearTimeout(pendingSnapshotTimer); | ||||||||||||||||
| pendingSnapshotTimer = null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| pendingSnapshotTimer = setTimeout(() => { | ||||||||||||||||
| pendingSnapshotTimer = null; | ||||||||||||||||
| const valueToPersist = pendingSnapshotValue; | ||||||||||||||||
| pendingSnapshotValue = null; | ||||||||||||||||
| if (!valueToPersist) return; | ||||||||||||||||
|
|
||||||||||||||||
| try { | ||||||||||||||||
| localStorage.setItem(getPendingSnapshotKey(name), valueToPersist); | ||||||||||||||||
| localStorage.setItem( | ||||||||||||||||
| getPendingSnapshotUpdatedAtKey(name), | ||||||||||||||||
| String(Date.now()), | ||||||||||||||||
| ); | ||||||||||||||||
| } catch (error) { | ||||||||||||||||
| console.error( | ||||||||||||||||
| "[trpc-storage] Failed to cache pending snapshot in localStorage:", | ||||||||||||||||
| error, | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
| }, pendingSnapshotDebounceMs); | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| const scheduleImmediateFlush = (name: string, snapshot: string): void => { | ||||||||||||||||
| // Ensure pending snapshot eventually syncs to appState. | ||||||||||||||||
| if (pendingValue === null) { | ||||||||||||||||
| pendingValue = snapshot; | ||||||||||||||||
| } | ||||||||||||||||
| if (!isFlushing && flushTimer === null) { | ||||||||||||||||
| flushTimer = setTimeout(() => { | ||||||||||||||||
| flushTimer = null; | ||||||||||||||||
| void flushPendingWrite(name); | ||||||||||||||||
| }, 0); | ||||||||||||||||
| } | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| const flushPendingWrite = async (name: string): Promise<void> => { | ||||||||||||||||
| if (isFlushing || pendingValue === null) return; | ||||||||||||||||
| const valueToFlush = pendingValue; | ||||||||||||||||
| pendingValue = null; | ||||||||||||||||
|
|
||||||||||||||||
| if (valueToFlush === lastFlushedValue) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| isFlushing = true; | ||||||||||||||||
| try { | ||||||||||||||||
| const parsed = JSON.parse(valueToFlush) as { | ||||||||||||||||
| state: unknown; | ||||||||||||||||
| version: number; | ||||||||||||||||
| }; | ||||||||||||||||
| // Persist version in localStorage, bare state via tRPC. | ||||||||||||||||
| localStorage.setItem(`${name}:version`, String(parsed.version)); | ||||||||||||||||
| await config.set(parsed.state); | ||||||||||||||||
| lastFlushedValue = valueToFlush; | ||||||||||||||||
|
|
||||||||||||||||
| // Cancel delayed snapshot write if this exact snapshot was already flushed. | ||||||||||||||||
| if (pendingSnapshotValue === valueToFlush && pendingSnapshotTimer) { | ||||||||||||||||
| clearTimeout(pendingSnapshotTimer); | ||||||||||||||||
| pendingSnapshotTimer = null; | ||||||||||||||||
| pendingSnapshotValue = null; | ||||||||||||||||
| } | ||||||||||||||||
| clearPendingSnapshot(name, valueToFlush); | ||||||||||||||||
| } catch (error) { | ||||||||||||||||
| console.error("[trpc-storage] Failed to set state:", error); | ||||||||||||||||
| } finally { | ||||||||||||||||
| isFlushing = false; | ||||||||||||||||
| if (pendingValue !== null) { | ||||||||||||||||
| if (debounceMs > 0) { | ||||||||||||||||
| flushTimer = setTimeout(() => { | ||||||||||||||||
| flushTimer = null; | ||||||||||||||||
| void flushPendingWrite(name); | ||||||||||||||||
| }, debounceMs); | ||||||||||||||||
| } else { | ||||||||||||||||
| void flushPendingWrite(name); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||||||||||||
| } | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| return { | ||||||||||||||||
| getItem: async (name: string): Promise<string | null> => { | ||||||||||||||||
| try { | ||||||||||||||||
| const state = await config.get(); | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Backend failure now prevents pending snapshot recovery. Moving Prompt for AI agents
Suggested change
|
||||||||||||||||
| if (!state) return null; | ||||||||||||||||
| // Version is stored in localStorage as a sidecar since the | ||||||||||||||||
| // tRPC backend validates bare state and rejects envelopes. | ||||||||||||||||
| const version = Number.parseInt( | ||||||||||||||||
| localStorage.getItem(`${name}:version`) ?? "0", | ||||||||||||||||
| 10, | ||||||||||||||||
| ); | ||||||||||||||||
| return JSON.stringify({ state, version }); | ||||||||||||||||
| const canonicalSnapshot = state | ||||||||||||||||
| ? JSON.stringify({ state, version }) | ||||||||||||||||
| : null; | ||||||||||||||||
|
|
||||||||||||||||
| const pendingSnapshot = localStorage.getItem( | ||||||||||||||||
| getPendingSnapshotKey(name), | ||||||||||||||||
| ); | ||||||||||||||||
| const pendingUpdatedAt = Number.parseInt( | ||||||||||||||||
| localStorage.getItem(getPendingSnapshotUpdatedAtKey(name)) ?? "0", | ||||||||||||||||
| 10, | ||||||||||||||||
| ); | ||||||||||||||||
| const pendingAgeMs = | ||||||||||||||||
| Number.isFinite(pendingUpdatedAt) && pendingUpdatedAt > 0 | ||||||||||||||||
| ? Date.now() - pendingUpdatedAt | ||||||||||||||||
| : Number.POSITIVE_INFINITY; | ||||||||||||||||
| const isPendingFresh = pendingAgeMs <= PENDING_SNAPSHOT_TTL_MS; | ||||||||||||||||
|
|
||||||||||||||||
| if (pendingSnapshot) { | ||||||||||||||||
| if (!canonicalSnapshot) { | ||||||||||||||||
| if (isPendingFresh) { | ||||||||||||||||
| scheduleImmediateFlush(name, pendingSnapshot); | ||||||||||||||||
| return pendingSnapshot; | ||||||||||||||||
| } | ||||||||||||||||
| clearPendingSnapshot(name); | ||||||||||||||||
| return null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (pendingSnapshot === canonicalSnapshot) { | ||||||||||||||||
| clearPendingSnapshot(name); | ||||||||||||||||
| return canonicalSnapshot; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Only trust pending snapshots that are very recent; otherwise | ||||||||||||||||
| // canonical appState remains the source of truth. | ||||||||||||||||
| if (isPendingFresh) { | ||||||||||||||||
| scheduleImmediateFlush(name, pendingSnapshot); | ||||||||||||||||
| return pendingSnapshot; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| clearPendingSnapshot(name); | ||||||||||||||||
| return canonicalSnapshot; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return canonicalSnapshot; | ||||||||||||||||
| } catch (error) { | ||||||||||||||||
| console.error("[trpc-storage] Failed to get state:", error); | ||||||||||||||||
| return null; | ||||||||||||||||
|
Comment on lines
153
to
205
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A localStorage exception inside this block currently drops to Suggested fix getItem: async (name: string): Promise<string | null> => {
try {
const state = await config.get();
- const version = Number.parseInt(
- localStorage.getItem(`${name}:version`) ?? "0",
- 10,
- );
+ let version = 0;
+ try {
+ version = Number.parseInt(
+ localStorage.getItem(`${name}:version`) ?? "0",
+ 10,
+ );
+ } catch (error) {
+ console.error("[trpc-storage] Failed to read version sidecar:", error);
+ }
const canonicalSnapshot = state
? JSON.stringify({ state, version })
: null;
- const pendingSnapshot = localStorage.getItem(
- getPendingSnapshotKey(name),
- );
- const pendingUpdatedAt = Number.parseInt(
- localStorage.getItem(getPendingSnapshotUpdatedAtKey(name)) ?? "0",
- 10,
- );
+ let pendingSnapshot: string | null = null;
+ let pendingUpdatedAt = 0;
+ try {
+ pendingSnapshot = localStorage.getItem(getPendingSnapshotKey(name));
+ pendingUpdatedAt = Number.parseInt(
+ localStorage.getItem(getPendingSnapshotUpdatedAtKey(name)) ?? "0",
+ 10,
+ );
+ } catch (error) {
+ console.error("[trpc-storage] Failed to read pending snapshot:", error);
+ }🤖 Prompt for AI Agents |
||||||||||||||||
| } | ||||||||||||||||
| }, | ||||||||||||||||
| setItem: async (name: string, value: string): Promise<void> => { | ||||||||||||||||
| try { | ||||||||||||||||
| const parsed = JSON.parse(value) as { | ||||||||||||||||
| state: unknown; | ||||||||||||||||
| version: number; | ||||||||||||||||
| }; | ||||||||||||||||
| // Persist version in localStorage, bare state via tRPC. | ||||||||||||||||
| localStorage.setItem(`${name}:version`, String(parsed.version)); | ||||||||||||||||
| await config.set(parsed.state); | ||||||||||||||||
| } catch (error) { | ||||||||||||||||
| console.error("[trpc-storage] Failed to set state:", error); | ||||||||||||||||
| if (value === pendingValue || value === lastFlushedValue) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| pendingValue = value; | ||||||||||||||||
| schedulePendingSnapshotPersist(name, value); | ||||||||||||||||
| if (flushTimer) { | ||||||||||||||||
| clearTimeout(flushTimer); | ||||||||||||||||
| flushTimer = null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (debounceMs > 0) { | ||||||||||||||||
| flushTimer = setTimeout(() => { | ||||||||||||||||
| flushTimer = null; | ||||||||||||||||
| void flushPendingWrite(name); | ||||||||||||||||
| }, debounceMs); | ||||||||||||||||
| } else { | ||||||||||||||||
| void flushPendingWrite(name); | ||||||||||||||||
| } | ||||||||||||||||
| }, | ||||||||||||||||
| removeItem: async (_name: string): Promise<void> => { | ||||||||||||||||
|
|
@@ -68,6 +241,7 @@ export const trpcTabsStorage = createJSONStorage(() => | |||||||||||||||
| get: () => electronTrpcClient.uiState.tabs.get.query(), | ||||||||||||||||
| // biome-ignore lint/suspicious/noExplicitAny: Zustand persist passes unknown, tRPC expects typed input | ||||||||||||||||
| set: (input) => electronTrpcClient.uiState.tabs.set.mutate(input as any), | ||||||||||||||||
| writeDebounceMs: 300, | ||||||||||||||||
| }), | ||||||||||||||||
| ); | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Persist the sidecar version only after the tRPC write succeeds.
Line 49 updates
${name}:versionbeforeconfig.set(parsed.state)resolves. If the tRPC write fails, hydrate can read old state with a newer version and skip required migrations.🔧 Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents