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) {