From 878a17f2aacf725406a6e0d272a4f4c8b2b1a1df Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Apr 2026 10:07:16 +0000 Subject: [PATCH 1/8] fix(settings): use shared saving label Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../[locale]/settings/config/_components/auto-cleanup-form.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx b/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx index 4f8b581ad..bdad4f2b7 100644 --- a/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx +++ b/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx @@ -29,6 +29,7 @@ interface AutoCleanupFormProps { export function AutoCleanupForm({ settings, onSuccess }: AutoCleanupFormProps) { const t = useTranslations("settings.config.form"); + const tCommon = useTranslations("settings.common"); const [isSubmitting, setIsSubmitting] = useState(false); const { @@ -190,7 +191,7 @@ export function AutoCleanupForm({ settings, onSuccess }: AutoCleanupFormProps) { {isSubmitting ? ( <> - {t("common.saving")} + {tCommon("saving")} ) : ( t("saveConfig") From 6f3b4736f2d2c58491350103f9c63e43c9100301 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Apr 2026 10:07:16 +0000 Subject: [PATCH 2/8] fix(i18n): redirect bare root safely Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/page.tsx | 6 ++++++ src/proxy.ts | 2 +- tests/unit/public-status/public-path.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/app/page.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 000000000..75e5ec785 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,6 @@ +import { defaultLocale } from "@/i18n/config"; +import { redirect } from "@/i18n/routing"; + +export default function RootPage() { + redirect({ href: "/dashboard", locale: defaultLocale }); +} diff --git a/src/proxy.ts b/src/proxy.ts index fd0178db0..29841bc2d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -104,7 +104,7 @@ function proxyHandler(request: NextRequest) { // Preserve locale in redirect const locale = isLocaleInPath ? potentialLocale : routing.defaultLocale; url.pathname = `/${locale}/login`; - url.searchParams.set("from", pathWithoutLocale || "/dashboard"); + url.searchParams.set("from", pathWithoutLocale === "/" ? "/dashboard" : pathWithoutLocale); return NextResponse.redirect(url); } diff --git a/tests/unit/public-status/public-path.test.ts b/tests/unit/public-status/public-path.test.ts index 1696b655c..1e992a5df 100644 --- a/tests/unit/public-status/public-path.test.ts +++ b/tests/unit/public-status/public-path.test.ts @@ -47,6 +47,15 @@ describe("public status proxy path", () => { expect(response.headers.get("location")).toContain("/en/login"); }); + it("redirects bare root to locale login with a dashboard fallback", async () => { + const { default: proxyHandler } = await import("@/proxy"); + const response = proxyHandler(new NextRequest("http://localhost/")); + const location = response.headers.get("location"); + + expect(location).toContain("/zh-CN/login"); + expect(location).toContain("from=%2Fdashboard"); + }); + it("strips spoofed x-cch-public-status on non-status requests", async () => { const { default: proxyHandler } = await import("@/proxy"); const response = proxyHandler( From 5d5c8050a263b5ac441c8d45fe202f306ba2f0e4 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Apr 2026 10:07:16 +0000 Subject: [PATCH 3/8] fix(settings): stabilize system settings lookup Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/repository/system-config.ts | 23 ++++- ...stem-config-non-chat-retry-setting.test.ts | 1 + ...stem-config-update-missing-columns.test.ts | 84 +++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 720b95224..91006166a 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -1,6 +1,6 @@ "use server"; -import { eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { systemSettings } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; @@ -274,7 +274,11 @@ export async function getSystemSettings(): Promise { }; try { - const [row] = await db.select(fullSelection).from(systemSettings).limit(1); + const [row] = await db + .select(fullSelection) + .from(systemSettings) + .orderBy(asc(systemSettings.id)) + .limit(1); return row ?? null; } catch (error) { // 兼容旧版本数据库:system_settings 表存在但列未迁移齐全 @@ -294,6 +298,7 @@ export async function getSystemSettings(): Promise { const [row] = await db .select(selectionWithoutNonConversationFallback) .from(systemSettings) + .orderBy(asc(systemSettings.id)) .limit(1); return row ?? null; } catch (nonConversationFallbackError) { @@ -308,7 +313,11 @@ export async function getSystemSettings(): Promise { } try { - const [row] = await db.select(selectionWithoutPassThrough).from(systemSettings).limit(1); + const [row] = await db + .select(selectionWithoutPassThrough) + .from(systemSettings) + .orderBy(asc(systemSettings.id)) + .limit(1); return row ?? null; } catch (passThroughFallbackError) { if (!isUndefinedColumnError(passThroughFallbackError)) { @@ -326,6 +335,7 @@ export async function getSystemSettings(): Promise { const [row] = await db .select(selectionWithoutHighConcurrencyMode) .from(systemSettings) + .orderBy(asc(systemSettings.id)) .limit(1); return row ?? null; } catch (fallbackError) { @@ -341,6 +351,7 @@ export async function getSystemSettings(): Promise { const [row] = await db .select(selectionWithoutCodexAndHighConcurrency) .from(systemSettings) + .orderBy(asc(systemSettings.id)) .limit(1); return row ?? null; } catch (legacyFallbackError) { @@ -363,7 +374,11 @@ export async function getSystemSettings(): Promise { updatedAt: systemSettings.updatedAt, }; - const [row] = await db.select(minimalSelection).from(systemSettings).limit(1); + const [row] = await db + .select(minimalSelection) + .from(systemSettings) + .orderBy(asc(systemSettings.id)) + .limit(1); return row ?? null; } } diff --git a/tests/unit/actions/system-config-non-chat-retry-setting.test.ts b/tests/unit/actions/system-config-non-chat-retry-setting.test.ts index 6563d1dba..86f1784b6 100644 --- a/tests/unit/actions/system-config-non-chat-retry-setting.test.ts +++ b/tests/unit/actions/system-config-non-chat-retry-setting.test.ts @@ -148,6 +148,7 @@ describe("non-chat fallback system setting", () => { select: vi.fn(() => { const query: any = {}; query.from = vi.fn(() => query); + query.orderBy = vi.fn(() => query); query.limit = vi.fn(() => Promise.reject({ code: "42P01" })); return query; }), diff --git a/tests/unit/repository/system-config-update-missing-columns.test.ts b/tests/unit/repository/system-config-update-missing-columns.test.ts index a812dc6bd..20aaaa318 100644 --- a/tests/unit/repository/system-config-update-missing-columns.test.ts +++ b/tests/unit/repository/system-config-update-missing-columns.test.ts @@ -4,6 +4,7 @@ function createThenableQuery(result: T) { const query: any = Promise.resolve(result); query.from = vi.fn(() => query); + query.orderBy = vi.fn(() => query); query.limit = vi.fn(() => query); query.set = vi.fn(() => query); @@ -20,6 +21,7 @@ function createRejectedThenableQuery(error: unknown) { const query: any = {}; query.from = vi.fn(() => query); + query.orderBy = vi.fn(() => query); query.limit = vi.fn(() => Promise.reject(error)); query.set = vi.fn(() => query); @@ -33,6 +35,88 @@ function createRejectedThenableQuery(error: unknown) { } describe("SystemSettings:数据库缺列时的保存兜底", () => { + test("getSystemSettings 应稳定按最早记录读取,避免无序 LIMIT 1 丢失 IP 提取配置", async () => { + vi.resetModules(); + + const now = new Date("2026-04-24T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const selectQuery = createThenableQuery([ + { + id: 1, + siteTitle: "AutoBits Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + codexPriorityBillingSource: "requested", + timezone: null, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + passThroughUpstreamErrorMessage: true, + enableHttp2: false, + enableHighConcurrencyMode: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + enableBillingHeaderRectifier: true, + enableResponseInputRectifier: true, + allowNonConversationEndpointProviderFallback: true, + enableCodexSessionIdCompletion: true, + enableClaudeMetadataUserIdInjection: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: "0.05", + quotaLeasePercentDaily: "0.05", + quotaLeasePercentWeekly: "0.05", + quotaLeasePercentMonthly: "0.05", + quotaLeaseCapUsd: null, + publicStatusWindowHours: 24, + publicStatusAggregationIntervalMinutes: 5, + ipExtractionConfig: { + headers: [ + { name: "cf-connecting-ip" }, + { name: "x-real-ip" }, + { name: "x-forwarded-for", pick: "rightmost" }, + ], + }, + ipGeoLookupEnabled: true, + createdAt: now, + updatedAt: now, + }, + ]); + const selectMock = vi.fn(() => selectQuery); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: vi.fn(() => createThenableQuery([])), + insert: vi.fn(() => createThenableQuery([])), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { getSystemSettings } = await import("@/repository/system-config"); + + const result = await getSystemSettings(); + + expect(selectQuery.orderBy).toHaveBeenCalledTimes(1); + expect(result.ipExtractionConfig?.headers[0]?.name).toBe("cf-connecting-ip"); + + vi.useRealTimers(); + }); + test("updateSystemSettings 遇到 42703(列缺失)应返回可行动的错误信息", async () => { vi.resetModules(); From 18340b0f46bd34951cd109548803d78c57c2a63d Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Apr 2026 10:07:16 +0000 Subject: [PATCH 4/8] fix(map): keep dialog map instances stable Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/ui/__tests__/map.test.tsx | 1 + src/components/ui/map.tsx | 95 ++++++++++++++---------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/components/ui/__tests__/map.test.tsx b/src/components/ui/__tests__/map.test.tsx index f3ce773b7..4ac9a879c 100644 --- a/src/components/ui/__tests__/map.test.tsx +++ b/src/components/ui/__tests__/map.test.tsx @@ -206,6 +206,7 @@ const maplibreMocks = vi.hoisted(() => { ); setProjection = vi.fn(); setPaintProperty = vi.fn(); + resize = vi.fn(); constructor(options: Record) { this.container = options.container as HTMLElement; diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx index a7de12cb4..1b7118e34 100644 --- a/src/components/ui/map.tsx +++ b/src/components/ui/map.tsx @@ -250,7 +250,12 @@ const Map = forwardRef(function Map( map.on("move", handleMove); setMapInstance(map); + const resizeFrame = window.requestAnimationFrame(() => { + map.resize(); + }); + return () => { + window.cancelAnimationFrame(resizeFrame); map.off("load", loadHandler); map.off("idle", idleHandler); map.off("styledata", styleDataHandler); @@ -261,7 +266,7 @@ const Map = forwardRef(function Map( setMapInstance(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [resolvedTheme, mapStyles.light, viewport, projection, props, mapStyles.dark]); + }, []); // Sync controlled viewport to map useEffect(() => { @@ -392,41 +397,44 @@ function MapMarker({ onDragEnd, }; - // biome-ignore lint/correctness/useExhaustiveDependencies: marker instance is intentionally stable - const marker = useMemo(() => { - const markerInstance = new MapLibreGL.Marker({ + const markerRef = useRef(null); + if (!markerRef.current) { + markerRef.current = new MapLibreGL.Marker({ ...markerOptions, element: document.createElement("div"), draggable, }).setLngLat([longitude, latitude]); - const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e); - const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e); - const handleMouseLeave = (e: MouseEvent) => callbacksRef.current.onMouseLeave?.(e); + const handleClick = (event: MouseEvent) => callbacksRef.current.onClick?.(event); + const handleMouseEnter = (event: MouseEvent) => callbacksRef.current.onMouseEnter?.(event); + const handleMouseLeave = (event: MouseEvent) => callbacksRef.current.onMouseLeave?.(event); - markerInstance.getElement()?.addEventListener("click", handleClick); - markerInstance.getElement()?.addEventListener("mouseenter", handleMouseEnter); - markerInstance.getElement()?.addEventListener("mouseleave", handleMouseLeave); + markerRef.current.getElement()?.addEventListener("click", handleClick); + markerRef.current.getElement()?.addEventListener("mouseenter", handleMouseEnter); + markerRef.current.getElement()?.addEventListener("mouseleave", handleMouseLeave); const handleDragStart = () => { - const lngLat = markerInstance.getLngLat(); + const lngLat = markerRef.current?.getLngLat(); + if (!lngLat) return; callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDrag = () => { - const lngLat = markerInstance.getLngLat(); + const lngLat = markerRef.current?.getLngLat(); + if (!lngLat) return; callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDragEnd = () => { - const lngLat = markerInstance.getLngLat(); + const lngLat = markerRef.current?.getLngLat(); + if (!lngLat) return; callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); }; - markerInstance.on("dragstart", handleDragStart); - markerInstance.on("drag", handleDrag); - markerInstance.on("dragend", handleDragEnd); + markerRef.current.on("dragstart", handleDragStart); + markerRef.current.on("drag", handleDrag); + markerRef.current.on("dragend", handleDragEnd); + } - return markerInstance; - }, [longitude, markerOptions, draggable, latitude]); + const marker = markerRef.current; useEffect(() => { if (!map) return; @@ -523,18 +531,18 @@ function MarkerPopup({ const container = useMemo(() => document.createElement("div"), []); const prevPopupOptions = useRef(popupOptions); - const popup = useMemo(() => { - const popupInstance = new MapLibreGL.Popup({ + const popupRef = useRef(null); + if (!popupRef.current) { + popupRef.current = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false, }) .setMaxWidth("none") .setDOMContent(container); + } - return popupInstance; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [container, popupOptions]); + const popup = popupRef.current; useEffect(() => { if (!map) return; @@ -599,17 +607,17 @@ function MarkerTooltip({ children, className, ...popupOptions }: MarkerTooltipPr const container = useMemo(() => document.createElement("div"), []); const prevTooltipOptions = useRef(popupOptions); - const tooltip = useMemo(() => { - const tooltipInstance = new MapLibreGL.Popup({ + const tooltipRef = useRef(null); + if (!tooltipRef.current) { + tooltipRef.current = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeOnClick: true, closeButton: false, }).setMaxWidth("none"); + } - return tooltipInstance; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popupOptions]); + const tooltip = tooltipRef.current; useEffect(() => { if (!map) return; @@ -944,23 +952,25 @@ function MapPopup({ ...popupOptions }: MapPopupProps) { const { map } = useMap(); - const popupOptionsRef = useRef(popupOptions); + const popupOptionsRef = useRef({ + offset: popupOptions.offset, + maxWidth: popupOptions.maxWidth, + }); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const container = useMemo(() => document.createElement("div"), []); - const popup = useMemo(() => { - const popupInstance = new MapLibreGL.Popup({ + const popupRef = useRef(null); + if (!popupRef.current) { + popupRef.current = new MapLibreGL.Popup({ offset: 16, ...popupOptions, closeButton: false, }) .setMaxWidth("none") .setLngLat([longitude, latitude]); - - return popupInstance; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [longitude, latitude, popupOptions]); + } + const popup = popupRef.current; useEffect(() => { if (!map) return; @@ -990,7 +1000,9 @@ function MapPopup({ popup.addTo, ]); - if (popup.isOpen()) { + useEffect(() => { + if (!popup.isOpen()) return; + const prev = popupOptionsRef.current; if (popup.getLngLat().lng !== longitude || popup.getLngLat().lat !== latitude) { @@ -1003,8 +1015,11 @@ function MapPopup({ if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { popup.setMaxWidth(popupOptions.maxWidth ?? "none"); } - popupOptionsRef.current = popupOptions; - } + popupOptionsRef.current = { + offset: popupOptions.offset, + maxWidth: popupOptions.maxWidth, + }; + }, [popup, longitude, latitude, popupOptions.offset, popupOptions.maxWidth]); const handleClose = () => { popup.remove(); @@ -1219,6 +1234,7 @@ function MapClusterLayer

{ @@ -1227,7 +1243,7 @@ function MapClusterLayer

Date: Fri, 24 Apr 2026 10:10:00 +0000 Subject: [PATCH 5/8] fix(map): cover dialog map edge cases Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/ui/__tests__/map.test.tsx | 61 ++++++++++++++++++++ src/components/ui/map.tsx | 11 +++- src/proxy.ts | 5 +- tests/unit/public-status/public-path.test.ts | 9 +++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/components/ui/__tests__/map.test.tsx b/src/components/ui/__tests__/map.test.tsx index 4ac9a879c..bfe138483 100644 --- a/src/components/ui/__tests__/map.test.tsx +++ b/src/components/ui/__tests__/map.test.tsx @@ -539,6 +539,31 @@ describe("Map UI", () => { unmount(); }); + test("projection updates sync without recreating the map", async () => { + const { rerender, unmount } = render( +

+ +
+ ); + + await flushMicrotasks(); + const map = maplibreMocks.maps.at(-1); + expect(maplibreMocks.maps.length).toBe(1); + + rerender( +
+ +
+ ); + + await flushMicrotasks(); + + expect(maplibreMocks.maps.length).toBe(1); + expect(map?.setProjection).toHaveBeenLastCalledWith({ type: "globe" }); + + unmount(); + }); + test("MapRoute clears rendered geometry when coordinates shrink below two points", async () => { const { rerender, unmount } = render(
@@ -616,4 +641,40 @@ describe("Map UI", () => { unmount(); }); + + test("MapClusterLayer re-adds recreated sources with the latest data", async () => { + const { rerender, unmount } = render( +
+ + + +
+ ); + + await flushMicrotasks(); + + rerender( +
+ + + +
+ ); + await flushMicrotasks(); + + rerender( +
+ + + +
+ ); + await flushMicrotasks(); + + const map = maplibreMocks.maps.at(-1); + const source = Array.from(map?.sources.values() ?? [])[0]; + expect(source?.data).toBe("https://example.com/b.geojson"); + + unmount(); + }); }); diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx index 1b7118e34..a24a02ba6 100644 --- a/src/components/ui/map.tsx +++ b/src/components/ui/map.tsx @@ -310,6 +310,12 @@ const Map = forwardRef(function Map( mapInstance.setStyle(newStyle, { diff: true }); }, [mapInstance, resolvedTheme, mapStyles]); + useEffect(() => { + if (!mapInstance || !projection || !isStyleLoaded) return; + + mapInstance.setProjection(projection); + }, [mapInstance, projection, isStyleLoaded]); + const contextValue = useMemo( () => ({ map: mapInstance, @@ -1234,7 +1240,8 @@ function MapClusterLayer

{ @@ -1243,7 +1250,7 @@ function MapClusterLayer

{ expect(location).toContain("from=%2Fdashboard"); }); + it("redirects locale root to login with a dashboard fallback", async () => { + const { default: proxyHandler } = await import("@/proxy"); + const response = proxyHandler(new NextRequest("http://localhost/en")); + const location = response.headers.get("location"); + + expect(location).toContain("/en/login"); + expect(location).toContain("from=%2Fdashboard"); + }); + it("strips spoofed x-cch-public-status on non-status requests", async () => { const { default: proxyHandler } = await import("@/proxy"); const response = proxyHandler( From 5ba854bb5301a932cb8735afb2b48edc3a270098 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Apr 2026 10:56:00 +0000 Subject: [PATCH 6/8] fix(map): address review lifecycle feedback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/ui/__tests__/map.test.tsx | 12 ++++ src/components/ui/map.tsx | 92 ++++++++++++++++-------- 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/src/components/ui/__tests__/map.test.tsx b/src/components/ui/__tests__/map.test.tsx index bfe138483..0d91d8b13 100644 --- a/src/components/ui/__tests__/map.test.tsx +++ b/src/components/ui/__tests__/map.test.tsx @@ -549,6 +549,18 @@ describe("Map UI", () => { await flushMicrotasks(); const map = maplibreMocks.maps.at(-1); expect(maplibreMocks.maps.length).toBe(1); + expect(map?.setProjection).toHaveBeenLastCalledWith({ type: "mercator" }); + const initialProjectionCalls = map?.setProjection.mock.calls.length ?? 0; + + rerender( +

+ +
+ ); + + await flushMicrotasks(); + + expect(map?.setProjection).toHaveBeenCalledTimes(initialProjectionCalls); rerender(
diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx index a24a02ba6..4c8f29a17 100644 --- a/src/components/ui/map.tsx +++ b/src/components/ui/map.tsx @@ -9,6 +9,7 @@ import { useCallback, useContext, useEffect, + useEffectEvent, useId, useImperativeHandle, useMemo, @@ -24,6 +25,8 @@ const defaultStyles = { light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", }; +const defaultMarkerOffset: NonNullable = [0, 0]; + type Theme = "light" | "dark"; // Check document class for theme (works with next-themes, etc.) @@ -169,6 +172,14 @@ function getViewport(map: MapLibreGL.Map): MapViewport { }; } +function getProjectionKey(projection?: MapLibreGL.ProjectionSpecification) { + return projection ? JSON.stringify(projection) : ""; +} + +function getMarkerOffsetTuple(offset: NonNullable): [number, number] { + return Array.isArray(offset) ? [offset[0], offset[1]] : [offset.x, offset.y]; +} + const Map = forwardRef(function Map( { children, @@ -204,6 +215,15 @@ const Map = forwardRef(function Map( }), [styles] ); + const projectionKey = useMemo(() => getProjectionKey(projection), [projection]); + const projectionRef = useRef(projection); + projectionRef.current = projection; + const syncProjection = useEffectEvent((targetMap: MapLibreGL.Map) => { + const nextProjection = projectionRef.current; + if (!nextProjection) return; + + targetMap.setProjection(nextProjection); + }); // Expose the map instance to the parent component useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]); @@ -231,9 +251,6 @@ const Map = forwardRef(function Map( const syncStyleReady = () => { if (!map.isStyleLoaded()) return; setIsStyleLoaded(true); - if (projection) { - map.setProjection(projection); - } }; const styleDataHandler = () => syncStyleReady(); const idleHandler = () => syncStyleReady(); @@ -311,10 +328,10 @@ const Map = forwardRef(function Map( }, [mapInstance, resolvedTheme, mapStyles]); useEffect(() => { - if (!mapInstance || !projection || !isStyleLoaded) return; + if (!mapInstance || !projectionKey || !isStyleLoaded) return; - mapInstance.setProjection(projection); - }, [mapInstance, projection, isStyleLoaded]); + syncProjection(mapInstance); + }, [mapInstance, projectionKey, isStyleLoaded]); const contextValue = useMemo( () => ({ @@ -441,6 +458,11 @@ function MapMarker({ } const marker = markerRef.current; + const markerOffset = markerOptions.offset ?? defaultMarkerOffset; + const [markerOffsetX, markerOffsetY] = getMarkerOffsetTuple(markerOffset); + const markerRotation = markerOptions.rotation ?? 0; + const markerRotationAlignment = markerOptions.rotationAlignment ?? "auto"; + const markerPitchAlignment = markerOptions.pitchAlignment ?? "auto"; useEffect(() => { if (!map) return; @@ -450,35 +472,43 @@ function MapMarker({ return () => { marker.remove(); }; + }, [map, marker]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map, marker.addTo, marker.remove]); + useEffect(() => { + const currentLngLat = marker.getLngLat(); + if (currentLngLat.lng !== longitude || currentLngLat.lat !== latitude) { + marker.setLngLat([longitude, latitude]); + } - if (marker.getLngLat().lng !== longitude || marker.getLngLat().lat !== latitude) { - marker.setLngLat([longitude, latitude]); - } - if (marker.isDraggable() !== draggable) { - marker.setDraggable(draggable); - } + if (marker.isDraggable() !== draggable) { + marker.setDraggable(draggable); + } - const currentOffset = marker.getOffset(); - const newOffset = markerOptions.offset ?? [0, 0]; - const [newOffsetX, newOffsetY] = Array.isArray(newOffset) - ? newOffset - : [newOffset.x, newOffset.y]; - if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) { - marker.setOffset(newOffset); - } + const currentOffset = marker.getOffset(); + if (currentOffset.x !== markerOffsetX || currentOffset.y !== markerOffsetY) { + marker.setOffset([markerOffsetX, markerOffsetY]); + } - if (marker.getRotation() !== markerOptions.rotation) { - marker.setRotation(markerOptions.rotation ?? 0); - } - if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) { - marker.setRotationAlignment(markerOptions.rotationAlignment ?? "auto"); - } - if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) { - marker.setPitchAlignment(markerOptions.pitchAlignment ?? "auto"); - } + if (marker.getRotation() !== markerRotation) { + marker.setRotation(markerRotation); + } + if (marker.getRotationAlignment() !== markerRotationAlignment) { + marker.setRotationAlignment(markerRotationAlignment); + } + if (marker.getPitchAlignment() !== markerPitchAlignment) { + marker.setPitchAlignment(markerPitchAlignment); + } + }, [ + marker, + longitude, + latitude, + draggable, + markerOffsetX, + markerOffsetY, + markerRotation, + markerRotationAlignment, + markerPitchAlignment, + ]); return {children}; } From 1dd2fe0a05ccd977ca1f404310577c0e8ff261a5 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Apr 2026 11:39:28 +0000 Subject: [PATCH 7/8] fix(map): sync projection without remounts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/ui/__tests__/map.test.tsx | 17 ++++++++++++++++- src/components/ui/map.tsx | 17 +++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/components/ui/__tests__/map.test.tsx b/src/components/ui/__tests__/map.test.tsx index 0d91d8b13..b99ceeff9 100644 --- a/src/components/ui/__tests__/map.test.tsx +++ b/src/components/ui/__tests__/map.test.tsx @@ -176,6 +176,7 @@ const maplibreMocks = vi.hoisted(() => { zoom: number; bearing: number; pitch: number; + projection: unknown; style: unknown; sources = new globalThis.Map(); layers = new globalThis.Map>(); @@ -204,7 +205,9 @@ const maplibreMocks = vi.hoisted(() => { this.pitch = next.pitch ?? this.pitch; } ); - setProjection = vi.fn(); + setProjection = vi.fn((nextProjection: unknown) => { + this.projection = nextProjection; + }); setPaintProperty = vi.fn(); resize = vi.fn(); @@ -214,6 +217,7 @@ const maplibreMocks = vi.hoisted(() => { this.zoom = (options.zoom as number | undefined) ?? 0; this.bearing = (options.bearing as number | undefined) ?? 0; this.pitch = (options.pitch as number | undefined) ?? 0; + this.projection = options.projection; this.style = options.style; this.container.requestFullscreen = vi.fn(async () => undefined); maps.push(this); @@ -549,6 +553,7 @@ describe("Map UI", () => { await flushMicrotasks(); const map = maplibreMocks.maps.at(-1); expect(maplibreMocks.maps.length).toBe(1); + expect(map?.projection).toEqual({ type: "mercator" }); expect(map?.setProjection).toHaveBeenLastCalledWith({ type: "mercator" }); const initialProjectionCalls = map?.setProjection.mock.calls.length ?? 0; @@ -573,6 +578,16 @@ describe("Map UI", () => { expect(maplibreMocks.maps.length).toBe(1); expect(map?.setProjection).toHaveBeenLastCalledWith({ type: "globe" }); + rerender( +
+ +
+ ); + + await flushMicrotasks(); + + expect(map?.setProjection).toHaveBeenLastCalledWith({ type: "mercator" }); + unmount(); }); diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx index 4c8f29a17..cea181cc4 100644 --- a/src/components/ui/map.tsx +++ b/src/components/ui/map.tsx @@ -9,7 +9,6 @@ import { useCallback, useContext, useEffect, - useEffectEvent, useId, useImperativeHandle, useMemo, @@ -26,6 +25,7 @@ const defaultStyles = { }; const defaultMarkerOffset: NonNullable = [0, 0]; +const defaultProjection: MapLibreGL.ProjectionSpecification = { type: "mercator" }; type Theme = "light" | "dark"; @@ -218,12 +218,12 @@ const Map = forwardRef(function Map( const projectionKey = useMemo(() => getProjectionKey(projection), [projection]); const projectionRef = useRef(projection); projectionRef.current = projection; - const syncProjection = useEffectEvent((targetMap: MapLibreGL.Map) => { + const syncProjection = useCallback((targetMap: MapLibreGL.Map, nextProjectionKey: string) => { const nextProjection = projectionRef.current; - if (!nextProjection) return; + if (nextProjectionKey && !nextProjection) return; - targetMap.setProjection(nextProjection); - }); + targetMap.setProjection(nextProjection ?? defaultProjection); + }, []); // Expose the map instance to the parent component useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]); @@ -245,6 +245,7 @@ const Map = forwardRef(function Map( }, ...props, ...viewport, + ...(projectionRef.current ? { projection: projectionRef.current } : {}), }); const loadHandler = () => setIsLoaded(true); @@ -328,10 +329,10 @@ const Map = forwardRef(function Map( }, [mapInstance, resolvedTheme, mapStyles]); useEffect(() => { - if (!mapInstance || !projectionKey || !isStyleLoaded) return; + if (!mapInstance || !isStyleLoaded) return; - syncProjection(mapInstance); - }, [mapInstance, projectionKey, isStyleLoaded]); + syncProjection(mapInstance, projectionKey); + }, [mapInstance, projectionKey, isStyleLoaded, syncProjection]); const contextValue = useMemo( () => ({ From 43e3135075eea628f1fc0ba0b0d43291176854a3 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 24 Apr 2026 12:18:43 +0000 Subject: [PATCH 8/8] fix(map): recreate construction-only markers safely --- src/components/ui/__tests__/map.test.tsx | 66 +++++++++++++++++- src/components/ui/map.tsx | 85 +++++++++++++++++------- 2 files changed, 126 insertions(+), 25 deletions(-) diff --git a/src/components/ui/__tests__/map.test.tsx b/src/components/ui/__tests__/map.test.tsx index b99ceeff9..deaca9a86 100644 --- a/src/components/ui/__tests__/map.test.tsx +++ b/src/components/ui/__tests__/map.test.tsx @@ -95,9 +95,13 @@ const maplibreMocks = vi.hoisted(() => { draggable = false; popup: FakePopup | null = null; element = globalThis.document?.createElement("div") ?? ({} as HTMLElement); + options: Record; + events = new globalThis.Map void>>(); constructor(options: Record) { + this.options = options; this.draggable = Boolean(options.draggable); + markers.push(this); } setLngLat([lng, lat]: [number, number]) { @@ -166,9 +170,23 @@ const maplibreMocks = vi.hoisted(() => { getPitchAlignment() { return "auto"; } + + on(event: string, handler: () => void) { + if (!this.events.has(event)) { + this.events.set(event, new Set()); + } + this.events.get(event)?.add(handler); + return this; + } + + off(event: string, handler: () => void) { + this.events.get(event)?.delete(handler); + return this; + } } const maps: FakeMap[] = []; + const markers: FakeMarker[] = []; class FakeMap { container: HTMLElement; @@ -342,6 +360,7 @@ const maplibreMocks = vi.hoisted(() => { FakeMarker, FakePopup, maps, + markers, }; }); @@ -354,7 +373,15 @@ vi.mock("maplibre-gl", () => ({ }, })); -import { Map, MapClusterLayer, MapControls, MapPopup, MapRoute } from "@/components/ui/map"; +import { + Map, + MapClusterLayer, + MapControls, + MapMarker, + MapPopup, + MapRoute, + MarkerContent, +} from "@/components/ui/map"; function render(node: ReactNode) { const container = document.createElement("div"); @@ -399,6 +426,7 @@ function click(element: Element) { describe("Map UI", () => { beforeEach(() => { maplibreMocks.maps.length = 0; + maplibreMocks.markers.length = 0; document.body.innerHTML = ""; Object.defineProperty(window, "matchMedia", { configurable: true, @@ -704,4 +732,40 @@ describe("Map UI", () => { unmount(); }); + + test("MapMarker recreates construction-only options outside render", async () => { + const { rerender, unmount } = render( +
+ + + marker + + +
+ ); + + await flushMicrotasks(); + + expect(maplibreMocks.markers).toHaveLength(1); + expect(maplibreMocks.markers[0]?.options.anchor).toBe("bottom"); + expect(maplibreMocks.markers[0]?.options.color).toBe("#111111"); + + rerender( +
+ + + marker + + +
+ ); + await flushMicrotasks(); + + expect(maplibreMocks.maps.length).toBe(1); + expect(maplibreMocks.markers).toHaveLength(2); + expect(maplibreMocks.markers[1]?.options.anchor).toBe("top"); + expect(maplibreMocks.markers[1]?.options.color).toBe("#222222"); + + unmount(); + }); }); diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx index cea181cc4..3ed6b127b 100644 --- a/src/components/ui/map.tsx +++ b/src/components/ui/map.tsx @@ -422,50 +422,83 @@ function MapMarker({ }; const markerRef = useRef(null); - if (!markerRef.current) { - markerRef.current = new MapLibreGL.Marker({ - ...markerOptions, + const markerOptionsRef = useRef(markerOptions); + markerOptionsRef.current = markerOptions; + const markerStateRef = useRef({ longitude, latitude, draggable }); + markerStateRef.current = { longitude, latitude, draggable }; + const [marker, setMarker] = useState(null); + const markerAnchor = markerOptions.anchor; + const markerClickTolerance = markerOptions.clickTolerance; + const markerColor = markerOptions.color; + const markerScale = markerOptions.scale; + const markerConstructorOptions = useMemo( + () => ({ + anchor: markerAnchor, + clickTolerance: markerClickTolerance, + color: markerColor, + scale: markerScale, + }), + [markerAnchor, markerClickTolerance, markerColor, markerScale] + ); + const markerOffset = markerOptions.offset ?? defaultMarkerOffset; + const [markerOffsetX, markerOffsetY] = getMarkerOffsetTuple(markerOffset); + const markerRotation = markerOptions.rotation ?? 0; + const markerRotationAlignment = markerOptions.rotationAlignment ?? "auto"; + const markerPitchAlignment = markerOptions.pitchAlignment ?? "auto"; + + useEffect(() => { + const initialState = markerStateRef.current; + const nextMarker = new MapLibreGL.Marker({ + ...markerOptionsRef.current, + ...markerConstructorOptions, element: document.createElement("div"), - draggable, - }).setLngLat([longitude, latitude]); + draggable: initialState.draggable, + }).setLngLat([initialState.longitude, initialState.latitude]); + markerRef.current = nextMarker; const handleClick = (event: MouseEvent) => callbacksRef.current.onClick?.(event); const handleMouseEnter = (event: MouseEvent) => callbacksRef.current.onMouseEnter?.(event); const handleMouseLeave = (event: MouseEvent) => callbacksRef.current.onMouseLeave?.(event); - markerRef.current.getElement()?.addEventListener("click", handleClick); - markerRef.current.getElement()?.addEventListener("mouseenter", handleMouseEnter); - markerRef.current.getElement()?.addEventListener("mouseleave", handleMouseLeave); + const element = nextMarker.getElement(); + element.addEventListener("click", handleClick); + element.addEventListener("mouseenter", handleMouseEnter); + element.addEventListener("mouseleave", handleMouseLeave); const handleDragStart = () => { - const lngLat = markerRef.current?.getLngLat(); - if (!lngLat) return; + const lngLat = nextMarker.getLngLat(); callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDrag = () => { - const lngLat = markerRef.current?.getLngLat(); - if (!lngLat) return; + const lngLat = nextMarker.getLngLat(); callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); }; const handleDragEnd = () => { - const lngLat = markerRef.current?.getLngLat(); - if (!lngLat) return; + const lngLat = nextMarker.getLngLat(); callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); }; - markerRef.current.on("dragstart", handleDragStart); - markerRef.current.on("drag", handleDrag); - markerRef.current.on("dragend", handleDragEnd); - } + nextMarker.on("dragstart", handleDragStart); + nextMarker.on("drag", handleDrag); + nextMarker.on("dragend", handleDragEnd); + setMarker(nextMarker); - const marker = markerRef.current; - const markerOffset = markerOptions.offset ?? defaultMarkerOffset; - const [markerOffsetX, markerOffsetY] = getMarkerOffsetTuple(markerOffset); - const markerRotation = markerOptions.rotation ?? 0; - const markerRotationAlignment = markerOptions.rotationAlignment ?? "auto"; - const markerPitchAlignment = markerOptions.pitchAlignment ?? "auto"; + return () => { + element.removeEventListener("click", handleClick); + element.removeEventListener("mouseenter", handleMouseEnter); + element.removeEventListener("mouseleave", handleMouseLeave); + nextMarker.off("dragstart", handleDragStart); + nextMarker.off("drag", handleDrag); + nextMarker.off("dragend", handleDragEnd); + nextMarker.remove(); + if (markerRef.current === nextMarker) { + markerRef.current = null; + } + }; + }, [markerConstructorOptions]); useEffect(() => { + if (!marker) return; if (!map) return; marker.addTo(map); @@ -476,6 +509,8 @@ function MapMarker({ }, [map, marker]); useEffect(() => { + if (!marker) return; + const currentLngLat = marker.getLngLat(); if (currentLngLat.lng !== longitude || currentLngLat.lat !== latitude) { marker.setLngLat([longitude, latitude]); @@ -511,6 +546,8 @@ function MapMarker({ markerPitchAlignment, ]); + if (!marker) return null; + return {children}; }