@mandujs/core 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ import {
15
15
  } from "./window-state";
16
16
  import { LRUCache } from "../utils/lru-cache";
17
17
  import { LIMITS } from "../constants";
18
+ import { registerCacheSize } from "../observability/metrics";
18
19
 
19
20
  // ========== Types ==========
20
21
 
@@ -158,6 +159,12 @@ interface CompiledPattern {
158
159
 
159
160
  const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN_CACHE);
160
161
 
162
+ // Phase 17 — expose the cache size to the /_mandu/heap + /_mandu/metrics
163
+ // endpoints so long-running processes can detect runaway growth.
164
+ // The registration happens at module init — safe to call once per process
165
+ // because `registerCacheSize` replaces any prior reporter under the same key.
166
+ registerCacheSize("patternCache", () => patternCache.size);
167
+
161
168
  /**
162
169
  * 패턴을 정규식으로 컴파일
163
170
  */
@@ -435,40 +442,105 @@ export function getNavigationState(): NavigationState {
435
442
  // ========== Link Click Handler ==========
436
443
 
437
444
  /**
438
- * 링크 클릭 이벤트 핸들러 (이벤트 위임용)
445
+ * Issue #193 Link click handler (event delegation).
446
+ *
447
+ * Mandu v0.22+ reversed the default from opt-in to opt-out SPA navigation.
448
+ * Every internal same-origin `<a href="/...">` click is intercepted and
449
+ * routed through the client-side router unless one of the explicit escape
450
+ * hatches fires:
451
+ *
452
+ * - `data-no-spa` → per-link opt-out (always skip).
453
+ * - `<a>` without `href` → degenerate anchor, let the browser decide.
454
+ * - `href="#fragment"` → same-page anchor, browser handles scroll.
455
+ * - `href="mailto:"` / `tel:` / → non-http schemes the browser owns.
456
+ * `javascript:` / `data:` / …
457
+ * - `target="_blank" / "_top" / → any target other than `_self` means the
458
+ * "_parent" / "framename" user wants a new browsing context.
459
+ * - `download` attribute present → file download, never a navigation.
460
+ * - Modifier keys (Ctrl / Cmd / → browser shortcut for new tab, bookmark,
461
+ * Shift / Alt) save, or save-as.
462
+ * - Non-left click → middle-click opens a new tab, right-click
463
+ * opens context menu.
464
+ * - `event.defaultPrevented` → another listener already handled it.
465
+ * - Cross-origin href → full document navigation required.
466
+ *
467
+ * The legacy opt-in attribute `data-mandu-link` still works for
468
+ * backward compatibility — it is simply a no-op under the new default
469
+ * because we already intercept by default. Teams that want the old
470
+ * opt-in behavior back can set `spa: false` in `mandu.config.ts`, which
471
+ * surfaces as `window.__MANDU_SPA__ === false` and re-introduces the
472
+ * requirement that `<a>` tags carry `data-mandu-link`.
439
473
  */
440
474
  function handleLinkClick(event: MouseEvent): void {
441
- // 기본 동작 조건 체크
475
+ // Pre-filter: obvious browser-owned events.
442
476
  if (
443
477
  event.defaultPrevented ||
444
- event.button !== 0 ||
445
- event.metaKey ||
446
- event.altKey ||
447
- event.ctrlKey ||
448
- event.shiftKey
478
+ event.button !== 0 || // middle-click / right-click — browser decides.
479
+ event.metaKey || // Cmd (macOS) — new tab.
480
+ event.altKey || // Alt — "save-as" in most browsers.
481
+ event.ctrlKey || // Ctrl (Windows/Linux) — new tab.
482
+ event.shiftKey // Shift — new window / bookmark.
449
483
  ) {
450
484
  return;
451
485
  }
452
486
 
453
- // 가장 가까운 앵커 태그 찾기
454
- const anchor = (event.target as HTMLElement).closest("a");
487
+ // Find the closest anchor ancestor — users commonly nest `<span>` /
488
+ // `<img>` inside `<a>` and the event target is the inner element.
489
+ const anchor = (event.target as HTMLElement | null)?.closest("a");
455
490
  if (!anchor) return;
456
491
 
457
- // data-mandu-link 속성이 있는 링크만 처리
458
- if (!anchor.hasAttribute("data-mandu-link")) return;
492
+ // Escape hatch 1: explicit per-link opt-out always wins.
493
+ if (anchor.hasAttribute("data-no-spa")) return;
494
+
495
+ // Escape hatch 2: global config `spa: false` — reverts to the legacy
496
+ // opt-in behavior (only `data-mandu-link` intercepts). SSR injects
497
+ // `window.__MANDU_SPA__ = false` when the user sets `spa: false`.
498
+ const spaGlobal = (globalThis as { window?: { __MANDU_SPA__?: boolean } }).window?.__MANDU_SPA__;
499
+ if (spaGlobal === false && !anchor.hasAttribute("data-mandu-link")) return;
459
500
 
501
+ // `<a>` without `href` is a degenerate anchor — the browser will not
502
+ // navigate, but a listener somewhere might. Don't intercept.
460
503
  const href = anchor.getAttribute("href");
461
504
  if (!href) return;
462
505
 
463
- // 외부 링크 체크
506
+ // Same-page fragment link — let the browser handle scroll / focus.
507
+ if (href.startsWith("#")) return;
508
+
509
+ // `target` other than `_self` (or absent) signals the user wants a
510
+ // new browsing context. `target="_blank"` is the common case but we
511
+ // also pass through `_top`, `_parent`, and framed targets.
512
+ const target = anchor.getAttribute("target");
513
+ if (target && target !== "_self") return;
514
+
515
+ // `download` attribute means the user wants to save the resource,
516
+ // never navigate to it.
517
+ if (anchor.hasAttribute("download")) return;
518
+
519
+ // URL parsing — catches both cross-origin and non-http schemes.
520
+ let url: URL;
464
521
  try {
465
- const url = new URL(href, window.location.origin);
466
- if (url.origin !== window.location.origin) return;
522
+ url = new URL(href, window.location.origin);
467
523
  } catch {
524
+ // Malformed href — let the browser produce its own error.
468
525
  return;
469
526
  }
470
527
 
471
- // 기본 동작 방지 Client-side 네비게이션
528
+ // Only same-origin http(s) navigations are eligible for SPA handling.
529
+ // `mailto:`, `tel:`, `javascript:`, `data:`, `blob:`, chrome-extension,
530
+ // etc. all fail this check because `new URL("mailto:foo@bar").origin`
531
+ // is the string `"null"` (spec-defined), never equal to
532
+ // `window.location.origin`.
533
+ if (url.origin !== window.location.origin) return;
534
+
535
+ // Only intercept http / https schemes. Defense-in-depth against any
536
+ // exotic same-origin scheme we haven't considered (e.g. a custom
537
+ // protocol handler installed by a browser extension).
538
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
539
+
540
+ // All clear — prevent the default full-page navigation and hand off
541
+ // to the client-side router. `href` preserves the user's original
542
+ // string (relative paths, fragments, query strings) so the router
543
+ // can normalize as needed.
472
544
  event.preventDefault();
473
545
  navigate(href);
474
546
  }
@@ -828,3 +900,9 @@ if (typeof window !== "undefined") {
828
900
  // can exercise the schema-check path directly without round-tripping
829
901
  // through `window.__MANDU_ROUTER_REVALIDATE__`.
830
902
  export { applyHDRUpdate as _testOnly_applyHDRUpdate };
903
+
904
+ // Issue #193: export the link-click handler for unit tests so we can
905
+ // drive every exclusion case without installing a real DOM click
906
+ // listener. Keeping this under a `_testOnly_` prefix to signal it is
907
+ // not part of the public API.
908
+ export { handleLinkClick as _testOnly_handleLinkClick };
@@ -1,239 +1,243 @@
1
- /**
2
- * Mandu useFetch Composable
3
- * SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시
4
- */
5
-
6
- import { useState, useEffect, useCallback, useRef } from "react";
7
-
8
- // ========== Types ==========
9
-
10
- export interface UseFetchOptions<T = unknown> {
11
- query?: Record<string, string | number>;
12
- headers?: Record<string, string>;
13
- method?: string;
14
- body?: unknown;
15
- /** SSR 데이터 있으면 클라이언트 fetch 생략 (기본: true) */
16
- dedupe?: boolean;
17
- /** SSR에서 전달된 초기 데이터 */
18
- initialData?: T;
19
- /** 캐시 유지 시간 (ms, 0이면 캐시 안 함) */
20
- cacheTime?: number;
21
- /** 자동 실행 여부 (기본: true) */
22
- immediate?: boolean;
23
- /** 응답 변환 함수 */
24
- transform?: (data: unknown) => T;
25
- }
26
-
27
- export interface UseFetchReturn<T> {
28
- data: T | null;
29
- error: Error | null;
30
- loading: boolean;
31
- refresh: () => Promise<void>;
32
- mutate: (updater: T | ((prev: T | null) => T)) => void;
33
- }
34
-
35
- // ========== Cache (LRU, 최대 200 엔트리) ==========
36
-
37
- const MAX_CACHE_SIZE = 200;
38
-
39
- interface CacheEntry { data: unknown; timestamp: number; }
40
- const fetchCache = new Map<string, CacheEntry>();
41
-
42
- function getCached(key: string, maxAge: number): unknown | undefined {
43
- const entry = fetchCache.get(key);
44
- if (!entry) return undefined;
45
- if (Date.now() - entry.timestamp > maxAge) {
46
- fetchCache.delete(key);
47
- return undefined;
48
- }
49
- return entry.data;
50
- }
51
-
52
- function setCache(key: string, data: unknown): void {
53
- // LRU: 오래된 것부터 제거
54
- if (fetchCache.size >= MAX_CACHE_SIZE) {
55
- const oldest = fetchCache.keys().next().value;
56
- if (oldest !== undefined) fetchCache.delete(oldest);
57
- }
58
- fetchCache.set(key, { data, timestamp: Date.now() });
59
- }
60
-
61
- function stableStringify(value: unknown): string {
62
- if (value === undefined || value === null) return "";
63
- if (typeof value !== "object") return String(value);
64
- // 키를 정렬하여 삽입 순서에 무관한 안정적 직렬화
65
- const obj = value as Record<string, unknown>;
66
- const sorted: Record<string, unknown> = {};
67
- for (const key of Object.keys(obj).sort()) {
68
- sorted[key] = obj[key];
69
- }
70
- return JSON.stringify(sorted);
71
- }
72
-
73
- export function buildFetchCacheKey(
74
- url: string,
75
- options: {
76
- queryKey?: string;
77
- method?: string;
78
- headersKey?: string;
79
- bodyKey?: string;
80
- } = {}
81
- ): string {
82
- return [
83
- (options.method ?? "GET").toUpperCase(),
84
- url,
85
- options.queryKey ?? "",
86
- options.headersKey ?? "",
87
- options.bodyKey ?? "",
88
- ].join("::");
89
- }
90
-
91
- function buildUrl(url: string, query?: Record<string, string | number>): string {
92
- if (!query || Object.keys(query).length === 0) return url;
93
- const params = new URLSearchParams();
94
- for (const [key, value] of Object.entries(query)) {
95
- if (value !== undefined && value !== null) {
96
- params.set(key, String(value));
97
- }
98
- }
99
- return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
100
- }
101
-
102
- // ========== Hook ==========
103
-
104
- /**
105
- * 데이터 페칭 훅 — SSR 중복 방지, 캐싱, pending/error 상태 관리
106
- *
107
- * @example
108
- * ```tsx
109
- * const { data, loading, error, refresh } = useFetch<Post[]>("/api/posts", {
110
- * query: { page: 1 },
111
- * cacheTime: 300_000,
112
- * });
113
- *
114
- * const { data, mutate } = useFetch<Todo[]>("/api/todos");
115
- * const addTodo = (todo: Todo) => mutate(prev => [...(prev ?? []), todo]);
116
- * ```
117
- */
118
- export function useFetch<T = unknown>(
119
- url: string,
120
- options?: UseFetchOptions<T>
121
- ): UseFetchReturn<T> {
122
- const {
123
- query,
124
- headers,
125
- method = "GET",
126
- body,
127
- dedupe = true,
128
- initialData,
129
- cacheTime = 0,
130
- immediate = true,
131
- transform,
132
- } = options ?? {};
133
-
134
- // 안정적 직렬화 — useRef로 이전 값과 비교하여 변경 시에만 갱신
135
- const queryStr = stableStringify(query);
136
- const headersStr = stableStringify(headers);
137
- const bodyStr = stableStringify(body);
138
-
139
- const prevQueryRef = useRef(queryStr);
140
- const prevHeadersRef = useRef(headersStr);
141
- const prevBodyRef = useRef(bodyStr);
142
-
143
- const stableQuery = prevQueryRef.current === queryStr ? prevQueryRef.current : (prevQueryRef.current = queryStr);
144
- const stableHeaders = prevHeadersRef.current === headersStr ? prevHeadersRef.current : (prevHeadersRef.current = headersStr);
145
- const stableBody = prevBodyRef.current === bodyStr ? prevBodyRef.current : (prevBodyRef.current = bodyStr);
146
-
147
- const cacheKey = buildFetchCacheKey(url, {
148
- queryKey: stableQuery,
149
- method,
150
- headersKey: stableHeaders,
151
- bodyKey: stableBody,
152
- });
153
-
154
- // URL/query 변경 감지
155
- const prevCacheKeyRef = useRef(cacheKey);
156
-
157
- const [data, setData] = useState<T | null>(() => {
158
- if (initialData !== undefined) return initialData;
159
- if (cacheTime > 0) {
160
- const cached = getCached(cacheKey, cacheTime);
161
- if (cached !== undefined) return (transform ? transform(cached) : cached) as T;
162
- }
163
- return null;
164
- });
165
- const [error, setError] = useState<Error | null>(null);
166
- const [loading, setLoading] = useState(!data && immediate);
167
- const abortRef = useRef<AbortController | null>(null);
168
-
169
- // URL/query 변경 data 초기화 (useEffect로 React 규칙 준수)
170
- useEffect(() => {
171
- if (prevCacheKeyRef.current === cacheKey) return;
172
- prevCacheKeyRef.current = cacheKey;
173
-
174
- if (cacheTime > 0) {
175
- const cached = getCached(cacheKey, cacheTime);
176
- if (cached !== undefined) {
177
- setData((transform ? transform(cached) : cached) as T);
178
- return;
179
- }
180
- }
181
- setData(null);
182
- }, [cacheKey, cacheTime, transform]);
183
-
184
- const fetchData = useCallback(async () => {
185
- abortRef.current?.abort();
186
- const controller = new AbortController();
187
- abortRef.current = controller;
188
-
189
- setLoading(true);
190
- setError(null);
191
-
192
- try {
193
- const fetchUrl = buildUrl(url, query);
194
- const response = await fetch(fetchUrl, {
195
- method,
196
- headers: { "Accept": "application/json", ...headers },
197
- body: body ? JSON.stringify(body) : undefined,
198
- signal: controller.signal,
199
- });
200
-
201
- if (controller.signal.aborted) return;
202
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
203
-
204
- // Content-Type 확인 후 파싱
205
- const contentType = response.headers.get("content-type") ?? "";
206
- let result: unknown;
207
- if (contentType.includes("application/json")) {
208
- result = await response.json();
209
- } else if (response.status === 204) {
210
- result = null;
211
- } else {
212
- result = await response.text();
213
- }
214
-
215
- if (transform) result = transform(result);
216
- setData(result as T);
217
-
218
- if (cacheTime > 0) setCache(cacheKey, result);
219
- } catch (e) {
220
- if (e instanceof DOMException && e.name === "AbortError") return;
221
- setError(e instanceof Error ? e : new Error(String(e)));
222
- } finally {
223
- if (!controller.signal.aborted) setLoading(false);
224
- }
225
- }, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]);
226
-
227
- useEffect(() => {
228
- if (!immediate) return;
229
- if (data && dedupe) return;
230
- fetchData();
231
- return () => { abortRef.current?.abort(); };
232
- }, [fetchData, immediate, dedupe]);
233
-
234
- const mutate = useCallback((updater: T | ((prev: T | null) => T)) => {
235
- setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater);
236
- }, []);
237
-
238
- return { data, error, loading, refresh: fetchData, mutate };
239
- }
1
+ /**
2
+ * Mandu useFetch Composable
3
+ * SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시
4
+ */
5
+
6
+ import { useState, useEffect, useCallback, useRef } from "react";
7
+ import { LRUCache } from "../utils/lru-cache";
8
+ import { registerCacheSize } from "../observability/metrics";
9
+
10
+ // ========== Types ==========
11
+
12
+ export interface UseFetchOptions<T = unknown> {
13
+ query?: Record<string, string | number>;
14
+ headers?: Record<string, string>;
15
+ method?: string;
16
+ body?: unknown;
17
+ /** SSR 데이터 있으면 클라이언트 fetch 생략 (기본: true) */
18
+ dedupe?: boolean;
19
+ /** SSR에서 전달된 초기 데이터 */
20
+ initialData?: T;
21
+ /** 캐시 유지 시간 (ms, 0이면 캐시 안 함) */
22
+ cacheTime?: number;
23
+ /** 자동 실행 여부 (기본: true) */
24
+ immediate?: boolean;
25
+ /** 응답 변환 함수 */
26
+ transform?: (data: unknown) => T;
27
+ }
28
+
29
+ export interface UseFetchReturn<T> {
30
+ data: T | null;
31
+ error: Error | null;
32
+ loading: boolean;
33
+ refresh: () => Promise<void>;
34
+ mutate: (updater: T | ((prev: T | null) => T)) => void;
35
+ }
36
+
37
+ // ========== Cache (LRU, 최대 200 엔트리) ==========
38
+ //
39
+ // Phase 17 swapped hand-rolled Map for `LRUCache` so `get()` actually
40
+ // promotes the entry to most-recently-used (was FIFO before). Size is
41
+ // registered with the observability module for `/_mandu/heap` + the
42
+ // Prometheus `mandu_cache_entries{cache="fetchCache"}` series.
43
+
44
+ const MAX_CACHE_SIZE = 200;
45
+
46
+ interface CacheEntry { data: unknown; timestamp: number; }
47
+ const fetchCache = new LRUCache<string, CacheEntry>({ maxSize: MAX_CACHE_SIZE });
48
+ registerCacheSize("fetchCache", () => fetchCache.size);
49
+
50
+ function getCached(key: string, maxAge: number): unknown | undefined {
51
+ const entry = fetchCache.get(key);
52
+ if (!entry) return undefined;
53
+ if (Date.now() - entry.timestamp > maxAge) {
54
+ fetchCache.delete(key);
55
+ return undefined;
56
+ }
57
+ return entry.data;
58
+ }
59
+
60
+ function setCache(key: string, data: unknown): void {
61
+ // LRUCache handles eviction — we just set.
62
+ fetchCache.set(key, { data, timestamp: Date.now() });
63
+ }
64
+
65
+ function stableStringify(value: unknown): string {
66
+ if (value === undefined || value === null) return "";
67
+ if (typeof value !== "object") return String(value);
68
+ // 키를 정렬하여 삽입 순서에 무관한 안정적 직렬화
69
+ const obj = value as Record<string, unknown>;
70
+ const sorted: Record<string, unknown> = {};
71
+ for (const key of Object.keys(obj).sort()) {
72
+ sorted[key] = obj[key];
73
+ }
74
+ return JSON.stringify(sorted);
75
+ }
76
+
77
+ export function buildFetchCacheKey(
78
+ url: string,
79
+ options: {
80
+ queryKey?: string;
81
+ method?: string;
82
+ headersKey?: string;
83
+ bodyKey?: string;
84
+ } = {}
85
+ ): string {
86
+ return [
87
+ (options.method ?? "GET").toUpperCase(),
88
+ url,
89
+ options.queryKey ?? "",
90
+ options.headersKey ?? "",
91
+ options.bodyKey ?? "",
92
+ ].join("::");
93
+ }
94
+
95
+ function buildUrl(url: string, query?: Record<string, string | number>): string {
96
+ if (!query || Object.keys(query).length === 0) return url;
97
+ const params = new URLSearchParams();
98
+ for (const [key, value] of Object.entries(query)) {
99
+ if (value !== undefined && value !== null) {
100
+ params.set(key, String(value));
101
+ }
102
+ }
103
+ return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
104
+ }
105
+
106
+ // ========== Hook ==========
107
+
108
+ /**
109
+ * 데이터 페칭 SSR 중복 방지, 캐싱, pending/error 상태 관리
110
+ *
111
+ * @example
112
+ * ```tsx
113
+ * const { data, loading, error, refresh } = useFetch<Post[]>("/api/posts", {
114
+ * query: { page: 1 },
115
+ * cacheTime: 300_000,
116
+ * });
117
+ *
118
+ * const { data, mutate } = useFetch<Todo[]>("/api/todos");
119
+ * const addTodo = (todo: Todo) => mutate(prev => [...(prev ?? []), todo]);
120
+ * ```
121
+ */
122
+ export function useFetch<T = unknown>(
123
+ url: string,
124
+ options?: UseFetchOptions<T>
125
+ ): UseFetchReturn<T> {
126
+ const {
127
+ query,
128
+ headers,
129
+ method = "GET",
130
+ body,
131
+ dedupe = true,
132
+ initialData,
133
+ cacheTime = 0,
134
+ immediate = true,
135
+ transform,
136
+ } = options ?? {};
137
+
138
+ // 안정적 직렬화 — useRef로 이전 값과 비교하여 변경 시에만 갱신
139
+ const queryStr = stableStringify(query);
140
+ const headersStr = stableStringify(headers);
141
+ const bodyStr = stableStringify(body);
142
+
143
+ const prevQueryRef = useRef(queryStr);
144
+ const prevHeadersRef = useRef(headersStr);
145
+ const prevBodyRef = useRef(bodyStr);
146
+
147
+ const stableQuery = prevQueryRef.current === queryStr ? prevQueryRef.current : (prevQueryRef.current = queryStr);
148
+ const stableHeaders = prevHeadersRef.current === headersStr ? prevHeadersRef.current : (prevHeadersRef.current = headersStr);
149
+ const stableBody = prevBodyRef.current === bodyStr ? prevBodyRef.current : (prevBodyRef.current = bodyStr);
150
+
151
+ const cacheKey = buildFetchCacheKey(url, {
152
+ queryKey: stableQuery,
153
+ method,
154
+ headersKey: stableHeaders,
155
+ bodyKey: stableBody,
156
+ });
157
+
158
+ // URL/query 변경 감지
159
+ const prevCacheKeyRef = useRef(cacheKey);
160
+
161
+ const [data, setData] = useState<T | null>(() => {
162
+ if (initialData !== undefined) return initialData;
163
+ if (cacheTime > 0) {
164
+ const cached = getCached(cacheKey, cacheTime);
165
+ if (cached !== undefined) return (transform ? transform(cached) : cached) as T;
166
+ }
167
+ return null;
168
+ });
169
+ const [error, setError] = useState<Error | null>(null);
170
+ const [loading, setLoading] = useState(!data && immediate);
171
+ const abortRef = useRef<AbortController | null>(null);
172
+
173
+ // URL/query 변경 시 data 초기화 (useEffect로 React 규칙 준수)
174
+ useEffect(() => {
175
+ if (prevCacheKeyRef.current === cacheKey) return;
176
+ prevCacheKeyRef.current = cacheKey;
177
+
178
+ if (cacheTime > 0) {
179
+ const cached = getCached(cacheKey, cacheTime);
180
+ if (cached !== undefined) {
181
+ setData((transform ? transform(cached) : cached) as T);
182
+ return;
183
+ }
184
+ }
185
+ setData(null);
186
+ }, [cacheKey, cacheTime, transform]);
187
+
188
+ const fetchData = useCallback(async () => {
189
+ abortRef.current?.abort();
190
+ const controller = new AbortController();
191
+ abortRef.current = controller;
192
+
193
+ setLoading(true);
194
+ setError(null);
195
+
196
+ try {
197
+ const fetchUrl = buildUrl(url, query);
198
+ const response = await fetch(fetchUrl, {
199
+ method,
200
+ headers: { "Accept": "application/json", ...headers },
201
+ body: body ? JSON.stringify(body) : undefined,
202
+ signal: controller.signal,
203
+ });
204
+
205
+ if (controller.signal.aborted) return;
206
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
207
+
208
+ // Content-Type 확인 후 파싱
209
+ const contentType = response.headers.get("content-type") ?? "";
210
+ let result: unknown;
211
+ if (contentType.includes("application/json")) {
212
+ result = await response.json();
213
+ } else if (response.status === 204) {
214
+ result = null;
215
+ } else {
216
+ result = await response.text();
217
+ }
218
+
219
+ if (transform) result = transform(result);
220
+ setData(result as T);
221
+
222
+ if (cacheTime > 0) setCache(cacheKey, result);
223
+ } catch (e) {
224
+ if (e instanceof DOMException && e.name === "AbortError") return;
225
+ setError(e instanceof Error ? e : new Error(String(e)));
226
+ } finally {
227
+ if (!controller.signal.aborted) setLoading(false);
228
+ }
229
+ }, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]);
230
+
231
+ useEffect(() => {
232
+ if (!immediate) return;
233
+ if (data && dedupe) return;
234
+ fetchData();
235
+ return () => { abortRef.current?.abort(); };
236
+ }, [fetchData, immediate, dedupe]);
237
+
238
+ const mutate = useCallback((updater: T | ((prev: T | null) => T)) => {
239
+ setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater);
240
+ }, []);
241
+
242
+ return { data, error, loading, refresh: fetchData, mutate };
243
+ }