diff --git a/.changeset/olive-planets-think.md b/.changeset/olive-planets-think.md new file mode 100644 index 0000000000..484ee12b37 --- /dev/null +++ b/.changeset/olive-planets-think.md @@ -0,0 +1,23 @@ +--- +"react-router": patch +--- + +Add new `unstable_useTransitions` flag to routers to give users control over the usage of [`React.startTransition`](https://react.dev/reference/react/startTransition) and [`React.useOptimistic`](https://react.dev/reference/react/useOptimistic). + +- Framework Mode + Data Mode: + - ``/`` + - When left unset (current default behavior), all state updates are wrapped in `React.startTransition` + - ⚠️ This can lead to buggy behaviors if you are wrapping your own navigations/fetchers in `React.startTransition` + - You should set the flag to `true` if you run into this scenario + - When set to `true`, all router navigations and state changes will be wrapped + in `React.startTransition` and router state changes will _also_ be sent through + `React.useOptimistic` to surface mid-navigation router state changes to the UI (i.e., `useNavigation()`) + - When set to `false`, the router will not leverage `React.startTransition` or + `React.useOptimistic` on any navigations or state changes +- Declarative Mode + - `` + - When left unset, all router state updates are wrapped in `React.startTransition` + - When set to `true`, all router navigations and state updates will be wrapped + in `React.startTransition` + - When set to `false`, the router will not leverage `React.startTransition` on + any navigations or state changes diff --git a/packages/react-router/__tests__/react-transitions-test.tsx b/packages/react-router/__tests__/react-transitions-test.tsx new file mode 100644 index 0000000000..a70d470092 --- /dev/null +++ b/packages/react-router/__tests__/react-transitions-test.tsx @@ -0,0 +1,636 @@ +import "@testing-library/jest-dom"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import * as React from "react"; +import { + Outlet, + RouterProvider, + createMemoryRouter, + useLoaderData, + useNavigation, +} from "react-router"; + +import { useNavigate, useSubmit } from "../index"; +import { createDeferred, tick } from "./router/utils/utils"; + +describe("react transitions", () => { + describe("", () => { + it("normal navigations surface all updates", async () => { + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let navigate = useNavigate(); + return ( + + ); + }, + }, + { + path: "page", + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render(); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#link")!); + // Without useOptimistic under the hood, our mid-navigation state updates don't surface + await waitFor(() => screen.getByText("Navigation:loading")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:1")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:loading")).toBeDefined(); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + + it("normal submissions surface all updates", async () => { + let actionDfd = createDeferred(); + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let submit = useSubmit(); + return ( + + ); + }, + }, + { + path: "page", + action: () => actionDfd.promise, + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render(); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#submit")!); + await waitFor(() => screen.getByText("Navigation:submitting")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:1")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:submitting")).toBeDefined(); + + actionDfd.resolve("Action"); + await waitFor(() => screen.getByText("Navigation:loading")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:3")); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:3")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + + it("navigations can be manually wrapped in startTransition (buggy optimistic behavior)", async () => { + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let navigate = useNavigate(); + return ( + + ); + }, + }, + { + path: "page", + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render(); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + // Without useOptimistic under the hood, our mid-navigation state updates + // don't surface + await fireEvent.click(container.querySelector("#link")!); + await waitFor(() => screen.getByText("Navigation:idle")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + + it("submissions can be manually wrapped in startTransition (buggy optimistic behavior)", async () => { + let actionDfd = createDeferred(); + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let submit = useSubmit(); + return ( + + ); + }, + }, + { + path: "page", + action: () => actionDfd.promise, + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render(); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + // Without useOptimistic under the hood, our mid-navigation state updates + // don't surface + await fireEvent.click(container.querySelector("#submit")!); + await waitFor(() => screen.getByText("Navigation:idle")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + + await act(() => { + actionDfd.resolve("Action"); + }); + await tick(); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:3")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + }); + + describe("", () => { + it("navigations are not transition-enabled", async () => { + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let navigate = useNavigate(); + return ( + + ); + }, + }, + { + path: "page", + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render( + , + ); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + // Without transitions enabled, all updates surface during navigation + await fireEvent.click(container.querySelector("#link")!); + await waitFor(() => screen.getByText("Navigation:loading")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:1")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:loading")).toBeDefined(); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + + it("submissions are not transition-enabled", async () => { + let actionDfd = createDeferred(); + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let submit = useSubmit(); + return ( + + ); + }, + }, + { + path: "page", + action: () => actionDfd.promise, + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render(); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + // Without transitions enabled, all updates surface during navigation + await fireEvent.click(container.querySelector("#submit")!); + await waitFor(() => screen.getByText("Navigation:submitting")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:1")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:submitting")).toBeDefined(); + + actionDfd.resolve("Action"); + await waitFor(() => screen.getByText("Navigation:loading")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:3")); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:3")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + }); + + describe("", () => { + it("navigations are transition-enabled", async () => { + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let navigate = useNavigate(); + return ( + + ); + }, + }, + { + path: "page", + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render( + , + ); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + // With transitions enabled, our updates surface via useOptimistic, but + // other transition-enabled updates do not + await fireEvent.click(container.querySelector("#link")!); + await waitFor(() => screen.getByText("Navigation:loading")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + expect(screen.getByText("Navigation:loading")).toBeDefined(); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:2")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + + it("submissions are transition-enabled", async () => { + let actionDfd = createDeferred(); + let loaderDfd = createDeferred(); + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigation = useNavigation(); + let [count, setCount] = React.useState(0); + return ( + <> +

{`Navigation:${navigation.state}`}

+ + + + ); + }, + children: [ + { + index: true, + Component() { + let submit = useSubmit(); + return ( + + ); + }, + }, + { + path: "page", + action: () => actionDfd.promise, + loader: () => loaderDfd.promise, + Component() { + return

{useLoaderData()}

; + }, + }, + ], + }, + ]); + + let { container } = render( + , + ); + + await waitFor(() => screen.getByText("Go to page")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + expect(screen.getByText("Increment:0")).toBeDefined(); + + // Without transitions enabled, all updates surface during navigation + await fireEvent.click(container.querySelector("#submit")!); + await waitFor(() => screen.getByText("Navigation:submitting")); + expect(screen.getByText("Increment:0")).toBeDefined(); + + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + expect(screen.getByText("Navigation:submitting")).toBeDefined(); + + actionDfd.resolve("Action"); + await waitFor(() => screen.getByText("Navigation:loading")); + await fireEvent.click(container.querySelector("#increment")!); + await waitFor(() => screen.getByText("Increment:0")); + + loaderDfd.resolve("Page"); + await waitFor(() => screen.getByText("Page")); + await waitFor(() => screen.getByText("Increment:3")); + expect(screen.getByText("Navigation:idle")).toBeDefined(); + }); + }); +}); diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 4be51e5cc5..954436fd17 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -69,12 +69,33 @@ import { } from "./hooks"; import type { ViewTransition } from "./dom/global"; import { warnOnce } from "./server-runtime/warnings"; -import type { - unstable_ClientInstrumentation, - unstable_InstrumentRouteFunction, - unstable_InstrumentRouterFunction, -} from "./router/instrumentation"; -import { instrumentClientSideRouter } from "./router/instrumentation"; +import type { unstable_ClientInstrumentation } from "./router/instrumentation"; + +/** + * Webpack can fail to compile on against react versions without this export + * complains that `startTransition` doesn't exist in `React`. + * + * Using the string constant directly at runtime fixes the webpack build issue + * but can result in terser stripping the actual call at minification time. + * + * Grabbing an exported reference once up front resolves that issue. + * + * See https://github.com/remix-run/react-router/issues/10579 + */ +const USE_OPTIMISTIC = "useOptimistic"; +// @ts-expect-error Needs React 19 types but we develop against 18 +const useOptimisticImpl = React[USE_OPTIMISTIC]; + +function useOptimisticSafe( + val: T, +): [T, React.Dispatch>] { + if (useOptimisticImpl) { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useOptimisticImpl(val); + } else { + return [val, () => undefined]; + } +} export function mapRouteProperties(route: RouteObject) { let updates: Partial & { hasErrorBoundary: boolean } = { @@ -354,143 +375,21 @@ export interface RouterProviderProps { * ``` */ unstable_onError?: unstable_ClientOnErrorFunction; -} - -function shallowDiff(a: any, b: any) { - if (a === b) { - return false; - } - let aKeys = Object.keys(a); - let bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) { - return true; - } - for (let key of aKeys) { - if (a[key] !== b[key]) { - return true; - } - } - return false; -} - -export function UNSTABLE_TransitionEnabledRouterProvider({ - router, - flushSync: reactDomFlushSyncImpl, - unstable_onError, -}: RouterProviderProps) { - let fetcherData = React.useRef>(new Map()); - let [revalidating, startRevalidation] = React.useTransition(); - let [state, setState] = React.useState(router.state); - - (router as any).__setPendingRerender = (promise: Promise<() => void>) => - startRevalidation( - // @ts-expect-error - need react 19 types for this to be async - async () => { - const rerender = await promise; - startRevalidation(() => { - rerender(); - }); - }, - ); - - let navigator = React.useMemo((): Navigator => { - return { - createHref: router.createHref, - encodeLocation: router.encodeLocation, - go: (n) => router.navigate(n), - push: (to, state, opts) => - router.navigate(to, { - state, - preventScrollReset: opts?.preventScrollReset, - }), - replace: (to, state, opts) => - router.navigate(to, { - replace: true, - state, - preventScrollReset: opts?.preventScrollReset, - }), - }; - }, [router]); - - let basename = router.basename || "/"; - - let dataRouterContext = React.useMemo( - () => ({ - router, - navigator, - static: false, - basename, - unstable_onError, - }), - [router, navigator, basename, unstable_onError], - ); - - React.useLayoutEffect(() => { - return router.subscribe( - (newState, { deletedFetchers, flushSync, viewTransitionOpts }) => { - newState.fetchers.forEach((fetcher, key) => { - if (fetcher.data !== undefined) { - fetcherData.current.set(key, fetcher.data); - } - }); - deletedFetchers.forEach((key) => fetcherData.current.delete(key)); - - const diff = shallowDiff(state, newState); - - if (!diff) return; - - if (flushSync) { - if (reactDomFlushSyncImpl) { - reactDomFlushSyncImpl(() => setState(newState)); - } else { - setState(newState); - } - } else { - React.startTransition(() => { - setState(newState); - }); - } - }, - ); - }, [router, reactDomFlushSyncImpl, state]); - - // The fragment and {null} here are important! We need them to keep React 18's - // useId happy when we are server-rendering since we may have a