Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose promises from underlying router operations for use in react 19 #11521

Merged
merged 5 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/expose-promises.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"react-router": major
---

- Expose the underlying router promises from the following APIs for compsition in React 19 APIs:
- `useNavigate()`
- `useSubmit`
- `useFetcher().load`
- `useFetcher().submit`
- `useRevalidator.revalidate`
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@
},
"filesize": {
"packages/react-router/dist/react-router.production.min.js": {
"none": "115.5 kB"
"none": "116.6 kB"
},
"packages/react-router/dist/umd/react-router.production.min.js": {
"none": "120 kB"
"none": "121.2 kB"
}
},
"pnpm": {
Expand Down
275 changes: 274 additions & 1 deletion packages/react-router/__tests__/data-memory-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ import {
useRouteLoaderData,
} from "react-router";

import type { ErrorResponse } from "../index";
import {
useFetcher,
useNavigate,
useRevalidator,
useSubmit,
type ErrorResponse,
} from "../index";
import urlDataStrategy from "./router/utils/urlDataStrategy";
import { createDeferred } from "./router/utils/utils";
import MemoryNavigate from "./utils/MemoryNavigate";
import getHtml from "./utils/getHtml";
import { RouterProvider as DomRouterProvider } from "../lib/dom/lib";

describe("createMemoryRouter", () => {
let consoleWarn: jest.SpyInstance;
Expand Down Expand Up @@ -1002,6 +1009,272 @@ describe("createMemoryRouter", () => {
`);
});

it("exposes promise from useNavigate", async () => {
let sequence: string[] = [];
let router = createMemoryRouter([
{
path: "/",
Component() {
let navigate = useNavigate();
return (
<>
<h1>Home</h1>
<button
onClick={async () => {
sequence.push("call navigate");
await navigate("/page");
sequence.push("navigate resolved");
}}
>
Navigate
</button>
</>
);
},
},
{
path: "/page",
async loader() {
sequence.push("loader start");
await new Promise((r) => setTimeout(r, 100));
sequence.push("loader end");
return null;
},
Component: () => {
sequence.push("render");
return <h1>Page</h1>;
},
},
]);
let { container } = render(<RouterProvider router={router} />);

expect(getHtml(container)).toContain("Home");
fireEvent.click(screen.getByText("Navigate"));
await waitFor(() => screen.getByText("Page"));

expect(sequence).toEqual([
"call navigate",
"loader start",
"loader end",
"navigate resolved",
"render",
]);
});

it("exposes promise from useSubmit", async () => {
let sequence: string[] = [];
let router = createMemoryRouter([
{
path: "/",
Component() {
let submit = useSubmit();
return (
<>
<h1>Home</h1>
<button
onClick={async () => {
sequence.push("call submit");
await submit({}, { action: "/page" });
sequence.push("submit resolved");
}}
>
Submit
</button>
</>
);
},
},
{
path: "/page",
async loader() {
sequence.push("loader start");
await new Promise((r) => setTimeout(r, 100));
sequence.push("loader end");
return null;
},
Component: () => {
sequence.push("render");
return <h1>Page</h1>;
},
},
]);
let { container } = render(<RouterProvider router={router} />);

expect(getHtml(container)).toContain("Home");
fireEvent.click(screen.getByText("Submit"));
await waitFor(() => screen.getByText("Page"));

expect(sequence).toEqual([
"call submit",
"loader start",
"loader end",
"submit resolved",
"render",
]);
});

it("exposes promise from useRevalidator", async () => {
let sequence: string[] = [];
let count = 0;
let router = createMemoryRouter([
{
path: "/",
async loader() {
sequence.push("loader start");
await new Promise((r) => setTimeout(r, 100));
sequence.push("loader end");
return ++count;
},
Component() {
let loaderCount = useLoaderData();
let revalidator = useRevalidator();
sequence.push(`render ${loaderCount}`);
return (
<button
onClick={async () => {
sequence.push("call revalidate");
await revalidator.revalidate();
sequence.push("revalidate resolved");
}}
>
Revalidate ({loaderCount})
</button>
);
},
},
]);
render(<RouterProvider router={router} />);

await waitFor(() => screen.getByText("Revalidate (1)"));
fireEvent.click(screen.getByText("Revalidate (1)"));
await waitFor(() => screen.getByText("Revalidate (2)"));

expect(sequence).toEqual([
"loader start",
"loader end",
"render 1",
"call revalidate",
"loader start",
"render 1", // revalidator.state === 'loading'
"loader end",
"revalidate resolved",
"render 2",
]);
});

it("exposes promise from useFetcher.load", async () => {
let sequence: string[] = [];
let count = 0;
let router = createMemoryRouter([
{
path: "/",
async loader() {
sequence.push("loader start");
await new Promise((r) => setTimeout(r, 100));
sequence.push("loader end");
return ++count;
},
Component() {
let loaderCount = useLoaderData();
let fetcher = useFetcher();
sequence.push(`render ${loaderCount} ${fetcher.data || "empty"}`);
return (
<button
onClick={async () => {
sequence.push("call fetcher.load");
await fetcher.load("/");
sequence.push("fetcher.load resolved");
}}
>
Fetch ({`${loaderCount}, ${fetcher.data || "empty"}`})
</button>
);
},
},
]);

// TODO: Fetchers only supported in DomRouterProvider at the moment, but
// that should be fixed once we align the two
render(<DomRouterProvider router={router} />);

await waitFor(() => screen.getByText("Fetch (1, empty)"));
fireEvent.click(screen.getByText("Fetch (1, empty)"));
await waitFor(() => screen.getByText("Fetch (1, 2)"));

expect(sequence).toEqual([
"loader start",
"loader end",
"render 1 empty",
"call fetcher.load",
"loader start",
"render 1 empty", // fetcher.state === 'loading'
"loader end",
"fetcher.load resolved",
"render 1 2",
]);
});

it("exposes promise from useFetcher.submit", async () => {
let sequence: string[] = [];
let count = 0;
let router = createMemoryRouter([
{
path: "/",
async action() {
sequence.push("action start");
await new Promise((r) => setTimeout(r, 100));
sequence.push("action end");
return ++count;
},
async loader() {
sequence.push("loader start");
await new Promise((r) => setTimeout(r, 100));
sequence.push("loader end");
return ++count;
},
Component() {
let loaderCount = useLoaderData();
let fetcher = useFetcher();
sequence.push(`render ${loaderCount} ${fetcher.data || "empty"}`);
return (
<button
onClick={async () => {
sequence.push("call fetcher.submit");
await fetcher.submit({}, { method: "post", action: "/" });
sequence.push("fetcher.submit resolved");
}}
>
Fetch ({`${loaderCount}, ${fetcher.data || "empty"}`})
</button>
);
},
},
]);

// TODO: Fetchers only supported in DomRouterProvider at the moment, but
// that should be fixed once we align the two
render(<DomRouterProvider router={router} />);

await waitFor(() => screen.getByText("Fetch (1, empty)"));
fireEvent.click(screen.getByText("Fetch (1, empty)"));
await waitFor(() => screen.getByText("Fetch (3, 2)"));

expect(sequence).toEqual([
"loader start",
"loader end",
"render 1 empty",
"call fetcher.submit",
"action start",
"render 1 empty", // fetcher.state === 'submitting'
"action end",
"loader start",
"render 1 2", // fetcher.state === 'loading'
"loader end",
"fetcher.submit resolved",
"render 3 2",
]);
});

describe("errors", () => {
it("renders hydration errors on leaf elements using errorElement", async () => {
let router = createMemoryRouter(
Expand Down
30 changes: 18 additions & 12 deletions packages/react-router/__tests__/dom/special-characters-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -831,8 +831,9 @@ describe("special character tests", () => {
it("does not encode characters in MemoryRouter (navigate)", () => {
function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
React.useEffect(() => {
navigate("/with space");
}, [navigate]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests needed to be updated so they don't return promises from useEffect

return null;
}
let ctx = render(
Expand Down Expand Up @@ -864,8 +865,9 @@ describe("special character tests", () => {
it("does not encode characters in createMemoryRouter (navigate)", () => {
function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
React.useEffect(() => {
navigate("/with space");
}, [navigate]);
return null;
}
let router = createMemoryRouter([
Expand Down Expand Up @@ -903,8 +905,9 @@ describe("special character tests", () => {

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
React.useEffect(() => {
navigate("/with space");
}, [navigate]);
return null;
}

Expand Down Expand Up @@ -943,8 +946,9 @@ describe("special character tests", () => {

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
React.useEffect(() => {
navigate("/with space");
}, [navigate]);
return null;
}

Expand Down Expand Up @@ -988,8 +992,9 @@ describe("special character tests", () => {

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
React.useEffect(() => {
navigate("/with space");
}, [navigate]);
return null;
}

Expand Down Expand Up @@ -1030,8 +1035,9 @@ describe("special character tests", () => {

function Start() {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate("/with space"), []);
React.useEffect(() => {
navigate("/with space");
}, [navigate]);
return null;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/react-router/__tests__/dom/trailing-slashes-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,8 @@ function getWindowImpl(initialUrl: string, isHash = false): Window {

function SingleNavigate({ to }: { to: To }) {
let navigate = useNavigate();
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => navigate(to), [to]);
React.useEffect(() => {
navigate(to);
}, [navigate, to]);
return null;
}
Loading