From d93f7243d2ac8c86cdff98fd51bff85aa1bbeb5c Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 12:04:23 -0400 Subject: [PATCH 1/2] fix(desktop): deduplicate relay outage notification Keep the reconnect card dismissed across retry-state churn and only re-arm it after a successful relay connection. Add coverage for cold-start failure, repeated retries, recovery, and a second outage. Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../ui/useSidebarRelayConnectionCard.ts | 31 ++++++------ desktop/tests/e2e/sidebar-relay-card.spec.ts | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts index f92e1a02e8..dcd4049cb0 100644 --- a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts +++ b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts @@ -79,7 +79,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 +94,8 @@ export function useSidebarRelayConnectionCard( const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected; const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess; const show = canShow && !isDismissed; + const outageActiveRef = React.useRef(false); + const outageRelayKeyRef = React.useRef(relaySuccessKey(relayUrl)); const wasProblemCardVisibleRef = React.useRef(false); const { isPending: isReconnectPending, @@ -111,27 +112,29 @@ export function useSidebarRelayConnectionCard( isReconnectPending || connectivityAction === "relay-connection"; React.useEffect(() => { - if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) { + const nextRelayKey = relaySuccessKey(relayUrl); + if (outageRelayKeyRef.current !== nextRelayKey) { + outageRelayKeyRef.current = nextRelayKey; + outageActiveRef.current = false; setIsDismissed(false); } - }, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]); - React.useEffect(() => { - if (isRelayConnectionStateDegraded || isRelayConnectionDisconnected) { - setRelayConnectivitySuccess(relayUrl, false); - setIsDismissed(false); - } - }, [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, diff --git a/desktop/tests/e2e/sidebar-relay-card.spec.ts b/desktop/tests/e2e/sidebar-relay-card.spec.ts index 9505c0f10f..85a7c09ef3 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,34 @@ 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("sidebar proxy sign-in failures use the reconnect card", async ({ page, }) => { From 2d5916517d0e6bd309bb53a3b58d16e22337c1f0 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 13:41:02 -0400 Subject: [PATCH 2/2] fix(desktop): re-arm relay outage on lifecycle reset Treat community lifecycle changes and idle teardown as explicit outage boundaries so same-URL community switches cannot preserve a dismissed outage. Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src/app/AppShell.tsx | 1 + .../ui/useSidebarRelayConnectionCard.ts | 18 ++++++++++++---- desktop/tests/e2e/sidebar-relay-card.spec.ts | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 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 dcd4049cb0..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 @@ -95,7 +96,7 @@ export function useSidebarRelayConnectionCard( const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess; const show = canShow && !isDismissed; const outageActiveRef = React.useRef(false); - const outageRelayKeyRef = React.useRef(relaySuccessKey(relayUrl)); + const outageRelayLifecycleKeyRef = React.useRef(relayLifecycleKey); const wasProblemCardVisibleRef = React.useRef(false); const { isPending: isReconnectPending, @@ -112,13 +113,20 @@ export function useSidebarRelayConnectionCard( isReconnectPending || connectivityAction === "relay-connection"; React.useEffect(() => { - const nextRelayKey = relaySuccessKey(relayUrl); - if (outageRelayKeyRef.current !== nextRelayKey) { - outageRelayKeyRef.current = nextRelayKey; + if (outageRelayLifecycleKeyRef.current !== relayLifecycleKey) { + outageRelayLifecycleKeyRef.current = relayLifecycleKey; outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; setIsDismissed(false); } + if (relayConnectionState === "idle") { + outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; + setIsDismissed(false); + return; + } + if (isRelayConnectionActuallyDegraded) { if (!outageActiveRef.current) { outageActiveRef.current = true; @@ -138,6 +146,8 @@ export function useSidebarRelayConnectionCard( } }, [ 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 85a7c09ef3..b9903614d2 100644 --- a/desktop/tests/e2e/sidebar-relay-card.spec.ts +++ b/desktop/tests/e2e/sidebar-relay-card.spec.ts @@ -156,6 +156,27 @@ test("relay outage notification stays dismissed through retries and re-arms afte 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, }) => {