", () => {
"
`);
});
+});
- it("handles setState in render in StrictMode using a data router (sync loader)", async () => {
- let renders: number[] = [];
- const router = createMemoryRouter([
- {
- path: "/",
- children: [
- {
- index: true,
- Component() {
- let [count, setCount] = React.useState(0);
- if (count === 0) {
- setCount(1);
- }
- return ;
+describe("concurrent mode", () => {
+ describe("v7_startTransition = false", () => {
+ it("handles setState in render in StrictMode using a data router (sync loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ let [count, setCount] = React.useState(0);
+ if (count === 0) {
+ setCount(1);
+ }
+ return ;
+ },
},
- },
- {
- path: "b",
- Component() {
- let { state } = useLocation() as { state: { count: number } };
- renders.push(state.count);
- return (
- <>
- Page B
- {state.count}
- >
- );
+ {
+ path: "b",
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
},
- },
- ],
- },
- ]);
+ ],
+ },
+ ]);
- let navigateSpy = jest.spyOn(router, "navigate");
+ let navigateSpy = jest.spyOn(router, "navigate");
- let { container } = render(
-
-
-
- );
+ let { container } = render(
+
+
+
+ );
- await waitFor(() => screen.getByText("Page B"));
+ await waitFor(() => screen.getByText("Page B"));
- expect(getHtml(container)).toMatchInlineSnapshot(`
+ expect(getHtml(container)).toMatchInlineSnapshot(`
"
Page B
@@ -618,64 +621,133 @@ describe("", () => {
"
`);
- expect(navigateSpy).toHaveBeenCalledTimes(2);
- expect(navigateSpy.mock.calls[0]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 1 } },
- ]);
- expect(navigateSpy.mock.calls[1]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 1 } },
- ]);
- expect(renders).toEqual([1, 1]);
- });
+ expect(navigateSpy).toHaveBeenCalledTimes(2);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ expect(renders).toEqual([1, 1]);
+ });
- it("handles setState in effect in StrictMode using a data router (sync loader)", async () => {
- let renders: number[] = [];
- const router = createMemoryRouter([
- {
- path: "/",
- children: [
- {
- index: true,
- Component() {
- let [count, setCount] = React.useState(0);
- React.useEffect(() => {
+ it("handles setState in effect in StrictMode using a data router (sync loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ let [count, setCount] = React.useState(0);
+ React.useEffect(() => {
+ if (count === 0) {
+ setCount(1);
+ }
+ }, [count]);
+ return ;
+ },
+ },
+ {
+ path: "b",
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
+ },
+ ],
+ },
+ ]);
+
+ let navigateSpy = jest.spyOn(router, "navigate");
+
+ let { container } = render(
+
+
+
+ );
+
+ await waitFor(() => screen.getByText("Page B"));
+
+ expect(getHtml(container)).toMatchInlineSnapshot(`
+ ""
+ `);
+ expect(navigateSpy).toHaveBeenCalledTimes(2);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ expect(renders).toEqual([0, 0]);
+ });
+
+ it("handles setState in render in StrictMode using a data router (async loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ let [count, setCount] = React.useState(0);
if (count === 0) {
setCount(1);
}
- }, [count]);
- return ;
+ return ;
+ },
},
- },
- {
- path: "b",
- Component() {
- let { state } = useLocation() as { state: { count: number } };
- renders.push(state.count);
- return (
- <>
- Page B
- {state.count}
- >
- );
+ {
+ path: "b",
+ async loader() {
+ await new Promise((r) => setTimeout(r, 10));
+ return null;
+ },
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
},
- },
- ],
- },
- ]);
+ ],
+ },
+ ]);
- let navigateSpy = jest.spyOn(router, "navigate");
+ let navigateSpy = jest.spyOn(router, "navigate");
- let { container } = render(
-
-
-
- );
+ let { container } = render(
+
+
+
+ );
- await waitFor(() => screen.getByText("Page B"));
+ await waitFor(() => screen.getByText("Page B"));
- expect(getHtml(container)).toMatchInlineSnapshot(`
+ expect(getHtml(container)).toMatchInlineSnapshot(`
"
Page B
@@ -685,66 +757,219 @@ describe("", () => {
"
`);
- expect(navigateSpy).toHaveBeenCalledTimes(3);
- expect(navigateSpy.mock.calls[0]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 0 } },
- ]);
- expect(navigateSpy.mock.calls[1]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 0 } },
- ]);
- expect(renders).toEqual([1, 1]);
+ expect(navigateSpy).toHaveBeenCalledTimes(2);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ // /a/b rendered with the same state value both times
+ expect(renders).toEqual([1, 1]);
+ });
+
+ it("handles setState in effect in StrictMode using a data router (async loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ // When state managed by react and changes during render, we'll
+ // only "see" the value from the first pass through here in our
+ // effects
+ let [count, setCount] = React.useState(0);
+ React.useEffect(() => {
+ if (count === 0) {
+ setCount(1);
+ }
+ }, [count]);
+ return ;
+ },
+ },
+ {
+ path: "b",
+ async loader() {
+ await new Promise((r) => setTimeout(r, 10));
+ return null;
+ },
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
+ },
+ ],
+ },
+ ]);
+
+ let navigateSpy = jest.spyOn(router, "navigate");
+
+ let { container } = render(
+
+
+
+ );
+
+ await waitFor(() => screen.getByText("Page B"));
+
+ expect(getHtml(container)).toMatchInlineSnapshot(`
+ ""
+ `);
+ expect(navigateSpy).toHaveBeenCalledTimes(3);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ // StrictMode only applies the double-effect execution on component mount,
+ // not component update
+ expect(navigateSpy.mock.calls[2]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ // /a/b rendered with the latest state value both times
+ expect(renders).toEqual([1, 1]);
+ });
});
- it("handles setState in render in StrictMode using a data router (async loader)", async () => {
- let renders: number[] = [];
- const router = createMemoryRouter([
- {
- path: "/",
- children: [
- {
- index: true,
- Component() {
- let [count, setCount] = React.useState(0);
- if (count === 0) {
- setCount(1);
- }
- return ;
+ describe("v7_startTransition = true", () => {
+ it("handles setState in render in StrictMode using a data router (sync loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ let [count, setCount] = React.useState(0);
+ if (count === 0) {
+ setCount(1);
+ }
+ return ;
+ },
},
- },
- {
- path: "b",
- async loader() {
- await new Promise((r) => setTimeout(r, 10));
- return null;
+ {
+ path: "b",
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
+ },
+ ],
+ },
+ ]);
+
+ let navigateSpy = jest.spyOn(router, "navigate");
+
+ let { container } = render(
+
+
+
+ );
+
+ await waitFor(() => screen.getByText("Page B"));
+
+ expect(getHtml(container)).toMatchInlineSnapshot(`
+ ""
+ `);
+ expect(navigateSpy).toHaveBeenCalledTimes(2);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ expect(renders).toEqual([1, 1]);
+ });
+
+ it("handles setState in effect in StrictMode using a data router (sync loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ let [count, setCount] = React.useState(0);
+ React.useEffect(() => {
+ if (count === 0) {
+ setCount(1);
+ }
+ }, [count]);
+ return ;
+ },
},
- Component() {
- let { state } = useLocation() as { state: { count: number } };
- renders.push(state.count);
- return (
- <>
- Page B
- {state.count}
- >
- );
+ {
+ path: "b",
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
},
- },
- ],
- },
- ]);
+ ],
+ },
+ ]);
- let navigateSpy = jest.spyOn(router, "navigate");
+ let navigateSpy = jest.spyOn(router, "navigate");
- let { container } = render(
-
-
-
- );
+ let { container } = render(
+
+
+
+ );
- await waitFor(() => screen.getByText("Page B"));
+ await waitFor(() => screen.getByText("Page B"));
- expect(getHtml(container)).toMatchInlineSnapshot(`
+ expect(getHtml(container)).toMatchInlineSnapshot(`
"
Page B
@@ -754,72 +979,151 @@ describe("", () => {
"
`);
- expect(navigateSpy).toHaveBeenCalledTimes(2);
- expect(navigateSpy.mock.calls[0]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 1 } },
- ]);
- expect(navigateSpy.mock.calls[1]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 1 } },
- ]);
- // /a/b rendered with the same state value both times
- expect(renders).toEqual([1, 1]);
- });
+ expect(navigateSpy).toHaveBeenCalledTimes(3);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ expect(navigateSpy.mock.calls[2]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ expect(renders).toEqual([1, 1]);
+ });
- it("handles setState in effect in StrictMode using a data router (async loader)", async () => {
- let renders: number[] = [];
- const router = createMemoryRouter([
- {
- path: "/",
- children: [
- {
- index: true,
- Component() {
- // When state managed by react and changes during render, we'll
- // only "see" the value from the first pass through here in our
- // effects
- let [count, setCount] = React.useState(0);
- React.useEffect(() => {
+ it("handles setState in render in StrictMode using a data router (async loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ let [count, setCount] = React.useState(0);
if (count === 0) {
setCount(1);
}
- }, [count]);
- return ;
+ return ;
+ },
},
- },
- {
- path: "b",
- async loader() {
- await new Promise((r) => setTimeout(r, 10));
- return null;
+ {
+ path: "b",
+ async loader() {
+ await new Promise((r) => setTimeout(r, 10));
+ return null;
+ },
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
},
- Component() {
- let { state } = useLocation() as { state: { count: number } };
- renders.push(state.count);
- return (
- <>
- Page B
- {state.count}
- >
- );
+ ],
+ },
+ ]);
+
+ let navigateSpy = jest.spyOn(router, "navigate");
+
+ let { container } = render(
+
+
+
+ );
+
+ await waitFor(() => screen.getByText("Page B"));
+
+ expect(getHtml(container)).toMatchInlineSnapshot(`
+ ""
+ `);
+ expect(navigateSpy).toHaveBeenCalledTimes(2);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ // /a/b rendered with the same state value both times
+ expect(renders).toEqual([1, 1]);
+ });
+
+ it("handles setState in effect in StrictMode using a data router (async loader)", async () => {
+ let renders: number[] = [];
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ children: [
+ {
+ index: true,
+ Component() {
+ // When state managed by react and changes during render, we'll
+ // only "see" the value from the first pass through here in our
+ // effects
+ let [count, setCount] = React.useState(0);
+ React.useEffect(() => {
+ if (count === 0) {
+ setCount(1);
+ }
+ }, [count]);
+ return ;
+ },
},
- },
- ],
- },
- ]);
+ {
+ path: "b",
+ async loader() {
+ await new Promise((r) => setTimeout(r, 10));
+ return null;
+ },
+ Component() {
+ let { state } = useLocation() as { state: { count: number } };
+ renders.push(state.count);
+ return (
+ <>
+ Page B
+ {state.count}
+ >
+ );
+ },
+ },
+ ],
+ },
+ ]);
- let navigateSpy = jest.spyOn(router, "navigate");
+ let navigateSpy = jest.spyOn(router, "navigate");
- let { container } = render(
-
-
-
- );
+ let { container } = render(
+
+
+
+ );
- await waitFor(() => screen.getByText("Page B"));
+ await waitFor(() => screen.getByText("Page B"));
- expect(getHtml(container)).toMatchInlineSnapshot(`
+ expect(getHtml(container)).toMatchInlineSnapshot(`
"
Page B
@@ -829,23 +1133,24 @@ describe("", () => {
"
`);
- expect(navigateSpy).toHaveBeenCalledTimes(3);
- expect(navigateSpy.mock.calls[0]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 0 } },
- ]);
- expect(navigateSpy.mock.calls[1]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 0 } },
- ]);
- // StrictMode only applies the double-effect execution on component mount,
- // not component update
- expect(navigateSpy.mock.calls[2]).toMatchObject([
- { pathname: "/b" },
- { state: { count: 1 } },
- ]);
- // /a/b rendered with the latest state value both times
- expect(renders).toEqual([1, 1]);
+ expect(navigateSpy).toHaveBeenCalledTimes(3);
+ expect(navigateSpy.mock.calls[0]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ expect(navigateSpy.mock.calls[1]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 0 } },
+ ]);
+ // StrictMode only applies the double-effect execution on component mount,
+ // not component update
+ expect(navigateSpy.mock.calls[2]).toMatchObject([
+ { pathname: "/b" },
+ { state: { count: 1 } },
+ ]);
+ // /a/b rendered with the latest state value both times
+ expect(renders).toEqual([1, 1]);
+ });
});
});
diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx
index 4512c03c5f..95499722d5 100644
--- a/packages/react-router/__tests__/useNavigate-test.tsx
+++ b/packages/react-router/__tests__/useNavigate-test.tsx
@@ -7,7 +7,6 @@ import {
Route,
useNavigate,
useLocation,
- useRoutes,
createMemoryRouter,
createRoutesFromElements,
Outlet,
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index 8167fcea9c..ad1e1fd56e 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -23,7 +23,7 @@ import type {
To,
InitialEntry,
LazyRouteFunction,
- FutureConfig,
+ FutureConfig as RouterFutureConfig,
} from "@remix-run/router";
import {
AbortedDeferredError,
@@ -43,6 +43,7 @@ import {
UNSAFE_warning as warning,
} from "@remix-run/router";
+import startTransitionImpl from "./lib/polyfills/start-transition";
import type {
AwaitProps,
MemoryRouterProps,
@@ -55,6 +56,7 @@ import type {
RouterProps,
RoutesProps,
RouterProviderProps,
+ FutureConfig,
} from "./lib/components";
import {
createRoutesFromChildren,
@@ -127,6 +129,7 @@ export type {
DataRouteMatch,
DataRouteObject,
Fetcher,
+ FutureConfig,
Hash,
IndexRouteObject,
IndexRouteProps,
@@ -256,7 +259,7 @@ export function createMemoryRouter(
routes: RouteObject[],
opts?: {
basename?: string;
- future?: Partial>;
+ future?: Partial>;
hydrationData?: HydrationState;
initialEntries?: InitialEntry[];
initialIndex?: number;
@@ -301,4 +304,5 @@ export {
mapRouteProperties as UNSAFE_mapRouteProperties,
useRouteId as UNSAFE_useRouteId,
useRoutesImpl as UNSAFE_useRoutesImpl,
+ startTransitionImpl as UNSAFE_startTransitionImpl,
};
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index e4d4eee360..444b496bf3 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -22,6 +22,7 @@ import {
UNSAFE_getPathContributingMatches as getPathContributingMatches,
} from "@remix-run/router";
+import startTransitionImpl from "./polyfills/start-transition";
import type {
DataRouteObject,
IndexRouteObject,
@@ -49,47 +50,35 @@ import {
useLocation,
} from "./hooks";
+export interface FutureConfig {
+ v7_startTransition: boolean;
+}
+
export interface RouterProviderProps {
fallbackElement?: React.ReactNode;
router: RemixRouter;
+ future?: FutureConfig;
}
-// Webpack + React 17 fails to compile on any of the following:
-// * import { startTransition } from "react"
-// * import * as React from from "react";
-// "startTransition" in React ? React.startTransition(() => setState()) : setState()
-// * import * as React from from "react";
-// "startTransition" in React ? React["startTransition"](() => setState()) : setState()
-//
-// Moving it to a constant such as the following solves the Webpack/React 17 issue:
-// * import * as React from from "react";
-// const START_TRANSITION = "startTransition";
-// START_TRANSITION in React ? React[START_TRANSITION](() => setState()) : setState()
-//
-// However, that introduces webpack/terser minification issues in production builds
-// in React 18 where minification/obfuscation ends up removing the call of
-// React.startTransition entirely from the first half of the ternary. Grabbing
-// this reference once up front resolves that issue.
-const START_TRANSITION = "startTransition";
-const startTransitionImpl = React[START_TRANSITION];
-
/**
* Given a Remix Router instance, render the appropriate UI
*/
export function RouterProvider({
fallbackElement,
router,
+ future,
}: RouterProviderProps): React.ReactElement {
// Need to use a layout effect here so we are subscribed early enough to
// pick up on any render-driven redirects/navigations (useEffect/)
let [state, setStateImpl] = React.useState(router.state);
+ let { v7_startTransition } = future || {};
let setState = React.useCallback(
(newState: RouterState) => {
- startTransitionImpl
+ v7_startTransition && startTransitionImpl
? startTransitionImpl(() => setStateImpl(newState))
: setStateImpl(newState);
},
- [setStateImpl]
+ [setStateImpl, v7_startTransition]
);
React.useLayoutEffect(() => router.subscribe(setState), [router, setState]);
@@ -168,6 +157,7 @@ export interface MemoryRouterProps {
children?: React.ReactNode;
initialEntries?: InitialEntry[];
initialIndex?: number;
+ future?: FutureConfig;
}
/**
@@ -180,6 +170,7 @@ export function MemoryRouter({
children,
initialEntries,
initialIndex,
+ future,
}: MemoryRouterProps): React.ReactElement {
let historyRef = React.useRef();
if (historyRef.current == null) {
@@ -195,13 +186,14 @@ export function MemoryRouter({
action: history.action,
location: history.location,
});
+ let { v7_startTransition } = future || {};
let setState = React.useCallback(
(newState: { action: NavigationType; location: Location }) => {
- startTransitionImpl
+ v7_startTransition && startTransitionImpl
? startTransitionImpl(() => setStateImpl(newState))
: setStateImpl(newState);
},
- [setStateImpl]
+ [setStateImpl, v7_startTransition]
);
React.useLayoutEffect(() => history.listen(setState), [history, setState]);
diff --git a/packages/react-router/lib/polyfills/start-transition.ts b/packages/react-router/lib/polyfills/start-transition.ts
new file mode 100644
index 0000000000..72c6b8bba3
--- /dev/null
+++ b/packages/react-router/lib/polyfills/start-transition.ts
@@ -0,0 +1,28 @@
+import * as React from "react";
+
+/**
+ Not a true "polyfill" since we guard via the feature flag at runtime,
+ but close enough :)
+
+ Webpack + React 17 fails to compile on any of the following because webpack
+ complains that `startTransition` doesn't exist in `React`:
+ * import { startTransition } from "react"
+ * import * as React from from "react";
+ "startTransition" in React ? React.startTransition(() => setState()) : setState()
+ * import * as React from from "react";
+ "startTransition" in React ? React["startTransition"](() => setState()) : setState()
+
+ Moving it to a constant such as the following solves the Webpack/React 17 issue:
+ * import * as React from from "react";
+ const START_TRANSITION = "startTransition";
+ START_TRANSITION in React ? React[START_TRANSITION](() => setState()) : setState()
+
+ However, that introduces webpack/terser minification issues in production builds
+ in React 18 where minification/obfuscation ends up removing the call of
+ React.startTransition entirely from the first half of the ternary. Grabbing
+ this exported reference once up front resolves that issue.
+
+ See https://github.com/remix-run/react-router/issues/10579
+*/
+const START_TRANSITION = "startTransition";
+export default React[START_TRANSITION];