Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/loopover-ui/src/components/site/state-views.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StateBoundary isError errorLabel="Widgets" onRetry={() => {}}>
<div>content</div>
</StateBoundary>,
);
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(
<StateBoundary isError errorLabel="Widgets" onRetry={() => {}}>
<div>content</div>
</StateBoundary>,
);
}
expect(notifyApiFailure).toHaveBeenCalledTimes(1);

// Leave the error state…
rerender(
<StateBoundary isError={false} errorLabel="Widgets" onRetry={() => {}}>
<div>content</div>
</StateBoundary>,
);
expect(notifyApiFailure).toHaveBeenCalledTimes(1);

// …then re-enter: exactly one more notification for the second false→true edge.
rerender(
<StateBoundary isError errorLabel="Widgets" onRetry={() => {}}>
<div>content</div>
</StateBoundary>,
);
expect(notifyApiFailure).toHaveBeenCalledTimes(2);
});
});
11 changes: 9 additions & 2 deletions packages/loopover-ui-kit/src/components/state-views.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -277,6 +283,7 @@ export function StateBoundary({
retry: onRetry,
});
}
wasFailureNotifyActive.current = failureNotifyActive;
}, [isError, errorLabel, errorKind, resolvedErrorDescription, onRetry, onFailureNotify]);

if (isLoading) {
Expand Down
Loading