Skip to content

Commit 9fcd99b

Browse files
authored
fix(load-script): submodule 로드 완료 대기 + URL 쉼표 인코딩 수정 (#159)
0.2.0(v2 재작성)에서 submodules 옵션이 무력화되던 두 회귀 수정: - buildUrl: URLSearchParams가 쉼표를 %2C로 인코딩 → maps-geocoder.js 청크 404. raw 쉼표로 append 복원. - loadScript: onJSContentLoaded 대기 복원 → submodule attach 완료 후 resolve. 회귀 방지 테스트 7개 포함. Closes #158 Co-authored-by: changgihong <changgihong@users.noreply.github.com>
1 parent 5118a91 commit 9fcd99b

2 files changed

Lines changed: 119 additions & 6 deletions

File tree

packages/react-naver-maps/src/__tests__/load-script.spec.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, test, expect } from 'vitest';
2-
import { getClientParam } from '../load-script.js';
2+
import {
3+
buildUrl,
4+
getClientParam,
5+
waitForJsContentLoaded,
6+
} from '../load-script.js';
37

48
describe('getClientParam', () => {
59
test('ncpKeyId 전달 시 ["ncpKeyId", value] 반환', () => {
@@ -34,3 +38,86 @@ describe('getClientParam', () => {
3438
expect(() => getClientParam({} as any)).toThrow('인증 키가 필요합니다');
3539
});
3640
});
41+
42+
describe('buildUrl', () => {
43+
test('submodules 전달 시 raw `,` 가 URL 에 보존됨 (URL-encoded `%2C` 아님)', () => {
44+
const url = buildUrl({
45+
ncpKeyId: 'test-key',
46+
submodules: ['geocoder', 'drawing'],
47+
});
48+
expect(url).toContain('submodules=geocoder,drawing');
49+
expect(url).not.toContain('%2C');
50+
});
51+
52+
test('submodules 미전달 시 submodules 쿼리 자체가 추가되지 않음', () => {
53+
const url = buildUrl({ ncpKeyId: 'test-key' });
54+
expect(url).not.toContain('submodules');
55+
});
56+
57+
test('인증 키 파라미터는 URLSearchParams 통과 (특수문자 안전)', () => {
58+
const url = buildUrl({ ncpKeyId: 'a b+c' });
59+
expect(url).toContain('ncpKeyId=a+b%2Bc');
60+
});
61+
});
62+
63+
describe('waitForJsContentLoaded', () => {
64+
test('jsContentLoaded=true 이면 즉시 resolve', async () => {
65+
const maps = { jsContentLoaded: true } as unknown as typeof naver.maps;
66+
await expect(waitForJsContentLoaded(maps)).resolves.toBe(maps);
67+
});
68+
69+
test('jsContentLoaded=false 면 onJSContentLoaded 콜백 발화까지 대기', async () => {
70+
const maps = {
71+
jsContentLoaded: false,
72+
onJSContentLoaded: undefined as undefined | (() => void),
73+
} as unknown as typeof naver.maps & {
74+
onJSContentLoaded: undefined | (() => void);
75+
};
76+
77+
const promise = waitForJsContentLoaded(maps);
78+
79+
let resolved = false;
80+
promise.then(() => {
81+
resolved = true;
82+
});
83+
84+
// 콜백 발화 전: promise 가 아직 pending 이어야 함
85+
await Promise.resolve();
86+
expect(resolved).toBe(false);
87+
88+
// 콜백 발화
89+
maps.onJSContentLoaded?.();
90+
await expect(promise).resolves.toBe(maps);
91+
});
92+
93+
test('기존 onJSContentLoaded 핸들러가 있으면 chain 보호 (prev 호출 후 resolve)', async () => {
94+
let prevCalled = false;
95+
const maps = {
96+
jsContentLoaded: false,
97+
onJSContentLoaded: () => {
98+
prevCalled = true;
99+
},
100+
} as unknown as typeof naver.maps;
101+
102+
const promise = waitForJsContentLoaded(maps);
103+
104+
// waitForJsContentLoaded 가 새로 등록한 콜백 발화
105+
(maps as any).onJSContentLoaded();
106+
await promise;
107+
108+
expect(prevCalled).toBe(true);
109+
});
110+
111+
test('기존 onJSContentLoaded 핸들러가 throw 해도 waitForJsContentLoaded 의 Promise resolve 는 진행됨', async () => {
112+
const maps = {
113+
jsContentLoaded: false,
114+
onJSContentLoaded: () => {
115+
throw new Error('prev throws');
116+
},
117+
} as unknown as typeof naver.maps;
118+
119+
const promise = waitForJsContentLoaded(maps);
120+
(maps as any).onJSContentLoaded();
121+
await expect(promise).resolves.toBe(maps);
122+
});
123+
});

packages/react-naver-maps/src/load-script.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,19 @@ export function getClientParam(options: LoadOptions): [string, string] {
5454
);
5555
}
5656

57-
function buildUrl(options: LoadOptions): string {
57+
/** @internal exported for tests */
58+
export function buildUrl(options: LoadOptions): string {
5859
const [paramName, paramValue] = getClientParam(options);
5960
const params = new URLSearchParams({ [paramName]: paramValue });
61+
let url = `https://oapi.map.naver.com/openapi/v3/maps.js?${params.toString()}`;
6062
if (options.submodules?.length) {
61-
params.set('submodules', options.submodules.join(','));
63+
// Naver Maps 로더는 submodules 쿼리 값을 raw `,` 로 split 해서
64+
// 각 `maps-{name}.js` 청크를 로드합니다. URLSearchParams 가 `,` 를
65+
// `%2C` 로 percent-encode 하면 로더가 전체 문자열을 하나의 submodule
66+
// 이름으로 취급해 `maps-{joined}%2C{...}.js` 에 요청 → 404 발생.
67+
url += `&submodules=${options.submodules.join(',')}`;
6268
}
63-
return `https://oapi.map.naver.com/openapi/v3/maps.js?${params.toString()}`;
69+
return url;
6470
}
6571

6672
function cacheKey(options: LoadOptions): string {
@@ -69,6 +75,26 @@ function cacheKey(options: LoadOptions): string {
6975
return `${paramValue}:${sub}`;
7076
}
7177

78+
/** @internal exported for tests */
79+
export function waitForJsContentLoaded(
80+
maps: typeof naver.maps,
81+
): Promise<typeof naver.maps> {
82+
if (maps.jsContentLoaded) return Promise.resolve(maps);
83+
return new Promise((resolve) => {
84+
const prev = maps.onJSContentLoaded;
85+
maps.onJSContentLoaded = () => {
86+
if (typeof prev === 'function') {
87+
try {
88+
prev();
89+
} catch {
90+
// chained handler 의 에러가 submodule resolution 을 막지 않도록 swallow
91+
}
92+
}
93+
resolve(maps);
94+
};
95+
});
96+
}
97+
7298
export function loadScript(options: LoadOptions): Promise<typeof naver.maps> {
7399
if (typeof document === 'undefined') {
74100
return new Promise<typeof naver.maps>(() => {});
@@ -80,7 +106,7 @@ export function loadScript(options: LoadOptions): Promise<typeof naver.maps> {
80106

81107
const promise = new Promise<typeof naver.maps>((resolve, reject) => {
82108
if (typeof naver !== 'undefined' && naver.maps) {
83-
resolve(naver.maps);
109+
waitForJsContentLoaded(naver.maps).then(resolve);
84110
return;
85111
}
86112

@@ -89,7 +115,7 @@ export function loadScript(options: LoadOptions): Promise<typeof naver.maps> {
89115
script.async = true;
90116
script.addEventListener('load', () => {
91117
if (typeof naver !== 'undefined' && naver.maps) {
92-
resolve(naver.maps);
118+
waitForJsContentLoaded(naver.maps).then(resolve);
93119
} else {
94120
reject(new Error('naver.maps not available after script load'));
95121
}

0 commit comments

Comments
 (0)