diff --git a/apps/loopover-ui/src/components/site/state-views.test.tsx b/apps/loopover-ui/src/components/site/state-views.test.tsx index 5a64223d78..d822e972ab 100644 --- a/apps/loopover-ui/src/components/site/state-views.test.tsx +++ b/apps/loopover-ui/src/components/site/state-views.test.tsx @@ -138,3 +138,42 @@ describe("StateBoundary retry/refresh actions (#793 regression guard)", () => { expect(screen.getByText("content")).toBeTruthy(); }); }); + +describe("StateBoundary onFailureNotify edge-trigger (#7436)", () => { + it("notifies exactly once while staying in error across unstable callback identities, then again on re-entry", () => { + notifyApiFailure.mockClear(); + const { rerender } = render( + {}}> +
content
+
, + ); + expect(notifyApiFailure).toHaveBeenCalledTimes(1); + + // Simulate the app wrapper's fresh onFailureNotify arrow + a fresh onRetry each parent render + // while isError stays true — must NOT re-fire. + for (let i = 0; i < 4; i += 1) { + rerender( + {}}> +
content
+
, + ); + } + expect(notifyApiFailure).toHaveBeenCalledTimes(1); + + // Leave the error state… + rerender( + {}}> +
content
+
, + ); + expect(notifyApiFailure).toHaveBeenCalledTimes(1); + + // …then re-enter: exactly one more notification for the second false→true edge. + rerender( + {}}> +
content
+
, + ); + expect(notifyApiFailure).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/loopover-ui-kit/src/components/state-views.tsx b/packages/loopover-ui-kit/src/components/state-views.tsx index 9af81ab03e..27f0b2a36d 100644 --- a/packages/loopover-ui-kit/src/components/state-views.tsx +++ b/packages/loopover-ui-kit/src/components/state-views.tsx @@ -1,5 +1,5 @@ import { Loader2, Inbox, AlertTriangle, RefreshCw, WifiOff } from "lucide-react"; -import { useEffect, type ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { toast } from "sonner"; import { cn } from "../utils"; @@ -265,8 +265,14 @@ export function StateBoundary({ : "The data source did not respond. Retry the request, or check back once the service has recovered."); // When this boundary flips into the error state, surface a toast with Retry. + // Edge-triggered (#7436): only fire on false→true of (isError && errorLabel). Level-triggering here + // re-notified on every re-render while already errored because app wrappers pass a fresh + // `onFailureNotify` arrow each render (see apps/loopover-ui site/state-views.tsx). Mirrors the + // wasError-ref pattern used by mcp-version-badge for transition detection. + const wasFailureNotifyActive = useRef(false); useEffect(() => { - if (isError && errorLabel) { + const failureNotifyActive = Boolean(isError && errorLabel); + if (isError && errorLabel && !wasFailureNotifyActive.current) { onFailureNotify?.({ label: errorLabel, kind: errorKind ?? "network", @@ -277,6 +283,7 @@ export function StateBoundary({ retry: onRetry, }); } + wasFailureNotifyActive.current = failureNotifyActive; }, [isError, errorLabel, errorKind, resolvedErrorDescription, onRetry, onFailureNotify]); if (isLoading) {