diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 0fdf16c762..84a1c2043c 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -2415,6 +2415,47 @@ describe("fetchers", () => { }); }); + it("skips all revalidation when callsite defaultShouldRevalidate is false", async () => { + let key = "key"; + let actionKey = "actionKey"; + let t = setup({ + routes: TASK_ROUTES, + initialEntries: ["/"], + hydrationData: { loaderData: { root: "ROOT", index: "INDEX" } }, + }); + + // preload a fetcher + let A = await t.fetch("/tasks/1", key); + await A.loaders.tasksId.resolve("TASKS ID"); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "TASKS ID", + }); + + // submit action with shouldRevalidate=false + let C = await t.fetch("/tasks", actionKey, { + formMethod: "post", + formData: createFormData({}), + defaultShouldRevalidate: false, + }); + + expect(t.fetchers[actionKey]).toMatchObject({ state: "submitting" }); + + // resolve action — no loaders should trigger + await C.actions.tasks.resolve("TASKS ACTION"); + + // verify all fetchers idle + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "TASKS ID", + }); + + expect(t.fetchers[actionKey]).toMatchObject({ + state: "idle", + data: "TASKS ACTION", + }); + }); + it("does not revalidate fetchers initiated from removed routes", async () => { let t = setup({ routes: TASK_ROUTES, diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index fa00d6c6a2..c9d1a98e18 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -154,6 +154,8 @@ export interface NavigateOptions { flushSync?: boolean; /** Enables a {@link https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API View Transition} for this navigation by wrapping the final state update in `document.startViewTransition()`. If you need to apply specific styles for this view transition, you will also need to leverage the {@link https://api.reactrouter.com/v7/functions/react_router.useViewTransitionState.html useViewTransitionState()} hook. */ viewTransition?: boolean; + /** When false, suppresses loader revalidation triggered by this navigation **/ + defaultShouldRevalidate?: boolean; } /** diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 82404939fb..c01c9d9143 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -192,6 +192,11 @@ interface SharedSubmitOptions { * Enable flushSync for this submission's state updates */ flushSync?: boolean; + + /** + * Determine if revalidation should occur post-submission. + */ + defaultShouldRevalidate?: boolean; } /** diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index cff63d829d..22c1eed9cb 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1287,6 +1287,22 @@ export interface LinkProps * To apply specific styles for the transition, see {@link useViewTransitionState} */ viewTransition?: boolean; + + /** + * Controls whether loaders should revalidate when this link is clicked. + * + * ```tsx + * + * ``` + * + * When set to `false`, prevents the default revalidation behavior after navigation, + * keeping the current loader data without refetching. This can be useful when updating + * search params and you don't want to trigger a revalidation. + * + * By default (when not specified), loaders will revalidate according to the framework's + * standard revalidation behavior. + */ + defaultShouldRevalidate?: boolean; } const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; @@ -1319,6 +1335,7 @@ const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; * @param {LinkProps.state} props.state n/a * @param {LinkProps.to} props.to n/a * @param {LinkProps.viewTransition} props.viewTransition [modes: framework, data] n/a + * @param {LinkProps.defaultShouldRevalidate} props.defaultShouldRevalidate n/a */ export const Link = React.forwardRef( function LinkWithRef( @@ -1334,6 +1351,7 @@ export const Link = React.forwardRef( to, preventScrollReset, viewTransition, + defaultShouldRevalidate, ...rest }, forwardedRef, @@ -1389,6 +1407,7 @@ export const Link = React.forwardRef( preventScrollReset, relative, viewTransition, + defaultShouldRevalidate, }); function handleClick( event: React.MouseEvent, @@ -1788,6 +1807,11 @@ interface SharedFormProps extends React.FormHTMLAttributes { * then this form will not do anything. */ onSubmit?: React.FormEventHandler; + + /** + * Determine if revalidation should occur post-submission. + */ + defaultShouldRevalidate?: boolean; } /** @@ -1911,6 +1935,7 @@ type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement; * @param {FormProps.replace} replace n/a * @param {FormProps.state} state n/a * @param {FormProps.viewTransition} viewTransition n/a + * @param {FormProps.defaultShouldRevalidate} defaultShouldRevalidate n/a * @returns A progressively enhanced [`
`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) component */ export const Form = React.forwardRef( @@ -1928,6 +1953,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, + defaultShouldRevalidate, ...props }, forwardedRef, @@ -1960,6 +1986,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, + defaultShouldRevalidate, }); }; @@ -2175,6 +2202,7 @@ function useDataRouterState(hookName: DataRouterStateHook) { * @param options.viewTransition Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) * for this navigation. To apply specific styles during the transition, see * {@link useViewTransitionState}. Defaults to `false`. + * @param options.defaultShouldRevalidate Defaults to `true` * @returns A click handler function that can be used in a custom {@link Link} component. */ export function useLinkClickHandler( @@ -2186,6 +2214,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + defaultShouldRevalidate, }: { target?: React.HTMLAttributeAnchorTarget; replace?: boolean; @@ -2193,6 +2222,7 @@ export function useLinkClickHandler( preventScrollReset?: boolean; relative?: RelativeRoutingType; viewTransition?: boolean; + defaultShouldRevalidate?: boolean; } = {}, ): (event: React.MouseEvent) => void { let navigate = useNavigate(); @@ -2217,6 +2247,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + defaultShouldRevalidate, }); } }, @@ -2231,6 +2262,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + defaultShouldRevalidate, ], ); } @@ -2549,6 +2581,7 @@ export function useSubmit(): SubmitFunction { if (options.navigate === false) { let key = options.fetcherKey || getUniqueFetcherId(); await router.fetch(key, currentRouteId, options.action || action, { + defaultShouldRevalidate: options.defaultShouldRevalidate, preventScrollReset: options.preventScrollReset, formData, body, @@ -2558,6 +2591,7 @@ export function useSubmit(): SubmitFunction { }); } else { await router.navigate(options.action || action, { + defaultShouldRevalidate: options.defaultShouldRevalidate, preventScrollReset: options.preventScrollReset, formData, body, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 50c79e0090..6969fa009f 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -524,6 +524,7 @@ type BaseNavigateOrFetchOptions = { preventScrollReset?: boolean; relative?: RelativeRoutingType; flushSync?: boolean; + defaultShouldRevalidate?: boolean; }; // Only allowed for navigations @@ -1510,6 +1511,8 @@ export function createRouter(init: RouterInit): Router { // action/loader this will be ignored and the redirect will be a PUSH historyAction = NavigationType.Replace; } + let callSiteDefaultShouldRevalidate = + opts?.defaultShouldRevalidate !== false; let preventScrollReset = opts && "preventScrollReset" in opts @@ -1557,6 +1560,7 @@ export function createRouter(init: RouterInit): Router { replace: opts && opts.replace, enableViewTransition: opts && opts.viewTransition, flushSync, + callSiteDefaultShouldRevalidate, }); } @@ -1632,6 +1636,7 @@ export function createRouter(init: RouterInit): Router { replace?: boolean; enableViewTransition?: boolean; flushSync?: boolean; + callSiteDefaultShouldRevalidate?: boolean; }, ): Promise { // Abort any in-progress navigations and start a new one. Unset any ongoing @@ -1803,6 +1808,7 @@ export function createRouter(init: RouterInit): Router { opts && opts.initialHydration === true, flushSync, pendingActionResult, + opts && opts.callSiteDefaultShouldRevalidate !== false, ); if (shortCircuited) { @@ -2008,6 +2014,7 @@ export function createRouter(init: RouterInit): Router { initialHydration?: boolean, flushSync?: boolean, pendingActionResult?: PendingActionResult, + callSiteDefaultShouldRevalidate?: boolean, ): Promise { // Figure out the right navigation we want to use for data loading let loadingNavigation = @@ -2115,6 +2122,7 @@ export function createRouter(init: RouterInit): Router { basename, init.patchRoutesOnNavigation != null, pendingActionResult, + callSiteDefaultShouldRevalidate, ); pendingNavigationLoadId = ++incrementingLoadId; @@ -2346,6 +2354,8 @@ export function createRouter(init: RouterInit): Router { let preventScrollReset = (opts && opts.preventScrollReset) === true; if (submission && isMutationMethod(submission.formMethod)) { + let callSiteDefaultShouldRevalidate = + opts?.defaultShouldRevalidate !== false; await handleFetcherAction( key, routeId, @@ -2356,6 +2366,7 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, + callSiteDefaultShouldRevalidate, ); return; } @@ -2388,6 +2399,7 @@ export function createRouter(init: RouterInit): Router { flushSync: boolean, preventScrollReset: boolean, submission: Submission, + callSiteDefaultShouldRevalidate: boolean, ) { interruptActiveLoads(); fetchLoadMatches.delete(key); @@ -2563,6 +2575,7 @@ export function createRouter(init: RouterInit): Router { basename, init.patchRoutesOnNavigation != null, [match.route.id, actionResult], + callSiteDefaultShouldRevalidate, ); // Put all revalidating fetchers into the loading state, except for the @@ -4820,6 +4833,7 @@ function getMatchesToLoad( basename: string | undefined, hasPatchRoutesOnNavigation: boolean, pendingActionResult?: PendingActionResult, + callSiteDefaultShouldRevalidate?: boolean, ): { dsMatches: DataStrategyMatch[]; revalidatingFetchers: RevalidatingFetcher[]; @@ -4913,15 +4927,28 @@ function getMatchesToLoad( // provides it's own implementation, then we give them full control but // provide this value so they can leverage it if needed after they check // their own specific use cases - let defaultShouldRevalidate = shouldSkipRevalidation - ? false - : // Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate - isRevalidationRequired || - currentUrl.pathname + currentUrl.search === - nextUrl.pathname + nextUrl.search || - // Search params affect all loaders - currentUrl.search !== nextUrl.search || - isNewRouteInstance(state.matches[index], match); + let defaultShouldRevalidate = false; + if (callSiteDefaultShouldRevalidate != null) { + // Use callsite value verbatim if provided + defaultShouldRevalidate = callSiteDefaultShouldRevalidate; + } else if (shouldSkipRevalidation) { + // Skip due to 4xx/5xx action result + defaultShouldRevalidate = false; + } else if (isRevalidationRequired) { + // Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate + defaultShouldRevalidate = true; + } else if ( + currentUrl.pathname + currentUrl.search === + nextUrl.pathname + nextUrl.search + ) { + // Same URL - mimic a hard reload + defaultShouldRevalidate = true; + } else if (currentUrl.search !== nextUrl.search) { + // Search params affect all loaders + defaultShouldRevalidate = true; + } else if (isNewRouteInstance(state.matches[index], match)) { + defaultShouldRevalidate = true; + } let shouldRevalidateArgs = { ...baseShouldRevalidateArgs, defaultShouldRevalidate, @@ -4937,6 +4964,7 @@ function getMatchesToLoad( scopedContext, shouldLoad, shouldRevalidateArgs, + callSiteDefaultShouldRevalidate, ); }); @@ -5832,6 +5860,7 @@ function getDataStrategyMatch( scopedContext: unknown, shouldLoad: boolean, unstable_shouldRevalidateArgs: DataStrategyMatch["unstable_shouldRevalidateArgs"] = null, + callSiteDefaultShouldRevalidate?: boolean, ): DataStrategyMatch { // The hope here is to avoid a breaking change to the resolve behavior. // Opt-ing into the `unstable_shouldCallHandler` API changes some nuanced behavior @@ -5857,6 +5886,13 @@ function getDataStrategyMatch( return shouldLoad; } + if (typeof callSiteDefaultShouldRevalidate === "boolean") { + return shouldRevalidateLoader(match, { + ...unstable_shouldRevalidateArgs, + defaultShouldRevalidate: callSiteDefaultShouldRevalidate, + }); + } + if (typeof defaultShouldRevalidate === "boolean") { return shouldRevalidateLoader(match, { ...unstable_shouldRevalidateArgs,