Files
buzz/desktop/src/app/useAppShellLifecycleEffects.ts
T
59f613c404 fix(desktop): defer foreground resume work (#5696)
## Summary

- defer foreground resume work until the activation task has returned, a
frame has painted, and a trailing task gets a turn
- centralize app-focus subscribers and remove the broad TanStack
`refetchOnWindowFocus` fan-out
- coalesce relay recovery and preserve an explicit deferred refresh only
for workflow data without a polling/push freshness path
- defer the notification permission native check while keeping blur and
cheap correctness signals immediate

## Why

Buzz Desktop 0.5.10 can spend roughly 1.5 seconds in the WebKit
window-focus listener/microtask checkpoint before returning to the run
loop. Focus currently fans out into query refetches, React polling
updates, relay reconnect/replay, and native work in one activation turn.
This patch establishes an interaction-first foreground boundary rather
than letting those consumers compete with the activating input and first
paint.

## Validation

- focused foreground/workflow/relay tests: 18/18 passed before commit
- `pnpm --dir desktop typecheck`: passed before commit
- pre-commit desktop check and file-size gate: passed
- pre-push desktop check, typecheck, and full desktop unit suite:
4,743/4,743 passed at `704e7b4b6618fafce655bb2b07c7a9fe0fc8c643`
- Princess Donut independent adversarial review: PASS after two
lifecycle/freshness blockers were resolved

## Manual test

1. Install the PR build and use Buzz long enough to populate channels,
home, workflows, agents, and other polling surfaces.
2. Switch to another app for 30-60 seconds.
3. Return by clicking Buzz and immediately click a channel or scroll.
4. Confirm the first interaction and paint are prompt, then confirm
channels/home/workflows refresh and a degraded relay reconnects after
the activation boundary.
5. Repeat while rapidly switching away again to verify no resume work
starts after focus has been lost.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-12 13:46:49 -07:00

94 lines
2.7 KiB
TypeScript

import * as React from "react";
import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
import { useForegroundQueryRefresh } from "@/features/workflows/hooks";
import { relayClient } from "@/shared/api/relayClient";
import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers";
type AppShellLifecycleEffectsOptions = {
desktopBadgeEnabled: boolean;
homeBadgeCountExcludingHighPriority: number;
unreadChannelIds: ReadonlySet<string>;
unreadChannelNotificationCount: number;
};
export function useAppShellLifecycleEffects({
desktopBadgeEnabled,
homeBadgeCountExcludingHighPriority,
unreadChannelIds,
unreadChannelNotificationCount,
}: AppShellLifecycleEffectsOptions) {
// Event-driven reconnect: network online / focus / visibility short-circuit
// the backoff timer when the relay session is degraded (CMD+R gap G1).
useRelayResumeTriggers();
useForegroundQueryRefresh();
// Prevent webview file:/// navigation on file drop outside the composer.
// Scoped to file drags only (text drag-and-drop into inputs still works).
// Composer's onDrop fires first (React synthetic before window bubble).
React.useEffect(() => {
function preventNavigation(e: DragEvent) {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
}
}
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
};
}, []);
React.useEffect(() => {
let isCancelled = false;
const startPreconnect = () => {
if (isCancelled) {
return;
}
void relayClient.preconnect().catch((error) => {
if (!isCancelled) {
console.error("Failed to preconnect to relay", error);
}
});
};
if ("requestIdleCallback" in window) {
const idleId = window.requestIdleCallback(startPreconnect, {
timeout: 1_500,
});
return () => {
isCancelled = true;
window.cancelIdleCallback(idleId);
};
}
const timeoutId = globalThis.setTimeout(startPreconnect, 250);
return () => {
isCancelled = true;
globalThis.clearTimeout(timeoutId);
};
}, []);
React.useEffect(() => {
if (!desktopBadgeEnabled) {
return;
}
const count =
unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority;
void setDesktopAppBadge(
count
? { kind: "count", count }
: { kind: unreadChannelIds.size ? "dot" : "none" },
);
}, [
desktopBadgeEnabled,
homeBadgeCountExcludingHighPriority,
unreadChannelIds,
unreadChannelNotificationCount,
]);
}