diff --git a/.changeset/expose-promises.md b/.changeset/expose-promises.md new file mode 100644 index 0000000000..43bf5fbd63 --- /dev/null +++ b/.changeset/expose-promises.md @@ -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` diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index e9d4a748d2..f40c085799 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -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; @@ -1002,6 +1009,272 @@ describe("createMemoryRouter", () => { `); }); + it("exposes promise from useNavigate", async () => { + let sequence: string[] = []; + let router = createMemoryRouter([ + { + path: "/", + Component() { + let navigate = useNavigate(); + return ( + <> +

Home

+ + + ); + }, + }, + { + 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

Page

; + }, + }, + ]); + let { container } = render(); + + 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 ( + <> +

Home

+ + + ); + }, + }, + { + 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

Page

; + }, + }, + ]); + let { container } = render(); + + 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 ( + + ); + }, + }, + ]); + render(); + + 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 ( + + ); + }, + }, + ]); + + // TODO: Fetchers only supported in DomRouterProvider at the moment, but + // that should be fixed once we align the two + render(); + + 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 ( + + ); + }, + }, + ]); + + // TODO: Fetchers only supported in DomRouterProvider at the moment, but + // that should be fixed once we align the two + render(); + + 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( diff --git a/packages/react-router/__tests__/dom/special-characters-test.tsx b/packages/react-router/__tests__/dom/special-characters-test.tsx index 1e8e7b4882..67a0cc5cc2 100644 --- a/packages/react-router/__tests__/dom/special-characters-test.tsx +++ b/packages/react-router/__tests__/dom/special-characters-test.tsx @@ -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]); return null; } let ctx = render( @@ -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([ @@ -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; } @@ -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; } @@ -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; } @@ -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; } diff --git a/packages/react-router/__tests__/dom/trailing-slashes-test.tsx b/packages/react-router/__tests__/dom/trailing-slashes-test.tsx index e54d146633..8628f00f0e 100644 --- a/packages/react-router/__tests__/dom/trailing-slashes-test.tsx +++ b/packages/react-router/__tests__/dom/trailing-slashes-test.tsx @@ -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; } diff --git a/packages/react-router/__tests__/router/revalidate-test.ts b/packages/react-router/__tests__/router/revalidate-test.ts index 4ea2e22846..8f15661e36 100644 --- a/packages/react-router/__tests__/router/revalidate-test.ts +++ b/packages/react-router/__tests__/router/revalidate-test.ts @@ -1,5 +1,11 @@ +import { createMemoryRouter } from "../../lib/components"; import { IDLE_NAVIGATION } from "../../lib/router"; -import { cleanup, setup, TASK_ROUTES } from "./utils/data-router-setup"; +import { + cleanup, + setup, + createDeferred, + TASK_ROUTES, +} from "./utils/data-router-setup"; import { createFormData } from "./utils/utils"; describe("router.revalidate", () => { @@ -911,4 +917,217 @@ describe("router.revalidate", () => { data: "ROOT_DATA**", }); }); + + it("exposes a revalidation promise across navigations", async () => { + let revalidationDfd = createDeferred(); + let navigationDfd = createDeferred(); + let isFirstCall = true; + let router = createMemoryRouter( + [ + { + id: "root", + loader() { + if (isFirstCall) { + isFirstCall = false; + return revalidationDfd.promise; + } else { + return navigationDfd.promise; + } + }, + children: [ + { + index: true, + }, + { + path: "/page", + }, + ], + }, + ], + { + hydrationData: { + loaderData: { root: "ROOT" }, + }, + } + ); + + let revalidationValue = null; + let navigationValue = null; + + await router.initialize(); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + navigation: { state: "idle" }, + revalidation: "idle", + loaderData: { root: "ROOT" }, + }); + expect(isFirstCall).toBe(true); + + // Start a revalidation, but don't resolve the revalidation loader call + router.revalidate().then(() => { + revalidationValue = router.state.loaderData.root; + }); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + navigation: { state: "idle" }, + revalidation: "loading", + loaderData: { root: "ROOT" }, + }); + expect(isFirstCall).toBe(false); + + // Interrupt with a GET navigation + router.navigate("/page").then(() => { + navigationValue = router.state.loaderData.root; + }); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + navigation: { state: "loading" }, + revalidation: "loading", + loaderData: { root: "ROOT" }, + }); + expect(revalidationValue).toBeNull(); + expect(navigationValue).toBeNull(); + + // Complete the navigation, which should resolve the revalidation + await navigationDfd.resolve("ROOT*"); + expect(router.state).toMatchObject({ + location: { pathname: "/page" }, + navigation: { state: "idle" }, + revalidation: "idle", + loaderData: { root: "ROOT*" }, + }); + expect(revalidationValue).toBe("ROOT*"); + expect(navigationValue).toBe("ROOT*"); + + // no-op + revalidationDfd.reject(); + expect(router.state).toMatchObject({ + location: { pathname: "/page" }, + navigation: { state: "idle" }, + revalidation: "idle", + loaderData: { root: "ROOT*" }, + }); + expect(revalidationValue).toBe("ROOT*"); + expect(navigationValue).toBe("ROOT*"); + }); + + it("exposes a revalidation promise across multiple navigations", async () => { + let revalidationDfd = createDeferred(); + let navigationDfd = createDeferred(); + let navigationDfd2 = createDeferred(); + let count = 0; + let router = createMemoryRouter( + [ + { + id: "root", + loader() { + count++; + if (count === 1) { + return revalidationDfd.promise; + } else if (count === 2) { + return navigationDfd.promise; + } else { + return navigationDfd2.promise; + } + }, + children: [ + { + index: true, + }, + { + path: "/page", + }, + { + path: "/page2", + }, + ], + }, + ], + { + hydrationData: { + loaderData: { root: "ROOT" }, + }, + } + ); + + let revalidationValue = null; + let navigationValue = null; + let navigationValue2 = null; + + await router.initialize(); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + navigation: { state: "idle" }, + revalidation: "idle", + loaderData: { root: "ROOT" }, + }); + expect(count).toBe(0); + + // Start a revalidation, but don't resolve the revalidation loader call + router.revalidate().then(() => { + revalidationValue = router.state.loaderData.root; + }); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + navigation: { state: "idle" }, + revalidation: "loading", + loaderData: { root: "ROOT" }, + }); + expect(count).toBe(1); + + // Interrupt with a navigation + router.navigate("/page").then(() => { + navigationValue = router.state.loaderData.root; + }); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + navigation: { state: "loading" }, + revalidation: "loading", + loaderData: { root: "ROOT" }, + }); + expect(count).toBe(2); + expect(revalidationValue).toBeNull(); + expect(navigationValue).toBeNull(); + expect(navigationValue2).toBeNull(); + + // Interrupt with another navigation + router.navigate("/page2").then(() => { + navigationValue2 = router.state.loaderData.root; + }); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + navigation: { state: "loading" }, + revalidation: "loading", + loaderData: { root: "ROOT" }, + }); + expect(count).toBe(3); + expect(revalidationValue).toBeNull(); + expect(navigationValue).toBeNull(); + expect(navigationValue2).toBeNull(); + + // Complete the navigation, which should resolve the revalidation + await navigationDfd2.resolve("ROOT**"); + expect(router.state).toMatchObject({ + location: { pathname: "/page2" }, + navigation: { state: "idle" }, + revalidation: "idle", + loaderData: { root: "ROOT**" }, + }); + expect(revalidationValue).toBe("ROOT**"); + expect(navigationValue).toBe("ROOT**"); + expect(navigationValue2).toBe("ROOT**"); + + // no-op + navigationDfd.reject(); + revalidationDfd.reject(); + expect(router.state).toMatchObject({ + location: { pathname: "/page2" }, + navigation: { state: "idle" }, + revalidation: "idle", + loaderData: { root: "ROOT**" }, + }); + expect(revalidationValue).toBe("ROOT**"); + expect(navigationValue).toBe("ROOT**"); + expect(navigationValue2).toBe("ROOT**"); + }); }); 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 e58836724a..6a6a09358d 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -142,7 +142,6 @@ type SetupOpts = { initialEntries?: InitialEntry[]; initialIndex?: number; hydrationData?: HydrationState; - future?: FutureConfig; dataStrategy?: DataStrategyFunction; future?: Partial; }; diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx index 528174ed05..66823db747 100644 --- a/packages/react-router/__tests__/useNavigate-test.tsx +++ b/packages/react-router/__tests__/useNavigate-test.tsx @@ -14,7 +14,7 @@ import { } from "react-router"; describe("useNavigate", () => { - it("navigates to the new location", () => { + it("navigates to the new location", async () => { function Home() { let navigate = useNavigate(); @@ -44,7 +44,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -54,7 +54,7 @@ describe("useNavigate", () => { `); }); - it("navigates to the new location when no pathname is provided", () => { + it("navigates to the new location when no pathname is provided", async () => { function Home() { let location = useLocation(); let navigate = useNavigate(); @@ -94,7 +94,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -111,7 +111,7 @@ describe("useNavigate", () => { `); }); - it("navigates to the new location when no pathname is provided (with a basename)", () => { + it("navigates to the new location when no pathname is provided (with a basename)", async () => { function Home() { let location = useLocation(); let navigate = useNavigate(); @@ -151,7 +151,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -168,7 +168,7 @@ describe("useNavigate", () => { `); }); - it("navigates to the new location with empty query string when no query string is provided", () => { + it("navigates to the new location with empty query string when no query string is provided", async () => { function Home() { let location = useLocation(); let navigate = useNavigate(); @@ -208,7 +208,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -301,7 +301,7 @@ describe("useNavigate", () => { ); }); - it("allows useNavigate usage in a mixed RouterProvider/ scenario", () => { + it("allows useNavigate usage in a mixed RouterProvider/ scenario", async () => { const router = createMemoryRouter([ { path: "/*", @@ -378,7 +378,7 @@ describe("useNavigate", () => { let button = renderer.root.findByProps({ children: "Navigate from RouterProvider", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/page"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -402,7 +402,7 @@ describe("useNavigate", () => { button = renderer.root.findByProps({ children: "Navigate from RouterProvider", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -426,7 +426,7 @@ describe("useNavigate", () => { button = renderer.root.findByProps({ children: "Navigate /page from Routes", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/page"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -450,7 +450,7 @@ describe("useNavigate", () => { button = renderer.root.findByProps({ children: "Navigate /home from Routes", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -529,7 +529,9 @@ describe("useNavigate", () => { function Home() { let navigate = useNavigate(); - React.useEffect(() => navigate("/about"), [navigate]); + React.useEffect(() => { + navigate("/about"); + }, [navigate]); return

Home

; } @@ -565,7 +567,9 @@ describe("useNavigate", () => { } function Child({ onChildRendered }) { - React.useEffect(() => onChildRendered()); + React.useEffect(() => { + onChildRendered(); + }); return null; } @@ -616,7 +620,9 @@ describe("useNavigate", () => { index: true, Component() { let navigate = useNavigate(); - React.useEffect(() => navigate("/about"), [navigate]); + React.useEffect(() => { + navigate("/about"); + }, [navigate]); return

Home

; }, }, @@ -663,7 +669,9 @@ describe("useNavigate", () => { }); function Child({ onChildRendered }) { - React.useEffect(() => onChildRendered()); + React.useEffect(() => { + onChildRendered(); + }); return null; } @@ -678,7 +686,7 @@ describe("useNavigate", () => { }); describe("with state", () => { - it("adds the state to location.state", () => { + it("adds the state to location.state", async () => { function Home() { let navigate = useNavigate(); @@ -712,7 +720,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -726,7 +734,7 @@ describe("useNavigate", () => { describe("when relative navigation is handled via React Context", () => { describe("with an absolute href", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -744,7 +752,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -756,7 +764,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -774,7 +782,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -784,7 +792,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -801,7 +809,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -811,7 +819,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -831,7 +839,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -841,7 +849,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -867,7 +875,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -877,7 +885,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -903,7 +911,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -913,7 +921,7 @@ describe("useNavigate", () => { `); }); - it("handles parent navigation from inside multiple pathless layout routes", () => { + it("handles parent navigation from inside multiple pathless layout routes", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -952,7 +960,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -962,7 +970,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -982,7 +990,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -994,7 +1002,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1012,7 +1020,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1022,7 +1030,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1042,7 +1050,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1052,7 +1060,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1072,7 +1080,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1082,7 +1090,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1110,7 +1118,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1120,7 +1128,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1148,7 +1156,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1158,7 +1166,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1181,7 +1189,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1191,7 +1199,7 @@ describe("useNavigate", () => { `); }); - it("preserves search params and hash", () => { + it("preserves search params and hash", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1224,7 +1232,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1367,7 +1375,7 @@ describe("useNavigate", () => { describe("when relative navigation is handled via @remix-run/router", () => { describe("with an absolute href", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1385,7 +1393,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1397,7 +1405,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1418,7 +1426,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1428,7 +1436,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1448,7 +1456,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1458,7 +1466,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1481,7 +1489,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1491,7 +1499,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1520,7 +1528,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1530,7 +1538,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1559,7 +1567,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1569,7 +1577,7 @@ describe("useNavigate", () => { `); }); - it("handles parent navigation from inside multiple pathless layout routes", () => { + it("handles parent navigation from inside multiple pathless layout routes", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1611,7 +1619,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1621,7 +1629,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1644,7 +1652,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1656,7 +1664,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1677,7 +1685,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1687,7 +1695,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1710,7 +1718,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1720,7 +1728,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1743,7 +1751,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1753,7 +1761,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1782,7 +1790,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1792,7 +1800,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1821,7 +1829,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1831,7 +1839,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1857,7 +1865,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1867,7 +1875,7 @@ describe("useNavigate", () => { `); }); - it("preserves search params and hash", () => { + it("preserves search params and hash", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1903,7 +1911,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2054,7 +2062,7 @@ describe("useNavigate", () => { describe("with a basename", () => { describe("in a MemoryRouter", () => { - it("in a root route", () => { + it("in a root route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -2081,7 +2089,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2091,7 +2099,7 @@ describe("useNavigate", () => { `); }); - it("in a descendant route", () => { + it("in a descendant route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -2125,7 +2133,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2137,7 +2145,7 @@ describe("useNavigate", () => { }); describe("in a RouterProvider", () => { - it("in a root route", () => { + it("in a root route", async () => { let router = createMemoryRouter( [ { @@ -2168,7 +2176,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2178,7 +2186,7 @@ describe("useNavigate", () => { `); }); - it("in a descendant route", () => { + it("in a descendant route", async () => { let router = createMemoryRouter( [ { @@ -2215,7 +2223,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 7ef1f634f7..1230997201 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -417,10 +417,9 @@ export function Navigate({ ); let jsonPath = JSON.stringify(path); - React.useEffect( - () => navigate(JSON.parse(jsonPath), { replace, state, relative }), - [navigate, jsonPath, relative, replace, state] - ); + React.useEffect(() => { + navigate(JSON.parse(jsonPath), { replace, state, relative }); + }, [navigate, jsonPath, relative, replace, state]); return null; } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index cf4ebbf7ad..af7c68c6d5 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1709,7 +1709,7 @@ export interface SubmitFunction { * submitting arbitrary data without a backing `
`. */ options?: SubmitOptions - ): void; + ): Promise; } /** @@ -1720,7 +1720,7 @@ export interface FetcherSubmitFunction { target: SubmitTarget, // Fetchers cannot replace or set state because they are not navigation events options?: Omit - ): void; + ): Promise; } function validateClientSideSubmission() { @@ -1745,7 +1745,7 @@ export function useSubmit(): SubmitFunction { let currentRouteId = useRouteId(); return React.useCallback( - (target, options = {}) => { + async (target, options = {}) => { validateClientSideSubmission(); let { action, method, encType, formData, body } = getFormSubmissionInfo( @@ -1755,7 +1755,7 @@ export function useSubmit(): SubmitFunction { if (options.navigate === false) { let key = options.fetcherKey || getUniqueFetcherId(); - router.fetch(key, currentRouteId, options.action || action, { + await router.fetch(key, currentRouteId, options.action || action, { preventScrollReset: options.preventScrollReset, formData, body, @@ -1764,7 +1764,7 @@ export function useSubmit(): SubmitFunction { unstable_flushSync: options.unstable_flushSync, }); } else { - router.navigate(options.action || action, { + await router.navigate(options.action || action, { preventScrollReset: options.preventScrollReset, formData, body, @@ -1839,7 +1839,10 @@ export type FetcherWithComponents = Fetcher & { FetcherFormProps & React.RefAttributes >; submit: FetcherSubmitFunction; - load: (href: string, opts?: { unstable_flushSync?: boolean }) => void; + load: ( + href: string, + opts?: { unstable_flushSync?: boolean } + ) => Promise; }; // TODO: (v7) Change the useFetcher generic default from `any` to `unknown` @@ -1889,17 +1892,17 @@ export function useFetcher({ // Fetcher additions let load = React.useCallback( - (href: string, opts?: { unstable_flushSync?: boolean }) => { + async (href: string, opts?: { unstable_flushSync?: boolean }) => { invariant(routeId, "No routeId available for fetcher.load()"); - router.fetch(fetcherKey, routeId, href, opts); + await router.fetch(fetcherKey, routeId, href, opts); }, [fetcherKey, routeId, router] ); let submitImpl = useSubmit(); let submit = React.useCallback( - (target, opts) => { - submitImpl(target, { + async (target, opts) => { + await submitImpl(target, { ...opts, navigate: false, fetcherKey, diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index c367614314..5b76f77f3a 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -156,8 +156,8 @@ export function useMatch< * @category Types */ export interface NavigateFunction { - (to: To, options?: NavigateOptions): void; - (delta: number): void; + (to: To, options?: NavigateOptions): void | Promise; + (delta: number): void | Promise; } const navigateEffectWarning = @@ -917,10 +917,12 @@ export function useRevalidator() { let state = useDataRouterState(DataRouterStateHook.UseRevalidator); return React.useMemo( () => ({ - revalidate: dataRouterContext.router.revalidate, + async revalidate() { + await dataRouterContext.router.revalidate(); + }, state: state.revalidation, }), - [dataRouterContext.router.revalidate, state.revalidation] + [dataRouterContext.router, state.revalidation] ); } @@ -1109,7 +1111,7 @@ function useNavigateStable(): NavigateFunction { }); let navigate: NavigateFunction = React.useCallback( - (to: To | number, options: NavigateOptions = {}) => { + async (to: To | number, options: NavigateOptions = {}) => { warning(activeRef.current, navigateEffectWarning); // Short circuit here since if this happens on first render the navigate @@ -1119,7 +1121,7 @@ function useNavigateStable(): NavigateFunction { if (typeof to === "number") { router.navigate(to); } else { - router.navigate(to, { fromRouteId: id, ...options }); + await router.navigate(to, { fromRouteId: id, ...options }); } }, [router, id] diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 3af41f84c2..cf6889487c 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -168,7 +168,7 @@ export interface Router { routeId: string, href: string | null, opts?: RouterFetchOptions - ): void; + ): Promise; /** * @internal @@ -176,7 +176,7 @@ export interface Router { * * Trigger a revalidation of all current route loaders and fetcher loads */ - revalidate(): void; + revalidate(): Promise; /** * @internal @@ -967,6 +967,9 @@ export function createRouter(init: RouterInit): Router { // a POP navigation that was blocked by the user without touching router state let ignoreNextHistoryUpdate = false; + let pendingRevalidationDfd: ReturnType> | null = + null; + // Initialize the router, all side effects should be kicked off from here. // Implemented as a Fluent API for ease of: // let router = createRouter(init).initialize(); @@ -1268,6 +1271,8 @@ export function createRouter(init: RouterInit): Router { pendingViewTransitionEnabled = false; isUninterruptedRevalidation = false; isRevalidationRequired = false; + pendingRevalidationDfd?.resolve(); + pendingRevalidationDfd = null; cancelledDeferredRoutes = []; cancelledFetcherLoads = []; } @@ -1277,7 +1282,7 @@ export function createRouter(init: RouterInit): Router { async function navigate( to: number | To | null, opts?: RouterNavigateOptions - ): Promise { + ) { if (typeof to === "number") { init.history.go(to); return; @@ -1370,7 +1375,7 @@ export function createRouter(init: RouterInit): Router { return; } - return await startNavigation(historyAction, nextLocation, { + await startNavigation(historyAction, nextLocation, { submission, // Send through the formData serialization error if we have one so we can // render at the right error boundary after we match routes @@ -1386,13 +1391,30 @@ export function createRouter(init: RouterInit): Router { // is interrupted by a navigation, allow this to "succeed" by calling all // loaders during the next loader round function revalidate() { + // We can't just return the promise from `startNavigation` because that + // navigation may be interrupted and our revalidation wouldn't be finished + // until the _next_ navigation completes. Instead we just track via a + // deferred and resolve it the next time we run through `completeNavigation` + // This is different than navigations which will settle if interrupted + // because the navigation to a specific location is no longer relevant. + // Revalidations are location-independent and will settle whenever we land + // on our final location + if (!pendingRevalidationDfd) { + pendingRevalidationDfd = createDeferred(); + } + interruptActiveLoads(); updateState({ revalidation: "loading" }); + // Capture this here for the edge-case that we have a fully synchronous + // startNavigation which would resolve and null out pendingRevalidationDfd + // before we return from this function + let promise = pendingRevalidationDfd.promise; + // If we're currently submitting an action, we don't need to start a new // navigation, we'll just let the follow up loader execution call all loaders if (state.navigation.state === "submitting") { - return; + return promise; } // If we're currently in an idle state, start a new navigation for the current @@ -1402,7 +1424,7 @@ export function createRouter(init: RouterInit): Router { startNavigation(state.historyAction, state.location, { startUninterruptedRevalidation: true, }); - return; + return promise; } // Otherwise, if we're currently in a loading state, just start a new @@ -1413,6 +1435,7 @@ export function createRouter(init: RouterInit): Router { state.navigation.location, { overrideNavigation: state.navigation } ); + return promise; } // Start a navigation to the given action/location. Can optionally provide a @@ -1898,7 +1921,7 @@ export function createRouter(init: RouterInit): Router { } // Trigger a fetcher load/submit for the given fetcher key - function fetch( + async function fetch( key: string, routeId: string, href: string | null, @@ -1955,7 +1978,7 @@ export function createRouter(init: RouterInit): Router { pendingPreventScrollReset = (opts && opts.preventScrollReset) === true; if (submission && isMutationMethod(submission.formMethod)) { - handleFetcherAction( + await handleFetcherAction( key, routeId, path, @@ -1970,7 +1993,7 @@ export function createRouter(init: RouterInit): Router { // Store off the match so we can call it's shouldRevalidate on subsequent // revalidations fetchLoadMatches.set(key, { routeId, path }); - handleFetcherLoader( + await handleFetcherLoader( key, routeId, path, @@ -5239,4 +5262,29 @@ function persistAppliedTransitions( } } +export function createDeferred() { + let resolve: (val?: any) => Promise; + let reject: (error?: Error) => Promise; + let promise = new Promise((res, rej) => { + resolve = async (val: T) => { + res(val); + try { + await promise; + } catch (e) {} + }; + reject = async (error?: Error) => { + rej(error); + try { + await promise; + } catch (e) {} + }; + }); + return { + promise, + //@ts-ignore + resolve, + //@ts-ignore + reject, + }; +} //#endregion