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 })` diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index b02c25643c..40c0071055 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -837,6 +837,232 @@ test.describe("single-fetch", () => { expect(urls).toEqual([]); }); + test("supports call-site revalidation opt-out on submissions (w/o shouldRevalidate)", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/action.tsx": js` + import { Form } from 'react-router'; + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export function action() { + return { count: ++count }; + } + + export default function Comp({ loaderData, actionData }) { + return ( + + +

{loaderData.count}

+ {actionData ?

{actionData.count}

: null} + + ); + } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + console.error = () => {}; + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="name"][value="value"]'); + await page.waitForSelector("#action-data"); + expect(await app.getHtml("#action-data")).toContain("2"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + }); + + test("supports call-site revalidation opt-in on 4xx/5xx action responses (w/o shouldRevalidate)", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/action.tsx": js` + import { Form, Link, useNavigation, data } from 'react-router'; + + export async function action({ request }) { + throw data("Thrown 500", { status: 500 }); + } + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export default function Comp({ loaderData }) { + let navigation = useNavigation(); + return ( +
+ +

{loaderData.count}

+ {navigation.state === "idle" ?

idle

: null} +
+ ); + } + + export function ErrorBoundary() { + return

Error

+ } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + console.error = () => {}; + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="throw"][value="5xx"]'); + await page.waitForSelector("#error"); + expect(urls).toEqual([expect.stringMatching(/\/action\.data$/)]); + }); + + test("supports call-site revalidation opt-out on submissions (w/ shouldRevalidate)", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/action.tsx": js` + import { Form } from 'react-router'; + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export function action() { + return { count: ++count }; + } + + export function shouldRevalidate({ defaultShouldRevalidate }) { + return defaultShouldRevalidate; + } + + export default function Comp({ loaderData, actionData }) { + return ( +
+ +

{loaderData.count}

+ {actionData ?

{actionData.count}

: null} +
+ ); + } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + console.error = () => {}; + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="name"][value="value"]'); + await page.waitForSelector("#action-data"); + expect(await app.getHtml("#action-data")).toContain("2"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + }); + + test("supports call-site revalidation opt-in on 4xx/5xx action responses (w shouldRevalidate)", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/action.tsx": js` + import { Form, Link, useNavigation, data } from 'react-router'; + + export async function action({ request }) { + throw data("Thrown 500", { status: 500 }); + } + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export function shouldRevalidate({ defaultShouldRevalidate }) { + return defaultShouldRevalidate; + } + + export default function Comp({ loaderData }) { + let navigation = useNavigation(); + return ( +
+ +

{loaderData.count}

+ {navigation.state === "idle" ?

idle

: null} +
+ ); + } + + export function ErrorBoundary() { + return

Error

+ } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + console.error = () => {}; + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="throw"][value="5xx"]'); + await page.waitForSelector("#error"); + expect(urls).toEqual([expect.stringMatching(/\/action\.data$/)]); + }); + test("returns headers correctly for singular loader and action calls", async () => { let fixture = await createFixture({ files: { 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 a4096258d3..413fb92dc6 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -2513,6 +2513,293 @@ function testDomRouter( }); }); + describe("call-site revalidation opt-out", () => { + it("accepts unstable_defaultShouldRevalidate on navigations", async () => { + let loaderDefer = createDeferred(); + + let router = createTestRouter( + [{ path: "/", loader: () => loaderDefer.promise, Component: Home }], + { + 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 unstable_defaultShouldRevalidate on setSearchParams navigations", async () => { + let loaderDefer = createDeferred(); + + let router = createTestRouter( + [{ path: "/", loader: () => loaderDefer.promise, Component: Home }], + { + 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 unstable_defaultShouldRevalidate on
navigations", async () => { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createTestRouter( + [ + { + path: "/", + loader: () => loaderDefer.promise, + action: () => actionDefer.promise, + Component: Home, + }, + ], + { + 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 unstable_defaultShouldRevalidate on fetcher.submit", async () => { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let router = createTestRouter( + [ + { + path: "/", + loader: () => loaderDefer.promise, + action: () => actionDefer.promise, + Component: Home, + }, + ], + { + 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/should-revalidate-test.ts b/packages/react-router/__tests__/router/should-revalidate-test.ts index ad0ce40b31..27b79e055a 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", { + unstable_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", { + unstable_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({}), + unstable_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({}), + unstable_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({}), + unstable_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({}), + unstable_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({}), + unstable_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/lib/context.ts b/packages/react-router/lib/context.ts index ed31101f3a..6e6db7199d 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -155,6 +155,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; + /** Specifies the default revalidation behavior after this submission */ + unstable_defaultShouldRevalidate?: boolean; } /** diff --git a/packages/react-router/lib/dom/dom.ts b/packages/react-router/lib/dom/dom.ts index 83886b632f..476af2552d 100644 --- a/packages/react-router/lib/dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -192,6 +192,19 @@ interface SharedSubmitOptions { * Enable flushSync for this submission's state updates */ flushSync?: boolean; + + /** + * 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. + */ + unstable_defaultShouldRevalidate?: boolean; } /** diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 54b87d058b..296d661738 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1357,6 +1357,23 @@ export interface LinkProps * To apply specific styles for the transition, see {@link useViewTransitionState} */ viewTransition?: boolean; + + /** + * Specify the default revalidation behavior for the navigation. + * + * ```tsx + * + * ``` + * + * 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. + */ + unstable_defaultShouldRevalidate?: boolean; } const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; @@ -1389,6 +1406,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.unstable_defaultShouldRevalidate} props.unstable_defaultShouldRevalidate n/a */ export const Link = React.forwardRef( function LinkWithRef( @@ -1404,6 +1422,7 @@ export const Link = React.forwardRef( to, preventScrollReset, viewTransition, + unstable_defaultShouldRevalidate, ...rest }, forwardedRef, @@ -1460,6 +1479,7 @@ export const Link = React.forwardRef( preventScrollReset, relative, viewTransition, + unstable_defaultShouldRevalidate, unstable_useTransitions, }); function handleClick( @@ -1860,6 +1880,19 @@ interface SharedFormProps extends React.FormHTMLAttributes { * then this form will not do anything. */ onSubmit?: React.FormEventHandler; + + /** + * 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. + */ + unstable_defaultShouldRevalidate?: boolean; } /** @@ -1983,6 +2016,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.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( @@ -2000,6 +2034,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, + unstable_defaultShouldRevalidate, ...props }, forwardedRef, @@ -2034,6 +2069,7 @@ export const Form = React.forwardRef( relative, preventScrollReset, viewTransition, + unstable_defaultShouldRevalidate, }); if (unstable_useTransitions && navigate !== false) { @@ -2256,6 +2292,8 @@ 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.unstable_defaultShouldRevalidate Specify the default revalidation + * behavior for the navigation. Defaults to `true`. * @param options.unstable_useTransitions Wraps the navigation in * [`React.startTransition`](https://react.dev/reference/react/startTransition) * for concurrent rendering. Defaults to `false`. @@ -2270,6 +2308,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + unstable_defaultShouldRevalidate, unstable_useTransitions, }: { target?: React.HTMLAttributeAnchorTarget; @@ -2278,6 +2317,7 @@ export function useLinkClickHandler( preventScrollReset?: boolean; relative?: RelativeRoutingType; viewTransition?: boolean; + unstable_defaultShouldRevalidate?: boolean; unstable_useTransitions?: boolean; } = {}, ): (event: React.MouseEvent) => void { @@ -2304,6 +2344,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + unstable_defaultShouldRevalidate, }); if (unstable_useTransitions) { @@ -2325,6 +2366,7 @@ export function useLinkClickHandler( preventScrollReset, relative, viewTransition, + unstable_defaultShouldRevalidate, unstable_useTransitions, ], ); @@ -2647,6 +2689,8 @@ export function useSubmit(): SubmitFunction { if (options.navigate === false) { let key = options.fetcherKey || getUniqueFetcherId(); await routerFetch(key, currentRouteId, options.action || action, { + unstable_defaultShouldRevalidate: + options.unstable_defaultShouldRevalidate, preventScrollReset: options.preventScrollReset, formData, body, @@ -2656,6 +2700,8 @@ export function useSubmit(): SubmitFunction { }); } else { await routerNavigate(options.action || action, { + 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 da9d2557fe..665899484d 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -526,6 +526,7 @@ type BaseNavigateOrFetchOptions = { preventScrollReset?: boolean; relative?: RelativeRoutingType; flushSync?: boolean; + unstable_defaultShouldRevalidate?: boolean; }; // Only allowed for navigations @@ -1592,6 +1593,8 @@ export function createRouter(init: RouterInit): Router { replace: opts && opts.replace, enableViewTransition: opts && opts.viewTransition, flushSync, + callSiteDefaultShouldRevalidate: + opts && opts.unstable_defaultShouldRevalidate, }); } @@ -1667,6 +1670,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 @@ -1838,6 +1842,7 @@ export function createRouter(init: RouterInit): Router { opts && opts.initialHydration === true, flushSync, pendingActionResult, + opts && opts.callSiteDefaultShouldRevalidate, ); if (shortCircuited) { @@ -2043,6 +2048,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 = @@ -2150,6 +2156,7 @@ export function createRouter(init: RouterInit): Router { basename, init.patchRoutesOnNavigation != null, pendingActionResult, + callSiteDefaultShouldRevalidate, ); pendingNavigationLoadId = ++incrementingLoadId; @@ -2391,6 +2398,7 @@ export function createRouter(init: RouterInit): Router { flushSync, preventScrollReset, submission, + opts && opts.unstable_defaultShouldRevalidate, ); return; } @@ -2423,6 +2431,7 @@ export function createRouter(init: RouterInit): Router { flushSync: boolean, preventScrollReset: boolean, submission: Submission, + callSiteDefaultShouldRevalidate: boolean | undefined, ) { interruptActiveLoads(); fetchLoadMatches.delete(key); @@ -2598,6 +2607,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 @@ -4863,6 +4873,7 @@ function getMatchesToLoad( basename: string | undefined, hasPatchRoutesOnNavigation: boolean, pendingActionResult?: PendingActionResult, + callSiteDefaultShouldRevalidate?: boolean, ): { dsMatches: DataStrategyMatch[]; revalidatingFetchers: RevalidatingFetcher[]; @@ -4956,15 +4967,29 @@ 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 (typeof callSiteDefaultShouldRevalidate === "boolean") { + // Use call-site 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, @@ -4980,6 +5005,7 @@ function getMatchesToLoad( scopedContext, shouldLoad, shouldRevalidateArgs, + callSiteDefaultShouldRevalidate, ); }); @@ -5077,11 +5103,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( @@ -5875,6 +5909,7 @@ function getDataStrategyMatch( scopedContext: unknown, shouldLoad: boolean, shouldRevalidateArgs: DataStrategyMatch["shouldRevalidateArgs"] = null, + callSiteDefaultShouldRevalidate?: boolean, ): DataStrategyMatch { // The hope here is to avoid a breaking change to the resolve behavior. // Opt-ing into the `shouldCallHandler` API changes some nuanced behavior @@ -5900,12 +5935,20 @@ function getDataStrategyMatch( return shouldLoad; } + if (typeof callSiteDefaultShouldRevalidate === "boolean") { + return shouldRevalidateLoader(match, { + ...shouldRevalidateArgs, + defaultShouldRevalidate: callSiteDefaultShouldRevalidate, + }); + } + if (typeof defaultShouldRevalidate === "boolean") { return shouldRevalidateLoader(match, { ...shouldRevalidateArgs, defaultShouldRevalidate, }); } + return shouldRevalidateLoader(match, shouldRevalidateArgs); }, resolve(handlerOverride) {