diff --git a/apps/web/app/home-client.tsx b/apps/web/app/home-client.tsx index 11f521ac8c..d177cadb3e 100644 --- a/apps/web/app/home-client.tsx +++ b/apps/web/app/home-client.tsx @@ -224,9 +224,10 @@ export default function HomeClient() { const [activeRightPanel, setActiveRightPanel] = useState(null); const [selectedAnnouncement, setSelectedAnnouncement] = useState(null); - const [isPanelCollapsed, setIsPanelCollapsed] = useState( + const [desktopPanelCollapsed, setIsPanelCollapsed] = useState( () => DEFAULT_HOME_MAP_USER_PREFERENCES.desktopPanelDefault === "collapsed", ); + const isPanelCollapsed = isDesktop && desktopPanelCollapsed; const [desktopMapLayout, setDesktopMapLayout] = useState( DEFAULT_HOME_MAP_USER_PREFERENCES.desktopMapLayout, ); @@ -234,7 +235,11 @@ export default function HomeClient() { DEFAULT_HOME_MAP_USER_PREFERENCES.desktopPanelSide, ); const [isAnnouncementSheetOpen, setIsAnnouncementSheetOpen] = useState(false); - const [isMapFullscreen, setIsMapFullscreen] = useState(false); + const [mobileMapFullscreen, setIsMapFullscreen] = useState(false); + const isMapFullscreen = isMobileOrTablet && mobileMapFullscreen; + useEffect(() => { + if (isDesktop) setIsMapFullscreen(false); + }, [isDesktop]); const [contextualRestaurantsPayload, setContextualRestaurantsPayload] = useState(null); const [mapInteractionEpoch, setMapInteractionEpoch] = useState(0); @@ -1269,6 +1274,7 @@ export default function HomeClient() { )} ; } -function MobileHomeLayout({ children }: { children: ReactNode }) { +function MobileHomeLayout() { const { user, needsNicknameSetup, completeNicknameSetup } = useAuth(); const queryClient = useQueryClient(); const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); @@ -236,21 +236,9 @@ function MobileHomeLayout({ children }: { children: ReactNode }) { return ( -
+ <> -
- - 본문 바로가기 - -
- -
- {children} -
-
-
- {!isPublicRestrictedMode && (
)} -
+ ); } function HomeLayoutContent({ children }: { children: ReactNode }) { const viewportMode = useHomeViewportMode(); - if (viewportMode === 'mobileOrTablet') { - return {children}; - } - - // 지도 본문은 pending과 desktop에서 같은 부모에 둔다. - // 오버레이 청크가 도착할 때 자식을 다른 부모로 옮기면 인기/최신 스켈레톤이 다시 마운트된다. + // 모든 뷰포트에서 지도 본문의 부모와 위치를 유지한다. + // 모바일 레이아웃을 조상으로 교체하면 지도와 검색/선택 상태가 다시 마운트된다. return ( - + : null} + > {children} {viewportMode === 'desktop' && !isPublicRestrictedMode ? ( @@ -343,16 +330,28 @@ function HomeStaticSkeleton() { ); } -function HomeRuntimePendingShell({ children }: { children: ReactNode }) { +function HomeRuntimePendingShell({ children, isMobile, mobileChrome }: { + children: ReactNode; + isMobile: boolean; + mobileChrome: ReactNode; +}) { return ( -
+
본문 바로가기 -
+
{children}
+ {mobileChrome}
); } diff --git a/apps/web/components/home/home-control-panel.tsx b/apps/web/components/home/home-control-panel.tsx index 7b34c15b75..3ca54ba1ee 100644 --- a/apps/web/components/home/home-control-panel.tsx +++ b/apps/web/components/home/home-control-panel.tsx @@ -4,7 +4,7 @@ import { memo, useEffect, useState, type ComponentType } from 'react'; import type { Region, Restaurant } from '@/types/restaurant'; import type { Announcement } from '@/types/announcement'; import type { FilterState } from '@/components/filters/filter-state'; -import { BREAKPOINTS, useDeviceType } from '@/hooks/useDeviceType'; +import type { HomeViewportMode } from '@/hooks/useHomeViewportMode'; import type { User } from '@supabase/supabase-js'; import type { DeviceMapLocation } from '@/lib/device-location-map'; import type { HomeMapPanelSide } from '@/lib/home-map-user-preferences'; @@ -57,7 +57,7 @@ const loadHomeDesktopControlPanel = async () => { return mod.default as ComponentType; }; -type MobileControlOverlayProps = HomeControlPanelProps; +type MobileControlOverlayProps = Omit; const loadMobileControlOverlay = async () => { const mod = await import('@/components/home/MobileControlOverlay'); @@ -65,6 +65,7 @@ const loadMobileControlOverlay = async () => { }; export interface HomeControlPanelProps { + viewportMode: HomeViewportMode; mapMode: 'domestic' | 'overseas'; selectedRegion: Region | null; selectedCountry: string | null; @@ -110,6 +111,7 @@ export interface HomeControlPanelProps { } function HomeControlPanelComponent({ + viewportMode, mapMode, selectedRegion, selectedCountry, @@ -158,17 +160,10 @@ function HomeControlPanelComponent({ delete document.documentElement.dataset.homeChromeReady; }; }, []); - const { isMobileOrTablet } = useDeviceType(); - const shouldRenderMobile = isMobileOrTablet || ( - typeof window !== 'undefined' && window.innerWidth <= BREAKPOINTS.tabletMax - ); - const [shouldLoadMobileOverlay, setShouldLoadMobileOverlay] = useState(() => ( - Boolean(initialIntent) || (typeof window !== 'undefined' && window.innerWidth <= BREAKPOINTS.tabletMax) - )); + const shouldRenderMobile = viewportMode === 'mobileOrTablet'; + const shouldLoadMobileOverlay = shouldRenderMobile; const [pendingMobileOverlayIntent, setPendingMobileOverlayIntent] = useState(initialIntent); - const [shouldLoadDesktopPanel, setShouldLoadDesktopPanel] = useState(() => ( - typeof window !== 'undefined' ? window.innerWidth > BREAKPOINTS.tabletMax : false - )); + const shouldLoadDesktopPanel = viewportMode === 'desktop'; const DeferredMobileControlOverlay = useDeferredComponent( shouldRenderMobile && shouldLoadMobileOverlay, loadMobileControlOverlay @@ -178,39 +173,12 @@ function HomeControlPanelComponent({ if (!initialIntent) return; setPendingMobileOverlayIntent(initialIntent); - setShouldLoadMobileOverlay(true); }, [initialIntent]); const DeferredHomeDesktopControlPanel = useDeferredComponent( shouldLoadDesktopPanel, loadHomeDesktopControlPanel ); - useEffect(() => { - let resizeRafId = 0; - const updateShouldLoadDesktopPanel = () => { - if (resizeRafId) return; - - resizeRafId = window.requestAnimationFrame(() => { - resizeRafId = 0; - setShouldLoadDesktopPanel(window.innerWidth > BREAKPOINTS.tabletMax); - }); - }; - - updateShouldLoadDesktopPanel(); - window.addEventListener('resize', updateShouldLoadDesktopPanel, { passive: true }); - - return () => { - window.removeEventListener('resize', updateShouldLoadDesktopPanel); - if (resizeRafId) window.cancelAnimationFrame(resizeRafId); - }; - }, []); - - useEffect(() => { - if (!shouldRenderMobile || shouldLoadMobileOverlay) return; - - setShouldLoadMobileOverlay(true); - }, [shouldLoadMobileOverlay, shouldRenderMobile]); - if (shouldRenderMobile) { if (!DeferredMobileControlOverlay) { return null; diff --git a/apps/web/components/home/home-map-container.tsx b/apps/web/components/home/home-map-container.tsx index 53a7c896c0..6ff027f71f 100644 --- a/apps/web/components/home/home-map-container.tsx +++ b/apps/web/components/home/home-map-container.tsx @@ -1,10 +1,10 @@ 'use client'; -import { Suspense, lazy, useState, useCallback, memo, useRef, useEffect, useMemo } from 'react'; +import { Suspense, lazy, useState, useCallback, memo, useRef, useEffect, useLayoutEffect, useMemo } from 'react'; import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; import type { Restaurant, Region } from '@/types/restaurant'; import type { FilterState } from '@/components/filters/filter-state'; -import { useDeviceType } from '@/hooks/useDeviceType'; +import type { HomeViewportMode } from '@/hooks/useHomeViewportMode'; import { cn } from '@/lib/utils'; import { filterHomeMapRestaurantsByMode, @@ -47,6 +47,7 @@ const RestaurantDetailPanel = lazy(() => const HydratedDetailRestaurant = lazy(() => import("@/components/home/HydratedDetailRestaurant")); interface HomeMapContainerProps { + viewportMode: HomeViewportMode; mapMode: 'domestic' | 'overseas'; mapFocusZoom?: number | null; // [New] 줌 레벨 제어 filters: FilterState; @@ -143,6 +144,7 @@ const RESTAURANT_CONTENT_SCROLL_SELECTOR = "[data-restaurant-detail-swipe-area=' // [CSR] 지도 렌더링 및 그리드/단일 모드 처리 - 브라우저 전용 지도 라이브러리 사용 function HomeMapContainerComponent({ + viewportMode, mapMode, mapFocusZoom, filters, @@ -179,7 +181,8 @@ function HomeMapContainerComponent({ showUserSubmittedMarkers = true, renderDesktopDetailPanel = true, }: HomeMapContainerProps) { - const { isMobileOrTablet, isDesktop } = useDeviceType(); + const isMobileOrTablet = viewportMode === 'mobileOrTablet'; + const isDesktop = viewportMode === 'desktop'; // [PERFORMANCE] 드래그 중 리렌더링 제거 - Ref로 관리 const viewportHeightRef = useRef(typeof window !== 'undefined' @@ -453,7 +456,13 @@ function HomeMapContainerComponent({ // 패널이 열릴 때 50% 높이로 열기 (헤더 배제) useEffect(() => { - if (!isMobileOrTablet) return; + if (!isMobileOrTablet) { + resetSheetInteractionState(); + wasPanelOpenRef.current = false; + lastPanelRestaurantIdRef.current = null; + contentScrollResetNeededRef.current = false; + return; + } if (!isPanelOpen) { onMapFullscreenChange?.(false); @@ -508,9 +517,7 @@ function HomeMapContainerComponent({ }; }, []); - useEffect(() => { - if (!isMobileOrTablet || !isPanelOpen) return; - + useLayoutEffect(() => { syncMobileLayout(sheetHeightRef.current); }, [isMapFullscreen, isMobileOrTablet, isPanelOpen, syncMobileLayout]); diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index c3c9b03405..21ca329445 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -49,6 +49,7 @@ const eslintConfig = [ files: [ 'performance/swipe-render-cache-20260925/measure.mjs', 'performance/swipe-render-cache-20260925/rejected-timer-resolution/measure.mjs', + 'performance/swipe-cache-revalidation-20260930/measure.mjs', ], rules: { // These frozen Node measurement runners bind a local variable named module. diff --git a/apps/web/performance/home-live-readback-20260930/baseline-v1/artifact-map.json b/apps/web/performance/home-live-readback-20260930/baseline-v1/artifact-map.json new file mode 100644 index 0000000000..c835e58276 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/baseline-v1/artifact-map.json @@ -0,0 +1,10 @@ +{ + "scope": "Five isolated anonymous Chromium production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "artifacts": { + "raw.json": "1a18b1f710cda68d8822e5303bbb4cebb13161c5e58b1768a2db2a46d558ec61", + "desktop.png": "1d000a82585f7e01c2841bb5ca51f912e69b49391d6a8af8aa417bc8faf6373e", + "mobile.png": "4ab43ee11ca9ffdf05dd916b6502e0d6f5b1c811f42579870115a85f1a3457be", + "../live-readback.mjs": "7dff99308424ff42767b808074c180f704abc22a93d93e06f1fedcbf5447e396" + } +} diff --git a/apps/web/performance/home-live-readback-20260930/baseline-v1/desktop.png b/apps/web/performance/home-live-readback-20260930/baseline-v1/desktop.png new file mode 100644 index 0000000000..79ca1eba1f Binary files /dev/null and b/apps/web/performance/home-live-readback-20260930/baseline-v1/desktop.png differ diff --git a/apps/web/performance/home-live-readback-20260930/baseline-v1/mobile.png b/apps/web/performance/home-live-readback-20260930/baseline-v1/mobile.png new file mode 100644 index 0000000000..c679ca6dc4 Binary files /dev/null and b/apps/web/performance/home-live-readback-20260930/baseline-v1/mobile.png differ diff --git a/apps/web/performance/home-live-readback-20260930/baseline-v1/raw.json b/apps/web/performance/home-live-readback-20260930/baseline-v1/raw.json new file mode 100644 index 0000000000..98c29019c9 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/baseline-v1/raw.json @@ -0,0 +1,189 @@ +{ + "scope": "Five isolated anonymous Chromium production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "capturedAt": "2026-09-30T03:56:26.033Z", + "environment": { + "node": "v24.21.0", + "chromium": "151.0.7922.34", + "authenticated": false + }, + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "results": [ + { + "sample": 0, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 51.09999990463257, + "observedFrames": 121, + "markerlessFrames": 3, + "tilelessFrames": 2, + "missingMapFrames": 0, + "maxGapMs": 16.69999999999891, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": { + "map_provider:xhr": 2, + "persistence:fetch": 1, + "application:image": 2 + } + }, + { + "sample": 1, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 58.200000047683716, + "observedFrames": 120, + "markerlessFrames": 4, + "tilelessFrames": 2, + "missingMapFrames": 0, + "maxGapMs": 17, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": { + "map_provider:xhr": 2, + "persistence:fetch": 1, + "application:image": 2 + } + }, + { + "sample": 2, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 65.79999995231628, + "observedFrames": 120, + "markerlessFrames": 4, + "tilelessFrames": 2, + "missingMapFrames": 0, + "maxGapMs": 25, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": { + "map_provider:xhr": 2, + "persistence:fetch": 1 + } + }, + { + "sample": 3, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 45.10000014305115, + "observedFrames": 121, + "markerlessFrames": 3, + "tilelessFrames": 2, + "missingMapFrames": 0, + "maxGapMs": 16.700000000000273, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": { + "map_provider:xhr": 2, + "persistence:fetch": 1 + } + }, + { + "sample": 4, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 50.89999985694885, + "observedFrames": 121, + "markerlessFrames": 3, + "tilelessFrames": 2, + "missingMapFrames": 0, + "maxGapMs": 17.40000000000009, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": { + "map_provider:xhr": 2, + "persistence:fetch": 1 + } + } + ] +} diff --git a/apps/web/performance/home-live-readback-20260930/baseline-v1/scored-v2.json b/apps/web/performance/home-live-readback-20260930/baseline-v1/scored-v2.json new file mode 100644 index 0000000000..5ebe33a50e --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/baseline-v1/scored-v2.json @@ -0,0 +1,43 @@ +{ + "scope": "Five isolated anonymous Chromium production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "kind": "baseline", + "artifactMapSha256": "df298024193ceff7977e6da0271accbade5b420e2cb2c4af71695062f707cb84", + "samples": 5, + "admission": "Map identity verified; layout-specific detail DOM replacement verified. Selection persistence is covered separately by browser regression. No paired speedup or pixel-level flicker admission.", + "timingBudgets": { + "absoluteMs": 1, + "relativeFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "summaries": { + "firstReadyMs": { + "median": 51.09999990463257, + "mad": 5.999999761581421, + "min": 45.10000014305115, + "max": 65.79999995231628, + "classification": "sequential_observation_only" + }, + "markerlessFrames": { + "median": 3, + "mad": 0, + "min": 3, + "max": 4, + "classification": "sequential_observation_only" + }, + "tilelessFrames": { + "median": 2, + "mad": 0, + "min": 2, + "max": 2, + "classification": "sequential_observation_only" + }, + "maxGapMs": { + "median": 17, + "mad": 0.3000000000010914, + "min": 16.69999999999891, + "max": 25, + "classification": "sequential_observation_only" + } + } +} diff --git a/apps/web/performance/home-live-readback-20260930/baseline-v1/scored.json b/apps/web/performance/home-live-readback-20260930/baseline-v1/scored.json new file mode 100644 index 0000000000..d8e7da02e2 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/baseline-v1/scored.json @@ -0,0 +1,43 @@ +{ + "scope": "Five isolated anonymous Chromium production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "kind": "baseline", + "artifactMapSha256": "df298024193ceff7977e6da0271accbade5b420e2cb2c4af71695062f707cb84", + "samples": 5, + "admission": "Identity invariants verified. No paired speedup or pixel-level flicker admission.", + "timingBudgets": { + "absoluteMs": 1, + "relativeFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "summaries": { + "firstReadyMs": { + "median": 51.09999990463257, + "mad": 5.999999761581421, + "min": 45.10000014305115, + "max": 65.79999995231628, + "classification": "sequential_observation_only" + }, + "markerlessFrames": { + "median": 3, + "mad": 0, + "min": 3, + "max": 4, + "classification": "sequential_observation_only" + }, + "tilelessFrames": { + "median": 2, + "mad": 0, + "min": 2, + "max": 2, + "classification": "sequential_observation_only" + }, + "maxGapMs": { + "median": 17, + "mad": 0.3000000000010914, + "min": 16.69999999999891, + "max": 25, + "classification": "sequential_observation_only" + } + } +} diff --git a/apps/web/performance/home-live-readback-20260930/diagnose-console.mjs b/apps/web/performance/home-live-readback-20260930/diagnose-console.mjs new file mode 100644 index 0000000000..ad469e3424 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/diagnose-console.mjs @@ -0,0 +1,76 @@ +import { spawn, execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { chromium } from '@playwright/test'; +import { installViewportContinuityMocks } from '../../tests/home-viewport-continuity-helpers.ts'; + +const root = new URL('./', import.meta.url); +const nodeExecutable = process.env.TZUDONG_NODE24_EXECUTABLE ?? (typeof Bun === 'undefined' ? process.execPath : 'node'); +if (!/^v24\./.test(execFileSync(nodeExecutable, ['--version'], { encoding: 'utf8' }).trim())) throw new Error('Use Node 24'); +await mkdir(new URL('diagnostic-v1/', root)); +const browser = await chromium.launch({ headless: true }); +const records = []; +let server; +function classify(message) { + if (/Content Security Policy/.test(message)) return 'csp_block'; + if (/Access-Control-Allow-Origin|CORS/.test(message)) return 'cors_block'; + if (/net::ERR_BLOCKED_BY_CLIENT/.test(message)) return 'fenced_resource'; + if (/Failed to load resource/.test(message)) return 'resource_load_failure'; + if (/hydration|Hydration/.test(message)) return 'hydration_error'; + return 'other_console_error'; +} +function group(url) { + const host = new URL(url).hostname; + return host === 'localhost' || host === 'www.tzudong.app' ? 'application' + : /naver|pstatic/.test(host) ? 'map_provider' : /supabase/.test(host) ? 'persistence' : 'external'; +} +try { + server = spawn(nodeExecutable, ['.next-viewport-candidate-20260930/standalone/apps/web/server.js'], { + cwd: new URL('../../', root), env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, + NODE_ENV: 'production', HOSTNAME: '127.0.0.1', PORT: '3000' }, stdio: 'ignore', + }); + let started = false; + for (let attempt = 0; attempt < 100; attempt++) { + if (server.exitCode !== null) throw new Error('Owned diagnostic server terminated'); + try { if ((await fetch('http://localhost:3000', { signal: AbortSignal.timeout(500) })).ok) { started = true; break; } } + catch { /* bounded owned-server startup */ } + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (!started) throw new Error('Owned diagnostic startup timeout'); + for (const kind of ['local-fixture', 'live']) { + const origin = kind === 'live' ? 'https://www.tzudong.app' : 'http://localhost:3000'; + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' }); + if (kind === 'local-fixture') { + await context.routeWebSocket('**/*', socket => socket.close()); + await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort('blockedbyclient')); + } + const page = await context.newPage(); + if (kind === 'local-fixture') { + await installViewportContinuityMocks(page); + await page.route('**/rest/v1/rpc/read_public_profile_summaries', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]', headers: { + 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', + } })); + } + const errors = [], failures = [], statuses = []; + page.on('console', message => { + if (message.type() === 'error') errors.push({ code: classify(message.text()), + sourceGroup: message.location().url ? group(message.location().url) : 'unspecified', + line: message.location().lineNumber }); + }); + page.on('requestfailed', request => failures.push({ group: group(request.url()), type: request.resourceType(), + code: ['net::ERR_ABORTED', 'net::ERR_BLOCKED_BY_CLIENT', 'net::ERR_FAILED'].includes(request.failure()?.errorText) + ? request.failure().errorText : 'other_network_failure' })); + page.on('response', response => { if (response.status() >= 400) statuses.push({ group: group(response.url()), + type: response.request().resourceType(), status: response.status() }); }); + try { + await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 45000 }); + await page.waitForFunction(() => document.querySelector('.cluster-marker-container,[data-testid="marker"]'), null, { timeout: 45000 }); + await page.waitForTimeout(1500); + records.push({ kind, errors, failures, statuses }); + } finally { await context.close(); } + } + await writeFile(new URL('diagnostic-v1/result.json', root), JSON.stringify({ scope: 'One desktop visit per environment. Fixed classification codes only; no URLs, payloads or provider diagnostics retained.', records }, null, 2) + '\n'); + console.log(JSON.stringify(records)); +} finally { + await browser.close(); + if (server?.exitCode === null) server.kill('SIGTERM'); +} diff --git a/apps/web/performance/home-live-readback-20260930/diagnostic-v1/result.json b/apps/web/performance/home-live-readback-20260930/diagnostic-v1/result.json new file mode 100644 index 0000000000..c5355cefba --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/diagnostic-v1/result.json @@ -0,0 +1,62 @@ +{ + "scope": "One desktop visit per environment. Fixed classification codes only; no URLs, payloads or provider diagnostics retained.", + "records": [ + { + "kind": "local-fixture", + "errors": [ + { + "code": "fenced_resource", + "sourceGroup": "persistence", + "line": 0 + } + ], + "failures": [ + { + "group": "persistence", + "type": "fetch", + "code": "other_network_failure" + } + ], + "statuses": [] + }, + { + "kind": "live", + "errors": [ + { + "code": "csp_block", + "sourceGroup": "map_provider", + "line": 15 + }, + { + "code": "resource_load_failure", + "sourceGroup": "persistence", + "line": 0 + }, + { + "code": "resource_load_failure", + "sourceGroup": "persistence", + "line": 0 + } + ], + "failures": [ + { + "group": "map_provider", + "type": "xhr", + "code": "other_network_failure" + } + ], + "statuses": [ + { + "group": "persistence", + "type": "fetch", + "status": 401 + }, + { + "group": "persistence", + "type": "fetch", + "status": 401 + } + ] + } + ] +} diff --git a/apps/web/performance/home-live-readback-20260930/live-readback.mjs b/apps/web/performance/home-live-readback-20260930/live-readback.mjs new file mode 100644 index 0000000000..b8c42e8960 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/live-readback.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { chromium, expect } from '@playwright/test'; + +const [label, expectedSha] = process.argv.slice(2); +assert.match(label ?? '', /^[a-z0-9-]+$/); +assert.match(expectedSha ?? '', /^[a-f0-9]{40}$/); +assert.equal(Number(process.versions.node.split('.')[0]), 24); +const root = new URL('./', import.meta.url); +const output = new URL(`${label}/`, root); +await mkdir(output); +const origin = 'https://www.tzudong.app'; +const browser = await chromium.launch({ headless: true }); +const results = [], files = ['raw.json']; +const scope = 'Five isolated anonymous Chromium production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.'; +const classifyConsole = text => text.includes('Failed to load resource') ? 'resource_load_failure' + : /hydration|Hydration/.test(text) ? 'hydration_error' : 'other_console_error'; +try { + for (let sample = 0; sample < 5; sample++) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, hasTouch: true, locale: 'ko-KR' }); + const page = await context.newPage(); + const errors = { page: 0, console: {} }, failedResources = {}; + page.on('pageerror', () => errors.page++); + page.on('console', message => { + if (message.type() !== 'error') return; + const code = classifyConsole(message.text()); + errors.console[code] = (errors.console[code] ?? 0) + 1; + }); + page.on('requestfailed', request => { + const url = new URL(request.url()); + const group = url.origin === origin ? 'application' : /naver|pstatic/.test(url.hostname) ? 'map_provider' + : /supabase/.test(url.hostname) ? 'persistence' : 'external'; + const key = `${group}:${request.resourceType()}`; + failedResources[key] = (failedResources[key] ?? 0) + 1; + }); + try { + const response = await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 45000 }); + assert.equal(response.status(), 200); + const health = await page.evaluate(async () => { + const d = await (await fetch('/api/health')).json(); + return { ok: d.ok, gitSha: d.gitSha, deploymentId: d.deploymentId, projectId: d.projectId }; + }); + assert.equal(health.gitSha, expectedSha); + assert.equal(health.projectId, 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl'); + await page.waitForFunction(() => { + const map = document.querySelector('[data-testid="map-container"]'); + return window.naver?.maps?.Map && map && Array.from(map.querySelectorAll('img')).some(i => i.complete && i.naturalWidth > 0) + && document.querySelector('.cluster-marker-container,[data-testid="marker"]'); + }, null, { timeout: 45000 }); + const popular = page.getByRole('button', { name: /인기 맛집 상세 보기/ }).first(); + await expect(popular).toBeVisible({ timeout: 30000 }); + await popular.click(); + await expect(page.getByTestId('restaurant-detail-panel')).toBeVisible(); + // Let the selected restaurant's map animation finish before this observation. + await page.waitForTimeout(600); + if (sample === 0) { + await page.screenshot({ path: new URL('desktop.png', output).pathname }); + files.push('desktop.png'); + } + await page.evaluate(() => { + const map = document.querySelector('[data-testid="map-container"]'); + const panel = document.querySelector('[data-testid="restaurant-detail-panel"]'); + const state = { map, panel, newMaps: 0, firstReadyMs: null, frames: 0, markerlessFrames: 0, + tilelessFrames: 0, missingMapFrames: 0, maxGapMs: 0, done: false }; + window.__liveReadback = state; + const Original = window.naver.maps.Map; + window.naver.maps.Map = new Proxy(Original, { construct(target, args) { + state.newMaps++; + return Reflect.construct(target, args); + } }); + const frame = time => { + if (innerWidth < 1280) { + state.firstFrame ??= time; + state.frames++; + const current = document.querySelector('[data-testid="map-container"]'); + const marker = !!document.querySelector('.cluster-marker-container,[data-testid="marker"]'); + const tile = current && Array.from(current.querySelectorAll('img')).some(i => i.complete && i.naturalWidth > 0); + if (!current) state.missingMapFrames++; + if (!marker) state.markerlessFrames++; + if (!tile) state.tilelessFrames++; + if (marker && tile && document.querySelector('[data-testid="bottom-nav"]') && state.firstReadyMs === null) { + state.firstReadyMs = performance.now() - state.started; + } + if (state.previous !== undefined) state.maxGapMs = Math.max(state.maxGapMs, time - state.previous); + state.previous = time; + if (time - state.firstFrame >= 1000) state.done = true; + } + if (!state.done) requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + state.started = performance.now(); + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForFunction(() => window.__liveReadback.done, null, { timeout: 15000 }); + const observation = await page.evaluate(() => { + const s = window.__liveReadback; + const map = document.querySelector('[data-testid="map-container"]'); + return { newMaps: s.newMaps, firstReadyMs: s.firstReadyMs, observedFrames: s.frames, + markerlessFrames: s.markerlessFrames, tilelessFrames: s.tilelessFrames, missingMapFrames: s.missingMapFrames, + maxGapMs: s.maxGapMs, mapRetained: map === s.map, + detailPanelRetained: document.querySelector('[data-testid="restaurant-detail-panel"]') === s.panel, + loadedMapImages: map ? Array.from(map.querySelectorAll('img')).filter(i => i.complete && i.naturalWidth > 0).length : 0, + markerCount: document.querySelectorAll('.cluster-marker-container,[data-testid="marker"]').length, + mainCount: document.querySelectorAll('main#main-content').length, + horizontalOverflowPx: Math.max(0, document.documentElement.scrollWidth - innerWidth) }; + }); + if (sample === 0) { + await page.screenshot({ path: new URL('mobile.png', output).pathname }); + files.push('mobile.png'); + } + const finalSha = await page.evaluate(async () => (await (await fetch('/api/health')).json()).gitSha); + assert.equal(finalSha, expectedSha); + results.push({ sample, health, observation, errors, failedResources }); + console.log(JSON.stringify({ sample, newMaps: observation.newMaps, mapRetained: observation.mapRetained, + tilelessFrames: observation.tilelessFrames, markerlessFrames: observation.markerlessFrames })); + } finally { await context.close(); } + } + await writeFile(new URL('raw.json', output), JSON.stringify({ scope, capturedAt: new Date().toISOString(), + environment: { node: process.version, chromium: browser.version(), authenticated: false }, expectedSha, results }, null, 2) + '\n'); + const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + const artifacts = {}; + for (const name of files) artifacts[name] = hash(await readFile(new URL(name, output))); + artifacts['../live-readback.mjs'] = hash(await readFile(new URL('live-readback.mjs', root))); + const map = JSON.stringify({ scope, expectedSha, artifacts }, null, 2) + '\n'; + await writeFile(new URL('artifact-map.json', output), map); + console.log(JSON.stringify({ artifactMapSha256: hash(map), completed: results.length })); +} finally { await browser.close(); } diff --git a/apps/web/performance/home-live-readback-20260930/verify-live.mjs b/apps/web/performance/home-live-readback-20260930/verify-live.mjs new file mode 100644 index 0000000000..52d6977426 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/verify-live.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; + +const [label, pin, expectedSha, kind, mode] = process.argv.slice(2); +assert.match(label ?? '', /^[a-z0-9-]+$/); +assert.match(pin ?? '', /^[a-f0-9]{64}$/); +assert.match(expectedSha ?? '', /^[a-f0-9]{40}$/); +assert.ok(['baseline', 'candidate'].includes(kind)); +assert.ok(mode === undefined || mode === '--score'); +const root = new URL(`${label}/`, import.meta.url); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const bytes = await readFile(new URL('artifact-map.json', root)); +assert.equal(hash(bytes), pin, 'detached map pin'); +const map = JSON.parse(bytes); +assert.equal(map.expectedSha, expectedSha); +const allowed = new Set(['raw.json', 'desktop.png', 'mobile.png', '../live-readback.mjs', '../webkit-readback.mjs']); +for (const [name, expected] of Object.entries(map.artifacts)) { + assert.ok(allowed.has(name), 'bounded artifact path'); + assert.equal(hash(await readFile(new URL(name, root))), expected, name); +} +for (const name of ['raw.json', 'desktop.png', 'mobile.png']) assert.ok(map.artifacts[name]); +assert.equal(Object.keys(map.artifacts).length, 4); +const raw = JSON.parse(await readFile(new URL('raw.json', root))); +assert.equal(raw.expectedSha, expectedSha); +assert.equal(raw.environment.authenticated, false); +assert.match(raw.environment.node, /^v24\./); +assert.equal(raw.results.length, 5); +for (const [index, result] of raw.results.entries()) { + assert.equal(result.sample, index); + assert.equal(result.health.ok, true); + assert.equal(result.health.gitSha, expectedSha); + assert.equal(result.health.projectId, 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl'); + assert.match(result.health.deploymentId, /^dpl_[a-zA-Z0-9]+$/); + assert.equal(result.errors.page, 0); + const o = result.observation; + for (const metric of ['newMaps', 'firstReadyMs', 'observedFrames', 'markerlessFrames', 'tilelessFrames', + 'missingMapFrames', 'maxGapMs', 'loadedMapImages', 'markerCount', 'mainCount', 'horizontalOverflowPx']) { + assert.ok(Number.isFinite(o[metric]) && o[metric] >= 0, metric); + } + assert.ok(o.observedFrames > 0 && o.loadedMapImages > 0 && o.markerCount > 0); + assert.equal(o.mainCount, 1); + assert.equal(o.horizontalOverflowPx, 0); + assert.equal(o.newMaps, kind === 'baseline' ? 1 : 0); + assert.equal(o.mapRetained, kind === 'candidate'); + // Desktop detail lives in the control panel; mobile detail lives in the + // map sheet. Their DOM nodes intentionally differ, even when map state survives. + assert.equal(o.detailPanelRetained, false, 'layout-specific detail DOM is replaced'); +} +const median = values => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +const summaries = {}; +for (const metric of ['firstReadyMs', 'markerlessFrames', 'tilelessFrames', 'maxGapMs']) { + const values = raw.results.map(result => result.observation[metric]); + const center = median(values); + summaries[metric] = { median: center, mad: median(values.map(value => Math.abs(value - center))), + min: Math.min(...values), max: Math.max(...values), classification: 'sequential_observation_only' }; +} +const scored = { scope: raw.scope, expectedSha, kind, artifactMapSha256: pin, samples: 5, + admission: 'Map identity verified; layout-specific detail DOM replacement verified. Selection persistence is covered separately by browser regression. No paired speedup or pixel-level flicker admission.', + timingBudgets: { absoluteMs: 1, relativeFraction: 0.05, noiseMadMultiplier: 2 }, summaries }; +if (mode === '--score') await writeFile(new URL('scored-v2.json', root), JSON.stringify(scored, null, 2) + '\n', { flag: 'wx' }); +else assert.deepEqual(JSON.parse(await readFile(new URL('scored-v2.json', root))), scored); +console.log(JSON.stringify({ verified: true, samples: 5, kind, expectedSha, artifactMapSha256: pin, + scoreSha256: hash(JSON.stringify(scored, null, 2) + '\n') })); diff --git a/apps/web/performance/home-live-readback-20260930/webkit-baseline-v1/rejected-launch.json b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v1/rejected-launch.json new file mode 100644 index 0000000000..96f6c7ec04 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v1/rejected-launch.json @@ -0,0 +1,5 @@ +{ + "reason": "project-pinned WebKit 2336 binary was absent", + "olderCache": "webkit-2311 was not substituted", + "admittedSamples": 0 +} diff --git a/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/artifact-map.json b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/artifact-map.json new file mode 100644 index 0000000000..a967b872ee --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/artifact-map.json @@ -0,0 +1,10 @@ +{ + "scope": "Five isolated anonymous WebKit production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "artifacts": { + "raw.json": "c2e9ce1041fce85d28fabc466babedd6830d415aee0b30af5ae13ccdfeaf7b37", + "desktop.png": "03bb67ad5f038d473a68ce062449046c629962fe94e1f4632ef65dc2e3e56c53", + "mobile.png": "b01fff0d601c72d97a2d7d2ac953fd12c05cf507c4fed3ef8bb1004db4754273", + "../webkit-readback.mjs": "64bf5577bf1ad1bb795606cbbaccfe3bd49053080d6debe535d2ddb96ded3e6d" + } +} diff --git a/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/desktop.png b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/desktop.png new file mode 100644 index 0000000000..5d64aa6af8 Binary files /dev/null and b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/desktop.png differ diff --git a/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/mobile.png b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/mobile.png new file mode 100644 index 0000000000..59b29e2b62 Binary files /dev/null and b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/mobile.png differ diff --git a/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/raw.json b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/raw.json new file mode 100644 index 0000000000..9e8ea3b7bd --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/raw.json @@ -0,0 +1,172 @@ +{ + "scope": "Five isolated anonymous WebKit production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "capturedAt": "2026-09-30T04:38:50.956Z", + "environment": { + "node": "v24.21.0", + "webkit": "26.5", + "authenticated": false + }, + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "results": [ + { + "sample": 0, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 90, + "observedFrames": 57, + "markerlessFrames": 2, + "tilelessFrames": 1, + "missingMapFrames": 0, + "maxGapMs": 47, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": {} + }, + { + "sample": 1, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 76, + "observedFrames": 61, + "markerlessFrames": 2, + "tilelessFrames": 1, + "missingMapFrames": 0, + "maxGapMs": 47, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": {} + }, + { + "sample": 2, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 87, + "observedFrames": 59, + "markerlessFrames": 2, + "tilelessFrames": 1, + "missingMapFrames": 0, + "maxGapMs": 46, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": {} + }, + { + "sample": 3, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 88, + "observedFrames": 62, + "markerlessFrames": 2, + "tilelessFrames": 1, + "missingMapFrames": 0, + "maxGapMs": 49, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": {} + }, + { + "sample": 4, + "health": { + "ok": true, + "gitSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "deploymentId": "dpl_AfWiDxscA9Jt4hq7V5QEBRDe9ibn", + "projectId": "prj_sau35J5uUtShIQ9OKofRtOVVnTSl" + }, + "observation": { + "newMaps": 1, + "firstReadyMs": 66, + "observedFrames": 62, + "markerlessFrames": 2, + "tilelessFrames": 1, + "missingMapFrames": 0, + "maxGapMs": 36, + "mapRetained": false, + "detailPanelRetained": false, + "loadedMapImages": 89, + "markerCount": 16, + "mainCount": 1, + "horizontalOverflowPx": 0 + }, + "errors": { + "page": 0, + "console": { + "other_console_error": 2, + "resource_load_failure": 2 + } + }, + "failedResources": {} + } + ] +} diff --git a/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/scored-v2.json b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/scored-v2.json new file mode 100644 index 0000000000..7bc9d2ac81 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/scored-v2.json @@ -0,0 +1,43 @@ +{ + "scope": "Five isolated anonymous WebKit production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "kind": "baseline", + "artifactMapSha256": "ecb324522892c8b8bd46cc36ae10284f6ef43aceedfd1302ccbf971934a9fa38", + "samples": 5, + "admission": "Map identity verified; layout-specific detail DOM replacement verified. Selection persistence is covered separately by browser regression. No paired speedup or pixel-level flicker admission.", + "timingBudgets": { + "absoluteMs": 1, + "relativeFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "summaries": { + "firstReadyMs": { + "median": 87, + "mad": 3, + "min": 66, + "max": 90, + "classification": "sequential_observation_only" + }, + "markerlessFrames": { + "median": 2, + "mad": 0, + "min": 2, + "max": 2, + "classification": "sequential_observation_only" + }, + "tilelessFrames": { + "median": 1, + "mad": 0, + "min": 1, + "max": 1, + "classification": "sequential_observation_only" + }, + "maxGapMs": { + "median": 47, + "mad": 1, + "min": 36, + "max": 49, + "classification": "sequential_observation_only" + } + } +} diff --git a/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/scored.json b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/scored.json new file mode 100644 index 0000000000..357555706a --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/webkit-baseline-v2/scored.json @@ -0,0 +1,43 @@ +{ + "scope": "Five isolated anonymous WebKit production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.", + "expectedSha": "157ced98a2414d434132c99f74c72bbe5f796ca4", + "kind": "baseline", + "artifactMapSha256": "ecb324522892c8b8bd46cc36ae10284f6ef43aceedfd1302ccbf971934a9fa38", + "samples": 5, + "admission": "Identity invariants verified. No paired speedup or pixel-level flicker admission.", + "timingBudgets": { + "absoluteMs": 1, + "relativeFraction": 0.05, + "noiseMadMultiplier": 2 + }, + "summaries": { + "firstReadyMs": { + "median": 87, + "mad": 3, + "min": 66, + "max": 90, + "classification": "sequential_observation_only" + }, + "markerlessFrames": { + "median": 2, + "mad": 0, + "min": 2, + "max": 2, + "classification": "sequential_observation_only" + }, + "tilelessFrames": { + "median": 1, + "mad": 0, + "min": 1, + "max": 1, + "classification": "sequential_observation_only" + }, + "maxGapMs": { + "median": 47, + "mad": 1, + "min": 36, + "max": 49, + "classification": "sequential_observation_only" + } + } +} diff --git a/apps/web/performance/home-live-readback-20260930/webkit-readback.mjs b/apps/web/performance/home-live-readback-20260930/webkit-readback.mjs new file mode 100644 index 0000000000..d1b7451691 --- /dev/null +++ b/apps/web/performance/home-live-readback-20260930/webkit-readback.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { webkit, expect } from '@playwright/test'; + +const [label, expectedSha] = process.argv.slice(2); +assert.match(label ?? '', /^[a-z0-9-]+$/); +assert.match(expectedSha ?? '', /^[a-f0-9]{40}$/); +assert.equal(Number(process.versions.node.split('.')[0]), 24); +const root = new URL('./', import.meta.url); +const output = new URL(`${label}/`, root); +await mkdir(output); +const origin = 'https://www.tzudong.app'; +const browser = await webkit.launch({ headless: true }); +const results = [], files = ['raw.json']; +const scope = 'Five isolated anonymous WebKit production visits with the real Naver SDK and catalog. Before/after groups are sequential, not alternating paired performance evidence. Image and DOM predicates are observations, not field INP/LCP or physical-device evidence.'; +const classifyConsole = text => text.includes('Failed to load resource') ? 'resource_load_failure' + : /hydration|Hydration/.test(text) ? 'hydration_error' : 'other_console_error'; +try { + for (let sample = 0; sample < 5; sample++) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, hasTouch: true, locale: 'ko-KR' }); + const page = await context.newPage(); + const errors = { page: 0, console: {} }, failedResources = {}; + page.on('pageerror', () => errors.page++); + page.on('console', message => { + if (message.type() !== 'error') return; + const code = classifyConsole(message.text()); + errors.console[code] = (errors.console[code] ?? 0) + 1; + }); + page.on('requestfailed', request => { + const url = new URL(request.url()); + const group = url.origin === origin ? 'application' : /naver|pstatic/.test(url.hostname) ? 'map_provider' + : /supabase/.test(url.hostname) ? 'persistence' : 'external'; + const key = `${group}:${request.resourceType()}`; + failedResources[key] = (failedResources[key] ?? 0) + 1; + }); + try { + const response = await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 45000 }); + assert.equal(response.status(), 200); + const health = await page.evaluate(async () => { + const d = await (await fetch('/api/health')).json(); + return { ok: d.ok, gitSha: d.gitSha, deploymentId: d.deploymentId, projectId: d.projectId }; + }); + assert.equal(health.gitSha, expectedSha); + assert.equal(health.projectId, 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl'); + await page.waitForFunction(() => { + const map = document.querySelector('[data-testid="map-container"]'); + return window.naver?.maps?.Map && map && Array.from(map.querySelectorAll('img')).some(i => i.complete && i.naturalWidth > 0) + && document.querySelector('.cluster-marker-container,[data-testid="marker"]'); + }, null, { timeout: 45000 }); + const popular = page.getByRole('button', { name: /인기 맛집 상세 보기/ }).first(); + await expect(popular).toBeVisible({ timeout: 30000 }); + await popular.click(); + await expect(page.getByTestId('restaurant-detail-panel')).toBeVisible(); + // Let the selected restaurant's map animation finish before this observation. + await page.waitForTimeout(600); + if (sample === 0) { + await page.screenshot({ path: new URL('desktop.png', output).pathname }); + files.push('desktop.png'); + } + await page.evaluate(() => { + const map = document.querySelector('[data-testid="map-container"]'); + const panel = document.querySelector('[data-testid="restaurant-detail-panel"]'); + const state = { map, panel, newMaps: 0, firstReadyMs: null, frames: 0, markerlessFrames: 0, + tilelessFrames: 0, missingMapFrames: 0, maxGapMs: 0, done: false }; + window.__liveReadback = state; + const Original = window.naver.maps.Map; + window.naver.maps.Map = new Proxy(Original, { construct(target, args) { + state.newMaps++; + return Reflect.construct(target, args); + } }); + const frame = time => { + if (innerWidth < 1280) { + state.firstFrame ??= time; + state.frames++; + const current = document.querySelector('[data-testid="map-container"]'); + const marker = !!document.querySelector('.cluster-marker-container,[data-testid="marker"]'); + const tile = current && Array.from(current.querySelectorAll('img')).some(i => i.complete && i.naturalWidth > 0); + if (!current) state.missingMapFrames++; + if (!marker) state.markerlessFrames++; + if (!tile) state.tilelessFrames++; + if (marker && tile && document.querySelector('[data-testid="bottom-nav"]') && state.firstReadyMs === null) { + state.firstReadyMs = performance.now() - state.started; + } + if (state.previous !== undefined) state.maxGapMs = Math.max(state.maxGapMs, time - state.previous); + state.previous = time; + if (time - state.firstFrame >= 1000) state.done = true; + } + if (!state.done) requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + state.started = performance.now(); + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForFunction(() => window.__liveReadback.done, null, { timeout: 15000 }); + const observation = await page.evaluate(() => { + const s = window.__liveReadback; + const map = document.querySelector('[data-testid="map-container"]'); + return { newMaps: s.newMaps, firstReadyMs: s.firstReadyMs, observedFrames: s.frames, + markerlessFrames: s.markerlessFrames, tilelessFrames: s.tilelessFrames, missingMapFrames: s.missingMapFrames, + maxGapMs: s.maxGapMs, mapRetained: map === s.map, + detailPanelRetained: document.querySelector('[data-testid="restaurant-detail-panel"]') === s.panel, + loadedMapImages: map ? Array.from(map.querySelectorAll('img')).filter(i => i.complete && i.naturalWidth > 0).length : 0, + markerCount: document.querySelectorAll('.cluster-marker-container,[data-testid="marker"]').length, + mainCount: document.querySelectorAll('main#main-content').length, + horizontalOverflowPx: Math.max(0, document.documentElement.scrollWidth - innerWidth) }; + }); + if (sample === 0) { + await page.screenshot({ path: new URL('mobile.png', output).pathname }); + files.push('mobile.png'); + } + const finalSha = await page.evaluate(async () => (await (await fetch('/api/health')).json()).gitSha); + assert.equal(finalSha, expectedSha); + results.push({ sample, health, observation, errors, failedResources }); + console.log(JSON.stringify({ sample, newMaps: observation.newMaps, mapRetained: observation.mapRetained, + tilelessFrames: observation.tilelessFrames, markerlessFrames: observation.markerlessFrames })); + } finally { await context.close(); } + } + await writeFile(new URL('raw.json', output), JSON.stringify({ scope, capturedAt: new Date().toISOString(), + environment: { node: process.version, webkit: browser.version(), authenticated: false }, expectedSha, results }, null, 2) + '\n'); + const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + const artifacts = {}; + for (const name of files) artifacts[name] = hash(await readFile(new URL(name, output))); + artifacts['../webkit-readback.mjs'] = hash(await readFile(new URL('webkit-readback.mjs', root))); + const map = JSON.stringify({ scope, expectedSha, artifacts }, null, 2) + '\n'; + await writeFile(new URL('artifact-map.json', output), map); + console.log(JSON.stringify({ artifactMapSha256: hash(map), completed: results.length })); +} finally { await browser.close(); } diff --git a/apps/web/performance/home-viewport-continuity-20260930/.gitattributes b/apps/web/performance/home-viewport-continuity-20260930/.gitattributes new file mode 100644 index 0000000000..c3f4ad9b88 --- /dev/null +++ b/apps/web/performance/home-viewport-continuity-20260930/.gitattributes @@ -0,0 +1,2 @@ +# Keep measurement log bytes immutable; code whitespace checks still apply. +*.log -whitespace diff --git a/apps/web/performance/home-viewport-continuity-20260930/REPORT.md b/apps/web/performance/home-viewport-continuity-20260930/REPORT.md new file mode 100644 index 0000000000..72fe8d9446 --- /dev/null +++ b/apps/web/performance/home-viewport-continuity-20260930/REPORT.md @@ -0,0 +1,223 @@ +# Home viewport continuity: reproduced remount and retained measurements + +Date: 2026-09-30. Frozen baseline: `157ced98a2414d434132c99f74c72bbe5f796ca4`. + +The home shell previously replaced `HomeRuntimePendingShell` with +`MobileHomeLayout` at the mobile/tablet boundary. That change of ancestor type +discarded `HomeClient`, its map instance and its in-memory state. The candidate +keeps the map under the same main element and React child position in pending, +desktop and mobile modes. Mobile navigation/modals are siblings of that main; +their existing effects and lazy-loading boundaries remain in place. + +This fixes one reproduced remount cause. The overall rendering/flicker goal is +still active: live-provider frame/pixel evidence, authenticated interactions and +paired page metrics across the other public routes are not established here. + +## Retained results + +Each admitted timed result uses 31 paired samples, alternating variant order. +Times are medians. Delta is candidate minus baseline. Noise is twice the larger +median absolute deviation (MAD). A delta is admitted only when its absolute value +exceeds the absolute budget, 5% of the baseline and noise. + +| Measurement | Baseline | Candidate | Absolute delta | Relative delta | Noise | Absolute budget | Classification | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| Structural React lab transition, 200 memoized rows | 0.445 ms | 0.075 ms | −0.370 ms | −83.146% | 0.060 ms | 0.05 ms | Local improvement | +| Local production route: mobile nav and marker DOM ready after resize | 27.5 ms | 18.7 ms | −8.8 ms | −32.0% | 4.8 ms | 1 ms | Local improvement | +| Local production route: frames with no marker DOM | 1 frame | 0 frames | −1 frame | −100% | 0 frames | 0 frames | Local improvement | +| Local production route: maximum frame callback gap | 9.3 ms | 9.3 ms | 0 ms | 0% | <0.001 ms | 1 ms | Below budget/noise | + +Structural lab row mounts/unmounts per boundary transition are **200→0**. +The complete Next route creates **1→0** new simulated provider map objects per +desktop→mobile transition. All 31 candidate route samples retained both the map +container and provider instance. Both variants had zero horizontal overflow and +one `main#main-content`. + +## What was exercised + +The structural lab compiles the actual frozen/current `HomeRuntimeShell` source +with production React. It controls the viewport mode and substitutes service +contexts and lazy chrome; the subtree has 200 memoized synthetic rows. Four +warmup pairs precede 31 scored pairs. Fourteen checks cover pending→desktop, +pending→mobile, both boundary directions, same-mode updates, and server markup +hydration followed by a controlled mode transition. The candidate preserves the +map DOM, selected synthetic value, main element and focus in every check. The +baseline loses them only on transitions that replace the mobile ancestor. + +The route experiment uses separately built Next.js 16.3.5 production bundles, +Node 24.21.0, the existing three public restaurant fixtures, and the existing +simulated Naver provider. Both variants run sequentially behind the same local +origin; the fixture helper's exact-origin check is preserved. External requests +and WebSockets are blocked. Two warmup pairs precede 31 scored pairs. + +The route readiness metric starts before the browser resize command and ends +when a frame sees the target viewport, mobile navigation, a map container and +marker DOM. It includes command transport and rendering, and is **not INP**. +Frames are then observed for a fixed one-second window. Marker absence is a DOM +predicate, not a pixel blankness detector; the simulated provider has no real map +tiles. Screenshots confirm layout geometry and the controlled detail state. + +The final candidate also passed the persisted Playwright regression for mobile search +selection followed by mobile→desktop→mobile, retaining the same map container +and provider creation count. A Chromium CDP touch gesture emitted one trusted +`touchstart` and changed the selected detail from 정원분식 to 명동칼국수. This +exercises the native Chromium input/event pipeline; it does not prove physical +device/WebKit behavior or a live Naver SDK gesture. + +There were zero page runtime errors. Each sampled page in both variants recorded +two console errors under the external-request fence. Their exact cause remains +unclassified, so this experiment does not establish an error-free complete page. +Raw load/LCP counters are retained for further analysis, but no page-load gain +is admitted from them in this report. + +## Mathematical interpretation + +React retains state when component type, key and position remain stable. For R +descendant rows, replacing the ancestor triggers R cleanup/mount pairs. Keeping +that ancestor removes this O(R) remount work and preserves the provider instance. +The candidate still runs mobile chrome effects, reconciliation, device-specific +marker/layout updates and context-driven renders. Total page rendering is not +claimed to be O(1), and no live SDK initialization complexity is assumed. + +For paired timings B and C, the reported delta is median(C)−median(B), the +relative delta is 100×delta/median(B), and the noise budget is +2×max(MAD(B), MAD(C)). For the structural case, 0.370 ms exceeds 0.05 ms, +0.05×0.445=0.02225 ms, and 0.060 ms. For route readiness, 8.8 ms exceeds 1 ms, +0.05×27.5=1.375 ms, and 4.8 ms. The maximum callback-gap delta is below its 1 ms +budget and is not an improvement. + +## Provenance and verification + +Detached artifact-map pins, emitted by the measurement commands and independently +verified: + +- `candidate-v2/artifact-map.json`: `c5c6a7948a6802529643d6d86341ed0c13aa0c7a9eda9130b2a87f0427d97ac9` +- `route-v8/artifact-map.json`: `100c44205fe507c307046a0ce84b965d83b99e4b5040f99c25dad7ed7f1f4de8` + +Both verifiers re-read artifact hashes and current affected source and recompute +medians, deltas, noise and admission from retained raw pairs. Build receipts bind +the two route bundles to retained build IDs, full commit/tree identity, and five +baseline/six candidate frozen input hashes. The +baseline is copied from the exact frozen Git tree; candidate code is also archived from its immutable commit and +locked dependencies are copied into task-owned scratch. Neither build replaces +files in the caller checkout. Audits do not require transient `.next` trees. Frozen source, +the browser bundle, raw pairs, scored outputs, screenshots and regression output +remain in this directory. Earlier structural runs and failed route setup runs +are retained separately and are not substituted for the admitted runs. + +Both production fixture builds passed compilation, TypeScript and all 48 static +pages. Native/stable type parity passed with 2,517 logical inputs and zero +diagnostics. The initial full unit suite had 2,444 passes, one skip and one stale +source-string assertion failure. The assertion that required the old ancestor +branch was removed from both affected source-contract files; their 38 tests +then passed. The new browser regression supplies behavioral coverage for the +replaced shape. After review repairs, all 72 affected unit tests passed with +3,318 expectations, type parity passed again, and all three browser regressions +passed. The second regression enters fullscreen through the mock SDK click +event and verifies desktop entry plus return to mobile; it is fixture input +evidence, not a native or physical-device click claim. Full source lint passed after excluding the two generated local +measurement build directories; application and measurement scripts were included. +The route CSS boundary verifier also passed against the candidate build: home +192,639 raw bytes / 45,626 gzip bytes, general/admin 370,197 raw bytes / 67,656 +gzip bytes. These are validation outputs, not a CSS reduction claim. + +## Remaining goal evidence + +- Read back the live SDK and deployed revision after this source change is promoted. +- Measure actual tile/marker pixels and browser frame timing under real catalog + and provider load; include desktop/mobile and the reported flicker triggers. +- Complete authenticated-session and physical-device/WebKit interaction coverage. +- Analyze the retained load counters and extend paired measurements to the other + affected public routes before making an overall performance claim. +- Investigate the two fenced-console errors and revalidate the separate + over-capacity swipe-cache regression rather than calling all scenarios faster. + +This report is local evidence. Protected promotion, deployment and release for +this continuation were not performed at the time this report was written. + +## Review repairs and current evidence + +The detail sheet and desktop detail renderer now receive the same viewport value +from HomeClient, removing their former independent 50 ms debounce window. Mobile +fullscreen is derived only in mobile mode and its stored flag is cleared on +desktop entry. The browser test samples every animation frame during both +breakpoint directions and never observes more than one detail panel. This is a +state-consistency assertion, not an additional timed performance claim. + +The provider mock and map-creation counter execute in one init script, and tests +require a positive numeric initial count. The Node executable is configurable +and version-checked. The final route run is `route-v8`; intermediate v4/v5 test +setup failures are retained and are not admitted measurements. Old v3 raw values +remain historical evidence, auditable with `--frozen-only`, rather than current +route results. The source-only structural v2 result remains applicable because +the measured shell bytes are unchanged. + +Five anonymous real-provider visits to production SHA `157ced98...` reproduced +one new map per breakpoint transition and lost map/detail DOM identity in every +visit. Two frames per visit had no loaded image anywhere inside the map +container. This image predicate includes markers/attribution and is not a +pixel-level tile blankness detector. The retained live baseline is under +`../home-live-readback-20260930/baseline-v1/`; its detached map hash is +`df298024193ceff7977e6da0271accbade5b420e2cb2c4af71695062f707cb84`. +These visits are sequential observations; they establish the live trigger, not +an alternating paired speedup. Candidate production readback is still pending. + +A one-visit diagnostic classified a blocked fixture persistence request and, +on production, a provider CSP block plus two persistence HTTP 401 responses. +It does not fully classify the two errors in every historical resize sample. +Fixed codes only are retained in `../home-live-readback-20260930/diagnostic-v1/`. +The independent cache revalidation retained all six scenarios and again admitted +the 128-search over-capacity regression; see its separate report. + +The final baseline build (`build-baseline-v3`) is bound to commit +`157ced98a2414d434132c99f74c72bbe5f796ca4` and tree +`2de4984fffa83011c67b7ccb9336a2c163517981`. The final candidate build +(`build-candidate-v5`) is bound to commit +`5a6d9f5a585656d89cad1e51766f2352522d1fc6` and tree +`17a82911d123526cccbae7a84a4d55bdb0c4007e`. Both are archived full code +trees, not caller working-file snapshots. The candidate v3 attempt failed +Google font fetching; it produced no admitted sample. A direct font endpoint +check returned HTTP 200 and the subsequent serial v4 build passed. Older v6 +results and v7 remain historical and are superseded by v8 for current route claims. + +WebKit 26.5 also reproduced one provider recreation and lost map DOM identity in +all five baseline visits. Its retained pin is +`ecb324522892c8b8bd46cc36ae10284f6ef43aceedfd1302ccbf971934a9fa38`. +`../home-live-readback-20260930/verify-live.mjs` independently checks detached +pins, retained hashes, exactly five observations, deployment/project SHA, +anonymous scope, bounded metrics and expected identity invariants. Scored +outputs classify timings as sequential observations without speedup admission. +A clean scratch audit accepted the original data and rejected altered raw data, +wrong expected SHA/kind, and an incomplete run even with consistent new hashes. + + +## Additional boundary-state review + +Desktop collapse is now desktop-scoped, and HomeControlPanel consumes the same +viewport prop as the map detail renderer instead of its independent debounced +hook and resize subscriber. Mobile sheet layout variables reset synchronously +before paint on desktop entry; interaction refs reset for the next mobile entry. +New behavioral checks cover collapsed desktop detail becoming visible on mobile, +root CSS ownership reset, and frame-level control chrome agreement. The v7 route +result predates these repairs. The final v8 run uses the full committed repair +tree and passes all three browser regressions, including control-chrome frame +agreement and desktop-collapse/mobile-globals checks. + +The live verifier now explicitly expects layout-specific detail DOM replacement +in both variants: desktop detail is under the control panel, mobile detail under +the map sheet. It verifies map identity, not a false promise of retaining the +same detail DOM node. Browser regression separately verifies selected detail. +Updated scoring is retained as scored-v2.json; historical scores are preserved. +The route verifier binds raw receipts to their pinned retained receipts, checks +commit^{tree} against Git and rehashes committed frozen inputs and build logs. +Clean scratch audits supply TZUDONG_EVIDENCE_GIT_REPOSITORY for immutable Git +objects; they still require no transient Next build trees. Negative checks reject +mis-associated receipts, wrong trees with consistent artifact hashes, and a +wrong detail DOM identity; see review-audit-v2.json. + +The current v8 raw pairs and receipt audit passed in the working checkout and +in clean scratch with only retained artifacts plus explicit Git objects. See +route-v8-audit.json. All 31 candidate samples retain the map/provider and the +trusted Chromium touch changes the selected fixture detail. This remains local +fixture evidence; the new live pixel baseline is retained separately under +../home-pixel-capture-20260930/REPORT.md and candidate deployment is pending. diff --git a/apps/web/performance/home-viewport-continuity-20260930/baseline-home-runtime-shell.tsx.txt b/apps/web/performance/home-viewport-continuity-20260930/baseline-home-runtime-shell.tsx.txt new file mode 100644 index 0000000000..de4b78f5e1 --- /dev/null +++ b/apps/web/performance/home-viewport-continuity-20260930/baseline-home-runtime-shell.tsx.txt @@ -0,0 +1,370 @@ +'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 { HomeSessionProviderBridge } from '@/components/home/home-session-provider-bridge'; +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); + + 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 ( +