From 577c106b40c6ee57340d8af156d579aee71faab6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Mar 2023 10:21:39 -0400 Subject: [PATCH 01/21] Initial work on stable useRouterNavigate --- packages/react-router-dom/dom.ts | 7 +- packages/react-router-dom/index.tsx | 1 + packages/react-router-native/index.tsx | 1 + .../react-router/__tests__/navigate-test.tsx | 1112 +++++++++++++++++ packages/react-router/index.ts | 4 +- packages/react-router/lib/components.tsx | 2 +- packages/react-router/lib/context.ts | 3 +- packages/react-router/lib/hooks.tsx | 39 +- packages/router/router.ts | 34 +- 9 files changed, 1187 insertions(+), 16 deletions(-) diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index 6a01100aff..6bb6f402ff 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -1,5 +1,8 @@ -import type { FormEncType, HTMLFormMethod } from "@remix-run/router"; -import type { RelativeRoutingType } from "react-router"; +import type { + FormEncType, + HTMLFormMethod, + RelativeRoutingType, +} from "@remix-run/router"; export const defaultMethod: HTMLFormMethod = "get"; const defaultEncType: FormEncType = "application/x-www-form-urlencoded"; diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 09fa1f4146..32ef667875 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -169,6 +169,7 @@ export { useRevalidator, useRouteError, useRouteLoaderData, + useRouterNavigate, useRoutes, } from "react-router"; diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index d3a890d6d8..29c20502cf 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -109,6 +109,7 @@ export { useRevalidator, useRouteError, useRouteLoaderData, + useRouterNavigate, useRoutes, } from "react-router"; diff --git a/packages/react-router/__tests__/navigate-test.tsx b/packages/react-router/__tests__/navigate-test.tsx index cc39aaa3cb..579c466b89 100644 --- a/packages/react-router/__tests__/navigate-test.tsx +++ b/packages/react-router/__tests__/navigate-test.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import * as TestRenderer from "react-test-renderer"; +import type { RelativeRoutingType, To } from "react-router"; import { MemoryRouter, Navigate, @@ -8,7 +9,10 @@ import { Route, RouterProvider, createMemoryRouter, + createRoutesFromElements, useLocation, + useNavigate, + useRouterNavigate, } from "react-router"; import { prettyDOM, render, screen, waitFor } from "@testing-library/react"; @@ -472,6 +476,1114 @@ describe("", () => { }); }); +function UseNavigateWrapper({ + to, + relative, +}: { + to: To; + relative: RelativeRoutingType; +}) { + let navigate = useNavigate(); + React.useEffect(() => { + navigate(to, { relative }); + }); + return null; +} + +describe("useNavigate", () => { + describe("with an absolute href", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } /> + About} /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + }); + + describe("with a relative href (relative=route)", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } + /> + About} /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + About} /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + } + /> + + About} /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + }> + }> + }> + } + /> + + + + + About} /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + }> + }> + }> + } + /> + + + + + About} /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles parent navigation from inside multiple pathless layout routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Home

+ + + } + > + }> + }> + }> + +

Page

+ + + } + /> +
+
+
+
+ About} /> +
+
+ ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Home +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } /> + Destination} /> + + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + }); + + describe("with a relative href (relative=path)", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + } + /> + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + + } + /> + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + }> + } + /> + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + + }> + }> + }> + } + /> + + + + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + Contacts} /> + }> + }> + }> + } + /> + + + + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } + /> + Destination} /> + + + + + ); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + + it("preserves search params and hash", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } /> + + } + /> + + + ); + }); + + function Contacts() { + let { search, hash } = useLocation(); + return ( + <> +

Contacts

+

+ {search} + {hash} +

+ + ); + } + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Contacts +

, +

+ ?foo=bar + #hash +

, + ] + `); + }); + }); + + it("is not stable across location changes", () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component: () => ( + <> + + + + ), + children: [ + { + path: "home", + element:

Home

, + }, + { + path: "about", + element:

About

, + }, + ], + }, + ], + { initialEntries: ["/home"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + function NavBar() { + let count = React.useRef(0); + let navigate = useNavigate(); + console.log("rendering NavBar"); + React.useEffect(() => { + count.current++; + }, [navigate]); + return

{count.current}

; + } + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 0 +

, +

+ Home +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/about"); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 1 +

, +

+ About +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/home"); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 2 +

, +

+ Home +

, + ] + `); + }); +}); + +function UseRouterNavigateWrapper({ + to, + relative, +}: { + to: To; + relative?: RelativeRoutingType; +}) { + let navigate = useRouterNavigate(); + React.useEffect(() => { + navigate(to, { relative }); + }); + return null; +} + +describe("useRouterNavigate", () => { + describe("with an absolute href", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } + /> + About} /> + + ), + { initialEntries: ["/home"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + }); + + describe("with a relative href (relative=route)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } + /> + About} /> + + ), + { initialEntries: ["/home"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + } + /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + } + /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + }> + }> + }> + } + /> + + + + + About} /> + + ), + { initialEntries: ["/home"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + }> + }> + }> + } + /> + + + + + About} /> + + ), + { initialEntries: ["/home/page"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles parent navigation from inside multiple pathless layout routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + +

Home

+ + + } + > + }> + }> + }> + +

Page

+ + + } + /> +
+
+
+
+ About} /> + + ), + { initialEntries: ["/home/page"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Home +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } /> + Destination} /> + + + + ), + { initialEntries: ["/layout/thing"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + }); + + describe("with a relative href (relative=path)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + }> + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + + }> + }> + }> + + } + /> + + + + + + ), + { initialEntries: ["/contacts/1"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + Contacts} /> + }> + }> + }> + + } + /> + + + + + + ), + { initialEntries: ["/contacts/1"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + + } + /> + Destination} /> + + + + ), + { initialEntries: ["/layout/thing"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + + it("preserves search params and hash", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } /> + + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + function Contacts() { + let { search, hash } = useLocation(); + return ( + <> +

Contacts

+

+ {search} + {hash} +

+ + ); + } + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Contacts +

, +

+ ?foo=bar + #hash +

, + ] + `); + }); + }); + + it("is stable across location changes", () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component: () => ( + <> + + + + ), + children: [ + { + path: "home", + element:

Home

, + }, + { + path: "about", + element:

About

, + }, + ], + }, + ], + { initialEntries: ["/home"] } + ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + function NavBar() { + let count = React.useRef(0); + let navigate = useRouterNavigate(); + React.useEffect(() => { + count.current++; + }, [navigate]); + return

{count.current}

; + } + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 0 +

, +

+ Home +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/about"); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 1 +

, +

+ About +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/home"); + }); + + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 1 +

, +

+ Home +

, + ] + `); + }); +}); + function getHtml(container: HTMLElement) { return prettyDOM(container, undefined, { highlight: false, diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 992e0f556e..63c2f7e3c5 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -17,6 +17,7 @@ import type { PathMatch, PathPattern, RedirectFunction, + RelativeRoutingType, Router as RemixRouter, ShouldRevalidateFunction, To, @@ -76,7 +77,6 @@ import type { NonIndexRouteObject, RouteMatch, RouteObject, - RelativeRoutingType, } from "./lib/context"; import { DataRouterContext, @@ -108,6 +108,7 @@ import { useRevalidator, useRouteError, useRouteLoaderData, + useRouterNavigate, } from "./lib/hooks"; // Exported for backwards compatibility, but not being used internally anymore @@ -205,6 +206,7 @@ export { useRevalidator, useRouteError, useRouteLoaderData, + useRouterNavigate, useRoutes, }; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index efa75820be..7cdd4c083b 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -8,6 +8,7 @@ import type { RouterState, To, LazyRouteFunction, + RelativeRoutingType, } from "@remix-run/router"; import { Action as NavigationType, @@ -27,7 +28,6 @@ import type { RouteObject, Navigator, NonIndexRouteObject, - RelativeRoutingType, } from "./context"; import { LocationContext, diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index da1340c7aa..d8660ebd1e 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -5,6 +5,7 @@ import type { AgnosticNonIndexRouteObject, History, Location, + RelativeRoutingType, Router, StaticHandlerContext, To, @@ -88,8 +89,6 @@ if (__DEV__) { AwaitContext.displayName = "Await"; } -export type RelativeRoutingType = "route" | "path"; - export interface NavigateOptions { replace?: boolean; state?: any; diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index c054436e63..41660c85d5 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -8,6 +8,7 @@ import type { Path, PathMatch, PathPattern, + RelativeRoutingType, Router as RemixRouter, To, } from "@remix-run/router"; @@ -30,7 +31,6 @@ import type { RouteMatch, RouteObject, DataRouteMatch, - RelativeRoutingType, } from "./context"; import { DataRouterContext, @@ -682,6 +682,7 @@ export function _renderMatches( enum DataRouterHook { UseBlocker = "useBlocker", UseRevalidator = "useRevalidator", + UseRouterNavigate = "useRouterNavigate", } enum DataRouterStateHook { @@ -693,6 +694,7 @@ enum DataRouterStateHook { UseRouteLoaderData = "useRouteLoaderData", UseMatches = "useMatches", UseRevalidator = "useRevalidator", + UseRouterNavigate = "useRouterNavigate", } function getDataRouterConsoleError( @@ -885,6 +887,41 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker { return state.blockers.get(blockerKey) || blocker; } +export function useRouterNavigate(): NavigateFunction { + let { router } = useDataRouterContext(DataRouterHook.UseRouterNavigate); + let id = useCurrentRouteId(DataRouterStateHook.UseRouterNavigate); + + let activeRef = React.useRef(false); + React.useEffect(() => { + activeRef.current = true; + }); + + let navigate: NavigateFunction = React.useCallback( + (to: To | number, options: NavigateOptions = {}) => { + // TODO: Do we still want this in a stable version? + warning( + activeRef.current, + `You should call navigate() in a React.useEffect(), not when ` + + `your component is first rendered.` + ); + + if (!activeRef.current) return; + + if (typeof to === "number") { + router.navigate(to); + } else { + router.navigate(to, { + fromRouteId: id, + ...options, + }); + } + }, + [router, id] + ); + + return navigate; +} + const alreadyWarned: Record = {}; function warningOnce(key: string, cond: boolean, message: string) { diff --git a/packages/router/router.ts b/packages/router/router.ts index 3427af0149..14d05f29c7 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -415,22 +415,25 @@ export interface GetScrollPositionFunction { (): number; } -/** - * Options for a navigate() call for a Link navigation - */ -type LinkNavigateOptions = { +export type RelativeRoutingType = "route" | "path"; + +type BaseNavigateOptions = { replace?: boolean; state?: any; preventScrollReset?: boolean; + relative?: RelativeRoutingType; + fromRouteId?: string; }; +/** + * Options for a navigate() call for a Link navigation + */ +type LinkNavigateOptions = BaseNavigateOptions; + /** * Options for a navigate() call for a Form navigation */ -type SubmissionNavigateOptions = { - replace?: boolean; - state?: any; - preventScrollReset?: boolean; +type SubmissionNavigateOptions = BaseNavigateOptions & { formMethod?: HTMLFormMethod; formEncType?: FormEncType; formData: FormData; @@ -1049,6 +1052,8 @@ export function createRouter(init: RouterInit): Router { let { path, submission, error } = normalizeNavigateOptions( to, + state.location, + state.matches, future, opts ); @@ -1633,6 +1638,8 @@ export function createRouter(init: RouterInit): Router { let { path, submission } = normalizeNavigateOptions( href, + state.location, + state.matches, future, opts, true @@ -3038,6 +3045,8 @@ function isSubmissionNavigation( // URLSearchParams so they behave identically to links with query params function normalizeNavigateOptions( to: To, + location: Location, + matches: AgnosticDataRouteMatch[], future: FutureConfig, opts?: RouterNavigateOptions, isFetcher = false @@ -3046,7 +3055,14 @@ function normalizeNavigateOptions( submission?: Submission; error?: ErrorResponse; } { - let path = typeof to === "string" ? to : createPath(to); + let path = createPath( + resolveTo( + to, + getPathContributingMatches(matches).map((match) => match.pathnameBase), + location.pathname, + opts?.relative === "path" + ) + ); // Return location verbatim on non-submission navigations if (!opts || !isSubmissionNavigation(opts)) { From eee2b74340f090387b0573de75de9d45c309109a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Mar 2023 11:35:39 -0400 Subject: [PATCH 02/21] Adjust tests --- .../react-router/__tests__/navigate-test.tsx | 1123 +------------- .../__tests__/useNavigate-test.tsx | 1315 ++++++++++++++++- 2 files changed, 1310 insertions(+), 1128 deletions(-) diff --git a/packages/react-router/__tests__/navigate-test.tsx b/packages/react-router/__tests__/navigate-test.tsx index 579c466b89..c33aeaf1f8 100644 --- a/packages/react-router/__tests__/navigate-test.tsx +++ b/packages/react-router/__tests__/navigate-test.tsx @@ -31,6 +31,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

About @@ -53,6 +54,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

About @@ -75,6 +77,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

About @@ -97,6 +100,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

About @@ -125,6 +129,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

About @@ -156,6 +161,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

About @@ -200,6 +206,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Home @@ -225,6 +232,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Destination @@ -250,6 +258,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Contacts @@ -272,6 +281,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Contacts @@ -297,6 +307,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Contacts @@ -328,6 +339,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Contacts @@ -359,6 +371,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Contacts @@ -387,6 +400,7 @@ describe("", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

Destination @@ -423,6 +437,7 @@ describe("", () => { ); } + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [

@@ -476,1114 +491,6 @@ describe("", () => { }); }); -function UseNavigateWrapper({ - to, - relative, -}: { - to: To; - relative: RelativeRoutingType; -}) { - let navigate = useNavigate(); - React.useEffect(() => { - navigate(to, { relative }); - }); - return null; -} - -describe("useNavigate", () => { - describe("with an absolute href", () => { - it("navigates to the correct URL", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - } /> - About

} /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - }); - - describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - } - /> - About

} /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from an index routes", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - - } /> - - About

} /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside a pathless layout route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - }> - } - /> - - About

} /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - - }> - }> - }> - } - /> - - - - - About

} /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - }> - }> - }> - }> - } - /> - - - - - About

} /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles parent navigation from inside multiple pathless layout routes", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - -

Home

- - - } - > - }> - }> - }> - -

Page

- - - } - /> -
-
-
-
- About

} /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Home -

- `); - }); - - it("handles relative navigation from nested index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - } /> - Destination

} /> - - - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); - }); - }); - - describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts

} /> - } - /> - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from an index routes", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts

} /> - - } - /> - - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from inside a pathless layout route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts

} /> - }> - } - /> - - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts

} /> - - }> - }> - }> - } - /> - - - - - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - }> - Contacts

} /> - }> - }> - }> - } - /> - - - - - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles relative navigation from nested index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - } - /> - Destination

} /> - - - - - ); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); - }); - - it("preserves search params and hash", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - } /> - - } - /> - - - ); - }); - - function Contacts() { - let { search, hash } = useLocation(); - return ( - <> -

Contacts

-

- {search} - {hash} -

- - ); - } - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- Contacts -

, -

- ?foo=bar - #hash -

, - ] - `); - }); - }); - - it("is not stable across location changes", () => { - let router = createMemoryRouter( - [ - { - path: "/", - Component: () => ( - <> - - - - ), - children: [ - { - path: "home", - element:

Home

, - }, - { - path: "about", - element:

About

, - }, - ], - }, - ], - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - function NavBar() { - let count = React.useRef(0); - let navigate = useNavigate(); - console.log("rendering NavBar"); - React.useEffect(() => { - count.current++; - }, [navigate]); - return

{count.current}

; - } - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- 0 -

, -

- Home -

, - ] - `); - - TestRenderer.act(() => { - router.navigate("/about"); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- 1 -

, -

- About -

, - ] - `); - - TestRenderer.act(() => { - router.navigate("/home"); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- 2 -

, -

- Home -

, - ] - `); - }); -}); - -function UseRouterNavigateWrapper({ - to, - relative, -}: { - to: To; - relative?: RelativeRoutingType; -}) { - let navigate = useRouterNavigate(); - React.useEffect(() => { - navigate(to, { relative }); - }); - return null; -} - -describe("useRouterNavigate", () => { - describe("with an absolute href", () => { - it("navigates to the correct URL", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - } - /> - About

} /> - - ), - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - }); - - describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - } - /> - About} /> - - ), - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from an index routes", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - - } - /> - - About} /> - - ), - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside a pathless layout route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - }> - } - /> - - About} /> - - ), - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - - }> - }> - }> - } - /> - - - - - About} /> - - ), - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - }> - }> - }> - }> - } - /> - - - - - About} /> - - ), - { initialEntries: ["/home/page"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles parent navigation from inside multiple pathless layout routes", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - -

Home

- - - } - > - }> - }> - }> - -

Page

- - - } - /> -
-
-
-
- About} /> - - ), - { initialEntries: ["/home/page"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Home -

- `); - }); - - it("handles relative navigation from nested index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - } /> - Destination} /> - - - - ), - { initialEntries: ["/layout/thing"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); - }); - }); - - describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - } - /> - - ), - { initialEntries: ["/contacts/1"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from an index routes", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - - } - /> - - - ), - { initialEntries: ["/contacts/1"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from inside a pathless layout route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - }> - } - /> - - - ), - { initialEntries: ["/contacts/1"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - - }> - }> - }> - - } - /> - - - - - - ), - { initialEntries: ["/contacts/1"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - }> - Contacts} /> - }> - }> - }> - - } - /> - - - - - - ), - { initialEntries: ["/contacts/1"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles relative navigation from nested index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - - } - /> - Destination} /> - - - - ), - { initialEntries: ["/layout/thing"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); - }); - - it("preserves search params and hash", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - } /> - - } - /> - - ), - { initialEntries: ["/contacts/1"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - function Contacts() { - let { search, hash } = useLocation(); - return ( - <> -

Contacts

-

- {search} - {hash} -

- - ); - } - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- Contacts -

, -

- ?foo=bar - #hash -

, - ] - `); - }); - }); - - it("is stable across location changes", () => { - let router = createMemoryRouter( - [ - { - path: "/", - Component: () => ( - <> - - - - ), - children: [ - { - path: "home", - element:

Home

, - }, - { - path: "about", - element:

About

, - }, - ], - }, - ], - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - function NavBar() { - let count = React.useRef(0); - let navigate = useRouterNavigate(); - React.useEffect(() => { - count.current++; - }, [navigate]); - return

{count.current}

; - } - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- 0 -

, -

- Home -

, - ] - `); - - TestRenderer.act(() => { - router.navigate("/about"); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- 1 -

, -

- About -

, - ] - `); - - TestRenderer.act(() => { - router.navigate("/home"); - }); - - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ -

- 1 -

, -

- Home -

, - ] - `); - }); -}); - function getHtml(container: HTMLElement) { return prettyDOM(container, undefined, { highlight: false, diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx index 1d423af0db..227fde1e3f 100644 --- a/packages/react-router/__tests__/useNavigate-test.tsx +++ b/packages/react-router/__tests__/useNavigate-test.tsx @@ -1,11 +1,17 @@ import * as React from "react"; import * as TestRenderer from "react-test-renderer"; +import type { RelativeRoutingType, To } from "react-router"; import { MemoryRouter, Routes, Route, useNavigate, useLocation, + createMemoryRouter, + createRoutesFromElements, + Outlet, + RouterProvider, + useRouterNavigate, } from "react-router"; describe("useNavigate", () => { @@ -37,12 +43,11 @@ describe("useNavigate", () => { ); }); + // @ts-expect-error let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - TestRenderer.act(() => { - button.props.onClick(); - }); - + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

About @@ -74,6 +79,7 @@ describe("useNavigate", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [

@@ -87,12 +93,11 @@ describe("useNavigate", () => { ] `); + // @ts-expect-error let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - TestRenderer.act(() => { - button.props.onClick(); - }); - + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [

@@ -131,6 +136,7 @@ describe("useNavigate", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [

@@ -144,12 +150,11 @@ describe("useNavigate", () => { ] `); + // @ts-expect-error let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - TestRenderer.act(() => { - button.props.onClick(); - }); - + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [

@@ -188,6 +193,7 @@ describe("useNavigate", () => { ); }); + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [

@@ -201,12 +207,11 @@ describe("useNavigate", () => { ] `); + // @ts-expect-error let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - TestRenderer.act(() => { - button.props.onClick(); - }); - + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [

@@ -330,12 +335,11 @@ describe("useNavigate", () => { ); }); + // @ts-expect-error let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - TestRenderer.act(() => { - button.props.onClick(); - }); - + // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(`

location.state: @@ -345,3 +349,1274 @@ describe("useNavigate", () => { }); }); }); + +function UseNavigateButton({ + to, + relative, +}: { + to: To; + relative?: RelativeRoutingType; +}) { + let navigate = useNavigate(); + return ; +} + +describe("useNavigate (mimicing tests)", () => { + describe("with an absolute href", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } /> + About

} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + }); + + describe("with a relative href (relative=route)", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } + /> + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + } + /> + + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + }> + }> + }> + } + /> + + + + + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + }> + }> + }> + } + /> + + + + + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles parent navigation from inside multiple pathless layout routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Home

+ + + } + > + }> + }> + }> + +

Page

+ + + } + /> +
+
+
+
+ About} /> +
+
+ ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Home +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } /> + Destination} /> + + + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + }); + + describe("with a relative href (relative=path)", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + } + /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + + } + /> + + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + }> + } + /> + + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + + }> + }> + }> + } + /> + + + + + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + Contacts} /> + }> + }> + }> + } + /> + + + + + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } + /> + Destination} /> + + + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + + it("preserves search params and hash", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } /> + + } + /> + + + ); + }); + + function Contacts() { + let { search, hash } = useLocation(); + return ( + <> +

Contacts

+

+ {search} + {hash} +

+ + ); + } + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Contacts +

, +

+ ?foo=bar + #hash +

, + ] + `); + }); + }); + + it("is not stable across location changes", () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component: () => ( + <> + + + + ), + children: [ + { + path: "home", + element:

Home

, + }, + { + path: "about", + element:

About

, + }, + ], + }, + ], + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + function NavBar() { + let count = React.useRef(0); + let navigate = useNavigate(); + React.useEffect(() => { + count.current++; + }, [navigate]); + return

{count.current}

; + } + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 0 +

, +

+ Home +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/about"); + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 1 +

, +

+ About +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/home"); + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 2 +

, +

+ Home +

, + ] + `); + }); +}); + +function UseRouterNavigateButton({ + to, + relative, +}: { + to: To; + relative?: RelativeRoutingType; +}) { + let navigate = useRouterNavigate(); + return ; +} + +describe("useRouterNavigate (mimicing tests)", () => { + describe("with an absolute href", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } + /> + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + }); + + describe("with a relative href (relative=route)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } + /> + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + } + /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + } + /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + }> + }> + }> + } + /> + + + + + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + }> + }> + }> + } + /> + + + + + About} /> + + ), + { initialEntries: ["/home/page"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles parent navigation from inside multiple pathless layout routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + +

Home

+ + + } + > + }> + }> + }> + +

Page

+ + + } + /> +
+
+
+
+ About} /> + + ), + { initialEntries: ["/home/page"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Home +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } /> + Destination} /> + + + + ), + { initialEntries: ["/layout/thing"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + }); + + describe("with a relative href (relative=path)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + }> + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + + }> + }> + }> + + } + /> + + + + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + Contacts} /> + }> + }> + }> + + } + /> + + + + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + + } + /> + Destination} /> + + + + ), + { initialEntries: ["/layout/thing"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + + it("preserves search params and hash", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } /> + + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); + + function Contacts() { + let { search, hash } = useLocation(); + return ( + <> +

Contacts

+

+ {search} + {hash} +

+ + ); + } + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Contacts +

, +

+ ?foo=bar + #hash +

, + ] + `); + }); + }); + + it("is stable across location changes", () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component: () => ( + <> + + + + ), + children: [ + { + path: "home", + element:

Home

, + }, + { + path: "about", + element:

About

, + }, + ], + }, + ], + { initialEntries: ["/home"] } + ); + + function NavBar() { + let count = React.useRef(0); + let navigate = useRouterNavigate(); + React.useEffect(() => { + count.current++; + }, [navigate]); + return

{count.current}

; + } + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 0 +

, +

+ Home +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/about"); + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 1 +

, +

+ About +

, + ] + `); + + TestRenderer.act(() => { + router.navigate("/home"); + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ 1 +

, +

+ Home +

, + ] + `); + }); +}); From dc590e2373f3ae672037b6a88aae3ef791ca29a9 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Mar 2023 11:25:56 -0400 Subject: [PATCH 03/21] router.resolvePath --- .../__tests__/data-browser-router-test.tsx | 75 +++++++ packages/react-router-dom/index.tsx | 8 +- .../__tests__/useNavigate-test.tsx | 152 ++++++++++--- packages/router/__tests__/router-test.ts | 211 ++++++++++++++++++ packages/router/router.ts | 63 ++++++ 5 files changed, 475 insertions(+), 34 deletions(-) diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index b50aee31b7..79054174de 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -33,6 +33,7 @@ import { defer, useLocation, useMatches, + useSearchParams, createRoutesFromElements, } from "react-router-dom"; @@ -4411,6 +4412,80 @@ function testDomRouter( " `); }); + + it("useFetcher is stable across across location changes", async () => { + let router = createBrowserRouter( + [ + { + path: "/", + Component() { + const [, setSearchParams] = useSearchParams(); + let [count, setCount] = React.useState(0); + let fetcherCount = React.useRef(0); + let fetcher = useFetcher(); + React.useEffect(() => { + fetcherCount.current++; + }, [fetcher.submit]); + return ( + <> + +

+ {count}-{fetcherCount.current} +

+ + ); + }, + }, + ], + { + window: getWindow("/"), + } + ); + + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ +

+ 0 + - + 0 +

+
" + `); + + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + await waitFor(() => screen.getByText(/5-1/)); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ +

+ 5 + - + 1 +

+
" + `); + }); }); describe("errors", () => { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 32ef667875..319eb4fa90 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1022,10 +1022,10 @@ export function useFormAction( } if ((!action || action === ".") && match.route.index) { - path.search = path.search - ? path.search.replace(/^\?/, "?index&") - : "?index"; - } + path.search = path.search + ? path.search.replace(/^\?/, "?index&") + : "?index"; + } // If we're operating within a basename, prepend it to the pathname prior // to creating the form action. If this is a root navigation, then just use diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx index 227fde1e3f..4c4b85ffb0 100644 --- a/packages/react-router/__tests__/useNavigate-test.tsx +++ b/packages/react-router/__tests__/useNavigate-test.tsx @@ -908,47 +908,93 @@ describe("useNavigate (mimicing tests)", () => { React.useEffect(() => { count.current++; }, [navigate]); - return

{count.current}

; + return ( + + ); } // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ -

- 0 -

, + ,

Home

, ] `); + // @ts-expect-error + let buttons = renderer.root.findAllByType("button"); TestRenderer.act(() => { - router.navigate("/about"); + buttons[1].props.onClick(); }); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ -

- 1 -

, + ,

About

, ] `); + // @ts-expect-error + buttons = renderer.root.findAllByType("button"); TestRenderer.act(() => { - router.navigate("/home"); + buttons[0].props.onClick(); }); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ -

- 2 -

, + ,

Home

, @@ -1535,7 +1581,7 @@ describe("useRouterNavigate (mimicing tests)", () => { }); }); - it("is stable across location changes", () => { + it("is not stable across location changes", () => { let router = createMemoryRouter( [ { @@ -1561,58 +1607,104 @@ describe("useRouterNavigate (mimicing tests)", () => { { initialEntries: ["/home"] } ); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + function NavBar() { let count = React.useRef(0); let navigate = useRouterNavigate(); React.useEffect(() => { count.current++; }, [navigate]); - return

{count.current}

; + return ( + + ); } - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ -

- 0 -

, + ,

Home

, ] `); + // @ts-expect-error + let buttons = renderer.root.findAllByType("button"); TestRenderer.act(() => { - router.navigate("/about"); + buttons[1].props.onClick(); }); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ -

- 1 -

, + ,

About

, ] `); + // @ts-expect-error + buttons = renderer.root.findAllByType("button"); TestRenderer.act(() => { - router.navigate("/home"); + buttons[0].props.onClick(); }); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ -

- 1 -

, + ,

Home

, diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index e4c4ef412f..eccbcaadd4 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -15542,4 +15542,215 @@ describe("a router", () => { }); }); }); + + describe.only("router.resolveRoute", () => { + it("static routes", () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + { + id: "activeRoute", + path: "bar", + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }); + expect( + router.resolvePath(undefined, { fromRouteId: "activeRoute" }) + ).toBe("/foo/bar?a=1#hash"); + expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar" + ); + expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar" + ); + }); + + it("layout routes", () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + { + id: "activeRoute", + path: "bar", + children: [ + { + index: true, + }, + ], + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }); + expect( + router.resolvePath(undefined, { fromRouteId: "activeRoute" }) + ).toBe("/foo/bar?a=1#hash"); + expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar" + ); + expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar" + ); + }); + + it("index routes", () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + { + path: "bar", + children: [ + { + id: "activeRoute", + index: true, + }, + ], + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }); + expect( + router.resolvePath(undefined, { fromRouteId: "activeRoute" }) + ).toBe("/foo/bar?index&a=1#hash"); + expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar?index" + ); + expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar?index" + ); + }); + + it("index routes with a path", () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }); + expect( + router.resolvePath(undefined, { fromRouteId: "activeRoute" }) + ).toBe("/foo/bar?index&a=1#hash"); + expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar?index" + ); + expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar?index" + ); + }); + + it("dynamic routes", () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + { + id: "activeRoute", + path: ":param", + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }); + expect( + router.resolvePath(undefined, { fromRouteId: "activeRoute" }) + ).toBe("/foo/bar?a=1#hash"); + expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar" + ); + expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( + "/foo/bar" + ); + }); + + it("splat routes", () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + { + id: "activeRoute", + path: "*", + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }); + expect( + router.resolvePath(undefined, { fromRouteId: "activeRoute" }) + ).toBe("/foo?a=1#hash"); + expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( + "/foo" + ); + expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( + "/foo" + ); + }); + }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 14d05f29c7..1f726f08b3 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -167,6 +167,16 @@ export interface Router { */ createHref(location: Location | URL): string; + /** + * @internal + * PRIVATE - DO NOT USE + * + * Utility function to resolve a path from a given source route, taking into + * account relative routing and any applicable basename. + * @param location + */ + resolvePath(to: To | undefined, opts?: ResolvePathOpts): string; + /** * @internal * PRIVATE - DO NOT USE @@ -415,6 +425,11 @@ export interface GetScrollPositionFunction { (): number; } +export interface ResolvePathOpts { + fromRouteId?: string; + relative?: RelativeRoutingType; +} + export type RelativeRoutingType = "route" | "path"; type BaseNavigateOptions = { @@ -2457,6 +2472,53 @@ export function createRouter(init: RouterInit): Router { inFlightDataRoutes = newRoutes; } + function resolvePath(to: To | undefined, opts?: ResolvePathOpts) { + let matches = getPathContributingMatches(state.matches); + let path = resolveTo( + to ? to : ".", + matches.map((m) => m.pathnameBase), + state.location.pathname, + opts?.relative === "path" + ); + + if (to == null) { + // Safe to write to these directly here since when `to` is undefined, + // resolveTo(".") will never include a search or hash + path.search = state.location.search; + path.hash = state.location.hash; + } + + // Ensure index routes have the naked ?index param in them + let activeRoute = + opts && opts.fromRouteId && manifest[opts.fromRouteId] + ? manifest[opts.fromRouteId] + : null; + + if ((!to || to === ".") && activeRoute && activeRoute.index) { + let params = new URLSearchParams(path.search); + if ( + !params.has("index") || + params.getAll("index").every((p) => p !== "") + ) { + path.search = path.search + ? path.search.replace(/^\?/, "?index&") + : "?index"; + } + } + + // If we're operating within a basename, prepend it to the pathname. If + // this is a root navigation, then just use the raw basename which allows + // the basename to have full control over the presence of a trailing slash + // on root actions + let basename = router.basename || "/"; + if (basename !== "/") { + path.pathname = + path.pathname === "/" ? basename : joinPaths([basename, path.pathname]); + } + + return createPath(path); + } + router = { get basename() { return init.basename; @@ -2473,6 +2535,7 @@ export function createRouter(init: RouterInit): Router { navigate, fetch, revalidate, + resolvePath, // Passthrough to history-aware createHref used by useHref so we get proper // hash-aware URLs in DOM paths createHref: (to: To) => init.history.createHref(to), From efaa2cd399db820904e19f14b3051e0d534941e8 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 11 Apr 2023 11:00:08 -0400 Subject: [PATCH 04/21] Stub static router resolvePath method --- packages/react-router-dom/server.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index e89006e066..461dfd45d3 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -290,6 +290,12 @@ export function createStaticRouter( revalidate() { throw msg("revalidate"); }, + resolvePath() { + // + // FIXME: TODO - Implement! + // + throw new Error("TODO - Not Implemented yet!"); + }, createHref, encodeLocation, getFetcher() { From 39666e52cb785d15320ea97beca8139ebbcdb250 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 13:05:08 -0400 Subject: [PATCH 05/21] Stabilize useNavigate/useSubmit/fetcher.submit for data routers --- packages/react-router-dom-v5-compat/index.ts | 1 + packages/react-router-dom/dom.ts | 41 +- packages/react-router-dom/index.tsx | 59 +- packages/react-router-dom/server.tsx | 12 +- packages/react-router-native/index.tsx | 2 +- .../__tests__/data-memory-router-test.tsx | 15 +- .../react-router/__tests__/navigate-test.tsx | 4 - .../__tests__/useNavigate-test.tsx | 2265 ++++++++--------- packages/react-router/index.ts | 11 +- packages/react-router/lib/hooks.tsx | 49 +- packages/router/__tests__/router-test.ts | 2 +- packages/router/router.ts | 275 +- 12 files changed, 1382 insertions(+), 1354 deletions(-) diff --git a/packages/react-router-dom-v5-compat/index.ts b/packages/react-router-dom-v5-compat/index.ts index c9c0ee0034..af15ab7a72 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -172,6 +172,7 @@ export { unstable_usePrompt, useRevalidator, useRouteError, + useRouteId, useRouteLoaderData, useSubmit, UNSAFE_DataRouterContext, diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index 6bb6f402ff..0e52d7facb 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -3,6 +3,7 @@ import type { HTMLFormMethod, RelativeRoutingType, } from "@remix-run/router"; +import { stripBasename } from "@remix-run/router"; export const defaultMethod: HTMLFormMethod = "get"; const defaultEncType: FormEncType = "application/x-www-form-urlencoded"; @@ -160,16 +161,16 @@ export function getFormSubmissionInfo( | URLSearchParams | { [name: string]: string } | null, - defaultAction: string, - options: SubmitOptions + options: SubmitOptions, + basename: string ): { - url: URL; + action: string | null; method: string; encType: string; formData: FormData; } { let method: string; - let action: string; + let action: string | null = null; let encType: string; let formData: FormData; @@ -178,8 +179,16 @@ export function getFormSubmissionInfo( options as any ).submissionTrigger; + if (options.action) { + action = options.action; + } else { + // When grabbing the action from the element, it will have had the basename + // prefixed to ensure non-JS scenarios work, so strip it since we'll + // re-prefix in the router + let attr = target.getAttribute("action"); + action = attr ? stripBasename(attr, basename) : null; + } method = options.method || target.getAttribute("method") || defaultMethod; - action = options.action || target.getAttribute("action") || defaultAction; encType = options.encType || target.getAttribute("enctype") || defaultEncType; @@ -203,16 +212,21 @@ export function getFormSubmissionInfo( // ; -} - -describe("useNavigate (mimicing tests)", () => { - describe("with an absolute href", () => { - it("navigates to the correct URL", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - } /> - About} /> - - - ); - }); - - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - }); - describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - } - /> - About} /> - - - ); + describe("when relative navigation is handled via React Context", () => { + describe("with an absolute href", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } + /> + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); }); - - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); }); - it("handles upward navigation from an index routes", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - - } /> - - About} /> - - - ); - }); - - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside a pathless layout route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - }> + describe("with a relative href (relative=route)", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + } /> - - About} /> - - - ); + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); + it("handles upward navigation from an index routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } /> + + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - + it("handles upward navigation from inside a pathless layout route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + }> + } + /> + + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> }> - } - /> + }> + } + /> + - - About} /> - - - ); + About} /> +
+
+ ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - }> - }> + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> }> }> - } - /> + }> + } + /> + - - About} /> - - - ); + About} /> + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); - - it("handles parent navigation from inside multiple pathless layout routes", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - -

Home

- - - } - > - }> + it("handles parent navigation from inside multiple pathless layout routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + +

Home

+ + + } + > }> }> - -

Page

- - - } - /> + }> + +

Page

+ + + } + /> +
-
- About} /> -
-
- ); + About} /> +
+
+ ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Home +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Home -

- `); - }); - - it("handles relative navigation from nested index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - } /> - Destination} /> + it("handles relative navigation from nested index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } /> + Destination} /> + - - - - ); - }); + + + ); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); - }); - }); - - describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts} /> - } - /> - - - ); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); }); - - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); }); - it("handles upward navigation from an index routes", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts} /> - + describe("with a relative href (relative=path)", () => { + it("navigates to the correct URL", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> } /> - - - - ); - }); + + + ); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - it("handles upward navigation from inside a pathless layout route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts} /> - }> - } - /> - - - - ); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + it("handles upward navigation from an index routes", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + + } + /> + + + + ); + }); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - Contacts} /> - + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> }> + } + /> + + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + Contacts} /> + }> }> - } - /> + }> + + } + /> + - - - - ); - }); +
+
+ ); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - }> - Contacts} /> - }> + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + }> + Contacts} /> }> }> - } - /> + }> + + } + /> + - - - - ); - }); + + + ); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); - it("handles relative navigation from nested index route", () => { - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create( - - - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - } - /> - Destination} /> + it("handles relative navigation from nested index route", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } + /> + Destination} /> + - - - - ); + + + ); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + it("preserves search params and hash", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + } /> + + } + /> + + + ); + }); + + function Contacts() { + let { search, hash } = useLocation(); + return ( + <> +

Contacts

+

+ {search} + {hash} +

+ + ); + } - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Contacts +

, +

+ ?foo=bar + #hash +

, + ] + `); + }); }); - it("preserves search params and hash", () => { + it("is not stable across location changes", () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( - + - } /> + <> + + + } - /> + > + Home} /> + About} /> + ); }); - function Contacts() { - let { search, hash } = useLocation(); + function NavBar() { + let count = React.useRef(0); + let navigate = useNavigate(); + React.useEffect(() => { + count.current++; + }, [navigate]); return ( - <> -

Contacts

-

- {search} - {hash} -

- + ); } - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ + ,

- Contacts + Home

, -

- ?foo=bar - #hash -

, ] `); - }); - }); - - it("is not stable across location changes", () => { - let router = createMemoryRouter( - [ - { - path: "/", - Component: () => ( - <> - - - - ), - children: [ - { - path: "home", - element:

Home

, - }, - { - path: "about", - element:

About

, - }, - ], - }, - ], - { initialEntries: ["/home"] } - ); - - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - function NavBar() { - let count = React.useRef(0); - let navigate = useNavigate(); - React.useEffect(() => { - count.current++; - }, [navigate]); - return ( - - ); - } - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ - , -

- Home -

, - ] - `); - - // @ts-expect-error - let buttons = renderer.root.findAllByType("button"); - TestRenderer.act(() => { - buttons[1].props.onClick(); - }); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ - , -

- About -

, - ] - `); - - // @ts-expect-error - buttons = renderer.root.findAllByType("button"); - TestRenderer.act(() => { - buttons[0].props.onClick(); - }); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ - , -

- Home -

, - ] - `); - }); -}); - -function UseRouterNavigateButton({ - to, - relative, -}: { - to: To; - relative?: RelativeRoutingType; -}) { - let navigate = useRouterNavigate(); - return ; -} - -describe("useRouterNavigate (mimicing tests)", () => { - describe("with an absolute href", () => { - it("navigates to the correct URL", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - } - /> - About} /> - - ), - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; + // @ts-expect-error + let buttons = renderer.root.findAllByType("button"); TestRenderer.act(() => { - renderer = TestRenderer.create(); + buttons[1].props.onClick(); // link to /about }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

+ [ + , +

+ About +

, + ] `); - }); - }); - - describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - } - /> - About} /> - - ), - { initialEntries: ["/home"] } - ); - let renderer: TestRenderer.ReactTestRenderer; + // @ts-expect-error + buttons = renderer.root.findAllByType("button"); TestRenderer.act(() => { - renderer = TestRenderer.create(); + buttons[0].props.onClick(); // link back to /home }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

+ [ + , +

+ Home +

, + ] `); }); + }); - it("handles upward navigation from an index routes", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - - } - /> - - About} /> - - ), - { initialEntries: ["/home"] } - ); + describe("when relative navigation is handled via @remix-run/router", () => { + describe("with an absolute href", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } /> + About} /> + + ), + { initialEntries: ["/home"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); }); - it("handles upward navigation from inside a pathless layout route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - }> + describe("with a relative href (relative=route)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> } + element={} /> - - About} /> - - ), - { initialEntries: ["/home"] } - ); + About} /> + + ), + { initialEntries: ["/home"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + } /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> }> + } + /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> }> - } - /> + }> + } + /> + - - About} /> - - ), - { initialEntries: ["/home"] } - ); + About} /> + + ), + { initialEntries: ["/home"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - }> - }> + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> }> }> - } - /> + }> + } + /> + - - About} /> - - ), - { initialEntries: ["/home/page"] } - ); + About} /> + + ), + { initialEntries: ["/home/page"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- About -

- `); - }); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); - it("handles parent navigation from inside multiple pathless layout routes", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - -

Home

- - - } - > - }> + it("handles parent navigation from inside multiple pathless layout routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + +

Home

+ + + } + > }> }> - -

Page

- - - } - /> + }> + +

Page

+ + + } + /> +
-
- About} /> - - ), - { initialEntries: ["/home/page"] } - ); + About} /> + + ), + { initialEntries: ["/home/page"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Home -

- `); - }); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Home +

+ `); + }); - it("handles relative navigation from nested index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - } /> - Destination} /> + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } /> + Destination} /> + - - - ), - { initialEntries: ["/layout/thing"] } - ); + + ), + { initialEntries: ["/layout/thing"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); }); - }); - describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - } - /> - - ), - { initialEntries: ["/contacts/1"] } - ); + describe("with a relative href (relative=path)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - it("handles upward navigation from an index routes", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - - } - /> - - - ), - { initialEntries: ["/contacts/1"] } - ); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + }> + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - it("handles upward navigation from inside a pathless layout route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - }> - } - /> - - - ), - { initialEntries: ["/contacts/1"] } - ); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); - - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - Contacts} /> - - }> + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + }> }> - - } - /> + }> + } + /> + - - - ), - { initialEntries: ["/contacts/1"] } - ); + + ), + { initialEntries: ["/contacts/1"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - }> - Contacts} /> - }> + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + Contacts} /> }> }> - - } - /> + }> + } + /> + - - - ), - { initialEntries: ["/contacts/1"] } - ); + + ), + { initialEntries: ["/contacts/1"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Contacts -

- `); - }); + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); - it("handles relative navigation from nested index route", () => { - let router = createMemoryRouter( - createRoutesFromElements( - <> - - - {/* redirect /layout/:param/ index routes to /layout/:param/dest */} - - } - /> - Destination} /> + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } + /> + Destination} /> + - - - ), - { initialEntries: ["/layout/thing"] } - ); + + ), + { initialEntries: ["/layout/thing"] } + ); - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + it("preserves search params and hash", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } /> + + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` -

- Destination -

- `); + function Contacts() { + let { search, hash } = useLocation(); + return ( + <> +

Contacts

+

+ {search} + {hash} +

+ + ); + } + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Contacts +

, +

+ ?foo=bar + #hash +

, + ] + `); + }); }); - it("preserves search params and hash", () => { + it("is stable across location changes", () => { let router = createMemoryRouter( - createRoutesFromElements( - <> - } /> - - } - /> - - ), - { initialEntries: ["/contacts/1"] } + [ + { + path: "/", + Component: () => ( + <> + + + + ), + children: [ + { + path: "home", + element:

Home

, + }, + { + path: "about", + element:

About

, + }, + ], + }, + ], + { initialEntries: ["/home"] } ); - function Contacts() { - let { search, hash } = useLocation(); - return ( - <> -

Contacts

-

- {search} - {hash} -

- - ); - } - let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create(); }); - // @ts-expect-error - let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + function NavBar() { + let count = React.useRef(0); + let navigate = useNavigate(); + React.useEffect(() => { + count.current++; + }, [navigate]); + return ( + + ); + } // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` [ + ,

- Contacts + Home

, -

- ?foo=bar - #hash -

, ] `); - }); - }); - it("is not stable across location changes", () => { - let router = createMemoryRouter( - [ - { - path: "/", - Component: () => ( - <> - - - - ), - children: [ - { - path: "home", - element:

Home

, - }, - { - path: "about", - element:

About

, - }, - ], - }, - ], - { initialEntries: ["/home"] } - ); - - let renderer: TestRenderer.ReactTestRenderer; - TestRenderer.act(() => { - renderer = TestRenderer.create(); - }); - - function NavBar() { - let count = React.useRef(0); - let navigate = useRouterNavigate(); - React.useEffect(() => { - count.current++; - }, [navigate]); - return ( - - ); - } + // @ts-expect-error + let buttons = renderer.root.findAllByType("button"); + TestRenderer.act(() => { + buttons[1].props.onClick(); // link to /about + }); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ - , +

About - -

- 0 -

- , -

- Home -

, - ] - `); + , + ] + `); - // @ts-expect-error - let buttons = renderer.root.findAllByType("button"); - TestRenderer.act(() => { - buttons[1].props.onClick(); - }); + // @ts-expect-error + buttons = renderer.root.findAllByType("button"); + TestRenderer.act(() => { + buttons[0].props.onClick(); // link back to /home + }); - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ - , +

Home - - -

- 1 -

- , -

- About -

, - ] - `); - - // @ts-expect-error - buttons = renderer.root.findAllByType("button"); - TestRenderer.act(() => { - buttons[0].props.onClick(); + , + ] + `); }); - - // @ts-expect-error - expect(renderer.toJSON()).toMatchInlineSnapshot(` - [ - , -

- Home -

, - ] - `); }); }); + +function UseNavigateButton({ + to, + relative, +}: { + to: To; + relative?: RelativeRoutingType; +}) { + let navigate = useNavigate(); + return ; +} diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 63c2f7e3c5..bcbf1a7e9a 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -102,13 +102,13 @@ import { useActionData, useAsyncError, useAsyncValue, + useRouteId, useLoaderData, useMatches, useNavigation, useRevalidator, useRouteError, useRouteLoaderData, - useRouterNavigate, } from "./lib/hooks"; // Exported for backwards compatibility, but not being used internally anymore @@ -205,8 +205,8 @@ export { useResolvedPath, useRevalidator, useRouteError, + useRouteId, useRouteLoaderData, - useRouterNavigate, useRoutes, }; @@ -256,7 +256,7 @@ export function createMemoryRouter( routes: RouteObject[], opts?: { basename?: string; - future?: FutureConfig; + future?: Partial>; hydrationData?: HydrationState; initialEntries?: InitialEntry[]; initialIndex?: number; @@ -264,7 +264,10 @@ export function createMemoryRouter( ): RemixRouter { return createRouter({ basename: opts?.basename, - future: opts?.future, + future: { + ...opts?.future, + v7_prependBasename: true, + }, history: createMemoryHistory({ initialEntries: opts?.initialEntries, initialIndex: opts?.initialIndex, diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 41660c85d5..4568e09880 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -156,6 +156,13 @@ export interface NavigateFunction { * @see https://reactrouter.com/hooks/use-navigate */ export function useNavigate(): NavigateFunction { + let isDataRouter = React.useContext(DataRouterContext) != null; + // Conditional usage is OK here because the usage of a data router is static + // eslint-disable-next-line react-hooks/rules-of-hooks + return isDataRouter ? useNavigateStable() : useNavigateUnstable(); +} + +function useNavigateUnstable(): NavigateFunction { invariant( useInRouterContext(), // TODO: This error is probably because they somehow have 2 versions of the @@ -682,7 +689,7 @@ export function _renderMatches( enum DataRouterHook { UseBlocker = "useBlocker", UseRevalidator = "useRevalidator", - UseRouterNavigate = "useRouterNavigate", + UseNavigateStable = "useNavigate", } enum DataRouterStateHook { @@ -694,7 +701,8 @@ enum DataRouterStateHook { UseRouteLoaderData = "useRouteLoaderData", UseMatches = "useMatches", UseRevalidator = "useRevalidator", - UseRouterNavigate = "useRouterNavigate", + UseNavigateStable = "useNavigate", + UseRouteId = "useRouteId", } function getDataRouterConsoleError( @@ -721,6 +729,7 @@ function useRouteContext(hookName: DataRouterStateHook) { return route; } +// Internal version with hookName-aware debugging function useCurrentRouteId(hookName: DataRouterStateHook) { let route = useRouteContext(hookName); let thisRoute = route.matches[route.matches.length - 1]; @@ -731,6 +740,13 @@ function useCurrentRouteId(hookName: DataRouterStateHook) { return thisRoute.route.id; } +/** + * Returns the ID for the nearest contextual route + */ +export function useRouteId() { + return useCurrentRouteId(DataRouterStateHook.UseRouteId); +} + /** * Returns the current navigation, defaulting to an "idle" navigation when * no navigation is in progress @@ -887,33 +903,20 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker { return state.blockers.get(blockerKey) || blocker; } -export function useRouterNavigate(): NavigateFunction { - let { router } = useDataRouterContext(DataRouterHook.UseRouterNavigate); - let id = useCurrentRouteId(DataRouterStateHook.UseRouterNavigate); - - let activeRef = React.useRef(false); - React.useEffect(() => { - activeRef.current = true; - }); +/** + * Stable version of useNavigate that is used when we are in the context of + * a RouterProvider. + */ +function useNavigateStable(): NavigateFunction { + let { router } = useDataRouterContext(DataRouterHook.UseNavigateStable); + let id = useCurrentRouteId(DataRouterStateHook.UseNavigateStable); let navigate: NavigateFunction = React.useCallback( (to: To | number, options: NavigateOptions = {}) => { - // TODO: Do we still want this in a stable version? - warning( - activeRef.current, - `You should call navigate() in a React.useEffect(), not when ` + - `your component is first rendered.` - ); - - if (!activeRef.current) return; - if (typeof to === "number") { router.navigate(to); } else { - router.navigate(to, { - fromRouteId: id, - ...options, - }); + router.navigate(to, { fromRouteId: id, ...options }); } }, [router, id] diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index eccbcaadd4..9b2dc94c0f 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -15543,7 +15543,7 @@ describe("a router", () => { }); }); - describe.only("router.resolveRoute", () => { + describe.skip("router.resolveRoute", () => { it("static routes", () => { let router = createRouter({ routes: [ diff --git a/packages/router/router.ts b/packages/router/router.ts index 1f726f08b3..5b3b507401 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -130,7 +130,7 @@ export interface Router { * @param to Path to navigate to * @param opts Navigation options (method, submission, etc.) */ - navigate(to: To, opts?: RouterNavigateOptions): Promise; + navigate(to: To | null, opts?: RouterNavigateOptions): Promise; /** * @internal @@ -146,7 +146,7 @@ export interface Router { fetch( key: string, routeId: string, - href: string, + href: string | null, opts?: RouterNavigateOptions ): void; @@ -167,16 +167,6 @@ export interface Router { */ createHref(location: Location | URL): string; - /** - * @internal - * PRIVATE - DO NOT USE - * - * Utility function to resolve a path from a given source route, taking into - * account relative routing and any applicable basename. - * @param location - */ - resolvePath(to: To | undefined, opts?: ResolvePathOpts): string; - /** * @internal * PRIVATE - DO NOT USE @@ -345,6 +335,7 @@ export type HydrationState = Partial< */ export interface FutureConfig { v7_normalizeFormMethod: boolean; + v7_prependBasename: boolean; } /** @@ -359,7 +350,7 @@ export interface RouterInit { */ detectErrorBoundary?: DetectErrorBoundaryFunction; mapRouteProperties?: MapRoutePropertiesFunction; - future?: FutureConfig; + future?: Partial; hydrationData?: HydrationState; } @@ -425,11 +416,6 @@ export interface GetScrollPositionFunction { (): number; } -export interface ResolvePathOpts { - fromRouteId?: string; - relative?: RelativeRoutingType; -} - export type RelativeRoutingType = "route" | "path"; type BaseNavigateOptions = { @@ -723,9 +709,11 @@ export function createRouter(init: RouterInit): Router { manifest ); let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; + let basename = init.basename || "/"; // Config driven behavior flags let future: FutureConfig = { v7_normalizeFormMethod: false, + v7_prependBasename: false, ...init.future, }; // Cleanup function for history @@ -746,11 +734,7 @@ export function createRouter(init: RouterInit): Router { // SSR did the initial scroll restoration. let initialScrollRestored = init.hydrationData != null; - let initialMatches = matchRoutes( - dataRoutes, - init.history.location, - init.basename - ); + let initialMatches = matchRoutes(dataRoutes, init.history.location, basename); let initialErrors: RouteData | null = null; if (initialMatches == null) { @@ -1057,7 +1041,7 @@ export function createRouter(init: RouterInit): Router { // Trigger a navigation event, which can either be a numerical POP or a PUSH // replace with an optional submission async function navigate( - to: number | To, + to: number | To | null, opts?: RouterNavigateOptions ): Promise { if (typeof to === "number") { @@ -1065,11 +1049,19 @@ export function createRouter(init: RouterInit): Router { return; } - let { path, submission, error } = normalizeNavigateOptions( - to, + let normalizedPath = normalizeTo( + manifest, state.location, state.matches, - future, + basename, + future.v7_prependBasename, + to, + opts + ); + let { path, submission, error } = normalizeNavigateOptions( + future.v7_normalizeFormMethod, + false, + normalizedPath, opts ); @@ -1214,7 +1206,7 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let loadingNavigation = opts && opts.overrideNavigation; - let matches = matchRoutes(routesToUse, location, init.basename); + let matches = matchRoutes(routesToUse, location, basename); // Short circuit with a 404 on the root error boundary if we match nothing if (!matches) { @@ -1474,7 +1466,7 @@ export function createRouter(init: RouterInit): Router { cancelledFetcherLoads, fetchLoadMatches, routesToUse, - init.basename, + basename, pendingActionData, pendingError ); @@ -1627,7 +1619,7 @@ export function createRouter(init: RouterInit): Router { function fetch( key: string, routeId: string, - href: string, + href: string | null, opts?: RouterFetchOptions ) { if (isServer) { @@ -1641,23 +1633,31 @@ export function createRouter(init: RouterInit): Router { if (fetchControllers.has(key)) abortFetcher(key); let routesToUse = inFlightDataRoutes || dataRoutes; - let matches = matchRoutes(routesToUse, href, init.basename); + let normalizedPath = normalizeTo( + manifest, + state.location, + state.matches, + basename, + future.v7_prependBasename, + href, + opts + ); + let matches = matchRoutes(routesToUse, normalizedPath, basename); + if (!matches) { setFetcherError( key, routeId, - getInternalRouterError(404, { pathname: href }) + getInternalRouterError(404, { pathname: normalizedPath }) ); return; } let { path, submission } = normalizeNavigateOptions( - href, - state.location, - state.matches, - future, - opts, - true + future.v7_normalizeFormMethod, + true, + normalizedPath, + opts ); let match = getTargetMatch(matches, path); @@ -1777,7 +1777,7 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let matches = state.navigation.state !== "idle" - ? matchRoutes(routesToUse, state.navigation.location, init.basename) + ? matchRoutes(routesToUse, state.navigation.location, basename) : state.matches; invariant(matches, "Didn't find any matches after fetcher action"); @@ -1804,7 +1804,7 @@ export function createRouter(init: RouterInit): Router { cancelledFetcherLoads, fetchLoadMatches, routesToUse, - init.basename, + basename, { [match.route.id]: actionResult.data }, undefined // No need to send through errors since we short circuit above ); @@ -2085,8 +2085,7 @@ export function createRouter(init: RouterInit): Router { typeof window?.location !== "undefined" ) { let url = init.history.createURL(redirect.location); - let isDifferentBasename = - stripBasename(url.pathname, init.basename || "/") == null; + let isDifferentBasename = stripBasename(url.pathname, basename) == null; if (window.location.origin !== url.origin || isDifferentBasename) { if (replace) { @@ -2472,56 +2471,9 @@ export function createRouter(init: RouterInit): Router { inFlightDataRoutes = newRoutes; } - function resolvePath(to: To | undefined, opts?: ResolvePathOpts) { - let matches = getPathContributingMatches(state.matches); - let path = resolveTo( - to ? to : ".", - matches.map((m) => m.pathnameBase), - state.location.pathname, - opts?.relative === "path" - ); - - if (to == null) { - // Safe to write to these directly here since when `to` is undefined, - // resolveTo(".") will never include a search or hash - path.search = state.location.search; - path.hash = state.location.hash; - } - - // Ensure index routes have the naked ?index param in them - let activeRoute = - opts && opts.fromRouteId && manifest[opts.fromRouteId] - ? manifest[opts.fromRouteId] - : null; - - if ((!to || to === ".") && activeRoute && activeRoute.index) { - let params = new URLSearchParams(path.search); - if ( - !params.has("index") || - params.getAll("index").every((p) => p !== "") - ) { - path.search = path.search - ? path.search.replace(/^\?/, "?index&") - : "?index"; - } - } - - // If we're operating within a basename, prepend it to the pathname. If - // this is a root navigation, then just use the raw basename which allows - // the basename to have full control over the presence of a trailing slash - // on root actions - let basename = router.basename || "/"; - if (basename !== "/") { - path.pathname = - path.pathname === "/" ? basename : joinPaths([basename, path.pathname]); - } - - return createPath(path); - } - router = { get basename() { - return init.basename; + return basename; }, get state() { return state; @@ -2535,7 +2487,6 @@ export function createRouter(init: RouterInit): Router { navigate, fetch, revalidate, - resolvePath, // Passthrough to history-aware createHref used by useHref so we get proper // hash-aware URLs in DOM paths createHref: (to: To) => init.history.createHref(to), @@ -3104,29 +3055,111 @@ function isSubmissionNavigation( return opts != null && "formData" in opts; } +function normalizeTo( + manifest: RouteManifest, + location: Path, + matches: AgnosticDataRouteMatch[], + basename: string, + prependBasename: boolean, + to: To | null, + opts?: RouterNavigateOptions | RouterFetchOptions +) { + let isSubmission = opts && isSubmissionNavigation(opts); + let contextualMatches: AgnosticDataRouteMatch[]; + if (opts?.fromRouteId != null) { + contextualMatches = []; + for (let match of matches) { + contextualMatches.push(match); + if (match.route.id === opts.fromRouteId) { + break; + } + } + } else { + contextualMatches = matches; + } + let pathMatches = getPathContributingMatches(contextualMatches); + let match = pathMatches[pathMatches.length - 1]; + let path = resolveTo( + to ? to : ".", + pathMatches.map((m) => m.pathnameBase), + location.pathname, + opts?.relative === "path" + ); + + if (to == null) { + // Safe to write to these directly here since when `to` is undefined, + // resolveTo(".") will never include a search or hash + path.search = location.search; + path.hash = location.hash; + } + + // Ensure index routes have the naked ?index param in them + let activeRoute = + opts && opts.fromRouteId && manifest[opts.fromRouteId] + ? manifest[opts.fromRouteId] + : null; + + // Previously we set the default action to ".". The problem with this is that + // `useResolvedPath(".")` excludes search params and the hash of the resolved + // URL. This is the intended behavior of when "." is specifically provided as + // the form action, but inconsistent w/ browsers when the action is omitted. + // https://github.com/remix-run/remix/issues/927 + if (isSubmission && to == null) { + // Safe to write to these directly here since if action was undefined, we + // would have called useResolvedPath(".") which will never include a search + // or hash + path.search = location.search; + path.hash = location.hash; + + // When grabbing search params from the URL, remove the automatically + // inserted ?index param so we match the useResolvedPath search behavior + // which would not include ?index + if (match.route.index) { + let params = new URLSearchParams(path.search); + params.delete("index"); + path.search = params.toString() ? `?${params.toString()}` : ""; + } + } + + if ((!to || to === ".") && match.route.index) { + path.search = path.search + ? path.search.replace(/^\?/, "?index&") + : "?index"; + } + + if ((!to || to === ".") && activeRoute && activeRoute.index) { + let params = new URLSearchParams(path.search); + if (!params.has("index") || params.getAll("index").every((p) => p !== "")) { + path.search = path.search + ? path.search.replace(/^\?/, "?index&") + : "?index"; + } + } + + // If we're operating within a basename, prepend it to the pathname. If + // this is a root navigation, then just use the raw basename which allows + // the basename to have full control over the presence of a trailing slash + // on root actions + if (prependBasename && basename !== "/") { + path.pathname = + path.pathname === "/" ? basename : joinPaths([basename, path.pathname]); + } + + return createPath(path); +} + // Normalize navigation options by converting formMethod=GET formData objects to // URLSearchParams so they behave identically to links with query params function normalizeNavigateOptions( - to: To, - location: Location, - matches: AgnosticDataRouteMatch[], - future: FutureConfig, - opts?: RouterNavigateOptions, - isFetcher = false + normalizeFormMethod: boolean, + isFetcher = false, + path: string, + opts?: RouterNavigateOptions ): { path: string; submission?: Submission; error?: ErrorResponse; } { - let path = createPath( - resolveTo( - to, - getPathContributingMatches(matches).map((match) => match.pathnameBase), - location.pathname, - opts?.relative === "path" - ) - ); - // Return location verbatim on non-submission navigations if (!opts || !isSubmissionNavigation(opts)) { return { path }; @@ -3144,7 +3177,7 @@ function normalizeNavigateOptions( if (opts.formData) { let formMethod = opts.formMethod || "get"; submission = { - formMethod: future.v7_normalizeFormMethod + formMethod: normalizeFormMethod ? (formMethod.toUpperCase() as V7_FormMethod) : (formMethod.toLowerCase() as FormMethod), formAction: stripHashFromPath(path), @@ -3552,28 +3585,14 @@ async function callLoaderOrAction( // Support relative routing in internal redirects if (!ABSOLUTE_URL_REGEX.test(location)) { - let activeMatches = matches.slice(0, matches.indexOf(match) + 1); - let routePathnames = getPathContributingMatches(activeMatches).map( - (match) => match.pathnameBase - ); - let resolvedLocation = resolveTo( - location, - routePathnames, - new URL(request.url).pathname - ); - invariant( - createPath(resolvedLocation), - `Unable to resolve redirect location: ${location}` + location = normalizeTo( + manifest, + new URL(request.url), + matches.slice(0, matches.indexOf(match) + 1), + basename, + true, + location ); - - // Prepend the basename to the redirect location if we have one - if (basename) { - let path = resolvedLocation.pathname; - resolvedLocation.pathname = - path === "/" ? basename : joinPaths([basename, path]); - } - - location = createPath(resolvedLocation); } else if (!isStaticRequest) { // Strip off the protocol+origin for same-origin + same-basename absolute // redirects. If this is a static request, we can let it go back to the From 257914e2d66110672e80e24ab5e80cfb5d7454e7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 15:18:31 -0400 Subject: [PATCH 06/21] Streamline navigateTo and move to UNSAFE_useRouteId --- packages/react-router-dom-v5-compat/index.ts | 2 +- .../__tests__/data-browser-router-test.tsx | 38 ++++--------- packages/react-router-dom/dom.ts | 3 - packages/react-router-dom/index.tsx | 7 ++- packages/react-router-native/index.tsx | 2 +- .../__tests__/useNavigate-test.tsx | 16 +++--- packages/react-router/index.ts | 2 +- packages/router/router.ts | 56 ++++--------------- 8 files changed, 36 insertions(+), 90 deletions(-) diff --git a/packages/react-router-dom-v5-compat/index.ts b/packages/react-router-dom-v5-compat/index.ts index af15ab7a72..d38c15c840 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -172,12 +172,12 @@ export { unstable_usePrompt, useRevalidator, useRouteError, - useRouteId, useRouteLoaderData, useSubmit, UNSAFE_DataRouterContext, UNSAFE_DataRouterStateContext, UNSAFE_useScrollRestoration, + UNSAFE_useRouteId, } from "./react-router-dom"; export type { StaticRouterProps } from "./lib/components"; diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 79054174de..fbcc0e60fb 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -4439,7 +4439,8 @@ function testDomRouter( Click

- {count}-{fetcherCount.current} + {`render count:${count}`} + {`fetcher count:${fetcherCount.current}`}

); @@ -4453,38 +4454,19 @@ function testDomRouter( let { container } = render(); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
- -

- 0 - - - 0 -

-
" - `); + let html = getHtml(container); + expect(html).toContain("render count:0"); + expect(html).toContain("fetcher count:0"); fireEvent.click(screen.getByText("Click")); fireEvent.click(screen.getByText("Click")); fireEvent.click(screen.getByText("Click")); - fireEvent.click(screen.getByText("Click")); - fireEvent.click(screen.getByText("Click")); - await waitFor(() => screen.getByText(/5-1/)); + await waitFor(() => screen.getByText(/render count:3/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
- -

- 5 - - - 1 -

-
" - `); + html = getHtml(container); + expect(html).toContain("render count:3"); + expect(html).toContain("fetcher count:1"); + }); }); }); diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index 0e52d7facb..7dc0f2c897 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -119,9 +119,6 @@ export interface SubmitOptions { /** * The action URL path used to submit the form. Overrides ``. * Defaults to the path of the current route. - * - * Note: It is assumed the path is already resolved. If you need to resolve a - * relative path, use `useFormAction`. */ action?: string; diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 4ac0bf1f92..d4cf14ddcc 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -18,13 +18,13 @@ import { useNavigate, useNavigation, useResolvedPath, - useRouteId, unstable_useBlocker as useBlocker, UNSAFE_DataRouterContext as DataRouterContext, UNSAFE_DataRouterStateContext as DataRouterStateContext, UNSAFE_NavigationContext as NavigationContext, UNSAFE_RouteContext as RouteContext, UNSAFE_mapRouteProperties as mapRouteProperties, + UNSAFE_useRouteId as useRouteId, } from "react-router"; import type { BrowserHistory, @@ -169,7 +169,6 @@ export { useResolvedPath, useRevalidator, useRouteError, - useRouteId, useRouteLoaderData, useRoutes, } from "react-router"; @@ -194,6 +193,7 @@ export { UNSAFE_NavigationContext, UNSAFE_LocationContext, UNSAFE_RouteContext, + UNSAFE_useRouteId, } from "react-router"; //#endregion @@ -1003,7 +1003,8 @@ function useSubmitImpl( ); } -// TODO: Deprecate entirely in favor of router.resolvePath()? +// v7: Eventually we should deprecate this entirely in favor of using the +// router method directly? export function useFormAction( action?: string, { relative }: { relative?: RelativeRoutingType } = {} diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index 1a25403d77..706539bd7e 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -108,7 +108,6 @@ export { useResolvedPath, useRevalidator, useRouteError, - useRouteId, useRouteLoaderData, useRoutes, } from "react-router"; @@ -133,6 +132,7 @@ export { UNSAFE_NavigationContext, UNSAFE_LocationContext, UNSAFE_RouteContext, + UNSAFE_useRouteId, } from "react-router"; //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx index 0d8c31ec5d..cd9d9d2433 100644 --- a/packages/react-router/__tests__/useNavigate-test.tsx +++ b/packages/react-router/__tests__/useNavigate-test.tsx @@ -898,7 +898,7 @@ describe("useNavigate", () => { ); } @@ -918,7 +918,7 @@ describe("useNavigate", () => { About

- 0 + count:0

,

@@ -948,7 +948,7 @@ describe("useNavigate", () => { About

- 1 + count:1

,

@@ -978,7 +978,7 @@ describe("useNavigate", () => { About

- 2 + count:2

,

@@ -1585,7 +1585,7 @@ describe("useNavigate", () => { ); } @@ -1605,7 +1605,7 @@ describe("useNavigate", () => { About

- 0 + count:0

,

@@ -1635,7 +1635,7 @@ describe("useNavigate", () => { About

- 1 + count:1

,

@@ -1665,7 +1665,7 @@ describe("useNavigate", () => { About

- 1 + count:1

,

diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index bcbf1a7e9a..cf1705bda2 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -205,7 +205,6 @@ export { useResolvedPath, useRevalidator, useRouteError, - useRouteId, useRouteLoaderData, useRoutes, }; @@ -299,4 +298,5 @@ export { DataRouterContext as UNSAFE_DataRouterContext, DataRouterStateContext as UNSAFE_DataRouterStateContext, mapRouteProperties as UNSAFE_mapRouteProperties, + useRouteId as UNSAFE_useRouteId, }; diff --git a/packages/router/router.ts b/packages/router/router.ts index 5b3b507401..7f23030f30 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -1050,7 +1050,6 @@ export function createRouter(init: RouterInit): Router { } let normalizedPath = normalizeTo( - manifest, state.location, state.matches, basename, @@ -1634,7 +1633,6 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let normalizedPath = normalizeTo( - manifest, state.location, state.matches, basename, @@ -3056,7 +3054,6 @@ function isSubmissionNavigation( } function normalizeTo( - manifest: RouteManifest, location: Path, matches: AgnosticDataRouteMatch[], basename: string, @@ -3064,9 +3061,9 @@ function normalizeTo( to: To | null, opts?: RouterNavigateOptions | RouterFetchOptions ) { - let isSubmission = opts && isSubmissionNavigation(opts); let contextualMatches: AgnosticDataRouteMatch[]; if (opts?.fromRouteId != null) { + // Grab matches up to the calling route route contextualMatches = []; for (let match of matches) { contextualMatches.push(match); @@ -3079,6 +3076,8 @@ function normalizeTo( } let pathMatches = getPathContributingMatches(contextualMatches); let match = pathMatches[pathMatches.length - 1]; + + // Resolve the relative path let path = resolveTo( to ? to : ".", pathMatches.map((m) => m.pathnameBase), @@ -3086,56 +3085,24 @@ function normalizeTo( opts?.relative === "path" ); + // When `to` is not specified we inherit search/hash from the current + // location, unlike when to="." and we just inherit the path. + // See https://github.com/remix-run/remix/issues/927 if (to == null) { - // Safe to write to these directly here since when `to` is undefined, - // resolveTo(".") will never include a search or hash - path.search = location.search; - path.hash = location.hash; - } - - // Ensure index routes have the naked ?index param in them - let activeRoute = - opts && opts.fromRouteId && manifest[opts.fromRouteId] - ? manifest[opts.fromRouteId] - : null; - - // Previously we set the default action to ".". The problem with this is that - // `useResolvedPath(".")` excludes search params and the hash of the resolved - // URL. This is the intended behavior of when "." is specifically provided as - // the form action, but inconsistent w/ browsers when the action is omitted. - // https://github.com/remix-run/remix/issues/927 - if (isSubmission && to == null) { - // Safe to write to these directly here since if action was undefined, we - // would have called useResolvedPath(".") which will never include a search - // or hash path.search = location.search; path.hash = location.hash; - - // When grabbing search params from the URL, remove the automatically - // inserted ?index param so we match the useResolvedPath search behavior - // which would not include ?index - if (match.route.index) { - let params = new URLSearchParams(path.search); - params.delete("index"); - path.search = params.toString() ? `?${params.toString()}` : ""; - } } - if ((!to || to === ".") && match.route.index) { + if ( + (!to || to === ".") && + match.route.index && + !hasNakedIndexQuery(path.search) + ) { path.search = path.search ? path.search.replace(/^\?/, "?index&") : "?index"; } - if ((!to || to === ".") && activeRoute && activeRoute.index) { - let params = new URLSearchParams(path.search); - if (!params.has("index") || params.getAll("index").every((p) => p !== "")) { - path.search = path.search - ? path.search.replace(/^\?/, "?index&") - : "?index"; - } - } - // If we're operating within a basename, prepend it to the pathname. If // this is a root navigation, then just use the raw basename which allows // the basename to have full control over the presence of a trailing slash @@ -3586,7 +3553,6 @@ async function callLoaderOrAction( // Support relative routing in internal redirects if (!ABSOLUTE_URL_REGEX.test(location)) { location = normalizeTo( - manifest, new URL(request.url), matches.slice(0, matches.indexOf(match) + 1), basename, From dac0b3a6cfbbf829c6cbddb7e26c55ddf4271a63 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 15:26:08 -0400 Subject: [PATCH 07/21] Add tests for fetcher basename support --- .../__tests__/data-browser-router-test.tsx | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index fbcc0e60fb..e71f780150 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -4467,6 +4467,230 @@ function testDomRouter( expect(html).toContain("render count:3"); expect(html).toContain("fetcher count:1"); }); + + describe("with a basename", () => { + it("prepends the basename to fetcher.load paths", async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + + it('prepends the basename to fetcher.submit({ method: "get" }) paths', async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + + it('prepends the basename to fetcher.submit({ method: "post" }) paths', async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("submit")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + it("prepends the basename to fetcher.Form paths", async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ + + +
" + `); + + fireEvent.click(screen.getByText("submit")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+
+ +
+
" + `); + }); }); }); From ede8398bc852d278f00a671869c4d9e30966d800 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 15:26:49 -0400 Subject: [PATCH 08/21] Bump bundle --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 69ce64c5a4..8b95a2d15f 100644 --- a/package.json +++ b/package.json @@ -105,19 +105,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "44.1 kB" + "none": "45 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "13.1 kB" + "none": "13.3 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "15.3 kB" + "none": "15.6 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "11.6 kB" + "none": "11.8 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "17.5 kB" + "none": "17.7 kB" } } } From b610b41fa71ff8d68d30a608b798a3dd477e7bb6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 16:02:43 -0400 Subject: [PATCH 09/21] Fix button formaction flow --- .../__tests__/data-browser-router-test.tsx | 63 +++++++++++++++++++ packages/react-router-dom/dom.ts | 3 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index e71f780150..93ecfa3504 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -2284,6 +2284,69 @@ function testDomRouter( `); }); + it("allows a button to override the
", async () => { + let router = createTestRouter( + createRoutesFromElements( + + { + throw new Error("No"); + }} + > + "Yes"} + Component={() => { + let actionData = useActionData() as string | undefined; + return ( + +

{actionData || "No"}

+ + + ); + }} + /> +
+
+ ), + { + window: getWindow("/foo/bar"), + } + ); + let { container } = render(); + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo" + ); + expect( + container.querySelector("button")?.getAttribute("formaction") + ).toBe("/foo/bar"); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => screen.getByText("Yes")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+

+ Yes +

+ +
+
" + `); + }); + it("supports uppercase form method attributes", async () => { let loaderDefer = createDeferred(); let actionDefer = createDeferred(); diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index 7dc0f2c897..4be6d69c7c 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -215,7 +215,8 @@ export function getFormSubmissionInfo( // When grabbing the action from the element, it will have had the basename // prefixed to ensure non-JS scenarios work, so strip it since we'll // re-prefix in the router - let attr = target.getAttribute("action") || form.getAttribute("action"); + let attr = + target.getAttribute("formaction") || form.getAttribute("action"); action = attr ? stripBasename(attr, basename) : null; } From b28e3204f5268404c6dc848286263d96bedc0a6b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 16:38:03 -0400 Subject: [PATCH 10/21] Fix bug with fetcher ?index params --- .../__tests__/data-browser-router-test.tsx | 1 + packages/router/router.ts | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 93ecfa3504..bbea7b5e92 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -3458,6 +3458,7 @@ function testDomRouter( async function clickAndAssert(btnText: string, expectedOutput: string) { fireEvent.click(screen.getByText(btnText)); + await new Promise((r) => setTimeout(r, 1)); await waitFor(() => screen.getByText(new RegExp(expectedOutput))); expect(getHtml(container.querySelector("#output")!)).toContain( expectedOutput diff --git a/packages/router/router.ts b/packages/router/router.ts index 7f23030f30..7fe63e7415 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -1055,7 +1055,8 @@ export function createRouter(init: RouterInit): Router { basename, future.v7_prependBasename, to, - opts + opts?.fromRouteId, + opts?.relative ); let { path, submission, error } = normalizeNavigateOptions( future.v7_normalizeFormMethod, @@ -1638,7 +1639,8 @@ export function createRouter(init: RouterInit): Router { basename, future.v7_prependBasename, href, - opts + routeId, + opts?.relative ); let matches = matchRoutes(routesToUse, normalizedPath, basename); @@ -3059,30 +3061,32 @@ function normalizeTo( basename: string, prependBasename: boolean, to: To | null, - opts?: RouterNavigateOptions | RouterFetchOptions + fromRouteId?: string, + relative?: RelativeRoutingType ) { let contextualMatches: AgnosticDataRouteMatch[]; - if (opts?.fromRouteId != null) { - // Grab matches up to the calling route route + let activeRouteMatch: AgnosticDataRouteMatch | undefined; + if (fromRouteId != null) { + // Grab matches up to the calling route contextualMatches = []; for (let match of matches) { contextualMatches.push(match); - if (match.route.id === opts.fromRouteId) { + if (match.route.id === fromRouteId) { + activeRouteMatch = match; break; } } } else { contextualMatches = matches; + activeRouteMatch = matches[matches.length - 1]; } - let pathMatches = getPathContributingMatches(contextualMatches); - let match = pathMatches[pathMatches.length - 1]; // Resolve the relative path let path = resolveTo( to ? to : ".", - pathMatches.map((m) => m.pathnameBase), + getPathContributingMatches(contextualMatches).map((m) => m.pathnameBase), location.pathname, - opts?.relative === "path" + relative === "path" ); // When `to` is not specified we inherit search/hash from the current @@ -3093,9 +3097,11 @@ function normalizeTo( path.hash = location.hash; } + // Add an ?index param for matched index routes if we don't already have one if ( (!to || to === ".") && - match.route.index && + activeRouteMatch && + activeRouteMatch.route.index && !hasNakedIndexQuery(path.search) ) { path.search = path.search From 2b2c8b5d4735be676bd4c30f26a85e6a08aaa510 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 16:38:17 -0400 Subject: [PATCH 11/21] Add changesets --- .changeset/fetcher-basename-copy.md | 6 ++++++ .changeset/stable-navigate-submit.md | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/fetcher-basename-copy.md create mode 100644 .changeset/stable-navigate-submit.md diff --git a/.changeset/fetcher-basename-copy.md b/.changeset/fetcher-basename-copy.md new file mode 100644 index 0000000000..b402ef1d13 --- /dev/null +++ b/.changeset/fetcher-basename-copy.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": minor +"@remix-run/router": minor +--- + +Support relative routing and `basename` in `useFetcher`. Note that this introduces a new `future.v7_prependBasename` flag to the `@remix-run/router`, so if you are writing code using the router directly you may need to enable this flag to opt-into this change. diff --git a/.changeset/stable-navigate-submit.md b/.changeset/stable-navigate-submit.md new file mode 100644 index 0000000000..3ef42d8254 --- /dev/null +++ b/.changeset/stable-navigate-submit.md @@ -0,0 +1,6 @@ +--- +"react-router": patch +"react-router-dom": patch +--- + +When using a `RouterProvider`, `useNavigate`/`useSubmit`/`fetcher.submit` are now stable across location changes, since we can handle relative routing via the `@remix-run/router` instance and get rid of our dependence on `useLocation()`. When using `BrowserRouter`, these hooks remain unstable across location changes because they still rely on `useLocation()`. From 375bff1e5c5f69ed990c103607ad01c39277f358 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 16:44:51 -0400 Subject: [PATCH 12/21] Fix basename type --- packages/router/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 7fe63e7415..2c0015b0dd 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3467,7 +3467,7 @@ async function callLoaderOrAction( matches: AgnosticDataRouteMatch[], manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, - basename = "/", + basename: string, isStaticRequest: boolean = false, isRouteRequest: boolean = false, requestContext?: unknown From 5095809198d2850972620ba6ed24c5ab5c02730f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 12 Apr 2023 16:57:04 -0400 Subject: [PATCH 13/21] remove router.basename in favor of normnalized basename --- packages/router/router.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 2c0015b0dd..7eee909bba 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -1357,7 +1357,7 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - router.basename + basename ); if (request.signal.aborted) { @@ -1725,7 +1725,7 @@ export function createRouter(init: RouterInit): Router { requestMatches, manifest, mapRouteProperties, - router.basename + basename ); if (fetchRequest.signal.aborted) { @@ -1968,7 +1968,7 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - router.basename + basename ); // Deferred isn't supported for fetcher loads, await everything and treat it @@ -2185,7 +2185,7 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - router.basename + basename ) ), ...fetchersToLoad.map((f) => { @@ -2197,7 +2197,7 @@ export function createRouter(init: RouterInit): Router { f.matches, manifest, mapRouteProperties, - router.basename + basename ); } else { let error: ErrorResult = { From b48bc8b060d7b30cbba53b4602fb5fd252e975e0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 13 Apr 2023 09:59:00 -0400 Subject: [PATCH 14/21] Add additional test for
?index param --- .../__tests__/data-browser-router-test.tsx | 33 +++++++++++++++++++ packages/router/router.ts | 6 ++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index bbea7b5e92..efc4f23f2e 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -2695,6 +2695,39 @@ function testDomRouter( "/foo/bar?index&a=1#hash" ); }); + + // eslint-disable-next-line jest/expect-expect + it('does not put ?index param in final URL for { + let testWindow = getWindow("/form"); + let router = createTestRouter( + createRoutesFromElements( + + + + + + } + /> + + + ), + { + window: testWindow, + } + ); + render(); + + assertLocation(testWindow, "/form", ""); + + fireEvent.click(screen.getByText("Submit")); + await new Promise((r) => setTimeout(r, 0)); + assertLocation(testWindow, "/form", "?name=value"); + }); }); describe("dynamic routes", () => { diff --git a/packages/router/router.ts b/packages/router/router.ts index 7eee909bba..7b22ab15e6 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3167,9 +3167,9 @@ function normalizeNavigateOptions( // Flatten submission onto URLSearchParams for GET submissions let parsedPath = parsePath(path); let searchParams = convertFormDataToSearchParams(opts.formData); - // Since fetcher GET submissions only run a single loader (as opposed to - // navigation GET submissions which run all loaders), we need to preserve - // any incoming ?index params + // On GET navigation submissions we can drop the ?index param from the + // resulting location since all loaders will run. But fetcher GET submissions + // only run a single loader so we need to preserve any incoming ?index params if (isFetcher && parsedPath.search && hasNakedIndexQuery(parsedPath.search)) { searchParams.append("index", ""); } From d3ab031315aab1bc7b4c008b397d71cebab74b39 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 13 Apr 2023 10:13:49 -0400 Subject: [PATCH 15/21] Update changesets and readme --- .changeset/fetcher-basename-copy.md | 6 ------ .changeset/fetcher-basename.md | 13 +++++++++++++ packages/router/README.md | 10 ++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fetcher-basename-copy.md create mode 100644 .changeset/fetcher-basename.md diff --git a/.changeset/fetcher-basename-copy.md b/.changeset/fetcher-basename-copy.md deleted file mode 100644 index b402ef1d13..0000000000 --- a/.changeset/fetcher-basename-copy.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"react-router-dom": minor -"@remix-run/router": minor ---- - -Support relative routing and `basename` in `useFetcher`. Note that this introduces a new `future.v7_prependBasename` flag to the `@remix-run/router`, so if you are writing code using the router directly you may need to enable this flag to opt-into this change. diff --git a/.changeset/fetcher-basename.md b/.changeset/fetcher-basename.md new file mode 100644 index 0000000000..5c6ded420c --- /dev/null +++ b/.changeset/fetcher-basename.md @@ -0,0 +1,13 @@ +--- +"react-router-dom": minor +"@remix-run/router": minor +--- + +- Enable relative routing in the `@remix-run/router` when providing a source route ID from which the path is relative to: + + - Example: `router.navigate("../path", { fromRouteId: "some-route" })`. + - This also applies to `router.fetch` which already receives a source route ID + +- Introduce a new `@remix-run/router` `future.v7_prependBasename` flag to enable `basename` prefixing to all paths coming into `router.navigate` and `router.fetch`. + - Previously the `basename` was prepended in the React Router layer, but now that relative routing is being handled by the router we need prepend the `basename` _after_ resolving any relative paths + - This also enables `basename` support in `useFetcher` as well diff --git a/packages/router/README.md b/packages/router/README.md index 2c8c83c58e..385ced7c36 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -106,7 +106,17 @@ router.fetch("key", "/page", { By default, active loaders will revalidate after any navigation or fetcher mutation. If you need to kick off a revalidation for other use-cases, you can use `router.revalidate()` to re-execute all active loaders. +### Future Flags + +We use _Future Flags_ in the router to help us introduce breaking changes in an opt-in fashion ahead of major releases. Please check out the [blog post][future-flags-post] and [React Router Docs][api-development-strategy] for more information on this process. The currently available future flags in `@remix-run/router` are: + +| Flag | Description | +| `v7_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method | +| `v7_prependBasename` | Prepend the `basename` to incoming `router.navigate`/`router.fetch` paths | + [react-router]: https://reactrouter.com [remix]: https://remix.run [react-router-repo]: https://github.com/remix-run/react-router [remix-routers-repo]: https://github.com/brophdawg11/remix-routers +[api-development-strategy]: https://reactrouter.com/en/main/guides/api-development-strategy +[future-flags-post]: https://remix.run/blog/future-flags From c2ac4bac030300f9747bec18bd9941919c4e939e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 13 Apr 2023 11:43:29 -0400 Subject: [PATCH 16/21] Add router relative routing tests --- packages/router/__tests__/router-test.ts | 525 ++++++++++++++++------- packages/router/router.ts | 2 +- 2 files changed, 381 insertions(+), 146 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 9b2dc94c0f..6d0256fb4c 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -14,6 +14,7 @@ import type { } from "../index"; import { createMemoryHistory, + createPath, createRouter, createStaticHandler, defer, @@ -15543,214 +15544,448 @@ describe("a router", () => { }); }); - describe.skip("router.resolveRoute", () => { - it("static routes", () => { - let router = createRouter({ - routes: [ - { - path: "/", - children: [ + describe("path resolution", () => { + describe("routing to self", () => { + // Utility that accepts children of /foo routes and executes the same + // routing tests starting at /foo/bar/?a=b#hash + function assertRoutingToSelf(fooChildren, expectedPath, expectIndex) { + const getRouter = () => + createRouter({ + routes: [ { - path: "foo", + path: "/", children: [ { - id: "activeRoute", - path: "bar", + path: "foo", + children: fooChildren, }, ], }, ], - }, - ], - history: createMemoryHistory({ - initialEntries: ["/foo/bar?a=1#hash"], - }), + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + // Null should preserve the search/hash + let router = getRouter(); + router.navigate(null, { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe( + expectedPath + (expectIndex ? "?index&a=1#hash" : "?a=1#hash") + ); + router.dispose(); + + // "." and "" should not preserve the search and hash + router = getRouter(); + router.navigate(".", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe( + expectedPath + (expectIndex ? "?index" : "") + ); + router.dispose(); + + router = getRouter(); + router.navigate("", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe( + expectedPath + (expectIndex ? "?index" : "") + ); + router.dispose(); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "bar", + }, + ], + "/foo/bar", + false + ); }); - expect( - router.resolvePath(undefined, { fromRouteId: "activeRoute" }) - ).toBe("/foo/bar?a=1#hash"); - expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar" - ); - expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar" - ); + + it("from a layout route", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "bar", + children: [ + { + index: true, + }, + ], + }, + ], + "/foo/bar", + false + ); + }); + + it("from an index route", () => { + assertRoutingToSelf( + [ + { + path: "bar", + children: [ + { + id: "activeRoute", + index: true, + }, + ], + }, + ], + "/foo/bar", + true + ); + }); + + it("from an index route with a path", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ], + "/foo/bar", + true + ); + }); + + it("from a dynamic param route", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: ":param", + }, + ], + "/foo/bar", + false + ); + }); + + it("from a splat route", () => { + debugger; + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "*", + }, + ], + "/foo", + false + ); + }); + /* eslint-enable jest/expect-expect */ }); - it("layout routes", () => { - let router = createRouter({ - routes: [ + describe("routing to parent", () => { + function assertRoutingToParent(fooChildren) { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: fooChildren, + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + // Null should preserve the search/hash + router.navigate("..", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo"); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToParent([ { - path: "/", + id: "activeRoute", + path: "bar", + }, + ]); + }); + + it("from a layout route", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: "bar", children: [ { - path: "foo", - children: [ - { - id: "activeRoute", - path: "bar", - children: [ - { - index: true, - }, - ], - }, - ], + index: true, }, ], }, - ], - history: createMemoryHistory({ - initialEntries: ["/foo/bar?a=1#hash"], - }), + ]); }); - expect( - router.resolvePath(undefined, { fromRouteId: "activeRoute" }) - ).toBe("/foo/bar?a=1#hash"); - expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar" - ); - expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar" - ); - }); - it("index routes", () => { - let router = createRouter({ - routes: [ + it("from an index route", () => { + assertRoutingToParent([ { - path: "/", + path: "bar", children: [ { - path: "foo", - children: [ - { - path: "bar", - children: [ - { - id: "activeRoute", - index: true, - }, - ], - }, - ], + id: "activeRoute", + index: true, }, ], }, - ], - history: createMemoryHistory({ - initialEntries: ["/foo/bar?a=1#hash"], - }), + ]); }); - expect( - router.resolvePath(undefined, { fromRouteId: "activeRoute" }) - ).toBe("/foo/bar?index&a=1#hash"); - expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar?index" - ); - expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar?index" - ); + + it("from an index route with a path", () => { + assertRoutingToParent( + [ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ], + "/foo" + ); + }); + + it("from a dynamic param route", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: ":param", + }, + ]); + }); + + it("from a splat route", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: "*", + }, + ]); + }); + /* eslint-enable jest/expect-expect */ }); - it("index routes with a path", () => { - let router = createRouter({ - routes: [ + describe("routing to sibling", () => { + function assertRoutingToSibling(fooChildren) { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + ...fooChildren, + { + path: "bar-sibling", + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + // Null should preserve the search/hash + router.navigate("../bar-sibling", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo/bar-sibling"); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToSibling([ { - path: "/", + id: "activeRoute", + path: "bar", + }, + ]); + }); + + it("from a layout route", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: "bar", children: [ { - path: "foo", - children: [ - { - id: "activeRoute", - path: "bar", - index: true, - }, - ], + index: true, }, ], }, - ], - history: createMemoryHistory({ - initialEntries: ["/foo/bar?a=1#hash"], - }), + ]); }); - expect( - router.resolvePath(undefined, { fromRouteId: "activeRoute" }) - ).toBe("/foo/bar?index&a=1#hash"); - expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar?index" - ); - expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar?index" - ); - }); - it("dynamic routes", () => { - let router = createRouter({ - routes: [ + it("from an index route", () => { + assertRoutingToSibling([ { - path: "/", + path: "bar", children: [ { - path: "foo", + id: "activeRoute", + index: true, + }, + ], + }, + ]); + }); + + it("from an index route with a path", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ]); + }); + + it("from a dynamic param route", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: ":param", + }, + ]); + }); + + it("from a splat route", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: "*", + }, + ]); + }); + /* eslint-enable jest/expect-expect */ + }); + + describe("routing to child", () => { + function assertRoutingToChild(fooChildren) { + const getRouter = () => + createRouter({ + routes: [ + { + path: "/", children: [ { - id: "activeRoute", - path: ":param", + path: "foo", + children: [...fooChildren], }, ], }, ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + let router = getRouter(); + router.navigate("baz", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo/bar/baz"); + router.dispose(); + + router = getRouter(); + router.navigate("./baz", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo/bar/baz"); + router.dispose(); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToChild([ + { + id: "activeRoute", + path: "bar", + children: [{ path: "baz" }], }, - ], - history: createMemoryHistory({ - initialEntries: ["/foo/bar?a=1#hash"], - }), + ]); }); - expect( - router.resolvePath(undefined, { fromRouteId: "activeRoute" }) - ).toBe("/foo/bar?a=1#hash"); - expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar" - ); - expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( - "/foo/bar" - ); + + it("from a layout route", () => { + assertRoutingToChild([ + { + id: "activeRoute", + path: "bar", + children: [ + { + index: true, + }, + { path: "baz" }, + ], + }, + ]); + }); + + it("from a dynamic param route", () => { + assertRoutingToChild([ + { + id: "activeRoute", + path: ":param", + children: [{ path: "baz" }], + }, + ]); + }); + /* eslint-enable jest/expect-expect */ }); - it("splat routes", () => { + it("should not append ?index to get submission navigations to self from index route", () => { let router = createRouter({ routes: [ { path: "/", children: [ { - path: "foo", + path: "path", children: [ { - id: "activeRoute", - path: "*", + id: "activeRouteId", + index: true, }, ], }, ], }, ], - history: createMemoryHistory({ - initialEntries: ["/foo/bar?a=1#hash"], - }), + history: createMemoryHistory({ initialEntries: ["/path"] }), + }).initialize(); + + router.navigate(null, { + fromRouteId: "activeRouteId", + formData: createFormData({}), }); - expect( - router.resolvePath(undefined, { fromRouteId: "activeRoute" }) - ).toBe("/foo?a=1#hash"); - expect(router.resolvePath(".", { fromRouteId: "activeRoute" })).toBe( - "/foo" - ); - expect(router.resolvePath("", { fromRouteId: "activeRoute" })).toBe( - "/foo" - ); + expect(createPath(router.state.location)).toBe("/path"); + expect(router.state.matches[2].route.index).toBe(true); + + router.navigate(".", { + fromRouteId: "activeRouteId", + formData: createFormData({}), + }); + expect(createPath(router.state.location)).toBe("/path"); + expect(router.state.matches[2].route.index).toBe(true); + + router.navigate("", { + fromRouteId: "activeRouteId", + formData: createFormData({}), + }); + expect(createPath(router.state.location)).toBe("/path"); + expect(router.state.matches[2].route.index).toBe(true); }); }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 7b22ab15e6..655b1412ea 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3125,7 +3125,7 @@ function normalizeTo( // URLSearchParams so they behave identically to links with query params function normalizeNavigateOptions( normalizeFormMethod: boolean, - isFetcher = false, + isFetcher: boolean, path: string, opts?: RouterNavigateOptions ): { From 35370c5e83da9d280ced33c7a99d719fa2c69b22 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 13 Apr 2023 12:56:52 -0400 Subject: [PATCH 17/21] Remove debugger --- packages/router/__tests__/router-test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 6d0256fb4c..2ff65417ac 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -15669,7 +15669,6 @@ describe("a router", () => { }); it("from a splat route", () => { - debugger; assertRoutingToSelf( [ { From 59182f45076e9d3cc48261316d878f03fdc6e4be Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 13 Apr 2023 14:36:30 -0400 Subject: [PATCH 18/21] Fix type errors in test --- packages/router/__tests__/router-test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 2ff65417ac..69468aaaa6 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -3707,6 +3707,7 @@ describe("a router", () => { ], future: { v7_normalizeFormMethod: true, + v7_prependBasename: false, }, }); let A = await t.navigate("/child", { @@ -15746,16 +15747,13 @@ describe("a router", () => { }); it("from an index route with a path", () => { - assertRoutingToParent( - [ - { - id: "activeRoute", - path: "bar", - index: true, - }, - ], - "/foo" - ); + assertRoutingToParent([ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ]); }); it("from a dynamic param route", () => { From c1e795ecc5fc2c1205e70527df916cfbc957e41f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 14 Apr 2023 12:20:37 -0400 Subject: [PATCH 19/21] Remove unused future opt from createStaticRouter --- packages/react-router-dom/server.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 62875ea844..5420fefd85 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -225,10 +225,7 @@ export function createStaticHandler( export function createStaticRouter( routes: RouteObject[], - context: StaticHandlerContext, - opts?: { - future?: Partial; - } + context: StaticHandlerContext ): RemixRouter { let manifest: RouteManifest = {}; let dataRoutes = convertRoutesToDataRoutes( From da61877c9b04c4ff12ae3bdb05dad4f3b83c4cba Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 14 Apr 2023 14:57:53 -0400 Subject: [PATCH 20/21] Add tests for relative:path --- packages/router/__tests__/router-test.ts | 51 ++++++++++++++++++++++++ packages/router/router.ts | 9 +++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 69468aaaa6..e641902986 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -15942,6 +15942,57 @@ describe("a router", () => { /* eslint-enable jest/expect-expect */ }); + it("resolves relative routes when using relative:path", () => { + let history = createMemoryHistory({ + initialEntries: ["/a/b/c/d/e/f"], + }); + let routes = [ + { + id: "a", + path: "/a", + children: [ + { + id: "bc", + path: "b/c", + children: [ + { + id: "de", + path: "d/e", + children: [ + { + id: "f", + path: "f", + }, + ], + }, + ], + }, + ], + }, + ]; + + // Navigating without relative:path + let router = createRouter({ routes, history }).initialize(); + router.navigate(".."); + expect(router.state.location.pathname).toBe("/a/b/c/d/e"); + router.navigate("/a/b/c/d/e/f"); + + router.navigate("../.."); + expect(router.state.location.pathname).toBe("/a/b/c"); + router.navigate("/a/b/c/d/e/f"); + + // Navigating with relative:path + router.navigate("..", { relative: "path" }); + expect(router.state.location.pathname).toBe("/a/b/c/d/e"); + router.navigate("/a/b/c/d/e/f"); + + router.navigate("../..", { relative: "path" }); + expect(router.state.location.pathname).toBe("/a/b/c/d"); + router.navigate("/a/b/c/d/e/f"); + + router.dispose(); + }); + it("should not append ?index to get submission navigations to self from index route", () => { let router = createRouter({ routes: [ diff --git a/packages/router/router.ts b/packages/router/router.ts index 655b1412ea..d59895075a 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3066,8 +3066,11 @@ function normalizeTo( ) { let contextualMatches: AgnosticDataRouteMatch[]; let activeRouteMatch: AgnosticDataRouteMatch | undefined; - if (fromRouteId != null) { - // Grab matches up to the calling route + if (fromRouteId != null && relative !== "path") { + // Grab matches up to the calling route so our route-relative logic is + // relative to the correct source route. When using relative:path, + // fromRouteId is ignored since that is always relative to the current + // location path contextualMatches = []; for (let match of matches) { contextualMatches.push(match); @@ -3099,7 +3102,7 @@ function normalizeTo( // Add an ?index param for matched index routes if we don't already have one if ( - (!to || to === ".") && + (to == null || to === "" || to === ".") && activeRouteMatch && activeRouteMatch.route.index && !hasNakedIndexQuery(path.search) From 9f164a6327bc4d04840f028802b6a01329c6d66d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 14 Apr 2023 15:02:08 -0400 Subject: [PATCH 21/21] Add relative routing example to README --- packages/router/README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/router/README.md b/packages/router/README.md index 385ced7c36..bd931f830c 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -17,12 +17,19 @@ A Router instance can be created using `createRouter`: // including history listeners and kicking off the initial data fetch let router = createRouter({ // Required properties - routes, // Routes array - history, // History instance + routes: [{ + path: '/', + loader: ({ request, params }) => { /* ... */ }, + children: [{ + path: 'home', + loader: ({ request, params }) => { /* ... */ }, + }] + }, + history: createBrowserHistory(), // Optional properties basename, // Base path - mapRouteProperties, // Map function framework-agnostic routes to framework-aware routes + mapRouteProperties, // Map framework-agnostic routes to framework-aware routes future, // Future flags hydrationData, // Hydration data if using server-side-rendering }).initialize(); @@ -83,6 +90,11 @@ router.navigate("/page", { formMethod: "post", formData, }); + +// Relative routing from a source routeId +router.navigate("../../somewhere", { + fromRouteId: "active-route-id", +}); ``` ### Fetchers