From 9fcd13a1560e6b17a03ed6137f6ac3816f6bfde8 Mon Sep 17 00:00:00 2001 From: twoimo <32544727+twoimo@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:40:12 +0900 Subject: [PATCH 1/4] perf(web): preserve home and marker identity with measured swipe caches --- apps/web/app/home-runtime-shell.tsx | 23 +- .../home/home-session-provider-bridge.tsx | 75 + apps/web/components/map/MapView.tsx | 93 +- .../web/lib/map-view-marker-reconciliation.ts | 105 + apps/web/lib/mobile-home-search-selection.ts | 158 +- .../artifact-map.json | 14 + .../baseline-MapView.tsx.txt | 675 ++++ .../baseline-effect.mjs | 70 + .../browser-bundle.mjs | 292 ++ .../browser-entry.mjs | 3 + .../candidate-MapView.tsx.txt | 632 ++++ .../candidate-helper.ts.txt | 105 + .../checks/results.json | 14 + .../frozen-tree.patch | 419 +++ .../measure.mjs | 186 + .../google-marker-reconcile-20260925/raw.json | 2760 +++++++++++++++ .../artifact-map.json | 14 + .../baseline-MapView.tsx.txt | 675 ++++ .../baseline-effect.mjs | 70 + .../browser-bundle.mjs | 292 ++ .../browser-entry.mjs | 3 + .../candidate-MapView.tsx.txt | 632 ++++ .../candidate-helper.ts.txt | 105 + .../frozen-tree.patch | 419 +++ .../rejected-html-image-response/measure.mjs | 173 + .../rejected-html-image-response/raw.json | 2759 +++++++++++++++ .../rejected-html-image-response/reason.txt | 1 + .../score-and-verify.mjs | 56 + .../score-and-verify.mjs | 57 + .../scored.json | 76 + .../artifact-map.json | 14 + .../baseline-MapView.tsx.txt | 675 ++++ .../baseline-effect.mjs | 70 + .../browser-bundle.mjs | 292 ++ .../browser-entry.mjs | 3 + .../candidate-MapView.tsx.txt | 632 ++++ .../candidate-helper.ts.txt | 105 + .../checks/affected-unit-tests.txt | 59 + .../checks/eslint.txt | 0 .../checks/typecheck-parity.txt | 5 + .../frozen-tree.patch | 419 +++ .../measure.mjs | 186 + .../raw.json | 2760 +++++++++++++++ .../score-and-verify.mjs | 57 + .../scored.json | 76 + .../baseline-boundary.jsx | 19 + .../baseline-home-runtime-shell.tsx.txt | 367 ++ .../browser-entry.jsx | 179 + .../candidate-boundary.jsx | 22 + .../checks/eslint.txt | 0 .../checks/typecheck-parity.txt | 5 + .../measure.mjs | 90 + .../run-v1/artifact-map.json | 46 + .../run-v1/browser-bundle.mjs | 61 + .../run-v1/raw.json | 625 ++++ .../run-v1/scored.json | 21 + .../run-v1/source-0.txt | 75 + .../run-v1/source-1.txt | 370 ++ .../run-v1/source-2.txt | 67 + .../run-v1/source-3.txt | 36 + .../run-v1/source-4.txt | 367 ++ .../run-v1/source-5.txt | 19 + .../run-v1/source-6.txt | 190 ++ .../run-v1/source-7.txt | 83 + .../run-v2/artifact-map.json | 50 + .../run-v2/browser-bundle.mjs | 61 + .../run-v2/raw.json | 626 ++++ .../run-v2/scored.json | 21 + .../run-v2/source-0.txt | 75 + .../run-v2/source-1.txt | 370 ++ .../run-v2/source-2.txt | 67 + .../run-v2/source-3.txt | 36 + .../run-v2/source-4.txt | 367 ++ .../run-v2/source-5.txt | 19 + .../run-v2/source-6.txt | 22 + .../run-v2/source-7.txt | 179 + .../run-v2/source-8.txt | 90 + .../strict-development/artifact-map.json | 50 + .../strict-development/browser-bundle.mjs | 363 ++ .../strict-development/raw.json | 129 + .../strict-development/scored.json | 5 + .../strict-development/source-0.txt | 75 + .../strict-development/source-1.txt | 370 ++ .../strict-development/source-2.txt | 67 + .../strict-development/source-3.txt | 36 + .../strict-development/source-4.txt | 367 ++ .../strict-development/source-5.txt | 19 + .../strict-development/source-6.txt | 22 + .../strict-development/source-7.txt | 179 + .../strict-development/source-8.txt | 90 + .../verify-artifacts.mjs | 53 + .../render-delivery-20260925/REPORT.md | 123 + .../observe-public.mjs | 99 + .../production-before/desktop-global-map.png | Bin 0 -> 916352 bytes .../production-before/desktop-home.png | Bin 0 -> 774784 bytes .../production-before/mobile-global-map.png | Bin 0 -> 289110 bytes .../production-before/mobile-home.png | Bin 0 -> 258228 bytes .../production-before/observations.json | 240 ++ .../artifact-map.json | 12 + .../baseline-source.txt | 328 ++ .../baseline-tests.txt | 158 + .../candidate-source.txt | 415 +++ .../checks/results.json | 20 + .../frozen-tree.patch | 270 ++ .../swipe-render-cache-20260925/measure.mjs | 141 + .../swipe-render-cache-20260925/raw.json | 3039 +++++++++++++++++ .../artifact-map.json | 12 + .../baseline-source.txt | 328 ++ .../candidate-source.txt | 395 +++ .../frozen-tree.patch | 251 ++ .../rejected-timer-resolution/measure.mjs | 131 + .../rejected-timer-resolution/raw.json | 2535 ++++++++++++++ .../score-and-verify.mjs | 50 + .../score-and-verify.mjs | 50 + .../swipe-render-cache-20260925/scored.json | 84 + .../mobile-home-search-selection.test.ts | 23 + .../web/tests-unit/swipe-render-cache.test.ts | 134 + 117 files changed, 31500 insertions(+), 97 deletions(-) create mode 100644 apps/web/components/home/home-session-provider-bridge.tsx create mode 100644 apps/web/lib/map-view-marker-reconciliation.ts create mode 100644 apps/web/performance/google-marker-reconcile-20260925/artifact-map.json create mode 100644 apps/web/performance/google-marker-reconcile-20260925/baseline-MapView.tsx.txt create mode 100644 apps/web/performance/google-marker-reconcile-20260925/baseline-effect.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/browser-bundle.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/browser-entry.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/candidate-MapView.tsx.txt create mode 100644 apps/web/performance/google-marker-reconcile-20260925/candidate-helper.ts.txt create mode 100644 apps/web/performance/google-marker-reconcile-20260925/checks/results.json create mode 100644 apps/web/performance/google-marker-reconcile-20260925/frozen-tree.patch create mode 100644 apps/web/performance/google-marker-reconcile-20260925/measure.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/raw.json create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/artifact-map.json create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-MapView.tsx.txt create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-effect.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-bundle.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-entry.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-MapView.tsx.txt create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-helper.ts.txt create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/frozen-tree.patch create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/measure.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/raw.json create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/reason.txt create mode 100644 apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/score-and-verify.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/score-and-verify.mjs create mode 100644 apps/web/performance/google-marker-reconcile-20260925/scored.json create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/artifact-map.json create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/baseline-MapView.tsx.txt create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/baseline-effect.mjs create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/browser-bundle.mjs create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/browser-entry.mjs create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/candidate-MapView.tsx.txt create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/candidate-helper.ts.txt create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/checks/affected-unit-tests.txt create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/checks/eslint.txt create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/checks/typecheck-parity.txt create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/frozen-tree.patch create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/measure.mjs create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/raw.json create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/score-and-verify.mjs create mode 100644 apps/web/performance/google-marker-reconcile-v2-20260925/scored.json create mode 100644 apps/web/performance/home-provider-transition-20260925/baseline-boundary.jsx create mode 100644 apps/web/performance/home-provider-transition-20260925/baseline-home-runtime-shell.tsx.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/browser-entry.jsx create mode 100644 apps/web/performance/home-provider-transition-20260925/candidate-boundary.jsx create mode 100644 apps/web/performance/home-provider-transition-20260925/checks/eslint.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/checks/typecheck-parity.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/measure.mjs create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/artifact-map.json create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/browser-bundle.mjs create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/raw.json create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/scored.json create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-0.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-1.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-2.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-3.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-4.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-5.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-6.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v1/source-7.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/artifact-map.json create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/browser-bundle.mjs create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/raw.json create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/scored.json create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-0.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-1.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-2.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-3.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-4.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-5.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-6.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-7.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/run-v2/source-8.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/artifact-map.json create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/browser-bundle.mjs create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/raw.json create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/scored.json create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-0.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-1.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-2.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-3.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-4.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-5.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-6.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-7.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/strict-development/source-8.txt create mode 100644 apps/web/performance/home-provider-transition-20260925/verify-artifacts.mjs create mode 100644 apps/web/performance/render-delivery-20260925/REPORT.md create mode 100644 apps/web/performance/render-field-observation-20260925/observe-public.mjs create mode 100644 apps/web/performance/render-field-observation-20260925/production-before/desktop-global-map.png create mode 100644 apps/web/performance/render-field-observation-20260925/production-before/desktop-home.png create mode 100644 apps/web/performance/render-field-observation-20260925/production-before/mobile-global-map.png create mode 100644 apps/web/performance/render-field-observation-20260925/production-before/mobile-home.png create mode 100644 apps/web/performance/render-field-observation-20260925/production-before/observations.json create mode 100644 apps/web/performance/swipe-render-cache-20260925/artifact-map.json create mode 100644 apps/web/performance/swipe-render-cache-20260925/baseline-source.txt create mode 100644 apps/web/performance/swipe-render-cache-20260925/baseline-tests.txt create mode 100644 apps/web/performance/swipe-render-cache-20260925/candidate-source.txt create mode 100644 apps/web/performance/swipe-render-cache-20260925/checks/results.json create mode 100644 apps/web/performance/swipe-render-cache-20260925/frozen-tree.patch create mode 100644 apps/web/performance/swipe-render-cache-20260925/measure.mjs create mode 100644 apps/web/performance/swipe-render-cache-20260925/raw.json create mode 100644 apps/web/performance/swipe-render-cache-20260925/rejected-timer-resolution/artifact-map.json create mode 100644 apps/web/performance/swipe-render-cache-20260925/rejected-timer-resolution/baseline-source.txt create mode 100644 apps/web/performance/swipe-render-cache-20260925/rejected-timer-resolution/candidate-source.txt create mode 100644 apps/web/performance/swipe-render-cache-20260925/rejected-timer-resolution/frozen-tree.patch create mode 100644 apps/web/performance/swipe-render-cache-20260925/rejected-timer-resolution/measure.mjs create mode 100644 apps/web/performance/swipe-render-cache-20260925/rejected-timer-resolution/raw.json create mode 100644 apps/web/performance/swipe-render-cache-20260925/rejected-timer-resolution/score-and-verify.mjs create mode 100644 apps/web/performance/swipe-render-cache-20260925/score-and-verify.mjs create mode 100644 apps/web/performance/swipe-render-cache-20260925/scored.json create mode 100644 apps/web/tests-unit/swipe-render-cache.test.ts diff --git a/apps/web/app/home-runtime-shell.tsx b/apps/web/app/home-runtime-shell.tsx index 3547407d18..de4b78f5e1 100644 --- a/apps/web/app/home-runtime-shell.tsx +++ b/apps/web/app/home-runtime-shell.tsx @@ -8,6 +8,7 @@ import { QueryProvider } from './providers'; import { LayoutProvider } from '@/contexts/LayoutContext'; import { AnonymousHomeAuthProvider, useAuth } from '@/contexts/AuthContextBase'; import { StaticNotificationProvider } from '@/contexts/NotificationContextBase'; +import { HomeSessionProviderBridge } from '@/components/home/home-session-provider-bridge'; import { useHomeViewportMode } from '@/hooks/useHomeViewportMode'; import { AUTH_UI_REQUEST_EVENT } from '@/lib/auth-ui-events'; import { @@ -82,17 +83,19 @@ function HomeSessionProviders({ children }: ProviderProps) { const AuthProvider = useDeferredComponent(hasStoredSession, loadAuthProvider); const NotificationProvider = useDeferredComponent(hasStoredSession, loadNotificationProvider); - if (hasStoredSession && AuthProvider && NotificationProvider) { - return ( - - {children} - - ); - } - return ( - - {children} + + + + {children} + + ); } diff --git a/apps/web/components/home/home-session-provider-bridge.tsx b/apps/web/components/home/home-session-provider-bridge.tsx new file mode 100644 index 0000000000..2fb657bc20 --- /dev/null +++ b/apps/web/components/home/home-session-provider-bridge.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { memo, useLayoutEffect, useState, type ComponentType, type ReactNode } from 'react'; +import { AuthContext, useAuth, type AuthContextType } from '@/contexts/AuthContextBase'; +import { NotificationContext, useNotifications } from '@/contexts/NotificationContextBase'; +import type { NotificationContextType } from '@/types/notification'; + +type ProviderComponent = ComponentType<{ children: ReactNode }>; +type SessionSnapshot = { + auth: AuthContextType; + notifications: NotificationContextType; + authSource: ProviderComponent; + notificationSource: ProviderComponent; +}; +type RuntimeProps = { + AuthProvider: ProviderComponent | null; + NotificationProvider: ProviderComponent | null; + publish: (snapshot: SessionSnapshot) => void; +}; + +function ReadSessionContexts({ + AuthProvider, + NotificationProvider, + publish, +}: Required & { AuthProvider: ProviderComponent; NotificationProvider: ProviderComponent }) { + const auth = useAuth(); + const notifications = useNotifications(); + useLayoutEffect(() => { + publish({ auth, notifications, authSource: AuthProvider, notificationSource: NotificationProvider }); + }, [auth, notifications, AuthProvider, NotificationProvider, publish]); + return null; +} + +// Publishing a snapshot must not re-render the source providers: some source +// values are new objects on each render. The runtime also stays outside the +// forwarded contexts so it cannot subscribe to its own projected value. +const LoadedSessionRuntime = memo(function LoadedSessionRuntime({ + AuthProvider, NotificationProvider, publish, +}: RuntimeProps) { + if (!AuthProvider || !NotificationProvider) return null; + return ( + + + + + + ); +}); + +/** Keep the application mounted while lazy providers become available. */ +export function HomeSessionProviderBridge({ + children, AuthProvider, NotificationProvider, +}: Omit & { children: ReactNode }) { + const anonymousAuth = useAuth(); + const staticNotifications = useNotifications(); + const [snapshot, publish] = useState(null); + const ready = Boolean(AuthProvider && NotificationProvider); + const current = ready && snapshot?.authSource === AuthProvider + && snapshot.notificationSource === NotificationProvider ? snapshot : null; + + useLayoutEffect(() => { + if (!ready) publish(null); + }, [ready]); + + return ( + <> + + + + {children} + + + + ); +} diff --git a/apps/web/components/map/MapView.tsx b/apps/web/components/map/MapView.tsx index 262f491255..11f0804a8e 100644 --- a/apps/web/components/map/MapView.tsx +++ b/apps/web/components/map/MapView.tsx @@ -14,7 +14,6 @@ import { } from "@/components/map/map-view-status-panels"; import { getMapViewCountryConfig, - getMapViewMarkerIcon, mergeSearchedRestaurant, } from "@/lib/map-view-helpers"; import { @@ -23,10 +22,9 @@ import { } from "@/lib/map-view-movement-helpers"; import { applyMapViewMarkerSelectedState, - buildMapViewMarkerHtml, - getMapViewMarkerSize, isMapViewMarkerSelected, } from "@/lib/map-view-marker-helpers"; +import { reconcileMapViewMarkers, type MapViewMarkerEntry } from "@/lib/map-view-marker-reconciliation"; import { buildMapViewBoundsQuery, findUpdatedSelectedRestaurant, @@ -76,12 +74,9 @@ interface GoogleMapLike { interface GoogleMarkerLike { map: GoogleMapLike | null; - content: Element | null; -} - -interface MapMarkerEntry { - marker: GoogleMarkerLike; - restaurantId: string; + content: Node | null; + position?: unknown; + title?: string; } function normalizeMapViewError(error: unknown): Error { if (error instanceof Error) { @@ -137,7 +132,7 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe const memoizedFilters = useMemo(() => filters, [filters]); const mapRef = useRef(null); const googleMapRef = useRef(null); - const markersRef = useRef([]); + const markersRef = useRef[]>([]); const detailPanelRef = useRef(null); const selectedCountryRef = useRef(selectedCountry); @@ -492,12 +487,6 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe useEffect(() => { if (!googleMapRef.current || !isLoaded) return; - // 기존 마커 제거 (메모리 누수 방지) - markersRef.current.forEach(({ marker }) => { - marker.map = null; - }); - markersRef.current = []; - const markerConstructor = google.maps.marker?.AdvancedMarkerElement; if (!markerConstructor) { console.warn('MapView: Advanced marker support unavailable'); @@ -505,66 +494,34 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe return; } - // 새 마커 생성 - restaurantsToShow.forEach((restaurant) => { - const position = getRestaurantLatLng(restaurant); - if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { - console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); - return; - } - - const isSelected = isMapViewMarkerSelected({ - restaurantId: restaurant.id, - searchedRestaurantId: searchedRestaurant?.id, - selectedRestaurantId: selectedRestaurant?.id, - }); - const imagePath = getMapViewMarkerIcon(restaurant.categories); - const markerSize = getMapViewMarkerSize(isSelected); - - const markerElement = document.createElement("div"); - markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; - markerElement.innerHTML = buildMapViewMarkerHtml({ - imagePath, - isSelected, - markerSize, - name: restaurant.name, - }); - - let marker: GoogleMarkerLike; - try { - marker = new markerConstructor({ - map: googleMapRef.current, - position, - content: markerElement, - title: restaurant.name, - }); - } catch { - console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); - return; - } - - markerElement.addEventListener("click", () => { - - - // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) + markersRef.current = reconcileMapViewMarkers({ + previous: markersRef.current, + restaurants: restaurantsToShow, + selectedRestaurantId: selectedRestaurant?.id, + searchedRestaurantId: searchedRestaurant?.id, + createMarker: (options) => { + try { + return new markerConstructor({ ...options, map: googleMapRef.current }); + } catch { + console.warn('MapView: Advanced marker creation skipped'); + return null; + } + }, + onActivate: (restaurant) => { onMarkerClick?.(restaurant); - - - // 2. selectedRestaurant 업데이트 onRestaurantSelect?.(restaurant); - - - // 3. 지도 이동은 마지막에 (비동기 작업) moveToRestaurant(restaurant); - - }); - - markersRef.current.push({ marker, restaurantId: restaurant.id }); + }, }); // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + useEffect(() => () => { + for (const { marker } of markersRef.current) marker.map = null; + markersRef.current = []; + }, []); + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) useEffect(() => { if (!isLoaded || markersRef.current.length === 0) return; diff --git a/apps/web/lib/map-view-marker-reconciliation.ts b/apps/web/lib/map-view-marker-reconciliation.ts new file mode 100644 index 0000000000..f4d0ea3488 --- /dev/null +++ b/apps/web/lib/map-view-marker-reconciliation.ts @@ -0,0 +1,105 @@ +import type { Restaurant } from '../types/restaurant'; +import { sanitizeMarkerImageUrl } from './html-escape'; +import { getRestaurantLatLng } from './map-view-google-helpers'; +import { getMapViewMarkerIcon } from './map-view-helpers'; +import { + buildMapViewMarkerHtml, + getMapViewMarkerSize, + isMapViewMarkerSelected, +} from './map-view-marker-helpers'; + +export interface ManagedMapViewMarker { + map: unknown; + content: Node | null; + position?: unknown; + title?: string; +} + +export interface MapViewMarkerEntry { + marker: M; + restaurantId: string; + restaurant: Restaurant; + position: { lat: number; lng: number }; + imagePath: string; + name: string; + onActivate: (restaurant: Restaurant) => void; +} + +// Reconcile in O(previous + next) time. Retained IDs keep their marker, image, +// and click listener; only added/removed IDs attach/detach provider objects. +export function reconcileMapViewMarkers({ + previous, + restaurants, + createMarker, + onActivate, + selectedRestaurantId, + searchedRestaurantId, +}: { + previous: readonly MapViewMarkerEntry[]; + restaurants: readonly Restaurant[]; + createMarker: (options: { + position: { lat: number; lng: number }; + content: HTMLElement; + title: string; + }) => M | null; + onActivate: (restaurant: Restaurant) => void; + selectedRestaurantId?: string | null; + searchedRestaurantId?: string | null; +}): MapViewMarkerEntry[] { + const remaining = new Map(previous.map((entry) => [entry.restaurantId, entry])); + const next: MapViewMarkerEntry[] = []; + const seen = new Set(); + + for (const restaurant of restaurants) { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng) + || seen.has(restaurant.id)) continue; + seen.add(restaurant.id); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const retained = remaining.get(restaurant.id); + if (retained) { + remaining.delete(restaurant.id); + if (retained.position.lat !== position.lat || retained.position.lng !== position.lng) { + retained.marker.position = position; + retained.position = position; + } + if (retained.imagePath !== imagePath || retained.name !== restaurant.name) { + const image = (retained.marker.content as HTMLElement | null)?.querySelector('img'); + if (retained.imagePath !== imagePath) image?.setAttribute('src', sanitizeMarkerImageUrl(imagePath)); + if (retained.name !== restaurant.name) { + image?.setAttribute('alt', restaurant.name); + retained.marker.title = restaurant.name; + } + retained.imagePath = imagePath; + retained.name = restaurant.name; + } + // The listener reads these fields at click time, including after a + // same-ID data refresh or a parent callback change. + retained.restaurant = restaurant; + retained.onActivate = onActivate; + next.push(retained); + continue; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, selectedRestaurantId, searchedRestaurantId, + }); + const content = document.createElement('div'); + content.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + content.innerHTML = buildMapViewMarkerHtml({ + imagePath, isSelected, markerSize: getMapViewMarkerSize(isSelected), name: restaurant.name, + }); + const marker = createMarker({ position, content, title: restaurant.name }); + if (!marker) continue; + const entry: MapViewMarkerEntry = { + marker, restaurantId: restaurant.id, restaurant, position, + imagePath, name: restaurant.name, onActivate, + }; + content.addEventListener('click', () => entry.onActivate(entry.restaurant)); + next.push(entry); + } + + // Keep the old visible markers until replacements have been attached. + for (const entry of remaining.values()) entry.marker.map = null; + return next; +} diff --git a/apps/web/lib/mobile-home-search-selection.ts b/apps/web/lib/mobile-home-search-selection.ts index a3b5681a8d..436c89dc18 100644 --- a/apps/web/lib/mobile-home-search-selection.ts +++ b/apps/web/lib/mobile-home-search-selection.ts @@ -44,6 +44,59 @@ export const isSameRestaurantSelection = (left: RestaurantMatch, right: Restaura return hasSameNameAndCoordinate(left, right); }; +// A render pass compares many candidates with one selection. Build its merged-id +// index at most once, instead of allocating a Set and arrays for every candidate. +const createRestaurantSelectionMatcher = (selection: Restaurant) => { + let selectionIds: Set | undefined; + return (candidate: Restaurant): boolean => { + if (candidate.id === selection.id || hasSameNameAndCoordinate(candidate, selection)) return true; + const selectionMerged = selection.mergedRestaurants; + const candidateMerged = candidate.mergedRestaurants; + if (!selectionMerged?.length) { + if (candidateMerged) { + for (let index = 0; index < candidateMerged.length; index += 1) { + if (candidateMerged[index].id === selection.id) return true; + } + } + return false; + } + if (!selectionIds) { + selectionIds = new Set([selection.id]); + for (let index = 0; index < selectionMerged.length; index += 1) { + selectionIds.add(selectionMerged[index].id); + } + } + if (selectionIds.has(candidate.id)) return true; + if (candidateMerged) { + for (let index = 0; index < candidateMerged.length; index += 1) { + if (selectionIds.has(candidateMerged[index].id)) return true; + } + } + return false; + }; +}; + +type SelectionCacheKey = Pick & { mergedIds: string[] }; + +const selectionCacheKey = (selection: Restaurant): SelectionCacheKey => ({ + id: selection.id, + name: selection.name, + lat: selection.lat, + lng: selection.lng, + mergedIds: selection.mergedRestaurants?.map((item) => item.id) ?? [], +}); + +const matchesSelectionCacheKey = (key: SelectionCacheKey, selection: Restaurant): boolean => { + if (key.id !== selection.id || key.name !== selection.name + || !Object.is(key.lat, selection.lat) || !Object.is(key.lng, selection.lng)) return false; + const merged = selection.mergedRestaurants; + if (key.mergedIds.length !== (merged?.length ?? 0)) return false; + for (let index = 0; index < key.mergedIds.length; index += 1) { + if (key.mergedIds[index] !== merged![index].id) return false; + } + return true; +}; + type SearchSelectionInput = { searchedRestaurant: Restaurant | null; selectedRestaurant: Restaurant | null; @@ -141,7 +194,16 @@ export const resolveReleasedSearchSelectionResetPlan = ({ return { nextReleasedSearchSelectionId: releasedSearchSelectionId } as const; }; +const rememberedUniqueSwipeLists = new WeakSet(); +let lastPostSearchDedupeExaminationCount = 0; + +export function getLastPostSearchDedupeExaminationCount() { + return lastPostSearchDedupeExaminationCount; +} + const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { + if (rememberedUniqueSwipeLists.has(restaurants)) return restaurants; + lastPostSearchDedupeExaminationCount += 1; const seenIds = new Set(); const seenMergedIds = new Set(); const restaurantsByName = new Map(); @@ -166,6 +228,11 @@ const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { else restaurantsByName.set(restaurant.name, [restaurant]); }); + if (uniqueRestaurants.length === restaurants.length) { + rememberedUniqueSwipeLists.add(restaurants); + return restaurants; + } + rememberedUniqueSwipeLists.add(uniqueRestaurants); return uniqueRestaurants; }; @@ -224,13 +291,39 @@ export const buildRestaurantsForSwipe = ({ return [...displayRestaurants, ...restaurantsToAdd]; }; -const nearestSwipeFallbackCache = new WeakMap>(); +// List-reference caches require replacing catalog/visible arrays on edits. +// Bound query history while retaining common back-and-forth searches. +const MAX_SEARCH_CACHE_ENTRIES = 64; +const rememberSelectionResult = (cache: Map, id: string, value: T) => { + cache.delete(id); + cache.set(id, value); + if (cache.size > MAX_SEARCH_CACHE_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } +}; + +const nearestSwipeFallbackCache = new WeakMap>(); let nearestFallbackScanCount = 0; export function getNearestFallbackScanCount() { return nearestFallbackScanCount; } +const orderedVisibleCache = new WeakMap>(); +let lastSwipeOrderBuildCount = 0; + +export function getLastSwipeOrderBuildCount() { + return lastSwipeOrderBuildCount; +} + export const buildPostSearchSwipeCandidates = ({ visibleRestaurants, allRestaurants, @@ -239,16 +332,37 @@ export const buildPostSearchSwipeCandidates = ({ const dedupedVisibleRestaurants = dedupeRestaurants(visibleRestaurants); let orderedVisibleRestaurants = dedupedVisibleRestaurants; if (activeSearchedRestaurant) { - const matchedRestaurants: Restaurant[] = []; - const otherRestaurants: Restaurant[] = []; - dedupedVisibleRestaurants.forEach((restaurant) => { - if (isSameRestaurantSelection(restaurant, activeSearchedRestaurant)) { - matchedRestaurants.push(restaurant); - } else { - otherRestaurants.push(restaurant); + let orders = orderedVisibleCache.get(dedupedVisibleRestaurants); + if (!orders) { + orders = new Map(); + orderedVisibleCache.set(dedupedVisibleRestaurants, orders); + } + const cachedOrder = orders.get(activeSearchedRestaurant.id); + if (cachedOrder && matchesSelectionCacheKey(cachedOrder.searched, activeSearchedRestaurant)) { + orderedVisibleRestaurants = cachedOrder.restaurants; + } else { + lastSwipeOrderBuildCount += 1; + const matches = createRestaurantSelectionMatcher(activeSearchedRestaurant); + orderedVisibleRestaurants = new Array(dedupedVisibleRestaurants.length); + let head = 0; + let tail = dedupedVisibleRestaurants.length - 1; + for (let index = 0; index < dedupedVisibleRestaurants.length; index += 1) { + const restaurant = dedupedVisibleRestaurants[index]; + if (matches(restaurant)) orderedVisibleRestaurants[head++] = restaurant; + else orderedVisibleRestaurants[tail--] = restaurant; } - }); - orderedVisibleRestaurants = matchedRestaurants.concat(otherRestaurants); + // Non-matches were written backwards; restore their input order. + // This stable partition needs one result array, not two + concat. + for (let left = head, right = orderedVisibleRestaurants.length - 1; left < right; left += 1, right -= 1) { + const restaurant = orderedVisibleRestaurants[left]; + orderedVisibleRestaurants[left] = orderedVisibleRestaurants[right]; + orderedVisibleRestaurants[right] = restaurant; + } + rememberSelectionResult(orders, activeSearchedRestaurant.id, { + searched: selectionCacheKey(activeSearchedRestaurant), + restaurants: orderedVisibleRestaurants, + }); + } } if (!activeSearchedRestaurant || orderedVisibleRestaurants.length !== 1) { @@ -256,14 +370,15 @@ export const buildPostSearchSwipeCandidates = ({ } const visibleRestaurant = orderedVisibleRestaurants[0]; - const cacheKey = `${activeSearchedRestaurant.id}:${visibleRestaurant.id}`; - let cachedFallbacks = nearestSwipeFallbackCache.get(allRestaurants); - if (!cachedFallbacks) { - cachedFallbacks = new Map(); - nearestSwipeFallbackCache.set(allRestaurants, cachedFallbacks); + let fallbacks = nearestSwipeFallbackCache.get(allRestaurants); + if (!fallbacks) { + fallbacks = new Map(); + nearestSwipeFallbackCache.set(allRestaurants, fallbacks); } - if (cachedFallbacks.has(cacheKey)) { - const cachedFallback = cachedFallbacks.get(cacheKey) ?? null; + const cached = fallbacks.get(activeSearchedRestaurant.id); + if (cached && matchesSelectionCacheKey(cached.searched, activeSearchedRestaurant) + && matchesSelectionCacheKey(cached.visible, visibleRestaurant)) { + const cachedFallback = cached.restaurant; return cachedFallback ? [...orderedVisibleRestaurants, cachedFallback] : orderedVisibleRestaurants; @@ -271,11 +386,12 @@ export const buildPostSearchSwipeCandidates = ({ nearestFallbackScanCount += 1; const dedupedRestaurants = dedupeRestaurants(allRestaurants); + const matchesVisible = createRestaurantSelectionMatcher(visibleRestaurant); let nearestFallbackRestaurant: Restaurant | null = null; let nearestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < dedupedRestaurants.length; index += 1) { const candidateRestaurant = dedupedRestaurants[index]; - if (isSameRestaurantSelection(candidateRestaurant, orderedVisibleRestaurants[0])) continue; + if (matchesVisible(candidateRestaurant)) continue; const candidateDistance = getApproximateRestaurantDistance( activeSearchedRestaurant, @@ -287,7 +403,11 @@ export const buildPostSearchSwipeCandidates = ({ nearestDistance = candidateDistance; } - cachedFallbacks.set(cacheKey, nearestFallbackRestaurant); + rememberSelectionResult(fallbacks, activeSearchedRestaurant.id, { + searched: selectionCacheKey(activeSearchedRestaurant), + visible: selectionCacheKey(visibleRestaurant), + restaurant: nearestFallbackRestaurant, + }); return nearestFallbackRestaurant ? [...orderedVisibleRestaurants, nearestFallbackRestaurant] diff --git a/apps/web/performance/google-marker-reconcile-20260925/artifact-map.json b/apps/web/performance/google-marker-reconcile-20260925/artifact-map.json new file mode 100644 index 0000000000..01490c51ca --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/artifact-map.json @@ -0,0 +1,14 @@ +{ + "artifacts": { + "baseline-MapView.tsx.txt": "0d13c1b5408c3b7118a76f64b0087293e87393e2ea8361ff621e533e90908da3", + "baseline-effect.mjs": "66c4222684af59f89b1e173602d0b01abaede1a117f78d2f0ea4133bcdbd270b", + "browser-entry.mjs": "caa4eca0b57706f933ca4b38fbdf978558e6d3200a67cdc050e13628a364b91f", + "browser-bundle.mjs": "77a31bb0249fb4f78596660c682e8c3a7d40a56e6900937d7cc3243178bb32ff", + "candidate-MapView.tsx.txt": "ddcb81ef3f5d71afaac15fe5f6752d56632dd70b2d2c54d8e6e52642cdd62c02", + "candidate-helper.ts.txt": "9e1f4b4d583f657f727c7a4eabb61ab67b99390373abf6dbb2fb23af58ba509a", + "frozen-tree.patch": "059bdc36a5618cb4b0f05d384a019017c1934d0406932981d2a640a33e4e061e", + "raw.json": "79caf77eec84b09ebba1db2272863ed28e7fd3bc7c8568e76bcef86886d52c77", + "measure.mjs": "cbeb6d4598e2e23cbe0e9ba0d1cfab09203e828bd27db6f702d66e1bcfa84160", + "score-and-verify.mjs": "bdb7a4a3231a5e9fdbf98fa82c6988e2b4c74a73887a63a0a38dfd468806e2d4" + } +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/baseline-MapView.tsx.txt b/apps/web/performance/google-marker-reconcile-20260925/baseline-MapView.tsx.txt new file mode 100644 index 0000000000..262f491255 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/baseline-MapView.tsx.txt @@ -0,0 +1,675 @@ +import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { useGoogleMaps } from "@/hooks/use-google-maps"; +import { useRestaurants } from "@/hooks/use-restaurants"; +import type { Restaurant } from "@/types/restaurant"; +import type { FilterState } from "@/components/filters/filter-state"; +import { MapSkeleton } from "@/components/skeletons/MapSkeleton"; +import { MapViewSurface } from "@/components/map/map-view-surface"; +import { MapViewSidepanelStack } from "@/components/map/map-view-sidepanel-stack"; +import { + MapViewErrorState, + MapViewGoogleLoadErrorState, + MapViewMissingApiKeyState, +} from "@/components/map/map-view-status-panels"; +import { + getMapViewCountryConfig, + getMapViewMarkerIcon, + mergeSearchedRestaurant, +} from "@/lib/map-view-helpers"; +import { + resolveMapViewSelectedPanTarget, + shouldCenterSelectedRestaurant, +} from "@/lib/map-view-movement-helpers"; +import { + applyMapViewMarkerSelectedState, + buildMapViewMarkerHtml, + getMapViewMarkerSize, + isMapViewMarkerSelected, +} from "@/lib/map-view-marker-helpers"; +import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, + getMapViewRestaurantCountToastVisible, +} from "@/lib/map-view-state-helpers"; +import { buildMapViewRestaurantsQueryOptions } from "@/lib/map-query-helpers"; +import { + buildMapViewPanelStateSetter, + buildMapViewPanelCloseHandler, + buildMapViewRestaurantAction, + buildMapViewReviewOpenHandler, + buildMapViewTogglePanelHandler, + resolveMapViewPanelOpenState, + resolveMapViewPanelWidth, +} from "@/lib/map-view-panel-helpers"; +import { buildMapViewPanelWidthObserver } from "@/lib/map-view-panel-width-helpers"; +import { + buildMapViewDetailPanelFocusCaptureHandler, + buildMapViewDetailPanelMouseDownCaptureHandler, + buildMapViewReviewCloseHandler, + buildMapViewReviewSuccessHandler, + shouldShowMapViewDetailPanel, +} from "@/lib/map-view-sidepanel-helpers"; +import { + buildGoogleMapOptions, + getRestaurantLatLng, + panGoogleMapToPosition, +} from "@/lib/map-view-google-helpers"; + +interface MapPointLike { + lat: () => number; + lng: () => number; +} + +interface MapBoundsLike { + getNorthEast: () => MapPointLike; + getSouthWest: () => MapPointLike; +} + +interface GoogleMapLike { + panTo: (position: { lat: number; lng: number }) => void; + setZoom: (zoom: number) => void; + setCenter: (position: { lat: number; lng: number }) => void; + getBounds: () => MapBoundsLike | null; + addListener: (eventName: string, handler: (...args: unknown[]) => void) => unknown; +} + +interface GoogleMarkerLike { + map: GoogleMapLike | null; + content: Element | null; +} + +interface MapMarkerEntry { + marker: GoogleMarkerLike; + restaurantId: string; +} +function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === "string") { + return new Error(error); + } + + if (typeof error === "object" && error !== null) { + try { + const message = JSON.stringify(error); + if (message) { + return new Error(message); + } + } catch { + // Fall through to the safe generic message. + } + } + + return new Error("알 수 없는 오류가 발생했습니다."); +} + +function MapViewErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ; +} + +interface MapViewProps { + filters: FilterState; + selectedCountry?: string | null; + searchedRestaurant?: Restaurant | null; + selectedRestaurant?: Restaurant | null; + refreshTrigger?: number; + onAdminAddRestaurant?: () => void; + onAdminEditRestaurant?: (restaurant: Restaurant) => void; + onRestaurantSelect?: (restaurant: Restaurant | null) => void; + onMapReady?: (moveToRestaurant: (restaurant: Restaurant) => void) => void; + onRequestEditRestaurant?: (restaurant: Restaurant) => void; + // [패널 관리] 마커 클릭, 패널 닫기 등 콜백 + onMarkerClick?: (restaurant: Restaurant) => void; + // [동적 레이아웃] 패널 너비에 따른 지도 중심 오프셋 계산용 + panelWidth?: number; + activePanel?: 'map' | 'detail' | 'control'; + onPanelClick?: (panel: 'map' | 'detail' | 'control') => void; + isPanelOpen?: boolean; + onPanelClose?: () => void; + onTogglePanelCollapse?: () => void; +} + +const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRestaurant, refreshTrigger, onAdminAddRestaurant, onAdminEditRestaurant, onRestaurantSelect, onMapReady, onRequestEditRestaurant, onMarkerClick, panelWidth: propPanelWidth, activePanel, onPanelClick, isPanelOpen: propIsPanelOpen, onPanelClose, onTogglePanelCollapse }: MapViewProps) => { + // 필터 객체 메모이제이션 + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); + const markersRef = useRef([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + + // [상태] 지도 이동 - 선택된 맛집으로 이동 (한 번만 수행) + const lastCenteredRestaurantId = useRef(null); + + // 패널 상태 관리 (내부적으로 관리하거나 props로 제어) + const [mapBounds, setMapBounds] = useState(null); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [panelWidth, setPanelWidth] = useState(0); + const [showRestaurantCount, setShowRestaurantCount] = useState(false); + const [localIsPanelOpen, setLocalIsPanelOpen] = useState(false); + const [hasGoogleRuntimeError, setHasGoogleRuntimeError] = useState(false); + const [safeBoundsQuery, setSafeBoundsQuery] = useState>(undefined); + + useEffect(() => { + selectedCountryRef.current = selectedCountry; + }, [selectedCountry]); + + // props로 전달된 isPanelOpen이 있으면 우선 사용, 없으면 로컬 상태 사용 + const isPanelOpen = useMemo( + () => resolveMapViewPanelOpenState({ localIsPanelOpen, propIsPanelOpen }), + [localIsPanelOpen, propIsPanelOpen] + ); + const setIsPanelOpen = useMemo( + () => buildMapViewPanelStateSetter({ onTogglePanelCollapse, setLocalIsPanelOpen }), + [onTogglePanelCollapse] + ); + + const handleDetailPanelMouseDownCapture = useMemo( + () => buildMapViewDetailPanelMouseDownCaptureHandler(onPanelClick), + [onPanelClick] + ); + + const handleDetailPanelFocusCapture = useMemo( + () => buildMapViewDetailPanelFocusCaptureHandler(onPanelClick), + [onPanelClick] + ); + + // props로 전달된 panelWidth가 있으면 우선 사용 + const effectivePanelWidth = useMemo( + () => resolveMapViewPanelWidth({ panelWidth, propPanelWidth }), + [panelWidth, propPanelWidth] + ); + const handlePanelClose = useMemo( + () => buildMapViewPanelCloseHandler({ onPanelClose, setIsPanelOpen }), + [onPanelClose, setIsPanelOpen] + ); + const handleOpenReviewModal = useMemo( + () => buildMapViewReviewOpenHandler(setIsReviewModalOpen), + [] + ); + const handleCloseReviewModal = useMemo( + () => buildMapViewReviewCloseHandler(setIsReviewModalOpen), + [] + ); + const handleTogglePanel = useMemo( + () => buildMapViewTogglePanelHandler({ isPanelOpen, setIsPanelOpen }), + [isPanelOpen, setIsPanelOpen] + ); + const handleEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onAdminEditRestaurant, selectedRestaurant), + [onAdminEditRestaurant, selectedRestaurant] + ); + const handleRequestEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onRequestEditRestaurant, selectedRestaurant), + [onRequestEditRestaurant, selectedRestaurant] + ); + + + // 맛집으로 지도 이동하는 함수 (즉시 실행, 재시도 없음) + const moveToRestaurant = useCallback((restaurant: Restaurant) => { + if (!googleMapRef.current) { + + return; + } + + const position = { lat: Number(restaurant.lat), lng: Number(restaurant.lng) }; + + try { + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current, "resize"), + zoom: 14, + }); + } catch (error) { + console.error('MapView: Error moving to restaurant position:'); + } + }, []); + + // Google Maps API 로드 + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + const { isLoaded, loadError } = useGoogleMaps({ apiKey }); + + // ResizeObserver로 패널 너비 추적 (내부 패널이 있는 경우만) + useEffect(() => { + // props로 panelWidth가 전달되면 ResizeObserver 불필요 + if (propPanelWidth !== undefined || !detailPanelRef.current) return; + + const { observerCallback } = buildMapViewPanelWidthObserver({ + setPanelWidth, + }); + const resizeObserver = new ResizeObserver(observerCallback); + + resizeObserver.observe(detailPanelRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [propPanelWidth]); + + // 외부 콜백에 지도 이동 함수 전달 + useEffect(() => { + if (onMapReady) { + // 부모 컴포넌트에 지도 이동 함수를 전달 + onMapReady(moveToRestaurant); + } + }, [onMapReady, moveToRestaurant]); + + // 검색된 맛집으로 지도 이동 (정확히 중앙) + useEffect(() => { + if (!searchedRestaurant || !isLoaded || !googleMapRef.current) { + return; + } + + const position = getRestaurantLatLng(searchedRestaurant); + if (!position) { + return; + } + + try { + if (!googleMapRef.current) return; + + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current!, "resize"), + zoom: 14, + }); + + // 검색된 맛집 선택 상태로 설정 + if (onRestaurantSelect) { + onRestaurantSelect(searchedRestaurant); + } + } catch (error) { + console.error('MapView: Error moving to searched restaurant position:'); + } + }, [searchedRestaurant, onRestaurantSelect, isLoaded]); + + // [지도 이동] 선택된 맛집으로 이동 (한 번만 수행) + useEffect(() => { + if (!selectedRestaurant || !mapRef.current || !isLoaded) return; + + // 이미 이 맛집으로 이동했다면 건너뛰기 (사용자가 지도를 움직일 수 있게 함) + // 단, selectedRestaurant 객체가 아예 바뀌었더라도 ID가 같다면 이동하지 않음 + if (!shouldCenterSelectedRestaurant({ + lastCenteredRestaurantId: lastCenteredRestaurantId.current, + selectedRestaurantId: selectedRestaurant.id, + })) { + return; + } + + const position = getRestaurantLatLng(selectedRestaurant); + if (!position) { + return; + } + + try { + const map = googleMapRef.current; + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + // 지도의 실제 너비 계산 + const mapWidth = mapRef.current?.offsetWidth || 0; + const sidebarWidth = 0; // GlobalMapPage에는 사이드바 없음 + + const adjustedLng = resolveMapViewSelectedPanTarget({ + boundsNorthEastLng: bounds.getNorthEast().lng(), + boundsSouthWestLng: bounds.getSouthWest().lng(), + lng: position.lng, + mapWidth, + panelWidth: effectivePanelWidth, + sidebarWidth, + }); + + // 지도 이동 + map.setCenter({ lat: position.lat, lng: adjustedLng }); + + // 이동 완료 표시 + lastCenteredRestaurantId.current = selectedRestaurant.id; + } catch (error) { + console.error('MapView: Error moving to selected restaurant:'); + } + }, [selectedRestaurant, isLoaded, effectivePanelWidth]); + + // 선택 해제 시 ref 초기화 + useEffect(() => { + if (!selectedRestaurant) { + lastCenteredRestaurantId.current = null; + } + }, [selectedRestaurant]); + + useEffect(() => { + if (!mapBounds) { + setSafeBoundsQuery(undefined); + return; + } + try { + setSafeBoundsQuery(buildMapViewBoundsQuery(mapBounds)); + } catch (error) { + console.warn('MapView: keeping previous valid bounds after bounds query failure'); + } + }, [mapBounds]); + + // useRestaurants 옵션 메모이제이션 + const restaurantsOptions = useMemo(() => buildMapViewRestaurantsQueryOptions({ + bounds: safeBoundsQuery, + filters: memoizedFilters, + isLoaded, + selectedCountry, + }), [safeBoundsQuery, memoizedFilters, selectedCountry, isLoaded]); + + const { data: restaurants = [], isLoading: isLoadingRestaurants, refetch } = useRestaurants(restaurantsOptions); + const handleReviewSuccess = useMemo( + () => buildMapViewReviewSuccessHandler(refetch), + [refetch] + ); + + // 맛집 개수 표시 자동 숨김 처리 + useEffect(() => { + if (getMapViewRestaurantCountToastVisible(restaurants.length, isLoadingRestaurants)) { + setShowRestaurantCount(true); + const timer = setTimeout(() => { + setShowRestaurantCount(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [restaurants, isLoadingRestaurants]); + + // 마커를 표시할 맛집 목록 (기존 restaurants + 검색된 맛집) + const restaurantsToShow = useMemo(() => { + return mergeSearchedRestaurant(restaurants, searchedRestaurant ?? null); + }, [restaurants, searchedRestaurant]); + const restaurantsById = useMemo(() => { + return new Map(restaurantsToShow.map((restaurant) => [restaurant.id, restaurant])); + }, [restaurantsToShow]); + + + // [데이터 갱신] refreshTrigger 변경 시 재조회 (리뷰 작성 등) + useEffect(() => { + if (refreshTrigger) { + refetch(); + } + }, [refreshTrigger, refetch]); + + useEffect(() => { + if (!isLoaded || !mapRef.current) return; + + let attempts = 0; + const interval = window.setInterval(() => { + attempts += 1; + const mapText = mapRef.current?.innerText ?? ''; + if (mapText.includes("This page didn't load Google Maps correctly")) { + setHasGoogleRuntimeError(true); + window.clearInterval(interval); + } + if (attempts >= 6) { + window.clearInterval(interval); + } + }, 500); + + return () => window.clearInterval(interval); + }, [isLoaded]); + + + // refreshTrigger 변경 시 선택된 레스토랑 정보 업데이트 + useEffect(() => { + const updatedRestaurant = findUpdatedSelectedRestaurant(restaurants, selectedRestaurant); + if (updatedRestaurant) { + onRestaurantSelect?.(updatedRestaurant); + // [수정] 화면 밖으로 벗어나서 리스트에 없더라도 패널을 닫지 않음 + // else if (!updatedRestaurant) { + // onRestaurantSelect?.(null); + // } + } + }, [restaurants, refreshTrigger, selectedRestaurant, onRestaurantSelect]); + + + // [지도 초기화] Google Maps 인스턴스 생성 + useEffect(() => { + if (!isLoaded || !mapRef.current) { + return; + } + + // [유효성 검사] Google Maps API 로드 확인 + if (!window.google || !window.google.maps || !window.google.maps.Map) { + return; + } + + // 선택된 국가에 따라 중심점과 줌 설정 (기본값: 미국) + const initialSelectedCountry = selectedCountryRef.current; + const countryConfig = getMapViewCountryConfig(initialSelectedCountry); + const center = { lat: countryConfig.lat, lng: countryConfig.lng }; + const zoom = countryConfig.zoom; + + try { + const map = new google.maps.Map(mapRef.current, buildGoogleMapOptions({ center, zoom })); + + googleMapRef.current = map; + + // [이동 감지] 지도 이동 종료 시 경계값 업데이트 + map.addListener("idle", () => { + const bounds = map.getBounds(); + if (bounds) { + setMapBounds(bounds); + } + }); + } catch (error) { + console.error("Error creating Google Map:"); + } + }, [isLoaded]); + + // [국가 변경] 선택된 국가에 따라 지도 중심 및 줌 레벨 조정 + useEffect(() => { + if (!googleMapRef.current || !selectedCountry) return; + + // 검색된 맛집이 있으면 국가 중심으로 이동하지 않음 (검색 이동 로직이 우선) + // 단, 검색된 맛집이 현재 선택된 국가와 다를 수 있으므로 주의 필요하지만, + // handleRestaurantSearch에서 이미 국가를 맞춰주므로 여기서는 이동만 막으면 됨 + if (searchedRestaurant) return; + + const countryConfig = getMapViewCountryConfig(selectedCountry); + if (countryConfig) { + googleMapRef.current.setCenter({ lat: countryConfig.lat, lng: countryConfig.lng }); + googleMapRef.current.setZoom(countryConfig.zoom); + } + }, [selectedCountry, searchedRestaurant]); + + // [재시도 로직] 지도 로드 실패 시 재시도 (API 지연 로드 대응) + useEffect(() => { + if (!isLoaded && window.google && window.google.maps && window.google.maps.Map && mapRef.current && !googleMapRef.current) { + // 강제 재로드 실행 + setTimeout(() => { + window.location.reload(); + }, 500); + } + }, [isLoaded]); + + // [마커 관리] 맛집 데이터 변경 시 마커 업데이트 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + // 기존 마커 제거 (메모리 누수 방지) + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + + // 새 마커 생성 + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name, + }); + + let marker: GoogleMarkerLike; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name, + }); + } catch { + console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); + return; + } + + markerElement.addEventListener("click", () => { + + + // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) + onMarkerClick?.(restaurant); + + + // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); + + + // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); + + }); + + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) { + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + markerElement.classList.toggle('selected-marker', isSelected); + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, [selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById, isLoaded]); + + // 줌 이벤트 시 마커 스타일 유지 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const handleZoomChange = () => { + // 줌 변경 후 약간의 지연을 주어 마커 스타일 재적용 + setTimeout(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) return; + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, 100); + }; + + // 줌 변경 이벤트 리스너 추가 + const zoomListener = googleMapRef.current.addListener('zoom_changed', handleZoomChange); + + return () => { + if (zoomListener) { + google.maps.event.removeListener(zoomListener); + } + }; + }, [isLoaded, selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById]); + + if (!apiKey) { + return ; + } + + if (loadError || hasGoogleRuntimeError) { + return ; + } + + if (!isLoaded) { + return ; + } + + return ( + +
+ + + {shouldShowMapViewDetailPanel({ onMarkerClick, selectedRestaurant }) && ( + + )} +
+
+ ); +}); + +MapView.displayName = 'MapView'; + +export default MapView; diff --git a/apps/web/performance/google-marker-reconcile-20260925/baseline-effect.mjs b/apps/web/performance/google-marker-reconcile-20260925/baseline-effect.mjs new file mode 100644 index 0000000000..3889bc2cf4 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/baseline-effect.mjs @@ -0,0 +1,70 @@ +import { getRestaurantLatLng } from '../../lib/map-view-google-helpers'; +import { getMapViewMarkerIcon } from '../../lib/map-view-helpers'; +import { buildMapViewMarkerHtml, getMapViewMarkerSize, isMapViewMarkerSelected } from '../../lib/map-view-marker-helpers'; +export function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = { current: previous }, restaurantsToShow = restaurants; + const googleMapRef = { current: map }, isLoaded = true; + const google = { maps: { marker: { AdvancedMarkerElement: Marker } } }; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => { }, setHasGoogleRuntimeError = () => { }; + if (!googleMapRef.current || !isLoaded) + return; + // 기존 마커 제거 (메모리 누수 방지) + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + // 새 마커 생성 + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); + return; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name, + }); + let marker; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name, + }); + } + catch { + console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); + return; + } + markerElement.addEventListener("click", () => { + // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) + onMarkerClick?.(restaurant); + // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); + // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); + }); + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + return markersRef.current; +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/browser-bundle.mjs b/apps/web/performance/google-marker-reconcile-20260925/browser-bundle.mjs new file mode 100644 index 0000000000..3771e6a1fc --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/browser-bundle.mjs @@ -0,0 +1,292 @@ +// lib/map-view-google-helpers.ts +function getRestaurantLatLng(restaurant) { + if (!restaurant) + return null; + const lat = Number(restaurant.lat); + const lng = Number(restaurant.lng); + if (Number.isNaN(lat) || Number.isNaN(lng)) { + return null; + } + return { lat, lng }; +} + +// lib/map-view-helpers.ts +function getMapViewMarkerIcon(categories) { + if (!categories) + return "/images/maker-images/webp/korean.webp"; + const category = Array.isArray(categories) ? categories[0] : categories; + const imageMap = { + 고기: "/images/maker-images/webp/meat_bbq.webp", + 치킨: "/images/maker-images/webp/chicken.webp", + 한식: "/images/maker-images/webp/korean.webp", + 중식: "/images/maker-images/webp/chinese.webp", + 일식: "/images/maker-images/webp/cutlet_sashimi.webp", + 양식: "/images/maker-images/webp/western.webp", + 분식: "/images/maker-images/webp/snack_bar.webp", + "카페·디저트": "/images/maker-images/webp/cafe_dessert.webp", + 아시안: "/images/maker-images/webp/asian.webp", + 패스트푸드: "/images/maker-images/webp/fastfood.webp", + "족발·보쌈": "/images/maker-images/webp/pork_feet.webp", + "돈까스·회": "/images/maker-images/webp/cutlet_sashimi.webp", + 피자: "/images/maker-images/webp/pizza.webp", + "찜·탕": "/images/maker-images/webp/stew.webp", + 야식: "/images/maker-images/webp/late_night.webp", + 도시락: "/images/maker-images/webp/lunch_box.webp" + }; + return imageMap[category] || "/images/maker-images/webp/korean.webp"; +} + +// lib/html-escape.ts +var MARKER_IMAGE_FALLBACK = "/images/maker-images/webp/chicken.webp"; +var MAX_MARKER_IMAGE_URL_LENGTH = 2048; +var MAX_PERCENT_DECODE_PASSES = 4; +var CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F-\u009F]/; +var ROOT_URL = "https://marker.invalid"; +function decodeForMarkerImageSafety(value) { + let decodedValue = value; + for (let pass = 0;pass < MAX_PERCENT_DECODE_PASSES; pass += 1) { + if (CONTROL_CHARACTER_PATTERN.test(decodedValue) || decodedValue.includes("\\")) { + return null; + } + if (!decodedValue.includes("%")) { + return decodedValue; + } + try { + const nextValue = decodeURIComponent(decodedValue); + if (nextValue === decodedValue) { + return decodedValue; + } + decodedValue = nextValue; + } catch { + return null; + } + } + return null; +} +function hasUnsafeDecodedMarkerPath(value) { + const decodedPath = decodeForMarkerImageSafety(value); + return decodedPath === null || decodedPath.includes("//") || decodedPath.split("/").some((segment) => segment === "." || segment === ".."); +} +function isCanonicalRootRelativeMarkerPath(value) { + if (!value.startsWith("/") || value.startsWith("//") || value.includes("?") || value.includes("#")) { + return false; + } + if (hasUnsafeDecodedMarkerPath(value)) { + return false; + } + try { + const url = new URL(value, ROOT_URL); + return url.origin === ROOT_URL && url.pathname === value && url.search === "" && url.hash === ""; + } catch { + return false; + } +} +function isCanonicalHttpsMarkerUrl(value) { + if (decodeForMarkerImageSafety(value) === null) { + return false; + } + try { + const url = new URL(value); + return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname !== "" && url.href === value && !hasUnsafeDecodedMarkerPath(url.pathname); + } catch { + return false; + } +} +function sanitizeMarkerImageUrl(value) { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_MARKER_IMAGE_URL_LENGTH || CONTROL_CHARACTER_PATTERN.test(value) || value.includes("\\")) { + return MARKER_IMAGE_FALLBACK; + } + if (isCanonicalRootRelativeMarkerPath(value) || isCanonicalHttpsMarkerUrl(value)) { + return value; + } + return MARKER_IMAGE_FALLBACK; +} +function escapeHtmlAttribute(value) { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + default: + return char; + } + }); +} + +// lib/map-view-marker-helpers.ts +var MAP_VIEW_MARKER_BASE_SIZE_CLASSES = ["h-8", "w-8"]; +var MAP_VIEW_MARKER_SELECTED_SIZE_CLASSES = ["h-[42px]", "w-[42px]"]; +function isMapViewMarkerSelected({ + restaurantId, + searchedRestaurantId, + selectedRestaurantId +}) { + return selectedRestaurantId === restaurantId || searchedRestaurantId === restaurantId; +} +function getMapViewMarkerSize(isSelected) { + return isSelected ? 42 : 32; +} +function buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name +}) { + const expectedMarkerSize = getMapViewMarkerSize(isSelected); + const normalizedMarkerSize = Number.isFinite(markerSize) && markerSize === expectedMarkerSize ? markerSize : expectedMarkerSize; + const markerSizeClasses = normalizedMarkerSize === 42 ? MAP_VIEW_MARKER_SELECTED_SIZE_CLASSES : MAP_VIEW_MARKER_BASE_SIZE_CLASSES; + const safeImagePath = escapeHtmlAttribute(sanitizeMarkerImageUrl(imagePath)); + const safeName = escapeHtmlAttribute(name); + return ` +
+ ${safeName} +
+ `; +} + +// performance/google-marker-reconcile-20260925/baseline-effect.mjs +function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = { current: previous }, restaurantsToShow = restaurants; + const googleMapRef = { current: map }, isLoaded = true; + const google = { maps: { marker: { AdvancedMarkerElement: Marker } } }; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => {}, setHasGoogleRuntimeError = () => {}; + if (!googleMapRef.current || !isLoaded) + return; + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn("MapView: Advanced marker support unavailable"); + setHasGoogleRuntimeError(true); + return; + } + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn("MapView: marker skipped because restaurant coordinates are invalid", { restaurantId: restaurant.id }); + return; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? "selected-marker" : ""}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name + }); + let marker; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name + }); + } catch { + console.warn("MapView: Advanced marker creation skipped", { restaurantId: restaurant.id }); + return; + } + markerElement.addEventListener("click", () => { + onMarkerClick?.(restaurant); + onRestaurantSelect?.(restaurant); + moveToRestaurant(restaurant); + }); + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + return markersRef.current; +} +// lib/map-view-marker-reconciliation.ts +function reconcileMapViewMarkers({ + previous, + restaurants, + createMarker, + onActivate, + selectedRestaurantId, + searchedRestaurantId +}) { + const remaining = new Map(previous.map((entry) => [entry.restaurantId, entry])); + const next = []; + const seen = new Set; + for (const restaurant of restaurants) { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng) || seen.has(restaurant.id)) + continue; + seen.add(restaurant.id); + const imagePath = sanitizeMarkerImageUrl(getMapViewMarkerIcon(restaurant.categories)); + const retained = remaining.get(restaurant.id); + if (retained) { + remaining.delete(restaurant.id); + if (retained.position.lat !== position.lat || retained.position.lng !== position.lng) { + retained.marker.position = position; + retained.position = position; + } + if (retained.imagePath !== imagePath || retained.name !== restaurant.name) { + const image = retained.marker.content?.querySelector("img"); + if (retained.imagePath !== imagePath) + image?.setAttribute("src", imagePath); + if (retained.name !== restaurant.name) { + image?.setAttribute("alt", restaurant.name); + retained.marker.title = restaurant.name; + } + retained.imagePath = imagePath; + retained.name = restaurant.name; + } + retained.restaurant = restaurant; + retained.onActivate = onActivate; + next.push(retained); + continue; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + selectedRestaurantId, + searchedRestaurantId + }); + const content = document.createElement("div"); + content.className = `custom-marker ${isSelected ? "selected-marker" : ""}`; + content.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize: getMapViewMarkerSize(isSelected), + name: restaurant.name + }); + const marker = createMarker({ position, content, title: restaurant.name }); + if (!marker) + continue; + const entry = { + marker, + restaurantId: restaurant.id, + restaurant, + position, + imagePath, + name: restaurant.name, + onActivate + }; + content.addEventListener("click", () => entry.onActivate(entry.restaurant)); + next.push(entry); + } + for (const entry of remaining.values()) + entry.marker.map = null; + return next; +} +export { + baselineUpdate, + reconcileMapViewMarkers +}; diff --git a/apps/web/performance/google-marker-reconcile-20260925/browser-entry.mjs b/apps/web/performance/google-marker-reconcile-20260925/browser-entry.mjs new file mode 100644 index 0000000000..d7af44a62d --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/browser-entry.mjs @@ -0,0 +1,3 @@ + +export { baselineUpdate } from './baseline-effect.mjs'; +export { reconcileMapViewMarkers } from '../../lib/map-view-marker-reconciliation'; diff --git a/apps/web/performance/google-marker-reconcile-20260925/candidate-MapView.tsx.txt b/apps/web/performance/google-marker-reconcile-20260925/candidate-MapView.tsx.txt new file mode 100644 index 0000000000..11f0804a8e --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/candidate-MapView.tsx.txt @@ -0,0 +1,632 @@ +import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { useGoogleMaps } from "@/hooks/use-google-maps"; +import { useRestaurants } from "@/hooks/use-restaurants"; +import type { Restaurant } from "@/types/restaurant"; +import type { FilterState } from "@/components/filters/filter-state"; +import { MapSkeleton } from "@/components/skeletons/MapSkeleton"; +import { MapViewSurface } from "@/components/map/map-view-surface"; +import { MapViewSidepanelStack } from "@/components/map/map-view-sidepanel-stack"; +import { + MapViewErrorState, + MapViewGoogleLoadErrorState, + MapViewMissingApiKeyState, +} from "@/components/map/map-view-status-panels"; +import { + getMapViewCountryConfig, + mergeSearchedRestaurant, +} from "@/lib/map-view-helpers"; +import { + resolveMapViewSelectedPanTarget, + shouldCenterSelectedRestaurant, +} from "@/lib/map-view-movement-helpers"; +import { + applyMapViewMarkerSelectedState, + isMapViewMarkerSelected, +} from "@/lib/map-view-marker-helpers"; +import { reconcileMapViewMarkers, type MapViewMarkerEntry } from "@/lib/map-view-marker-reconciliation"; +import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, + getMapViewRestaurantCountToastVisible, +} from "@/lib/map-view-state-helpers"; +import { buildMapViewRestaurantsQueryOptions } from "@/lib/map-query-helpers"; +import { + buildMapViewPanelStateSetter, + buildMapViewPanelCloseHandler, + buildMapViewRestaurantAction, + buildMapViewReviewOpenHandler, + buildMapViewTogglePanelHandler, + resolveMapViewPanelOpenState, + resolveMapViewPanelWidth, +} from "@/lib/map-view-panel-helpers"; +import { buildMapViewPanelWidthObserver } from "@/lib/map-view-panel-width-helpers"; +import { + buildMapViewDetailPanelFocusCaptureHandler, + buildMapViewDetailPanelMouseDownCaptureHandler, + buildMapViewReviewCloseHandler, + buildMapViewReviewSuccessHandler, + shouldShowMapViewDetailPanel, +} from "@/lib/map-view-sidepanel-helpers"; +import { + buildGoogleMapOptions, + getRestaurantLatLng, + panGoogleMapToPosition, +} from "@/lib/map-view-google-helpers"; + +interface MapPointLike { + lat: () => number; + lng: () => number; +} + +interface MapBoundsLike { + getNorthEast: () => MapPointLike; + getSouthWest: () => MapPointLike; +} + +interface GoogleMapLike { + panTo: (position: { lat: number; lng: number }) => void; + setZoom: (zoom: number) => void; + setCenter: (position: { lat: number; lng: number }) => void; + getBounds: () => MapBoundsLike | null; + addListener: (eventName: string, handler: (...args: unknown[]) => void) => unknown; +} + +interface GoogleMarkerLike { + map: GoogleMapLike | null; + content: Node | null; + position?: unknown; + title?: string; +} +function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === "string") { + return new Error(error); + } + + if (typeof error === "object" && error !== null) { + try { + const message = JSON.stringify(error); + if (message) { + return new Error(message); + } + } catch { + // Fall through to the safe generic message. + } + } + + return new Error("알 수 없는 오류가 발생했습니다."); +} + +function MapViewErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ; +} + +interface MapViewProps { + filters: FilterState; + selectedCountry?: string | null; + searchedRestaurant?: Restaurant | null; + selectedRestaurant?: Restaurant | null; + refreshTrigger?: number; + onAdminAddRestaurant?: () => void; + onAdminEditRestaurant?: (restaurant: Restaurant) => void; + onRestaurantSelect?: (restaurant: Restaurant | null) => void; + onMapReady?: (moveToRestaurant: (restaurant: Restaurant) => void) => void; + onRequestEditRestaurant?: (restaurant: Restaurant) => void; + // [패널 관리] 마커 클릭, 패널 닫기 등 콜백 + onMarkerClick?: (restaurant: Restaurant) => void; + // [동적 레이아웃] 패널 너비에 따른 지도 중심 오프셋 계산용 + panelWidth?: number; + activePanel?: 'map' | 'detail' | 'control'; + onPanelClick?: (panel: 'map' | 'detail' | 'control') => void; + isPanelOpen?: boolean; + onPanelClose?: () => void; + onTogglePanelCollapse?: () => void; +} + +const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRestaurant, refreshTrigger, onAdminAddRestaurant, onAdminEditRestaurant, onRestaurantSelect, onMapReady, onRequestEditRestaurant, onMarkerClick, panelWidth: propPanelWidth, activePanel, onPanelClick, isPanelOpen: propIsPanelOpen, onPanelClose, onTogglePanelCollapse }: MapViewProps) => { + // 필터 객체 메모이제이션 + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); + const markersRef = useRef[]>([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + + // [상태] 지도 이동 - 선택된 맛집으로 이동 (한 번만 수행) + const lastCenteredRestaurantId = useRef(null); + + // 패널 상태 관리 (내부적으로 관리하거나 props로 제어) + const [mapBounds, setMapBounds] = useState(null); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [panelWidth, setPanelWidth] = useState(0); + const [showRestaurantCount, setShowRestaurantCount] = useState(false); + const [localIsPanelOpen, setLocalIsPanelOpen] = useState(false); + const [hasGoogleRuntimeError, setHasGoogleRuntimeError] = useState(false); + const [safeBoundsQuery, setSafeBoundsQuery] = useState>(undefined); + + useEffect(() => { + selectedCountryRef.current = selectedCountry; + }, [selectedCountry]); + + // props로 전달된 isPanelOpen이 있으면 우선 사용, 없으면 로컬 상태 사용 + const isPanelOpen = useMemo( + () => resolveMapViewPanelOpenState({ localIsPanelOpen, propIsPanelOpen }), + [localIsPanelOpen, propIsPanelOpen] + ); + const setIsPanelOpen = useMemo( + () => buildMapViewPanelStateSetter({ onTogglePanelCollapse, setLocalIsPanelOpen }), + [onTogglePanelCollapse] + ); + + const handleDetailPanelMouseDownCapture = useMemo( + () => buildMapViewDetailPanelMouseDownCaptureHandler(onPanelClick), + [onPanelClick] + ); + + const handleDetailPanelFocusCapture = useMemo( + () => buildMapViewDetailPanelFocusCaptureHandler(onPanelClick), + [onPanelClick] + ); + + // props로 전달된 panelWidth가 있으면 우선 사용 + const effectivePanelWidth = useMemo( + () => resolveMapViewPanelWidth({ panelWidth, propPanelWidth }), + [panelWidth, propPanelWidth] + ); + const handlePanelClose = useMemo( + () => buildMapViewPanelCloseHandler({ onPanelClose, setIsPanelOpen }), + [onPanelClose, setIsPanelOpen] + ); + const handleOpenReviewModal = useMemo( + () => buildMapViewReviewOpenHandler(setIsReviewModalOpen), + [] + ); + const handleCloseReviewModal = useMemo( + () => buildMapViewReviewCloseHandler(setIsReviewModalOpen), + [] + ); + const handleTogglePanel = useMemo( + () => buildMapViewTogglePanelHandler({ isPanelOpen, setIsPanelOpen }), + [isPanelOpen, setIsPanelOpen] + ); + const handleEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onAdminEditRestaurant, selectedRestaurant), + [onAdminEditRestaurant, selectedRestaurant] + ); + const handleRequestEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onRequestEditRestaurant, selectedRestaurant), + [onRequestEditRestaurant, selectedRestaurant] + ); + + + // 맛집으로 지도 이동하는 함수 (즉시 실행, 재시도 없음) + const moveToRestaurant = useCallback((restaurant: Restaurant) => { + if (!googleMapRef.current) { + + return; + } + + const position = { lat: Number(restaurant.lat), lng: Number(restaurant.lng) }; + + try { + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current, "resize"), + zoom: 14, + }); + } catch (error) { + console.error('MapView: Error moving to restaurant position:'); + } + }, []); + + // Google Maps API 로드 + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + const { isLoaded, loadError } = useGoogleMaps({ apiKey }); + + // ResizeObserver로 패널 너비 추적 (내부 패널이 있는 경우만) + useEffect(() => { + // props로 panelWidth가 전달되면 ResizeObserver 불필요 + if (propPanelWidth !== undefined || !detailPanelRef.current) return; + + const { observerCallback } = buildMapViewPanelWidthObserver({ + setPanelWidth, + }); + const resizeObserver = new ResizeObserver(observerCallback); + + resizeObserver.observe(detailPanelRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [propPanelWidth]); + + // 외부 콜백에 지도 이동 함수 전달 + useEffect(() => { + if (onMapReady) { + // 부모 컴포넌트에 지도 이동 함수를 전달 + onMapReady(moveToRestaurant); + } + }, [onMapReady, moveToRestaurant]); + + // 검색된 맛집으로 지도 이동 (정확히 중앙) + useEffect(() => { + if (!searchedRestaurant || !isLoaded || !googleMapRef.current) { + return; + } + + const position = getRestaurantLatLng(searchedRestaurant); + if (!position) { + return; + } + + try { + if (!googleMapRef.current) return; + + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current!, "resize"), + zoom: 14, + }); + + // 검색된 맛집 선택 상태로 설정 + if (onRestaurantSelect) { + onRestaurantSelect(searchedRestaurant); + } + } catch (error) { + console.error('MapView: Error moving to searched restaurant position:'); + } + }, [searchedRestaurant, onRestaurantSelect, isLoaded]); + + // [지도 이동] 선택된 맛집으로 이동 (한 번만 수행) + useEffect(() => { + if (!selectedRestaurant || !mapRef.current || !isLoaded) return; + + // 이미 이 맛집으로 이동했다면 건너뛰기 (사용자가 지도를 움직일 수 있게 함) + // 단, selectedRestaurant 객체가 아예 바뀌었더라도 ID가 같다면 이동하지 않음 + if (!shouldCenterSelectedRestaurant({ + lastCenteredRestaurantId: lastCenteredRestaurantId.current, + selectedRestaurantId: selectedRestaurant.id, + })) { + return; + } + + const position = getRestaurantLatLng(selectedRestaurant); + if (!position) { + return; + } + + try { + const map = googleMapRef.current; + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + // 지도의 실제 너비 계산 + const mapWidth = mapRef.current?.offsetWidth || 0; + const sidebarWidth = 0; // GlobalMapPage에는 사이드바 없음 + + const adjustedLng = resolveMapViewSelectedPanTarget({ + boundsNorthEastLng: bounds.getNorthEast().lng(), + boundsSouthWestLng: bounds.getSouthWest().lng(), + lng: position.lng, + mapWidth, + panelWidth: effectivePanelWidth, + sidebarWidth, + }); + + // 지도 이동 + map.setCenter({ lat: position.lat, lng: adjustedLng }); + + // 이동 완료 표시 + lastCenteredRestaurantId.current = selectedRestaurant.id; + } catch (error) { + console.error('MapView: Error moving to selected restaurant:'); + } + }, [selectedRestaurant, isLoaded, effectivePanelWidth]); + + // 선택 해제 시 ref 초기화 + useEffect(() => { + if (!selectedRestaurant) { + lastCenteredRestaurantId.current = null; + } + }, [selectedRestaurant]); + + useEffect(() => { + if (!mapBounds) { + setSafeBoundsQuery(undefined); + return; + } + try { + setSafeBoundsQuery(buildMapViewBoundsQuery(mapBounds)); + } catch (error) { + console.warn('MapView: keeping previous valid bounds after bounds query failure'); + } + }, [mapBounds]); + + // useRestaurants 옵션 메모이제이션 + const restaurantsOptions = useMemo(() => buildMapViewRestaurantsQueryOptions({ + bounds: safeBoundsQuery, + filters: memoizedFilters, + isLoaded, + selectedCountry, + }), [safeBoundsQuery, memoizedFilters, selectedCountry, isLoaded]); + + const { data: restaurants = [], isLoading: isLoadingRestaurants, refetch } = useRestaurants(restaurantsOptions); + const handleReviewSuccess = useMemo( + () => buildMapViewReviewSuccessHandler(refetch), + [refetch] + ); + + // 맛집 개수 표시 자동 숨김 처리 + useEffect(() => { + if (getMapViewRestaurantCountToastVisible(restaurants.length, isLoadingRestaurants)) { + setShowRestaurantCount(true); + const timer = setTimeout(() => { + setShowRestaurantCount(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [restaurants, isLoadingRestaurants]); + + // 마커를 표시할 맛집 목록 (기존 restaurants + 검색된 맛집) + const restaurantsToShow = useMemo(() => { + return mergeSearchedRestaurant(restaurants, searchedRestaurant ?? null); + }, [restaurants, searchedRestaurant]); + const restaurantsById = useMemo(() => { + return new Map(restaurantsToShow.map((restaurant) => [restaurant.id, restaurant])); + }, [restaurantsToShow]); + + + // [데이터 갱신] refreshTrigger 변경 시 재조회 (리뷰 작성 등) + useEffect(() => { + if (refreshTrigger) { + refetch(); + } + }, [refreshTrigger, refetch]); + + useEffect(() => { + if (!isLoaded || !mapRef.current) return; + + let attempts = 0; + const interval = window.setInterval(() => { + attempts += 1; + const mapText = mapRef.current?.innerText ?? ''; + if (mapText.includes("This page didn't load Google Maps correctly")) { + setHasGoogleRuntimeError(true); + window.clearInterval(interval); + } + if (attempts >= 6) { + window.clearInterval(interval); + } + }, 500); + + return () => window.clearInterval(interval); + }, [isLoaded]); + + + // refreshTrigger 변경 시 선택된 레스토랑 정보 업데이트 + useEffect(() => { + const updatedRestaurant = findUpdatedSelectedRestaurant(restaurants, selectedRestaurant); + if (updatedRestaurant) { + onRestaurantSelect?.(updatedRestaurant); + // [수정] 화면 밖으로 벗어나서 리스트에 없더라도 패널을 닫지 않음 + // else if (!updatedRestaurant) { + // onRestaurantSelect?.(null); + // } + } + }, [restaurants, refreshTrigger, selectedRestaurant, onRestaurantSelect]); + + + // [지도 초기화] Google Maps 인스턴스 생성 + useEffect(() => { + if (!isLoaded || !mapRef.current) { + return; + } + + // [유효성 검사] Google Maps API 로드 확인 + if (!window.google || !window.google.maps || !window.google.maps.Map) { + return; + } + + // 선택된 국가에 따라 중심점과 줌 설정 (기본값: 미국) + const initialSelectedCountry = selectedCountryRef.current; + const countryConfig = getMapViewCountryConfig(initialSelectedCountry); + const center = { lat: countryConfig.lat, lng: countryConfig.lng }; + const zoom = countryConfig.zoom; + + try { + const map = new google.maps.Map(mapRef.current, buildGoogleMapOptions({ center, zoom })); + + googleMapRef.current = map; + + // [이동 감지] 지도 이동 종료 시 경계값 업데이트 + map.addListener("idle", () => { + const bounds = map.getBounds(); + if (bounds) { + setMapBounds(bounds); + } + }); + } catch (error) { + console.error("Error creating Google Map:"); + } + }, [isLoaded]); + + // [국가 변경] 선택된 국가에 따라 지도 중심 및 줌 레벨 조정 + useEffect(() => { + if (!googleMapRef.current || !selectedCountry) return; + + // 검색된 맛집이 있으면 국가 중심으로 이동하지 않음 (검색 이동 로직이 우선) + // 단, 검색된 맛집이 현재 선택된 국가와 다를 수 있으므로 주의 필요하지만, + // handleRestaurantSearch에서 이미 국가를 맞춰주므로 여기서는 이동만 막으면 됨 + if (searchedRestaurant) return; + + const countryConfig = getMapViewCountryConfig(selectedCountry); + if (countryConfig) { + googleMapRef.current.setCenter({ lat: countryConfig.lat, lng: countryConfig.lng }); + googleMapRef.current.setZoom(countryConfig.zoom); + } + }, [selectedCountry, searchedRestaurant]); + + // [재시도 로직] 지도 로드 실패 시 재시도 (API 지연 로드 대응) + useEffect(() => { + if (!isLoaded && window.google && window.google.maps && window.google.maps.Map && mapRef.current && !googleMapRef.current) { + // 강제 재로드 실행 + setTimeout(() => { + window.location.reload(); + }, 500); + } + }, [isLoaded]); + + // [마커 관리] 맛집 데이터 변경 시 마커 업데이트 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + + markersRef.current = reconcileMapViewMarkers({ + previous: markersRef.current, + restaurants: restaurantsToShow, + selectedRestaurantId: selectedRestaurant?.id, + searchedRestaurantId: searchedRestaurant?.id, + createMarker: (options) => { + try { + return new markerConstructor({ ...options, map: googleMapRef.current }); + } catch { + console.warn('MapView: Advanced marker creation skipped'); + return null; + } + }, + onActivate: (restaurant) => { + onMarkerClick?.(restaurant); + onRestaurantSelect?.(restaurant); + moveToRestaurant(restaurant); + }, + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + + useEffect(() => () => { + for (const { marker } of markersRef.current) marker.map = null; + markersRef.current = []; + }, []); + + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) { + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + markerElement.classList.toggle('selected-marker', isSelected); + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, [selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById, isLoaded]); + + // 줌 이벤트 시 마커 스타일 유지 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const handleZoomChange = () => { + // 줌 변경 후 약간의 지연을 주어 마커 스타일 재적용 + setTimeout(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) return; + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, 100); + }; + + // 줌 변경 이벤트 리스너 추가 + const zoomListener = googleMapRef.current.addListener('zoom_changed', handleZoomChange); + + return () => { + if (zoomListener) { + google.maps.event.removeListener(zoomListener); + } + }; + }, [isLoaded, selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById]); + + if (!apiKey) { + return ; + } + + if (loadError || hasGoogleRuntimeError) { + return ; + } + + if (!isLoaded) { + return ; + } + + return ( + +
+ + + {shouldShowMapViewDetailPanel({ onMarkerClick, selectedRestaurant }) && ( + + )} +
+
+ ); +}); + +MapView.displayName = 'MapView'; + +export default MapView; diff --git a/apps/web/performance/google-marker-reconcile-20260925/candidate-helper.ts.txt b/apps/web/performance/google-marker-reconcile-20260925/candidate-helper.ts.txt new file mode 100644 index 0000000000..a23d5691ee --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/candidate-helper.ts.txt @@ -0,0 +1,105 @@ +import type { Restaurant } from '../types/restaurant'; +import { sanitizeMarkerImageUrl } from './html-escape'; +import { getRestaurantLatLng } from './map-view-google-helpers'; +import { getMapViewMarkerIcon } from './map-view-helpers'; +import { + buildMapViewMarkerHtml, + getMapViewMarkerSize, + isMapViewMarkerSelected, +} from './map-view-marker-helpers'; + +export interface ManagedMapViewMarker { + map: unknown; + content: Node | null; + position?: unknown; + title?: string; +} + +export interface MapViewMarkerEntry { + marker: M; + restaurantId: string; + restaurant: Restaurant; + position: { lat: number; lng: number }; + imagePath: string; + name: string; + onActivate: (restaurant: Restaurant) => void; +} + +// Reconcile in O(previous + next) time. Retained IDs keep their marker, image, +// and click listener; only added/removed IDs attach/detach provider objects. +export function reconcileMapViewMarkers({ + previous, + restaurants, + createMarker, + onActivate, + selectedRestaurantId, + searchedRestaurantId, +}: { + previous: readonly MapViewMarkerEntry[]; + restaurants: readonly Restaurant[]; + createMarker: (options: { + position: { lat: number; lng: number }; + content: HTMLElement; + title: string; + }) => M | null; + onActivate: (restaurant: Restaurant) => void; + selectedRestaurantId?: string | null; + searchedRestaurantId?: string | null; +}): MapViewMarkerEntry[] { + const remaining = new Map(previous.map((entry) => [entry.restaurantId, entry])); + const next: MapViewMarkerEntry[] = []; + const seen = new Set(); + + for (const restaurant of restaurants) { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng) + || seen.has(restaurant.id)) continue; + seen.add(restaurant.id); + const imagePath = sanitizeMarkerImageUrl(getMapViewMarkerIcon(restaurant.categories)); + const retained = remaining.get(restaurant.id); + if (retained) { + remaining.delete(restaurant.id); + if (retained.position.lat !== position.lat || retained.position.lng !== position.lng) { + retained.marker.position = position; + retained.position = position; + } + if (retained.imagePath !== imagePath || retained.name !== restaurant.name) { + const image = (retained.marker.content as HTMLElement | null)?.querySelector('img'); + if (retained.imagePath !== imagePath) image?.setAttribute('src', imagePath); + if (retained.name !== restaurant.name) { + image?.setAttribute('alt', restaurant.name); + retained.marker.title = restaurant.name; + } + retained.imagePath = imagePath; + retained.name = restaurant.name; + } + // The listener reads these fields at click time, including after a + // same-ID data refresh or a parent callback change. + retained.restaurant = restaurant; + retained.onActivate = onActivate; + next.push(retained); + continue; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, selectedRestaurantId, searchedRestaurantId, + }); + const content = document.createElement('div'); + content.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + content.innerHTML = buildMapViewMarkerHtml({ + imagePath, isSelected, markerSize: getMapViewMarkerSize(isSelected), name: restaurant.name, + }); + const marker = createMarker({ position, content, title: restaurant.name }); + if (!marker) continue; + const entry: MapViewMarkerEntry = { + marker, restaurantId: restaurant.id, restaurant, position, + imagePath, name: restaurant.name, onActivate, + }; + content.addEventListener('click', () => entry.onActivate(entry.restaurant)); + next.push(entry); + } + + // Keep the old visible markers until replacements have been attached. + for (const entry of remaining.values()) entry.marker.map = null; + return next; +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/checks/results.json b/apps/web/performance/google-marker-reconcile-20260925/checks/results.json new file mode 100644 index 0000000000..168f18c369 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/checks/results.json @@ -0,0 +1,14 @@ +[ + { + "check": "unit", + "exitCode": 0 + }, + { + "check": "lint", + "exitCode": 0 + }, + { + "check": "typecheck-parity", + "exitCode": 0 + } +] diff --git a/apps/web/performance/google-marker-reconcile-20260925/frozen-tree.patch b/apps/web/performance/google-marker-reconcile-20260925/frozen-tree.patch new file mode 100644 index 0000000000..a7a017b928 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/frozen-tree.patch @@ -0,0 +1,419 @@ +diff --git a/apps/web/components/map/MapView.tsx b/apps/web/components/map/MapView.tsx +index 262f4912..11f0804a 100644 +--- a/apps/web/components/map/MapView.tsx ++++ b/apps/web/components/map/MapView.tsx +@@ -14,7 +14,6 @@ import { + } from "@/components/map/map-view-status-panels"; + import { + getMapViewCountryConfig, +- getMapViewMarkerIcon, + mergeSearchedRestaurant, + } from "@/lib/map-view-helpers"; + import { +@@ -23,10 +22,9 @@ import { + } from "@/lib/map-view-movement-helpers"; + import { + applyMapViewMarkerSelectedState, +- buildMapViewMarkerHtml, +- getMapViewMarkerSize, + isMapViewMarkerSelected, + } from "@/lib/map-view-marker-helpers"; ++import { reconcileMapViewMarkers, type MapViewMarkerEntry } from "@/lib/map-view-marker-reconciliation"; + import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, +@@ -76,12 +74,9 @@ interface GoogleMapLike { + + interface GoogleMarkerLike { + map: GoogleMapLike | null; +- content: Element | null; +-} +- +-interface MapMarkerEntry { +- marker: GoogleMarkerLike; +- restaurantId: string; ++ content: Node | null; ++ position?: unknown; ++ title?: string; + } + function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { +@@ -137,7 +132,7 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); +- const markersRef = useRef([]); ++ const markersRef = useRef[]>([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + +@@ -492,12 +487,6 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + +- // 기존 마커 제거 (메모리 누수 방지) +- markersRef.current.forEach(({ marker }) => { +- marker.map = null; +- }); +- markersRef.current = []; +- + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); +@@ -505,66 +494,34 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + return; + } + +- // 새 마커 생성 +- restaurantsToShow.forEach((restaurant) => { +- const position = getRestaurantLatLng(restaurant); +- if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { +- console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); +- return; +- } +- +- const isSelected = isMapViewMarkerSelected({ +- restaurantId: restaurant.id, +- searchedRestaurantId: searchedRestaurant?.id, +- selectedRestaurantId: selectedRestaurant?.id, +- }); +- const imagePath = getMapViewMarkerIcon(restaurant.categories); +- const markerSize = getMapViewMarkerSize(isSelected); +- +- const markerElement = document.createElement("div"); +- markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; +- markerElement.innerHTML = buildMapViewMarkerHtml({ +- imagePath, +- isSelected, +- markerSize, +- name: restaurant.name, +- }); +- +- let marker: GoogleMarkerLike; +- try { +- marker = new markerConstructor({ +- map: googleMapRef.current, +- position, +- content: markerElement, +- title: restaurant.name, +- }); +- } catch { +- console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); +- return; +- } +- +- markerElement.addEventListener("click", () => { +- +- +- // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) ++ markersRef.current = reconcileMapViewMarkers({ ++ previous: markersRef.current, ++ restaurants: restaurantsToShow, ++ selectedRestaurantId: selectedRestaurant?.id, ++ searchedRestaurantId: searchedRestaurant?.id, ++ createMarker: (options) => { ++ try { ++ return new markerConstructor({ ...options, map: googleMapRef.current }); ++ } catch { ++ console.warn('MapView: Advanced marker creation skipped'); ++ return null; ++ } ++ }, ++ onActivate: (restaurant) => { + onMarkerClick?.(restaurant); +- +- +- // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); +- +- +- // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); +- +- }); +- +- markersRef.current.push({ marker, restaurantId: restaurant.id }); ++ }, + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + ++ useEffect(() => () => { ++ for (const { marker } of markersRef.current) marker.map = null; ++ markersRef.current = []; ++ }, []); ++ + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; +diff --git a/apps/web/lib/mobile-home-search-selection.ts b/apps/web/lib/mobile-home-search-selection.ts +index a3b5681a..436c89dc 100644 +--- a/apps/web/lib/mobile-home-search-selection.ts ++++ b/apps/web/lib/mobile-home-search-selection.ts +@@ -44,6 +44,59 @@ export const isSameRestaurantSelection = (left: RestaurantMatch, right: Restaura + return hasSameNameAndCoordinate(left, right); + }; + ++// A render pass compares many candidates with one selection. Build its merged-id ++// index at most once, instead of allocating a Set and arrays for every candidate. ++const createRestaurantSelectionMatcher = (selection: Restaurant) => { ++ let selectionIds: Set | undefined; ++ return (candidate: Restaurant): boolean => { ++ if (candidate.id === selection.id || hasSameNameAndCoordinate(candidate, selection)) return true; ++ const selectionMerged = selection.mergedRestaurants; ++ const candidateMerged = candidate.mergedRestaurants; ++ if (!selectionMerged?.length) { ++ if (candidateMerged) { ++ for (let index = 0; index < candidateMerged.length; index += 1) { ++ if (candidateMerged[index].id === selection.id) return true; ++ } ++ } ++ return false; ++ } ++ if (!selectionIds) { ++ selectionIds = new Set([selection.id]); ++ for (let index = 0; index < selectionMerged.length; index += 1) { ++ selectionIds.add(selectionMerged[index].id); ++ } ++ } ++ if (selectionIds.has(candidate.id)) return true; ++ if (candidateMerged) { ++ for (let index = 0; index < candidateMerged.length; index += 1) { ++ if (selectionIds.has(candidateMerged[index].id)) return true; ++ } ++ } ++ return false; ++ }; ++}; ++ ++type SelectionCacheKey = Pick & { mergedIds: string[] }; ++ ++const selectionCacheKey = (selection: Restaurant): SelectionCacheKey => ({ ++ id: selection.id, ++ name: selection.name, ++ lat: selection.lat, ++ lng: selection.lng, ++ mergedIds: selection.mergedRestaurants?.map((item) => item.id) ?? [], ++}); ++ ++const matchesSelectionCacheKey = (key: SelectionCacheKey, selection: Restaurant): boolean => { ++ if (key.id !== selection.id || key.name !== selection.name ++ || !Object.is(key.lat, selection.lat) || !Object.is(key.lng, selection.lng)) return false; ++ const merged = selection.mergedRestaurants; ++ if (key.mergedIds.length !== (merged?.length ?? 0)) return false; ++ for (let index = 0; index < key.mergedIds.length; index += 1) { ++ if (key.mergedIds[index] !== merged![index].id) return false; ++ } ++ return true; ++}; ++ + type SearchSelectionInput = { + searchedRestaurant: Restaurant | null; + selectedRestaurant: Restaurant | null; +@@ -141,7 +194,16 @@ export const resolveReleasedSearchSelectionResetPlan = ({ + return { nextReleasedSearchSelectionId: releasedSearchSelectionId } as const; + }; + ++const rememberedUniqueSwipeLists = new WeakSet(); ++let lastPostSearchDedupeExaminationCount = 0; ++ ++export function getLastPostSearchDedupeExaminationCount() { ++ return lastPostSearchDedupeExaminationCount; ++} ++ + const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { ++ if (rememberedUniqueSwipeLists.has(restaurants)) return restaurants; ++ lastPostSearchDedupeExaminationCount += 1; + const seenIds = new Set(); + const seenMergedIds = new Set(); + const restaurantsByName = new Map(); +@@ -166,6 +228,11 @@ const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { + else restaurantsByName.set(restaurant.name, [restaurant]); + }); + ++ if (uniqueRestaurants.length === restaurants.length) { ++ rememberedUniqueSwipeLists.add(restaurants); ++ return restaurants; ++ } ++ rememberedUniqueSwipeLists.add(uniqueRestaurants); + return uniqueRestaurants; + }; + +@@ -224,13 +291,39 @@ export const buildRestaurantsForSwipe = ({ + return [...displayRestaurants, ...restaurantsToAdd]; + }; + +-const nearestSwipeFallbackCache = new WeakMap>(); ++// List-reference caches require replacing catalog/visible arrays on edits. ++// Bound query history while retaining common back-and-forth searches. ++const MAX_SEARCH_CACHE_ENTRIES = 64; ++const rememberSelectionResult = (cache: Map, id: string, value: T) => { ++ cache.delete(id); ++ cache.set(id, value); ++ if (cache.size > MAX_SEARCH_CACHE_ENTRIES) { ++ const oldest = cache.keys().next().value; ++ if (oldest !== undefined) cache.delete(oldest); ++ } ++}; ++ ++const nearestSwipeFallbackCache = new WeakMap>(); + let nearestFallbackScanCount = 0; + + export function getNearestFallbackScanCount() { + return nearestFallbackScanCount; + } + ++const orderedVisibleCache = new WeakMap>(); ++let lastSwipeOrderBuildCount = 0; ++ ++export function getLastSwipeOrderBuildCount() { ++ return lastSwipeOrderBuildCount; ++} ++ + export const buildPostSearchSwipeCandidates = ({ + visibleRestaurants, + allRestaurants, +@@ -239,16 +332,37 @@ export const buildPostSearchSwipeCandidates = ({ + const dedupedVisibleRestaurants = dedupeRestaurants(visibleRestaurants); + let orderedVisibleRestaurants = dedupedVisibleRestaurants; + if (activeSearchedRestaurant) { +- const matchedRestaurants: Restaurant[] = []; +- const otherRestaurants: Restaurant[] = []; +- dedupedVisibleRestaurants.forEach((restaurant) => { +- if (isSameRestaurantSelection(restaurant, activeSearchedRestaurant)) { +- matchedRestaurants.push(restaurant); +- } else { +- otherRestaurants.push(restaurant); ++ let orders = orderedVisibleCache.get(dedupedVisibleRestaurants); ++ if (!orders) { ++ orders = new Map(); ++ orderedVisibleCache.set(dedupedVisibleRestaurants, orders); ++ } ++ const cachedOrder = orders.get(activeSearchedRestaurant.id); ++ if (cachedOrder && matchesSelectionCacheKey(cachedOrder.searched, activeSearchedRestaurant)) { ++ orderedVisibleRestaurants = cachedOrder.restaurants; ++ } else { ++ lastSwipeOrderBuildCount += 1; ++ const matches = createRestaurantSelectionMatcher(activeSearchedRestaurant); ++ orderedVisibleRestaurants = new Array(dedupedVisibleRestaurants.length); ++ let head = 0; ++ let tail = dedupedVisibleRestaurants.length - 1; ++ for (let index = 0; index < dedupedVisibleRestaurants.length; index += 1) { ++ const restaurant = dedupedVisibleRestaurants[index]; ++ if (matches(restaurant)) orderedVisibleRestaurants[head++] = restaurant; ++ else orderedVisibleRestaurants[tail--] = restaurant; + } +- }); +- orderedVisibleRestaurants = matchedRestaurants.concat(otherRestaurants); ++ // Non-matches were written backwards; restore their input order. ++ // This stable partition needs one result array, not two + concat. ++ for (let left = head, right = orderedVisibleRestaurants.length - 1; left < right; left += 1, right -= 1) { ++ const restaurant = orderedVisibleRestaurants[left]; ++ orderedVisibleRestaurants[left] = orderedVisibleRestaurants[right]; ++ orderedVisibleRestaurants[right] = restaurant; ++ } ++ rememberSelectionResult(orders, activeSearchedRestaurant.id, { ++ searched: selectionCacheKey(activeSearchedRestaurant), ++ restaurants: orderedVisibleRestaurants, ++ }); ++ } + } + + if (!activeSearchedRestaurant || orderedVisibleRestaurants.length !== 1) { +@@ -256,14 +370,15 @@ export const buildPostSearchSwipeCandidates = ({ + } + + const visibleRestaurant = orderedVisibleRestaurants[0]; +- const cacheKey = `${activeSearchedRestaurant.id}:${visibleRestaurant.id}`; +- let cachedFallbacks = nearestSwipeFallbackCache.get(allRestaurants); +- if (!cachedFallbacks) { +- cachedFallbacks = new Map(); +- nearestSwipeFallbackCache.set(allRestaurants, cachedFallbacks); ++ let fallbacks = nearestSwipeFallbackCache.get(allRestaurants); ++ if (!fallbacks) { ++ fallbacks = new Map(); ++ nearestSwipeFallbackCache.set(allRestaurants, fallbacks); + } +- if (cachedFallbacks.has(cacheKey)) { +- const cachedFallback = cachedFallbacks.get(cacheKey) ?? null; ++ const cached = fallbacks.get(activeSearchedRestaurant.id); ++ if (cached && matchesSelectionCacheKey(cached.searched, activeSearchedRestaurant) ++ && matchesSelectionCacheKey(cached.visible, visibleRestaurant)) { ++ const cachedFallback = cached.restaurant; + return cachedFallback + ? [...orderedVisibleRestaurants, cachedFallback] + : orderedVisibleRestaurants; +@@ -271,11 +386,12 @@ export const buildPostSearchSwipeCandidates = ({ + + nearestFallbackScanCount += 1; + const dedupedRestaurants = dedupeRestaurants(allRestaurants); ++ const matchesVisible = createRestaurantSelectionMatcher(visibleRestaurant); + let nearestFallbackRestaurant: Restaurant | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < dedupedRestaurants.length; index += 1) { + const candidateRestaurant = dedupedRestaurants[index]; +- if (isSameRestaurantSelection(candidateRestaurant, orderedVisibleRestaurants[0])) continue; ++ if (matchesVisible(candidateRestaurant)) continue; + + const candidateDistance = getApproximateRestaurantDistance( + activeSearchedRestaurant, +@@ -287,7 +403,11 @@ export const buildPostSearchSwipeCandidates = ({ + nearestDistance = candidateDistance; + } + +- cachedFallbacks.set(cacheKey, nearestFallbackRestaurant); ++ rememberSelectionResult(fallbacks, activeSearchedRestaurant.id, { ++ searched: selectionCacheKey(activeSearchedRestaurant), ++ visible: selectionCacheKey(visibleRestaurant), ++ restaurant: nearestFallbackRestaurant, ++ }); + + return nearestFallbackRestaurant + ? [...orderedVisibleRestaurants, nearestFallbackRestaurant] +diff --git a/apps/web/tests-unit/mobile-home-search-selection.test.ts b/apps/web/tests-unit/mobile-home-search-selection.test.ts +index 93e2c091..62ea969c 100644 +--- a/apps/web/tests-unit/mobile-home-search-selection.test.ts ++++ b/apps/web/tests-unit/mobile-home-search-selection.test.ts +@@ -3,6 +3,8 @@ import { describe, expect, test } from 'bun:test'; + import type { Restaurant } from '../types/restaurant'; + import { + buildPostSearchSwipeCandidates, ++ getLastPostSearchDedupeExaminationCount, ++ getLastSwipeOrderBuildCount, + getNearestFallbackScanCount, + buildRestaurantsForSwipe, + getActiveSearchedRestaurant, +@@ -254,4 +256,25 @@ describe('mobile home search selection helpers', () => { + + expect(getNearestFallbackScanCount()).toBe(scansBefore + 1); + }); ++ ++ test('reuses the searched swipe order for the same visible list', () => { ++ const searchedRestaurant = makeRestaurant({ id: 'search-1', lat: 37.2, lng: 127.2 }); ++ const otherRestaurant = makeRestaurant({ id: 'other-1', lat: 37.8, lng: 127.8 }); ++ const visibleRestaurants = [otherRestaurant, searchedRestaurant]; ++ buildPostSearchSwipeCandidates({ ++ visibleRestaurants, ++ allRestaurants: visibleRestaurants, ++ activeSearchedRestaurant: searchedRestaurant, ++ }); ++ const dedupes = getLastPostSearchDedupeExaminationCount(); ++ const orders = getLastSwipeOrderBuildCount(); ++ const again = buildPostSearchSwipeCandidates({ ++ visibleRestaurants, ++ allRestaurants: visibleRestaurants, ++ activeSearchedRestaurant: searchedRestaurant, ++ }); ++ expect(getLastPostSearchDedupeExaminationCount()).toBe(dedupes); ++ expect(getLastSwipeOrderBuildCount()).toBe(orders); ++ expect(again.map((restaurant) => restaurant.id)).toEqual(['search-1', 'other-1']); ++ }); + }); diff --git a/apps/web/performance/google-marker-reconcile-20260925/measure.mjs b/apps/web/performance/google-marker-reconcile-20260925/measure.mjs new file mode 100644 index 0000000000..3451bac9b1 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/measure.mjs @@ -0,0 +1,186 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpus } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import ts from 'typescript'; +import { chromium } from '@playwright/test'; + +assert.equal(process.versions.node.split('.')[0], '24', 'Use the project Node 24 runtime'); +const root = new URL('./', import.meta.url); +const app = fileURLToPath(new URL('../../', root)); +const hash = (value) => createHash('sha256').update(value).digest('hex'); +const baselineSource = await readFile(new URL('baseline-MapView.tsx.txt', root), 'utf8'); +const start = baselineSource.indexOf(' // [마커 관리]'); +const bodyStart = baselineSource.indexOf('useEffect(() => {', start) + 'useEffect(() => {'.length; +const bodyEnd = baselineSource.indexOf(' // Selection IDs are applied', bodyStart); +assert.ok(start >= 0 && bodyEnd > bodyStart); +const baselineModule = ` +import { getRestaurantLatLng } from '../../lib/map-view-google-helpers'; +import { getMapViewMarkerIcon } from '../../lib/map-view-helpers'; +import { buildMapViewMarkerHtml, getMapViewMarkerSize, isMapViewMarkerSelected } from '../../lib/map-view-marker-helpers'; +export function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = {current: previous}, restaurantsToShow = restaurants; + const googleMapRef = {current: map}, isLoaded = true; + const google = {maps: {marker: {AdvancedMarkerElement: Marker}}}; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => {}, setHasGoogleRuntimeError = () => {}; + ${baselineSource.slice(bodyStart, bodyEnd)} + return markersRef.current; +} +`; +await writeFile(new URL('baseline-effect.mjs', root), ts.transpileModule(baselineModule, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, +}).outputText); +await writeFile(new URL('browser-entry.mjs', root), ` +export { baselineUpdate } from './baseline-effect.mjs'; +export { reconcileMapViewMarkers } from '../../lib/map-view-marker-reconciliation'; +`); +execFileSync('bun', ['build', fileURLToPath(new URL('browser-entry.mjs', root)), '--target=browser', '--format=esm', + `--outfile=${fileURLToPath(new URL('browser-bundle.mjs', root))}`], { cwd: app, stdio: 'pipe' }); +const bundle = await readFile(new URL('browser-bundle.mjs', root), 'utf8'); +const browser = await chromium.launch({ headless: true }); +const samples = []; +const scenarios = ['same-list-new-array', 'ten-percent-turnover', 'coordinate-updates', 'full-turnover']; +const pairs = 31, iterations = 10, size = 200; +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.route('http://127.0.0.1/**', (route) => route.request().resourceType() === 'image' ? route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) : route.fulfill({ + headers: { 'Content-Type': 'text/html', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' }, + body: 'Google marker DOM reconciliation laboratory', + })); + await page.goto('http://127.0.0.1/'); + await page.evaluate(async (code) => { + const { baselineUpdate, reconcileMapViewMarkers } = await import('data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)))); + const makeRestaurant = (id, index) => ({ id, name: `Restaurant ${id}`, lat: 35 + index / 10000, lng: 126 + index / 10000, categories: ['한식'] }); + const fixture = (variant, count) => { + const container = document.createElement('div'); + document.body.append(container); + const stats = { created: 0, detached: 0, moved: 0 }; + const map = { container }; + class Marker { + constructor({ map, content, position, title }) { + this.content = content; this.title = title; this._map = map; + this.position = position; map.container.append(content); stats.created++; + } + set map(value) { this._map = value; if (!value) { this.content.remove(); stats.detached++; } } + get map() { return this._map; } + set position(value) { this._position = value; this.content.style.transform = `translate(${(value.lng - 126) * 10000 % 1300}px,${(value.lat - 35) * 10000 % 800}px)`; stats.moved++; } + get position() { return this._position; } + } + let entries = [], fail = false; + const update = (restaurants, onActivate = () => {}) => { + if (variant === 0) entries = baselineUpdate({ previous: entries, restaurants, Marker, map, onActivate }); + else entries = reconcileMapViewMarkers({ previous: entries, restaurants, onActivate, + createMarker: (options) => fail ? null : new Marker({ ...options, map }), + }); + return entries; + }; + const restaurants = Array.from({ length: count }, (_, index) => makeRestaurant(`r${index}`, index)); + update(restaurants); + const reset = () => { stats.created = 0; stats.detached = 0; stats.moved = 0; }; + reset(); + return { container, stats, update, restaurants, reset, setFail: (value) => { fail = value; }, dispose: () => container.remove() }; + }; + globalThis.preloadMarkerFixture = async () => { + const f = fixture(1, 1); + const image = f.container.querySelector('img'); + await image.decode(); + const imageDecoded = image.complete && image.naturalWidth === 32; + f.dispose(); + if (!imageDecoded) throw new Error('The marker fixture image did not decode'); + return imageDecoded; + }; + globalThis.checkMarkers = () => { + let checks = 0; + const check = (condition, label) => { if (!condition) throw new Error(label); checks++; }; + const f = fixture(1, 3); + let entries = f.update(f.restaurants); + const first = entries[0], image = first.marker.content.querySelector('img'); + entries = f.update(f.restaurants.map((r) => ({ ...r }))); + check(entries[0] === first, 'retained entry identity'); + check(entries[0].marker.content.querySelector('img') === image, 'retained image identity'); + check(f.stats.created === 0 && f.stats.detached === 0 && f.stats.moved === 0, 'no unchanged provider mutation'); + const replacement = { ...f.restaurants[0], name: 'Refreshed ', lat: 36 }; + let clicked; + f.update([replacement, ...f.restaurants.slice(1)], (r) => { clicked = r; }); + first.marker.content.click(); + check(clicked === replacement, 'click uses newest object and callback'); + check(first.marker.title === replacement.name && image.alt === replacement.name, 'safe in-place name update'); + check(first.marker.position.lat === 36 && f.stats.moved === 1, 'coordinate-only mutation'); + replacement.lat = 37; + f.update([replacement, ...f.restaurants.slice(1)]); + check(first.marker.position.lat === 37 && f.stats.moved === 2, 'in-place coordinate mutation'); + f.reset(); + entries = f.update([f.restaurants[1], makeRestaurant('new', 4)]); + check(entries.length === 2 && f.stats.created === 1 && f.stats.detached === 2, 'bounded membership reconciliation'); + f.reset(); + entries = f.update([{ ...f.restaurants[1], lat: NaN }, makeRestaurant('new', 4), makeRestaurant('new', 4)]); + check(entries.length === 1 && f.stats.detached === 1, 'invalid coordinates and duplicate IDs'); + entries = f.update([]); + check(entries.length === 0 && f.container.childElementCount === 0, 'empty list cleanup'); + f.setFail(true); entries = f.update(f.restaurants); + check(entries.length === 0, 'creation failure not cached'); + f.setFail(false); entries = f.update(f.restaurants); + check(entries.length === 3, 'creation retry recovery'); + f.dispose(); + const b = fixture(0, 3); b.update(b.restaurants.slice()); + check(b.stats.created === 3 && b.stats.detached === 3, 'baseline reproduces rebuild churn'); + b.dispose(); + return { checks, failures: 0 }; + }; + globalThis.measureMarkers = ({ scenario, variant, size, iterations }) => { + const f = fixture(variant, size); + const inputs = Array.from({ length: iterations }, (_, round) => Array.from({ length: size }, (_, index) => { + if (scenario === 'full-turnover' || (scenario === 'ten-percent-turnover' && index < size / 10)) return makeRestaurant(`new-${round}-${index}`, index); + const restaurant = { ...f.restaurants[index] }; + if (scenario === 'coordinate-updates') restaurant.lat += (round + 1) / 10000; + return restaurant; + })); + const start = performance.now(); + for (const input of inputs) f.update(input); + // Include the DOM update's synchronous layout, not only JS dispatch. + f.container.getBoundingClientRect(); + const totalMs = performance.now() - start; + const result = { totalMs, ...f.stats, markerCount: f.container.childElementCount }; + f.dispose(); + return result; + }; + }, bundle); + const imageDecoded = await page.evaluate(() => globalThis.preloadMarkerFixture()); + const verification = { ...await page.evaluate(() => globalThis.checkMarkers()), imageDecoded }; + console.log(JSON.stringify({ verification })); + for (const scenario of scenarios) { + for (let warmup = 0; warmup < 3; warmup++) for (const variant of [0, 1]) await page.evaluate((input) => globalThis.measureMarkers(input), { scenario, variant, size, iterations }); + for (let sample = 0; sample < pairs; sample++) { + const order = sample % 2 ? [1, 0] : [0, 1], results = {}; + for (const variant of order) results[variant ? 'candidate' : 'baseline'] = await page.evaluate((input) => globalThis.measureMarkers(input), { scenario, variant, size, iterations }); + assert.equal(results.baseline.markerCount, size); assert.equal(results.candidate.markerCount, size); + samples.push({ scenario, sample, order, ...results }); + } + console.log(JSON.stringify({ measured: scenario, pairedSamples: pairs })); + } + const raw = { + scope: 'Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.', + frozenAt: new Date().toISOString(), environment: { node: process.version, chromium: browser.version(), cpu: cpus()[0]?.model, crossOriginIsolated: await page.evaluate(() => crossOriginIsolated) }, + gitHead: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: app, encoding: 'utf8' }).trim(), + budgets: { absoluteDeltaMs: 0.05, relativeDeltaFraction: 0.05, noiseMadMultiplier: 2 }, + scenarios, size, iterations, pairs, verification, samples, + }; + await writeFile(new URL('raw.json', root), JSON.stringify(raw, null, 2) + '\n'); + for (const [name, path] of Object.entries({ 'candidate-MapView.tsx.txt': '../../components/map/MapView.tsx', 'candidate-helper.ts.txt': '../../lib/map-view-marker-reconciliation.ts' })) { + await writeFile(new URL(name, root), await readFile(new URL(path, root))); + } + await writeFile(new URL('frozen-tree.patch', root), execFileSync('git', ['diff', '--binary', 'HEAD'], { cwd: app })); + const files = ['baseline-MapView.tsx.txt', 'baseline-effect.mjs', 'browser-entry.mjs', 'browser-bundle.mjs', 'candidate-MapView.tsx.txt', 'candidate-helper.ts.txt', 'frozen-tree.patch', 'raw.json', 'measure.mjs', 'score-and-verify.mjs']; + const artifacts = {}; + for (const file of files) artifacts[file] = hash(await readFile(new URL(file, root))); + const map = JSON.stringify({ artifacts }, null, 2) + '\n'; + await writeFile(new URL('artifact-map.json', root), map); + console.log(JSON.stringify({ artifactMapSha256: hash(map) })); +} finally { await browser.close(); } diff --git a/apps/web/performance/google-marker-reconcile-20260925/raw.json b/apps/web/performance/google-marker-reconcile-20260925/raw.json new file mode 100644 index 0000000000..dfe15afba1 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/raw.json @@ -0,0 +1,2760 @@ +{ + "scope": "Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.", + "frozenAt": "2026-09-25T04:30:00.075Z", + "environment": { + "node": "v24.21.0", + "chromium": "151.0.7922.34", + "cpu": "Apple M5 Max", + "crossOriginIsolated": true + }, + "gitHead": "baa2415d011fcd18b7d0e038d4ce46c39c01bdec", + "budgets": { + "absoluteDeltaMs": 0.05, + "relativeDeltaFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "scenarios": [ + "same-list-new-array", + "ten-percent-turnover", + "coordinate-updates", + "full-turnover" + ], + "size": 200, + "iterations": 10, + "pairs": 31, + "verification": { + "checks": 13, + "failures": 0, + "imageDecoded": true + }, + "samples": [ + { + "scenario": "same-list-new-array", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 22.77500009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.190000057220459, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.174999952316284, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.65499997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.134999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.210000038146973, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.0049999952316284, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 18.294999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 19.944999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 6.1549999713897705, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.8350000381469727, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.055000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.31499993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.784999966621399, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.75, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.139999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.835000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.9700000286102295, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.740000009536743, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.855000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.644999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.715000033378601, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6850000619888306, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.389999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.475000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.995000004768372, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.7950000762939453, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.56000006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 19.875, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.4100000858306885, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.725000023841858, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.290000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.289999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.6050000190734863, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6100000143051147, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.634999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.440000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.584999918937683, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.559999942779541, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.614999890327454, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 20.399999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.28000009059906, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.7300000190734863, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.985000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.559999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.6200000047683716, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6549999713897705, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.320000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.879999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.565000057220459, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.524999976158142, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.78000009059906, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.934999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.4450000524520874, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.4399999380111694, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.269999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.480000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.5399999618530273, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6299999952316284, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.800000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.079999923706055, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.6050000190734863, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.709999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.629999995231628, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.739999890327454, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.889999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.805000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.564999938011169, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.139999985694885, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.324999928474426, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.925000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 5.169999957084656, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.450000047683716, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.355000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.264999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.399999976158142, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.159999966621399, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.860000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.929999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.1600000858306885, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.134999990463257, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.920000076293945, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 20.399999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 5.415000081062317, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.725000023841858, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.784999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.03000009059906, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.295000076293945, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.120000004768372, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.715000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.475000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.674999952316284, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.585000038146973, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.56000006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.34500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.475000023841858, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.064999938011169, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.440000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 18.304999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 5.179999947547913, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.189999938011169, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.534999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.879999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.065000057220459, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.9199999570846558, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.144999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.184999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.170000076293945, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.0899999141693115, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.045000076293945, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.725000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.924999952316284, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.149999976158142, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.665000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.629999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.245000004768372, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.09499990940094, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.504999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.485000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 5.024999976158142, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.4700000286102295, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.710000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.839999914169312, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.040000081062317, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.039999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.380000114440918, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.4049999713897705, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.014999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.674999952316284, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.110000014305115, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.4800000190734863, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.309999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.289999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.225000023841858, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.7050000429153442, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.120000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.134999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.705000042915344, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.4950000047683716, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.179999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.269999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.1950000524520874, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.2049999237060547, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.379999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.81000006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.810000061988831, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.5299999713897705, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.199999928474426, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.945000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.28000009059906, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.2049999237060547, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.25, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.894999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.1050000190734863, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.240000009536743, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.835000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 21.149999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.9450000524520874, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.4550000429153442, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.194999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.549999952316284, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.134999990463257, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.049999952316284, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.125, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.855000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.7100000381469727, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.2350000143051147, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.990000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.93500006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.1449999809265137, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.1399999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.604999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.679999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.0850000381469727, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.149999976158142, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.125, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.725000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.1549999713897705, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.1100000143051147, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.09500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.47000002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.3200000524520874, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.3100000619888306, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.489999890327454, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 21.59500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.135000109672546, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.875, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 16.835000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 21.335000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 18.665000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 22.419999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 19.9650000333786, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 16.99500000476837, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.839999914169312, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.84500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 16.68999993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 16.475000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.479999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.434999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 17.28500008583069, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 16.87999999523163, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.90500009059906, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.429999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 16.50499999523163, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 17.2849999666214, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 23.875, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.099999904632568, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 18.760000109672546, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 18.960000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.675000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 30.77500009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 15.745000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 17.805000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.399999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.089999914169312, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 25.110000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 18.37999999523163, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.794999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.269999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 18.44000005722046, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 17.384999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 16.5900000333786, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.759999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 18.18499994277954, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 18.174999952316284, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 20.12000000476837, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.665000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 17.584999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 17.115000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 16.97000002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.800000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 15.950000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 15.919999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.434999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 20.509999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 18.180000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 16.264999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 16.070000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.06000006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 19.164999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 18.274999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.330000042915344, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.504999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 17.480000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 17.93999993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 20.479999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.965000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 16.790000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + } + ] +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/artifact-map.json b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/artifact-map.json new file mode 100644 index 0000000000..cde3148cf0 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/artifact-map.json @@ -0,0 +1,14 @@ +{ + "artifacts": { + "baseline-MapView.tsx.txt": "0d13c1b5408c3b7118a76f64b0087293e87393e2ea8361ff621e533e90908da3", + "baseline-effect.mjs": "66c4222684af59f89b1e173602d0b01abaede1a117f78d2f0ea4133bcdbd270b", + "browser-entry.mjs": "caa4eca0b57706f933ca4b38fbdf978558e6d3200a67cdc050e13628a364b91f", + "browser-bundle.mjs": "77a31bb0249fb4f78596660c682e8c3a7d40a56e6900937d7cc3243178bb32ff", + "candidate-MapView.tsx.txt": "ddcb81ef3f5d71afaac15fe5f6752d56632dd70b2d2c54d8e6e52642cdd62c02", + "candidate-helper.ts.txt": "9e1f4b4d583f657f727c7a4eabb61ab67b99390373abf6dbb2fb23af58ba509a", + "frozen-tree.patch": "059bdc36a5618cb4b0f05d384a019017c1934d0406932981d2a640a33e4e061e", + "raw.json": "4d647486ec669461c15edeb0c111127cf97b9efe7ccf6a71b9597a9150d6eff1", + "measure.mjs": "37b93e1de5865d9c58c44793693bec0d0a1291b77ebe440e6ab79eeb0b80f191", + "score-and-verify.mjs": "90ee9018564ad4c7059015bccf1e3ff895ed33f31fab093403cb9b1dd028eee4" + } +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-MapView.tsx.txt b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-MapView.tsx.txt new file mode 100644 index 0000000000..262f491255 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-MapView.tsx.txt @@ -0,0 +1,675 @@ +import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { useGoogleMaps } from "@/hooks/use-google-maps"; +import { useRestaurants } from "@/hooks/use-restaurants"; +import type { Restaurant } from "@/types/restaurant"; +import type { FilterState } from "@/components/filters/filter-state"; +import { MapSkeleton } from "@/components/skeletons/MapSkeleton"; +import { MapViewSurface } from "@/components/map/map-view-surface"; +import { MapViewSidepanelStack } from "@/components/map/map-view-sidepanel-stack"; +import { + MapViewErrorState, + MapViewGoogleLoadErrorState, + MapViewMissingApiKeyState, +} from "@/components/map/map-view-status-panels"; +import { + getMapViewCountryConfig, + getMapViewMarkerIcon, + mergeSearchedRestaurant, +} from "@/lib/map-view-helpers"; +import { + resolveMapViewSelectedPanTarget, + shouldCenterSelectedRestaurant, +} from "@/lib/map-view-movement-helpers"; +import { + applyMapViewMarkerSelectedState, + buildMapViewMarkerHtml, + getMapViewMarkerSize, + isMapViewMarkerSelected, +} from "@/lib/map-view-marker-helpers"; +import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, + getMapViewRestaurantCountToastVisible, +} from "@/lib/map-view-state-helpers"; +import { buildMapViewRestaurantsQueryOptions } from "@/lib/map-query-helpers"; +import { + buildMapViewPanelStateSetter, + buildMapViewPanelCloseHandler, + buildMapViewRestaurantAction, + buildMapViewReviewOpenHandler, + buildMapViewTogglePanelHandler, + resolveMapViewPanelOpenState, + resolveMapViewPanelWidth, +} from "@/lib/map-view-panel-helpers"; +import { buildMapViewPanelWidthObserver } from "@/lib/map-view-panel-width-helpers"; +import { + buildMapViewDetailPanelFocusCaptureHandler, + buildMapViewDetailPanelMouseDownCaptureHandler, + buildMapViewReviewCloseHandler, + buildMapViewReviewSuccessHandler, + shouldShowMapViewDetailPanel, +} from "@/lib/map-view-sidepanel-helpers"; +import { + buildGoogleMapOptions, + getRestaurantLatLng, + panGoogleMapToPosition, +} from "@/lib/map-view-google-helpers"; + +interface MapPointLike { + lat: () => number; + lng: () => number; +} + +interface MapBoundsLike { + getNorthEast: () => MapPointLike; + getSouthWest: () => MapPointLike; +} + +interface GoogleMapLike { + panTo: (position: { lat: number; lng: number }) => void; + setZoom: (zoom: number) => void; + setCenter: (position: { lat: number; lng: number }) => void; + getBounds: () => MapBoundsLike | null; + addListener: (eventName: string, handler: (...args: unknown[]) => void) => unknown; +} + +interface GoogleMarkerLike { + map: GoogleMapLike | null; + content: Element | null; +} + +interface MapMarkerEntry { + marker: GoogleMarkerLike; + restaurantId: string; +} +function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === "string") { + return new Error(error); + } + + if (typeof error === "object" && error !== null) { + try { + const message = JSON.stringify(error); + if (message) { + return new Error(message); + } + } catch { + // Fall through to the safe generic message. + } + } + + return new Error("알 수 없는 오류가 발생했습니다."); +} + +function MapViewErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ; +} + +interface MapViewProps { + filters: FilterState; + selectedCountry?: string | null; + searchedRestaurant?: Restaurant | null; + selectedRestaurant?: Restaurant | null; + refreshTrigger?: number; + onAdminAddRestaurant?: () => void; + onAdminEditRestaurant?: (restaurant: Restaurant) => void; + onRestaurantSelect?: (restaurant: Restaurant | null) => void; + onMapReady?: (moveToRestaurant: (restaurant: Restaurant) => void) => void; + onRequestEditRestaurant?: (restaurant: Restaurant) => void; + // [패널 관리] 마커 클릭, 패널 닫기 등 콜백 + onMarkerClick?: (restaurant: Restaurant) => void; + // [동적 레이아웃] 패널 너비에 따른 지도 중심 오프셋 계산용 + panelWidth?: number; + activePanel?: 'map' | 'detail' | 'control'; + onPanelClick?: (panel: 'map' | 'detail' | 'control') => void; + isPanelOpen?: boolean; + onPanelClose?: () => void; + onTogglePanelCollapse?: () => void; +} + +const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRestaurant, refreshTrigger, onAdminAddRestaurant, onAdminEditRestaurant, onRestaurantSelect, onMapReady, onRequestEditRestaurant, onMarkerClick, panelWidth: propPanelWidth, activePanel, onPanelClick, isPanelOpen: propIsPanelOpen, onPanelClose, onTogglePanelCollapse }: MapViewProps) => { + // 필터 객체 메모이제이션 + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); + const markersRef = useRef([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + + // [상태] 지도 이동 - 선택된 맛집으로 이동 (한 번만 수행) + const lastCenteredRestaurantId = useRef(null); + + // 패널 상태 관리 (내부적으로 관리하거나 props로 제어) + const [mapBounds, setMapBounds] = useState(null); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [panelWidth, setPanelWidth] = useState(0); + const [showRestaurantCount, setShowRestaurantCount] = useState(false); + const [localIsPanelOpen, setLocalIsPanelOpen] = useState(false); + const [hasGoogleRuntimeError, setHasGoogleRuntimeError] = useState(false); + const [safeBoundsQuery, setSafeBoundsQuery] = useState>(undefined); + + useEffect(() => { + selectedCountryRef.current = selectedCountry; + }, [selectedCountry]); + + // props로 전달된 isPanelOpen이 있으면 우선 사용, 없으면 로컬 상태 사용 + const isPanelOpen = useMemo( + () => resolveMapViewPanelOpenState({ localIsPanelOpen, propIsPanelOpen }), + [localIsPanelOpen, propIsPanelOpen] + ); + const setIsPanelOpen = useMemo( + () => buildMapViewPanelStateSetter({ onTogglePanelCollapse, setLocalIsPanelOpen }), + [onTogglePanelCollapse] + ); + + const handleDetailPanelMouseDownCapture = useMemo( + () => buildMapViewDetailPanelMouseDownCaptureHandler(onPanelClick), + [onPanelClick] + ); + + const handleDetailPanelFocusCapture = useMemo( + () => buildMapViewDetailPanelFocusCaptureHandler(onPanelClick), + [onPanelClick] + ); + + // props로 전달된 panelWidth가 있으면 우선 사용 + const effectivePanelWidth = useMemo( + () => resolveMapViewPanelWidth({ panelWidth, propPanelWidth }), + [panelWidth, propPanelWidth] + ); + const handlePanelClose = useMemo( + () => buildMapViewPanelCloseHandler({ onPanelClose, setIsPanelOpen }), + [onPanelClose, setIsPanelOpen] + ); + const handleOpenReviewModal = useMemo( + () => buildMapViewReviewOpenHandler(setIsReviewModalOpen), + [] + ); + const handleCloseReviewModal = useMemo( + () => buildMapViewReviewCloseHandler(setIsReviewModalOpen), + [] + ); + const handleTogglePanel = useMemo( + () => buildMapViewTogglePanelHandler({ isPanelOpen, setIsPanelOpen }), + [isPanelOpen, setIsPanelOpen] + ); + const handleEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onAdminEditRestaurant, selectedRestaurant), + [onAdminEditRestaurant, selectedRestaurant] + ); + const handleRequestEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onRequestEditRestaurant, selectedRestaurant), + [onRequestEditRestaurant, selectedRestaurant] + ); + + + // 맛집으로 지도 이동하는 함수 (즉시 실행, 재시도 없음) + const moveToRestaurant = useCallback((restaurant: Restaurant) => { + if (!googleMapRef.current) { + + return; + } + + const position = { lat: Number(restaurant.lat), lng: Number(restaurant.lng) }; + + try { + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current, "resize"), + zoom: 14, + }); + } catch (error) { + console.error('MapView: Error moving to restaurant position:'); + } + }, []); + + // Google Maps API 로드 + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + const { isLoaded, loadError } = useGoogleMaps({ apiKey }); + + // ResizeObserver로 패널 너비 추적 (내부 패널이 있는 경우만) + useEffect(() => { + // props로 panelWidth가 전달되면 ResizeObserver 불필요 + if (propPanelWidth !== undefined || !detailPanelRef.current) return; + + const { observerCallback } = buildMapViewPanelWidthObserver({ + setPanelWidth, + }); + const resizeObserver = new ResizeObserver(observerCallback); + + resizeObserver.observe(detailPanelRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [propPanelWidth]); + + // 외부 콜백에 지도 이동 함수 전달 + useEffect(() => { + if (onMapReady) { + // 부모 컴포넌트에 지도 이동 함수를 전달 + onMapReady(moveToRestaurant); + } + }, [onMapReady, moveToRestaurant]); + + // 검색된 맛집으로 지도 이동 (정확히 중앙) + useEffect(() => { + if (!searchedRestaurant || !isLoaded || !googleMapRef.current) { + return; + } + + const position = getRestaurantLatLng(searchedRestaurant); + if (!position) { + return; + } + + try { + if (!googleMapRef.current) return; + + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current!, "resize"), + zoom: 14, + }); + + // 검색된 맛집 선택 상태로 설정 + if (onRestaurantSelect) { + onRestaurantSelect(searchedRestaurant); + } + } catch (error) { + console.error('MapView: Error moving to searched restaurant position:'); + } + }, [searchedRestaurant, onRestaurantSelect, isLoaded]); + + // [지도 이동] 선택된 맛집으로 이동 (한 번만 수행) + useEffect(() => { + if (!selectedRestaurant || !mapRef.current || !isLoaded) return; + + // 이미 이 맛집으로 이동했다면 건너뛰기 (사용자가 지도를 움직일 수 있게 함) + // 단, selectedRestaurant 객체가 아예 바뀌었더라도 ID가 같다면 이동하지 않음 + if (!shouldCenterSelectedRestaurant({ + lastCenteredRestaurantId: lastCenteredRestaurantId.current, + selectedRestaurantId: selectedRestaurant.id, + })) { + return; + } + + const position = getRestaurantLatLng(selectedRestaurant); + if (!position) { + return; + } + + try { + const map = googleMapRef.current; + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + // 지도의 실제 너비 계산 + const mapWidth = mapRef.current?.offsetWidth || 0; + const sidebarWidth = 0; // GlobalMapPage에는 사이드바 없음 + + const adjustedLng = resolveMapViewSelectedPanTarget({ + boundsNorthEastLng: bounds.getNorthEast().lng(), + boundsSouthWestLng: bounds.getSouthWest().lng(), + lng: position.lng, + mapWidth, + panelWidth: effectivePanelWidth, + sidebarWidth, + }); + + // 지도 이동 + map.setCenter({ lat: position.lat, lng: adjustedLng }); + + // 이동 완료 표시 + lastCenteredRestaurantId.current = selectedRestaurant.id; + } catch (error) { + console.error('MapView: Error moving to selected restaurant:'); + } + }, [selectedRestaurant, isLoaded, effectivePanelWidth]); + + // 선택 해제 시 ref 초기화 + useEffect(() => { + if (!selectedRestaurant) { + lastCenteredRestaurantId.current = null; + } + }, [selectedRestaurant]); + + useEffect(() => { + if (!mapBounds) { + setSafeBoundsQuery(undefined); + return; + } + try { + setSafeBoundsQuery(buildMapViewBoundsQuery(mapBounds)); + } catch (error) { + console.warn('MapView: keeping previous valid bounds after bounds query failure'); + } + }, [mapBounds]); + + // useRestaurants 옵션 메모이제이션 + const restaurantsOptions = useMemo(() => buildMapViewRestaurantsQueryOptions({ + bounds: safeBoundsQuery, + filters: memoizedFilters, + isLoaded, + selectedCountry, + }), [safeBoundsQuery, memoizedFilters, selectedCountry, isLoaded]); + + const { data: restaurants = [], isLoading: isLoadingRestaurants, refetch } = useRestaurants(restaurantsOptions); + const handleReviewSuccess = useMemo( + () => buildMapViewReviewSuccessHandler(refetch), + [refetch] + ); + + // 맛집 개수 표시 자동 숨김 처리 + useEffect(() => { + if (getMapViewRestaurantCountToastVisible(restaurants.length, isLoadingRestaurants)) { + setShowRestaurantCount(true); + const timer = setTimeout(() => { + setShowRestaurantCount(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [restaurants, isLoadingRestaurants]); + + // 마커를 표시할 맛집 목록 (기존 restaurants + 검색된 맛집) + const restaurantsToShow = useMemo(() => { + return mergeSearchedRestaurant(restaurants, searchedRestaurant ?? null); + }, [restaurants, searchedRestaurant]); + const restaurantsById = useMemo(() => { + return new Map(restaurantsToShow.map((restaurant) => [restaurant.id, restaurant])); + }, [restaurantsToShow]); + + + // [데이터 갱신] refreshTrigger 변경 시 재조회 (리뷰 작성 등) + useEffect(() => { + if (refreshTrigger) { + refetch(); + } + }, [refreshTrigger, refetch]); + + useEffect(() => { + if (!isLoaded || !mapRef.current) return; + + let attempts = 0; + const interval = window.setInterval(() => { + attempts += 1; + const mapText = mapRef.current?.innerText ?? ''; + if (mapText.includes("This page didn't load Google Maps correctly")) { + setHasGoogleRuntimeError(true); + window.clearInterval(interval); + } + if (attempts >= 6) { + window.clearInterval(interval); + } + }, 500); + + return () => window.clearInterval(interval); + }, [isLoaded]); + + + // refreshTrigger 변경 시 선택된 레스토랑 정보 업데이트 + useEffect(() => { + const updatedRestaurant = findUpdatedSelectedRestaurant(restaurants, selectedRestaurant); + if (updatedRestaurant) { + onRestaurantSelect?.(updatedRestaurant); + // [수정] 화면 밖으로 벗어나서 리스트에 없더라도 패널을 닫지 않음 + // else if (!updatedRestaurant) { + // onRestaurantSelect?.(null); + // } + } + }, [restaurants, refreshTrigger, selectedRestaurant, onRestaurantSelect]); + + + // [지도 초기화] Google Maps 인스턴스 생성 + useEffect(() => { + if (!isLoaded || !mapRef.current) { + return; + } + + // [유효성 검사] Google Maps API 로드 확인 + if (!window.google || !window.google.maps || !window.google.maps.Map) { + return; + } + + // 선택된 국가에 따라 중심점과 줌 설정 (기본값: 미국) + const initialSelectedCountry = selectedCountryRef.current; + const countryConfig = getMapViewCountryConfig(initialSelectedCountry); + const center = { lat: countryConfig.lat, lng: countryConfig.lng }; + const zoom = countryConfig.zoom; + + try { + const map = new google.maps.Map(mapRef.current, buildGoogleMapOptions({ center, zoom })); + + googleMapRef.current = map; + + // [이동 감지] 지도 이동 종료 시 경계값 업데이트 + map.addListener("idle", () => { + const bounds = map.getBounds(); + if (bounds) { + setMapBounds(bounds); + } + }); + } catch (error) { + console.error("Error creating Google Map:"); + } + }, [isLoaded]); + + // [국가 변경] 선택된 국가에 따라 지도 중심 및 줌 레벨 조정 + useEffect(() => { + if (!googleMapRef.current || !selectedCountry) return; + + // 검색된 맛집이 있으면 국가 중심으로 이동하지 않음 (검색 이동 로직이 우선) + // 단, 검색된 맛집이 현재 선택된 국가와 다를 수 있으므로 주의 필요하지만, + // handleRestaurantSearch에서 이미 국가를 맞춰주므로 여기서는 이동만 막으면 됨 + if (searchedRestaurant) return; + + const countryConfig = getMapViewCountryConfig(selectedCountry); + if (countryConfig) { + googleMapRef.current.setCenter({ lat: countryConfig.lat, lng: countryConfig.lng }); + googleMapRef.current.setZoom(countryConfig.zoom); + } + }, [selectedCountry, searchedRestaurant]); + + // [재시도 로직] 지도 로드 실패 시 재시도 (API 지연 로드 대응) + useEffect(() => { + if (!isLoaded && window.google && window.google.maps && window.google.maps.Map && mapRef.current && !googleMapRef.current) { + // 강제 재로드 실행 + setTimeout(() => { + window.location.reload(); + }, 500); + } + }, [isLoaded]); + + // [마커 관리] 맛집 데이터 변경 시 마커 업데이트 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + // 기존 마커 제거 (메모리 누수 방지) + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + + // 새 마커 생성 + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name, + }); + + let marker: GoogleMarkerLike; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name, + }); + } catch { + console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); + return; + } + + markerElement.addEventListener("click", () => { + + + // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) + onMarkerClick?.(restaurant); + + + // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); + + + // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); + + }); + + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) { + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + markerElement.classList.toggle('selected-marker', isSelected); + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, [selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById, isLoaded]); + + // 줌 이벤트 시 마커 스타일 유지 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const handleZoomChange = () => { + // 줌 변경 후 약간의 지연을 주어 마커 스타일 재적용 + setTimeout(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) return; + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, 100); + }; + + // 줌 변경 이벤트 리스너 추가 + const zoomListener = googleMapRef.current.addListener('zoom_changed', handleZoomChange); + + return () => { + if (zoomListener) { + google.maps.event.removeListener(zoomListener); + } + }; + }, [isLoaded, selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById]); + + if (!apiKey) { + return ; + } + + if (loadError || hasGoogleRuntimeError) { + return ; + } + + if (!isLoaded) { + return ; + } + + return ( + +
+ + + {shouldShowMapViewDetailPanel({ onMarkerClick, selectedRestaurant }) && ( + + )} +
+
+ ); +}); + +MapView.displayName = 'MapView'; + +export default MapView; diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-effect.mjs b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-effect.mjs new file mode 100644 index 0000000000..3889bc2cf4 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/baseline-effect.mjs @@ -0,0 +1,70 @@ +import { getRestaurantLatLng } from '../../lib/map-view-google-helpers'; +import { getMapViewMarkerIcon } from '../../lib/map-view-helpers'; +import { buildMapViewMarkerHtml, getMapViewMarkerSize, isMapViewMarkerSelected } from '../../lib/map-view-marker-helpers'; +export function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = { current: previous }, restaurantsToShow = restaurants; + const googleMapRef = { current: map }, isLoaded = true; + const google = { maps: { marker: { AdvancedMarkerElement: Marker } } }; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => { }, setHasGoogleRuntimeError = () => { }; + if (!googleMapRef.current || !isLoaded) + return; + // 기존 마커 제거 (메모리 누수 방지) + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + // 새 마커 생성 + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); + return; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name, + }); + let marker; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name, + }); + } + catch { + console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); + return; + } + markerElement.addEventListener("click", () => { + // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) + onMarkerClick?.(restaurant); + // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); + // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); + }); + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + return markersRef.current; +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-bundle.mjs b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-bundle.mjs new file mode 100644 index 0000000000..3771e6a1fc --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-bundle.mjs @@ -0,0 +1,292 @@ +// lib/map-view-google-helpers.ts +function getRestaurantLatLng(restaurant) { + if (!restaurant) + return null; + const lat = Number(restaurant.lat); + const lng = Number(restaurant.lng); + if (Number.isNaN(lat) || Number.isNaN(lng)) { + return null; + } + return { lat, lng }; +} + +// lib/map-view-helpers.ts +function getMapViewMarkerIcon(categories) { + if (!categories) + return "/images/maker-images/webp/korean.webp"; + const category = Array.isArray(categories) ? categories[0] : categories; + const imageMap = { + 고기: "/images/maker-images/webp/meat_bbq.webp", + 치킨: "/images/maker-images/webp/chicken.webp", + 한식: "/images/maker-images/webp/korean.webp", + 중식: "/images/maker-images/webp/chinese.webp", + 일식: "/images/maker-images/webp/cutlet_sashimi.webp", + 양식: "/images/maker-images/webp/western.webp", + 분식: "/images/maker-images/webp/snack_bar.webp", + "카페·디저트": "/images/maker-images/webp/cafe_dessert.webp", + 아시안: "/images/maker-images/webp/asian.webp", + 패스트푸드: "/images/maker-images/webp/fastfood.webp", + "족발·보쌈": "/images/maker-images/webp/pork_feet.webp", + "돈까스·회": "/images/maker-images/webp/cutlet_sashimi.webp", + 피자: "/images/maker-images/webp/pizza.webp", + "찜·탕": "/images/maker-images/webp/stew.webp", + 야식: "/images/maker-images/webp/late_night.webp", + 도시락: "/images/maker-images/webp/lunch_box.webp" + }; + return imageMap[category] || "/images/maker-images/webp/korean.webp"; +} + +// lib/html-escape.ts +var MARKER_IMAGE_FALLBACK = "/images/maker-images/webp/chicken.webp"; +var MAX_MARKER_IMAGE_URL_LENGTH = 2048; +var MAX_PERCENT_DECODE_PASSES = 4; +var CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F-\u009F]/; +var ROOT_URL = "https://marker.invalid"; +function decodeForMarkerImageSafety(value) { + let decodedValue = value; + for (let pass = 0;pass < MAX_PERCENT_DECODE_PASSES; pass += 1) { + if (CONTROL_CHARACTER_PATTERN.test(decodedValue) || decodedValue.includes("\\")) { + return null; + } + if (!decodedValue.includes("%")) { + return decodedValue; + } + try { + const nextValue = decodeURIComponent(decodedValue); + if (nextValue === decodedValue) { + return decodedValue; + } + decodedValue = nextValue; + } catch { + return null; + } + } + return null; +} +function hasUnsafeDecodedMarkerPath(value) { + const decodedPath = decodeForMarkerImageSafety(value); + return decodedPath === null || decodedPath.includes("//") || decodedPath.split("/").some((segment) => segment === "." || segment === ".."); +} +function isCanonicalRootRelativeMarkerPath(value) { + if (!value.startsWith("/") || value.startsWith("//") || value.includes("?") || value.includes("#")) { + return false; + } + if (hasUnsafeDecodedMarkerPath(value)) { + return false; + } + try { + const url = new URL(value, ROOT_URL); + return url.origin === ROOT_URL && url.pathname === value && url.search === "" && url.hash === ""; + } catch { + return false; + } +} +function isCanonicalHttpsMarkerUrl(value) { + if (decodeForMarkerImageSafety(value) === null) { + return false; + } + try { + const url = new URL(value); + return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname !== "" && url.href === value && !hasUnsafeDecodedMarkerPath(url.pathname); + } catch { + return false; + } +} +function sanitizeMarkerImageUrl(value) { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_MARKER_IMAGE_URL_LENGTH || CONTROL_CHARACTER_PATTERN.test(value) || value.includes("\\")) { + return MARKER_IMAGE_FALLBACK; + } + if (isCanonicalRootRelativeMarkerPath(value) || isCanonicalHttpsMarkerUrl(value)) { + return value; + } + return MARKER_IMAGE_FALLBACK; +} +function escapeHtmlAttribute(value) { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + default: + return char; + } + }); +} + +// lib/map-view-marker-helpers.ts +var MAP_VIEW_MARKER_BASE_SIZE_CLASSES = ["h-8", "w-8"]; +var MAP_VIEW_MARKER_SELECTED_SIZE_CLASSES = ["h-[42px]", "w-[42px]"]; +function isMapViewMarkerSelected({ + restaurantId, + searchedRestaurantId, + selectedRestaurantId +}) { + return selectedRestaurantId === restaurantId || searchedRestaurantId === restaurantId; +} +function getMapViewMarkerSize(isSelected) { + return isSelected ? 42 : 32; +} +function buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name +}) { + const expectedMarkerSize = getMapViewMarkerSize(isSelected); + const normalizedMarkerSize = Number.isFinite(markerSize) && markerSize === expectedMarkerSize ? markerSize : expectedMarkerSize; + const markerSizeClasses = normalizedMarkerSize === 42 ? MAP_VIEW_MARKER_SELECTED_SIZE_CLASSES : MAP_VIEW_MARKER_BASE_SIZE_CLASSES; + const safeImagePath = escapeHtmlAttribute(sanitizeMarkerImageUrl(imagePath)); + const safeName = escapeHtmlAttribute(name); + return ` +
+ ${safeName} +
+ `; +} + +// performance/google-marker-reconcile-20260925/baseline-effect.mjs +function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = { current: previous }, restaurantsToShow = restaurants; + const googleMapRef = { current: map }, isLoaded = true; + const google = { maps: { marker: { AdvancedMarkerElement: Marker } } }; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => {}, setHasGoogleRuntimeError = () => {}; + if (!googleMapRef.current || !isLoaded) + return; + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn("MapView: Advanced marker support unavailable"); + setHasGoogleRuntimeError(true); + return; + } + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn("MapView: marker skipped because restaurant coordinates are invalid", { restaurantId: restaurant.id }); + return; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? "selected-marker" : ""}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name + }); + let marker; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name + }); + } catch { + console.warn("MapView: Advanced marker creation skipped", { restaurantId: restaurant.id }); + return; + } + markerElement.addEventListener("click", () => { + onMarkerClick?.(restaurant); + onRestaurantSelect?.(restaurant); + moveToRestaurant(restaurant); + }); + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + return markersRef.current; +} +// lib/map-view-marker-reconciliation.ts +function reconcileMapViewMarkers({ + previous, + restaurants, + createMarker, + onActivate, + selectedRestaurantId, + searchedRestaurantId +}) { + const remaining = new Map(previous.map((entry) => [entry.restaurantId, entry])); + const next = []; + const seen = new Set; + for (const restaurant of restaurants) { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng) || seen.has(restaurant.id)) + continue; + seen.add(restaurant.id); + const imagePath = sanitizeMarkerImageUrl(getMapViewMarkerIcon(restaurant.categories)); + const retained = remaining.get(restaurant.id); + if (retained) { + remaining.delete(restaurant.id); + if (retained.position.lat !== position.lat || retained.position.lng !== position.lng) { + retained.marker.position = position; + retained.position = position; + } + if (retained.imagePath !== imagePath || retained.name !== restaurant.name) { + const image = retained.marker.content?.querySelector("img"); + if (retained.imagePath !== imagePath) + image?.setAttribute("src", imagePath); + if (retained.name !== restaurant.name) { + image?.setAttribute("alt", restaurant.name); + retained.marker.title = restaurant.name; + } + retained.imagePath = imagePath; + retained.name = restaurant.name; + } + retained.restaurant = restaurant; + retained.onActivate = onActivate; + next.push(retained); + continue; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + selectedRestaurantId, + searchedRestaurantId + }); + const content = document.createElement("div"); + content.className = `custom-marker ${isSelected ? "selected-marker" : ""}`; + content.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize: getMapViewMarkerSize(isSelected), + name: restaurant.name + }); + const marker = createMarker({ position, content, title: restaurant.name }); + if (!marker) + continue; + const entry = { + marker, + restaurantId: restaurant.id, + restaurant, + position, + imagePath, + name: restaurant.name, + onActivate + }; + content.addEventListener("click", () => entry.onActivate(entry.restaurant)); + next.push(entry); + } + for (const entry of remaining.values()) + entry.marker.map = null; + return next; +} +export { + baselineUpdate, + reconcileMapViewMarkers +}; diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-entry.mjs b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-entry.mjs new file mode 100644 index 0000000000..d7af44a62d --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/browser-entry.mjs @@ -0,0 +1,3 @@ + +export { baselineUpdate } from './baseline-effect.mjs'; +export { reconcileMapViewMarkers } from '../../lib/map-view-marker-reconciliation'; diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-MapView.tsx.txt b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-MapView.tsx.txt new file mode 100644 index 0000000000..11f0804a8e --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-MapView.tsx.txt @@ -0,0 +1,632 @@ +import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { useGoogleMaps } from "@/hooks/use-google-maps"; +import { useRestaurants } from "@/hooks/use-restaurants"; +import type { Restaurant } from "@/types/restaurant"; +import type { FilterState } from "@/components/filters/filter-state"; +import { MapSkeleton } from "@/components/skeletons/MapSkeleton"; +import { MapViewSurface } from "@/components/map/map-view-surface"; +import { MapViewSidepanelStack } from "@/components/map/map-view-sidepanel-stack"; +import { + MapViewErrorState, + MapViewGoogleLoadErrorState, + MapViewMissingApiKeyState, +} from "@/components/map/map-view-status-panels"; +import { + getMapViewCountryConfig, + mergeSearchedRestaurant, +} from "@/lib/map-view-helpers"; +import { + resolveMapViewSelectedPanTarget, + shouldCenterSelectedRestaurant, +} from "@/lib/map-view-movement-helpers"; +import { + applyMapViewMarkerSelectedState, + isMapViewMarkerSelected, +} from "@/lib/map-view-marker-helpers"; +import { reconcileMapViewMarkers, type MapViewMarkerEntry } from "@/lib/map-view-marker-reconciliation"; +import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, + getMapViewRestaurantCountToastVisible, +} from "@/lib/map-view-state-helpers"; +import { buildMapViewRestaurantsQueryOptions } from "@/lib/map-query-helpers"; +import { + buildMapViewPanelStateSetter, + buildMapViewPanelCloseHandler, + buildMapViewRestaurantAction, + buildMapViewReviewOpenHandler, + buildMapViewTogglePanelHandler, + resolveMapViewPanelOpenState, + resolveMapViewPanelWidth, +} from "@/lib/map-view-panel-helpers"; +import { buildMapViewPanelWidthObserver } from "@/lib/map-view-panel-width-helpers"; +import { + buildMapViewDetailPanelFocusCaptureHandler, + buildMapViewDetailPanelMouseDownCaptureHandler, + buildMapViewReviewCloseHandler, + buildMapViewReviewSuccessHandler, + shouldShowMapViewDetailPanel, +} from "@/lib/map-view-sidepanel-helpers"; +import { + buildGoogleMapOptions, + getRestaurantLatLng, + panGoogleMapToPosition, +} from "@/lib/map-view-google-helpers"; + +interface MapPointLike { + lat: () => number; + lng: () => number; +} + +interface MapBoundsLike { + getNorthEast: () => MapPointLike; + getSouthWest: () => MapPointLike; +} + +interface GoogleMapLike { + panTo: (position: { lat: number; lng: number }) => void; + setZoom: (zoom: number) => void; + setCenter: (position: { lat: number; lng: number }) => void; + getBounds: () => MapBoundsLike | null; + addListener: (eventName: string, handler: (...args: unknown[]) => void) => unknown; +} + +interface GoogleMarkerLike { + map: GoogleMapLike | null; + content: Node | null; + position?: unknown; + title?: string; +} +function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === "string") { + return new Error(error); + } + + if (typeof error === "object" && error !== null) { + try { + const message = JSON.stringify(error); + if (message) { + return new Error(message); + } + } catch { + // Fall through to the safe generic message. + } + } + + return new Error("알 수 없는 오류가 발생했습니다."); +} + +function MapViewErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ; +} + +interface MapViewProps { + filters: FilterState; + selectedCountry?: string | null; + searchedRestaurant?: Restaurant | null; + selectedRestaurant?: Restaurant | null; + refreshTrigger?: number; + onAdminAddRestaurant?: () => void; + onAdminEditRestaurant?: (restaurant: Restaurant) => void; + onRestaurantSelect?: (restaurant: Restaurant | null) => void; + onMapReady?: (moveToRestaurant: (restaurant: Restaurant) => void) => void; + onRequestEditRestaurant?: (restaurant: Restaurant) => void; + // [패널 관리] 마커 클릭, 패널 닫기 등 콜백 + onMarkerClick?: (restaurant: Restaurant) => void; + // [동적 레이아웃] 패널 너비에 따른 지도 중심 오프셋 계산용 + panelWidth?: number; + activePanel?: 'map' | 'detail' | 'control'; + onPanelClick?: (panel: 'map' | 'detail' | 'control') => void; + isPanelOpen?: boolean; + onPanelClose?: () => void; + onTogglePanelCollapse?: () => void; +} + +const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRestaurant, refreshTrigger, onAdminAddRestaurant, onAdminEditRestaurant, onRestaurantSelect, onMapReady, onRequestEditRestaurant, onMarkerClick, panelWidth: propPanelWidth, activePanel, onPanelClick, isPanelOpen: propIsPanelOpen, onPanelClose, onTogglePanelCollapse }: MapViewProps) => { + // 필터 객체 메모이제이션 + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); + const markersRef = useRef[]>([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + + // [상태] 지도 이동 - 선택된 맛집으로 이동 (한 번만 수행) + const lastCenteredRestaurantId = useRef(null); + + // 패널 상태 관리 (내부적으로 관리하거나 props로 제어) + const [mapBounds, setMapBounds] = useState(null); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [panelWidth, setPanelWidth] = useState(0); + const [showRestaurantCount, setShowRestaurantCount] = useState(false); + const [localIsPanelOpen, setLocalIsPanelOpen] = useState(false); + const [hasGoogleRuntimeError, setHasGoogleRuntimeError] = useState(false); + const [safeBoundsQuery, setSafeBoundsQuery] = useState>(undefined); + + useEffect(() => { + selectedCountryRef.current = selectedCountry; + }, [selectedCountry]); + + // props로 전달된 isPanelOpen이 있으면 우선 사용, 없으면 로컬 상태 사용 + const isPanelOpen = useMemo( + () => resolveMapViewPanelOpenState({ localIsPanelOpen, propIsPanelOpen }), + [localIsPanelOpen, propIsPanelOpen] + ); + const setIsPanelOpen = useMemo( + () => buildMapViewPanelStateSetter({ onTogglePanelCollapse, setLocalIsPanelOpen }), + [onTogglePanelCollapse] + ); + + const handleDetailPanelMouseDownCapture = useMemo( + () => buildMapViewDetailPanelMouseDownCaptureHandler(onPanelClick), + [onPanelClick] + ); + + const handleDetailPanelFocusCapture = useMemo( + () => buildMapViewDetailPanelFocusCaptureHandler(onPanelClick), + [onPanelClick] + ); + + // props로 전달된 panelWidth가 있으면 우선 사용 + const effectivePanelWidth = useMemo( + () => resolveMapViewPanelWidth({ panelWidth, propPanelWidth }), + [panelWidth, propPanelWidth] + ); + const handlePanelClose = useMemo( + () => buildMapViewPanelCloseHandler({ onPanelClose, setIsPanelOpen }), + [onPanelClose, setIsPanelOpen] + ); + const handleOpenReviewModal = useMemo( + () => buildMapViewReviewOpenHandler(setIsReviewModalOpen), + [] + ); + const handleCloseReviewModal = useMemo( + () => buildMapViewReviewCloseHandler(setIsReviewModalOpen), + [] + ); + const handleTogglePanel = useMemo( + () => buildMapViewTogglePanelHandler({ isPanelOpen, setIsPanelOpen }), + [isPanelOpen, setIsPanelOpen] + ); + const handleEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onAdminEditRestaurant, selectedRestaurant), + [onAdminEditRestaurant, selectedRestaurant] + ); + const handleRequestEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onRequestEditRestaurant, selectedRestaurant), + [onRequestEditRestaurant, selectedRestaurant] + ); + + + // 맛집으로 지도 이동하는 함수 (즉시 실행, 재시도 없음) + const moveToRestaurant = useCallback((restaurant: Restaurant) => { + if (!googleMapRef.current) { + + return; + } + + const position = { lat: Number(restaurant.lat), lng: Number(restaurant.lng) }; + + try { + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current, "resize"), + zoom: 14, + }); + } catch (error) { + console.error('MapView: Error moving to restaurant position:'); + } + }, []); + + // Google Maps API 로드 + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + const { isLoaded, loadError } = useGoogleMaps({ apiKey }); + + // ResizeObserver로 패널 너비 추적 (내부 패널이 있는 경우만) + useEffect(() => { + // props로 panelWidth가 전달되면 ResizeObserver 불필요 + if (propPanelWidth !== undefined || !detailPanelRef.current) return; + + const { observerCallback } = buildMapViewPanelWidthObserver({ + setPanelWidth, + }); + const resizeObserver = new ResizeObserver(observerCallback); + + resizeObserver.observe(detailPanelRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [propPanelWidth]); + + // 외부 콜백에 지도 이동 함수 전달 + useEffect(() => { + if (onMapReady) { + // 부모 컴포넌트에 지도 이동 함수를 전달 + onMapReady(moveToRestaurant); + } + }, [onMapReady, moveToRestaurant]); + + // 검색된 맛집으로 지도 이동 (정확히 중앙) + useEffect(() => { + if (!searchedRestaurant || !isLoaded || !googleMapRef.current) { + return; + } + + const position = getRestaurantLatLng(searchedRestaurant); + if (!position) { + return; + } + + try { + if (!googleMapRef.current) return; + + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current!, "resize"), + zoom: 14, + }); + + // 검색된 맛집 선택 상태로 설정 + if (onRestaurantSelect) { + onRestaurantSelect(searchedRestaurant); + } + } catch (error) { + console.error('MapView: Error moving to searched restaurant position:'); + } + }, [searchedRestaurant, onRestaurantSelect, isLoaded]); + + // [지도 이동] 선택된 맛집으로 이동 (한 번만 수행) + useEffect(() => { + if (!selectedRestaurant || !mapRef.current || !isLoaded) return; + + // 이미 이 맛집으로 이동했다면 건너뛰기 (사용자가 지도를 움직일 수 있게 함) + // 단, selectedRestaurant 객체가 아예 바뀌었더라도 ID가 같다면 이동하지 않음 + if (!shouldCenterSelectedRestaurant({ + lastCenteredRestaurantId: lastCenteredRestaurantId.current, + selectedRestaurantId: selectedRestaurant.id, + })) { + return; + } + + const position = getRestaurantLatLng(selectedRestaurant); + if (!position) { + return; + } + + try { + const map = googleMapRef.current; + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + // 지도의 실제 너비 계산 + const mapWidth = mapRef.current?.offsetWidth || 0; + const sidebarWidth = 0; // GlobalMapPage에는 사이드바 없음 + + const adjustedLng = resolveMapViewSelectedPanTarget({ + boundsNorthEastLng: bounds.getNorthEast().lng(), + boundsSouthWestLng: bounds.getSouthWest().lng(), + lng: position.lng, + mapWidth, + panelWidth: effectivePanelWidth, + sidebarWidth, + }); + + // 지도 이동 + map.setCenter({ lat: position.lat, lng: adjustedLng }); + + // 이동 완료 표시 + lastCenteredRestaurantId.current = selectedRestaurant.id; + } catch (error) { + console.error('MapView: Error moving to selected restaurant:'); + } + }, [selectedRestaurant, isLoaded, effectivePanelWidth]); + + // 선택 해제 시 ref 초기화 + useEffect(() => { + if (!selectedRestaurant) { + lastCenteredRestaurantId.current = null; + } + }, [selectedRestaurant]); + + useEffect(() => { + if (!mapBounds) { + setSafeBoundsQuery(undefined); + return; + } + try { + setSafeBoundsQuery(buildMapViewBoundsQuery(mapBounds)); + } catch (error) { + console.warn('MapView: keeping previous valid bounds after bounds query failure'); + } + }, [mapBounds]); + + // useRestaurants 옵션 메모이제이션 + const restaurantsOptions = useMemo(() => buildMapViewRestaurantsQueryOptions({ + bounds: safeBoundsQuery, + filters: memoizedFilters, + isLoaded, + selectedCountry, + }), [safeBoundsQuery, memoizedFilters, selectedCountry, isLoaded]); + + const { data: restaurants = [], isLoading: isLoadingRestaurants, refetch } = useRestaurants(restaurantsOptions); + const handleReviewSuccess = useMemo( + () => buildMapViewReviewSuccessHandler(refetch), + [refetch] + ); + + // 맛집 개수 표시 자동 숨김 처리 + useEffect(() => { + if (getMapViewRestaurantCountToastVisible(restaurants.length, isLoadingRestaurants)) { + setShowRestaurantCount(true); + const timer = setTimeout(() => { + setShowRestaurantCount(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [restaurants, isLoadingRestaurants]); + + // 마커를 표시할 맛집 목록 (기존 restaurants + 검색된 맛집) + const restaurantsToShow = useMemo(() => { + return mergeSearchedRestaurant(restaurants, searchedRestaurant ?? null); + }, [restaurants, searchedRestaurant]); + const restaurantsById = useMemo(() => { + return new Map(restaurantsToShow.map((restaurant) => [restaurant.id, restaurant])); + }, [restaurantsToShow]); + + + // [데이터 갱신] refreshTrigger 변경 시 재조회 (리뷰 작성 등) + useEffect(() => { + if (refreshTrigger) { + refetch(); + } + }, [refreshTrigger, refetch]); + + useEffect(() => { + if (!isLoaded || !mapRef.current) return; + + let attempts = 0; + const interval = window.setInterval(() => { + attempts += 1; + const mapText = mapRef.current?.innerText ?? ''; + if (mapText.includes("This page didn't load Google Maps correctly")) { + setHasGoogleRuntimeError(true); + window.clearInterval(interval); + } + if (attempts >= 6) { + window.clearInterval(interval); + } + }, 500); + + return () => window.clearInterval(interval); + }, [isLoaded]); + + + // refreshTrigger 변경 시 선택된 레스토랑 정보 업데이트 + useEffect(() => { + const updatedRestaurant = findUpdatedSelectedRestaurant(restaurants, selectedRestaurant); + if (updatedRestaurant) { + onRestaurantSelect?.(updatedRestaurant); + // [수정] 화면 밖으로 벗어나서 리스트에 없더라도 패널을 닫지 않음 + // else if (!updatedRestaurant) { + // onRestaurantSelect?.(null); + // } + } + }, [restaurants, refreshTrigger, selectedRestaurant, onRestaurantSelect]); + + + // [지도 초기화] Google Maps 인스턴스 생성 + useEffect(() => { + if (!isLoaded || !mapRef.current) { + return; + } + + // [유효성 검사] Google Maps API 로드 확인 + if (!window.google || !window.google.maps || !window.google.maps.Map) { + return; + } + + // 선택된 국가에 따라 중심점과 줌 설정 (기본값: 미국) + const initialSelectedCountry = selectedCountryRef.current; + const countryConfig = getMapViewCountryConfig(initialSelectedCountry); + const center = { lat: countryConfig.lat, lng: countryConfig.lng }; + const zoom = countryConfig.zoom; + + try { + const map = new google.maps.Map(mapRef.current, buildGoogleMapOptions({ center, zoom })); + + googleMapRef.current = map; + + // [이동 감지] 지도 이동 종료 시 경계값 업데이트 + map.addListener("idle", () => { + const bounds = map.getBounds(); + if (bounds) { + setMapBounds(bounds); + } + }); + } catch (error) { + console.error("Error creating Google Map:"); + } + }, [isLoaded]); + + // [국가 변경] 선택된 국가에 따라 지도 중심 및 줌 레벨 조정 + useEffect(() => { + if (!googleMapRef.current || !selectedCountry) return; + + // 검색된 맛집이 있으면 국가 중심으로 이동하지 않음 (검색 이동 로직이 우선) + // 단, 검색된 맛집이 현재 선택된 국가와 다를 수 있으므로 주의 필요하지만, + // handleRestaurantSearch에서 이미 국가를 맞춰주므로 여기서는 이동만 막으면 됨 + if (searchedRestaurant) return; + + const countryConfig = getMapViewCountryConfig(selectedCountry); + if (countryConfig) { + googleMapRef.current.setCenter({ lat: countryConfig.lat, lng: countryConfig.lng }); + googleMapRef.current.setZoom(countryConfig.zoom); + } + }, [selectedCountry, searchedRestaurant]); + + // [재시도 로직] 지도 로드 실패 시 재시도 (API 지연 로드 대응) + useEffect(() => { + if (!isLoaded && window.google && window.google.maps && window.google.maps.Map && mapRef.current && !googleMapRef.current) { + // 강제 재로드 실행 + setTimeout(() => { + window.location.reload(); + }, 500); + } + }, [isLoaded]); + + // [마커 관리] 맛집 데이터 변경 시 마커 업데이트 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + + markersRef.current = reconcileMapViewMarkers({ + previous: markersRef.current, + restaurants: restaurantsToShow, + selectedRestaurantId: selectedRestaurant?.id, + searchedRestaurantId: searchedRestaurant?.id, + createMarker: (options) => { + try { + return new markerConstructor({ ...options, map: googleMapRef.current }); + } catch { + console.warn('MapView: Advanced marker creation skipped'); + return null; + } + }, + onActivate: (restaurant) => { + onMarkerClick?.(restaurant); + onRestaurantSelect?.(restaurant); + moveToRestaurant(restaurant); + }, + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + + useEffect(() => () => { + for (const { marker } of markersRef.current) marker.map = null; + markersRef.current = []; + }, []); + + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) { + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + markerElement.classList.toggle('selected-marker', isSelected); + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, [selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById, isLoaded]); + + // 줌 이벤트 시 마커 스타일 유지 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const handleZoomChange = () => { + // 줌 변경 후 약간의 지연을 주어 마커 스타일 재적용 + setTimeout(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) return; + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, 100); + }; + + // 줌 변경 이벤트 리스너 추가 + const zoomListener = googleMapRef.current.addListener('zoom_changed', handleZoomChange); + + return () => { + if (zoomListener) { + google.maps.event.removeListener(zoomListener); + } + }; + }, [isLoaded, selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById]); + + if (!apiKey) { + return ; + } + + if (loadError || hasGoogleRuntimeError) { + return ; + } + + if (!isLoaded) { + return ; + } + + return ( + +
+ + + {shouldShowMapViewDetailPanel({ onMarkerClick, selectedRestaurant }) && ( + + )} +
+
+ ); +}); + +MapView.displayName = 'MapView'; + +export default MapView; diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-helper.ts.txt b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-helper.ts.txt new file mode 100644 index 0000000000..a23d5691ee --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/candidate-helper.ts.txt @@ -0,0 +1,105 @@ +import type { Restaurant } from '../types/restaurant'; +import { sanitizeMarkerImageUrl } from './html-escape'; +import { getRestaurantLatLng } from './map-view-google-helpers'; +import { getMapViewMarkerIcon } from './map-view-helpers'; +import { + buildMapViewMarkerHtml, + getMapViewMarkerSize, + isMapViewMarkerSelected, +} from './map-view-marker-helpers'; + +export interface ManagedMapViewMarker { + map: unknown; + content: Node | null; + position?: unknown; + title?: string; +} + +export interface MapViewMarkerEntry { + marker: M; + restaurantId: string; + restaurant: Restaurant; + position: { lat: number; lng: number }; + imagePath: string; + name: string; + onActivate: (restaurant: Restaurant) => void; +} + +// Reconcile in O(previous + next) time. Retained IDs keep their marker, image, +// and click listener; only added/removed IDs attach/detach provider objects. +export function reconcileMapViewMarkers({ + previous, + restaurants, + createMarker, + onActivate, + selectedRestaurantId, + searchedRestaurantId, +}: { + previous: readonly MapViewMarkerEntry[]; + restaurants: readonly Restaurant[]; + createMarker: (options: { + position: { lat: number; lng: number }; + content: HTMLElement; + title: string; + }) => M | null; + onActivate: (restaurant: Restaurant) => void; + selectedRestaurantId?: string | null; + searchedRestaurantId?: string | null; +}): MapViewMarkerEntry[] { + const remaining = new Map(previous.map((entry) => [entry.restaurantId, entry])); + const next: MapViewMarkerEntry[] = []; + const seen = new Set(); + + for (const restaurant of restaurants) { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng) + || seen.has(restaurant.id)) continue; + seen.add(restaurant.id); + const imagePath = sanitizeMarkerImageUrl(getMapViewMarkerIcon(restaurant.categories)); + const retained = remaining.get(restaurant.id); + if (retained) { + remaining.delete(restaurant.id); + if (retained.position.lat !== position.lat || retained.position.lng !== position.lng) { + retained.marker.position = position; + retained.position = position; + } + if (retained.imagePath !== imagePath || retained.name !== restaurant.name) { + const image = (retained.marker.content as HTMLElement | null)?.querySelector('img'); + if (retained.imagePath !== imagePath) image?.setAttribute('src', imagePath); + if (retained.name !== restaurant.name) { + image?.setAttribute('alt', restaurant.name); + retained.marker.title = restaurant.name; + } + retained.imagePath = imagePath; + retained.name = restaurant.name; + } + // The listener reads these fields at click time, including after a + // same-ID data refresh or a parent callback change. + retained.restaurant = restaurant; + retained.onActivate = onActivate; + next.push(retained); + continue; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, selectedRestaurantId, searchedRestaurantId, + }); + const content = document.createElement('div'); + content.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + content.innerHTML = buildMapViewMarkerHtml({ + imagePath, isSelected, markerSize: getMapViewMarkerSize(isSelected), name: restaurant.name, + }); + const marker = createMarker({ position, content, title: restaurant.name }); + if (!marker) continue; + const entry: MapViewMarkerEntry = { + marker, restaurantId: restaurant.id, restaurant, position, + imagePath, name: restaurant.name, onActivate, + }; + content.addEventListener('click', () => entry.onActivate(entry.restaurant)); + next.push(entry); + } + + // Keep the old visible markers until replacements have been attached. + for (const entry of remaining.values()) entry.marker.map = null; + return next; +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/frozen-tree.patch b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/frozen-tree.patch new file mode 100644 index 0000000000..a7a017b928 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/frozen-tree.patch @@ -0,0 +1,419 @@ +diff --git a/apps/web/components/map/MapView.tsx b/apps/web/components/map/MapView.tsx +index 262f4912..11f0804a 100644 +--- a/apps/web/components/map/MapView.tsx ++++ b/apps/web/components/map/MapView.tsx +@@ -14,7 +14,6 @@ import { + } from "@/components/map/map-view-status-panels"; + import { + getMapViewCountryConfig, +- getMapViewMarkerIcon, + mergeSearchedRestaurant, + } from "@/lib/map-view-helpers"; + import { +@@ -23,10 +22,9 @@ import { + } from "@/lib/map-view-movement-helpers"; + import { + applyMapViewMarkerSelectedState, +- buildMapViewMarkerHtml, +- getMapViewMarkerSize, + isMapViewMarkerSelected, + } from "@/lib/map-view-marker-helpers"; ++import { reconcileMapViewMarkers, type MapViewMarkerEntry } from "@/lib/map-view-marker-reconciliation"; + import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, +@@ -76,12 +74,9 @@ interface GoogleMapLike { + + interface GoogleMarkerLike { + map: GoogleMapLike | null; +- content: Element | null; +-} +- +-interface MapMarkerEntry { +- marker: GoogleMarkerLike; +- restaurantId: string; ++ content: Node | null; ++ position?: unknown; ++ title?: string; + } + function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { +@@ -137,7 +132,7 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); +- const markersRef = useRef([]); ++ const markersRef = useRef[]>([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + +@@ -492,12 +487,6 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + +- // 기존 마커 제거 (메모리 누수 방지) +- markersRef.current.forEach(({ marker }) => { +- marker.map = null; +- }); +- markersRef.current = []; +- + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); +@@ -505,66 +494,34 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + return; + } + +- // 새 마커 생성 +- restaurantsToShow.forEach((restaurant) => { +- const position = getRestaurantLatLng(restaurant); +- if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { +- console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); +- return; +- } +- +- const isSelected = isMapViewMarkerSelected({ +- restaurantId: restaurant.id, +- searchedRestaurantId: searchedRestaurant?.id, +- selectedRestaurantId: selectedRestaurant?.id, +- }); +- const imagePath = getMapViewMarkerIcon(restaurant.categories); +- const markerSize = getMapViewMarkerSize(isSelected); +- +- const markerElement = document.createElement("div"); +- markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; +- markerElement.innerHTML = buildMapViewMarkerHtml({ +- imagePath, +- isSelected, +- markerSize, +- name: restaurant.name, +- }); +- +- let marker: GoogleMarkerLike; +- try { +- marker = new markerConstructor({ +- map: googleMapRef.current, +- position, +- content: markerElement, +- title: restaurant.name, +- }); +- } catch { +- console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); +- return; +- } +- +- markerElement.addEventListener("click", () => { +- +- +- // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) ++ markersRef.current = reconcileMapViewMarkers({ ++ previous: markersRef.current, ++ restaurants: restaurantsToShow, ++ selectedRestaurantId: selectedRestaurant?.id, ++ searchedRestaurantId: searchedRestaurant?.id, ++ createMarker: (options) => { ++ try { ++ return new markerConstructor({ ...options, map: googleMapRef.current }); ++ } catch { ++ console.warn('MapView: Advanced marker creation skipped'); ++ return null; ++ } ++ }, ++ onActivate: (restaurant) => { + onMarkerClick?.(restaurant); +- +- +- // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); +- +- +- // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); +- +- }); +- +- markersRef.current.push({ marker, restaurantId: restaurant.id }); ++ }, + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + ++ useEffect(() => () => { ++ for (const { marker } of markersRef.current) marker.map = null; ++ markersRef.current = []; ++ }, []); ++ + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; +diff --git a/apps/web/lib/mobile-home-search-selection.ts b/apps/web/lib/mobile-home-search-selection.ts +index a3b5681a..436c89dc 100644 +--- a/apps/web/lib/mobile-home-search-selection.ts ++++ b/apps/web/lib/mobile-home-search-selection.ts +@@ -44,6 +44,59 @@ export const isSameRestaurantSelection = (left: RestaurantMatch, right: Restaura + return hasSameNameAndCoordinate(left, right); + }; + ++// A render pass compares many candidates with one selection. Build its merged-id ++// index at most once, instead of allocating a Set and arrays for every candidate. ++const createRestaurantSelectionMatcher = (selection: Restaurant) => { ++ let selectionIds: Set | undefined; ++ return (candidate: Restaurant): boolean => { ++ if (candidate.id === selection.id || hasSameNameAndCoordinate(candidate, selection)) return true; ++ const selectionMerged = selection.mergedRestaurants; ++ const candidateMerged = candidate.mergedRestaurants; ++ if (!selectionMerged?.length) { ++ if (candidateMerged) { ++ for (let index = 0; index < candidateMerged.length; index += 1) { ++ if (candidateMerged[index].id === selection.id) return true; ++ } ++ } ++ return false; ++ } ++ if (!selectionIds) { ++ selectionIds = new Set([selection.id]); ++ for (let index = 0; index < selectionMerged.length; index += 1) { ++ selectionIds.add(selectionMerged[index].id); ++ } ++ } ++ if (selectionIds.has(candidate.id)) return true; ++ if (candidateMerged) { ++ for (let index = 0; index < candidateMerged.length; index += 1) { ++ if (selectionIds.has(candidateMerged[index].id)) return true; ++ } ++ } ++ return false; ++ }; ++}; ++ ++type SelectionCacheKey = Pick & { mergedIds: string[] }; ++ ++const selectionCacheKey = (selection: Restaurant): SelectionCacheKey => ({ ++ id: selection.id, ++ name: selection.name, ++ lat: selection.lat, ++ lng: selection.lng, ++ mergedIds: selection.mergedRestaurants?.map((item) => item.id) ?? [], ++}); ++ ++const matchesSelectionCacheKey = (key: SelectionCacheKey, selection: Restaurant): boolean => { ++ if (key.id !== selection.id || key.name !== selection.name ++ || !Object.is(key.lat, selection.lat) || !Object.is(key.lng, selection.lng)) return false; ++ const merged = selection.mergedRestaurants; ++ if (key.mergedIds.length !== (merged?.length ?? 0)) return false; ++ for (let index = 0; index < key.mergedIds.length; index += 1) { ++ if (key.mergedIds[index] !== merged![index].id) return false; ++ } ++ return true; ++}; ++ + type SearchSelectionInput = { + searchedRestaurant: Restaurant | null; + selectedRestaurant: Restaurant | null; +@@ -141,7 +194,16 @@ export const resolveReleasedSearchSelectionResetPlan = ({ + return { nextReleasedSearchSelectionId: releasedSearchSelectionId } as const; + }; + ++const rememberedUniqueSwipeLists = new WeakSet(); ++let lastPostSearchDedupeExaminationCount = 0; ++ ++export function getLastPostSearchDedupeExaminationCount() { ++ return lastPostSearchDedupeExaminationCount; ++} ++ + const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { ++ if (rememberedUniqueSwipeLists.has(restaurants)) return restaurants; ++ lastPostSearchDedupeExaminationCount += 1; + const seenIds = new Set(); + const seenMergedIds = new Set(); + const restaurantsByName = new Map(); +@@ -166,6 +228,11 @@ const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { + else restaurantsByName.set(restaurant.name, [restaurant]); + }); + ++ if (uniqueRestaurants.length === restaurants.length) { ++ rememberedUniqueSwipeLists.add(restaurants); ++ return restaurants; ++ } ++ rememberedUniqueSwipeLists.add(uniqueRestaurants); + return uniqueRestaurants; + }; + +@@ -224,13 +291,39 @@ export const buildRestaurantsForSwipe = ({ + return [...displayRestaurants, ...restaurantsToAdd]; + }; + +-const nearestSwipeFallbackCache = new WeakMap>(); ++// List-reference caches require replacing catalog/visible arrays on edits. ++// Bound query history while retaining common back-and-forth searches. ++const MAX_SEARCH_CACHE_ENTRIES = 64; ++const rememberSelectionResult = (cache: Map, id: string, value: T) => { ++ cache.delete(id); ++ cache.set(id, value); ++ if (cache.size > MAX_SEARCH_CACHE_ENTRIES) { ++ const oldest = cache.keys().next().value; ++ if (oldest !== undefined) cache.delete(oldest); ++ } ++}; ++ ++const nearestSwipeFallbackCache = new WeakMap>(); + let nearestFallbackScanCount = 0; + + export function getNearestFallbackScanCount() { + return nearestFallbackScanCount; + } + ++const orderedVisibleCache = new WeakMap>(); ++let lastSwipeOrderBuildCount = 0; ++ ++export function getLastSwipeOrderBuildCount() { ++ return lastSwipeOrderBuildCount; ++} ++ + export const buildPostSearchSwipeCandidates = ({ + visibleRestaurants, + allRestaurants, +@@ -239,16 +332,37 @@ export const buildPostSearchSwipeCandidates = ({ + const dedupedVisibleRestaurants = dedupeRestaurants(visibleRestaurants); + let orderedVisibleRestaurants = dedupedVisibleRestaurants; + if (activeSearchedRestaurant) { +- const matchedRestaurants: Restaurant[] = []; +- const otherRestaurants: Restaurant[] = []; +- dedupedVisibleRestaurants.forEach((restaurant) => { +- if (isSameRestaurantSelection(restaurant, activeSearchedRestaurant)) { +- matchedRestaurants.push(restaurant); +- } else { +- otherRestaurants.push(restaurant); ++ let orders = orderedVisibleCache.get(dedupedVisibleRestaurants); ++ if (!orders) { ++ orders = new Map(); ++ orderedVisibleCache.set(dedupedVisibleRestaurants, orders); ++ } ++ const cachedOrder = orders.get(activeSearchedRestaurant.id); ++ if (cachedOrder && matchesSelectionCacheKey(cachedOrder.searched, activeSearchedRestaurant)) { ++ orderedVisibleRestaurants = cachedOrder.restaurants; ++ } else { ++ lastSwipeOrderBuildCount += 1; ++ const matches = createRestaurantSelectionMatcher(activeSearchedRestaurant); ++ orderedVisibleRestaurants = new Array(dedupedVisibleRestaurants.length); ++ let head = 0; ++ let tail = dedupedVisibleRestaurants.length - 1; ++ for (let index = 0; index < dedupedVisibleRestaurants.length; index += 1) { ++ const restaurant = dedupedVisibleRestaurants[index]; ++ if (matches(restaurant)) orderedVisibleRestaurants[head++] = restaurant; ++ else orderedVisibleRestaurants[tail--] = restaurant; + } +- }); +- orderedVisibleRestaurants = matchedRestaurants.concat(otherRestaurants); ++ // Non-matches were written backwards; restore their input order. ++ // This stable partition needs one result array, not two + concat. ++ for (let left = head, right = orderedVisibleRestaurants.length - 1; left < right; left += 1, right -= 1) { ++ const restaurant = orderedVisibleRestaurants[left]; ++ orderedVisibleRestaurants[left] = orderedVisibleRestaurants[right]; ++ orderedVisibleRestaurants[right] = restaurant; ++ } ++ rememberSelectionResult(orders, activeSearchedRestaurant.id, { ++ searched: selectionCacheKey(activeSearchedRestaurant), ++ restaurants: orderedVisibleRestaurants, ++ }); ++ } + } + + if (!activeSearchedRestaurant || orderedVisibleRestaurants.length !== 1) { +@@ -256,14 +370,15 @@ export const buildPostSearchSwipeCandidates = ({ + } + + const visibleRestaurant = orderedVisibleRestaurants[0]; +- const cacheKey = `${activeSearchedRestaurant.id}:${visibleRestaurant.id}`; +- let cachedFallbacks = nearestSwipeFallbackCache.get(allRestaurants); +- if (!cachedFallbacks) { +- cachedFallbacks = new Map(); +- nearestSwipeFallbackCache.set(allRestaurants, cachedFallbacks); ++ let fallbacks = nearestSwipeFallbackCache.get(allRestaurants); ++ if (!fallbacks) { ++ fallbacks = new Map(); ++ nearestSwipeFallbackCache.set(allRestaurants, fallbacks); + } +- if (cachedFallbacks.has(cacheKey)) { +- const cachedFallback = cachedFallbacks.get(cacheKey) ?? null; ++ const cached = fallbacks.get(activeSearchedRestaurant.id); ++ if (cached && matchesSelectionCacheKey(cached.searched, activeSearchedRestaurant) ++ && matchesSelectionCacheKey(cached.visible, visibleRestaurant)) { ++ const cachedFallback = cached.restaurant; + return cachedFallback + ? [...orderedVisibleRestaurants, cachedFallback] + : orderedVisibleRestaurants; +@@ -271,11 +386,12 @@ export const buildPostSearchSwipeCandidates = ({ + + nearestFallbackScanCount += 1; + const dedupedRestaurants = dedupeRestaurants(allRestaurants); ++ const matchesVisible = createRestaurantSelectionMatcher(visibleRestaurant); + let nearestFallbackRestaurant: Restaurant | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < dedupedRestaurants.length; index += 1) { + const candidateRestaurant = dedupedRestaurants[index]; +- if (isSameRestaurantSelection(candidateRestaurant, orderedVisibleRestaurants[0])) continue; ++ if (matchesVisible(candidateRestaurant)) continue; + + const candidateDistance = getApproximateRestaurantDistance( + activeSearchedRestaurant, +@@ -287,7 +403,11 @@ export const buildPostSearchSwipeCandidates = ({ + nearestDistance = candidateDistance; + } + +- cachedFallbacks.set(cacheKey, nearestFallbackRestaurant); ++ rememberSelectionResult(fallbacks, activeSearchedRestaurant.id, { ++ searched: selectionCacheKey(activeSearchedRestaurant), ++ visible: selectionCacheKey(visibleRestaurant), ++ restaurant: nearestFallbackRestaurant, ++ }); + + return nearestFallbackRestaurant + ? [...orderedVisibleRestaurants, nearestFallbackRestaurant] +diff --git a/apps/web/tests-unit/mobile-home-search-selection.test.ts b/apps/web/tests-unit/mobile-home-search-selection.test.ts +index 93e2c091..62ea969c 100644 +--- a/apps/web/tests-unit/mobile-home-search-selection.test.ts ++++ b/apps/web/tests-unit/mobile-home-search-selection.test.ts +@@ -3,6 +3,8 @@ import { describe, expect, test } from 'bun:test'; + import type { Restaurant } from '../types/restaurant'; + import { + buildPostSearchSwipeCandidates, ++ getLastPostSearchDedupeExaminationCount, ++ getLastSwipeOrderBuildCount, + getNearestFallbackScanCount, + buildRestaurantsForSwipe, + getActiveSearchedRestaurant, +@@ -254,4 +256,25 @@ describe('mobile home search selection helpers', () => { + + expect(getNearestFallbackScanCount()).toBe(scansBefore + 1); + }); ++ ++ test('reuses the searched swipe order for the same visible list', () => { ++ const searchedRestaurant = makeRestaurant({ id: 'search-1', lat: 37.2, lng: 127.2 }); ++ const otherRestaurant = makeRestaurant({ id: 'other-1', lat: 37.8, lng: 127.8 }); ++ const visibleRestaurants = [otherRestaurant, searchedRestaurant]; ++ buildPostSearchSwipeCandidates({ ++ visibleRestaurants, ++ allRestaurants: visibleRestaurants, ++ activeSearchedRestaurant: searchedRestaurant, ++ }); ++ const dedupes = getLastPostSearchDedupeExaminationCount(); ++ const orders = getLastSwipeOrderBuildCount(); ++ const again = buildPostSearchSwipeCandidates({ ++ visibleRestaurants, ++ allRestaurants: visibleRestaurants, ++ activeSearchedRestaurant: searchedRestaurant, ++ }); ++ expect(getLastPostSearchDedupeExaminationCount()).toBe(dedupes); ++ expect(getLastSwipeOrderBuildCount()).toBe(orders); ++ expect(again.map((restaurant) => restaurant.id)).toEqual(['search-1', 'other-1']); ++ }); + }); diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/measure.mjs b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/measure.mjs new file mode 100644 index 0000000000..276560b91c --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/measure.mjs @@ -0,0 +1,173 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpus } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import ts from 'typescript'; +import { chromium } from '@playwright/test'; + +assert.equal(process.versions.node.split('.')[0], '24', 'Use the project Node 24 runtime'); +const root = new URL('./', import.meta.url); +const app = fileURLToPath(new URL('../../', root)); +const hash = (value) => createHash('sha256').update(value).digest('hex'); +const baselineSource = await readFile(new URL('baseline-MapView.tsx.txt', root), 'utf8'); +const start = baselineSource.indexOf(' // [마커 관리]'); +const bodyStart = baselineSource.indexOf('useEffect(() => {', start) + 'useEffect(() => {'.length; +const bodyEnd = baselineSource.indexOf(' // Selection IDs are applied', bodyStart); +assert.ok(start >= 0 && bodyEnd > bodyStart); +const baselineModule = ` +import { getRestaurantLatLng } from '../../lib/map-view-google-helpers'; +import { getMapViewMarkerIcon } from '../../lib/map-view-helpers'; +import { buildMapViewMarkerHtml, getMapViewMarkerSize, isMapViewMarkerSelected } from '../../lib/map-view-marker-helpers'; +export function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = {current: previous}, restaurantsToShow = restaurants; + const googleMapRef = {current: map}, isLoaded = true; + const google = {maps: {marker: {AdvancedMarkerElement: Marker}}}; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => {}, setHasGoogleRuntimeError = () => {}; + ${baselineSource.slice(bodyStart, bodyEnd)} + return markersRef.current; +} +`; +await writeFile(new URL('baseline-effect.mjs', root), ts.transpileModule(baselineModule, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, +}).outputText); +await writeFile(new URL('browser-entry.mjs', root), ` +export { baselineUpdate } from './baseline-effect.mjs'; +export { reconcileMapViewMarkers } from '../../lib/map-view-marker-reconciliation'; +`); +execFileSync('bun', ['build', fileURLToPath(new URL('browser-entry.mjs', root)), '--target=browser', '--format=esm', + `--outfile=${fileURLToPath(new URL('browser-bundle.mjs', root))}`], { cwd: app, stdio: 'pipe' }); +const bundle = await readFile(new URL('browser-bundle.mjs', root), 'utf8'); +const browser = await chromium.launch({ headless: true }); +const samples = []; +const scenarios = ['same-list-new-array', 'ten-percent-turnover', 'coordinate-updates', 'full-turnover']; +const pairs = 31, iterations = 10, size = 200; +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.route('http://127.0.0.1/**', (route) => route.fulfill({ + headers: { 'Content-Type': 'text/html', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' }, + body: 'Google marker DOM reconciliation laboratory', + })); + await page.goto('http://127.0.0.1/'); + await page.evaluate(async (code) => { + const { baselineUpdate, reconcileMapViewMarkers } = await import('data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)))); + const makeRestaurant = (id, index) => ({ id, name: `Restaurant ${id}`, lat: 35 + index / 10000, lng: 126 + index / 10000, categories: ['한식'] }); + const fixture = (variant, count) => { + const container = document.createElement('div'); + document.body.append(container); + const stats = { created: 0, detached: 0, moved: 0 }; + const map = { container }; + class Marker { + constructor({ map, content, position, title }) { + this.content = content; this.title = title; this._map = map; + this.position = position; map.container.append(content); stats.created++; + } + set map(value) { this._map = value; if (!value) { this.content.remove(); stats.detached++; } } + get map() { return this._map; } + set position(value) { this._position = value; this.content.style.transform = `translate(${(value.lng - 126) * 10000 % 1300}px,${(value.lat - 35) * 10000 % 800}px)`; stats.moved++; } + get position() { return this._position; } + } + let entries = [], fail = false; + const update = (restaurants, onActivate = () => {}) => { + if (variant === 0) entries = baselineUpdate({ previous: entries, restaurants, Marker, map, onActivate }); + else entries = reconcileMapViewMarkers({ previous: entries, restaurants, onActivate, + createMarker: (options) => fail ? null : new Marker({ ...options, map }), + }); + return entries; + }; + const restaurants = Array.from({ length: count }, (_, index) => makeRestaurant(`r${index}`, index)); + update(restaurants); + const reset = () => { stats.created = 0; stats.detached = 0; stats.moved = 0; }; + reset(); + return { container, stats, update, restaurants, reset, setFail: (value) => { fail = value; }, dispose: () => container.remove() }; + }; + globalThis.checkMarkers = () => { + let checks = 0; + const check = (condition, label) => { if (!condition) throw new Error(label); checks++; }; + const f = fixture(1, 3); + let entries = f.update(f.restaurants); + const first = entries[0], image = first.marker.content.querySelector('img'); + entries = f.update(f.restaurants.map((r) => ({ ...r }))); + check(entries[0] === first, 'retained entry identity'); + check(entries[0].marker.content.querySelector('img') === image, 'retained image identity'); + check(f.stats.created === 0 && f.stats.detached === 0 && f.stats.moved === 0, 'no unchanged provider mutation'); + const replacement = { ...f.restaurants[0], name: 'Refreshed ', lat: 36 }; + let clicked; + f.update([replacement, ...f.restaurants.slice(1)], (r) => { clicked = r; }); + first.marker.content.click(); + check(clicked === replacement, 'click uses newest object and callback'); + check(first.marker.title === replacement.name && image.alt === replacement.name, 'safe in-place name update'); + check(first.marker.position.lat === 36 && f.stats.moved === 1, 'coordinate-only mutation'); + replacement.lat = 37; + f.update([replacement, ...f.restaurants.slice(1)]); + check(first.marker.position.lat === 37 && f.stats.moved === 2, 'in-place coordinate mutation'); + f.reset(); + entries = f.update([f.restaurants[1], makeRestaurant('new', 4)]); + check(entries.length === 2 && f.stats.created === 1 && f.stats.detached === 2, 'bounded membership reconciliation'); + f.reset(); + entries = f.update([{ ...f.restaurants[1], lat: NaN }, makeRestaurant('new', 4), makeRestaurant('new', 4)]); + check(entries.length === 1 && f.stats.detached === 1, 'invalid coordinates and duplicate IDs'); + entries = f.update([]); + check(entries.length === 0 && f.container.childElementCount === 0, 'empty list cleanup'); + f.setFail(true); entries = f.update(f.restaurants); + check(entries.length === 0, 'creation failure not cached'); + f.setFail(false); entries = f.update(f.restaurants); + check(entries.length === 3, 'creation retry recovery'); + f.dispose(); + const b = fixture(0, 3); b.update(b.restaurants.slice()); + check(b.stats.created === 3 && b.stats.detached === 3, 'baseline reproduces rebuild churn'); + b.dispose(); + return { checks, failures: 0 }; + }; + globalThis.measureMarkers = ({ scenario, variant, size, iterations }) => { + const f = fixture(variant, size); + const inputs = Array.from({ length: iterations }, (_, round) => Array.from({ length: size }, (_, index) => { + if (scenario === 'full-turnover' || (scenario === 'ten-percent-turnover' && index < size / 10)) return makeRestaurant(`new-${round}-${index}`, index); + const restaurant = { ...f.restaurants[index] }; + if (scenario === 'coordinate-updates') restaurant.lat += (round + 1) / 10000; + return restaurant; + })); + const start = performance.now(); + for (const input of inputs) f.update(input); + // Include the DOM update's synchronous layout, not only JS dispatch. + f.container.getBoundingClientRect(); + const totalMs = performance.now() - start; + const result = { totalMs, ...f.stats, markerCount: f.container.childElementCount }; + f.dispose(); + return result; + }; + }, bundle); + const verification = await page.evaluate(() => globalThis.checkMarkers()); + console.log(JSON.stringify({ verification })); + for (const scenario of scenarios) { + for (let warmup = 0; warmup < 3; warmup++) for (const variant of [0, 1]) await page.evaluate((input) => globalThis.measureMarkers(input), { scenario, variant, size, iterations }); + for (let sample = 0; sample < pairs; sample++) { + const order = sample % 2 ? [1, 0] : [0, 1], results = {}; + for (const variant of order) results[variant ? 'candidate' : 'baseline'] = await page.evaluate((input) => globalThis.measureMarkers(input), { scenario, variant, size, iterations }); + assert.equal(results.baseline.markerCount, size); assert.equal(results.candidate.markerCount, size); + samples.push({ scenario, sample, order, ...results }); + } + console.log(JSON.stringify({ measured: scenario, pairedSamples: pairs })); + } + const raw = { + scope: 'Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.', + frozenAt: new Date().toISOString(), environment: { node: process.version, chromium: browser.version(), cpu: cpus()[0]?.model, crossOriginIsolated: await page.evaluate(() => crossOriginIsolated) }, + gitHead: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: app, encoding: 'utf8' }).trim(), + budgets: { absoluteDeltaMs: 0.05, relativeDeltaFraction: 0.05, noiseMadMultiplier: 2 }, + scenarios, size, iterations, pairs, verification, samples, + }; + await writeFile(new URL('raw.json', root), JSON.stringify(raw, null, 2) + '\n'); + for (const [name, path] of Object.entries({ 'candidate-MapView.tsx.txt': '../../components/map/MapView.tsx', 'candidate-helper.ts.txt': '../../lib/map-view-marker-reconciliation.ts' })) { + await writeFile(new URL(name, root), await readFile(new URL(path, root))); + } + await writeFile(new URL('frozen-tree.patch', root), execFileSync('git', ['diff', '--binary', 'HEAD'], { cwd: app })); + const files = ['baseline-MapView.tsx.txt', 'baseline-effect.mjs', 'browser-entry.mjs', 'browser-bundle.mjs', 'candidate-MapView.tsx.txt', 'candidate-helper.ts.txt', 'frozen-tree.patch', 'raw.json', 'measure.mjs', 'score-and-verify.mjs']; + const artifacts = {}; + for (const file of files) artifacts[file] = hash(await readFile(new URL(file, root))); + const map = JSON.stringify({ artifacts }, null, 2) + '\n'; + await writeFile(new URL('artifact-map.json', root), map); + console.log(JSON.stringify({ artifactMapSha256: hash(map) })); +} finally { await browser.close(); } diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/raw.json b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/raw.json new file mode 100644 index 0000000000..43019f69ca --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/raw.json @@ -0,0 +1,2759 @@ +{ + "scope": "Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.", + "frozenAt": "2026-09-25T04:27:59.978Z", + "environment": { + "node": "v24.21.0", + "chromium": "151.0.7922.34", + "cpu": "Apple M5 Max", + "crossOriginIsolated": true + }, + "gitHead": "baa2415d011fcd18b7d0e038d4ce46c39c01bdec", + "budgets": { + "absoluteDeltaMs": 0.05, + "relativeDeltaFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "scenarios": [ + "same-list-new-array", + "ten-percent-turnover", + "coordinate-updates", + "full-turnover" + ], + "size": 200, + "iterations": 10, + "pairs": 31, + "verification": { + "checks": 13, + "failures": 0 + }, + "samples": [ + { + "scenario": "same-list-new-array", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.454999923706055, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.6850000619888306, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.7949999570846558, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.180000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.070000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 5.495000004768372, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6999999284744263, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.799999952316284, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.070000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.7299998998641968, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.7649999856948853, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.75, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.475000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.6549999713897705, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.634999990463257, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 16.414999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.559999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.305000066757202, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.5700000524520874, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 9.370000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.350000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.475000023841858, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.4200000762939453, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.519999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 11.014999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.5799999237060547, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.5350000858306885, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 9.919999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.449999928474426, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.115000009536743, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.159999966621399, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.200000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.425000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.524999976158142, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.5049999952316284, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.009999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.235000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.555000066757202, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.5149999856948853, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.450000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.659999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.059999942779541, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.9049999713897705, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.110000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.444999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.325000047683716, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.1850000619888306, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 9.804999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.840000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.4350000619888306, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.4850000143051147, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.09500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.215000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.815000057220459, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.744999885559082, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.625, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.894999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.2850000858306885, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.2350000143051147, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.125, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.679999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.440000057220459, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.590000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.884999990463257, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.7899999618530273, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.059999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.860000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 5.879999995231628, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.455000042915344, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.575000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.09500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.4399999380111694, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.4950000047683716, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.445000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.450000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.375, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.5799999237060547, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 9.730000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.809999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.459999918937683, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.235000014305115, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.045000076293945, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 11.735000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.319999933242798, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.184999942779541, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.554999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.245000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.6449999809265137, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.5, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.110000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.409999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.575000047683716, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.625, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.879999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.040000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.3600000143051147, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.325000047683716, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.559999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.93999993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.690000057220459, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.115000009536743, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.729999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.27999997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.334999918937683, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.325000047683716, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.31000006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.870000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.3450000286102295, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 5.019999980926514, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.565000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.680000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.9299999475479126, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 5.735000014305115, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.215000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.130000114440918, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.559999942779541, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.3700000047683716, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.59500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.585000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.3550000190734863, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.2700001001358032, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.919999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.129999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 4.215000033378601, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.764999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.125, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.790000081062317, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.419999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.069999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.9700000286102295, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.340000033378601, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.27999997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.544999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.8549998998641968, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.8149999380111694, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.759999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.024999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.9250000715255737, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.534999966621399, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.294999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.684999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.0100001096725464, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.875, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.800000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.585000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.9600000381469727, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.9499999284744263, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.774999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.745000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.9399999380111694, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.8899999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 9.52999997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.725000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.8899999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.8899999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.40499997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.75, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.8600000143051147, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 5.460000038146973, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.475000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.659999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.9049999713897705, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.159999966621399, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.81499993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.085000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.75, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.774999976158142, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.414999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.815000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.8700000047683716, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.2200000286102295, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.25, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.019999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.7649999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6799999475479126, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.585000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.370000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.399999976158142, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.340000033378601, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.995000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.330000042915344, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.790000081062317, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6200000047683716, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.115000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.014999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.134999990463257, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 11.274999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.950000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 14.940000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.315000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.104999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.384999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 20.62000000476837, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.539999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.75, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 19.115000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.370000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.890000104904175, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.919999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.865000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 12.75, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.849999904632568, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 11.164999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 18.084999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 12.289999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.71999990940094, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.05999994277954, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 12.459999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 12.960000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.115000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.384999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 12.355000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 12.705000042915344, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 16.725000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.605000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 12.975000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 14.240000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.480000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.365000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.259999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 19.864999890327454, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.269999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.65499997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 17.610000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 12.854999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.634999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 11.054999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 12.234999895095825, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 12.389999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.649999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.004999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 14.269999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.43500006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.389999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 9.800000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 12.600000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 17.990000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.069999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.569999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 12.5, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.105000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 11.125, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 10.240000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.304999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 14.210000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 10.474999904632568, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 12.349999904632568, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 12.899999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + } + ] +} diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/reason.txt b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/reason.txt new file mode 100644 index 0000000000..87a4e9eef7 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/reason.txt @@ -0,0 +1 @@ +Rejected timing: image requests received HTML. Remeasure with a decoded image fixture. diff --git a/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/score-and-verify.mjs b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/score-and-verify.mjs new file mode 100644 index 0000000000..1dca7fb5f5 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/rejected-html-image-response/score-and-verify.mjs @@ -0,0 +1,56 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; + +const root = new URL('./', import.meta.url); +const pin = process.argv[2]; +assert.match(pin ?? '', /^[a-f0-9]{64}$/); +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const mapBytes = await readFile(new URL('artifact-map.json', root)); +assert.equal(hash(mapBytes), pin); +const map = JSON.parse(mapBytes); +for (const [name, expected] of Object.entries(map.artifacts)) { + assert.match(name, /^[a-zA-Z0-9.-]+$/); + assert.equal(hash(await readFile(new URL(name, root))), expected, name); +} +for (const [name, live] of Object.entries({ 'candidate-MapView.tsx.txt': '../../components/map/MapView.tsx', 'candidate-helper.ts.txt': '../../lib/map-view-marker-reconciliation.ts' })) { + assert.equal(hash(await readFile(new URL(live, root))), map.artifacts[name], 'Measured source drift: ' + live); +} +const raw = JSON.parse(await readFile(new URL('raw.json', root), 'utf8')); +assert.equal(raw.verification.failures, 0); assert.ok(raw.verification.checks >= 13); +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +const mad = (values) => { const m = median(values); return median(values.map((value) => Math.abs(value - m))); }; +const results = raw.scenarios.map((scenario) => { + const samples = raw.samples.filter((sample) => sample.scenario === scenario); + assert.equal(samples.length, raw.pairs); + samples.forEach((sample, index) => { + assert.equal(sample.sample, index); assert.deepEqual(sample.order, index % 2 ? [1, 0] : [0, 1]); + for (const variant of ['baseline', 'candidate']) { + assert.ok(sample[variant].totalMs > 0 && Number.isFinite(sample[variant].totalMs)); + assert.equal(sample[variant].markerCount, raw.size); + } + }); + const b = samples.map((sample) => sample.baseline.totalMs / raw.iterations); + const c = samples.map((sample) => sample.candidate.totalMs / raw.iterations); + const baselineMs = median(b), candidateMs = median(c); + const absoluteReductionMs = baselineMs - candidateMs, relativeReductionPercent = 100 * absoluteReductionMs / baselineMs; + const noiseMs = raw.budgets.noiseMadMultiplier * Math.max(mad(b), mad(c)); + const admitted = Math.abs(absoluteReductionMs) > Math.max(raw.budgets.absoluteDeltaMs, noiseMs) + && Math.abs(relativeReductionPercent) > raw.budgets.relativeDeltaFraction * 100; + const expectedChanged = scenario === 'full-turnover' ? raw.size : scenario === 'ten-percent-turnover' ? raw.size / 10 : 0; + for (const sample of samples) { + assert.equal(sample.baseline.created, raw.size * raw.iterations); + assert.equal(sample.baseline.detached, raw.size * raw.iterations); + assert.equal(sample.candidate.created, expectedChanged * raw.iterations); + assert.equal(sample.candidate.detached, expectedChanged * raw.iterations); + } + return { scenario, baselineMs, candidateMs, absoluteReductionMs, relativeReductionPercent, + baselineMadMs: mad(b), candidateMadMs: mad(c), noiseMs, + baselineCreatesPerUpdate: raw.size, candidateCreatesPerUpdate: expectedChanged, + baselineDetachesPerUpdate: raw.size, candidateDetachesPerUpdate: expectedChanged, + classification: admitted ? (absoluteReductionMs > 0 ? 'local_improvement' : 'local_regression') : 'below_budget_or_noise' }; +}); +const score = { scope: raw.scope, artifactMapSha256: pin, budgets: raw.budgets, verification: raw.verification, results }; +if (process.argv[3] === '--verify') assert.deepEqual(JSON.parse(await readFile(new URL('scored.json', root), 'utf8')), score); +else await writeFile(new URL('scored.json', root), JSON.stringify(score, null, 2) + '\n'); +console.log(JSON.stringify({ verified: true, ...score }, null, 2)); diff --git a/apps/web/performance/google-marker-reconcile-20260925/score-and-verify.mjs b/apps/web/performance/google-marker-reconcile-20260925/score-and-verify.mjs new file mode 100644 index 0000000000..d9a5b5e37b --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/score-and-verify.mjs @@ -0,0 +1,57 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; + +const root = new URL('./', import.meta.url); +const pin = process.argv[2]; +assert.match(pin ?? '', /^[a-f0-9]{64}$/); +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const mapBytes = await readFile(new URL('artifact-map.json', root)); +assert.equal(hash(mapBytes), pin); +const map = JSON.parse(mapBytes); +for (const [name, expected] of Object.entries(map.artifacts)) { + assert.match(name, /^[a-zA-Z0-9.-]+$/); + assert.equal(hash(await readFile(new URL(name, root))), expected, name); +} +for (const [name, live] of Object.entries({ 'candidate-MapView.tsx.txt': '../../components/map/MapView.tsx', 'candidate-helper.ts.txt': '../../lib/map-view-marker-reconciliation.ts' })) { + assert.equal(hash(await readFile(new URL(live, root))), map.artifacts[name], 'Measured source drift: ' + live); +} +const raw = JSON.parse(await readFile(new URL('raw.json', root), 'utf8')); +assert.equal(raw.verification.failures, 0); assert.ok(raw.verification.checks >= 13); +assert.equal(raw.verification.imageDecoded, true); +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +const mad = (values) => { const m = median(values); return median(values.map((value) => Math.abs(value - m))); }; +const results = raw.scenarios.map((scenario) => { + const samples = raw.samples.filter((sample) => sample.scenario === scenario); + assert.equal(samples.length, raw.pairs); + samples.forEach((sample, index) => { + assert.equal(sample.sample, index); assert.deepEqual(sample.order, index % 2 ? [1, 0] : [0, 1]); + for (const variant of ['baseline', 'candidate']) { + assert.ok(sample[variant].totalMs > 0 && Number.isFinite(sample[variant].totalMs)); + assert.equal(sample[variant].markerCount, raw.size); + } + }); + const b = samples.map((sample) => sample.baseline.totalMs / raw.iterations); + const c = samples.map((sample) => sample.candidate.totalMs / raw.iterations); + const baselineMs = median(b), candidateMs = median(c); + const absoluteReductionMs = baselineMs - candidateMs, relativeReductionPercent = 100 * absoluteReductionMs / baselineMs; + const noiseMs = raw.budgets.noiseMadMultiplier * Math.max(mad(b), mad(c)); + const admitted = Math.abs(absoluteReductionMs) > Math.max(raw.budgets.absoluteDeltaMs, noiseMs) + && Math.abs(relativeReductionPercent) > raw.budgets.relativeDeltaFraction * 100; + const expectedChanged = scenario === 'full-turnover' ? raw.size : scenario === 'ten-percent-turnover' ? raw.size / 10 : 0; + for (const sample of samples) { + assert.equal(sample.baseline.created, raw.size * raw.iterations); + assert.equal(sample.baseline.detached, raw.size * raw.iterations); + assert.equal(sample.candidate.created, expectedChanged * raw.iterations); + assert.equal(sample.candidate.detached, expectedChanged * raw.iterations); + } + return { scenario, baselineMs, candidateMs, absoluteReductionMs, relativeReductionPercent, + baselineMadMs: mad(b), candidateMadMs: mad(c), noiseMs, + baselineCreatesPerUpdate: raw.size, candidateCreatesPerUpdate: expectedChanged, + baselineDetachesPerUpdate: raw.size, candidateDetachesPerUpdate: expectedChanged, + classification: admitted ? (absoluteReductionMs > 0 ? 'local_improvement' : 'local_regression') : 'below_budget_or_noise' }; +}); +const score = { scope: raw.scope, artifactMapSha256: pin, budgets: raw.budgets, verification: raw.verification, results }; +if (process.argv[3] === '--verify') assert.deepEqual(JSON.parse(await readFile(new URL('scored.json', root), 'utf8')), score); +else await writeFile(new URL('scored.json', root), JSON.stringify(score, null, 2) + '\n'); +console.log(JSON.stringify({ verified: true, ...score }, null, 2)); diff --git a/apps/web/performance/google-marker-reconcile-20260925/scored.json b/apps/web/performance/google-marker-reconcile-20260925/scored.json new file mode 100644 index 0000000000..6c52fa31bf --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-20260925/scored.json @@ -0,0 +1,76 @@ +{ + "scope": "Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.", + "artifactMapSha256": "3421881130b97a47b53d97abe286f0c290f6495da88057daf662c994d917a97b", + "budgets": { + "absoluteDeltaMs": 0.05, + "relativeDeltaFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "verification": { + "checks": 13, + "failures": 0, + "imageDecoded": true + }, + "results": [ + { + "scenario": "same-list-new-array", + "baselineMs": 1.3985000014305116, + "candidateMs": 0.2725000023841858, + "absoluteReductionMs": 1.1259999990463259, + "relativeReductionPercent": 80.51483717515565, + "baselineMadMs": 0.06599999666213985, + "candidateMadMs": 0.014000010490417492, + "noiseMs": 0.1319999933242797, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 0, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 0, + "classification": "local_improvement" + }, + { + "scenario": "ten-percent-turnover", + "baselineMs": 1.4475000023841857, + "candidateMs": 0.42950000762939455, + "absoluteReductionMs": 1.017999994754791, + "relativeReductionPercent": 70.32815150798186, + "baselineMadMs": 0.03649998903274532, + "candidateMadMs": 0.02050001621246339, + "noiseMs": 0.07299997806549063, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 20, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 20, + "classification": "local_improvement" + }, + { + "scenario": "coordinate-updates", + "baselineMs": 1.437999999523163, + "candidateMs": 0.328000009059906, + "absoluteReductionMs": 1.109999990463257, + "relativeReductionPercent": 77.19054178242908, + "baselineMadMs": 0.048500001430511475, + "candidateMadMs": 0.014500010013580311, + "noiseMs": 0.09700000286102295, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 0, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 0, + "classification": "local_improvement" + }, + { + "scenario": "full-turnover", + "baselineMs": 1.5434999942779541, + "candidateMs": 1.7480000019073487, + "absoluteReductionMs": -0.20450000762939458, + "relativeReductionPercent": -13.249109710885307, + "baselineMadMs": 0.06399999856948857, + "candidateMadMs": 0.07900000810623187, + "noiseMs": 0.15800001621246373, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 200, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 200, + "classification": "local_regression" + } + ] +} diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/artifact-map.json b/apps/web/performance/google-marker-reconcile-v2-20260925/artifact-map.json new file mode 100644 index 0000000000..f88133a494 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/artifact-map.json @@ -0,0 +1,14 @@ +{ + "artifacts": { + "baseline-MapView.tsx.txt": "0d13c1b5408c3b7118a76f64b0087293e87393e2ea8361ff621e533e90908da3", + "baseline-effect.mjs": "66c4222684af59f89b1e173602d0b01abaede1a117f78d2f0ea4133bcdbd270b", + "browser-entry.mjs": "caa4eca0b57706f933ca4b38fbdf978558e6d3200a67cdc050e13628a364b91f", + "browser-bundle.mjs": "2b560c0a8242377ccfc72a6512a474dfff749d8e7e67d0c2f4d568be8c3036be", + "candidate-MapView.tsx.txt": "ddcb81ef3f5d71afaac15fe5f6752d56632dd70b2d2c54d8e6e52642cdd62c02", + "candidate-helper.ts.txt": "ed9a4bf17485f79ad9bd6f68fa2e5958e7a6c82d7564a43ff837400e7c322815", + "frozen-tree.patch": "059bdc36a5618cb4b0f05d384a019017c1934d0406932981d2a640a33e4e061e", + "raw.json": "33343e021ec568113eb3bd3b1aef74796febeea5ea423971f2d6d54731f25a2a", + "measure.mjs": "cbeb6d4598e2e23cbe0e9ba0d1cfab09203e828bd27db6f702d66e1bcfa84160", + "score-and-verify.mjs": "bdb7a4a3231a5e9fdbf98fa82c6988e2b4c74a73887a63a0a38dfd468806e2d4" + } +} diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/baseline-MapView.tsx.txt b/apps/web/performance/google-marker-reconcile-v2-20260925/baseline-MapView.tsx.txt new file mode 100644 index 0000000000..262f491255 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/baseline-MapView.tsx.txt @@ -0,0 +1,675 @@ +import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { useGoogleMaps } from "@/hooks/use-google-maps"; +import { useRestaurants } from "@/hooks/use-restaurants"; +import type { Restaurant } from "@/types/restaurant"; +import type { FilterState } from "@/components/filters/filter-state"; +import { MapSkeleton } from "@/components/skeletons/MapSkeleton"; +import { MapViewSurface } from "@/components/map/map-view-surface"; +import { MapViewSidepanelStack } from "@/components/map/map-view-sidepanel-stack"; +import { + MapViewErrorState, + MapViewGoogleLoadErrorState, + MapViewMissingApiKeyState, +} from "@/components/map/map-view-status-panels"; +import { + getMapViewCountryConfig, + getMapViewMarkerIcon, + mergeSearchedRestaurant, +} from "@/lib/map-view-helpers"; +import { + resolveMapViewSelectedPanTarget, + shouldCenterSelectedRestaurant, +} from "@/lib/map-view-movement-helpers"; +import { + applyMapViewMarkerSelectedState, + buildMapViewMarkerHtml, + getMapViewMarkerSize, + isMapViewMarkerSelected, +} from "@/lib/map-view-marker-helpers"; +import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, + getMapViewRestaurantCountToastVisible, +} from "@/lib/map-view-state-helpers"; +import { buildMapViewRestaurantsQueryOptions } from "@/lib/map-query-helpers"; +import { + buildMapViewPanelStateSetter, + buildMapViewPanelCloseHandler, + buildMapViewRestaurantAction, + buildMapViewReviewOpenHandler, + buildMapViewTogglePanelHandler, + resolveMapViewPanelOpenState, + resolveMapViewPanelWidth, +} from "@/lib/map-view-panel-helpers"; +import { buildMapViewPanelWidthObserver } from "@/lib/map-view-panel-width-helpers"; +import { + buildMapViewDetailPanelFocusCaptureHandler, + buildMapViewDetailPanelMouseDownCaptureHandler, + buildMapViewReviewCloseHandler, + buildMapViewReviewSuccessHandler, + shouldShowMapViewDetailPanel, +} from "@/lib/map-view-sidepanel-helpers"; +import { + buildGoogleMapOptions, + getRestaurantLatLng, + panGoogleMapToPosition, +} from "@/lib/map-view-google-helpers"; + +interface MapPointLike { + lat: () => number; + lng: () => number; +} + +interface MapBoundsLike { + getNorthEast: () => MapPointLike; + getSouthWest: () => MapPointLike; +} + +interface GoogleMapLike { + panTo: (position: { lat: number; lng: number }) => void; + setZoom: (zoom: number) => void; + setCenter: (position: { lat: number; lng: number }) => void; + getBounds: () => MapBoundsLike | null; + addListener: (eventName: string, handler: (...args: unknown[]) => void) => unknown; +} + +interface GoogleMarkerLike { + map: GoogleMapLike | null; + content: Element | null; +} + +interface MapMarkerEntry { + marker: GoogleMarkerLike; + restaurantId: string; +} +function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === "string") { + return new Error(error); + } + + if (typeof error === "object" && error !== null) { + try { + const message = JSON.stringify(error); + if (message) { + return new Error(message); + } + } catch { + // Fall through to the safe generic message. + } + } + + return new Error("알 수 없는 오류가 발생했습니다."); +} + +function MapViewErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ; +} + +interface MapViewProps { + filters: FilterState; + selectedCountry?: string | null; + searchedRestaurant?: Restaurant | null; + selectedRestaurant?: Restaurant | null; + refreshTrigger?: number; + onAdminAddRestaurant?: () => void; + onAdminEditRestaurant?: (restaurant: Restaurant) => void; + onRestaurantSelect?: (restaurant: Restaurant | null) => void; + onMapReady?: (moveToRestaurant: (restaurant: Restaurant) => void) => void; + onRequestEditRestaurant?: (restaurant: Restaurant) => void; + // [패널 관리] 마커 클릭, 패널 닫기 등 콜백 + onMarkerClick?: (restaurant: Restaurant) => void; + // [동적 레이아웃] 패널 너비에 따른 지도 중심 오프셋 계산용 + panelWidth?: number; + activePanel?: 'map' | 'detail' | 'control'; + onPanelClick?: (panel: 'map' | 'detail' | 'control') => void; + isPanelOpen?: boolean; + onPanelClose?: () => void; + onTogglePanelCollapse?: () => void; +} + +const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRestaurant, refreshTrigger, onAdminAddRestaurant, onAdminEditRestaurant, onRestaurantSelect, onMapReady, onRequestEditRestaurant, onMarkerClick, panelWidth: propPanelWidth, activePanel, onPanelClick, isPanelOpen: propIsPanelOpen, onPanelClose, onTogglePanelCollapse }: MapViewProps) => { + // 필터 객체 메모이제이션 + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); + const markersRef = useRef([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + + // [상태] 지도 이동 - 선택된 맛집으로 이동 (한 번만 수행) + const lastCenteredRestaurantId = useRef(null); + + // 패널 상태 관리 (내부적으로 관리하거나 props로 제어) + const [mapBounds, setMapBounds] = useState(null); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [panelWidth, setPanelWidth] = useState(0); + const [showRestaurantCount, setShowRestaurantCount] = useState(false); + const [localIsPanelOpen, setLocalIsPanelOpen] = useState(false); + const [hasGoogleRuntimeError, setHasGoogleRuntimeError] = useState(false); + const [safeBoundsQuery, setSafeBoundsQuery] = useState>(undefined); + + useEffect(() => { + selectedCountryRef.current = selectedCountry; + }, [selectedCountry]); + + // props로 전달된 isPanelOpen이 있으면 우선 사용, 없으면 로컬 상태 사용 + const isPanelOpen = useMemo( + () => resolveMapViewPanelOpenState({ localIsPanelOpen, propIsPanelOpen }), + [localIsPanelOpen, propIsPanelOpen] + ); + const setIsPanelOpen = useMemo( + () => buildMapViewPanelStateSetter({ onTogglePanelCollapse, setLocalIsPanelOpen }), + [onTogglePanelCollapse] + ); + + const handleDetailPanelMouseDownCapture = useMemo( + () => buildMapViewDetailPanelMouseDownCaptureHandler(onPanelClick), + [onPanelClick] + ); + + const handleDetailPanelFocusCapture = useMemo( + () => buildMapViewDetailPanelFocusCaptureHandler(onPanelClick), + [onPanelClick] + ); + + // props로 전달된 panelWidth가 있으면 우선 사용 + const effectivePanelWidth = useMemo( + () => resolveMapViewPanelWidth({ panelWidth, propPanelWidth }), + [panelWidth, propPanelWidth] + ); + const handlePanelClose = useMemo( + () => buildMapViewPanelCloseHandler({ onPanelClose, setIsPanelOpen }), + [onPanelClose, setIsPanelOpen] + ); + const handleOpenReviewModal = useMemo( + () => buildMapViewReviewOpenHandler(setIsReviewModalOpen), + [] + ); + const handleCloseReviewModal = useMemo( + () => buildMapViewReviewCloseHandler(setIsReviewModalOpen), + [] + ); + const handleTogglePanel = useMemo( + () => buildMapViewTogglePanelHandler({ isPanelOpen, setIsPanelOpen }), + [isPanelOpen, setIsPanelOpen] + ); + const handleEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onAdminEditRestaurant, selectedRestaurant), + [onAdminEditRestaurant, selectedRestaurant] + ); + const handleRequestEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onRequestEditRestaurant, selectedRestaurant), + [onRequestEditRestaurant, selectedRestaurant] + ); + + + // 맛집으로 지도 이동하는 함수 (즉시 실행, 재시도 없음) + const moveToRestaurant = useCallback((restaurant: Restaurant) => { + if (!googleMapRef.current) { + + return; + } + + const position = { lat: Number(restaurant.lat), lng: Number(restaurant.lng) }; + + try { + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current, "resize"), + zoom: 14, + }); + } catch (error) { + console.error('MapView: Error moving to restaurant position:'); + } + }, []); + + // Google Maps API 로드 + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + const { isLoaded, loadError } = useGoogleMaps({ apiKey }); + + // ResizeObserver로 패널 너비 추적 (내부 패널이 있는 경우만) + useEffect(() => { + // props로 panelWidth가 전달되면 ResizeObserver 불필요 + if (propPanelWidth !== undefined || !detailPanelRef.current) return; + + const { observerCallback } = buildMapViewPanelWidthObserver({ + setPanelWidth, + }); + const resizeObserver = new ResizeObserver(observerCallback); + + resizeObserver.observe(detailPanelRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [propPanelWidth]); + + // 외부 콜백에 지도 이동 함수 전달 + useEffect(() => { + if (onMapReady) { + // 부모 컴포넌트에 지도 이동 함수를 전달 + onMapReady(moveToRestaurant); + } + }, [onMapReady, moveToRestaurant]); + + // 검색된 맛집으로 지도 이동 (정확히 중앙) + useEffect(() => { + if (!searchedRestaurant || !isLoaded || !googleMapRef.current) { + return; + } + + const position = getRestaurantLatLng(searchedRestaurant); + if (!position) { + return; + } + + try { + if (!googleMapRef.current) return; + + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current!, "resize"), + zoom: 14, + }); + + // 검색된 맛집 선택 상태로 설정 + if (onRestaurantSelect) { + onRestaurantSelect(searchedRestaurant); + } + } catch (error) { + console.error('MapView: Error moving to searched restaurant position:'); + } + }, [searchedRestaurant, onRestaurantSelect, isLoaded]); + + // [지도 이동] 선택된 맛집으로 이동 (한 번만 수행) + useEffect(() => { + if (!selectedRestaurant || !mapRef.current || !isLoaded) return; + + // 이미 이 맛집으로 이동했다면 건너뛰기 (사용자가 지도를 움직일 수 있게 함) + // 단, selectedRestaurant 객체가 아예 바뀌었더라도 ID가 같다면 이동하지 않음 + if (!shouldCenterSelectedRestaurant({ + lastCenteredRestaurantId: lastCenteredRestaurantId.current, + selectedRestaurantId: selectedRestaurant.id, + })) { + return; + } + + const position = getRestaurantLatLng(selectedRestaurant); + if (!position) { + return; + } + + try { + const map = googleMapRef.current; + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + // 지도의 실제 너비 계산 + const mapWidth = mapRef.current?.offsetWidth || 0; + const sidebarWidth = 0; // GlobalMapPage에는 사이드바 없음 + + const adjustedLng = resolveMapViewSelectedPanTarget({ + boundsNorthEastLng: bounds.getNorthEast().lng(), + boundsSouthWestLng: bounds.getSouthWest().lng(), + lng: position.lng, + mapWidth, + panelWidth: effectivePanelWidth, + sidebarWidth, + }); + + // 지도 이동 + map.setCenter({ lat: position.lat, lng: adjustedLng }); + + // 이동 완료 표시 + lastCenteredRestaurantId.current = selectedRestaurant.id; + } catch (error) { + console.error('MapView: Error moving to selected restaurant:'); + } + }, [selectedRestaurant, isLoaded, effectivePanelWidth]); + + // 선택 해제 시 ref 초기화 + useEffect(() => { + if (!selectedRestaurant) { + lastCenteredRestaurantId.current = null; + } + }, [selectedRestaurant]); + + useEffect(() => { + if (!mapBounds) { + setSafeBoundsQuery(undefined); + return; + } + try { + setSafeBoundsQuery(buildMapViewBoundsQuery(mapBounds)); + } catch (error) { + console.warn('MapView: keeping previous valid bounds after bounds query failure'); + } + }, [mapBounds]); + + // useRestaurants 옵션 메모이제이션 + const restaurantsOptions = useMemo(() => buildMapViewRestaurantsQueryOptions({ + bounds: safeBoundsQuery, + filters: memoizedFilters, + isLoaded, + selectedCountry, + }), [safeBoundsQuery, memoizedFilters, selectedCountry, isLoaded]); + + const { data: restaurants = [], isLoading: isLoadingRestaurants, refetch } = useRestaurants(restaurantsOptions); + const handleReviewSuccess = useMemo( + () => buildMapViewReviewSuccessHandler(refetch), + [refetch] + ); + + // 맛집 개수 표시 자동 숨김 처리 + useEffect(() => { + if (getMapViewRestaurantCountToastVisible(restaurants.length, isLoadingRestaurants)) { + setShowRestaurantCount(true); + const timer = setTimeout(() => { + setShowRestaurantCount(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [restaurants, isLoadingRestaurants]); + + // 마커를 표시할 맛집 목록 (기존 restaurants + 검색된 맛집) + const restaurantsToShow = useMemo(() => { + return mergeSearchedRestaurant(restaurants, searchedRestaurant ?? null); + }, [restaurants, searchedRestaurant]); + const restaurantsById = useMemo(() => { + return new Map(restaurantsToShow.map((restaurant) => [restaurant.id, restaurant])); + }, [restaurantsToShow]); + + + // [데이터 갱신] refreshTrigger 변경 시 재조회 (리뷰 작성 등) + useEffect(() => { + if (refreshTrigger) { + refetch(); + } + }, [refreshTrigger, refetch]); + + useEffect(() => { + if (!isLoaded || !mapRef.current) return; + + let attempts = 0; + const interval = window.setInterval(() => { + attempts += 1; + const mapText = mapRef.current?.innerText ?? ''; + if (mapText.includes("This page didn't load Google Maps correctly")) { + setHasGoogleRuntimeError(true); + window.clearInterval(interval); + } + if (attempts >= 6) { + window.clearInterval(interval); + } + }, 500); + + return () => window.clearInterval(interval); + }, [isLoaded]); + + + // refreshTrigger 변경 시 선택된 레스토랑 정보 업데이트 + useEffect(() => { + const updatedRestaurant = findUpdatedSelectedRestaurant(restaurants, selectedRestaurant); + if (updatedRestaurant) { + onRestaurantSelect?.(updatedRestaurant); + // [수정] 화면 밖으로 벗어나서 리스트에 없더라도 패널을 닫지 않음 + // else if (!updatedRestaurant) { + // onRestaurantSelect?.(null); + // } + } + }, [restaurants, refreshTrigger, selectedRestaurant, onRestaurantSelect]); + + + // [지도 초기화] Google Maps 인스턴스 생성 + useEffect(() => { + if (!isLoaded || !mapRef.current) { + return; + } + + // [유효성 검사] Google Maps API 로드 확인 + if (!window.google || !window.google.maps || !window.google.maps.Map) { + return; + } + + // 선택된 국가에 따라 중심점과 줌 설정 (기본값: 미국) + const initialSelectedCountry = selectedCountryRef.current; + const countryConfig = getMapViewCountryConfig(initialSelectedCountry); + const center = { lat: countryConfig.lat, lng: countryConfig.lng }; + const zoom = countryConfig.zoom; + + try { + const map = new google.maps.Map(mapRef.current, buildGoogleMapOptions({ center, zoom })); + + googleMapRef.current = map; + + // [이동 감지] 지도 이동 종료 시 경계값 업데이트 + map.addListener("idle", () => { + const bounds = map.getBounds(); + if (bounds) { + setMapBounds(bounds); + } + }); + } catch (error) { + console.error("Error creating Google Map:"); + } + }, [isLoaded]); + + // [국가 변경] 선택된 국가에 따라 지도 중심 및 줌 레벨 조정 + useEffect(() => { + if (!googleMapRef.current || !selectedCountry) return; + + // 검색된 맛집이 있으면 국가 중심으로 이동하지 않음 (검색 이동 로직이 우선) + // 단, 검색된 맛집이 현재 선택된 국가와 다를 수 있으므로 주의 필요하지만, + // handleRestaurantSearch에서 이미 국가를 맞춰주므로 여기서는 이동만 막으면 됨 + if (searchedRestaurant) return; + + const countryConfig = getMapViewCountryConfig(selectedCountry); + if (countryConfig) { + googleMapRef.current.setCenter({ lat: countryConfig.lat, lng: countryConfig.lng }); + googleMapRef.current.setZoom(countryConfig.zoom); + } + }, [selectedCountry, searchedRestaurant]); + + // [재시도 로직] 지도 로드 실패 시 재시도 (API 지연 로드 대응) + useEffect(() => { + if (!isLoaded && window.google && window.google.maps && window.google.maps.Map && mapRef.current && !googleMapRef.current) { + // 강제 재로드 실행 + setTimeout(() => { + window.location.reload(); + }, 500); + } + }, [isLoaded]); + + // [마커 관리] 맛집 데이터 변경 시 마커 업데이트 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + // 기존 마커 제거 (메모리 누수 방지) + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + + // 새 마커 생성 + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name, + }); + + let marker: GoogleMarkerLike; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name, + }); + } catch { + console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); + return; + } + + markerElement.addEventListener("click", () => { + + + // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) + onMarkerClick?.(restaurant); + + + // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); + + + // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); + + }); + + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) { + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + markerElement.classList.toggle('selected-marker', isSelected); + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, [selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById, isLoaded]); + + // 줌 이벤트 시 마커 스타일 유지 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const handleZoomChange = () => { + // 줌 변경 후 약간의 지연을 주어 마커 스타일 재적용 + setTimeout(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) return; + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, 100); + }; + + // 줌 변경 이벤트 리스너 추가 + const zoomListener = googleMapRef.current.addListener('zoom_changed', handleZoomChange); + + return () => { + if (zoomListener) { + google.maps.event.removeListener(zoomListener); + } + }; + }, [isLoaded, selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById]); + + if (!apiKey) { + return ; + } + + if (loadError || hasGoogleRuntimeError) { + return ; + } + + if (!isLoaded) { + return ; + } + + return ( + +
+ + + {shouldShowMapViewDetailPanel({ onMarkerClick, selectedRestaurant }) && ( + + )} +
+
+ ); +}); + +MapView.displayName = 'MapView'; + +export default MapView; diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/baseline-effect.mjs b/apps/web/performance/google-marker-reconcile-v2-20260925/baseline-effect.mjs new file mode 100644 index 0000000000..3889bc2cf4 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/baseline-effect.mjs @@ -0,0 +1,70 @@ +import { getRestaurantLatLng } from '../../lib/map-view-google-helpers'; +import { getMapViewMarkerIcon } from '../../lib/map-view-helpers'; +import { buildMapViewMarkerHtml, getMapViewMarkerSize, isMapViewMarkerSelected } from '../../lib/map-view-marker-helpers'; +export function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = { current: previous }, restaurantsToShow = restaurants; + const googleMapRef = { current: map }, isLoaded = true; + const google = { maps: { marker: { AdvancedMarkerElement: Marker } } }; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => { }, setHasGoogleRuntimeError = () => { }; + if (!googleMapRef.current || !isLoaded) + return; + // 기존 마커 제거 (메모리 누수 방지) + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + // 새 마커 생성 + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); + return; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name, + }); + let marker; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name, + }); + } + catch { + console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); + return; + } + markerElement.addEventListener("click", () => { + // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) + onMarkerClick?.(restaurant); + // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); + // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); + }); + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + return markersRef.current; +} diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/browser-bundle.mjs b/apps/web/performance/google-marker-reconcile-v2-20260925/browser-bundle.mjs new file mode 100644 index 0000000000..710ceafc33 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/browser-bundle.mjs @@ -0,0 +1,292 @@ +// lib/map-view-google-helpers.ts +function getRestaurantLatLng(restaurant) { + if (!restaurant) + return null; + const lat = Number(restaurant.lat); + const lng = Number(restaurant.lng); + if (Number.isNaN(lat) || Number.isNaN(lng)) { + return null; + } + return { lat, lng }; +} + +// lib/map-view-helpers.ts +function getMapViewMarkerIcon(categories) { + if (!categories) + return "/images/maker-images/webp/korean.webp"; + const category = Array.isArray(categories) ? categories[0] : categories; + const imageMap = { + 고기: "/images/maker-images/webp/meat_bbq.webp", + 치킨: "/images/maker-images/webp/chicken.webp", + 한식: "/images/maker-images/webp/korean.webp", + 중식: "/images/maker-images/webp/chinese.webp", + 일식: "/images/maker-images/webp/cutlet_sashimi.webp", + 양식: "/images/maker-images/webp/western.webp", + 분식: "/images/maker-images/webp/snack_bar.webp", + "카페·디저트": "/images/maker-images/webp/cafe_dessert.webp", + 아시안: "/images/maker-images/webp/asian.webp", + 패스트푸드: "/images/maker-images/webp/fastfood.webp", + "족발·보쌈": "/images/maker-images/webp/pork_feet.webp", + "돈까스·회": "/images/maker-images/webp/cutlet_sashimi.webp", + 피자: "/images/maker-images/webp/pizza.webp", + "찜·탕": "/images/maker-images/webp/stew.webp", + 야식: "/images/maker-images/webp/late_night.webp", + 도시락: "/images/maker-images/webp/lunch_box.webp" + }; + return imageMap[category] || "/images/maker-images/webp/korean.webp"; +} + +// lib/html-escape.ts +var MARKER_IMAGE_FALLBACK = "/images/maker-images/webp/chicken.webp"; +var MAX_MARKER_IMAGE_URL_LENGTH = 2048; +var MAX_PERCENT_DECODE_PASSES = 4; +var CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F-\u009F]/; +var ROOT_URL = "https://marker.invalid"; +function decodeForMarkerImageSafety(value) { + let decodedValue = value; + for (let pass = 0;pass < MAX_PERCENT_DECODE_PASSES; pass += 1) { + if (CONTROL_CHARACTER_PATTERN.test(decodedValue) || decodedValue.includes("\\")) { + return null; + } + if (!decodedValue.includes("%")) { + return decodedValue; + } + try { + const nextValue = decodeURIComponent(decodedValue); + if (nextValue === decodedValue) { + return decodedValue; + } + decodedValue = nextValue; + } catch { + return null; + } + } + return null; +} +function hasUnsafeDecodedMarkerPath(value) { + const decodedPath = decodeForMarkerImageSafety(value); + return decodedPath === null || decodedPath.includes("//") || decodedPath.split("/").some((segment) => segment === "." || segment === ".."); +} +function isCanonicalRootRelativeMarkerPath(value) { + if (!value.startsWith("/") || value.startsWith("//") || value.includes("?") || value.includes("#")) { + return false; + } + if (hasUnsafeDecodedMarkerPath(value)) { + return false; + } + try { + const url = new URL(value, ROOT_URL); + return url.origin === ROOT_URL && url.pathname === value && url.search === "" && url.hash === ""; + } catch { + return false; + } +} +function isCanonicalHttpsMarkerUrl(value) { + if (decodeForMarkerImageSafety(value) === null) { + return false; + } + try { + const url = new URL(value); + return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname !== "" && url.href === value && !hasUnsafeDecodedMarkerPath(url.pathname); + } catch { + return false; + } +} +function sanitizeMarkerImageUrl(value) { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_MARKER_IMAGE_URL_LENGTH || CONTROL_CHARACTER_PATTERN.test(value) || value.includes("\\")) { + return MARKER_IMAGE_FALLBACK; + } + if (isCanonicalRootRelativeMarkerPath(value) || isCanonicalHttpsMarkerUrl(value)) { + return value; + } + return MARKER_IMAGE_FALLBACK; +} +function escapeHtmlAttribute(value) { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + default: + return char; + } + }); +} + +// lib/map-view-marker-helpers.ts +var MAP_VIEW_MARKER_BASE_SIZE_CLASSES = ["h-8", "w-8"]; +var MAP_VIEW_MARKER_SELECTED_SIZE_CLASSES = ["h-[42px]", "w-[42px]"]; +function isMapViewMarkerSelected({ + restaurantId, + searchedRestaurantId, + selectedRestaurantId +}) { + return selectedRestaurantId === restaurantId || searchedRestaurantId === restaurantId; +} +function getMapViewMarkerSize(isSelected) { + return isSelected ? 42 : 32; +} +function buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name +}) { + const expectedMarkerSize = getMapViewMarkerSize(isSelected); + const normalizedMarkerSize = Number.isFinite(markerSize) && markerSize === expectedMarkerSize ? markerSize : expectedMarkerSize; + const markerSizeClasses = normalizedMarkerSize === 42 ? MAP_VIEW_MARKER_SELECTED_SIZE_CLASSES : MAP_VIEW_MARKER_BASE_SIZE_CLASSES; + const safeImagePath = escapeHtmlAttribute(sanitizeMarkerImageUrl(imagePath)); + const safeName = escapeHtmlAttribute(name); + return ` +
+ ${safeName} +
+ `; +} + +// performance/google-marker-reconcile-v2-20260925/baseline-effect.mjs +function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = { current: previous }, restaurantsToShow = restaurants; + const googleMapRef = { current: map }, isLoaded = true; + const google = { maps: { marker: { AdvancedMarkerElement: Marker } } }; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => {}, setHasGoogleRuntimeError = () => {}; + if (!googleMapRef.current || !isLoaded) + return; + markersRef.current.forEach(({ marker }) => { + marker.map = null; + }); + markersRef.current = []; + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn("MapView: Advanced marker support unavailable"); + setHasGoogleRuntimeError(true); + return; + } + restaurantsToShow.forEach((restaurant) => { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + console.warn("MapView: marker skipped because restaurant coordinates are invalid", { restaurantId: restaurant.id }); + return; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id + }); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const markerSize = getMapViewMarkerSize(isSelected); + const markerElement = document.createElement("div"); + markerElement.className = `custom-marker ${isSelected ? "selected-marker" : ""}`; + markerElement.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize, + name: restaurant.name + }); + let marker; + try { + marker = new markerConstructor({ + map: googleMapRef.current, + position, + content: markerElement, + title: restaurant.name + }); + } catch { + console.warn("MapView: Advanced marker creation skipped", { restaurantId: restaurant.id }); + return; + } + markerElement.addEventListener("click", () => { + onMarkerClick?.(restaurant); + onRestaurantSelect?.(restaurant); + moveToRestaurant(restaurant); + }); + markersRef.current.push({ marker, restaurantId: restaurant.id }); + }); + return markersRef.current; +} +// lib/map-view-marker-reconciliation.ts +function reconcileMapViewMarkers({ + previous, + restaurants, + createMarker, + onActivate, + selectedRestaurantId, + searchedRestaurantId +}) { + const remaining = new Map(previous.map((entry) => [entry.restaurantId, entry])); + const next = []; + const seen = new Set; + for (const restaurant of restaurants) { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng) || seen.has(restaurant.id)) + continue; + seen.add(restaurant.id); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const retained = remaining.get(restaurant.id); + if (retained) { + remaining.delete(restaurant.id); + if (retained.position.lat !== position.lat || retained.position.lng !== position.lng) { + retained.marker.position = position; + retained.position = position; + } + if (retained.imagePath !== imagePath || retained.name !== restaurant.name) { + const image = retained.marker.content?.querySelector("img"); + if (retained.imagePath !== imagePath) + image?.setAttribute("src", sanitizeMarkerImageUrl(imagePath)); + if (retained.name !== restaurant.name) { + image?.setAttribute("alt", restaurant.name); + retained.marker.title = restaurant.name; + } + retained.imagePath = imagePath; + retained.name = restaurant.name; + } + retained.restaurant = restaurant; + retained.onActivate = onActivate; + next.push(retained); + continue; + } + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + selectedRestaurantId, + searchedRestaurantId + }); + const content = document.createElement("div"); + content.className = `custom-marker ${isSelected ? "selected-marker" : ""}`; + content.innerHTML = buildMapViewMarkerHtml({ + imagePath, + isSelected, + markerSize: getMapViewMarkerSize(isSelected), + name: restaurant.name + }); + const marker = createMarker({ position, content, title: restaurant.name }); + if (!marker) + continue; + const entry = { + marker, + restaurantId: restaurant.id, + restaurant, + position, + imagePath, + name: restaurant.name, + onActivate + }; + content.addEventListener("click", () => entry.onActivate(entry.restaurant)); + next.push(entry); + } + for (const entry of remaining.values()) + entry.marker.map = null; + return next; +} +export { + baselineUpdate, + reconcileMapViewMarkers +}; diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/browser-entry.mjs b/apps/web/performance/google-marker-reconcile-v2-20260925/browser-entry.mjs new file mode 100644 index 0000000000..d7af44a62d --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/browser-entry.mjs @@ -0,0 +1,3 @@ + +export { baselineUpdate } from './baseline-effect.mjs'; +export { reconcileMapViewMarkers } from '../../lib/map-view-marker-reconciliation'; diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/candidate-MapView.tsx.txt b/apps/web/performance/google-marker-reconcile-v2-20260925/candidate-MapView.tsx.txt new file mode 100644 index 0000000000..11f0804a8e --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/candidate-MapView.tsx.txt @@ -0,0 +1,632 @@ +import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; +import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; +import { useGoogleMaps } from "@/hooks/use-google-maps"; +import { useRestaurants } from "@/hooks/use-restaurants"; +import type { Restaurant } from "@/types/restaurant"; +import type { FilterState } from "@/components/filters/filter-state"; +import { MapSkeleton } from "@/components/skeletons/MapSkeleton"; +import { MapViewSurface } from "@/components/map/map-view-surface"; +import { MapViewSidepanelStack } from "@/components/map/map-view-sidepanel-stack"; +import { + MapViewErrorState, + MapViewGoogleLoadErrorState, + MapViewMissingApiKeyState, +} from "@/components/map/map-view-status-panels"; +import { + getMapViewCountryConfig, + mergeSearchedRestaurant, +} from "@/lib/map-view-helpers"; +import { + resolveMapViewSelectedPanTarget, + shouldCenterSelectedRestaurant, +} from "@/lib/map-view-movement-helpers"; +import { + applyMapViewMarkerSelectedState, + isMapViewMarkerSelected, +} from "@/lib/map-view-marker-helpers"; +import { reconcileMapViewMarkers, type MapViewMarkerEntry } from "@/lib/map-view-marker-reconciliation"; +import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, + getMapViewRestaurantCountToastVisible, +} from "@/lib/map-view-state-helpers"; +import { buildMapViewRestaurantsQueryOptions } from "@/lib/map-query-helpers"; +import { + buildMapViewPanelStateSetter, + buildMapViewPanelCloseHandler, + buildMapViewRestaurantAction, + buildMapViewReviewOpenHandler, + buildMapViewTogglePanelHandler, + resolveMapViewPanelOpenState, + resolveMapViewPanelWidth, +} from "@/lib/map-view-panel-helpers"; +import { buildMapViewPanelWidthObserver } from "@/lib/map-view-panel-width-helpers"; +import { + buildMapViewDetailPanelFocusCaptureHandler, + buildMapViewDetailPanelMouseDownCaptureHandler, + buildMapViewReviewCloseHandler, + buildMapViewReviewSuccessHandler, + shouldShowMapViewDetailPanel, +} from "@/lib/map-view-sidepanel-helpers"; +import { + buildGoogleMapOptions, + getRestaurantLatLng, + panGoogleMapToPosition, +} from "@/lib/map-view-google-helpers"; + +interface MapPointLike { + lat: () => number; + lng: () => number; +} + +interface MapBoundsLike { + getNorthEast: () => MapPointLike; + getSouthWest: () => MapPointLike; +} + +interface GoogleMapLike { + panTo: (position: { lat: number; lng: number }) => void; + setZoom: (zoom: number) => void; + setCenter: (position: { lat: number; lng: number }) => void; + getBounds: () => MapBoundsLike | null; + addListener: (eventName: string, handler: (...args: unknown[]) => void) => unknown; +} + +interface GoogleMarkerLike { + map: GoogleMapLike | null; + content: Node | null; + position?: unknown; + title?: string; +} +function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + if (typeof error === "string") { + return new Error(error); + } + + if (typeof error === "object" && error !== null) { + try { + const message = JSON.stringify(error); + if (message) { + return new Error(message); + } + } catch { + // Fall through to the safe generic message. + } + } + + return new Error("알 수 없는 오류가 발생했습니다."); +} + +function MapViewErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ; +} + +interface MapViewProps { + filters: FilterState; + selectedCountry?: string | null; + searchedRestaurant?: Restaurant | null; + selectedRestaurant?: Restaurant | null; + refreshTrigger?: number; + onAdminAddRestaurant?: () => void; + onAdminEditRestaurant?: (restaurant: Restaurant) => void; + onRestaurantSelect?: (restaurant: Restaurant | null) => void; + onMapReady?: (moveToRestaurant: (restaurant: Restaurant) => void) => void; + onRequestEditRestaurant?: (restaurant: Restaurant) => void; + // [패널 관리] 마커 클릭, 패널 닫기 등 콜백 + onMarkerClick?: (restaurant: Restaurant) => void; + // [동적 레이아웃] 패널 너비에 따른 지도 중심 오프셋 계산용 + panelWidth?: number; + activePanel?: 'map' | 'detail' | 'control'; + onPanelClick?: (panel: 'map' | 'detail' | 'control') => void; + isPanelOpen?: boolean; + onPanelClose?: () => void; + onTogglePanelCollapse?: () => void; +} + +const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRestaurant, refreshTrigger, onAdminAddRestaurant, onAdminEditRestaurant, onRestaurantSelect, onMapReady, onRequestEditRestaurant, onMarkerClick, panelWidth: propPanelWidth, activePanel, onPanelClick, isPanelOpen: propIsPanelOpen, onPanelClose, onTogglePanelCollapse }: MapViewProps) => { + // 필터 객체 메모이제이션 + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); + const markersRef = useRef[]>([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + + // [상태] 지도 이동 - 선택된 맛집으로 이동 (한 번만 수행) + const lastCenteredRestaurantId = useRef(null); + + // 패널 상태 관리 (내부적으로 관리하거나 props로 제어) + const [mapBounds, setMapBounds] = useState(null); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [panelWidth, setPanelWidth] = useState(0); + const [showRestaurantCount, setShowRestaurantCount] = useState(false); + const [localIsPanelOpen, setLocalIsPanelOpen] = useState(false); + const [hasGoogleRuntimeError, setHasGoogleRuntimeError] = useState(false); + const [safeBoundsQuery, setSafeBoundsQuery] = useState>(undefined); + + useEffect(() => { + selectedCountryRef.current = selectedCountry; + }, [selectedCountry]); + + // props로 전달된 isPanelOpen이 있으면 우선 사용, 없으면 로컬 상태 사용 + const isPanelOpen = useMemo( + () => resolveMapViewPanelOpenState({ localIsPanelOpen, propIsPanelOpen }), + [localIsPanelOpen, propIsPanelOpen] + ); + const setIsPanelOpen = useMemo( + () => buildMapViewPanelStateSetter({ onTogglePanelCollapse, setLocalIsPanelOpen }), + [onTogglePanelCollapse] + ); + + const handleDetailPanelMouseDownCapture = useMemo( + () => buildMapViewDetailPanelMouseDownCaptureHandler(onPanelClick), + [onPanelClick] + ); + + const handleDetailPanelFocusCapture = useMemo( + () => buildMapViewDetailPanelFocusCaptureHandler(onPanelClick), + [onPanelClick] + ); + + // props로 전달된 panelWidth가 있으면 우선 사용 + const effectivePanelWidth = useMemo( + () => resolveMapViewPanelWidth({ panelWidth, propPanelWidth }), + [panelWidth, propPanelWidth] + ); + const handlePanelClose = useMemo( + () => buildMapViewPanelCloseHandler({ onPanelClose, setIsPanelOpen }), + [onPanelClose, setIsPanelOpen] + ); + const handleOpenReviewModal = useMemo( + () => buildMapViewReviewOpenHandler(setIsReviewModalOpen), + [] + ); + const handleCloseReviewModal = useMemo( + () => buildMapViewReviewCloseHandler(setIsReviewModalOpen), + [] + ); + const handleTogglePanel = useMemo( + () => buildMapViewTogglePanelHandler({ isPanelOpen, setIsPanelOpen }), + [isPanelOpen, setIsPanelOpen] + ); + const handleEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onAdminEditRestaurant, selectedRestaurant), + [onAdminEditRestaurant, selectedRestaurant] + ); + const handleRequestEditSelectedRestaurant = useMemo( + () => buildMapViewRestaurantAction(onRequestEditRestaurant, selectedRestaurant), + [onRequestEditRestaurant, selectedRestaurant] + ); + + + // 맛집으로 지도 이동하는 함수 (즉시 실행, 재시도 없음) + const moveToRestaurant = useCallback((restaurant: Restaurant) => { + if (!googleMapRef.current) { + + return; + } + + const position = { lat: Number(restaurant.lat), lng: Number(restaurant.lng) }; + + try { + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current, "resize"), + zoom: 14, + }); + } catch (error) { + console.error('MapView: Error moving to restaurant position:'); + } + }, []); + + // Google Maps API 로드 + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + const { isLoaded, loadError } = useGoogleMaps({ apiKey }); + + // ResizeObserver로 패널 너비 추적 (내부 패널이 있는 경우만) + useEffect(() => { + // props로 panelWidth가 전달되면 ResizeObserver 불필요 + if (propPanelWidth !== undefined || !detailPanelRef.current) return; + + const { observerCallback } = buildMapViewPanelWidthObserver({ + setPanelWidth, + }); + const resizeObserver = new ResizeObserver(observerCallback); + + resizeObserver.observe(detailPanelRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [propPanelWidth]); + + // 외부 콜백에 지도 이동 함수 전달 + useEffect(() => { + if (onMapReady) { + // 부모 컴포넌트에 지도 이동 함수를 전달 + onMapReady(moveToRestaurant); + } + }, [onMapReady, moveToRestaurant]); + + // 검색된 맛집으로 지도 이동 (정확히 중앙) + useEffect(() => { + if (!searchedRestaurant || !isLoaded || !googleMapRef.current) { + return; + } + + const position = getRestaurantLatLng(searchedRestaurant); + if (!position) { + return; + } + + try { + if (!googleMapRef.current) return; + + panGoogleMapToPosition({ + map: googleMapRef.current, + position, + triggerResize: () => google.maps.event.trigger(googleMapRef.current!, "resize"), + zoom: 14, + }); + + // 검색된 맛집 선택 상태로 설정 + if (onRestaurantSelect) { + onRestaurantSelect(searchedRestaurant); + } + } catch (error) { + console.error('MapView: Error moving to searched restaurant position:'); + } + }, [searchedRestaurant, onRestaurantSelect, isLoaded]); + + // [지도 이동] 선택된 맛집으로 이동 (한 번만 수행) + useEffect(() => { + if (!selectedRestaurant || !mapRef.current || !isLoaded) return; + + // 이미 이 맛집으로 이동했다면 건너뛰기 (사용자가 지도를 움직일 수 있게 함) + // 단, selectedRestaurant 객체가 아예 바뀌었더라도 ID가 같다면 이동하지 않음 + if (!shouldCenterSelectedRestaurant({ + lastCenteredRestaurantId: lastCenteredRestaurantId.current, + selectedRestaurantId: selectedRestaurant.id, + })) { + return; + } + + const position = getRestaurantLatLng(selectedRestaurant); + if (!position) { + return; + } + + try { + const map = googleMapRef.current; + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + // 지도의 실제 너비 계산 + const mapWidth = mapRef.current?.offsetWidth || 0; + const sidebarWidth = 0; // GlobalMapPage에는 사이드바 없음 + + const adjustedLng = resolveMapViewSelectedPanTarget({ + boundsNorthEastLng: bounds.getNorthEast().lng(), + boundsSouthWestLng: bounds.getSouthWest().lng(), + lng: position.lng, + mapWidth, + panelWidth: effectivePanelWidth, + sidebarWidth, + }); + + // 지도 이동 + map.setCenter({ lat: position.lat, lng: adjustedLng }); + + // 이동 완료 표시 + lastCenteredRestaurantId.current = selectedRestaurant.id; + } catch (error) { + console.error('MapView: Error moving to selected restaurant:'); + } + }, [selectedRestaurant, isLoaded, effectivePanelWidth]); + + // 선택 해제 시 ref 초기화 + useEffect(() => { + if (!selectedRestaurant) { + lastCenteredRestaurantId.current = null; + } + }, [selectedRestaurant]); + + useEffect(() => { + if (!mapBounds) { + setSafeBoundsQuery(undefined); + return; + } + try { + setSafeBoundsQuery(buildMapViewBoundsQuery(mapBounds)); + } catch (error) { + console.warn('MapView: keeping previous valid bounds after bounds query failure'); + } + }, [mapBounds]); + + // useRestaurants 옵션 메모이제이션 + const restaurantsOptions = useMemo(() => buildMapViewRestaurantsQueryOptions({ + bounds: safeBoundsQuery, + filters: memoizedFilters, + isLoaded, + selectedCountry, + }), [safeBoundsQuery, memoizedFilters, selectedCountry, isLoaded]); + + const { data: restaurants = [], isLoading: isLoadingRestaurants, refetch } = useRestaurants(restaurantsOptions); + const handleReviewSuccess = useMemo( + () => buildMapViewReviewSuccessHandler(refetch), + [refetch] + ); + + // 맛집 개수 표시 자동 숨김 처리 + useEffect(() => { + if (getMapViewRestaurantCountToastVisible(restaurants.length, isLoadingRestaurants)) { + setShowRestaurantCount(true); + const timer = setTimeout(() => { + setShowRestaurantCount(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [restaurants, isLoadingRestaurants]); + + // 마커를 표시할 맛집 목록 (기존 restaurants + 검색된 맛집) + const restaurantsToShow = useMemo(() => { + return mergeSearchedRestaurant(restaurants, searchedRestaurant ?? null); + }, [restaurants, searchedRestaurant]); + const restaurantsById = useMemo(() => { + return new Map(restaurantsToShow.map((restaurant) => [restaurant.id, restaurant])); + }, [restaurantsToShow]); + + + // [데이터 갱신] refreshTrigger 변경 시 재조회 (리뷰 작성 등) + useEffect(() => { + if (refreshTrigger) { + refetch(); + } + }, [refreshTrigger, refetch]); + + useEffect(() => { + if (!isLoaded || !mapRef.current) return; + + let attempts = 0; + const interval = window.setInterval(() => { + attempts += 1; + const mapText = mapRef.current?.innerText ?? ''; + if (mapText.includes("This page didn't load Google Maps correctly")) { + setHasGoogleRuntimeError(true); + window.clearInterval(interval); + } + if (attempts >= 6) { + window.clearInterval(interval); + } + }, 500); + + return () => window.clearInterval(interval); + }, [isLoaded]); + + + // refreshTrigger 변경 시 선택된 레스토랑 정보 업데이트 + useEffect(() => { + const updatedRestaurant = findUpdatedSelectedRestaurant(restaurants, selectedRestaurant); + if (updatedRestaurant) { + onRestaurantSelect?.(updatedRestaurant); + // [수정] 화면 밖으로 벗어나서 리스트에 없더라도 패널을 닫지 않음 + // else if (!updatedRestaurant) { + // onRestaurantSelect?.(null); + // } + } + }, [restaurants, refreshTrigger, selectedRestaurant, onRestaurantSelect]); + + + // [지도 초기화] Google Maps 인스턴스 생성 + useEffect(() => { + if (!isLoaded || !mapRef.current) { + return; + } + + // [유효성 검사] Google Maps API 로드 확인 + if (!window.google || !window.google.maps || !window.google.maps.Map) { + return; + } + + // 선택된 국가에 따라 중심점과 줌 설정 (기본값: 미국) + const initialSelectedCountry = selectedCountryRef.current; + const countryConfig = getMapViewCountryConfig(initialSelectedCountry); + const center = { lat: countryConfig.lat, lng: countryConfig.lng }; + const zoom = countryConfig.zoom; + + try { + const map = new google.maps.Map(mapRef.current, buildGoogleMapOptions({ center, zoom })); + + googleMapRef.current = map; + + // [이동 감지] 지도 이동 종료 시 경계값 업데이트 + map.addListener("idle", () => { + const bounds = map.getBounds(); + if (bounds) { + setMapBounds(bounds); + } + }); + } catch (error) { + console.error("Error creating Google Map:"); + } + }, [isLoaded]); + + // [국가 변경] 선택된 국가에 따라 지도 중심 및 줌 레벨 조정 + useEffect(() => { + if (!googleMapRef.current || !selectedCountry) return; + + // 검색된 맛집이 있으면 국가 중심으로 이동하지 않음 (검색 이동 로직이 우선) + // 단, 검색된 맛집이 현재 선택된 국가와 다를 수 있으므로 주의 필요하지만, + // handleRestaurantSearch에서 이미 국가를 맞춰주므로 여기서는 이동만 막으면 됨 + if (searchedRestaurant) return; + + const countryConfig = getMapViewCountryConfig(selectedCountry); + if (countryConfig) { + googleMapRef.current.setCenter({ lat: countryConfig.lat, lng: countryConfig.lng }); + googleMapRef.current.setZoom(countryConfig.zoom); + } + }, [selectedCountry, searchedRestaurant]); + + // [재시도 로직] 지도 로드 실패 시 재시도 (API 지연 로드 대응) + useEffect(() => { + if (!isLoaded && window.google && window.google.maps && window.google.maps.Map && mapRef.current && !googleMapRef.current) { + // 강제 재로드 실행 + setTimeout(() => { + window.location.reload(); + }, 500); + } + }, [isLoaded]); + + // [마커 관리] 맛집 데이터 변경 시 마커 업데이트 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); + setHasGoogleRuntimeError(true); + return; + } + + markersRef.current = reconcileMapViewMarkers({ + previous: markersRef.current, + restaurants: restaurantsToShow, + selectedRestaurantId: selectedRestaurant?.id, + searchedRestaurantId: searchedRestaurant?.id, + createMarker: (options) => { + try { + return new markerConstructor({ ...options, map: googleMapRef.current }); + } catch { + console.warn('MapView: Advanced marker creation skipped'); + return null; + } + }, + onActivate: (restaurant) => { + onMarkerClick?.(restaurant); + onRestaurantSelect?.(restaurant); + moveToRestaurant(restaurant); + }, + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + + useEffect(() => () => { + for (const { marker } of markersRef.current) marker.map = null; + markersRef.current = []; + }, []); + + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) { + return; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + markerElement.classList.toggle('selected-marker', isSelected); + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, [selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById, isLoaded]); + + // 줌 이벤트 시 마커 스타일 유지 + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + + const handleZoomChange = () => { + // 줌 변경 후 약간의 지연을 주어 마커 스타일 재적용 + setTimeout(() => { + if (!isLoaded || markersRef.current.length === 0) return; + + markersRef.current.forEach(({ marker, restaurantId }) => { + const restaurant = restaurantsById.get(restaurantId); + if (!restaurant) return; + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, + searchedRestaurantId: searchedRestaurant?.id, + selectedRestaurantId: selectedRestaurant?.id, + }); + const markerElement = marker.content as HTMLElement; + if (!markerElement) return; + applyMapViewMarkerSelectedState({ isSelected, markerElement }); + }); + }, 100); + }; + + // 줌 변경 이벤트 리스너 추가 + const zoomListener = googleMapRef.current.addListener('zoom_changed', handleZoomChange); + + return () => { + if (zoomListener) { + google.maps.event.removeListener(zoomListener); + } + }; + }, [isLoaded, selectedRestaurant?.id, searchedRestaurant?.id, restaurantsById]); + + if (!apiKey) { + return ; + } + + if (loadError || hasGoogleRuntimeError) { + return ; + } + + if (!isLoaded) { + return ; + } + + return ( + +
+ + + {shouldShowMapViewDetailPanel({ onMarkerClick, selectedRestaurant }) && ( + + )} +
+
+ ); +}); + +MapView.displayName = 'MapView'; + +export default MapView; diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/candidate-helper.ts.txt b/apps/web/performance/google-marker-reconcile-v2-20260925/candidate-helper.ts.txt new file mode 100644 index 0000000000..f4d0ea3488 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/candidate-helper.ts.txt @@ -0,0 +1,105 @@ +import type { Restaurant } from '../types/restaurant'; +import { sanitizeMarkerImageUrl } from './html-escape'; +import { getRestaurantLatLng } from './map-view-google-helpers'; +import { getMapViewMarkerIcon } from './map-view-helpers'; +import { + buildMapViewMarkerHtml, + getMapViewMarkerSize, + isMapViewMarkerSelected, +} from './map-view-marker-helpers'; + +export interface ManagedMapViewMarker { + map: unknown; + content: Node | null; + position?: unknown; + title?: string; +} + +export interface MapViewMarkerEntry { + marker: M; + restaurantId: string; + restaurant: Restaurant; + position: { lat: number; lng: number }; + imagePath: string; + name: string; + onActivate: (restaurant: Restaurant) => void; +} + +// Reconcile in O(previous + next) time. Retained IDs keep their marker, image, +// and click listener; only added/removed IDs attach/detach provider objects. +export function reconcileMapViewMarkers({ + previous, + restaurants, + createMarker, + onActivate, + selectedRestaurantId, + searchedRestaurantId, +}: { + previous: readonly MapViewMarkerEntry[]; + restaurants: readonly Restaurant[]; + createMarker: (options: { + position: { lat: number; lng: number }; + content: HTMLElement; + title: string; + }) => M | null; + onActivate: (restaurant: Restaurant) => void; + selectedRestaurantId?: string | null; + searchedRestaurantId?: string | null; +}): MapViewMarkerEntry[] { + const remaining = new Map(previous.map((entry) => [entry.restaurantId, entry])); + const next: MapViewMarkerEntry[] = []; + const seen = new Set(); + + for (const restaurant of restaurants) { + const position = getRestaurantLatLng(restaurant); + if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng) + || seen.has(restaurant.id)) continue; + seen.add(restaurant.id); + const imagePath = getMapViewMarkerIcon(restaurant.categories); + const retained = remaining.get(restaurant.id); + if (retained) { + remaining.delete(restaurant.id); + if (retained.position.lat !== position.lat || retained.position.lng !== position.lng) { + retained.marker.position = position; + retained.position = position; + } + if (retained.imagePath !== imagePath || retained.name !== restaurant.name) { + const image = (retained.marker.content as HTMLElement | null)?.querySelector('img'); + if (retained.imagePath !== imagePath) image?.setAttribute('src', sanitizeMarkerImageUrl(imagePath)); + if (retained.name !== restaurant.name) { + image?.setAttribute('alt', restaurant.name); + retained.marker.title = restaurant.name; + } + retained.imagePath = imagePath; + retained.name = restaurant.name; + } + // The listener reads these fields at click time, including after a + // same-ID data refresh or a parent callback change. + retained.restaurant = restaurant; + retained.onActivate = onActivate; + next.push(retained); + continue; + } + + const isSelected = isMapViewMarkerSelected({ + restaurantId: restaurant.id, selectedRestaurantId, searchedRestaurantId, + }); + const content = document.createElement('div'); + content.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; + content.innerHTML = buildMapViewMarkerHtml({ + imagePath, isSelected, markerSize: getMapViewMarkerSize(isSelected), name: restaurant.name, + }); + const marker = createMarker({ position, content, title: restaurant.name }); + if (!marker) continue; + const entry: MapViewMarkerEntry = { + marker, restaurantId: restaurant.id, restaurant, position, + imagePath, name: restaurant.name, onActivate, + }; + content.addEventListener('click', () => entry.onActivate(entry.restaurant)); + next.push(entry); + } + + // Keep the old visible markers until replacements have been attached. + for (const entry of remaining.values()) entry.marker.map = null; + return next; +} diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/checks/affected-unit-tests.txt b/apps/web/performance/google-marker-reconcile-v2-20260925/checks/affected-unit-tests.txt new file mode 100644 index 0000000000..9f77bd6ff2 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/checks/affected-unit-tests.txt @@ -0,0 +1,59 @@ +bun test v1.4.0 (34cbb9a40) + +tests-unit/swipe-render-cache.test.ts: +(pass) swipe render cache correctness and capacity > invalidates search ordering when the same search id gets different merged ids [0.57ms] +(pass) swipe render cache correctness and capacity > invalidates ordering when the same search id gets a new name or coordinates [0.03ms] +(pass) swipe render cache correctness and capacity > detects an in-place edit of the active search merged ids [0.03ms] +(pass) swipe render cache correctness and capacity > recomputes the nearest fallback when search coordinates change with the same id [0.06ms] +(pass) swipe render cache correctness and capacity > recomputes fallback exclusion when the visible selection changes with the same id [0.06ms] +(pass) swipe render cache correctness and capacity > bounds ordering history to 64 searches per visible list [0.23ms] +(pass) swipe render cache correctness and capacity > bounds fallback history to 64 searches per catalog [0.39ms] +(pass) swipe render cache correctness and capacity > preserves the order within both matched and unmatched groups [0.09ms] +(pass) swipe render cache correctness and capacity > preserves empty, unavailable, invalid-coordinate and tie behavior [0.97ms] +(pass) swipe render cache correctness and capacity > matches an uncached reference across 2,000 deterministic selection changes [97.70ms] + +tests-unit/mobile-home-search-selection.test.ts: +(pass) mobile home search selection helpers > treats merged or coordinate-matched restaurants as the same selection [0.11ms] +(pass) mobile home search selection helpers > keeps searched restaurant active only while it still matches the selected restaurant [0.05ms] +(pass) mobile home search selection helpers > handles a searched restaurant only once until search state is cleared [0.03ms] +(pass) mobile home search selection helpers > releases search ownership while keeping the current panel selection open [0.02ms] +(pass) mobile home search selection helpers > plans search selection release only once for an active searched restaurant [0.02ms] +(pass) mobile home search selection helpers > resets released search selection id when active search changes or clears [0.02ms] +(pass) mobile home search selection helpers > adds active searched restaurant to swipe candidates when it is outside display ids [0.09ms] +(pass) mobile home search selection helpers > does not duplicate active searched restaurant already present in display ids [0.01ms] +(pass) mobile home search selection helpers > keeps selected search result in swipe candidates after search ownership is released [0.02ms] +(pass) mobile home search selection helpers > does not duplicate selected restaurant that matches an existing display restaurant [0.02ms] +(pass) mobile home search selection helpers > adds the nearest fallback restaurant when only one visible restaurant remains after search [0.20ms] +(pass) mobile home search selection helpers > reuses the nearest fallback when the list and visible restaurant are unchanged [0.34ms] +(pass) mobile home search selection helpers > reuses the searched swipe order for the same visible list [0.23ms] + +tests-unit/mobile-home-map-regressions.test.ts: +(pass) mobile home map regression guards > search-selected restaurant can still swipe to a different visible restaurant [0.07ms] +(pass) mobile home map regression guards > marker render is not skipped when another marker takes over after a search selection [0.15ms] +(pass) mobile home map regression guards > marker render is not skipped when the sheet closes and a new marker opens with cleared search state [0.02ms] +(pass) mobile home map regression guards > single-visible post-search swipe falls back to the nearest restaurant [0.07ms] +(pass) mobile home map regression guards > search ownership release keeps the current detail selection active [0.02ms] +(pass) mobile home map regression guards > peek-state downward sheet gesture dismisses only after intentional distance or speed [0.09ms] +(pass) mobile home map regression guards > blank map taps collapse the sheet, enter fullscreen map, then restore the peek sheet [0.05ms] +(pass) mobile home map regression guards > blank map fullscreen toggle is inactive outside a mobile restaurant panel [0.01ms] +(pass) mobile home map regression guards > cluster marker zoom jumps immediately without Naver morph animation [0.69ms] +(pass) mobile home map regression guards > mobile restaurant detail sheet peeks on map interaction without skipping fullscreen tap sequence [1.92ms] +(pass) mobile home map regression guards > mobile visible marker sheet receives map interaction epoch before requesting peek height [1.22ms] +(pass) mobile home map regression guards > G002 mobile category sheet commits immediately without delayed apply affordance [0.68ms] +(pass) mobile home map regression guards > G012 mobile sheets and details own fixed map-control safe area [0.25ms] +(pass) mobile home map regression guards > home detail route history uses app-owned list/detail states and browser back restoration contracts [0.50ms] + +tests-unit/map-view-marker-helpers.test.ts: +(pass) map view marker helpers > detects selected marker from selected or searched id [0.04ms] +(pass) map view marker helpers > returns marker size by selection state +(pass) map view marker helpers > builds class-only marker HTML with valid local and HTTPS images [0.28ms] +(pass) map view marker helpers > fails closed for unsafe marker image values and escapes alt text [0.15ms] +(pass) map view marker helpers > normalizes non-canonical marker sizes to the selection state size classes [0.14ms] +(pass) map view marker helpers > toggles only the fixed selected and size classes without style mutation [0.13ms] +(pass) map view marker helpers > treats empty marker content as a safe no-op [0.01ms] +(pass) map view marker helpers > updates selection in place without rebuilding every Google marker [0.12ms] + + 45 pass + 0 fail + 2246 expect() calls +Ran 45 tests across 4 files. [148.00ms] diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/checks/eslint.txt b/apps/web/performance/google-marker-reconcile-v2-20260925/checks/eslint.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/checks/typecheck-parity.txt b/apps/web/performance/google-marker-reconcile-v2-20260925/checks/typecheck-parity.txt new file mode 100644 index 0000000000..df9ec9216a --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/checks/typecheck-parity.txt @@ -0,0 +1,5 @@ + +> web@1.2.4 typecheck:parity +> node scripts/run-typecheck.mjs --compiler parity + +{"status":"passed","diagnostics":0,"logicalInputs":2350,"logicalInputNamesSha256":"90a7cd66eb6178c9adaf196df961aa7029df9d32970a93a7cdda9658d71b9f08","nonLibraryContentSha256":"bb1a8094180f08085d7ec89a8c90d0d3b8b5cec58e13b7d22788b4f3b108cd1f","standardLibraryContentSha256":{"native":"d328ef5dcbac7d4c183cd37a26eb383e8e6921ab2147b20e5d73f3c149223a64","compat":"82afdd90154e9b315a83eb66f2d4ee59972c492094bbf66d7f0791f584e41eb0"}} diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/frozen-tree.patch b/apps/web/performance/google-marker-reconcile-v2-20260925/frozen-tree.patch new file mode 100644 index 0000000000..a7a017b928 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/frozen-tree.patch @@ -0,0 +1,419 @@ +diff --git a/apps/web/components/map/MapView.tsx b/apps/web/components/map/MapView.tsx +index 262f4912..11f0804a 100644 +--- a/apps/web/components/map/MapView.tsx ++++ b/apps/web/components/map/MapView.tsx +@@ -14,7 +14,6 @@ import { + } from "@/components/map/map-view-status-panels"; + import { + getMapViewCountryConfig, +- getMapViewMarkerIcon, + mergeSearchedRestaurant, + } from "@/lib/map-view-helpers"; + import { +@@ -23,10 +22,9 @@ import { + } from "@/lib/map-view-movement-helpers"; + import { + applyMapViewMarkerSelectedState, +- buildMapViewMarkerHtml, +- getMapViewMarkerSize, + isMapViewMarkerSelected, + } from "@/lib/map-view-marker-helpers"; ++import { reconcileMapViewMarkers, type MapViewMarkerEntry } from "@/lib/map-view-marker-reconciliation"; + import { + buildMapViewBoundsQuery, + findUpdatedSelectedRestaurant, +@@ -76,12 +74,9 @@ interface GoogleMapLike { + + interface GoogleMarkerLike { + map: GoogleMapLike | null; +- content: Element | null; +-} +- +-interface MapMarkerEntry { +- marker: GoogleMarkerLike; +- restaurantId: string; ++ content: Node | null; ++ position?: unknown; ++ title?: string; + } + function normalizeMapViewError(error: unknown): Error { + if (error instanceof Error) { +@@ -137,7 +132,7 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + const memoizedFilters = useMemo(() => filters, [filters]); + const mapRef = useRef(null); + const googleMapRef = useRef(null); +- const markersRef = useRef([]); ++ const markersRef = useRef[]>([]); + const detailPanelRef = useRef(null); + const selectedCountryRef = useRef(selectedCountry); + +@@ -492,12 +487,6 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + useEffect(() => { + if (!googleMapRef.current || !isLoaded) return; + +- // 기존 마커 제거 (메모리 누수 방지) +- markersRef.current.forEach(({ marker }) => { +- marker.map = null; +- }); +- markersRef.current = []; +- + const markerConstructor = google.maps.marker?.AdvancedMarkerElement; + if (!markerConstructor) { + console.warn('MapView: Advanced marker support unavailable'); +@@ -505,66 +494,34 @@ const MapView = memo(({ filters, selectedCountry, searchedRestaurant, selectedRe + return; + } + +- // 새 마커 생성 +- restaurantsToShow.forEach((restaurant) => { +- const position = getRestaurantLatLng(restaurant); +- if (!position || !Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { +- console.warn('MapView: marker skipped because restaurant coordinates are invalid', { restaurantId: restaurant.id }); +- return; +- } +- +- const isSelected = isMapViewMarkerSelected({ +- restaurantId: restaurant.id, +- searchedRestaurantId: searchedRestaurant?.id, +- selectedRestaurantId: selectedRestaurant?.id, +- }); +- const imagePath = getMapViewMarkerIcon(restaurant.categories); +- const markerSize = getMapViewMarkerSize(isSelected); +- +- const markerElement = document.createElement("div"); +- markerElement.className = `custom-marker ${isSelected ? 'selected-marker' : ''}`; +- markerElement.innerHTML = buildMapViewMarkerHtml({ +- imagePath, +- isSelected, +- markerSize, +- name: restaurant.name, +- }); +- +- let marker: GoogleMarkerLike; +- try { +- marker = new markerConstructor({ +- map: googleMapRef.current, +- position, +- content: markerElement, +- title: restaurant.name, +- }); +- } catch { +- console.warn('MapView: Advanced marker creation skipped', { restaurantId: restaurant.id }); +- return; +- } +- +- markerElement.addEventListener("click", () => { +- +- +- // 1. 패널을 먼저 즉시 열기 (지도 이동 전에) ++ markersRef.current = reconcileMapViewMarkers({ ++ previous: markersRef.current, ++ restaurants: restaurantsToShow, ++ selectedRestaurantId: selectedRestaurant?.id, ++ searchedRestaurantId: searchedRestaurant?.id, ++ createMarker: (options) => { ++ try { ++ return new markerConstructor({ ...options, map: googleMapRef.current }); ++ } catch { ++ console.warn('MapView: Advanced marker creation skipped'); ++ return null; ++ } ++ }, ++ onActivate: (restaurant) => { + onMarkerClick?.(restaurant); +- +- +- // 2. selectedRestaurant 업데이트 + onRestaurantSelect?.(restaurant); +- +- +- // 3. 지도 이동은 마지막에 (비동기 작업) + moveToRestaurant(restaurant); +- +- }); +- +- markersRef.current.push({ marker, restaurantId: restaurant.id }); ++ }, + }); + // Selection IDs are applied in place by the dedicated effect below; changing them must not rebuild every marker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, moveToRestaurant, onMarkerClick, onRestaurantSelect, restaurantsToShow]); + ++ useEffect(() => () => { ++ for (const { marker } of markersRef.current) marker.map = null; ++ markersRef.current = []; ++ }, []); ++ + // 선택된 마커의 스타일을 실시간 업데이트 (줌 이벤트 시 애니메이션 유지) + useEffect(() => { + if (!isLoaded || markersRef.current.length === 0) return; +diff --git a/apps/web/lib/mobile-home-search-selection.ts b/apps/web/lib/mobile-home-search-selection.ts +index a3b5681a..436c89dc 100644 +--- a/apps/web/lib/mobile-home-search-selection.ts ++++ b/apps/web/lib/mobile-home-search-selection.ts +@@ -44,6 +44,59 @@ export const isSameRestaurantSelection = (left: RestaurantMatch, right: Restaura + return hasSameNameAndCoordinate(left, right); + }; + ++// A render pass compares many candidates with one selection. Build its merged-id ++// index at most once, instead of allocating a Set and arrays for every candidate. ++const createRestaurantSelectionMatcher = (selection: Restaurant) => { ++ let selectionIds: Set | undefined; ++ return (candidate: Restaurant): boolean => { ++ if (candidate.id === selection.id || hasSameNameAndCoordinate(candidate, selection)) return true; ++ const selectionMerged = selection.mergedRestaurants; ++ const candidateMerged = candidate.mergedRestaurants; ++ if (!selectionMerged?.length) { ++ if (candidateMerged) { ++ for (let index = 0; index < candidateMerged.length; index += 1) { ++ if (candidateMerged[index].id === selection.id) return true; ++ } ++ } ++ return false; ++ } ++ if (!selectionIds) { ++ selectionIds = new Set([selection.id]); ++ for (let index = 0; index < selectionMerged.length; index += 1) { ++ selectionIds.add(selectionMerged[index].id); ++ } ++ } ++ if (selectionIds.has(candidate.id)) return true; ++ if (candidateMerged) { ++ for (let index = 0; index < candidateMerged.length; index += 1) { ++ if (selectionIds.has(candidateMerged[index].id)) return true; ++ } ++ } ++ return false; ++ }; ++}; ++ ++type SelectionCacheKey = Pick & { mergedIds: string[] }; ++ ++const selectionCacheKey = (selection: Restaurant): SelectionCacheKey => ({ ++ id: selection.id, ++ name: selection.name, ++ lat: selection.lat, ++ lng: selection.lng, ++ mergedIds: selection.mergedRestaurants?.map((item) => item.id) ?? [], ++}); ++ ++const matchesSelectionCacheKey = (key: SelectionCacheKey, selection: Restaurant): boolean => { ++ if (key.id !== selection.id || key.name !== selection.name ++ || !Object.is(key.lat, selection.lat) || !Object.is(key.lng, selection.lng)) return false; ++ const merged = selection.mergedRestaurants; ++ if (key.mergedIds.length !== (merged?.length ?? 0)) return false; ++ for (let index = 0; index < key.mergedIds.length; index += 1) { ++ if (key.mergedIds[index] !== merged![index].id) return false; ++ } ++ return true; ++}; ++ + type SearchSelectionInput = { + searchedRestaurant: Restaurant | null; + selectedRestaurant: Restaurant | null; +@@ -141,7 +194,16 @@ export const resolveReleasedSearchSelectionResetPlan = ({ + return { nextReleasedSearchSelectionId: releasedSearchSelectionId } as const; + }; + ++const rememberedUniqueSwipeLists = new WeakSet(); ++let lastPostSearchDedupeExaminationCount = 0; ++ ++export function getLastPostSearchDedupeExaminationCount() { ++ return lastPostSearchDedupeExaminationCount; ++} ++ + const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { ++ if (rememberedUniqueSwipeLists.has(restaurants)) return restaurants; ++ lastPostSearchDedupeExaminationCount += 1; + const seenIds = new Set(); + const seenMergedIds = new Set(); + const restaurantsByName = new Map(); +@@ -166,6 +228,11 @@ const dedupeRestaurants = (restaurants: Restaurant[]): Restaurant[] => { + else restaurantsByName.set(restaurant.name, [restaurant]); + }); + ++ if (uniqueRestaurants.length === restaurants.length) { ++ rememberedUniqueSwipeLists.add(restaurants); ++ return restaurants; ++ } ++ rememberedUniqueSwipeLists.add(uniqueRestaurants); + return uniqueRestaurants; + }; + +@@ -224,13 +291,39 @@ export const buildRestaurantsForSwipe = ({ + return [...displayRestaurants, ...restaurantsToAdd]; + }; + +-const nearestSwipeFallbackCache = new WeakMap>(); ++// List-reference caches require replacing catalog/visible arrays on edits. ++// Bound query history while retaining common back-and-forth searches. ++const MAX_SEARCH_CACHE_ENTRIES = 64; ++const rememberSelectionResult = (cache: Map, id: string, value: T) => { ++ cache.delete(id); ++ cache.set(id, value); ++ if (cache.size > MAX_SEARCH_CACHE_ENTRIES) { ++ const oldest = cache.keys().next().value; ++ if (oldest !== undefined) cache.delete(oldest); ++ } ++}; ++ ++const nearestSwipeFallbackCache = new WeakMap>(); + let nearestFallbackScanCount = 0; + + export function getNearestFallbackScanCount() { + return nearestFallbackScanCount; + } + ++const orderedVisibleCache = new WeakMap>(); ++let lastSwipeOrderBuildCount = 0; ++ ++export function getLastSwipeOrderBuildCount() { ++ return lastSwipeOrderBuildCount; ++} ++ + export const buildPostSearchSwipeCandidates = ({ + visibleRestaurants, + allRestaurants, +@@ -239,16 +332,37 @@ export const buildPostSearchSwipeCandidates = ({ + const dedupedVisibleRestaurants = dedupeRestaurants(visibleRestaurants); + let orderedVisibleRestaurants = dedupedVisibleRestaurants; + if (activeSearchedRestaurant) { +- const matchedRestaurants: Restaurant[] = []; +- const otherRestaurants: Restaurant[] = []; +- dedupedVisibleRestaurants.forEach((restaurant) => { +- if (isSameRestaurantSelection(restaurant, activeSearchedRestaurant)) { +- matchedRestaurants.push(restaurant); +- } else { +- otherRestaurants.push(restaurant); ++ let orders = orderedVisibleCache.get(dedupedVisibleRestaurants); ++ if (!orders) { ++ orders = new Map(); ++ orderedVisibleCache.set(dedupedVisibleRestaurants, orders); ++ } ++ const cachedOrder = orders.get(activeSearchedRestaurant.id); ++ if (cachedOrder && matchesSelectionCacheKey(cachedOrder.searched, activeSearchedRestaurant)) { ++ orderedVisibleRestaurants = cachedOrder.restaurants; ++ } else { ++ lastSwipeOrderBuildCount += 1; ++ const matches = createRestaurantSelectionMatcher(activeSearchedRestaurant); ++ orderedVisibleRestaurants = new Array(dedupedVisibleRestaurants.length); ++ let head = 0; ++ let tail = dedupedVisibleRestaurants.length - 1; ++ for (let index = 0; index < dedupedVisibleRestaurants.length; index += 1) { ++ const restaurant = dedupedVisibleRestaurants[index]; ++ if (matches(restaurant)) orderedVisibleRestaurants[head++] = restaurant; ++ else orderedVisibleRestaurants[tail--] = restaurant; + } +- }); +- orderedVisibleRestaurants = matchedRestaurants.concat(otherRestaurants); ++ // Non-matches were written backwards; restore their input order. ++ // This stable partition needs one result array, not two + concat. ++ for (let left = head, right = orderedVisibleRestaurants.length - 1; left < right; left += 1, right -= 1) { ++ const restaurant = orderedVisibleRestaurants[left]; ++ orderedVisibleRestaurants[left] = orderedVisibleRestaurants[right]; ++ orderedVisibleRestaurants[right] = restaurant; ++ } ++ rememberSelectionResult(orders, activeSearchedRestaurant.id, { ++ searched: selectionCacheKey(activeSearchedRestaurant), ++ restaurants: orderedVisibleRestaurants, ++ }); ++ } + } + + if (!activeSearchedRestaurant || orderedVisibleRestaurants.length !== 1) { +@@ -256,14 +370,15 @@ export const buildPostSearchSwipeCandidates = ({ + } + + const visibleRestaurant = orderedVisibleRestaurants[0]; +- const cacheKey = `${activeSearchedRestaurant.id}:${visibleRestaurant.id}`; +- let cachedFallbacks = nearestSwipeFallbackCache.get(allRestaurants); +- if (!cachedFallbacks) { +- cachedFallbacks = new Map(); +- nearestSwipeFallbackCache.set(allRestaurants, cachedFallbacks); ++ let fallbacks = nearestSwipeFallbackCache.get(allRestaurants); ++ if (!fallbacks) { ++ fallbacks = new Map(); ++ nearestSwipeFallbackCache.set(allRestaurants, fallbacks); + } +- if (cachedFallbacks.has(cacheKey)) { +- const cachedFallback = cachedFallbacks.get(cacheKey) ?? null; ++ const cached = fallbacks.get(activeSearchedRestaurant.id); ++ if (cached && matchesSelectionCacheKey(cached.searched, activeSearchedRestaurant) ++ && matchesSelectionCacheKey(cached.visible, visibleRestaurant)) { ++ const cachedFallback = cached.restaurant; + return cachedFallback + ? [...orderedVisibleRestaurants, cachedFallback] + : orderedVisibleRestaurants; +@@ -271,11 +386,12 @@ export const buildPostSearchSwipeCandidates = ({ + + nearestFallbackScanCount += 1; + const dedupedRestaurants = dedupeRestaurants(allRestaurants); ++ const matchesVisible = createRestaurantSelectionMatcher(visibleRestaurant); + let nearestFallbackRestaurant: Restaurant | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < dedupedRestaurants.length; index += 1) { + const candidateRestaurant = dedupedRestaurants[index]; +- if (isSameRestaurantSelection(candidateRestaurant, orderedVisibleRestaurants[0])) continue; ++ if (matchesVisible(candidateRestaurant)) continue; + + const candidateDistance = getApproximateRestaurantDistance( + activeSearchedRestaurant, +@@ -287,7 +403,11 @@ export const buildPostSearchSwipeCandidates = ({ + nearestDistance = candidateDistance; + } + +- cachedFallbacks.set(cacheKey, nearestFallbackRestaurant); ++ rememberSelectionResult(fallbacks, activeSearchedRestaurant.id, { ++ searched: selectionCacheKey(activeSearchedRestaurant), ++ visible: selectionCacheKey(visibleRestaurant), ++ restaurant: nearestFallbackRestaurant, ++ }); + + return nearestFallbackRestaurant + ? [...orderedVisibleRestaurants, nearestFallbackRestaurant] +diff --git a/apps/web/tests-unit/mobile-home-search-selection.test.ts b/apps/web/tests-unit/mobile-home-search-selection.test.ts +index 93e2c091..62ea969c 100644 +--- a/apps/web/tests-unit/mobile-home-search-selection.test.ts ++++ b/apps/web/tests-unit/mobile-home-search-selection.test.ts +@@ -3,6 +3,8 @@ import { describe, expect, test } from 'bun:test'; + import type { Restaurant } from '../types/restaurant'; + import { + buildPostSearchSwipeCandidates, ++ getLastPostSearchDedupeExaminationCount, ++ getLastSwipeOrderBuildCount, + getNearestFallbackScanCount, + buildRestaurantsForSwipe, + getActiveSearchedRestaurant, +@@ -254,4 +256,25 @@ describe('mobile home search selection helpers', () => { + + expect(getNearestFallbackScanCount()).toBe(scansBefore + 1); + }); ++ ++ test('reuses the searched swipe order for the same visible list', () => { ++ const searchedRestaurant = makeRestaurant({ id: 'search-1', lat: 37.2, lng: 127.2 }); ++ const otherRestaurant = makeRestaurant({ id: 'other-1', lat: 37.8, lng: 127.8 }); ++ const visibleRestaurants = [otherRestaurant, searchedRestaurant]; ++ buildPostSearchSwipeCandidates({ ++ visibleRestaurants, ++ allRestaurants: visibleRestaurants, ++ activeSearchedRestaurant: searchedRestaurant, ++ }); ++ const dedupes = getLastPostSearchDedupeExaminationCount(); ++ const orders = getLastSwipeOrderBuildCount(); ++ const again = buildPostSearchSwipeCandidates({ ++ visibleRestaurants, ++ allRestaurants: visibleRestaurants, ++ activeSearchedRestaurant: searchedRestaurant, ++ }); ++ expect(getLastPostSearchDedupeExaminationCount()).toBe(dedupes); ++ expect(getLastSwipeOrderBuildCount()).toBe(orders); ++ expect(again.map((restaurant) => restaurant.id)).toEqual(['search-1', 'other-1']); ++ }); + }); diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/measure.mjs b/apps/web/performance/google-marker-reconcile-v2-20260925/measure.mjs new file mode 100644 index 0000000000..3451bac9b1 --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/measure.mjs @@ -0,0 +1,186 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpus } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import ts from 'typescript'; +import { chromium } from '@playwright/test'; + +assert.equal(process.versions.node.split('.')[0], '24', 'Use the project Node 24 runtime'); +const root = new URL('./', import.meta.url); +const app = fileURLToPath(new URL('../../', root)); +const hash = (value) => createHash('sha256').update(value).digest('hex'); +const baselineSource = await readFile(new URL('baseline-MapView.tsx.txt', root), 'utf8'); +const start = baselineSource.indexOf(' // [마커 관리]'); +const bodyStart = baselineSource.indexOf('useEffect(() => {', start) + 'useEffect(() => {'.length; +const bodyEnd = baselineSource.indexOf(' // Selection IDs are applied', bodyStart); +assert.ok(start >= 0 && bodyEnd > bodyStart); +const baselineModule = ` +import { getRestaurantLatLng } from '../../lib/map-view-google-helpers'; +import { getMapViewMarkerIcon } from '../../lib/map-view-helpers'; +import { buildMapViewMarkerHtml, getMapViewMarkerSize, isMapViewMarkerSelected } from '../../lib/map-view-marker-helpers'; +export function baselineUpdate({ previous, restaurants, Marker, map, onActivate }) { + const markersRef = {current: previous}, restaurantsToShow = restaurants; + const googleMapRef = {current: map}, isLoaded = true; + const google = {maps: {marker: {AdvancedMarkerElement: Marker}}}; + const searchedRestaurant = null, selectedRestaurant = null; + const onMarkerClick = onActivate, onRestaurantSelect = undefined; + const moveToRestaurant = () => {}, setHasGoogleRuntimeError = () => {}; + ${baselineSource.slice(bodyStart, bodyEnd)} + return markersRef.current; +} +`; +await writeFile(new URL('baseline-effect.mjs', root), ts.transpileModule(baselineModule, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, +}).outputText); +await writeFile(new URL('browser-entry.mjs', root), ` +export { baselineUpdate } from './baseline-effect.mjs'; +export { reconcileMapViewMarkers } from '../../lib/map-view-marker-reconciliation'; +`); +execFileSync('bun', ['build', fileURLToPath(new URL('browser-entry.mjs', root)), '--target=browser', '--format=esm', + `--outfile=${fileURLToPath(new URL('browser-bundle.mjs', root))}`], { cwd: app, stdio: 'pipe' }); +const bundle = await readFile(new URL('browser-bundle.mjs', root), 'utf8'); +const browser = await chromium.launch({ headless: true }); +const samples = []; +const scenarios = ['same-list-new-array', 'ten-percent-turnover', 'coordinate-updates', 'full-turnover']; +const pairs = 31, iterations = 10, size = 200; +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.route('http://127.0.0.1/**', (route) => route.request().resourceType() === 'image' ? route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) : route.fulfill({ + headers: { 'Content-Type': 'text/html', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' }, + body: 'Google marker DOM reconciliation laboratory', + })); + await page.goto('http://127.0.0.1/'); + await page.evaluate(async (code) => { + const { baselineUpdate, reconcileMapViewMarkers } = await import('data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)))); + const makeRestaurant = (id, index) => ({ id, name: `Restaurant ${id}`, lat: 35 + index / 10000, lng: 126 + index / 10000, categories: ['한식'] }); + const fixture = (variant, count) => { + const container = document.createElement('div'); + document.body.append(container); + const stats = { created: 0, detached: 0, moved: 0 }; + const map = { container }; + class Marker { + constructor({ map, content, position, title }) { + this.content = content; this.title = title; this._map = map; + this.position = position; map.container.append(content); stats.created++; + } + set map(value) { this._map = value; if (!value) { this.content.remove(); stats.detached++; } } + get map() { return this._map; } + set position(value) { this._position = value; this.content.style.transform = `translate(${(value.lng - 126) * 10000 % 1300}px,${(value.lat - 35) * 10000 % 800}px)`; stats.moved++; } + get position() { return this._position; } + } + let entries = [], fail = false; + const update = (restaurants, onActivate = () => {}) => { + if (variant === 0) entries = baselineUpdate({ previous: entries, restaurants, Marker, map, onActivate }); + else entries = reconcileMapViewMarkers({ previous: entries, restaurants, onActivate, + createMarker: (options) => fail ? null : new Marker({ ...options, map }), + }); + return entries; + }; + const restaurants = Array.from({ length: count }, (_, index) => makeRestaurant(`r${index}`, index)); + update(restaurants); + const reset = () => { stats.created = 0; stats.detached = 0; stats.moved = 0; }; + reset(); + return { container, stats, update, restaurants, reset, setFail: (value) => { fail = value; }, dispose: () => container.remove() }; + }; + globalThis.preloadMarkerFixture = async () => { + const f = fixture(1, 1); + const image = f.container.querySelector('img'); + await image.decode(); + const imageDecoded = image.complete && image.naturalWidth === 32; + f.dispose(); + if (!imageDecoded) throw new Error('The marker fixture image did not decode'); + return imageDecoded; + }; + globalThis.checkMarkers = () => { + let checks = 0; + const check = (condition, label) => { if (!condition) throw new Error(label); checks++; }; + const f = fixture(1, 3); + let entries = f.update(f.restaurants); + const first = entries[0], image = first.marker.content.querySelector('img'); + entries = f.update(f.restaurants.map((r) => ({ ...r }))); + check(entries[0] === first, 'retained entry identity'); + check(entries[0].marker.content.querySelector('img') === image, 'retained image identity'); + check(f.stats.created === 0 && f.stats.detached === 0 && f.stats.moved === 0, 'no unchanged provider mutation'); + const replacement = { ...f.restaurants[0], name: 'Refreshed ', lat: 36 }; + let clicked; + f.update([replacement, ...f.restaurants.slice(1)], (r) => { clicked = r; }); + first.marker.content.click(); + check(clicked === replacement, 'click uses newest object and callback'); + check(first.marker.title === replacement.name && image.alt === replacement.name, 'safe in-place name update'); + check(first.marker.position.lat === 36 && f.stats.moved === 1, 'coordinate-only mutation'); + replacement.lat = 37; + f.update([replacement, ...f.restaurants.slice(1)]); + check(first.marker.position.lat === 37 && f.stats.moved === 2, 'in-place coordinate mutation'); + f.reset(); + entries = f.update([f.restaurants[1], makeRestaurant('new', 4)]); + check(entries.length === 2 && f.stats.created === 1 && f.stats.detached === 2, 'bounded membership reconciliation'); + f.reset(); + entries = f.update([{ ...f.restaurants[1], lat: NaN }, makeRestaurant('new', 4), makeRestaurant('new', 4)]); + check(entries.length === 1 && f.stats.detached === 1, 'invalid coordinates and duplicate IDs'); + entries = f.update([]); + check(entries.length === 0 && f.container.childElementCount === 0, 'empty list cleanup'); + f.setFail(true); entries = f.update(f.restaurants); + check(entries.length === 0, 'creation failure not cached'); + f.setFail(false); entries = f.update(f.restaurants); + check(entries.length === 3, 'creation retry recovery'); + f.dispose(); + const b = fixture(0, 3); b.update(b.restaurants.slice()); + check(b.stats.created === 3 && b.stats.detached === 3, 'baseline reproduces rebuild churn'); + b.dispose(); + return { checks, failures: 0 }; + }; + globalThis.measureMarkers = ({ scenario, variant, size, iterations }) => { + const f = fixture(variant, size); + const inputs = Array.from({ length: iterations }, (_, round) => Array.from({ length: size }, (_, index) => { + if (scenario === 'full-turnover' || (scenario === 'ten-percent-turnover' && index < size / 10)) return makeRestaurant(`new-${round}-${index}`, index); + const restaurant = { ...f.restaurants[index] }; + if (scenario === 'coordinate-updates') restaurant.lat += (round + 1) / 10000; + return restaurant; + })); + const start = performance.now(); + for (const input of inputs) f.update(input); + // Include the DOM update's synchronous layout, not only JS dispatch. + f.container.getBoundingClientRect(); + const totalMs = performance.now() - start; + const result = { totalMs, ...f.stats, markerCount: f.container.childElementCount }; + f.dispose(); + return result; + }; + }, bundle); + const imageDecoded = await page.evaluate(() => globalThis.preloadMarkerFixture()); + const verification = { ...await page.evaluate(() => globalThis.checkMarkers()), imageDecoded }; + console.log(JSON.stringify({ verification })); + for (const scenario of scenarios) { + for (let warmup = 0; warmup < 3; warmup++) for (const variant of [0, 1]) await page.evaluate((input) => globalThis.measureMarkers(input), { scenario, variant, size, iterations }); + for (let sample = 0; sample < pairs; sample++) { + const order = sample % 2 ? [1, 0] : [0, 1], results = {}; + for (const variant of order) results[variant ? 'candidate' : 'baseline'] = await page.evaluate((input) => globalThis.measureMarkers(input), { scenario, variant, size, iterations }); + assert.equal(results.baseline.markerCount, size); assert.equal(results.candidate.markerCount, size); + samples.push({ scenario, sample, order, ...results }); + } + console.log(JSON.stringify({ measured: scenario, pairedSamples: pairs })); + } + const raw = { + scope: 'Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.', + frozenAt: new Date().toISOString(), environment: { node: process.version, chromium: browser.version(), cpu: cpus()[0]?.model, crossOriginIsolated: await page.evaluate(() => crossOriginIsolated) }, + gitHead: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: app, encoding: 'utf8' }).trim(), + budgets: { absoluteDeltaMs: 0.05, relativeDeltaFraction: 0.05, noiseMadMultiplier: 2 }, + scenarios, size, iterations, pairs, verification, samples, + }; + await writeFile(new URL('raw.json', root), JSON.stringify(raw, null, 2) + '\n'); + for (const [name, path] of Object.entries({ 'candidate-MapView.tsx.txt': '../../components/map/MapView.tsx', 'candidate-helper.ts.txt': '../../lib/map-view-marker-reconciliation.ts' })) { + await writeFile(new URL(name, root), await readFile(new URL(path, root))); + } + await writeFile(new URL('frozen-tree.patch', root), execFileSync('git', ['diff', '--binary', 'HEAD'], { cwd: app })); + const files = ['baseline-MapView.tsx.txt', 'baseline-effect.mjs', 'browser-entry.mjs', 'browser-bundle.mjs', 'candidate-MapView.tsx.txt', 'candidate-helper.ts.txt', 'frozen-tree.patch', 'raw.json', 'measure.mjs', 'score-and-verify.mjs']; + const artifacts = {}; + for (const file of files) artifacts[file] = hash(await readFile(new URL(file, root))); + const map = JSON.stringify({ artifacts }, null, 2) + '\n'; + await writeFile(new URL('artifact-map.json', root), map); + console.log(JSON.stringify({ artifactMapSha256: hash(map) })); +} finally { await browser.close(); } diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/raw.json b/apps/web/performance/google-marker-reconcile-v2-20260925/raw.json new file mode 100644 index 0000000000..9943e4605b --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/raw.json @@ -0,0 +1,2760 @@ +{ + "scope": "Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.", + "frozenAt": "2026-09-25T04:31:36.908Z", + "environment": { + "node": "v24.21.0", + "chromium": "151.0.7922.34", + "cpu": "Apple M5 Max", + "crossOriginIsolated": true + }, + "gitHead": "baa2415d011fcd18b7d0e038d4ce46c39c01bdec", + "budgets": { + "absoluteDeltaMs": 0.05, + "relativeDeltaFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "scenarios": [ + "same-list-new-array", + "ten-percent-turnover", + "coordinate-updates", + "full-turnover" + ], + "size": 200, + "iterations": 10, + "pairs": 31, + "verification": { + "checks": 13, + "failures": 0, + "imageDecoded": true + }, + "samples": [ + { + "scenario": "same-list-new-array", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 21.180000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.350000023841858, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.3049999475479126, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.979999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.945000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.1100000143051147, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.9800000190734863, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.559999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.495000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.049999952316284, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.1200000047683716, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 16.81499993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 21.18499994277954, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.2350000143051147, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.1700000762939453, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.15499997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.354999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.3050000667572021, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.1399999856948853, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.180000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 17.110000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.225000023841858, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.1899999380111694, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.894999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.074999928474426, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.1449999809265137, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.2149999141693115, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.870000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.370000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.2300000190734863, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.149999976158142, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.229999899864197, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.925000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.125, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.100000023841858, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 18.62999999523163, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 19.075000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.7549999952316284, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.125, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.629999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.369999885559082, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.024999976158142, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.15500009059906, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.700000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.120000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.0750000476837158, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 0.9900000095367432, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.72000002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.855000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 0.9700000286102295, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 0.9600000381469727, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.93500006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.389999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 0.9500000476837158, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.0199999809265137, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.90499997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.990000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.3600000143051147, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.024999976158142, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.710000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "same-list-new-array", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.47000002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 0.9149999618530273, + "created": 0, + "detached": 0, + "moved": 0, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.789999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.5800000429153442, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.559999942779541, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.84500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.68999993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.375, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.490000009536743, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.555000066757202, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.590000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.3899999856948853, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 3.184999942779541, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.965000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.910000085830688, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.2899999618530273, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6050000190734863, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.929999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.25, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.5199999809265137, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.4700000286102295, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.090000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.120000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.3700000047683716, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 4.004999995231628, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.164999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.735000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.444999933242798, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.3200000524520874, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.450000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 17.205000042915344, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.759999990463257, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.9700000286102295, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.889999985694885, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.364999890327454, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.4700000286102295, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.440000057220459, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.459999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.865000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.424999952316284, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.6549999713897705, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.569999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.945000052452087, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.4049999713897705, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.459999918937683, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.179999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.684999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.415000081062317, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.4850000143051147, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.97000002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.409999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.5549999475479126, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.634999990463257, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.165000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.105000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.3899999856948853, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.4800000190734863, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.034999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 18.47000002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 3.0950000286102295, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 2.4799998998641968, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.754999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "ten-percent-turnover", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.899999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.25, + "created": 200, + "detached": 200, + "moved": 200, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.254999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.6700000762939453, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.7799999713897705, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.495000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.519999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.53000009059906, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.5749999284744263, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 17.894999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.485000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.2799999713897705, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.6150000095367432, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 12.899999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.414999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.4350000619888306, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.4499999284744263, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.054999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.130000114440918, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.5149999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.4449999332427979, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.419999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.329999923706055, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.4500000476837158, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.5700000524520874, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.365000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.295000076293945, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.5, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.5049999952316284, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.21999990940094, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.34500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.5, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.465000033378601, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.350000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 17.269999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 2.009999990463257, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.4500000476837158, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.384999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.534999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.3899999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.4199999570846558, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.575000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.414999961853027, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.340000033378601, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.375, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.610000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.5, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.3149999380111694, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.375, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 17.875, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 17.40000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.9950000047683716, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.5049999952316284, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.245000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.235000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.34499990940094, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.7649999856948853, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.409999966621399, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.085000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.3550000190734863, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 1.4299999475479126, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.839999914169312, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "coordinate-updates", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.68500006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 1.3700000047683716, + "created": 0, + "detached": 0, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 0, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 19.040000081062317, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 19.40499997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 1, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 23.350000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.634999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 2, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 16.31500005722046, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 14.299999952316284, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 3, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 14.625, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.084999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 4, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.940000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.899999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 5, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.81000006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.649999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 6, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.52999997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 14.450000047683716, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 7, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.959999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.600000023841858, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 8, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.34500002861023, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 15, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 9, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 23.87000000476837, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.365000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 10, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.245000004768372, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 16.3400000333786, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 11, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.794999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.295000076293945, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 12, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 15.149999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 15.125, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 13, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 14.990000009536743, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.105000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 14, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 20.509999990463257, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 15.394999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 15, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 14.56499993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.910000085830688, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 16, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.524999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 16.5, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 17, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 15.569999933242798, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.519999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 18, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.675000071525574, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 14.129999995231628, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 19, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.929999947547913, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.559999942779541, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 20, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.589999914169312, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 14.020000100135803, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 21, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.894999980926514, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 16, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 22, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.960000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.940000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 23, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 13.93500006198883, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 13.785000085830688, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 24, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 18.27999997138977, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 17.419999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 25, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 14.485000014305115, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.340000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 26, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 14.440000057220459, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 17.085000038146973, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 27, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 16.68999993801117, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 14.524999976158142, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 28, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.840000033378601, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 14.669999957084656, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 29, + "order": [ + 1, + 0 + ], + "candidate": { + "totalMs": 19.230000019073486, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "baseline": { + "totalMs": 15.049999952316284, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + }, + { + "scenario": "full-turnover", + "sample": 30, + "order": [ + 0, + 1 + ], + "baseline": { + "totalMs": 13.5, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + }, + "candidate": { + "totalMs": 13.834999918937683, + "created": 2000, + "detached": 2000, + "moved": 2000, + "markerCount": 200 + } + } + ] +} diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/score-and-verify.mjs b/apps/web/performance/google-marker-reconcile-v2-20260925/score-and-verify.mjs new file mode 100644 index 0000000000..d9a5b5e37b --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/score-and-verify.mjs @@ -0,0 +1,57 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; + +const root = new URL('./', import.meta.url); +const pin = process.argv[2]; +assert.match(pin ?? '', /^[a-f0-9]{64}$/); +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const mapBytes = await readFile(new URL('artifact-map.json', root)); +assert.equal(hash(mapBytes), pin); +const map = JSON.parse(mapBytes); +for (const [name, expected] of Object.entries(map.artifacts)) { + assert.match(name, /^[a-zA-Z0-9.-]+$/); + assert.equal(hash(await readFile(new URL(name, root))), expected, name); +} +for (const [name, live] of Object.entries({ 'candidate-MapView.tsx.txt': '../../components/map/MapView.tsx', 'candidate-helper.ts.txt': '../../lib/map-view-marker-reconciliation.ts' })) { + assert.equal(hash(await readFile(new URL(live, root))), map.artifacts[name], 'Measured source drift: ' + live); +} +const raw = JSON.parse(await readFile(new URL('raw.json', root), 'utf8')); +assert.equal(raw.verification.failures, 0); assert.ok(raw.verification.checks >= 13); +assert.equal(raw.verification.imageDecoded, true); +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +const mad = (values) => { const m = median(values); return median(values.map((value) => Math.abs(value - m))); }; +const results = raw.scenarios.map((scenario) => { + const samples = raw.samples.filter((sample) => sample.scenario === scenario); + assert.equal(samples.length, raw.pairs); + samples.forEach((sample, index) => { + assert.equal(sample.sample, index); assert.deepEqual(sample.order, index % 2 ? [1, 0] : [0, 1]); + for (const variant of ['baseline', 'candidate']) { + assert.ok(sample[variant].totalMs > 0 && Number.isFinite(sample[variant].totalMs)); + assert.equal(sample[variant].markerCount, raw.size); + } + }); + const b = samples.map((sample) => sample.baseline.totalMs / raw.iterations); + const c = samples.map((sample) => sample.candidate.totalMs / raw.iterations); + const baselineMs = median(b), candidateMs = median(c); + const absoluteReductionMs = baselineMs - candidateMs, relativeReductionPercent = 100 * absoluteReductionMs / baselineMs; + const noiseMs = raw.budgets.noiseMadMultiplier * Math.max(mad(b), mad(c)); + const admitted = Math.abs(absoluteReductionMs) > Math.max(raw.budgets.absoluteDeltaMs, noiseMs) + && Math.abs(relativeReductionPercent) > raw.budgets.relativeDeltaFraction * 100; + const expectedChanged = scenario === 'full-turnover' ? raw.size : scenario === 'ten-percent-turnover' ? raw.size / 10 : 0; + for (const sample of samples) { + assert.equal(sample.baseline.created, raw.size * raw.iterations); + assert.equal(sample.baseline.detached, raw.size * raw.iterations); + assert.equal(sample.candidate.created, expectedChanged * raw.iterations); + assert.equal(sample.candidate.detached, expectedChanged * raw.iterations); + } + return { scenario, baselineMs, candidateMs, absoluteReductionMs, relativeReductionPercent, + baselineMadMs: mad(b), candidateMadMs: mad(c), noiseMs, + baselineCreatesPerUpdate: raw.size, candidateCreatesPerUpdate: expectedChanged, + baselineDetachesPerUpdate: raw.size, candidateDetachesPerUpdate: expectedChanged, + classification: admitted ? (absoluteReductionMs > 0 ? 'local_improvement' : 'local_regression') : 'below_budget_or_noise' }; +}); +const score = { scope: raw.scope, artifactMapSha256: pin, budgets: raw.budgets, verification: raw.verification, results }; +if (process.argv[3] === '--verify') assert.deepEqual(JSON.parse(await readFile(new URL('scored.json', root), 'utf8')), score); +else await writeFile(new URL('scored.json', root), JSON.stringify(score, null, 2) + '\n'); +console.log(JSON.stringify({ verified: true, ...score }, null, 2)); diff --git a/apps/web/performance/google-marker-reconcile-v2-20260925/scored.json b/apps/web/performance/google-marker-reconcile-v2-20260925/scored.json new file mode 100644 index 0000000000..ebeac1345e --- /dev/null +++ b/apps/web/performance/google-marker-reconcile-v2-20260925/scored.json @@ -0,0 +1,76 @@ +{ + "scope": "Chromium DOM laboratory using the real old effect and new helper with a simulated Google marker adapter. Not the live Google SDK, app-page LCP/INP, or production evidence.", + "artifactMapSha256": "8c565f743370310fafa691706b65484892f8664f4b348c4143b308890d8af376", + "budgets": { + "absoluteDeltaMs": 0.05, + "relativeDeltaFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "verification": { + "checks": 13, + "failures": 0, + "imageDecoded": true + }, + "results": [ + { + "scenario": "same-list-new-array", + "baselineMs": 1.4870000004768371, + "candidateMs": 0.11499999761581421, + "absoluteReductionMs": 1.3720000028610229, + "relativeReductionPercent": 92.26630816550524, + "baselineMadMs": 0.11699999570846553, + "candidateMadMs": 0.012499999999999997, + "noiseMs": 0.23399999141693106, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 0, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 0, + "classification": "local_improvement" + }, + { + "scenario": "ten-percent-turnover", + "baselineMs": 1.4364999890327455, + "candidateMs": 0.2490000009536743, + "absoluteReductionMs": 1.187499988079071, + "relativeReductionPercent": 82.66620237697765, + "baselineMadMs": 0.043499994277954146, + "candidateMadMs": 0.010000002384185774, + "noiseMs": 0.08699998855590829, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 20, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 20, + "classification": "local_improvement" + }, + { + "scenario": "coordinate-updates", + "baselineMs": 1.3495000004768372, + "candidateMs": 0.14650000333786012, + "absoluteReductionMs": 1.202999997138977, + "relativeReductionPercent": 89.14412721110817, + "baselineMadMs": 0.027500009536743253, + "candidateMadMs": 0.009000003337860107, + "noiseMs": 0.055000019073486506, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 0, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 0, + "classification": "local_improvement" + }, + { + "scenario": "full-turnover", + "baselineMs": 1.4340000033378602, + "candidateMs": 1.4625, + "absoluteReductionMs": -0.02849999666213976, + "relativeReductionPercent": -1.9874474613529667, + "baselineMadMs": 0.07400000095367432, + "candidateMadMs": 0.07250000238418575, + "noiseMs": 0.14800000190734863, + "baselineCreatesPerUpdate": 200, + "candidateCreatesPerUpdate": 200, + "baselineDetachesPerUpdate": 200, + "candidateDetachesPerUpdate": 200, + "classification": "below_budget_or_noise" + } + ] +} diff --git a/apps/web/performance/home-provider-transition-20260925/baseline-boundary.jsx b/apps/web/performance/home-provider-transition-20260925/baseline-boundary.jsx new file mode 100644 index 0000000000..97d4cda184 --- /dev/null +++ b/apps/web/performance/home-provider-transition-20260925/baseline-boundary.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { AnonymousHomeAuthProvider } from '../../contexts/AuthContextBase'; +import { StaticNotificationProvider } from '../../contexts/NotificationContextBase'; +const isPublicRestrictedMode = false; +export function BaselineBoundary({children, hasStoredSession, AuthProvider, NotificationProvider}) { + if (hasStoredSession && AuthProvider && NotificationProvider) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/apps/web/performance/home-provider-transition-20260925/baseline-home-runtime-shell.tsx.txt b/apps/web/performance/home-provider-transition-20260925/baseline-home-runtime-shell.tsx.txt new file mode 100644 index 0000000000..3547407d18 --- /dev/null +++ b/apps/web/performance/home-provider-transition-20260925/baseline-home-runtime-shell.tsx.txt @@ -0,0 +1,367 @@ +'use client'; +// Keep the home Tailwind entry separate from the full app stylesheet loaded by AppRuntimeShell. + +import './home-app-globals.css'; +import { Suspense, lazy, type ComponentType, type ReactNode, useCallback, useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryProvider } from './providers'; +import { LayoutProvider } from '@/contexts/LayoutContext'; +import { AnonymousHomeAuthProvider, useAuth } from '@/contexts/AuthContextBase'; +import { StaticNotificationProvider } from '@/contexts/NotificationContextBase'; +import { useHomeViewportMode } from '@/hooks/useHomeViewportMode'; +import { AUTH_UI_REQUEST_EVENT } from '@/lib/auth-ui-events'; +import { + AUTH_PRIVACY_ONBOARDING_REASON, + readHomeAuthLoginRequestFromLocation, +} from '@/lib/auth/auth-redirect'; +import { HOME_AUTH_SESSION_UPDATED_EVENT, type HomeAuthSessionUpdatedDetail } from '@/lib/home-auth-events'; +import { useDeferredComponent } from '@/hooks/use-deferred-component'; +import { hasSupabaseAuthSessionHint } from '@/lib/supabase-auth-session-hints'; +import { isPublicRestrictedMode } from '@/lib/site-config'; +import { + APP_HEADER_HEIGHT_VAR, + MOBILE_SHEET_HEADER_OFFSET_VAR, + MOBILE_SHEET_HEADER_PROGRESS_VAR, +} from '@/lib/mobile-sheet-layout'; + +import MobileBottomNav from '@/components/layout/MobileBottomNav'; +import { AppToaster } from '@/components/ui/app-toaster'; + +const OverlayLayout = lazy(() => import('@/components/layout/OverlayLayout')); + +type AuthModalProps = { + isOpen: boolean; + onClose: () => void; + onAuthSuccess?: () => void; + redirectTo?: string | null; + reason?: string | null; + initialAuthTab?: 'login' | 'signup'; +}; +type ProfileModalProps = AuthModalProps; +type NicknameSetupModalProps = { isOpen: boolean; onComplete: () => void }; +type ProviderProps = { children: ReactNode }; + +const loadAuthProvider = async () => { + const mod = await import('@/contexts/AuthContext'); + return mod.AuthProvider as ComponentType; +}; + +const loadNotificationProvider = async () => { + const mod = await import('@/contexts/NotificationContext'); + return mod.NotificationProvider as ComponentType; +}; + +function HomeSessionProviders({ children }: ProviderProps) { + const [hasStoredSession, setHasStoredSession] = useState( + () => !isPublicRestrictedMode && hasSupabaseAuthSessionHint(), + ); + + useEffect(() => { + if (isPublicRestrictedMode) return undefined; + + const updateSessionHint = (event?: Event) => { + const detail = (event as CustomEvent | undefined)?.detail; + if (typeof detail?.hasSession === 'boolean') { + setHasStoredSession(detail.hasSession); + return; + } + + setHasStoredSession(hasSupabaseAuthSessionHint()); + }; + + updateSessionHint(); + window.addEventListener(HOME_AUTH_SESSION_UPDATED_EVENT, updateSessionHint); + window.addEventListener('storage', updateSessionHint); + + return () => { + window.removeEventListener(HOME_AUTH_SESSION_UPDATED_EVENT, updateSessionHint); + window.removeEventListener('storage', updateSessionHint); + }; + }, []); + + const AuthProvider = useDeferredComponent(hasStoredSession, loadAuthProvider); + const NotificationProvider = useDeferredComponent(hasStoredSession, loadNotificationProvider); + + if (hasStoredSession && AuthProvider && NotificationProvider) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +function DeferredAuthModal(props: AuthModalProps) { + const AuthModal = useDeferredComponent(props.isOpen, async () => { + const mod = await import('@/components/auth/AuthModal'); + return mod.default as ComponentType; + }); + + if (!props.isOpen || !AuthModal) return null; + return ; +} + +function DeferredProfileModal(props: ProfileModalProps) { + const ProfileModal = useDeferredComponent(props.isOpen, async () => { + const mod = await import('@/components/profile/ProfileModal'); + return mod.ProfileModal as ComponentType; + }); + + if (!props.isOpen || !ProfileModal) return null; + return ; +} + +function DeferredNicknameSetupModal(props: NicknameSetupModalProps) { + const NicknameSetupModal = useDeferredComponent(props.isOpen, async () => { + const mod = await import('@/components/profile/NicknameSetupModal'); + return mod.NicknameSetupModal as ComponentType; + }); + + if (!props.isOpen || !NicknameSetupModal) return null; + return ; +} + +function DeferredUserDataPrefetcher({ enabled }: { enabled: boolean }) { + const UserDataPrefetcher = useDeferredComponent>(enabled, async () => { + const mod = await import('@/components/layout/UserDataPrefetcher'); + return mod.default as ComponentType>; + }); + + if (!enabled || !UserDataPrefetcher) return null; + return ; +} + +function MobileHomeLayout({ children }: { children: ReactNode }) { + const { user, needsNicknameSetup, completeNicknameSetup } = useAuth(); + const queryClient = useQueryClient(); + const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); + const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); + const [authLoginRequest, setAuthLoginRequest] = useState({ + requested: false, + reason: null as string | null, + nextPath: '/', + }); + + const openAuth = useCallback(() => { + if (isPublicRestrictedMode) return; + setIsAuthModalOpen(true); + }, []); + const closeAuth = useCallback(() => { + setIsAuthModalOpen(false); + setAuthLoginRequest((current) => { + if (!current.requested) return current; + window.history.replaceState(window.history.state, '', '/'); + return { requested: false, reason: null, nextPath: '/' }; + }); + }, []); + const closeAuthAfterSuccess = useCallback(() => { + closeAuth(); + }, [closeAuth]); + + const openProfile = useCallback(() => { + if (isPublicRestrictedMode) return; + setIsProfileModalOpen(true); + }, []); + + useEffect(() => { + const root = document.documentElement; + root.style.setProperty(MOBILE_SHEET_HEADER_PROGRESS_VAR, '0'); + root.style.setProperty(MOBILE_SHEET_HEADER_OFFSET_VAR, '0px'); + root.style.setProperty(APP_HEADER_HEIGHT_VAR, '0px'); + + const openAuthListener = (event: Event) => { + const detail = (event as CustomEvent<{ route?: string }> | undefined)?.detail; + if (detail?.route && detail.route !== '/') return; + openAuth(); + }; + + const openProfileListener = (event: Event) => { + const detail = (event as CustomEvent<{ route?: string }> | undefined)?.detail; + if (detail?.route && detail.route !== '/') return; + openProfile(); + }; + + if (!isPublicRestrictedMode) { + window.addEventListener(AUTH_UI_REQUEST_EVENT, openAuth); + window.addEventListener('home:mobile-auth-request', openAuthListener); + window.addEventListener('home:mobile-profile-request', openProfileListener); + } + + return () => { + if (!isPublicRestrictedMode) { + window.removeEventListener(AUTH_UI_REQUEST_EVENT, openAuth); + window.removeEventListener('home:mobile-auth-request', openAuthListener); + window.removeEventListener('home:mobile-profile-request', openProfileListener); + } + root.style.setProperty(MOBILE_SHEET_HEADER_PROGRESS_VAR, '0'); + root.style.setProperty(MOBILE_SHEET_HEADER_OFFSET_VAR, '0px'); + root.style.setProperty(APP_HEADER_HEIGHT_VAR, '56px'); + }; + }, [openAuth, openProfile]); + + useEffect(() => { + const request = readHomeAuthLoginRequestFromLocation(window.location); + if (request.requested && isPublicRestrictedMode) { + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete('auth'); + currentUrl.searchParams.delete('reason'); + currentUrl.searchParams.delete('next'); + const nextSearch = currentUrl.searchParams.toString(); + const nextUrl = `${currentUrl.pathname}${nextSearch ? `?${nextSearch}` : ''}${currentUrl.hash}`; + window.history.replaceState(window.history.state, '', nextUrl); + setAuthLoginRequest({ requested: false, reason: null, nextPath: '/' }); + return; + } + + setAuthLoginRequest(request); + if (request.requested && !isPublicRestrictedMode) { + setIsAuthModalOpen(true); + } + }, []); + + useEffect(() => { + if (!user) { + queryClient.removeQueries({ queryKey: ['user-bookmarks'] }); + } + }, [queryClient, user]); + + + return ( +
+ + +
+ + 본문 바로가기 + +
+ +
+ {children} +
+
+
+ + {!isPublicRestrictedMode && ( +
+ +
+ )} + + {!isPublicRestrictedMode && isAuthModalOpen && ( + + + + )} + + {!isPublicRestrictedMode && isProfileModalOpen && ( + + setIsProfileModalOpen(false)} + /> + + )} + + {!isPublicRestrictedMode && needsNicknameSetup && ( + + + + )} +
+ ); +} + +function HomeLayoutContent({ children }: { children: ReactNode }) { + const viewportMode = useHomeViewportMode(); + + if (viewportMode === 'mobileOrTablet') { + return {children}; + } + + // 지도 본문은 pending과 desktop에서 같은 부모에 둔다. + // 오버레이 청크가 도착할 때 자식을 다른 부모로 옮기면 인기/최신 스켈레톤이 다시 마운트된다. + return ( + + {children} + {viewportMode === 'desktop' && !isPublicRestrictedMode ? ( + + + + ) : null} + + ); +} + +function HomeStaticSkeleton() { + return ( +