fix(map): stabilize notifications during map navigation
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s

Ignore obsolete tile cancellations and update source notices without dismissing their existing toast. Keep notice input above the task ticker and disable swipe removal.

The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage.
This commit is contained in:
2026-11-08 03:23:00 +08:00
parent e0fe4a5a10
commit b948551c1f
6 changed files with 272 additions and 14 deletions
@@ -0,0 +1,52 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("keeps initial healthy sources quiet and announces recovery once", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
expect(showMapNotice).toHaveBeenCalledTimes(2);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledOnce();
});
it("updates a degraded source in place and only removes it when unmounted", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenCalledTimes(3);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("source");
});
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender(<MapErrorNotice id="new" message="另一项错误" />);
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
unmount();
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
});
it("keeps loading visible while its title or message changes", () => {
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
});
+20 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, type CSSProperties } from "react";
import { useEffect, useRef, type CSSProperties } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
@@ -17,10 +17,11 @@ export function MapToaster() {
position="top-center"
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
swipeDirections={[]}
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-none",
toast: "group pointer-events-auto",
closeButton:
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
}
@@ -30,6 +31,7 @@ export function MapToaster() {
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -38,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
message,
duration: Infinity
});
return () => dismissMapNotice(id);
}, [id, message]);
return null;
@@ -54,6 +54,7 @@ export function MapLoadingNotice({
title?: string;
id?: string | number;
}) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -63,8 +64,6 @@ export function MapLoadingNotice({
duration: Infinity,
dismissible: false
});
return () => dismissMapNotice(id);
}, [id, message, title]);
return null;
@@ -89,7 +88,16 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
}: MapSourceStatusNoticeProps) {
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
useNoticeCleanup(id);
useEffect(() => {
const last = previous.current;
previous.current = { id, status };
// Initial healthy sources and repeated tile-load messages stay quiet.
// Success is announced when the same source recovers from a problem.
if (status === "online" && !(actionLabel && onAction)
&& (!last || last.id !== id || last.status === "online")) return;
showMapNotice({
id,
tone: getSourceStatusTone(status),
@@ -99,13 +107,17 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
});
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]);
return null;
}
function useNoticeCleanup(id: string | number) {
// Content updates reuse the toast id. Dismissing during an update races
// Sonner's asynchronous removal against the newly published content.
useEffect(() => () => dismissMapNotice(id), [id]);
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
@@ -416,9 +416,8 @@ export function useWorkbenchMap({
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
});
map.on("sourcedataabort", (event) => {
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
});
// Moving or zooming cancels obsolete tile requests. Cancellation is part
// of navigation; source failures are reported by the error handler below.
map.on("error", (event) => {
const message = event.error?.message ?? "地图渲染异常";
+2 -2
View File
@@ -2445,7 +2445,7 @@ details[open] > summary > .disclosure-accordion-indicator {
height: min(720px, 80dvh);
max-width: 760px;
}
/* Layout only: keep the shared acrylic material and its shadows unclipped. */
/* Flow layout owns stack geometry; keep Sonner opacity transitions without its absolute offsets. */
[data-sonner-toaster].map-notice-stack {
display: flex;
flex-direction: column;
@@ -2461,7 +2461,7 @@ details[open] > summary > .disclosure-accordion-indicator {
bottom: auto;
flex-shrink: 0;
height: auto;
--y: translateY(0);
transform: none;
}
[data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] {
display: none;