Skip to content

Commit 40c9da3

Browse files
authored
Merge pull request #18 from cobocho/codex/issue-17-suspense-fallback-priority
feat: add NaverMap suspense mode and fallback priority coverage
2 parents aaea9e9 + b283b00 commit 40c9da3

10 files changed

Lines changed: 362 additions & 9 deletions

File tree

.github/workflows/e2e-api-main.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
name: E2E By API Changes (main)
22

33
on:
4+
pull_request:
5+
branches: [main]
46
push:
5-
branches:
6-
- main
7+
branches: [main]
78

89
permissions:
910
contents: read

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased (2026-02-26)
44

5+
### Added
6+
- `NaverMap``suspense` 옵션을 추가해 SDK 로딩을 React `Suspense`/Error Boundary로 위임할 수 있도록 지원했습니다.
7+
58
### Fixed
69
- `InfoWindow`에서 `visible/anchor/position` 제어 시 불필요한 재오픈과 상태 루프를 줄이도록 동기화 로직을 분리했습니다.
710
- `Panorama`의 controlled `visible` 동기화가 `setOptions` 경로에만 의존하지 않도록 보완했습니다.

packages/docs/api/map.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ interface NaverMapLifecycleProps {
5757
retryOnError?: boolean;
5858
retryDelayMs?: number;
5959
fallback?: React.ReactNode;
60+
suspense?: boolean;
6061
}
6162

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

272274
## 지도 이벤트 프로퍼티
273275

@@ -388,4 +390,5 @@ function RefExample() {
388390
- 언마운트 시 이벤트 리스너 정리, destroy 호출, 컨테이너 초기화까지 수행합니다.
389391
- `defaultCenter`/`defaultZoom`은 uncontrolled 모드로, 초기값만 설정하고 이후 내부 상태로 관리합니다.
390392
- `center`/`zoom`은 controlled 모드로, React 상태와 동기화합니다.
391-
- `fallback`은 SDK 로딩 중(`loading`) 또는 에러 발생 시(`error`) 표시됩니다.
393+
- `fallback``suspense``false`일 때 SDK 로딩 중(`loading`) 또는 에러 발생 시(`error`) 표시됩니다.
394+
- `suspense``true`이면 SDK 준비 전에는 Promise를 throw하여 `Suspense` fallback을 사용하고, 에러 상태에서는 Error를 throw하여 Error Boundary로 위임합니다.

packages/playground/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useNavigate, useParams, useLocation } from "re
33
import { NaverMapProvider } from "react-naver-maps-kit";
44

55
import { NaverMapDemo } from "./demos/NaverMapDemo.tsx";
6+
import { SuspenseDemo } from "./demos/SuspenseDemo.tsx";
67
import { MarkerDemo } from "./demos/MarkerDemo.tsx";
78
import { InfoWindowDemo } from "./demos/InfoWindowDemo.tsx";
89
import { CircleDemo } from "./demos/CircleDemo.tsx";
@@ -31,6 +32,7 @@ type SidebarItem = DemoEntry | SectionEntry;
3132
const DEMOS: SidebarItem[] = [
3233
{ section: "Core" },
3334
{ id: "navermap", label: "NaverMap", component: NaverMapDemo },
35+
{ id: "core-suspense", label: "Suspense", component: SuspenseDemo },
3436
{ section: "Overlays" },
3537
{ id: "marker", label: "Marker", component: MarkerDemo },
3638
{ id: "infowindow", label: "InfoWindow", component: InfoWindowDemo },
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Suspense, useCallback, useState } from "react";
2+
import { NaverMap, useNaverMap } from "react-naver-maps-kit";
3+
4+
import { EventLog } from "../EventLog.tsx";
5+
import { useEventLog } from "../useEventLog.ts";
6+
7+
type BrowserWindow = Window & {
8+
naver?: unknown;
9+
};
10+
11+
const NAVER_SDK_SCRIPT_SELECTOR =
12+
'script[data-react-naver-maps-kit="true"], script[src*="oapi.map.naver.com/openapi/v3/maps.js"]';
13+
14+
function resetSdkRuntime(): number {
15+
const scriptNodes = document.querySelectorAll<HTMLScriptElement>(NAVER_SDK_SCRIPT_SELECTOR);
16+
scriptNodes.forEach((node) => node.remove());
17+
18+
const browserWindow = window as BrowserWindow;
19+
browserWindow.naver = undefined;
20+
21+
try {
22+
delete (browserWindow as BrowserWindow & { naver?: unknown }).naver;
23+
} catch {
24+
// ignore delete failure
25+
}
26+
27+
return scriptNodes.length;
28+
}
29+
30+
export function SuspenseDemo() {
31+
const { sdkStatus, sdkError, reloadSdk } = useNaverMap();
32+
const { entries, log, clear } = useEventLog();
33+
34+
const [_, setResetKey] = useState(0);
35+
36+
const loadingFallbackStyle = {
37+
width: "100%",
38+
height: 500,
39+
display: "grid",
40+
placeItems: "center",
41+
border: "1px dashed #d0d7de",
42+
background: "#f6f8fa",
43+
color: "#57606a",
44+
fontWeight: 600
45+
} as const;
46+
47+
const handleRetrySdk = useCallback(() => {
48+
const removedScriptCount = resetSdkRuntime();
49+
log(`Removed ${removedScriptCount} SDK script(s). Re-requesting SDK via reloadSdk().`);
50+
51+
void reloadSdk()
52+
.then(() => {
53+
log("reloadSdk resolved.");
54+
})
55+
.catch((error) => {
56+
const message = error instanceof Error ? error.message : String(error);
57+
log(`reloadSdk rejected: ${message}`);
58+
})
59+
.finally(() => {
60+
setResetKey((prev) => prev + 1);
61+
});
62+
}, [log, reloadSdk]);
63+
64+
return (
65+
<>
66+
<h1 className="demo-title">suspense</h1>
67+
<p className="demo-description">
68+
<code>NaverMap suspense</code><code>Suspense</code>로 처리하는 기본 예제입니다.
69+
</p>
70+
71+
<div className="info-row">
72+
<span className="info-chip">SDK: {sdkStatus}</span>
73+
<span className="info-chip">Mode: suspense</span>
74+
{sdkError && (
75+
<span className="info-chip" style={{ background: "#ffebee", color: "#d32f2f" }}>
76+
Error: {sdkError.message}
77+
</span>
78+
)}
79+
</div>
80+
81+
<div className="controls-panel" style={{ marginBottom: 16 }}>
82+
<div className="controls-title">SDK Controls</div>
83+
<div className="btn-group">
84+
<button className="btn" onClick={handleRetrySdk}>
85+
SDK 재시도
86+
</button>
87+
</div>
88+
</div>
89+
90+
<div className="map-container">
91+
<Suspense fallback={<div style={loadingFallbackStyle}>Suspense로 SDK 로딩 중...</div>}>
92+
<NaverMap
93+
suspense={sdkStatus !== "error"}
94+
defaultCenter={{ lat: 37.5666102, lng: 126.9783881 }}
95+
defaultZoom={12}
96+
style={{ width: "100%", height: 500 }}
97+
fallback={
98+
<div style={loadingFallbackStyle}>
99+
{sdkStatus === "error" ? "SDK 로딩 에러가 발생했습니다." : "SDK 로딩 중..."}
100+
</div>
101+
}
102+
onMapReady={() => log("map ready")}
103+
onMapError={(error) => log(`onMapError: ${error.message}`)}
104+
/>
105+
</Suspense>
106+
</div>
107+
108+
<EventLog entries={entries} onClear={clear} />
109+
</>
110+
);
111+
}

packages/react-naver-maps-kit/e2e/app/pages/MapTestApp.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import React, { useRef, useState, useCallback } from "react";
2-
import { NaverMapProvider, NaverMap, type NaverMapRef } from "react-naver-maps-kit";
1+
import React, { Suspense, useRef, useState, useCallback } from "react";
2+
import {
3+
NaverMapProvider,
4+
NaverMap,
5+
NaverMapContext,
6+
type NaverMapContextValue,
7+
type NaverMapRef
8+
} from "react-naver-maps-kit";
39

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

@@ -147,6 +153,94 @@ function FallbackErrorPage() {
147153
);
148154
}
149155

156+
/* ─── suspense fallback priority ─── */
157+
158+
function SuspensePriorityNaverFallbackPage() {
159+
const [mapReady, setMapReady] = useState(false);
160+
const pendingReload = useCallback(() => new Promise<void>(() => undefined), []);
161+
const loadingContextValue = {
162+
sdkStatus: "loading",
163+
sdkError: null,
164+
reloadSdk: pendingReload,
165+
retrySdk: pendingReload,
166+
clearSdkError: () => undefined,
167+
submodules: [],
168+
map: null,
169+
setMap: () => undefined
170+
} satisfies NaverMapContextValue;
171+
172+
return (
173+
<ScenarioLayout
174+
buttons={<span data-testid="scenario-name">suspense-priority-naver-fallback</span>}
175+
logs={<span data-testid="map-ready">{String(mapReady)}</span>}
176+
map={
177+
<NaverMapContext.Provider value={loadingContextValue}>
178+
<NaverMap
179+
data-testid="map-container"
180+
defaultCenter={DEFAULT_CENTER}
181+
defaultZoom={12}
182+
style={{ width: "100%", height: 500 }}
183+
onMapReady={() => setMapReady(true)}
184+
fallback={
185+
<div
186+
data-testid="naver-fallback-priority"
187+
style={{ width: "100%", height: 500, background: "#e5e7eb" }}
188+
>
189+
NaverMap fallback
190+
</div>
191+
}
192+
/>
193+
</NaverMapContext.Provider>
194+
}
195+
/>
196+
);
197+
}
198+
199+
function SuspensePrioritySuspenseFallbackPage() {
200+
const [mapReady, setMapReady] = useState(false);
201+
const pendingReload = useCallback(() => new Promise<void>(() => undefined), []);
202+
const loadingContextValue = {
203+
sdkStatus: "loading",
204+
sdkError: null,
205+
reloadSdk: pendingReload,
206+
retrySdk: pendingReload,
207+
clearSdkError: () => undefined,
208+
submodules: [],
209+
map: null,
210+
setMap: () => undefined
211+
} satisfies NaverMapContextValue;
212+
213+
return (
214+
<ScenarioLayout
215+
buttons={<span data-testid="scenario-name">suspense-priority-suspense-fallback</span>}
216+
logs={<span data-testid="map-ready">{String(mapReady)}</span>}
217+
map={
218+
<NaverMapContext.Provider value={loadingContextValue}>
219+
<Suspense
220+
fallback={
221+
<div
222+
data-testid="suspense-fallback-priority"
223+
style={{ width: "100%", height: 500, background: "#dbeafe" }}
224+
>
225+
Suspense fallback
226+
</div>
227+
}
228+
>
229+
<NaverMap
230+
suspense
231+
data-testid="map-container"
232+
defaultCenter={DEFAULT_CENTER}
233+
defaultZoom={12}
234+
style={{ width: "100%", height: 500 }}
235+
onMapReady={() => setMapReady(true)}
236+
/>
237+
</Suspense>
238+
</NaverMapContext.Provider>
239+
}
240+
/>
241+
);
242+
}
243+
150244
/* ─── uncontrolled ─── */
151245

152246
function UncontrolledPage() {
@@ -645,6 +739,8 @@ function RefImperativePage() {
645739
export const mapRoutes: Record<string, React.FC> = {
646740
"/map/smoke": SmokePage,
647741
"/map/fallback-error": FallbackErrorPage,
742+
"/map/suspense-priority/naver-fallback": SuspensePriorityNaverFallbackPage,
743+
"/map/suspense-priority/suspense-fallback": SuspensePrioritySuspenseFallbackPage,
648744
"/map/uncontrolled": UncontrolledPage,
649745
"/map/controlled": ControlledPage,
650746
"/map/interaction-toggle": InteractionTogglePage,

packages/react-naver-maps-kit/e2e/app/styles.css

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,14 @@ body {
6767
border: 2px solid var(--panel-border);
6868
background: #fff;
6969
display: flex;
70-
flex-wrap: wrap;
70+
flex-wrap: nowrap;
7171
gap: 8px;
72+
overflow-x: auto;
73+
overflow-y: hidden;
7274
}
7375

7476
.e2e-nav-item {
77+
flex: 0 0 auto;
7578
text-decoration: none;
7679
font-size: 0.86rem;
7780
font-weight: 700;

packages/react-naver-maps-kit/e2e/map.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,25 @@ test.describe("2. fallback/에러/재시도", () => {
112112
});
113113
});
114114

115+
test.describe("2.1 suspense fallback 우선순위", () => {
116+
test("Suspense fallback이 없으면 NaverMap fallback이 먼저 보인다", async ({ page }) => {
117+
await page.goto("/#/map/suspense-priority/naver-fallback");
118+
119+
await expect(page.getByTestId("naver-fallback-priority")).toBeVisible({ timeout: 5000 });
120+
await expect(page.getByTestId("map-ready")).toHaveText("false");
121+
});
122+
123+
test("Suspense fallback이 있고 NaverMap fallback이 없으면 Suspense fallback이 먼저 보인다", async ({
124+
page
125+
}) => {
126+
await page.goto("/#/map/suspense-priority/suspense-fallback");
127+
128+
await expect(page.getByTestId("suspense-fallback-priority")).toBeVisible({ timeout: 5000 });
129+
await expect(page.getByTestId("naver-fallback-priority")).toHaveCount(0);
130+
await expect(page.getByTestId("map-ready")).toHaveText("false");
131+
});
132+
});
133+
115134
/* ─── 3. 초기 옵션 적용 (uncontrolled) ─── */
116135

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

0 commit comments

Comments
 (0)