UNPKG

next-unified-query

Version:

React hooks and components for next-unified-query-core

560 lines (558 loc) 19.9 kB
"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