Merge pull request #2267 from modelcontextprotocol/v2/fix/2219-toast-teardown-set

fix: swap the toast ref Set on teardown instead of clearing it
This commit is contained in:
Cliff Hall 2026-09-06 12:00:07 -04:00 • committed by GitHub
commit 6d93a33a40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 98 additions and 3 deletions

View file

@ -43,6 +43,18 @@ function harness(client: InspectorClientEventTarget) {
total,
});
}),
/** Same as `progress`, but from a client swapped in after mount. */
progressOn: (
next: InspectorClientEventTarget,
progressToken: string,
progress: number,
) =>
act(() => {
next.dispatchTypedEvent("progressNotification", {
progressToken,
progress,
});
}),
swapClient: (next: InspectorClientEventTarget | null) =>
act(() => rerender(<Probe c={next} />)),
unmount: () => act(() => unmount()),
@ -141,4 +153,31 @@ describe("useProgressToasts", () => {
progressToastId("tok-1"),
);
});
// #2219: `hide()` defers `onClose` past the exit transition, so an outgoing
// toast's callback can fire *after* the new session has re-shown the same id
// (ids come from the progress token, so a reconnect replays them exactly).
// The teardown swaps in a fresh Set rather than clearing the shared one, so
// that late callback must not touch the new session's bookkeeping. No timers
// needed: calling the captured `onClose` by hand is exactly that late fire.
it("keeps the new session's bookkeeping when a hidden toast's onClose fires late", () => {
const h = harness(fakeClient());
h.progress("tok-1", 1);
const { onClose: staleOnClose } = notificationsMock.show.mock
.calls[0][0] as { onClose: () => void };
const next = fakeClient();
h.swapClient(next);
h.progressOn(next, "tok-1", 1);
expect(notificationsMock.show).toHaveBeenCalledTimes(2);
// The outgoing session's toast finishes its exit transition here.
staleOnClose();
h.progressOn(next, "tok-1", 2);
expect(notificationsMock.show).toHaveBeenCalledTimes(2);
expect(notificationsMock.update).toHaveBeenCalledWith(
expect.objectContaining({ id: progressToastId("tok-1") }),
);
});
});

View file

@ -86,8 +86,22 @@ export function useProgressToasts(
// "Tool progress" toast from lingering into the next session, and avoids
// a race where the lingering toast's `onClose` would later delete an id
// from the *new* session's set and trigger a duplicate-id re-show.
//
// The bookkeeping is dropped by **swapping in a fresh Set**, not by
// calling `liveToastIds.clear()` — the distinction is load-bearing and
// invisible otherwise (#2219). `hide()` does not fire `onClose`
// synchronously; it plays the toast's exit transition first. Every
// outgoing toast's `onClose` closes over *this* Set object, so clearing
// it in place would leave those callbacks pointed at the very container
// the next session refills — and a delayed `onClose` for an id the new
// session has already re-shown (both hooks derive ids from data, so a
// reconnect replays them exactly) would delete the new entry. The stream
// would then take the `show` branch instead of `update`, Mantine would
// refuse the duplicate id, and the toast would freeze mid-progress.
// Reassigning detaches the outgoing session's callbacks onto a Set
// nothing reads again.
liveToastIds.forEach((id) => notifications.hide(id));
liveToastIds.clear();
progressToastIdsRef.current = new Set();
};
}, [inspectorClient]);
}

View file

@ -83,6 +83,18 @@ function harness(client: InspectorClientEventTarget) {
if (!latest) throw new Error("hook did not render");
fn(latest);
}),
/** Same as `statusChange`, but from a client swapped in after mount. */
statusChangeOn: (
next: InspectorClientEventTarget,
taskId: string,
status: Task["status"],
) =>
dispatch(() =>
next.dispatchTypedEvent("taskStatusChange", {
taskId,
task: task(taskId, status),
}),
),
swapClient: (next: InspectorClientEventTarget | null) =>
act(() => rerender(<Probe c={next} />)),
unmount: () => act(() => unmount()),
@ -270,6 +282,31 @@ describe("useTaskToasts", () => {
expect(notificationsMock.show).not.toHaveBeenCalled();
});
// #2219: mirrors the `useProgressToasts` case — `hide()` defers `onClose`
// past the exit transition, and task ids are equally replayable across a
// reconnect, so the teardown swaps in a fresh Set instead of clearing the
// one the outgoing toasts' callbacks close over.
it("keeps the new session's bookkeeping when a hidden toast's onClose fires late", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");
const { onClose: staleOnClose } = notificationsMock.show.mock
.calls[0][0] as { onClose: () => void };
const next = fakeClient();
h.swapClient(next);
h.statusChangeOn(next, "t1", "working");
expect(notificationsMock.show).toHaveBeenCalledTimes(2);
// The outgoing session's toast finishes its exit transition here.
staleOnClose();
h.statusChangeOn(next, "t1", "working");
expect(notificationsMock.show).toHaveBeenCalledTimes(2);
expect(notificationsMock.update).toHaveBeenCalledWith(
expect.objectContaining({ id: taskToastId("t1") }),
);
});
it("hides the live toasts on unmount", () => {
const h = harness(fakeClient());
h.statusChange("t1", "working");

View file

@ -220,9 +220,14 @@ export function useTaskToasts(
inspectorClient.removeEventListener("taskCancelled", onTaskCancelled);
// Hide any still-visible task toasts on client swap so they don't linger
// into the next session, then drop the bookkeeping (mirrors the progress-
// toast teardown).
// toast teardown). As there, the drop is a **swap to a fresh Set** rather
// than `liveToastIds.clear()`: `hide()` defers `onClose` until the exit
// transition ends, and those callbacks close over this Set object, so
// clearing in place would let a late `onClose` delete the *next*
// session's entry for the same task id and freeze that task's toast
// (#2219). See `useProgressToasts` for the full reasoning.
liveToastIds.forEach((id) => notifications.hide(id));
liveToastIds.clear();
taskToastIdsRef.current = new Set();
};
}, [inspectorClient]);