From 62e3f8f10c477e682e50b2f144bc0c865123de09 Mon Sep 17 00:00:00 2001 From: dadamssg Date: Thu, 13 Nov 2025 12:55:03 -0600 Subject: [PATCH 1/8] Add callsite revalidation optout --- .../__tests__/router/fetchers-test.ts | 41 ++++++++++++++++++ packages/react-router/lib/dom/dom.ts | 5 +++ packages/react-router/lib/dom/lib.tsx | 10 +++++ packages/react-router/lib/router/router.ts | 43 ++++++++++++++++++- 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 0fdf16c762..48c07d1287 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 shouldRevalidate 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({}), + shouldRevalidate: 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/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 82404939fb..2b5c5b2179 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. + */ + shouldRevalidate?: boolean | (() => boolean); } /** diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index cff63d829d..a4d2322edb 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1788,6 +1788,11 @@ interface SharedFormProps extends React.FormHTMLAttributes { * then this form will not do anything. */ onSubmit?: React.FormEventHandler; + + /** + * Determine if revalidation should occur post-submission. + */ + shouldRevalidate?: boolean | (() => boolean); } /** @@ -1911,6 +1916,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.shouldRevalidate} shouldRevalidate 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 +1934,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, + shouldRevalidate, ...props }, forwardedRef, @@ -1960,6 +1967,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, + shouldRevalidate, }); }; @@ -2549,6 +2557,7 @@ export function useSubmit(): SubmitFunction { if (options.navigate === false) { let key = options.fetcherKey || getUniqueFetcherId(); await router.fetch(key, currentRouteId, options.action || action, { + shouldRevalidate: options.shouldRevalidate, preventScrollReset: options.preventScrollReset, formData, body, @@ -2558,6 +2567,7 @@ export function useSubmit(): SubmitFunction { }); } else { await router.navigate(options.action || action, { + shouldRevalidate: options.shouldRevalidate, 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..a9a75a3200 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; + shouldRevalidate?: boolean | (() => boolean); }; // Only allowed for navigations @@ -1548,6 +1549,14 @@ export function createRouter(init: RouterInit): Router { return; } + let shouldRevalidate = + opts && "shouldRevalidate" in opts + ? typeof opts.shouldRevalidate === "function" + ? opts.shouldRevalidate() + : // undefined should eval to true + opts.shouldRevalidate !== false + : true; + await startNavigation(historyAction, nextLocation, { submission, // Send through the formData serialization error if we have one so we can @@ -1556,6 +1565,7 @@ export function createRouter(init: RouterInit): Router { preventScrollReset, replace: opts && opts.replace, enableViewTransition: opts && opts.viewTransition, + shouldRevalidate, flushSync, }); } @@ -1632,6 +1642,7 @@ export function createRouter(init: RouterInit): Router { replace?: boolean; enableViewTransition?: boolean; flushSync?: boolean; + shouldRevalidate?: boolean; }, ): Promise { // Abort any in-progress navigations and start a new one. Unset any ongoing @@ -1771,6 +1782,15 @@ export function createRouter(init: RouterInit): Router { matches = actionResult.matches || matches; pendingActionResult = actionResult.pendingActionResult; + + if (opts.shouldRevalidate === false) { + completeNavigation(location, { + matches, + ...getActionDataForCommit(pendingActionResult), + }); + return; + } + loadingNavigation = getLoadingNavigation(location, opts.submission); flushSync = false; // No need to do fog of war matching again on loader execution @@ -2346,6 +2366,13 @@ export function createRouter(init: RouterInit): Router { let preventScrollReset = (opts && opts.preventScrollReset) === true; if (submission && isMutationMethod(submission.formMethod)) { + let shouldRevalidate = + opts && "shouldRevalidate" in opts + ? typeof opts.shouldRevalidate === "function" + ? opts.shouldRevalidate() + : // undefined should eval to true + opts.shouldRevalidate !== false + : true; await handleFetcherAction( key, routeId, @@ -2356,6 +2383,7 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, + shouldRevalidate, ); return; } @@ -2388,6 +2416,7 @@ export function createRouter(init: RouterInit): Router { flushSync: boolean, preventScrollReset: boolean, submission: Submission, + shouldRevalidate: boolean, ) { interruptActiveLoads(); fetchLoadMatches.delete(key); @@ -2563,6 +2592,7 @@ export function createRouter(init: RouterInit): Router { basename, init.patchRoutesOnNavigation != null, [match.route.id, actionResult], + shouldRevalidate, ); // Put all revalidating fetchers into the loading state, except for the @@ -2594,6 +2624,15 @@ export function createRouter(init: RouterInit): Router { abortPendingFetchRevalidations, ); + if (!shouldRevalidate) { + if (state.fetchers.has(key)) { + let doneFetcher = getDoneFetcher(actionResult.data); + state.fetchers.set(key, doneFetcher); + } + fetchControllers.delete(key); + return; + } + let { loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( dsMatches, @@ -4820,6 +4859,7 @@ function getMatchesToLoad( basename: string | undefined, hasPatchRoutesOnNavigation: boolean, pendingActionResult?: PendingActionResult, + shouldRevalidate?: boolean, ): { dsMatches: DataStrategyMatch[]; revalidatingFetchers: RevalidatingFetcher[]; @@ -4855,7 +4895,8 @@ function getMatchesToLoad( let actionStatus = pendingActionResult ? pendingActionResult[1].statusCode : undefined; - let shouldSkipRevalidation = actionStatus && actionStatus >= 400; + let shouldSkipRevalidation = + (actionStatus && actionStatus >= 400) || shouldRevalidate === false; let baseShouldRevalidateArgs = { currentUrl, From b95dff094c8210e1984ef3e6bc38e030ed37c658 Mon Sep 17 00:00:00 2001 From: dadamssg Date: Thu, 20 Nov 2025 08:04:41 -0600 Subject: [PATCH 2/8] Refactor callsite revalidation optout to use defaultShouldRevalidate --- .../__tests__/router/fetchers-test.ts | 2 +- packages/react-router/lib/dom/dom.ts | 2 +- packages/react-router/lib/dom/lib.tsx | 14 ++++---- .../react-router/lib/dom/ssr/single-fetch.tsx | 7 ++-- packages/react-router/lib/router/router.ts | 33 ++++++------------- 5 files changed, 24 insertions(+), 34 deletions(-) diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 48c07d1287..c5683bc9a7 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -2436,7 +2436,7 @@ describe("fetchers", () => { let C = await t.fetch("/tasks", actionKey, { formMethod: "post", formData: createFormData({}), - shouldRevalidate: false, + defaultShouldRevalidate: false, }); expect(t.fetchers[actionKey]).toMatchObject({ state: "submitting" }); diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 2b5c5b2179..c01c9d9143 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -196,7 +196,7 @@ interface SharedSubmitOptions { /** * Determine if revalidation should occur post-submission. */ - shouldRevalidate?: boolean | (() => boolean); + defaultShouldRevalidate?: boolean; } /** diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index a4d2322edb..be4173c47c 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1792,7 +1792,7 @@ interface SharedFormProps extends React.FormHTMLAttributes { /** * Determine if revalidation should occur post-submission. */ - shouldRevalidate?: boolean | (() => boolean); + defaultShouldRevalidate?: boolean; } /** @@ -1916,7 +1916,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.shouldRevalidate} shouldRevalidate 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( @@ -1934,7 +1934,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, - shouldRevalidate, + defaultShouldRevalidate, ...props }, forwardedRef, @@ -1967,7 +1967,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, - shouldRevalidate, + defaultShouldRevalidate, }); }; @@ -2549,6 +2549,8 @@ export function useSubmit(): SubmitFunction { return React.useCallback( async (target, options = {}) => { + debugger; + console.log("useSubmit", target, options); let { action, method, encType, formData, body } = getFormSubmissionInfo( target, basename, @@ -2557,7 +2559,7 @@ export function useSubmit(): SubmitFunction { if (options.navigate === false) { let key = options.fetcherKey || getUniqueFetcherId(); await router.fetch(key, currentRouteId, options.action || action, { - shouldRevalidate: options.shouldRevalidate, + defaultShouldRevalidate: options.defaultShouldRevalidate, preventScrollReset: options.preventScrollReset, formData, body, @@ -2567,7 +2569,7 @@ export function useSubmit(): SubmitFunction { }); } else { await router.navigate(options.action || action, { - shouldRevalidate: options.shouldRevalidate, + defaultShouldRevalidate: options.defaultShouldRevalidate, preventScrollReset: options.preventScrollReset, formData, body, diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index bffc02708d..9b218acf53 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -385,9 +385,10 @@ async function singleFetchLoaderNavigationStrategy( getRouteInfo(m); let defaultShouldRevalidate = - !m.unstable_shouldRevalidateArgs || - m.unstable_shouldRevalidateArgs.actionStatus == null || - m.unstable_shouldRevalidateArgs.actionStatus < 400; + m.unstable_shouldRevalidateArgs?.defaultShouldRevalidate !== false && + (!m.unstable_shouldRevalidateArgs || + m.unstable_shouldRevalidateArgs.actionStatus == null || + m.unstable_shouldRevalidateArgs.actionStatus < 400); let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate); if (!shouldCall) { diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index a9a75a3200..e10780ed70 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -524,7 +524,7 @@ type BaseNavigateOrFetchOptions = { preventScrollReset?: boolean; relative?: RelativeRoutingType; flushSync?: boolean; - shouldRevalidate?: boolean | (() => boolean); + defaultShouldRevalidate?: boolean; }; // Only allowed for navigations @@ -2366,13 +2366,8 @@ export function createRouter(init: RouterInit): Router { let preventScrollReset = (opts && opts.preventScrollReset) === true; if (submission && isMutationMethod(submission.formMethod)) { - let shouldRevalidate = - opts && "shouldRevalidate" in opts - ? typeof opts.shouldRevalidate === "function" - ? opts.shouldRevalidate() - : // undefined should eval to true - opts.shouldRevalidate !== false - : true; + let callSiteDefaultShouldRevalidate = + opts?.defaultShouldRevalidate !== false; await handleFetcherAction( key, routeId, @@ -2383,7 +2378,7 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, - shouldRevalidate, + callSiteDefaultShouldRevalidate, ); return; } @@ -2401,6 +2396,7 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, + // defaultShouldRevalidate, // todo ); } @@ -2416,7 +2412,7 @@ export function createRouter(init: RouterInit): Router { flushSync: boolean, preventScrollReset: boolean, submission: Submission, - shouldRevalidate: boolean, + callSiteDefaultShouldRevalidate: boolean, ) { interruptActiveLoads(); fetchLoadMatches.delete(key); @@ -2592,7 +2588,7 @@ export function createRouter(init: RouterInit): Router { basename, init.patchRoutesOnNavigation != null, [match.route.id, actionResult], - shouldRevalidate, + callSiteDefaultShouldRevalidate, ); // Put all revalidating fetchers into the loading state, except for the @@ -2624,15 +2620,6 @@ export function createRouter(init: RouterInit): Router { abortPendingFetchRevalidations, ); - if (!shouldRevalidate) { - if (state.fetchers.has(key)) { - let doneFetcher = getDoneFetcher(actionResult.data); - state.fetchers.set(key, doneFetcher); - } - fetchControllers.delete(key); - return; - } - let { loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( dsMatches, @@ -4859,7 +4846,7 @@ function getMatchesToLoad( basename: string | undefined, hasPatchRoutesOnNavigation: boolean, pendingActionResult?: PendingActionResult, - shouldRevalidate?: boolean, + callSiteDefaultShouldRevalidate?: boolean, ): { dsMatches: DataStrategyMatch[]; revalidatingFetchers: RevalidatingFetcher[]; @@ -4896,7 +4883,8 @@ function getMatchesToLoad( ? pendingActionResult[1].statusCode : undefined; let shouldSkipRevalidation = - (actionStatus && actionStatus >= 400) || shouldRevalidate === false; + (actionStatus && actionStatus >= 400) || + callSiteDefaultShouldRevalidate === false; let baseShouldRevalidateArgs = { currentUrl, @@ -4949,7 +4937,6 @@ function getMatchesToLoad( forceShouldLoad, ); } - // This is the default implementation for when we revalidate. If the route // 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 From 9c211aee5702deb91495c791560b9f4f9c7d0f09 Mon Sep 17 00:00:00 2001 From: dadamssg Date: Thu, 20 Nov 2025 09:38:00 -0600 Subject: [PATCH 3/8] Add callsite revalidation optout to navigations --- .../__tests__/router/fetchers-test.ts | 2 +- packages/react-router/lib/context.ts | 2 ++ packages/react-router/lib/dom/lib.tsx | 26 +++++++++++++++-- packages/react-router/lib/router/router.ts | 28 ++++++------------- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index c5683bc9a7..84a1c2043c 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -2415,7 +2415,7 @@ describe("fetchers", () => { }); }); - it("skips all revalidation when shouldRevalidate is false", async () => { + it("skips all revalidation when callsite defaultShouldRevalidate is false", async () => { let key = "key"; let actionKey = "actionKey"; let t = setup({ 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/lib.tsx b/packages/react-router/lib/dom/lib.tsx index be4173c47c..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, @@ -2183,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( @@ -2194,6 +2214,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + defaultShouldRevalidate, }: { target?: React.HTMLAttributeAnchorTarget; replace?: boolean; @@ -2201,6 +2222,7 @@ export function useLinkClickHandler( preventScrollReset?: boolean; relative?: RelativeRoutingType; viewTransition?: boolean; + defaultShouldRevalidate?: boolean; } = {}, ): (event: React.MouseEvent) => void { let navigate = useNavigate(); @@ -2225,6 +2247,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + defaultShouldRevalidate, }); } }, @@ -2239,6 +2262,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + defaultShouldRevalidate, ], ); } @@ -2549,8 +2573,6 @@ export function useSubmit(): SubmitFunction { return React.useCallback( async (target, options = {}) => { - debugger; - console.log("useSubmit", target, options); let { action, method, encType, formData, body } = getFormSubmissionInfo( target, basename, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index e10780ed70..4396b0f274 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1511,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 @@ -1549,14 +1551,6 @@ export function createRouter(init: RouterInit): Router { return; } - let shouldRevalidate = - opts && "shouldRevalidate" in opts - ? typeof opts.shouldRevalidate === "function" - ? opts.shouldRevalidate() - : // undefined should eval to true - opts.shouldRevalidate !== false - : true; - await startNavigation(historyAction, nextLocation, { submission, // Send through the formData serialization error if we have one so we can @@ -1565,8 +1559,8 @@ export function createRouter(init: RouterInit): Router { preventScrollReset, replace: opts && opts.replace, enableViewTransition: opts && opts.viewTransition, - shouldRevalidate, flushSync, + callSiteDefaultShouldRevalidate, }); } @@ -1642,7 +1636,7 @@ export function createRouter(init: RouterInit): Router { replace?: boolean; enableViewTransition?: boolean; flushSync?: boolean; - shouldRevalidate?: boolean; + callSiteDefaultShouldRevalidate?: boolean; }, ): Promise { // Abort any in-progress navigations and start a new one. Unset any ongoing @@ -1782,15 +1776,6 @@ export function createRouter(init: RouterInit): Router { matches = actionResult.matches || matches; pendingActionResult = actionResult.pendingActionResult; - - if (opts.shouldRevalidate === false) { - completeNavigation(location, { - matches, - ...getActionDataForCommit(pendingActionResult), - }); - return; - } - loadingNavigation = getLoadingNavigation(location, opts.submission); flushSync = false; // No need to do fog of war matching again on loader execution @@ -1823,6 +1808,7 @@ export function createRouter(init: RouterInit): Router { opts && opts.initialHydration === true, flushSync, pendingActionResult, + opts && opts.callSiteDefaultShouldRevalidate !== false, ); if (shortCircuited) { @@ -2028,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 = @@ -2135,6 +2122,7 @@ export function createRouter(init: RouterInit): Router { basename, init.patchRoutesOnNavigation != null, pendingActionResult, + callSiteDefaultShouldRevalidate, ); pendingNavigationLoadId = ++incrementingLoadId; @@ -2396,7 +2384,6 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, - // defaultShouldRevalidate, // todo ); } @@ -4937,6 +4924,7 @@ function getMatchesToLoad( forceShouldLoad, ); } + // This is the default implementation for when we revalidate. If the route // 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 From 67ee043e081d92062df7691f2c31095914ceaa7f Mon Sep 17 00:00:00 2001 From: dadamssg Date: Thu, 20 Nov 2025 11:16:41 -0600 Subject: [PATCH 4/8] Move revalidation logic out of single-fetch strategy --- .../react-router/lib/dom/ssr/single-fetch.tsx | 7 ++- packages/react-router/lib/router/router.ts | 44 ++++++++++++++----- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 9b218acf53..bffc02708d 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -385,10 +385,9 @@ async function singleFetchLoaderNavigationStrategy( getRouteInfo(m); let defaultShouldRevalidate = - m.unstable_shouldRevalidateArgs?.defaultShouldRevalidate !== false && - (!m.unstable_shouldRevalidateArgs || - m.unstable_shouldRevalidateArgs.actionStatus == null || - m.unstable_shouldRevalidateArgs.actionStatus < 400); + !m.unstable_shouldRevalidateArgs || + m.unstable_shouldRevalidateArgs.actionStatus == null || + m.unstable_shouldRevalidateArgs.actionStatus < 400; let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate); if (!shouldCall) { diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 4396b0f274..6969fa009f 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -4869,9 +4869,7 @@ function getMatchesToLoad( let actionStatus = pendingActionResult ? pendingActionResult[1].statusCode : undefined; - let shouldSkipRevalidation = - (actionStatus && actionStatus >= 400) || - callSiteDefaultShouldRevalidate === false; + let shouldSkipRevalidation = actionStatus && actionStatus >= 400; let baseShouldRevalidateArgs = { currentUrl, @@ -4929,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, @@ -4953,6 +4964,7 @@ function getMatchesToLoad( scopedContext, shouldLoad, shouldRevalidateArgs, + callSiteDefaultShouldRevalidate, ); }); @@ -5848,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 @@ -5873,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, From c10e842711873e3d0a0564c05661050bf6018b9f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 21 Nov 2025 10:40:50 -0500 Subject: [PATCH 5/8] Minor updates + tests --- .../dom/data-browser-router-test.tsx | 299 +++++++++++++ .../__tests__/router/fetchers-test.ts | 41 -- .../router/should-revalidate-test.ts | 418 +++++++++++++++++- .../router/utils/data-router-setup.ts | 2 +- packages/react-router/lib/context.ts | 2 +- packages/react-router/lib/dom/dom.ts | 10 +- packages/react-router/lib/dom/lib.tsx | 21 +- packages/react-router/lib/router/router.ts | 33 +- 8 files changed, 760 insertions(+), 66 deletions(-) diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx index 6d05d6e883..71b34a9973 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -2527,6 +2527,305 @@ function testDomRouter( `); }); + describe("call-site revalidation opt-out", () => { + it("accepts defaultShouldRevalidate on navigations", async () => { + let loaderDefer = createDeferred(); + + let router = createTestRouter( + createRoutesFromElements( + loaderDefer.promise} + element={} + />, + ), + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": null } }, + }, + ); + let { container } = render(); + + function Home() { + let data = useLoaderData() as string; + let location = useLocation(); + let navigation = useNavigation(); + return ( +
+ + Change Search Params + +
+

{location.pathname + location.search}

+

{navigation.state}

+

{data}

+
+
+ ); + } + + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ / +

+

+ idle +

+

+

" + `); + + fireEvent.click(screen.getByText("Change Search Params")); + await waitFor(() => screen.getByText("idle")); + loaderDefer.resolve("SHOULD NOT SEE ME"); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ /?foo=bar +

+

+ idle +

+

+

" + `); + }); + + it("accepts defaultShouldRevalidate on setSearchParams navigations", async () => { + let loaderDefer = createDeferred(); + + let router = createTestRouter( + createRoutesFromElements( + loaderDefer.promise} + element={} + />, + ), + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": null } }, + }, + ); + let { container } = render(); + + function Home() { + let data = useLoaderData() as string; + let location = useLocation(); + let navigation = useNavigation(); + let [, setSearchParams] = useSearchParams(); + return ( +
+ +
+

{location.pathname + location.search}

+

{navigation.state}

+

{data}

+
+
+ ); + } + + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ / +

+

+ idle +

+

+

" + `); + + fireEvent.click(screen.getByText("Change Search Params")); + await waitFor(() => screen.getByText("idle")); + loaderDefer.resolve("SHOULD NOT SEE ME"); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ /?foo=bar +

+

+ idle +

+

+

" + `); + }); + + it("accepts defaultShouldRevalidate on navigations", async () => { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createTestRouter( + createRoutesFromElements( + actionDefer.promise} + loader={() => loaderDefer.promise} + element={} + />, + ), + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": null } }, + }, + ); + let { container } = render(); + + function Home() { + let data = useLoaderData() as string; + let actionData = useActionData() as string | undefined; + let navigation = useNavigation(); + return ( +
+ + + + +
+

{navigation.state}

+

{data}

+

{actionData}

+
+
+ ); + } + + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+

+

" + `); + + fireEvent.click(screen.getByText("Submit Form")); + await waitFor(() => screen.getByText("submitting")); + actionDefer.resolve("Action Data"); + await waitFor(() => screen.getByText("idle")); + loaderDefer.resolve("SHOULD NOT SEE ME"); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+

+ Action Data +

+
" + `); + }); + + it("accepts defaultShouldRevalidate on fetcher.submit", async () => { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createTestRouter( + createRoutesFromElements( + actionDefer.promise} + loader={() => loaderDefer.promise} + element={} + />, + ), + { + window: getWindow("/"), + hydrationData: { loaderData: { "0": null } }, + }, + ); + let { container } = render(); + + function Home() { + let data = useLoaderData() as string; + let fetcher = useFetcher(); + return ( +
+ +
+

{`${fetcher.state}:${fetcher.data}`}

+

{data}

+
+
+ ); + } + + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle:undefined +

+

+

" + `); + + fireEvent.click(screen.getByText("Submit Fetcher")); + await waitFor(() => screen.getByText("submitting:undefined")); + actionDefer.resolve("Action Data"); + await waitFor(() => screen.getByText("idle:Action Data")); + loaderDefer.resolve("SHOULD NOT SEE ME"); + expect(getHtml(container.querySelector("#output")!)) + .toMatchInlineSnapshot(` + "
+

+ idle:Action Data +

+

+

" + `); + }); + }); + describe("
", () => { function NoActionComponent() { return ( diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 84a1c2043c..0fdf16c762 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -2415,47 +2415,6 @@ 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/__tests__/router/should-revalidate-test.ts b/packages/react-router/__tests__/router/should-revalidate-test.ts index 35a085c780..eaaf16f816 100644 --- a/packages/react-router/__tests__/router/should-revalidate-test.ts +++ b/packages/react-router/__tests__/router/should-revalidate-test.ts @@ -1,9 +1,9 @@ import { createMemoryHistory } from "../../lib/router/history"; -import { createRouter } from "../../lib/router/router"; +import { IDLE_NAVIGATION, createRouter } from "../../lib/router/router"; import { ErrorResponseImpl, redirect } from "../../lib/router/utils"; import type { ShouldRevalidateFunctionArgs } from "../../lib/router/utils"; import { urlMatch } from "./utils/custom-matchers"; -import { cleanup, getFetcherData } from "./utils/data-router-setup"; +import { cleanup, getFetcherData, setup } from "./utils/data-router-setup"; import { createFormData, tick } from "./utils/utils"; interface CustomMatchers { @@ -1232,4 +1232,418 @@ describe("shouldRevalidate", () => { router.dispose(); }); + + describe("call-site revalidation opt out", () => { + it("skips revalidation on loading navigation", async () => { + let t = setup({ + routes: [ + { + id: "index", + path: "/", + loader: true, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + let A = await t.navigate("/?foo=bar", { + defaultShouldRevalidate: false, + }); + + A.loaders.index.resolve("SHOULD NOT BE CALLED"); + + expect(t.router.state).toMatchObject({ + location: expect.objectContaining({ + pathname: "/", + search: "?foo=bar", + }), + navigation: IDLE_NAVIGATION, + loaderData: { + index: "INDEX", + }, + }); + }); + + it("passes value through to route shouldRevalidate for loading navigations", async () => { + let calledWithValue: boolean | undefined = undefined; + let t = setup({ + routes: [ + { + id: "index", + path: "/", + loader: true, + shouldRevalidate: ({ defaultShouldRevalidate }) => { + calledWithValue = defaultShouldRevalidate; + return defaultShouldRevalidate; + }, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + let A = await t.navigate("/?foo=bar", { + defaultShouldRevalidate: false, + }); + + A.loaders.index.resolve("SHOULD NOT BE CALLED"); + + expect(calledWithValue).toBe(false); + expect(t.router.state).toMatchObject({ + location: expect.objectContaining({ + pathname: "/", + search: "?foo=bar", + }), + navigation: IDLE_NAVIGATION, + loaderData: { + index: "INDEX", + }, + }); + }); + + it("skips revalidation on submission navigation", async () => { + let key = "key"; + let t = setup({ + routes: [ + { + id: "index", + path: "/", + loader: true, + action: true, + }, + { + id: "fetch", + path: "/fetch", + loader: true, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + // preload a fetcher + let A = await t.fetch("/fetch", key); + await A.loaders.fetch.resolve("LOAD"); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + + // submit action with shouldRevalidate=false + let B = await t.navigate( + "/", + { + formMethod: "post", + formData: createFormData({}), + defaultShouldRevalidate: false, + }, + ["fetch"], + ); + + // resolve action — no loaders should trigger + await B.actions.index.resolve("ACTION"); + + B.loaders.index.resolve("SHOULD NOT BE CALLED"); + B.loaders.fetch.resolve("SHOULD NOT BE CALLED"); + + expect(t.router.state).toMatchObject({ + navigation: IDLE_NAVIGATION, + actionData: { + index: "ACTION", + }, + loaderData: { + index: "INDEX", + }, + }); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + }); + + it("passes value through to route shouldRevalidate on submission navigation", async () => { + let key = "key"; + let calledWithValue1: boolean | undefined = undefined; + let calledWithValue2: boolean | undefined = undefined; + let t = setup({ + routes: [ + { + id: "index", + path: "/", + loader: true, + action: true, + shouldRevalidate: ({ defaultShouldRevalidate }) => { + calledWithValue1 = defaultShouldRevalidate; + return defaultShouldRevalidate; + }, + }, + { + id: "fetch", + path: "/fetch", + loader: true, + shouldRevalidate: ({ defaultShouldRevalidate }) => { + calledWithValue2 = defaultShouldRevalidate; + return defaultShouldRevalidate; + }, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + // preload a fetcher + let A = await t.fetch("/fetch", key); + await A.loaders.fetch.resolve("LOAD"); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + + // submit action with shouldRevalidate=false + let B = await t.navigate( + "/", + { + formMethod: "post", + formData: createFormData({}), + defaultShouldRevalidate: false, + }, + ["fetch"], + ); + + // resolve action — no loaders should trigger + await B.actions.index.resolve("ACTION"); + + B.loaders.index.resolve("SHOULD NOT BE CALLED"); + B.loaders.fetch.resolve("SHOULD NOT BE CALLED"); + + expect(calledWithValue1).toBe(false); + expect(calledWithValue2).toBe(false); + + expect(t.router.state).toMatchObject({ + navigation: IDLE_NAVIGATION, + actionData: { + index: "ACTION", + }, + loaderData: { + index: "INDEX", + }, + }); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + }); + + it("skips revalidation on fetcher.submit", async () => { + let key = "key"; + let actionKey = "actionKey"; + let t = setup({ + routes: [ + { + id: "index", + path: "/", + loader: true, + }, + { + id: "fetch", + path: "/fetch", + action: true, + loader: true, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + // preload a fetcher + let A = await t.fetch("/fetch", key); + await A.loaders.fetch.resolve("LOAD"); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + + // submit action with shouldRevalidate=false + let B = await t.fetch("/fetch", actionKey, "index", { + formMethod: "post", + formData: createFormData({}), + defaultShouldRevalidate: false, + }); + t.shimHelper(B.loaders, "fetch", "loader", "fetch"); + + // resolve action — no loaders should trigger + await B.actions.fetch.resolve("ACTION"); + + B.loaders.index.resolve("SHOULD NOT BE CALLED"); + B.loaders.fetch.resolve("SHOULD NOT BE CALLED"); + + expect(t.router.state.loaderData).toEqual({ + index: "INDEX", + }); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + expect(t.fetchers[actionKey]).toMatchObject({ + state: "idle", + data: "ACTION", + }); + }); + + it("passes through value on fetcher.submit", async () => { + let key = "key"; + let actionKey = "actionKey"; + let calledWithValue1: boolean | undefined = undefined; + let calledWithValue2: boolean | undefined = undefined; + let t = setup({ + routes: [ + { + id: "index", + path: "/", + loader: true, + shouldRevalidate: ({ defaultShouldRevalidate }) => { + calledWithValue1 = defaultShouldRevalidate; + return defaultShouldRevalidate; + }, + }, + { + id: "fetch", + path: "/fetch", + action: true, + loader: true, + shouldRevalidate: ({ defaultShouldRevalidate }) => { + calledWithValue2 = defaultShouldRevalidate; + return defaultShouldRevalidate; + }, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + // preload a fetcher + let A = await t.fetch("/fetch", key); + await A.loaders.fetch.resolve("LOAD"); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + + // submit action with shouldRevalidate=false + let B = await t.fetch("/fetch", actionKey, "index", { + formMethod: "post", + formData: createFormData({}), + defaultShouldRevalidate: false, + }); + t.shimHelper(B.loaders, "fetch", "loader", "fetch"); + + // resolve action — no loaders should trigger + await B.actions.fetch.resolve("ACTION"); + + B.loaders.index.resolve("SHOULD NOT BE CALLED"); + B.loaders.fetch.resolve("SHOULD NOT BE CALLED"); + + expect(calledWithValue1).toBe(false); + expect(calledWithValue2).toBe(false); + expect(t.router.state.loaderData).toEqual({ + index: "INDEX", + }); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + expect(t.fetchers[actionKey]).toMatchObject({ + state: "idle", + data: "ACTION", + }); + }); + + it("allows route to override call-site value", async () => { + let key = "key"; + let actionKey = "actionKey"; + let calledWithValue1: boolean | undefined = undefined; + let calledWithValue2: boolean | undefined = undefined; + let t = setup({ + routes: [ + { + id: "index", + path: "/", + loader: true, + shouldRevalidate: ({ defaultShouldRevalidate }) => { + calledWithValue1 = defaultShouldRevalidate; + return true; + }, + }, + { + id: "fetch", + path: "/fetch", + action: true, + loader: true, + shouldRevalidate: ({ defaultShouldRevalidate }) => { + calledWithValue2 = defaultShouldRevalidate; + return defaultShouldRevalidate; + }, + }, + ], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + // preload a fetcher + let A = await t.fetch("/fetch", key); + await A.loaders.fetch.resolve("LOAD"); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + + // submit action with shouldRevalidate=false + let B = await t.fetch("/fetch", actionKey, "index", { + formMethod: "post", + formData: createFormData({}), + defaultShouldRevalidate: false, + }); + t.shimHelper(B.loaders, "fetch", "loader", "fetch"); + + await B.actions.fetch.resolve("ACTION"); + await B.loaders.index.resolve("INDEX*"); + B.loaders.fetch.resolve("SHOULD NOT BE CALLED"); + + expect(calledWithValue1).toBe(false); + expect(calledWithValue2).toBe(false); + expect(t.router.state.loaderData).toEqual({ + index: "INDEX*", + }); + expect(t.fetchers[key]).toMatchObject({ + state: "idle", + data: "LOAD", + }); + expect(t.fetchers[actionKey]).toMatchObject({ + state: "idle", + data: "ACTION", + }); + }); + }); }); diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts index dffbd34c95..df2c2c2145 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -717,7 +717,7 @@ export function setup({ history, router: currentRouter, get fetchers() { - let fetchers = {}; + let fetchers: Record = {}; currentRouter?.state.fetchers.forEach((f, key) => { fetchers[key] = { ...f, diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index c9d1a98e18..7b81214493 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -154,7 +154,7 @@ 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 **/ + /** Specifies the default revalidation behavior after this submission */ defaultShouldRevalidate?: boolean; } diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index c01c9d9143..1d5b2f54bf 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -194,7 +194,15 @@ interface SharedSubmitOptions { flushSync?: boolean; /** - * Determine if revalidation should occur post-submission. + * Specify the default revalidation behavior after this submission + * + * If no `shouldRevalidate` functions are present on the active routes, then this + * value will be used directly. Otherwise it will be passed into `shouldRevalidate` + * so the route can make the final determination on revalidation. 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 routers + * standard revalidation behavior. */ defaultShouldRevalidate?: boolean; } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 22c1eed9cb..3df890fd0b 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1289,17 +1289,18 @@ export interface LinkProps viewTransition?: boolean; /** - * Controls whether loaders should revalidate when this link is clicked. + * Specify the default revalidation behavior for the navigation. * * ```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. + * If no `shouldRevalidate` functions are present on the active routes, then this + * value will be used directly. Otherwise it will be passed into `shouldRevalidate` + * so the route can make the final determination on revalidation. 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 + * By default (when not specified), loaders will revalidate according to the routers * standard revalidation behavior. */ defaultShouldRevalidate?: boolean; @@ -1809,7 +1810,15 @@ interface SharedFormProps extends React.FormHTMLAttributes { onSubmit?: React.FormEventHandler; /** - * Determine if revalidation should occur post-submission. + * Specify the default revalidation behavior after this submission + * + * If no `shouldRevalidate` functions are present on the active routes, then this + * value will be used directly. Otherwise it will be passed into `shouldRevalidate` + * so the route can make the final determination on revalidation. 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 routers + * standard revalidation behavior. */ defaultShouldRevalidate?: boolean; } diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 6969fa009f..c8c2afff53 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1511,9 +1511,6 @@ 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 ? opts.preventScrollReset === true @@ -1560,7 +1557,7 @@ export function createRouter(init: RouterInit): Router { replace: opts && opts.replace, enableViewTransition: opts && opts.viewTransition, flushSync, - callSiteDefaultShouldRevalidate, + callSiteDefaultShouldRevalidate: opts && opts.defaultShouldRevalidate, }); } @@ -1808,7 +1805,7 @@ export function createRouter(init: RouterInit): Router { opts && opts.initialHydration === true, flushSync, pendingActionResult, - opts && opts.callSiteDefaultShouldRevalidate !== false, + opts && opts.callSiteDefaultShouldRevalidate, ); if (shortCircuited) { @@ -2354,8 +2351,6 @@ 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, @@ -2366,7 +2361,7 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, - callSiteDefaultShouldRevalidate, + opts && opts.defaultShouldRevalidate, ); return; } @@ -2399,7 +2394,7 @@ export function createRouter(init: RouterInit): Router { flushSync: boolean, preventScrollReset: boolean, submission: Submission, - callSiteDefaultShouldRevalidate: boolean, + callSiteDefaultShouldRevalidate: boolean | undefined, ) { interruptActiveLoads(); fetchLoadMatches.delete(key); @@ -4928,8 +4923,8 @@ function getMatchesToLoad( // provide this value so they can leverage it if needed after they check // their own specific use cases let defaultShouldRevalidate = false; - if (callSiteDefaultShouldRevalidate != null) { - // Use callsite value verbatim if provided + if (typeof callSiteDefaultShouldRevalidate === "boolean") { + // Use call-site value verbatim if provided defaultShouldRevalidate = callSiteDefaultShouldRevalidate; } else if (shouldSkipRevalidation) { // Skip due to 4xx/5xx action result @@ -4949,6 +4944,7 @@ function getMatchesToLoad( } else if (isNewRouteInstance(state.matches[index], match)) { defaultShouldRevalidate = true; } + let shouldRevalidateArgs = { ...baseShouldRevalidateArgs, defaultShouldRevalidate, @@ -5062,11 +5058,19 @@ function getMatchesToLoad( } else { // Otherwise fall back on any user-defined shouldRevalidate, defaulting // to explicit revalidations only + let defaultShouldRevalidate: boolean; + if (typeof callSiteDefaultShouldRevalidate === "boolean") { + // Use call-site value verbatim if provided + defaultShouldRevalidate = callSiteDefaultShouldRevalidate; + } else if (shouldSkipRevalidation) { + defaultShouldRevalidate = false; + } else { + defaultShouldRevalidate = isRevalidationRequired; + } + let shouldRevalidateArgs: ShouldRevalidateFunctionArgs = { ...baseShouldRevalidateArgs, - defaultShouldRevalidate: shouldSkipRevalidation - ? false - : isRevalidationRequired, + defaultShouldRevalidate, }; if (shouldRevalidateLoader(fetcherMatch, shouldRevalidateArgs)) { fetcherDsMatches = getTargetedDataStrategyMatches( @@ -5899,6 +5903,7 @@ function getDataStrategyMatch( defaultShouldRevalidate, }); } + return shouldRevalidateLoader(match, unstable_shouldRevalidateArgs); }, resolve(handlerOverride) { From 6c5f899edbea1614b280d7faef8d3039a430497d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 21 Nov 2025 10:51:44 -0500 Subject: [PATCH 6/8] Rename with unstable --- .../dom/data-browser-router-test.tsx | 16 +++++----- .../router/should-revalidate-test.ts | 14 ++++----- packages/react-router/lib/context.ts | 2 +- packages/react-router/lib/dom/dom.ts | 2 +- packages/react-router/lib/dom/lib.tsx | 30 ++++++++++--------- packages/react-router/lib/router/router.ts | 7 +++-- 6 files changed, 37 insertions(+), 34 deletions(-) diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx index 71b34a9973..afad17cda3 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -2528,7 +2528,7 @@ function testDomRouter( }); describe("call-site revalidation opt-out", () => { - it("accepts defaultShouldRevalidate on navigations", async () => { + it("accepts unstable_defaultShouldRevalidate on navigations", async () => { let loaderDefer = createDeferred(); let router = createTestRouter( @@ -2552,7 +2552,7 @@ function testDomRouter( let navigation = useNavigation(); return (
- + Change Search Params
@@ -2598,7 +2598,7 @@ function testDomRouter( `); }); - it("accepts defaultShouldRevalidate on setSearchParams navigations", async () => { + it("accepts unstable_defaultShouldRevalidate on setSearchParams navigations", async () => { let loaderDefer = createDeferred(); let router = createTestRouter( @@ -2626,7 +2626,7 @@ function testDomRouter( @@ -2748,7 +2748,7 @@ function testDomRouter( `); }); - it("accepts defaultShouldRevalidate on fetcher.submit", async () => { + it("accepts unstable_defaultShouldRevalidate on fetcher.submit", async () => { let loaderDefer = createDeferred(); let actionDefer = createDeferred(); @@ -2780,7 +2780,7 @@ function testDomRouter( { method: "post", action: "/", - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }, ) } diff --git a/packages/react-router/__tests__/router/should-revalidate-test.ts b/packages/react-router/__tests__/router/should-revalidate-test.ts index eaaf16f816..ff18b2ef83 100644 --- a/packages/react-router/__tests__/router/should-revalidate-test.ts +++ b/packages/react-router/__tests__/router/should-revalidate-test.ts @@ -1251,7 +1251,7 @@ describe("shouldRevalidate", () => { }); let A = await t.navigate("/?foo=bar", { - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }); A.loaders.index.resolve("SHOULD NOT BE CALLED"); @@ -1290,7 +1290,7 @@ describe("shouldRevalidate", () => { }); let A = await t.navigate("/?foo=bar", { - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }); A.loaders.index.resolve("SHOULD NOT BE CALLED"); @@ -1345,7 +1345,7 @@ describe("shouldRevalidate", () => { { formMethod: "post", formData: createFormData({}), - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }, ["fetch"], ); @@ -1418,7 +1418,7 @@ describe("shouldRevalidate", () => { { formMethod: "post", formData: createFormData({}), - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }, ["fetch"], ); @@ -1483,7 +1483,7 @@ describe("shouldRevalidate", () => { let B = await t.fetch("/fetch", actionKey, "index", { formMethod: "post", formData: createFormData({}), - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }); t.shimHelper(B.loaders, "fetch", "loader", "fetch"); @@ -1552,7 +1552,7 @@ describe("shouldRevalidate", () => { let B = await t.fetch("/fetch", actionKey, "index", { formMethod: "post", formData: createFormData({}), - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }); t.shimHelper(B.loaders, "fetch", "loader", "fetch"); @@ -1623,7 +1623,7 @@ describe("shouldRevalidate", () => { let B = await t.fetch("/fetch", actionKey, "index", { formMethod: "post", formData: createFormData({}), - defaultShouldRevalidate: false, + unstable_defaultShouldRevalidate: false, }); t.shimHelper(B.loaders, "fetch", "loader", "fetch"); diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index 7b81214493..c73f33b126 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -155,7 +155,7 @@ export interface NavigateOptions { /** 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; /** Specifies the default revalidation behavior after this submission */ - defaultShouldRevalidate?: boolean; + unstable_defaultShouldRevalidate?: boolean; } /** diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 1d5b2f54bf..71eac7219a 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -204,7 +204,7 @@ interface SharedSubmitOptions { * By default (when not specified), loaders will revalidate according to the routers * standard revalidation behavior. */ - defaultShouldRevalidate?: boolean; + unstable_defaultShouldRevalidate?: boolean; } /** diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 3df890fd0b..bd97ae2eb7 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1303,7 +1303,7 @@ export interface LinkProps * By default (when not specified), loaders will revalidate according to the routers * standard revalidation behavior. */ - defaultShouldRevalidate?: boolean; + unstable_defaultShouldRevalidate?: boolean; } const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; @@ -1352,7 +1352,7 @@ export const Link = React.forwardRef( to, preventScrollReset, viewTransition, - defaultShouldRevalidate, + unstable_defaultShouldRevalidate, ...rest }, forwardedRef, @@ -1408,7 +1408,7 @@ export const Link = React.forwardRef( preventScrollReset, relative, viewTransition, - defaultShouldRevalidate, + unstable_defaultShouldRevalidate, }); function handleClick( event: React.MouseEvent, @@ -1820,7 +1820,7 @@ interface SharedFormProps extends React.FormHTMLAttributes { * By default (when not specified), loaders will revalidate according to the routers * standard revalidation behavior. */ - defaultShouldRevalidate?: boolean; + unstable_defaultShouldRevalidate?: boolean; } /** @@ -1944,7 +1944,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 + * @param {FormProps.unstable_defaultShouldRevalidate} unstable_defaultShouldRevalidate n/a * @returns A progressively enhanced [`
`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) component */ export const Form = React.forwardRef( @@ -1962,7 +1962,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, - defaultShouldRevalidate, + unstable_defaultShouldRevalidate, ...props }, forwardedRef, @@ -1995,7 +1995,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, - defaultShouldRevalidate, + unstable_defaultShouldRevalidate, }); }; @@ -2211,7 +2211,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` + * @param options.unstable_defaultShouldRevalidate Defaults to `true` * @returns A click handler function that can be used in a custom {@link Link} component. */ export function useLinkClickHandler( @@ -2223,7 +2223,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, - defaultShouldRevalidate, + unstable_defaultShouldRevalidate, }: { target?: React.HTMLAttributeAnchorTarget; replace?: boolean; @@ -2231,7 +2231,7 @@ export function useLinkClickHandler( preventScrollReset?: boolean; relative?: RelativeRoutingType; viewTransition?: boolean; - defaultShouldRevalidate?: boolean; + unstable_defaultShouldRevalidate?: boolean; } = {}, ): (event: React.MouseEvent) => void { let navigate = useNavigate(); @@ -2256,7 +2256,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, - defaultShouldRevalidate, + unstable_defaultShouldRevalidate, }); } }, @@ -2271,7 +2271,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, - defaultShouldRevalidate, + unstable_defaultShouldRevalidate, ], ); } @@ -2590,7 +2590,8 @@ export function useSubmit(): SubmitFunction { if (options.navigate === false) { let key = options.fetcherKey || getUniqueFetcherId(); await router.fetch(key, currentRouteId, options.action || action, { - defaultShouldRevalidate: options.defaultShouldRevalidate, + unstable_defaultShouldRevalidate: + options.unstable_defaultShouldRevalidate, preventScrollReset: options.preventScrollReset, formData, body, @@ -2600,7 +2601,8 @@ export function useSubmit(): SubmitFunction { }); } else { await router.navigate(options.action || action, { - defaultShouldRevalidate: options.defaultShouldRevalidate, + unstable_defaultShouldRevalidate: + options.unstable_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 c8c2afff53..9061236676 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -524,7 +524,7 @@ type BaseNavigateOrFetchOptions = { preventScrollReset?: boolean; relative?: RelativeRoutingType; flushSync?: boolean; - defaultShouldRevalidate?: boolean; + unstable_defaultShouldRevalidate?: boolean; }; // Only allowed for navigations @@ -1557,7 +1557,8 @@ export function createRouter(init: RouterInit): Router { replace: opts && opts.replace, enableViewTransition: opts && opts.viewTransition, flushSync, - callSiteDefaultShouldRevalidate: opts && opts.defaultShouldRevalidate, + callSiteDefaultShouldRevalidate: + opts && opts.unstable_defaultShouldRevalidate, }); } @@ -2361,7 +2362,7 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, - opts && opts.defaultShouldRevalidate, + opts && opts.unstable_defaultShouldRevalidate, ); return; } From 2d8f5c807472385ff1be307d42881584c84145ab Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 21 Nov 2025 10:55:16 -0500 Subject: [PATCH 7/8] A few missed renames --- packages/react-router/lib/dom/lib.tsx | 4 ++-- packages/react-router/lib/router/router.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index bd97ae2eb7..1bcb594b99 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1292,7 +1292,7 @@ export interface LinkProps * Specify the default revalidation behavior for the navigation. * * ```tsx - * + * * ``` * * If no `shouldRevalidate` functions are present on the active routes, then this @@ -1336,7 +1336,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 + * @param {LinkProps.unstable_defaultShouldRevalidate} props.unstable_defaultShouldRevalidate n/a */ export const Link = React.forwardRef( function LinkWithRef( diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 9061236676..7e8da5cee7 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1511,6 +1511,7 @@ export function createRouter(init: RouterInit): Router { // action/loader this will be ignored and the redirect will be a PUSH historyAction = NavigationType.Replace; } + let preventScrollReset = opts && "preventScrollReset" in opts ? opts.preventScrollReset === true From 0715840b2c60cfc8d555c3ed87db154e437279cf Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 21 Nov 2025 11:08:18 -0500 Subject: [PATCH 8/8] Add changeset --- .changeset/rich-points-talk.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/rich-points-talk.md diff --git a/.changeset/rich-points-talk.md b/.changeset/rich-points-talk.md new file mode 100644 index 0000000000..3693c2bbe9 --- /dev/null +++ b/.changeset/rich-points-talk.md @@ -0,0 +1,18 @@ +--- +"react-router": patch +--- + +[UNSTABLE] Add a new `unstable_defaultShouldRevalidate` flag to various APIs to allow opt-ing out of standard revalidation behaviors. + +If active routes include a `shouldRevalidate` function, then your value will be passed as `defaultShouldRevalidate` in those function so that the route always has the final revalidation determination. + +- `` +- `submit(data, { method: "post", unstable_defaultShouldRevalidate: false })` +- `` +- `fetcher.submit(data, { method: "post", unstable_defaultShouldRevalidate: false })` + +This is also available on non-submission APIs that may trigger revalidations due to changing search params: + +- `` +- `navigate("/?foo=bar", { unstable_defaultShouldRevalidate: false })` +- `setSearchParams(params, { unstable_defaultShouldRevalidate: false })`