next-unified-query-core
Version:
Framework-agnostic HTTP client with built-in query state management
1,617 lines (1,610 loc) • 48.2 kB
JavaScript
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