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:
commit
6d93a33a40
4 changed files with 98 additions and 3 deletions
|
|
@ -43,6 +43,18 @@ function harness(client: InspectorClientEventTarget) {
|
||||||
total,
|
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) =>
|
swapClient: (next: InspectorClientEventTarget | null) =>
|
||||||
act(() => rerender(<Probe c={next} />)),
|
act(() => rerender(<Probe c={next} />)),
|
||||||
unmount: () => act(() => unmount()),
|
unmount: () => act(() => unmount()),
|
||||||
|
|
@ -141,4 +153,31 @@ describe("useProgressToasts", () => {
|
||||||
progressToastId("tok-1"),
|
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") }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,22 @@ export function useProgressToasts(
|
||||||
// "Tool progress" toast from lingering into the next session, and avoids
|
// "Tool progress" toast from lingering into the next session, and avoids
|
||||||
// a race where the lingering toast's `onClose` would later delete an id
|
// 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.
|
// 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.forEach((id) => notifications.hide(id));
|
||||||
liveToastIds.clear();
|
progressToastIdsRef.current = new Set();
|
||||||
};
|
};
|
||||||
}, [inspectorClient]);
|
}, [inspectorClient]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,18 @@ function harness(client: InspectorClientEventTarget) {
|
||||||
if (!latest) throw new Error("hook did not render");
|
if (!latest) throw new Error("hook did not render");
|
||||||
fn(latest);
|
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) =>
|
swapClient: (next: InspectorClientEventTarget | null) =>
|
||||||
act(() => rerender(<Probe c={next} />)),
|
act(() => rerender(<Probe c={next} />)),
|
||||||
unmount: () => act(() => unmount()),
|
unmount: () => act(() => unmount()),
|
||||||
|
|
@ -270,6 +282,31 @@ describe("useTaskToasts", () => {
|
||||||
expect(notificationsMock.show).not.toHaveBeenCalled();
|
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", () => {
|
it("hides the live toasts on unmount", () => {
|
||||||
const h = harness(fakeClient());
|
const h = harness(fakeClient());
|
||||||
h.statusChange("t1", "working");
|
h.statusChange("t1", "working");
|
||||||
|
|
|
||||||
|
|
@ -220,9 +220,14 @@ export function useTaskToasts(
|
||||||
inspectorClient.removeEventListener("taskCancelled", onTaskCancelled);
|
inspectorClient.removeEventListener("taskCancelled", onTaskCancelled);
|
||||||
// Hide any still-visible task toasts on client swap so they don't linger
|
// 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-
|
// 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.forEach((id) => notifications.hide(id));
|
||||||
liveToastIds.clear();
|
taskToastIdsRef.current = new Set();
|
||||||
};
|
};
|
||||||
}, [inspectorClient]);
|
}, [inspectorClient]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue