Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/web/app/home-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,17 +224,22 @@ export default function HomeClient() {
const [activeRightPanel, setActiveRightPanel] = useState<PanelType>(null);
const [selectedAnnouncement, setSelectedAnnouncement] =
useState<Announcement | null>(null);
const [isPanelCollapsed, setIsPanelCollapsed] = useState(
const [desktopPanelCollapsed, setIsPanelCollapsed] = useState(
() => DEFAULT_HOME_MAP_USER_PREFERENCES.desktopPanelDefault === "collapsed",
);
const isPanelCollapsed = isDesktop && desktopPanelCollapsed;
const [desktopMapLayout, setDesktopMapLayout] = useState<HomeMapLayoutMode>(
DEFAULT_HOME_MAP_USER_PREFERENCES.desktopMapLayout,
);
const [desktopPanelSide, setDesktopPanelSide] = useState<HomeMapPanelSide>(
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<HomeMapContextualRestaurantsPayload | null>(null);
const [mapInteractionEpoch, setMapInteractionEpoch] = useState(0);
Expand Down Expand Up @@ -1269,6 +1274,7 @@ export default function HomeClient() {
)}
<HomeMapContainer
key={mapMountKey}
viewportMode={viewportMode}
mapMode={mapMode}
mapFocusZoom={mapFocusZoom} // [New] 줌 레벨 전달
filters={state.filters}
Expand Down Expand Up @@ -1307,6 +1313,7 @@ export default function HomeClient() {

{isViewportResolved && !(isMobileOrTablet && isMapFullscreen) && (
<HomeControlPanel
viewportMode={viewportMode}
mapMode={mapMode}
selectedRegion={state.selectedRegion}
selectedCountry={state.selectedCountry}
Expand Down
49 changes: 24 additions & 25 deletions apps/web/app/home-runtime-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ function DeferredUserDataPrefetcher({ enabled }: { enabled: boolean }) {
return <UserDataPrefetcher />;
}

function MobileHomeLayout({ children }: { children: ReactNode }) {
function MobileHomeLayout() {
const { user, needsNicknameSetup, completeNicknameSetup } = useAuth();
const queryClient = useQueryClient();
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
Expand Down Expand Up @@ -236,21 +236,9 @@ function MobileHomeLayout({ children }: { children: ReactNode }) {


return (
<div className="flex overflow-hidden" style={{ height: 'var(--full-height, 100vh)' }}>
<>
<DeferredUserDataPrefetcher enabled={Boolean(user)} />

<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<a href="#main-content" className="skip-link">
본문 바로가기
</a>
<main id="main-content" className="relative flex-1 overflow-hidden" aria-label="쯔동여지도 지도 본문">
<HomeStaticSkeleton />
<div className="h-full w-full">
{children}
</div>
</main>
</div>

{!isPublicRestrictedMode && (
<div className="min-[1600px]:hidden">
<MobileBottomNav
Expand Down Expand Up @@ -293,21 +281,20 @@ function MobileHomeLayout({ children }: { children: ReactNode }) {
/>
</Suspense>
)}
</div>
</>
);
}

function HomeLayoutContent({ children }: { children: ReactNode }) {
const viewportMode = useHomeViewportMode();

if (viewportMode === 'mobileOrTablet') {
return <MobileHomeLayout>{children}</MobileHomeLayout>;
}

// 지도 본문은 pending과 desktop에서 같은 부모에 둔다.
// 오버레이 청크가 도착할 때 자식을 다른 부모로 옮기면 인기/최신 스켈레톤이 다시 마운트된다.
// 모든 뷰포트에서 지도 본문의 부모와 위치를 유지한다.
// 모바일 레이아웃을 조상으로 교체하면 지도와 검색/선택 상태가 다시 마운트된다.
return (
<HomeRuntimePendingShell>
<HomeRuntimePendingShell
isMobile={viewportMode === 'mobileOrTablet'}
mobileChrome={viewportMode === 'mobileOrTablet' ? <MobileHomeLayout /> : null}
>
{children}
{viewportMode === 'desktop' && !isPublicRestrictedMode ? (
<Suspense fallback={null}>
Expand Down Expand Up @@ -343,16 +330,28 @@ function HomeStaticSkeleton() {
);
}

function HomeRuntimePendingShell({ children }: { children: ReactNode }) {
function HomeRuntimePendingShell({ children, isMobile, mobileChrome }: {
children: ReactNode;
isMobile: boolean;
mobileChrome: ReactNode;
}) {
return (
<div className="flex flex-col bg-background text-foreground" style={{ height: 'var(--full-height, 100vh)' }}>
<div
className={`flex flex-col bg-background text-foreground${isMobile ? ' overflow-hidden' : ''}`}
style={{ height: 'var(--full-height, 100vh)' }}
>
<a href="#main-content" className="skip-link">
본문 바로가기
</a>
<main id="main-content" className="relative h-full min-h-0 w-full flex-1 bg-background" aria-label="쯔동여지도 지도 본문">
<main
id="main-content"
className={`relative h-full min-h-0 w-full flex-1 bg-background${isMobile ? ' overflow-hidden' : ''}`}
aria-label="쯔동여지도 지도 본문"
>
<HomeStaticSkeleton />
{children}
</main>
{mobileChrome}
</div>
);
}
Expand Down
46 changes: 7 additions & 39 deletions apps/web/components/home/home-control-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,14 +57,15 @@ const loadHomeDesktopControlPanel = async () => {
return mod.default as ComponentType<HomeDesktopControlPanelProps>;
};

type MobileControlOverlayProps = HomeControlPanelProps;
type MobileControlOverlayProps = Omit<HomeControlPanelProps, 'viewportMode'>;

const loadMobileControlOverlay = async () => {
const mod = await import('@/components/home/MobileControlOverlay');
return mod.default as ComponentType<MobileControlOverlayProps>;
};

export interface HomeControlPanelProps {
viewportMode: HomeViewportMode;
mapMode: 'domestic' | 'overseas';
selectedRegion: Region | null;
selectedCountry: string | null;
Expand Down Expand Up @@ -110,6 +111,7 @@ export interface HomeControlPanelProps {
}

function HomeControlPanelComponent({
viewportMode,
mapMode,
selectedRegion,
selectedCountry,
Expand Down Expand Up @@ -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<MobileControlOverlayIntent | null>(initialIntent);
const [shouldLoadDesktopPanel, setShouldLoadDesktopPanel] = useState(() => (
typeof window !== 'undefined' ? window.innerWidth > BREAKPOINTS.tabletMax : false
));
const shouldLoadDesktopPanel = viewportMode === 'desktop';
const DeferredMobileControlOverlay = useDeferredComponent<MobileControlOverlayProps>(
shouldRenderMobile && shouldLoadMobileOverlay,
loadMobileControlOverlay
Expand All @@ -178,39 +173,12 @@ function HomeControlPanelComponent({
if (!initialIntent) return;

setPendingMobileOverlayIntent(initialIntent);
setShouldLoadMobileOverlay(true);
}, [initialIntent]);
const DeferredHomeDesktopControlPanel = useDeferredComponent<HomeDesktopControlPanelProps>(
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;
Expand Down
21 changes: 14 additions & 7 deletions apps/web/components/home/home-map-container.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -143,6 +144,7 @@ const RESTAURANT_CONTENT_SCROLL_SELECTOR = "[data-restaurant-detail-swipe-area='

// [CSR] 지도 렌더링 및 그리드/단일 모드 처리 - 브라우저 전용 지도 라이브러리 사용
function HomeMapContainerComponent({
viewportMode,
mapMode,
mapFocusZoom,
filters,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -508,9 +517,7 @@ function HomeMapContainerComponent({
};
}, []);

useEffect(() => {
if (!isMobileOrTablet || !isPanelOpen) return;

useLayoutEffect(() => {
syncMobileLayout(sheetHeightRef.current);
}, [isMapFullscreen, isMobileOrTablet, isPanelOpen, syncMobileLayout]);

Expand Down
1 change: 1 addition & 0 deletions apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading