UNPKG

next-unified-query-core

Version:

Framework-agnostic HTTP client with built-in query state management

1,617 lines (1,610 loc) 48.2 kB
import { createFetch } from './chunk-KRKSGPO7.mjs'; export { ContentType, ErrorCode, FetchError, ResponseType, createError, createFetch, fetch_default as defaultInstance, del, errorToResponse, get, getHeaders, getStatus, getValidationErrors, handleFetchError, handleHttpError, hasErrorCode, hasStatus, head, interceptorTypes, interceptors, isFetchError, isValidationError, ntFetch, options, patch, post, put, request, unwrap } from './chunk-KRKSGPO7.mjs'; import { isFunction, isString, isArray, forEach, isEqual, isEmpty, isPlainObject, isNil, merge } from 'es-toolkit/compat'; import QuickLRU from 'quick-lru'; import { isFunction as isFunction$1, isNotNil } from 'es-toolkit/predicate'; export { z } from 'zod/v4'; var ERROR_MESSAGES = { BOTH_APPROACHES: "QueryConfig cannot have both 'queryFn' and 'url' at the same time. Choose either custom function approach (queryFn) or URL-based approach (url).", MISSING_APPROACHES: "QueryConfig must have either 'queryFn' or 'url'. Provide either a custom function or URL-based configuration." }; function validateQueryConfig(config) { if (process.env.NODE_ENV === "production") { return; } const hasQueryFn = isFunction(config.queryFn); const hasUrl = isFunction(config.url) || isString(config.url); if (hasQueryFn && hasUrl) { throw new Error(ERROR_MESSAGES.BOTH_APPROACHES); } if (!hasQueryFn && !hasUrl) { throw new Error(ERROR_MESSAGES.MISSING_APPROACHES); } } function createQueryFactory(defs) { Object.entries(defs).forEach(([key, config]) => { try { validateQueryConfig(config); } catch (error) { throw new Error(`Invalid QueryConfig for '${key}': ${error.message}`); } }); return defs; } var ERROR_MESSAGES2 = { BOTH_APPROACHES: "MutationConfig cannot have both 'mutationFn' and 'url'+'method' at the same time. Choose either custom function approach (mutationFn) or URL-based approach (url + method).", MISSING_APPROACHES: "MutationConfig must have either 'mutationFn' or both 'url' and 'method'. Provide either a custom function or URL-based configuration." }; function validateMutationConfig(config) { if (process.env.NODE_ENV === "production") { return; } const hasMutationFn = isFunction(config.mutationFn); const hasUrlMethod = config.url && config.method; if (hasMutationFn && hasUrlMethod) { throw new Error(ERROR_MESSAGES2.BOTH_APPROACHES); } if (!hasMutationFn && !hasUrlMethod) { throw new Error(ERROR_MESSAGES2.MISSING_APPROACHES); } } function createMutationFactory(defs) { Object.entries(defs).forEach(([key, config]) => { try { validateMutationConfig(config); } catch (error) { throw new Error(`Invalid MutationConfig for '${key}': ${error.message}`); } }); return defs; } function serializeQueryKey(key) { if (isString(key)) return key; return JSON.stringify(key, (_, value) => { if (isFunction(value)) return void 0; return value; }); } var QueryCache = class { constructor(options2 = {}) { this.subscribers = /* @__PURE__ */ new Map(); this.listeners = /* @__PURE__ */ new Map(); this.gcTimers = /* @__PURE__ */ new Map(); const { maxQueries = 1e3 } = options2; this.cache = new QuickLRU({ maxSize: maxQueries, onEviction: (key, value) => { this.cleanupMetadata(key); } }); } set(key, state) { const sKey = serializeQueryKey(key); this.cache.set(sKey, state); this.notifyListeners(sKey); } get(key) { const result = this.cache.get(serializeQueryKey(key)); return result; } has(key) { const result = this.cache.has(serializeQueryKey(key)); return result; } delete(key) { const sKey = serializeQueryKey(key); this.cache.delete(sKey); this.cleanupMetadata(sKey); } clear() { this.cache.clear(); this.subscribers.clear(); this.listeners.clear(); this.gcTimers.forEach((timer) => { clearTimeout(timer); }); this.gcTimers.clear(); } getAll() { const result = {}; for (const [key, value] of this.cache.entries()) { result[key] = value; } return result; } /** * 컴포넌트가 쿼리를 구독하여 refetch 콜백을 등록합니다. * @returns unsubscribe 함수 */ subscribeListener(key, listener) { const sKey = serializeQueryKey(key); if (!this.listeners.has(sKey)) { this.listeners.set(sKey, /* @__PURE__ */ new Set()); } this.listeners.get(sKey).add(listener); return () => { const listenerSet = this.listeners.get(sKey); if (listenerSet) { listenerSet.delete(listener); if (listenerSet.size === 0) { this.listeners.delete(sKey); } } }; } /** * 특정 쿼리 키의 모든 리스너에게 알림을 보냅니다. */ notifyListeners(key) { const sKey = serializeQueryKey(key); Promise.resolve().then(() => { this.listeners.get(sKey)?.forEach((l) => l()); }); } /** * 구독자 수 증가 및 gcTime 타이머 해제 (생명주기 관리) */ subscribe(key) { const sKey = serializeQueryKey(key); const prev = this.subscribers.get(sKey) ?? 0; this.subscribers.set(sKey, prev + 1); const timer = this.gcTimers.get(sKey); if (timer) { clearTimeout(timer); this.gcTimers.delete(sKey); } } /** * 구독자 수 감소 및 0이 되면 gcTime 타이머 시작 (생명주기 관리) */ unsubscribe(key, gcTime) { const sKey = serializeQueryKey(key); const prev = this.subscribers.get(sKey) ?? 0; if (prev <= 1) { this.subscribers.set(sKey, 0); if (gcTime !== Infinity) { const timer = setTimeout(() => { this.delete(key); }, gcTime); this.gcTimers.set(sKey, timer); } } else { this.subscribers.set(sKey, prev - 1); } } serialize() { return this.getAll(); } deserialize(cache) { Object.entries(cache).forEach(([key, state]) => { this.cache.set(key, state); }); } /** * 현재 캐시 크기를 반환합니다. */ get size() { return this.cache.size; } /** * 캐시의 최대 크기를 반환합니다. */ get maxSize() { return this.cache.maxSize; } /** * 캐시 통계를 반환합니다. * * @description 디버깅 및 모니터링 목적으로 사용됩니다. * 성능 분석, 메모리 사용량 추적, 캐시 상태 확인 등에 활용할 수 있습니다. * * @example * ```typescript * const queryClient = useQueryClient(); * const stats = queryClient.getQueryCache().getStats(); * console.log('Current cache size:', stats.cacheSize); * console.log('Active GC timers:', stats.activeGcTimersCount); * ``` */ getStats() { return { /** 현재 캐시된 쿼리 수 */ cacheSize: this.cache.size, /** 최대 쿼리 수 (메모리 보호 한계) */ maxSize: this.cache.maxSize, /** 활성 구독자 수 */ subscribersCount: this.subscribers.size, /** 등록된 리스너 수 */ listenersCount: this.listeners.size, /** 활성 GC 타이머 수 (생명주기 관리 중인 쿼리) */ activeGcTimersCount: this.gcTimers.size }; } /** * 특정 키의 메타데이터를 정리합니다. */ cleanupMetadata(sKey) { this.subscribers.delete(sKey); this.listeners.delete(sKey); const timer = this.gcTimers.get(sKey); if (timer) { clearTimeout(timer); this.gcTimers.delete(sKey); } } }; var QueryClient = class { constructor(options2) { this.cache = new QueryCache(options2?.queryCache); this.fetcher = options2?.fetcher || createFetch(options2); } has(key) { return this.cache.has(key); } getFetcher() { return this.fetcher; } /** * 쿼리 상태 조회 */ get(key) { return this.cache.get(key); } /** * 쿼리 상태 저장 */ set(key, state) { this.cache.set(key, state); } /** * 쿼리 데이터만 업데이트 (optimistic update에 최적화) * 기존 상태(isLoading, isFetching, error)를 유지하면서 data와 updatedAt만 업데이트 */ setQueryData(key, updater) { const existing = this.get(key); const newData = typeof updater === "function" ? updater(existing?.data) : updater; const newState = { data: newData, error: existing?.error, isLoading: existing?.isLoading ?? false, isFetching: existing?.isFetching ?? false, updatedAt: Date.now() }; this.set(key, newState); } /** * 쿼리 상태 삭제 */ delete(key) { this.cache.delete(key); } /** * 모든 쿼리 상태 반환 */ getAll() { return this.cache.getAll(); } /** * 모든 쿼리 상태 초기화 */ clear() { this.cache.clear(); } /** * 특정 쿼리키(혹은 prefix)로 시작하는 모든 쿼리 캐시를 무효화(삭제) * 예: invalidateQueries(['user']) → ['user', ...]로 시작하는 모든 캐시 삭제 */ invalidateQueries(prefix) { const all = this.getAll(); if (isArray(prefix)) { const prefixArr = Array.from(prefix); forEach(Object.keys(all), (key) => { try { const keyArr = JSON.parse(key); if (Array.isArray(keyArr) && isEqual(keyArr.slice(0, prefixArr.length), prefixArr)) { const currentState = this.cache.get(keyArr); if (currentState) { this.cache.set(keyArr, { ...currentState, updatedAt: 0 }); } } } catch { } }); } else { const prefixStr = isString(prefix) ? prefix : String(prefix); forEach(Object.keys(all), (key) => { if (key.startsWith(prefixStr)) { const currentState = this.cache.get(key); if (currentState) { this.cache.set(key, { ...currentState, updatedAt: 0 }); } } }); } } /** * 구독자 관리 (public) */ subscribeListener(key, listener) { return this.cache.subscribeListener(key, listener); } subscribe(key) { this.cache.subscribe(key); } unsubscribe(key, gcTime) { this.cache.unsubscribe(key, gcTime); } // 구현 async prefetchQuery(keyOrQuery, fetchFnOrParams) { if (typeof keyOrQuery === "object" && keyOrQuery && "cacheKey" in keyOrQuery) { const query = keyOrQuery; const params = fetchFnOrParams; const cacheKey = query.cacheKey(params); const fetchFn2 = async () => { let data2; if (query.queryFn) { data2 = await query.queryFn(params, this.fetcher); } else if (query.url) { const url = query.url(params); const response = await this.fetcher.get(url, query.fetchConfig); data2 = response.data; } else { throw new Error("Either 'url' or 'queryFn' must be provided in QueryConfig"); } if (query.schema) { data2 = query.schema.parse(data2); } if (query.select) { data2 = query.select(data2); } return data2; }; return this.prefetchQuery(cacheKey, fetchFn2); } const key = keyOrQuery; const fetchFn = fetchFnOrParams; const data = await fetchFn(); this.set(key, { data, error: void 0, isLoading: false, isFetching: false, updatedAt: Date.now() }); return data; } dehydrate() { return this.cache.serialize(); } hydrate(cache) { this.cache.deserialize(cache); } /** * 캐시 통계를 반환합니다. (디버깅 목적) * * @description 성능 분석, 메모리 사용량 추적, 캐시 상태 확인 등에 활용할 수 있습니다. */ getQueryCache() { return this.cache; } }; // src/query/client/query-client-manager.ts var globalQueryClient = void 0; var defaultOptions = void 0; function setDefaultQueryClientOptions(options2) { defaultOptions = options2; updateGlobalFetchInstance(options2); if (typeof window !== "undefined" && globalQueryClient) { globalQueryClient = createQueryClientWithSetup(options2); } } function updateGlobalFetchInstance(options2) { import('./fetch-NPLAB7HC.mjs').then(({ updateDefaultInstance }) => { const { setupInterceptors, ...fetchConfig } = options2; updateDefaultInstance(fetchConfig); if (setupInterceptors) { import('./fetch-NPLAB7HC.mjs').then(({ interceptors: interceptors2 }) => { const dummyFetcher = { interceptors: interceptors2 }; setupInterceptors(dummyFetcher); }); } }); } function createQueryClientWithSetup(options2) { if (!options2?.setupInterceptors) { return new QueryClient(options2); } const { setupInterceptors, ...clientOptions } = options2; const client = new QueryClient(clientOptions); setupInterceptors(client.getFetcher()); return client; } function getQueryClient(options2) { const finalOptions = defaultOptions ? { ...defaultOptions, ...options2 } : options2; if (typeof window === "undefined") { return createQueryClientWithSetup(finalOptions); } if (!globalQueryClient) { globalQueryClient = createQueryClientWithSetup(finalOptions); } return globalQueryClient; } function resetQueryClient() { if (typeof window !== "undefined") { globalQueryClient = void 0; } } function createQueryClientWithInterceptors(options2, setupInterceptors) { return createQueryClientWithSetup({ ...options2, setupInterceptors }); } function replaceEqualDeep(prev, next) { if (prev === next) { return prev; } const prevType = typeof prev; const nextType = typeof next; if (prevType !== nextType) { return next; } if (prevType !== "object" || prev === null || next === null) { return next; } const prevIsArray = Array.isArray(prev); const nextIsArray = Array.isArray(next); if (prevIsArray !== nextIsArray) { return next; } if (prevIsArray && nextIsArray) { if (prev.length !== next.length) { return next; } let hasChanged = false; const result = prev.map((item, index) => { const nextItem = replaceEqualDeep(item, next[index]); if (nextItem !== item) { hasChanged = true; } return nextItem; }); return hasChanged ? result : prev; } if (isPlainObject(prev) && isPlainObject(next)) { const prevObj = prev; const nextObj = next; const prevKeys = Object.keys(prevObj); const nextKeys = Object.keys(nextObj); if (prevKeys.length !== nextKeys.length) { return next; } let hasChanged = false; const result = {}; for (const key of nextKeys) { if (!(key in prevObj)) { return next; } } for (const key of nextKeys) { const prevValue = prevObj[key]; const nextValue = nextObj[key]; if (prevValue === nextValue) { result[key] = prevValue; } else { const optimizedValue = replaceEqualDeep(prevValue, nextValue); if (optimizedValue !== prevValue) { hasChanged = true; } result[key] = optimizedValue; } } return hasChanged ? result : prev; } if (isEqual(prev, next)) { return prev; } return next; } // src/query/observer/utils/tracked-result.ts var TrackedResult = class { constructor(result) { this.trackedProps = /* @__PURE__ */ new Set(); this.cachedProxy = null; this.result = result; } createProxy() { if (this.cachedProxy) { return this.cachedProxy; } this.cachedProxy = new Proxy(this.result, { get: (target, prop) => { if (typeof prop === "string" && prop in target) { this.trackedProps.add(prop); } return target[prop]; } }); return this.cachedProxy; } getTrackedProps() { return this.trackedProps; } hasTrackedProp(prop) { return this.trackedProps.has(prop); } getResult() { return this.result; } // 결과가 변경될 때 캐시 무효화 updateResult(newResult) { this.result = newResult; this.cachedProxy = null; } }; var FetchManager = class { constructor(queryClient, placeholderManager) { this.queryClient = queryClient; this.placeholderManager = placeholderManager; } /** * Fetch 실행 * enabled 옵션과 stale 상태를 확인하여 필요한 경우에만 페칭을 수행합니다. * 각 QueryObserver가 독립적으로 실행되며, HTTP 레벨에서 중복 방지가 이루어집니다. */ async executeFetch(cacheKey, options2, onComplete) { const { staleTime = 0 } = options2; const cached = this.queryClient.get(cacheKey); const isStale = cached ? Date.now() - cached.updatedAt >= staleTime : true; if (!cached || isStale) { try { await this.fetchData(cacheKey, options2); onComplete?.(); } catch (error) { console.error("fetchData error:", error); onComplete?.(); } } else { onComplete?.(); } } /** * 데이터 페칭 * 실제 HTTP 요청을 수행하고 결과를 캐시에 저장합니다. */ async fetchData(cacheKey, options2) { try { const currentState = this.queryClient.get(cacheKey); const result = await this.performHttpRequest(options2); this.placeholderManager.deactivatePlaceholder(); this.queryClient.set(cacheKey, { data: result, error: void 0, isLoading: false, isFetching: false, updatedAt: Date.now() }); } catch (error) { this.placeholderManager.deactivatePlaceholder(); this.queryClient.set(cacheKey, { data: void 0, error, isLoading: false, isFetching: false, updatedAt: Date.now() }); } } /** * HTTP 요청 수행 * 실제 네트워크 요청을 처리하고 스키마 검증을 수행합니다. */ async performHttpRequest(options2) { const fetcher = this.queryClient.getFetcher(); if ("queryFn" in options2 && options2.queryFn) { return this.executeQueryFn(options2, fetcher); } if ("url" in options2 && options2.url) { return this.executeUrlRequest(options2, fetcher); } throw new Error("Invalid QueryObserverOptions: neither 'url' nor 'queryFn' is provided"); } /** * NextTypeFetch에서 QueryFetcher 인스턴스 생성 * GET/HEAD 메서드만 허용하는 제한된 fetcher 반환 */ createQueryFetcher(fetcher) { return { get: fetcher.get.bind(fetcher), head: fetcher.head.bind(fetcher), request: (config) => { const method = config.method || "GET"; if (method !== "GET" && method !== "HEAD") { throw new Error(`Query fetcher only supports GET and HEAD methods, but received: ${method}`); } return fetcher.request({ ...config, method }); } }; } /** * queryFn 실행 * Factory 방식과 Options 방식을 구분하여 적절한 인자로 호출 */ async executeQueryFn(options2, fetcher) { const queryFn = options2.queryFn; const queryFetcher = this.createQueryFetcher(fetcher); let result; if ("params" in options2 && options2.params !== void 0) { result = await queryFn(options2.params, queryFetcher); } else { result = await queryFn(queryFetcher); } return this.applySchemaValidation(result, options2.schema); } /** * URL 기반 요청 실행 */ async executeUrlRequest(options2, fetcher) { const url = options2.url; const config = this.buildFetchConfig(options2); const response = await fetcher.get(url, config); return this.applySchemaValidation(response.data, options2.schema); } /** * Fetch 설정 구성 */ buildFetchConfig(options2) { let config = merge({}, options2.fetchConfig ?? {}); if (isNotNil(options2.params)) { config = merge(config, { params: options2.params }); } if (isNotNil(options2.schema)) { config = merge(config, { schema: options2.schema }); } return config; } /** * 스키마 검증 적용 */ applySchemaValidation(data, schema) { if (schema) { return schema.parse(data); } return data; } /** * 수동 refetch * 캐시 키와 옵션을 받아 즉시 데이터를 다시 페칭합니다. * force 옵션이 true인 경우 staleTime을 무시하고 강제로 페칭합니다. */ async refetch(cacheKey, options2, onComplete, force = true) { if (force) { try { await this.fetchData(cacheKey, options2); onComplete(); } catch (error) { console.error("refetch fetchData error:", error); onComplete(); } } else { const { staleTime = 0 } = options2; const cached = this.queryClient.get(cacheKey); const isStale = cached ? Date.now() - cached.updatedAt >= staleTime : true; if (isStale) { try { await this.fetchData(cacheKey, options2); onComplete(); } catch (error) { console.error("refetch fetchData error:", error); onComplete(); } } } } /** * 페칭 상태 확인 * 현재 페칭 중인지 확인합니다. */ isFetching(cacheKey) { const cached = this.queryClient.get(cacheKey); return cached?.isFetching ?? false; } /** * Stale 상태 확인 * 캐시된 데이터가 stale한지 확인합니다. */ isStale(cacheKey, staleTime = 0) { const cached = this.queryClient.get(cacheKey); return cached ? Date.now() - cached.updatedAt >= staleTime : true; } }; var PlaceholderManager = class { constructor(queryClient) { this.placeholderState = null; this.queryClient = queryClient; } /** * placeholderData 계산 * 캐시와 완전히 독립적으로 처리 */ computePlaceholderData(options2) { const { placeholderData } = options2; if (!placeholderData) return void 0; if (!isFunction$1(placeholderData)) { return placeholderData; } const prevQuery = this.findPreviousQuery(options2); if (!prevQuery || prevQuery.data === void 0) return void 0; return placeholderData(prevQuery.data, prevQuery); } /** * 이전 쿼리 데이터 찾기 * 같은 타입의 쿼리 중에서 가장 최근에 성공한 쿼리를 찾습니다. */ findPreviousQuery(options2) { const allQueries = this.queryClient.getAll(); const currentKey = options2.key; let mostRecentQuery; let mostRecentTime = 0; for (const [keyStr, state] of Object.entries(allQueries)) { try { const keyArray = JSON.parse(keyStr); if (this.isValidPreviousQuery(keyArray, currentKey, state, options2)) { const updatedAt = state.updatedAt || 0; if (this.isMoreRecent(updatedAt, mostRecentTime)) { mostRecentQuery = state; mostRecentTime = updatedAt; } } } catch { } } return mostRecentQuery; } /** * 유효한 이전 쿼리인지 확인 */ isValidPreviousQuery(keyArray, currentKey, state, options2) { return this.isArrayKey(keyArray, options2) && this.isSameQueryType(keyArray, currentKey) && this.isDifferentQueryKey(keyArray, currentKey) && this.hasValidData(state); } /** * 배열 키인지 확인 */ isArrayKey(keyArray, options2) { return Array.isArray(keyArray) && Array.isArray(options2.key); } /** * 같은 쿼리 타입인지 확인 (첫 번째 키 요소로 판단) */ isSameQueryType(keyArray, currentKey) { return keyArray[0] === currentKey[0]; } /** * 다른 쿼리 키인지 확인 (같은 키는 제외) */ isDifferentQueryKey(keyArray, currentKey) { return !isEqual(keyArray, currentKey); } /** * 유효한 데이터가 있는지 확인 */ hasValidData(state) { return state && !isNil(state.data); } /** * 더 최근 데이터인지 확인 */ isMoreRecent(updatedAt, mostRecentTime) { return updatedAt > mostRecentTime; } /** * PlaceholderData가 유효한지 확인 */ hasValidPlaceholderData(placeholderData) { return !isNil(placeholderData); } /** * PlaceholderState 설정 */ setPlaceholderState(state) { this.placeholderState = state; } /** * PlaceholderState 가져오기 */ getPlaceholderState() { return this.placeholderState; } /** * PlaceholderData 비활성화 (fetch 성공 또는 실패 시) */ deactivatePlaceholder() { this.placeholderState = null; } }; var ResultComputer = class { constructor(queryClient, placeholderManager) { // Select 상태 추적 (TanStack Query 방식) this.selectState = {}; this.queryClient = queryClient; this.placeholderManager = placeholderManager; } /** * 결과 계산 * 캐시 상태와 placeholderData를 완전히 분리하여 처리 */ computeResult(cacheKey, options2, refetchFn) { const { enabled = true } = options2; const cached = this.queryClient.get(cacheKey); if (!enabled) { return this.createDisabledResult(cached, options2, refetchFn); } if (this.hasCachedData(cached)) { return this.createCachedResult(cached, options2, refetchFn); } const placeholderData = this.placeholderManager.computePlaceholderData(options2); if (this.placeholderManager.hasValidPlaceholderData(placeholderData)) { return this.createPlaceholderResult(placeholderData, options2, refetchFn); } return this.createInitialLoadingResult(refetchFn); } /** * 비활성화된 결과 생성 (enabled: false) */ createDisabledResult(cached, options2, refetchFn) { if (cached) { const finalData = this.applySelect(cached.data, options2); const isStale = this.computeStaleTime(cached.updatedAt, options2); return { data: finalData, error: cached.error, isLoading: false, // enabled: false이므로 로딩하지 않음 isFetching: false, // enabled: false이므로 fetch하지 않음 isError: !!cached.error, isSuccess: this.isSuccessState(cached), isStale, isPlaceholderData: false, refetch: refetchFn }; } this.placeholderManager.deactivatePlaceholder(); return { data: void 0, error: void 0, isLoading: false, // enabled: false이므로 로딩하지 않음 isFetching: false, // enabled: false이므로 fetch하지 않음 isError: false, isSuccess: false, isStale: true, isPlaceholderData: false, refetch: refetchFn }; } /** * 캐시된 데이터가 있는지 확인 */ hasCachedData(cached) { return !!cached; } /** * 캐시된 결과 생성 */ createCachedResult(cached, options2, refetchFn) { const finalData = this.applySelect(cached.data, options2); const isStale = this.computeStaleTime(cached.updatedAt, options2); return { data: finalData, error: cached.error, isLoading: cached.isLoading, isFetching: cached.isFetching, // 캐시된 상태의 isFetching 값 사용 isError: !!cached.error, isSuccess: this.isSuccessState(cached), isStale, isPlaceholderData: false, // 캐시된 데이터는 항상 false refetch: refetchFn }; } /** * PlaceholderData 결과 생성 */ createPlaceholderResult(placeholderData, options2, refetchFn) { this.placeholderManager.setPlaceholderState({ data: placeholderData, isActive: true }); const finalData = this.applySelect(placeholderData, options2); return { data: finalData, error: void 0, isLoading: false, // placeholderData는 success 상태 isFetching: true, // 백그라운드에서 fetch 중 isError: false, isSuccess: true, isStale: true, isPlaceholderData: true, refetch: refetchFn }; } /** * 초기 로딩 결과 생성 */ createInitialLoadingResult(refetchFn) { this.placeholderManager.deactivatePlaceholder(); return { data: void 0, error: void 0, isLoading: true, isFetching: true, isError: false, isSuccess: false, isStale: true, isPlaceholderData: false, refetch: refetchFn }; } /** * 성공 상태인지 확인 */ isSuccessState(cached) { return !cached.isLoading && !cached.error && !isNil(cached.data); } /** * select 함수 적용 (TanStack Query 방식 메모이제이션) */ applySelect(data, options2) { if (isNil(data) || !options2.select) return data; const shouldRecompute = this.shouldRecomputeSelect(options2, data); if (!shouldRecompute && this.selectState.lastResult !== void 0) { return this.selectState.lastResult; } this.provideDevelopmentHelp(options2); try { const result = options2.select(data); this.updateSelectState(options2, result); return result; } catch { return data; } } /** * select 함수나 의존성이 변경되어 재계산이 필요한지 확인 (TanStack Query 방식) */ shouldRecomputeSelect(options2, data) { const { select, selectDeps } = options2; const { selectFunction, selectDeps: prevSelectDeps } = this.selectState; if (select !== selectFunction) { return true; } if (selectDeps || prevSelectDeps) { return !this.areSelectDepsEqual(selectDeps, prevSelectDeps); } return false; } /** * selectDeps 배열이 동일한지 비교 */ areSelectDepsEqual(currentDeps, prevDeps) { if (currentDeps === prevDeps) return true; if (!currentDeps || !prevDeps) return false; if (currentDeps.length !== prevDeps.length) return false; return currentDeps.every((dep, index) => Object.is(dep, prevDeps[index])); } /** * select 상태 업데이트 */ updateSelectState(options2, result) { this.selectState = { selectFunction: options2.select, selectDeps: options2.selectDeps, lastResult: result }; } /** * 개발 환경에서 도움말 제공 */ provideDevelopmentHelp(options2) { if (process.env.NODE_ENV !== "production") { const { select, selectDeps } = options2; const { selectFunction } = this.selectState; if (this.isInlineFunction(select, selectFunction) && !selectDeps) { console.warn( "\u26A0\uFE0F next-unified-query: Select function recreated on every render.\nSolution 1: Add selectDeps: [dependency1, dependency2]\nSolution 2: Use useCallback with dependencies\nSolution 3: Extract function outside component" ); } if (this.hasClosureVariables(select) && !selectDeps) { console.warn( "\u{1F4A1} next-unified-query: Detected closure variables in select function.\nConsider adding selectDeps to track dependencies:\nselectDeps: [variable1, variable2]" ); } } } /** * 인라인 함수인지 감지 (함수 참조가 매번 변경되는지) */ isInlineFunction(currentSelect, previousSelect) { return currentSelect !== previousSelect && currentSelect?.toString() === previousSelect?.toString(); } /** * 클로저 변수 사용 패턴 감지 (간단한 휴리스틱) */ hasClosureVariables(selectFunction) { if (!selectFunction) return false; const functionString = selectFunction.toString(); const closurePatterns = [ /\b[a-zA-Z_$][a-zA-Z0-9_$]*\s*[.[]/, // 변수.property 또는 변수[key] 패턴 /\?\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*\./ // ?.variable. 패턴 ]; return closurePatterns.some((pattern) => pattern.test(functionString)); } /** * Select 상태 정리 (QueryObserver에서 호출) */ clearSelectState() { this.selectState = {}; } /** * Stale 시간 계산 */ computeStaleTime(updatedAt, options2) { return updatedAt ? Date.now() - updatedAt >= (options2.staleTime || 0) : true; } }; // src/query/observer/query-observer.ts var QueryObserver = class { constructor(queryClient, options2) { this.listeners = /* @__PURE__ */ new Set(); this.isDestroyed = false; this.optionsHash = ""; // 결과 캐싱으로 불필요한 렌더링 방지 this.lastResultReference = null; // computeResult 메모이제이션을 위한 캐시 this.computeResultCache = null; // Tracked Properties this.trackedResult = null; // Select 함수 내용 추적 및 메모이제이션 this.lastSelectFunctionContent = null; this.selectFunctionChanged = false; // Observer 시작 여부 플래그 this.isStarted = false; this.queryClient = queryClient; this.options = options2; this.cacheKey = serializeQueryKey(options2.key); this.optionsHash = this.createOptionsHash(options2); this.placeholderManager = null; this.resultComputer = null; this.fetchManager = null; this.subscribeToCache(); this.currentResult = this.computeResult(); } subscribeToCache() { this.queryClient.subscribeListener(this.options.key, () => { if (!this.isDestroyed) { const hasChanged = this.updateResult(); if (hasChanged) { this.scheduleNotifyListeners(); } this.handlePotentialInvalidation(); } }); this.queryClient.subscribe(this.options.key); } /** * invalidateQueries로 인한 무효화 감지 및 처리 * updatedAt이 0이면 invalidateQueries로 인한 무효화로 간주 */ handlePotentialInvalidation() { const { enabled = true } = this.options; if (!enabled) return; const cached = this.queryClient.get(this.cacheKey); if (cached && cached.updatedAt === 0) { if (!cached.isFetching && !cached.isLoading) { this.fetchData(); } } } /** * 결과 계산 * 캐시 상태와 placeholderData를 완전히 분리하여 처리 */ computeResult() { this.ensureManagersInitialized(); const cached = this.queryClient.get(this.cacheKey); const selectDepsStr = JSON.stringify(this.options.selectDeps); const selectFnStr = this.options.select ? this.options.select.toString() : "null"; const cacheHash = cached ? `${this.cacheKey}:${cached.updatedAt}:${cached.isFetching}:${this.options.staleTime}:${this.options.enabled}:${selectDepsStr}:${selectFnStr}` : `${this.cacheKey}:null:${this.options.staleTime}:${this.options.enabled}:${selectDepsStr}:${selectFnStr}`; if (this.computeResultCache && this.computeResultCache.hash === cacheHash) { return this.computeResultCache.result; } const result = this.resultComputer.computeResult(this.cacheKey, this.options, () => { this.refetch(); }); this.computeResultCache = { hash: cacheHash, result }; return result; } /** * Tracked Properties 기반 결과 업데이트 * 기본적으로 tracked 모드로 동작 */ updateResult() { const newResult = this.computeResult(); const optimizedResult = this.applyStructuralSharing(newResult); if (this.hasChangeInTrackedProps(optimizedResult)) { this.currentResult = optimizedResult; this.lastResultReference = optimizedResult; this.computeResultCache = null; return true; } return false; } /** * Structural Sharing 적용 */ applyStructuralSharing(newResult) { if (!this.lastResultReference) { return newResult; } return replaceEqualDeep(this.lastResultReference, newResult); } hasChangeInTrackedProps(newResult) { if (this.isInitialState()) { return true; } const trackedProps = this.trackedResult.getTrackedProps(); if (this.hasNoTrackedProperties(trackedProps)) { return true; } return this.hasTrackedPropertyChanged(trackedProps, newResult); } isInitialState() { return !this.lastResultReference || !this.trackedResult; } hasNoTrackedProperties(trackedProps) { return isEmpty(trackedProps); } hasTrackedPropertyChanged(trackedProps, newResult) { for (const prop of trackedProps) { if (this.lastResultReference[prop] !== newResult[prop]) { return true; } } return false; } async executeFetch(onComplete) { this.ensureManagersInitialized(); await this.fetchManager.executeFetch(this.cacheKey, this.options, onComplete); } /** * 초기 fetch 실행 - 캐시 상태를 확인하고 필요한 경우에만 fetch */ async executeInitialFetch() { const isEnabled = this.isQueryEnabled(); if (!isEnabled) { return; } const hasCached = this.queryClient.has(this.cacheKey); const isServer = this.isServerSide(); if (isServer) { return; } if (!hasCached) { try { await this.executeFetch(); } catch (error) { console.error("executeFetch error:", error); } return; } this.handleCachedDataAvailable(); } /** * 쿼리가 활성화되어 있는지 확인 */ isQueryEnabled() { return this.options.enabled !== false; } /** * 서버사이드 환경인지 확인 */ isServerSide() { return typeof window === "undefined"; } /** * 캐시가 stale 상태인지 확인 */ isCacheStale(cached) { const { staleTime = 0 } = this.options; return Date.now() - cached.updatedAt >= staleTime; } async fetchData() { this.ensureManagersInitialized(); try { await this.fetchManager.fetchData(this.cacheKey, this.options); } catch (error) { console.error("fetchData error:", error); } } notifyListeners() { this.listeners.forEach((listener) => listener()); } /** * 결과 구독 (React 컴포넌트에서 사용) */ subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** * Tracked Properties가 적용된 현재 결과 반환 * TrackedResult 인스턴스를 재사용하여 속성 추적을 유지 */ getCurrentResult() { this.ensureTrackedResultExists(); return this.trackedResult.createProxy(); } /** * TrackedResult 인스턴스가 존재하고 최신 상태인지 확인 */ ensureTrackedResultExists() { if (!this.trackedResult) { this.createNewTrackedResult(); } else if (this.isTrackedResultOutdated()) { this.updateTrackedResult(); } } /** * 새로운 TrackedResult 인스턴스 생성 */ createNewTrackedResult() { this.trackedResult = new TrackedResult(this.currentResult); } /** * TrackedResult가 구식인지 확인 */ isTrackedResultOutdated() { return this.trackedResult.getResult() !== this.currentResult; } /** * TrackedResult를 최신 결과로 업데이트 */ updateTrackedResult() { this.trackedResult.updateResult(this.currentResult); } /** * Observer 시작 - React useEffect에서 호출 * 렌더링과 분리하여 안전하게 초기 fetch 시작 */ start() { if (this.isStarted) { return; } this.isStarted = true; queueMicrotask(async () => { try { await this.executeInitialFetch(); } catch (error) { console.error("executeInitialFetch error:", error); } }); } /** * 수동 refetch * force 옵션이 true인 경우 staleTime을 무시하고 강제로 페칭합니다. */ refetch(force = true) { this.ensureManagersInitialized(); this.fetchManager.refetch( this.cacheKey, this.options, () => { const hasChanged = this.updateResult(); if (hasChanged) { this.scheduleNotifyListeners(); } }, force ); } /** * 옵션 업데이트 최적화 */ setOptions(options2) { const prevKey = this.cacheKey; const prevHash = this.optionsHash; const prevEnabled = this.options.enabled; this.options.select; const newHash = this.createOptionsHash(options2); const currentSelectContent = options2.select ? options2.select.toString() : null; this.selectFunctionChanged = this.lastSelectFunctionContent !== currentSelectContent; this.lastSelectFunctionContent = currentSelectContent; if (prevHash === newHash && !this.selectFunctionChanged) { this.options = options2; return; } if (prevHash === newHash && this.selectFunctionChanged) { this.options = options2; this.invalidateSelectRelatedCaches(); const hasChanged = this.updateResult(); if (hasChanged) { this.scheduleNotifyListeners(); } return; } const prevOptions = this.options; this.options = options2; this.cacheKey = serializeQueryKey(options2.key); this.optionsHash = newHash; const enabledChanged = prevEnabled !== options2.enabled; if (prevKey !== this.cacheKey) { this.ensureManagersInitialized(); this.handleKeyChange(prevOptions); } else { this.handleOptionsChange(enabledChanged, prevEnabled, options2.enabled); } } /** * 키 변경 처리 */ handleKeyChange(prevOptions) { this.trackedResult = null; this.queryClient.unsubscribe(prevOptions.key, prevOptions.gcTime || 3e5); if (this.placeholderManager) { this.placeholderManager.deactivatePlaceholder(); } this.subscribeToCache(); if (this.queryClient.has(this.cacheKey)) { this.handleCachedDataAvailable(); } else { this.handleNoCachedData(); } } /** * 옵션 변경 처리 */ handleOptionsChange(enabledChanged, prevEnabled, newEnabled) { const hasChanged = this.updateResult(); if (hasChanged) { this.scheduleNotifyListeners(); } if (enabledChanged && prevEnabled === false && newEnabled === true && this.isStarted) { queueMicrotask(async () => { try { const cached = this.queryClient.get(this.cacheKey); const hasCached = !!cached; if (!hasCached) { this.queryClient.set(this.cacheKey, { data: void 0, error: void 0, isLoading: true, isFetching: true, updatedAt: 0 }); } else if (this.isCacheStale(cached)) { this.queryClient.set(this.cacheKey, { ...cached, isFetching: true }); } await this.executeFetch(() => { const hasChanged2 = this.updateResult(); if (hasChanged2) { this.scheduleNotifyListeners(); } }); } catch (error) { console.error("executeFetch error after enabled change:", error); } }); } } handleCachedDataAvailable() { const hasChanged = this.updateResult(); const cached = this.queryClient.get(this.cacheKey); if (this.shouldStartBackgroundFetch(cached)) { this.startBackgroundFetch(cached); } if (hasChanged) { this.scheduleNotifyListeners(); } } /** * 백그라운드 fetch를 시작해야 하는지 확인 */ shouldStartBackgroundFetch(cached) { if (!cached || this.options.enabled === false) { return false; } return this.isCacheStale(cached); } /** * 백그라운드 fetch 시작 */ startBackgroundFetch(cached) { this.queryClient.set(this.cacheKey, { ...cached, isFetching: true }); this.executeFetch(); } handleNoCachedData() { const hasChanged = this.updateResult(); if (hasChanged) { this.scheduleNotifyListeners(); } this.executeFetch(); } scheduleNotifyListeners() { queueMicrotask(() => { if (!this.isDestroyed) { this.notifyListeners(); } }); } /** * 매니저들이 초기화되었는지 확인하고 필요한 경우 초기화 */ ensureManagersInitialized() { if (!this.placeholderManager) { this.placeholderManager = new PlaceholderManager(this.queryClient); this.resultComputer = new ResultComputer(this.queryClient, this.placeholderManager); this.fetchManager = new FetchManager(this.queryClient, this.placeholderManager); } } /** * 옵션 해시 생성 * Select 함수는 내용 기반으로 해시 생성 */ createOptionsHash(options2) { const hashableOptions = { key: options2.key, url: options2.url, params: options2.params, enabled: options2.enabled, staleTime: options2.staleTime, gcTime: options2.gcTime, // Select 함수 내용을 해시에 포함 (함수 내용이 같으면 같은 해시) selectFnContent: options2.select ? options2.select.toString() : null, selectDeps: options2.selectDeps }; return JSON.stringify(hashableOptions); } /** * Select 함수 관련 캐시 무효화 */ invalidateSelectRelatedCaches() { this.computeResultCache = null; if (this.resultComputer) { this.resultComputer.clearSelectState(); } } /** * Observer 정리 */ destroy() { this.isDestroyed = true; this.queryClient.unsubscribe(this.options.key, this.options.gcTime || 3e5); this.listeners.clear(); if (this.placeholderManager) { this.placeholderManager.deactivatePlaceholder(); } if (this.resultComputer) { this.resultComputer.clearSelectState(); } this.lastResultReference = null; this.trackedResult = null; this.computeResultCache = null; this.lastSelectFunctionContent = null; this.selectFunctionChanged = false; } }; // src/query/ssr/ssr-query-client.ts var SSRQueryClient = class { constructor() { this.cache = /* @__PURE__ */ new Map(); } /** * 캐시에 데이터 저장 */ set(key, state) { const sKey = serializeQueryKey(key); this.cache.set(sKey, state); } /** * 캐시에서 데이터 조회 */ get(key) { return this.cache.get(serializeQueryKey(key)); } /** * 캐시 존재 여부 확인 */ has(key) { return this.cache.has(serializeQueryKey(key)); } /** * 캐시 직렬화 (hydration용) * 최적화: 필요한 데이터만 포함 */ dehydrate() { const result = {}; this.cache.forEach((state, key) => { if (!state.error && state.data !== void 0) { result[key] = { data: state.data, error: void 0, isLoading: false, isFetching: false, updatedAt: state.updatedAt }; } }); return result; } /** * 캐시 크기 반환 (디버깅용) */ get size() { return this.cache.size; } }; // src/query/ssr/ssr-prefetch.ts async function ssrPrefetch(queries, globalFetchConfig = {}, client) { const queryClient = new SSRQueryClient(); const fetcher = client ? client.getFetcher() : getQueryClient(globalFetchConfig).getFetcher(); await Promise.all( queries.map(async (queryItem) => { const [query, params] = queryItem; const cacheKey = query.cacheKey(params); const sKey = serializeQueryKey(cacheKey); try { let data; if (query.queryFn) { data = await query.queryFn(params, fetcher); } else if (query.url) { const url = query.url(params); const response = await fetcher.get(url, Object.assign({}, globalFetchConfig, query.fetchConfig, { params })); data = response.data; } if (query.schema && data) { data = query.schema.parse(data); } if (query.select && data) { data = query.select(data); } queryClient.set(sKey, { data, error: void 0, isLoading: false, isFetching: false, updatedAt: Date.now() }); } catch (error) { queryClient.set(sKey, { data: void 0, error, isLoading: false, isFetching: false, updatedAt: Date.now() }); } }) ); return queryClient.dehydrate(); } export { QueryClient, QueryObserver, createMutationFactory, createQueryClientWithInterceptors, createQueryFactory, getQueryClient, resetQueryClient, setDefaultQueryClientOptions, ssrPrefetch, validateMutationConfig, validateQueryConfig }; //# sourceMappingURL=index.mjs.map //# sourceMappingURL=index.mjs.map