diff --git a/.changeset/selfish-walls-relate.md b/.changeset/selfish-walls-relate.md new file mode 100644 index 0000000000..bcd22a68a2 --- /dev/null +++ b/.changeset/selfish-walls-relate.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Narrow the types of `pathname`, `search` and `hash` on `Location` diff --git a/contributors.yml b/contributors.yml index bd68d214eb..f02dd5489e 100644 --- a/contributors.yml +++ b/contributors.yml @@ -136,6 +136,7 @@ - kylegirard - landisdesign - latin-1 +- lensbart - lequangdongg - liuhanqu - lkwr diff --git a/packages/react-router-dom-v5-compat/lib/components.tsx b/packages/react-router-dom-v5-compat/lib/components.tsx index cdf5e7eb1b..0b6bafb11a 100644 --- a/packages/react-router-dom-v5-compat/lib/components.tsx +++ b/packages/react-router-dom-v5-compat/lib/components.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import type { Location, To } from "history"; -import { Action, createPath, parsePath } from "history"; +import type { To } from "history"; +import { Action, createPath, parsePath, Location } from "history"; // Get useHistory from react-router-dom v5 (peer dep). // @ts-expect-error @@ -82,13 +82,13 @@ export function StaticRouter({ } let action = Action.Pop; - let location: Location = { - pathname: locationProp.pathname || "/", - search: locationProp.search || "", - hash: locationProp.hash || "", + let location = { + pathname: (locationProp.pathname || "/") as "" | `/${string}`, + search: (locationProp.search || "") as "" | `?${string}`, + hash: (locationProp.hash || "") as "" | `#${string}`, state: locationProp.state || null, key: locationProp.key || "default", - }; + } satisfies Location; let staticNavigator = { createHref(to: To) { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index b30ed811ae..19b9c90188 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1063,16 +1063,20 @@ export const NavLink = React.forwardRef( : null; if (!caseSensitive) { - locationPathname = locationPathname.toLowerCase(); + locationPathname = locationPathname.toLowerCase() as Lowercase< + typeof locationPathname + >; nextLocationPathname = nextLocationPathname - ? nextLocationPathname.toLowerCase() + ? (nextLocationPathname.toLowerCase() as Lowercase< + typeof nextLocationPathname + >) : null; toPathname = toPathname.toLowerCase(); } if (nextLocationPathname && basename) { - nextLocationPathname = - stripBasename(nextLocationPathname, basename) || nextLocationPathname; + nextLocationPathname = (stripBasename(nextLocationPathname, basename) || + nextLocationPathname) as Location["pathname"]; } // If the `to` has a trailing slash, look at that exact spot. Otherwise, @@ -1822,9 +1826,8 @@ function useScrollRestoration({ // Strip the basename to match useLocation() { ...location, - pathname: - stripBasename(location.pathname, basename) || - location.pathname, + pathname: (stripBasename(location.pathname, basename) || + location.pathname) as Location["pathname"], }, matches ) diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 0e07d98d35..451df5ea68 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -58,7 +58,7 @@ export function StaticRouter({ future, }: StaticRouterProps) { if (typeof locationProp === "string") { - locationProp = parsePath(locationProp); + locationProp = parsePath(locationProp) as Partial; } let action = Action.Pop; diff --git a/packages/react-router/__tests__/context-test.tsx b/packages/react-router/__tests__/context-test.tsx new file mode 100644 index 0000000000..6c0c684d6c --- /dev/null +++ b/packages/react-router/__tests__/context-test.tsx @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/no-unused-vars -- type tests */ +/* eslint-disable jest/expect-expect -- type tests */ +import * as React from "react"; +import { + UNSAFE_LocationContext as LocationContext, + NavigationType, +} from "react-router"; + +const location = { + pathname: "", + search: "", + hash: "", + state: null, + key: "default", +} as const; + +describe("LocationContext", () => { + it("accepts an object with the correct `pathname`", () => { + const validCases = [ + , + , + , + ]; + }); + + it("accepts an object with the correct `hash`", () => { + const validCases = [ + , + , + , + ]; + }); + + it("accepts an object with the correct `search`", () => { + const validCases = [ + , + , + , + ]; + }); + + it("rejects an object with the wrong `pathname`", () => { + const invalidCases = [ + , + , + ]; + }); + + it("rejects an object with the wrong `hash`", () => { + const invalidCases = [ + , + , + ]; + }); + + it("rejects an object with the wrong `search`", () => { + const invalidCases = [ + , + , + ]; + }); +}); diff --git a/packages/react-router/__tests__/useLocation-test.tsx b/packages/react-router/__tests__/useLocation-test.tsx index 121073a0d5..8935251e11 100644 --- a/packages/react-router/__tests__/useLocation-test.tsx +++ b/packages/react-router/__tests__/useLocation-test.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import * as TestRenderer from "react-test-renderer"; import { MemoryRouter, Routes, Route, useLocation } from "react-router"; +import type { Equal, Expect } from "@remix-run/router/__tests__/utils/utils"; function ShowLocation() { let location = useLocation(); @@ -84,4 +85,21 @@ describe("useLocation", () => { `); }); + + // eslint-disable-next-line jest/expect-expect -- type tests + it("returns an object with the correct type", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- used for type tests + function TestUseLocationReturnType() { + let location = useLocation(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type test + type Test1 = Expect>; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type test + type Test2 = Expect>; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type test + type Test3 = Expect>; + + return null; + } + }); }); diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 918bc34d39..1e27a7e9b1 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -441,7 +441,7 @@ export function Router({ ); if (typeof locationProp === "string") { - locationProp = parsePath(locationProp); + locationProp = parsePath(locationProp) as Partial; } let { @@ -461,7 +461,7 @@ export function Router({ return { location: { - pathname: trailingPathname, + pathname: trailingPathname as Location["pathname"], search, hash, state, diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 0feda4720f..5823e81c0d 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -421,7 +421,7 @@ export function useRoutesImpl( location = locationFromContext; } - let pathname = location.pathname || "/"; + let pathname = (location.pathname || "/") as `/${string}`; let remainingPathname = pathname; if (parentPathnameBase !== "/") { @@ -441,7 +441,8 @@ export function useRoutesImpl( // And the direct substring removal approach won't work :/ let parentSegments = parentPathnameBase.replace(/^\//, "").split("/"); let segments = pathname.replace(/^\//, "").split("/"); - remainingPathname = "/" + segments.slice(parentSegments.length).join("/"); + remainingPathname = ("/" + + segments.slice(parentSegments.length).join("/")) as `/${string}`; } let matches = matchRoutes(routes, { pathname: remainingPathname }); @@ -506,7 +507,7 @@ export function useRoutesImpl( state: null, key: "default", ...location, - }, + } as Location, navigationType: NavigationType.Pop, }} > @@ -1020,15 +1021,13 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker { return shouldBlock({ currentLocation: { ...currentLocation, - pathname: - stripBasename(currentLocation.pathname, basename) || - currentLocation.pathname, + pathname: (stripBasename(currentLocation.pathname, basename) || + currentLocation.pathname) as Location["pathname"], }, nextLocation: { ...nextLocation, - pathname: - stripBasename(nextLocation.pathname, basename) || - nextLocation.pathname, + pathname: (stripBasename(nextLocation.pathname, basename) || + nextLocation.pathname) as Location["pathname"], }, historyAction, }); diff --git a/packages/router/__tests__/utils/utils.ts b/packages/router/__tests__/utils/utils.ts index bc123e9819..76fbd81a58 100644 --- a/packages/router/__tests__/utils/utils.ts +++ b/packages/router/__tests__/utils/utils.ts @@ -91,3 +91,11 @@ export function createSubmitRequest(path: string, opts?: RequestInit) { ...opts, }); } + +// See https://www.totaltypescript.com/how-to-test-your-types +export type Expect = T; +export type Equal = (() => T extends X ? 1 : 2) extends < + T +>() => T extends Y ? 1 : 2 + ? true + : false; diff --git a/packages/router/history.ts b/packages/router/history.ts index 335645db1c..7d627cb18b 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -57,6 +57,21 @@ export interface Path { * URL path, as well as possibly some arbitrary state and a key. */ export interface Location extends Path { + /** + * A URL pathname, beginning with a /. + */ + pathname: "" | `/${string}`; + + /** + * A URL search string, beginning with a ?. + */ + search: "" | `?${string}`; + + /** + * A URL fragment identifier, beginning with a #. + */ + hash: "" | `#${string}`; + /** * A value of arbitrary data associated with this location. */ @@ -536,7 +551,7 @@ export function createLocation( state: any = null, key?: string ): Readonly { - let location: Readonly = { + let location = { pathname: typeof current === "string" ? current : current.pathname, search: "", hash: "", @@ -547,7 +562,7 @@ export function createLocation( // But that's a pretty big refactor to the current test suite so going to // keep as is for the time being and just let any incoming keys take precedence key: (to && (to as Location).key) || key || createKey(), - }; + } as Readonly; return location; } diff --git a/packages/router/router.ts b/packages/router/router.ts index 1c1b04b4aa..e2494b442f 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -1290,7 +1290,7 @@ export function createRouter(init: RouterInit): Router { nextLocation = { ...nextLocation, ...init.history.encodeLocation(nextLocation), - }; + } as Location; let userReplace = opts && opts.replace != null ? opts.replace : undefined;