|
| 1 | +import { atom, PrimitiveAtom, useAtomValue, useSetAtom } from 'jotai'; |
| 2 | +import { atomWithDefault } from 'jotai/utils'; |
| 3 | +import _ from 'lodash'; |
| 4 | +import { createContext, use, useCallback, useMemo } from 'react'; |
| 5 | +import { useLocation } from 'react-router-dom'; |
| 6 | +import { |
| 7 | + useQueryParams, |
| 8 | + QueryParamConfigMap, |
| 9 | + DecodedValueMap, |
| 10 | + UrlUpdateType, |
| 11 | +} from 'use-query-params'; |
| 12 | + |
| 13 | +/** |
| 14 | + * A custom hook that synchronizes URL search parameters with application state while handling React transitions. |
| 15 | + * This hook solves the issue where URL parameter changes within React transitions are not properly reflected |
| 16 | + * in the rendering cycle, as search parameter changes are detected through events rather than React's state system. |
| 17 | + * |
| 18 | + * @template QPCMap - Type extending QueryParamConfigMap that defines the structure of URL parameters |
| 19 | + * @param {QPCMap} paramConfigMap - Configuration object that defines the URL parameters to be managed |
| 20 | + * |
| 21 | + * @returns {[ |
| 22 | + * DecodedValueMap<QPCMap>, |
| 23 | + * (nextQueryParams: Partial<DecodedValueMap<QPCMap>> | ((prevQueryParams: DecodedValueMap<QPCMap>) => Partial<DecodedValueMap<QPCMap>>), |
| 24 | + * updateType: UrlUpdateType) => void |
| 25 | + * ]} A tuple containing: |
| 26 | + * - currentQueryParams: The current state of the URL parameters |
| 27 | + * - updateQueryParams: Function to update URL parameters with transition support |
| 28 | + * |
| 29 | + * @example |
| 30 | + * const [queryParams, updateQueryParams] = useTransitionSafeQueryParams({ |
| 31 | + * page: NumberParam, |
| 32 | + * search: StringParam |
| 33 | + * }); |
| 34 | + * |
| 35 | + * // Update URL parameters |
| 36 | + * updateQueryParams({ page: 2 }, 'pushIn'); |
| 37 | + */ |
| 38 | + |
| 39 | +export function useTransitionSafeQueryParams< |
| 40 | + QPCMap extends QueryParamConfigMap, |
| 41 | +>(paramConfigMap: QPCMap) { |
| 42 | + const [query, setQuery] = useQueryParams(paramConfigMap); |
| 43 | + |
| 44 | + const { paramKeys } = useMemo( |
| 45 | + () => ({ |
| 46 | + paramKeys: Object.keys(paramConfigMap), |
| 47 | + }), |
| 48 | + // Memoize based on the stringified version of the config map |
| 49 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 50 | + [JSON.stringify(paramConfigMap)], |
| 51 | + ); |
| 52 | + |
| 53 | + const pageQueryParamDeltaAtom = usePageQueryParamDeltaAtom(); |
| 54 | + // Create page-specific atoms that reset when pagePath changes |
| 55 | + const localQueryParamsAtom = useMemo(() => { |
| 56 | + // Derived atom that manages the query parameters |
| 57 | + const queryParamsAtom = atomWithDefault((get) => { |
| 58 | + const pageQueryParamDelta = get(pageQueryParamDeltaAtom); |
| 59 | + return _.pick( |
| 60 | + { |
| 61 | + // `query` can have stale values until event is processed and re-rendered |
| 62 | + ...query, |
| 63 | + ...pageQueryParamDelta, |
| 64 | + }, |
| 65 | + paramKeys, |
| 66 | + ) as DecodedValueMap<QPCMap>; |
| 67 | + }); |
| 68 | + |
| 69 | + return queryParamsAtom; |
| 70 | + |
| 71 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 72 | + }, [pageQueryParamDeltaAtom, paramKeys]); // New atoms when page changes and paramKeys change |
| 73 | + |
| 74 | + const localQueryParams = useAtomValue(localQueryParamsAtom); |
| 75 | + const setPageQueryParamDelta = useSetAtom(pageQueryParamDeltaAtom); |
| 76 | + |
| 77 | + const updateQueryParams = useCallback( |
| 78 | + ( |
| 79 | + nextQueryParams: |
| 80 | + | Partial<DecodedValueMap<QPCMap>> |
| 81 | + | (( |
| 82 | + prevQueryParams: DecodedValueMap<QPCMap>, |
| 83 | + ) => Partial<DecodedValueMap<QPCMap>>), |
| 84 | + updateType: UrlUpdateType, |
| 85 | + ) => { |
| 86 | + const resolvedNextParams = |
| 87 | + typeof nextQueryParams === 'function' |
| 88 | + ? nextQueryParams(localQueryParams) |
| 89 | + : nextQueryParams; |
| 90 | + |
| 91 | + // Update internal state atom |
| 92 | + if (updateType === 'replaceIn' || updateType === 'pushIn') { |
| 93 | + // do not touch |
| 94 | + setPageQueryParamDelta((prev) => ({ |
| 95 | + ...prev, |
| 96 | + ...resolvedNextParams, |
| 97 | + })); |
| 98 | + } else { |
| 99 | + setPageQueryParamDelta(resolvedNextParams as DecodedValueMap<QPCMap>); |
| 100 | + } |
| 101 | + |
| 102 | + // Sync all(merged) query parameters with URL |
| 103 | + setQuery( |
| 104 | + { |
| 105 | + ...localQueryParams, |
| 106 | + ...resolvedNextParams, |
| 107 | + }, |
| 108 | + updateType, |
| 109 | + ); |
| 110 | + }, |
| 111 | + [localQueryParams, setQuery, setPageQueryParamDelta], |
| 112 | + ); |
| 113 | + |
| 114 | + return [localQueryParams, updateQueryParams] as const; |
| 115 | +} |
| 116 | + |
| 117 | +// Holds only the "delta" (patch) of query params modified after the initial URL load for the current page (pathname). |
| 118 | +// It does NOT mirror the full set of current URL query params; instead it stores overrides that will be merged |
| 119 | +// with defaults + initially decoded URL values. |
| 120 | +const PageQueryParamDeltaAtomContext = createContext< |
| 121 | + PrimitiveAtom<Record<string, any>> | undefined |
| 122 | +>(undefined); |
| 123 | + |
| 124 | +function usePageQueryParamDeltaAtom() { |
| 125 | + const ctx = use(PageQueryParamDeltaAtomContext); |
| 126 | + if (!ctx) { |
| 127 | + throw new Error( |
| 128 | + 'usePageQueryParamDeltaAtom must be used within <PageQueryParamAtomP44rovider>', |
| 129 | + ); |
| 130 | + } |
| 131 | + return ctx; |
| 132 | +} |
| 133 | + |
| 134 | +export function PageQueryParamAtomProvider({ |
| 135 | + children, |
| 136 | +}: { |
| 137 | + children: React.ReactNode; |
| 138 | +}) { |
| 139 | + const { pathname } = useLocation(); |
| 140 | + |
| 141 | + // When the provider re-renders and the pathname changes, a new instance is provided. |
| 142 | + const pageQueryParamDeltaAtom = useMemo( |
| 143 | + () => atom<Record<string, any>>({}), |
| 144 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 145 | + [pathname], |
| 146 | + ); |
| 147 | + |
| 148 | + return ( |
| 149 | + <PageQueryParamDeltaAtomContext.Provider value={pageQueryParamDeltaAtom}> |
| 150 | + {children} |
| 151 | + </PageQueryParamDeltaAtomContext.Provider> |
| 152 | + ); |
| 153 | +} |
0 commit comments