From 66e7054928cc29395f828467c3e8c81b7408dd29 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 29 Jul 2026 15:48:06 -0400 Subject: [PATCH] fix(desktop): deduplicate relay outage notification (#3579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary - Keep the relay reconnect notification dismissed across repeated connection retries during one continuous outage. - Re-arm the notification after recovery or relay lifecycle replacement, including switches between communities that use the same relay URL. - Preserve the existing dedicated path for authentication and other application-level errors. ### How an outage is tracked The AppShell-owned relay-card hook treats an outage as one contiguous runtime episode rather than assigning it a persisted ID. A hook-local `outageActiveRef` is armed by the first qualifying unreachable/degraded observation. While it is armed, intermediate retry states (`connecting`, `reconnecting`, `stalled`, and `disconnected`) belong to that same episode, so retry churn cannot clear dismissal or emit another notification. The hook receives the same lifecycle identity used by community initialization: community ID plus `reinitKey`. This distinguishes multiple communities even when they share a relay URL, and it also changes when the active community is explicitly reinitialized. The latch and dismissal are reset when that identity changes or when the relay singleton reports its authoritative `idle` teardown state. A successful `connected` state also closes the episode and re-arms the next outage. These boundaries deliberately bias toward re-notifying rather than suppressing a later outage: recovery, community switch/reinit, or relay teardown cannot leave the hook stuck believing an old outage is still active. No outage state is persisted beyond the mounted hook lifecycle. ### Related issue None found. ### Testing - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,769 passed - `pnpm --dir desktop check` - `pnpm --dir desktop build:e2e` - `pnpm --dir desktop exec playwright test tests/e2e/sidebar-relay-card.spec.ts --project=integration` — 11 passed --------- Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src/app/AppShell.tsx | 1 + .../ui/useSidebarRelayConnectionCard.ts | 35 +++++++--- desktop/tests/e2e/sidebar-relay-card.spec.ts | 68 +++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 75f57257cc..abbbf29610 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -231,6 +231,7 @@ export function AppShell() { const relayConnectionCard = useSidebarRelayConnectionCard( channelsErrorMessage, communitiesHook.activeCommunity?.relayUrl, + `${communitiesHook.activeCommunity?.id ?? "none"}-${communitiesHook.reinitKey}`, ); const memberChannels = React.useMemo( () => channels.filter((channel) => channel.isMember), diff --git a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts index f92e1a02e8..11f2bd1064 100644 --- a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts +++ b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts @@ -63,6 +63,7 @@ function isDocumentVisible() { export function useSidebarRelayConnectionCard( errorMessage?: string, relayUrl?: string | null, + relayLifecycleKey = relaySuccessKey(relayUrl), ) { const relayConnectionState = useRelayConnection(); const hasRelayUnreachableError = errorMessage @@ -79,7 +80,6 @@ export function useSidebarRelayConnectionCard( relayConnectionState === "stalled" || (relayConnectionState === "disconnected" && !hasNonUnreachableError); const isRelayConnectionConnected = relayConnectionState === "connected"; - const isRelayConnectionDisconnected = relayConnectionState === "disconnected"; const [isDismissed, setIsDismissed] = React.useState(false); const hasSuccess = React.useSyncExternalStore( subscribeRelayConnectivitySuccess, @@ -95,6 +95,8 @@ export function useSidebarRelayConnectionCard( const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected; const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess; const show = canShow && !isDismissed; + const outageActiveRef = React.useRef(false); + const outageRelayLifecycleKeyRef = React.useRef(relayLifecycleKey); const wasProblemCardVisibleRef = React.useRef(false); const { isPending: isReconnectPending, @@ -111,30 +113,41 @@ export function useSidebarRelayConnectionCard( isReconnectPending || connectivityAction === "relay-connection"; React.useEffect(() => { - if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) { + if (outageRelayLifecycleKeyRef.current !== relayLifecycleKey) { + outageRelayLifecycleKeyRef.current = relayLifecycleKey; + outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; setIsDismissed(false); } - }, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]); - React.useEffect(() => { - if (isRelayConnectionStateDegraded || isRelayConnectionDisconnected) { - setRelayConnectivitySuccess(relayUrl, false); + if (relayConnectionState === "idle") { + outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; setIsDismissed(false); + return; } - }, [isRelayConnectionDisconnected, isRelayConnectionStateDegraded, relayUrl]); - React.useEffect(() => { if (isRelayConnectionActuallyDegraded) { + if (!outageActiveRef.current) { + outageActiveRef.current = true; + setRelayConnectivitySuccess(relayUrl, false); + setIsDismissed(false); + } wasProblemCardVisibleRef.current = show && !isRelayConnectionSuccess; return; } - if (wasProblemCardVisibleRef.current && isRelayConnectionConnected) { - wasProblemCardVisibleRef.current = false; - setRelayConnectivitySuccess(relayUrl, true); + if (outageActiveRef.current && isRelayConnectionConnected) { + outageActiveRef.current = false; + if (wasProblemCardVisibleRef.current) { + wasProblemCardVisibleRef.current = false; + setRelayConnectivitySuccess(relayUrl, true); + } } }, [ isRelayConnectionSuccess, + relayLifecycleKey, + relayConnectionState, relayUrl, show, isRelayConnectionActuallyDegraded, diff --git a/desktop/tests/e2e/sidebar-relay-card.spec.ts b/desktop/tests/e2e/sidebar-relay-card.spec.ts index 9505c0f10f..b9903614d2 100644 --- a/desktop/tests/e2e/sidebar-relay-card.spec.ts +++ b/desktop/tests/e2e/sidebar-relay-card.spec.ts @@ -84,6 +84,25 @@ async function setRelayConnectionState( }, state); } +async function emitRelayConnectionState( + page: Page, + state: RelayConnectionState, +) { + await page.evaluate((nextState) => { + const setConnectionState = ( + window as Window & { + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: ( + state: RelayConnectionState, + ) => void; + } + ).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__; + if (!setConnectionState) { + throw new Error("Mock relay connection state helper is not installed."); + } + setConnectionState(nextState); + }, state); +} + async function expectGenericReconnectCard(page: Page) { const card = page.getByTestId("sidebar-relay-unreachable"); await expect(card).toBeVisible(); @@ -109,6 +128,55 @@ test("sidebar generic relay failures use the reconnect card", async ({ await expectGenericReconnectCard(page); }); +test("relay outage notification stays dismissed through retries and re-arms after recovery", async ({ + page, +}) => { + await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); + await page.goto("/"); + await setRelayConnectionState(page, "disconnected"); + + const card = await expectGenericReconnectCard(page); + await card + .getByRole("button", { name: "Dismiss relay notification" }) + .click({ force: true }); + await expect(card).toBeHidden(); + + // Retry churn is still the same outage: no successful connection occurred. + await emitRelayConnectionState(page, "connecting"); + await emitRelayConnectionState(page, "disconnected"); + await emitRelayConnectionState(page, "reconnecting"); + await page.waitForTimeout(2_100); + await expect(card).toBeHidden(); + + // A successful connection ends the episode and re-arms the next outage. + await setChannelsReadError(page, null); + await emitRelayConnectionState(page, "connected"); + await setChannelsReadError(page, CONNECT_ERROR); + await emitRelayConnectionState(page, "disconnected"); + await expectGenericReconnectCard(page); +}); + +test("relay outage notification re-arms after same-URL lifecycle teardown", async ({ + page, +}) => { + await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); + await page.goto("/"); + await setRelayConnectionState(page, "disconnected"); + + const card = await expectGenericReconnectCard(page); + await card + .getByRole("button", { name: "Dismiss relay notification" }) + .click({ force: true }); + await expect(card).toBeHidden(); + + // Community switches and reconnectCommunity() tear down the singleton to + // idle before applying the next lifecycle. The next lifecycle may reuse the + // same relay URL, so URL identity alone must not preserve the old dismissal. + await emitRelayConnectionState(page, "idle"); + await emitRelayConnectionState(page, "disconnected"); + await expectGenericReconnectCard(page); +}); + test("sidebar proxy sign-in failures use the reconnect card", async ({ page, }) => {