fix(desktop): deduplicate relay outage notification (#3579)

🤖
## 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>
This commit is contained in:
Logan Johnson
2026-07-29 15:48:06 -04:00
committed by GitHub
parent b42a8d447e
commit 66e7054928
3 changed files with 93 additions and 11 deletions

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,
}) => {