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
5 changes: 3 additions & 2 deletions .github/workflows/e2e-api-main.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
name: E2E By API Changes (main)

on:
pull_request:
branches: [main]
push:
branches:
- main
branches: [main]

permissions:
contents: read
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased (2026-02-26)

### Added
- `NaverMap`에 `suspense` 옵션을 추가해 SDK 로딩을 React `Suspense`/Error Boundary로 위임할 수 있도록 지원했습니다.

### Fixed
- `InfoWindow`에서 `visible/anchor/position` 제어 시 불필요한 재오픈과 상태 루프를 줄이도록 동기화 로직을 분리했습니다.
- `Panorama`의 controlled `visible` 동기화가 `setOptions` 경로에만 의존하지 않도록 보완했습니다.
5 changes: 4 additions & 1 deletion packages/docs/api/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface NaverMapLifecycleProps {
retryOnError?: boolean;
retryDelayMs?: number;
fallback?: React.ReactNode;
suspense?: boolean;
}

interface NaverMapEventProps {
Expand Down Expand Up @@ -268,6 +269,7 @@ export interface NaverMapRef {
| `retryOnError` | `boolean` | 에러 시 SDK 재시도 여부 |
| `retryDelayMs` | `number` | 재시도 지연(ms) |
| `fallback` | `React.ReactNode` | SDK 로딩/에러 시 표시할 대체 UI |
| `suspense` | `boolean` | SDK 로딩을 React Suspense로 처리 |

## 지도 이벤트 프로퍼티

Expand Down Expand Up @@ -388,4 +390,5 @@ function RefExample() {
- 언마운트 시 이벤트 리스너 정리, destroy 호출, 컨테이너 초기화까지 수행합니다.
- `defaultCenter`/`defaultZoom`은 uncontrolled 모드로, 초기값만 설정하고 이후 내부 상태로 관리합니다.
- `center`/`zoom`은 controlled 모드로, React 상태와 동기화합니다.
- `fallback`은 SDK 로딩 중(`loading`) 또는 에러 발생 시(`error`) 표시됩니다.
- `fallback`은 `suspense`가 `false`일 때 SDK 로딩 중(`loading`) 또는 에러 발생 시(`error`) 표시됩니다.
- `suspense`가 `true`이면 SDK 준비 전에는 Promise를 throw하여 `Suspense` fallback을 사용하고, 에러 상태에서는 Error를 throw하여 Error Boundary로 위임합니다.
2 changes: 2 additions & 0 deletions packages/playground/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useNavigate, useParams, useLocation } from "re
import { NaverMapProvider } from "react-naver-maps-kit";

import { NaverMapDemo } from "./demos/NaverMapDemo.tsx";
import { SuspenseDemo } from "./demos/SuspenseDemo.tsx";
import { MarkerDemo } from "./demos/MarkerDemo.tsx";
import { InfoWindowDemo } from "./demos/InfoWindowDemo.tsx";
import { CircleDemo } from "./demos/CircleDemo.tsx";
Expand Down Expand Up @@ -31,6 +32,7 @@ type SidebarItem = DemoEntry | SectionEntry;
const DEMOS: SidebarItem[] = [
{ section: "Core" },
{ id: "navermap", label: "NaverMap", component: NaverMapDemo },
{ id: "core-suspense", label: "Suspense", component: SuspenseDemo },
{ section: "Overlays" },
{ id: "marker", label: "Marker", component: MarkerDemo },
{ id: "infowindow", label: "InfoWindow", component: InfoWindowDemo },
Expand Down
111 changes: 111 additions & 0 deletions packages/playground/src/demos/SuspenseDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Suspense, useCallback, useState } from "react";
import { NaverMap, useNaverMap } from "react-naver-maps-kit";

import { EventLog } from "../EventLog.tsx";
import { useEventLog } from "../useEventLog.ts";

type BrowserWindow = Window & {
naver?: unknown;
};

const NAVER_SDK_SCRIPT_SELECTOR =
'script[data-react-naver-maps-kit="true"], script[src*="oapi.map.naver.com/openapi/v3/maps.js"]';

function resetSdkRuntime(): number {
const scriptNodes = document.querySelectorAll<HTMLScriptElement>(NAVER_SDK_SCRIPT_SELECTOR);
scriptNodes.forEach((node) => node.remove());

const browserWindow = window as BrowserWindow;
browserWindow.naver = undefined;

try {
delete (browserWindow as BrowserWindow & { naver?: unknown }).naver;
} catch {
// ignore delete failure
}

return scriptNodes.length;
}

export function SuspenseDemo() {
const { sdkStatus, sdkError, reloadSdk } = useNaverMap();
const { entries, log, clear } = useEventLog();

const [_, setResetKey] = useState(0);

const loadingFallbackStyle = {
width: "100%",
height: 500,
display: "grid",
placeItems: "center",
border: "1px dashed #d0d7de",
background: "#f6f8fa",
color: "#57606a",
fontWeight: 600
} as const;

const handleRetrySdk = useCallback(() => {
const removedScriptCount = resetSdkRuntime();
log(`Removed ${removedScriptCount} SDK script(s). Re-requesting SDK via reloadSdk().`);

void reloadSdk()
.then(() => {
log("reloadSdk resolved.");
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
log(`reloadSdk rejected: ${message}`);
})
.finally(() => {
setResetKey((prev) => prev + 1);
});
}, [log, reloadSdk]);

return (
<>
<h1 className="demo-title">suspense</h1>
<p className="demo-description">
<code>NaverMap suspense</code>를 <code>Suspense</code>로 처리하는 기본 예제입니다.
</p>

<div className="info-row">
<span className="info-chip">SDK: {sdkStatus}</span>
<span className="info-chip">Mode: suspense</span>
{sdkError && (
<span className="info-chip" style={{ background: "#ffebee", color: "#d32f2f" }}>
Error: {sdkError.message}
</span>
)}
</div>

<div className="controls-panel" style={{ marginBottom: 16 }}>
<div className="controls-title">SDK Controls</div>
<div className="btn-group">
<button className="btn" onClick={handleRetrySdk}>
SDK 재시도
</button>
</div>
</div>

<div className="map-container">
<Suspense fallback={<div style={loadingFallbackStyle}>Suspense로 SDK 로딩 중...</div>}>
<NaverMap
suspense={sdkStatus !== "error"}
defaultCenter={{ lat: 37.5666102, lng: 126.9783881 }}
defaultZoom={12}
style={{ width: "100%", height: 500 }}
fallback={
<div style={loadingFallbackStyle}>
{sdkStatus === "error" ? "SDK 로딩 에러가 발생했습니다." : "SDK 로딩 중..."}
</div>
}
onMapReady={() => log("map ready")}
onMapError={(error) => log(`onMapError: ${error.message}`)}
/>
</Suspense>
</div>

<EventLog entries={entries} onClear={clear} />
</>
);
}
100 changes: 98 additions & 2 deletions packages/react-naver-maps-kit/e2e/app/pages/MapTestApp.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import React, { useRef, useState, useCallback } from "react";
import { NaverMapProvider, NaverMap, type NaverMapRef } from "react-naver-maps-kit";
import React, { Suspense, useRef, useState, useCallback } from "react";
import {
NaverMapProvider,
NaverMap,
NaverMapContext,
type NaverMapContextValue,
type NaverMapRef
} from "react-naver-maps-kit";

import { DEFAULT_CENTER, BUSAN_CENTER, JEJU_CENTER, NCP_KEY_ID } from "../constants";

Expand Down Expand Up @@ -147,6 +153,94 @@ function FallbackErrorPage() {
);
}

/* ─── suspense fallback priority ─── */

function SuspensePriorityNaverFallbackPage() {
const [mapReady, setMapReady] = useState(false);
const pendingReload = useCallback(() => new Promise<void>(() => undefined), []);
const loadingContextValue = {
sdkStatus: "loading",
sdkError: null,
reloadSdk: pendingReload,
retrySdk: pendingReload,
clearSdkError: () => undefined,
submodules: [],
map: null,
setMap: () => undefined
} satisfies NaverMapContextValue;

return (
<ScenarioLayout
buttons={<span data-testid="scenario-name">suspense-priority-naver-fallback</span>}
logs={<span data-testid="map-ready">{String(mapReady)}</span>}
map={
<NaverMapContext.Provider value={loadingContextValue}>
<NaverMap
data-testid="map-container"
defaultCenter={DEFAULT_CENTER}
defaultZoom={12}
style={{ width: "100%", height: 500 }}
onMapReady={() => setMapReady(true)}
fallback={
<div
data-testid="naver-fallback-priority"
style={{ width: "100%", height: 500, background: "#e5e7eb" }}
>
NaverMap fallback
</div>
}
/>
</NaverMapContext.Provider>
}
/>
);
}

function SuspensePrioritySuspenseFallbackPage() {
const [mapReady, setMapReady] = useState(false);
const pendingReload = useCallback(() => new Promise<void>(() => undefined), []);
const loadingContextValue = {
sdkStatus: "loading",
sdkError: null,
reloadSdk: pendingReload,
retrySdk: pendingReload,
clearSdkError: () => undefined,
submodules: [],
map: null,
setMap: () => undefined
} satisfies NaverMapContextValue;

return (
<ScenarioLayout
buttons={<span data-testid="scenario-name">suspense-priority-suspense-fallback</span>}
logs={<span data-testid="map-ready">{String(mapReady)}</span>}
map={
<NaverMapContext.Provider value={loadingContextValue}>
<Suspense
fallback={
<div
data-testid="suspense-fallback-priority"
style={{ width: "100%", height: 500, background: "#dbeafe" }}
>
Suspense fallback
</div>
}
>
<NaverMap
suspense
data-testid="map-container"
defaultCenter={DEFAULT_CENTER}
defaultZoom={12}
style={{ width: "100%", height: 500 }}
onMapReady={() => setMapReady(true)}
/>
</Suspense>
</NaverMapContext.Provider>
}
/>
);
}

/* ─── uncontrolled ─── */

function UncontrolledPage() {
Expand Down Expand Up @@ -645,6 +739,8 @@ function RefImperativePage() {
export const mapRoutes: Record<string, React.FC> = {
"/map/smoke": SmokePage,
"/map/fallback-error": FallbackErrorPage,
"/map/suspense-priority/naver-fallback": SuspensePriorityNaverFallbackPage,
"/map/suspense-priority/suspense-fallback": SuspensePrioritySuspenseFallbackPage,
"/map/uncontrolled": UncontrolledPage,
"/map/controlled": ControlledPage,
"/map/interaction-toggle": InteractionTogglePage,
Expand Down
5 changes: 4 additions & 1 deletion packages/react-naver-maps-kit/e2e/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,14 @@ body {
border: 2px solid var(--panel-border);
background: #fff;
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
}

.e2e-nav-item {
flex: 0 0 auto;
text-decoration: none;
font-size: 0.86rem;
font-weight: 700;
Expand Down
19 changes: 19 additions & 0 deletions packages/react-naver-maps-kit/e2e/map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ test.describe("2. fallback/에러/재시도", () => {
});
});

test.describe("2.1 suspense fallback 우선순위", () => {
test("Suspense fallback이 없으면 NaverMap fallback이 먼저 보인다", async ({ page }) => {
await page.goto("/#/map/suspense-priority/naver-fallback");

await expect(page.getByTestId("naver-fallback-priority")).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId("map-ready")).toHaveText("false");
});

test("Suspense fallback이 있고 NaverMap fallback이 없으면 Suspense fallback이 먼저 보인다", async ({
page
}) => {
await page.goto("/#/map/suspense-priority/suspense-fallback");

await expect(page.getByTestId("suspense-fallback-priority")).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId("naver-fallback-priority")).toHaveCount(0);
await expect(page.getByTestId("map-ready")).toHaveText("false");
});
});

/* ─── 3. 초기 옵션 적용 (uncontrolled) ─── */

test.describe("3. 초기 옵션 적용 (uncontrolled)", () => {
Expand Down
Loading