next-unified-query
Version:
React hooks and components for next-unified-query-core
560 lines (558 loc) • 19.9 kB
JavaScript
"use client";
import * as React2 from 'react';
import React2__default, { createContext, useRef, useMemo, useContext, useCallback, useSyncExternalStore, useEffect, useReducer, useState } from 'react';
import { isObject, isFunction, merge, isArray } from 'es-toolkit/compat';
import { configureQueryClient, getQueryClient, validateQueryConfig, QueryObserver, validateMutationConfig, z, FetchError } from 'next-unified-query-core';
// src/hooks/use-query.ts
var QueryClientContext = createContext(null);
var QueryConfigContext = createContext(void 0);
function HydrationBoundary({
state,
children
}) {
const client = useQueryClient();
const hydratedRef = useRef(false);
if (state && !hydratedRef.current) {
client.hydrate(state);
hydratedRef.current = true;
}
return /* @__PURE__ */ React2__default.createElement(React2__default.Fragment, null, children);
}
function QueryClientProvider({ client, config, children }) {
if (config && typeof window !== "undefined") {
configureQueryClient(config);
}
const queryClient = useMemo(
() => client || getQueryClient(config),
[client, config]
);
return /* @__PURE__ */ React2__default.createElement(QueryConfigContext.Provider, { value: config }, /* @__PURE__ */ React2__default.createElement(QueryClientContext.Provider, { value: queryClient }, children));
}
function useQueryClient() {
const ctx = useContext(QueryClientContext);
if (!ctx) throw new Error("You must wrap your component tree with <QueryClientProvider>.");
return ctx;
}
function useQueryConfig() {
return useContext(QueryConfigContext);
}
function useQuery(arg1, arg2) {
if (isObject(arg1) && "cacheKey" in arg1 && isFunction(arg1.cacheKey)) {
const query = arg1;
validateQueryConfig(query);
const options = arg2 ?? {};
const params = options.params;
const cacheKey = query.cacheKey?.(params);
const url = query.url?.(params);
const queryFn = query.queryFn;
const schema = query.schema;
const placeholderData = options.placeholderData ?? query.placeholderData;
const fetchConfig = options.fetchConfig ?? query.fetchConfig;
const select = options.select ?? query.select;
const selectDeps = options.selectDeps ?? query.selectDeps;
const enabled = "enabled" in options ? options.enabled : isFunction(query.enabled) ? query.enabled(params) : query.enabled;
return _useQueryObserver({
...query,
...options,
enabled,
cacheKey,
url,
queryFn,
params,
schema,
placeholderData,
fetchConfig,
select,
selectDeps
});
}
return _useQueryObserver({
...arg1
});
}
function _useQueryObserver(options) {
validateQueryConfig(options);
const queryClient = useQueryClient();
const defaultOptions = queryClient.getDefaultOptions();
const observerRef = useRef(void 0);
const defaultResultRef = useRef({
data: void 0,
error: void 0,
isLoading: true,
isFetching: true,
isError: false,
isSuccess: false,
isStale: true,
isPlaceholderData: false,
refetch: () => {
}
});
const defaults = defaultOptions?.queries || {};
const mergedOptions = {
...defaults,
...options,
// 명시적으로 undefined인 경우에만 기본값 사용
throwOnError: options.throwOnError !== void 0 ? options.throwOnError : defaults.throwOnError,
suspense: options.suspense !== void 0 ? options.suspense : defaults.suspense,
staleTime: options.staleTime !== void 0 ? options.staleTime : defaults.staleTime,
gcTime: options.gcTime !== void 0 ? options.gcTime : defaults.gcTime
};
if (!observerRef.current) {
observerRef.current = new QueryObserver(queryClient, {
...mergedOptions,
key: mergedOptions.cacheKey
});
} else {
observerRef.current.setOptions({
...mergedOptions,
key: mergedOptions.cacheKey
});
}
const subscribe = useCallback((callback) => {
return observerRef.current.subscribe(callback);
}, []);
const getSnapshot = useCallback(() => {
if (!observerRef.current) {
return defaultResultRef.current;
}
return observerRef.current.getCurrentResult();
}, []);
const result = useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot
// getServerSnapshot도 동일하게
);
useEffect(() => {
observerRef.current?.start();
}, []);
if (mergedOptions.suspense && !result.data && !result.error) {
const promise = observerRef.current?.getPromise();
if (promise) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[next-unified-query] Warning: suspense: true is enabled but no Suspense boundary detected.\nThis will cause your app to crash if a Suspense boundary is not present.\n\nTo fix this issue:\n1. Wrap your component with <Suspense>:\n <Suspense fallback={<div>Loading...</div>}>\n <YourComponent />\n </Suspense>\n\n2. Or disable suspense mode:\n useQuery({ suspense: false, ... })"
);
}
throw promise;
}
}
const throwOnError = mergedOptions.throwOnError;
useEffect(() => {
if (result.error && throwOnError) {
const shouldThrow = typeof throwOnError === "function" ? throwOnError(result.error) : throwOnError;
if (shouldThrow) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[next-unified-query] Warning: throwOnError is enabled but no Error Boundary detected.\nThis will cause your app to crash if an Error Boundary is not present.\n\nTo fix this issue:\n1. Wrap your component with an Error Boundary:\n <ErrorBoundary fallback={<ErrorFallback />}>\n <YourComponent />\n </ErrorBoundary>\n\n2. Or disable throwOnError:\n useQuery({ throwOnError: false, ... })\n\nOriginal error:",
result.error
);
}
throw result.error;
}
}
}, [result.error, throwOnError]);
useEffect(() => {
return () => {
observerRef.current?.destroy();
};
}, []);
return result;
}
var getInitialState = () => ({
data: void 0,
error: null,
isPending: false,
isSuccess: false,
isError: false
});
function useMutation(configOrOptions, overrideOptions) {
const isFactoryConfig = "url" in configOrOptions || "mutationFn" in configOrOptions;
if (isFactoryConfig && overrideOptions) {
const factoryConfig = configOrOptions;
const mergedOptions = mergeMutationOptions(factoryConfig, overrideOptions);
return _useMutationInternal(mergedOptions);
} else {
return _useMutationInternal(configOrOptions);
}
}
function mergeMutationOptions(factoryConfig, overrideOptions) {
const factoryOnMutate = factoryConfig.onMutate;
const factoryOnSuccess = factoryConfig.onSuccess;
const factoryOnError = factoryConfig.onError;
const factoryOnSettled = factoryConfig.onSettled;
const overrideOnMutate = overrideOptions.onMutate;
const overrideOnSuccess = overrideOptions.onSuccess;
const overrideOnError = overrideOptions.onError;
const overrideOnSettled = overrideOptions.onSettled;
return {
// Factory 기본 속성들
...factoryConfig,
// Override 옵션들로 덮어쓰기 (콜백 제외)
...overrideOptions,
// 콜백들은 양쪽 모두 실행하도록 병합
onMutate: combinedCallback(factoryOnMutate, overrideOnMutate),
onSuccess: combinedCallback(factoryOnSuccess, overrideOnSuccess),
onError: combinedCallback(factoryOnError, overrideOnError),
onSettled: combinedCallback(factoryOnSettled, overrideOnSettled)
};
}
function combinedCallback(first, second) {
if (!first && !second) return void 0;
if (!first) return second;
if (!second) return first;
return ((...args) => {
const firstResult = first(...args);
const secondResult = second(...args);
if (firstResult && typeof firstResult.then === "function") {
return firstResult.then(() => secondResult);
}
return secondResult;
});
}
function _useMutationInternal(options) {
const queryClient = useQueryClient();
const fetcher = queryClient.getFetcher();
const defaultOptions = queryClient.getDefaultOptions();
const defaults = defaultOptions?.mutations || {};
const mergedThrowOnError = options.throwOnError !== void 0 ? options.throwOnError : defaults.throwOnError;
if (process.env.NODE_ENV !== "production") {
try {
validateMutationConfig(options);
} catch (error) {
throw error;
}
}
const [state, dispatch] = useReducer(
(prevState, action) => {
switch (action.type) {
case "MUTATE":
return {
...prevState,
isPending: true,
isSuccess: false,
isError: false,
error: null
};
case "SUCCESS":
return {
...prevState,
isPending: false,
isSuccess: true,
isError: false,
data: action.data,
error: null
};
case "ERROR":
return {
...prevState,
isPending: false,
isSuccess: false,
isError: true,
error: action.error
};
case "RESET":
return getInitialState();
default:
return prevState;
}
},
getInitialState()
);
const latestOptions = useRef(options);
latestOptions.current = options;
const getMutationFn = useCallback(() => {
if ("mutationFn" in options && options.mutationFn) {
return options.mutationFn;
}
return async (variables, fetcher2) => {
const urlBasedOptions = options;
const url = isFunction(urlBasedOptions.url) ? urlBasedOptions.url(variables) : urlBasedOptions.url;
const method = urlBasedOptions.method;
let dataForRequest = variables;
if (options.requestSchema) {
try {
dataForRequest = options.requestSchema.parse(variables);
} catch (e) {
if (e instanceof z.ZodError) {
const config = {
url,
method,
data: variables
};
const fetchError = new FetchError(
`Request validation failed: ${e.issues.map((issue) => issue.message).join(", ")}`,
config,
"ERR_VALIDATION"
);
fetchError.name = "ValidationError";
fetchError.cause = e;
fetchError.isValidationError = true;
throw fetchError;
}
throw e;
}
}
const requestConfig = merge(
{ schema: options.responseSchema },
// fetcher.defaults에서 baseURL을 가져와서 기본값으로 설정
{ baseURL: fetcher2.defaults.baseURL },
options.fetchConfig || {},
{
url,
method,
// MutationMethod는 HttpMethod의 부분집합이므로 안전
data: dataForRequest
}
);
const response = await fetcher2.request(requestConfig);
return response.data;
};
}, [options, fetcher]);
const mutateCallback = useCallback(
async (variables, mutateLocalOptions) => {
dispatch({ type: "MUTATE", variables });
let context;
try {
const onMutateCb = latestOptions.current.onMutate;
if (onMutateCb) {
context = await onMutateCb(variables);
}
const mutationFn = getMutationFn();
const data = await mutationFn(variables, fetcher);
dispatch({ type: "SUCCESS", data });
if (latestOptions.current.onSuccess) {
await latestOptions.current.onSuccess(data, variables, context);
}
if (mutateLocalOptions?.onSuccess) {
mutateLocalOptions.onSuccess(data, variables, context);
}
const invalidateQueriesOption = latestOptions.current.invalidateQueries;
if (invalidateQueriesOption) {
let keysToInvalidate;
if (isFunction(invalidateQueriesOption)) {
keysToInvalidate = invalidateQueriesOption(data, variables, context);
} else {
keysToInvalidate = invalidateQueriesOption;
}
if (isArray(keysToInvalidate)) {
keysToInvalidate.forEach((queryKey) => {
queryClient.invalidateQueries(queryKey);
});
}
}
if (latestOptions.current.onSettled) {
await latestOptions.current.onSettled(data, null, variables, context);
}
if (mutateLocalOptions?.onSettled) {
mutateLocalOptions.onSettled(data, null, variables, context);
}
return data;
} catch (err) {
const error = err;
dispatch({ type: "ERROR", error });
if (latestOptions.current.onError) {
await latestOptions.current.onError(error, variables, context);
}
if (mutateLocalOptions?.onError) {
mutateLocalOptions.onError(error, variables, context);
}
if (latestOptions.current.onSettled) {
await latestOptions.current.onSettled(void 0, error, variables, context);
}
if (mutateLocalOptions?.onSettled) {
mutateLocalOptions.onSettled(void 0, error, variables, context);
}
throw error;
}
},
[getMutationFn, queryClient, fetcher]
);
const mutate = useCallback(
(variables, localOptions) => {
mutateCallback(variables, localOptions).catch(() => {
});
},
[mutateCallback]
);
const mutateAsync = useCallback(
(variables, localOptions) => {
return mutateCallback(variables, localOptions);
},
[mutateCallback]
);
const reset = useCallback(() => {
dispatch({ type: "RESET" });
}, []);
useEffect(() => {
if (state.error && mergedThrowOnError) {
const shouldThrow = typeof mergedThrowOnError === "function" ? mergedThrowOnError(state.error) : mergedThrowOnError;
if (shouldThrow) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[next-unified-query] Warning: Mutation throwOnError is enabled.\nThis will propagate the error to the nearest Error Boundary.\nMake sure you have an Error Boundary set up.\n\nTo fix this issue:\n1. Wrap your component with an Error Boundary:\n <ErrorBoundary fallback={<ErrorFallback />}>\n <YourComponent />\n </ErrorBoundary>\n\n2. Or disable throwOnError:\n useMutation({ throwOnError: false, ... })\n\nOriginal error:",
state.error
);
}
throw state.error;
}
}
}, [state.error, mergedThrowOnError]);
return {
...state,
mutate,
mutateAsync,
reset
};
}
var QueryErrorBoundaryClass = class extends React2.Component {
constructor(props) {
super(props);
/**
* Error Boundary를 리셋하고 정상 상태로 되돌립니다.
*/
this.reset = () => {
this.props.onReset?.();
this.setState({ hasError: false, error: null });
};
this.state = { hasError: false, error: null };
}
/**
* React 공식 Error Boundary 메서드
* 에러 발생 시 state를 업데이트하여 fallback UI를 렌더링합니다.
*/
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
/**
* React 공식 Error Boundary 메서드
* 에러 정보를 로깅하고 부수 효과를 처리합니다.
*/
componentDidCatch(error, errorInfo) {
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
if (this.isFetchError(error)) {
console.error("Query Error Details:", {
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method,
message: error.message,
code: error.code,
componentStack: errorInfo.componentStack
});
} else {
console.error("Error caught by QueryErrorBoundary:", error, errorInfo);
}
}
componentDidUpdate(prevProps) {
if (this.state.hasError && this.props.resetKeys && prevProps.resetKeys) {
const hasResetKeyChanged = this.props.resetKeys.length !== prevProps.resetKeys.length || this.props.resetKeys.some((key, idx) => key !== prevProps.resetKeys[idx]);
if (hasResetKeyChanged) {
this.reset();
}
} else if (this.state.hasError && this.props.resetKeys && !prevProps.resetKeys) {
this.reset();
}
}
/**
* 에러가 FetchError 인스턴스인지 확인합니다.
*/
isFetchError(error) {
return error !== null && typeof error === "object" && "config" in error && "response" in error && "code" in error;
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset);
}
return /* @__PURE__ */ React2.createElement(
"div",
{
role: "alert",
"aria-live": "assertive",
style: { padding: "20px", border: "1px solid #f0f0f0", borderRadius: "4px" }
},
/* @__PURE__ */ React2.createElement("h2", { style: { color: "#d32f2f", marginTop: 0 } }, "Something went wrong"),
/* @__PURE__ */ React2.createElement("p", { style: { color: "#666" } }, "An error occurred while rendering this component."),
/* @__PURE__ */ React2.createElement("details", { style: { marginTop: "16px", cursor: "pointer" } }, /* @__PURE__ */ React2.createElement("summary", { style: { cursor: "pointer", outline: "none" } }, "Error details"), /* @__PURE__ */ React2.createElement(
"pre",
{
style: {
marginTop: "8px",
padding: "12px",
backgroundColor: "#f5f5f5",
borderRadius: "4px",
overflow: "auto",
fontSize: "12px",
whiteSpace: "pre-wrap"
},
"aria-label": "Error stack trace"
},
this.state.error.toString(),
this.state.error.stack && /* @__PURE__ */ React2.createElement(React2.Fragment, null, "\n\nStack trace:\n", this.state.error.stack)
)),
/* @__PURE__ */ React2.createElement(
"button",
{
onClick: this.reset,
style: {
marginTop: "16px",
padding: "8px 16px",
backgroundColor: "#1976d2",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px"
},
"aria-label": "Reset error boundary and try again"
},
"Try again"
)
);
}
return this.props.children;
}
};
var QueryErrorBoundary = ((props) => {
return React2.createElement(
QueryErrorBoundaryClass,
props
);
});
QueryErrorBoundary.displayName = "QueryErrorBoundary";
var ErrorResetContext = createContext(() => {
if (process.env.NODE_ENV !== "production") {
console.warn(
"useErrorResetBoundary must be used within QueryErrorResetBoundary"
);
}
});
var useErrorResetBoundary = () => {
return useContext(ErrorResetContext);
};
function QueryErrorResetBoundary({
children,
onReset,
...errorBoundaryProps
}) {
const [resetCount, setResetCount] = useState(0);
const reset = useCallback(() => {
setResetCount((count) => count + 1);
onReset?.();
}, [onReset]);
return /* @__PURE__ */ React2__default.createElement(ErrorResetContext.Provider, { value: reset }, /* @__PURE__ */ React2__default.createElement(
QueryErrorBoundary,
{
...errorBoundaryProps,
resetKeys: [resetCount],
onReset: reset
},
children
));
}
export { HydrationBoundary, QueryClientProvider, QueryErrorBoundary, QueryErrorResetBoundary, useErrorResetBoundary, useMutation, useQuery, useQueryClient, useQueryConfig };
//# sourceMappingURL=react.mjs.map
//# sourceMappingURL=react.mjs.map