From 4c69cdf873a6b0f71f97f3dc8bd3142efbabfd1c Mon Sep 17 00:00:00 2001 From: Leonid Fenko Date: Mon, 23 Jan 2023 12:36:46 +0100 Subject: [PATCH] Use inheritance for non-path params in $ children --- CHANGELOG.md | 1 + README.md | 37 ++++--- src/common/createRoute/createRoute.ts | 44 ++++++--- src/dom/route.test.ts | 135 +++++++++++++++++++++++--- 4 files changed, 168 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7478b5cce..fcf1bfd6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking**: Fallbacks are now run through type functions to ensure fallbacks validity, and therefore `TRetrieved` was replaced with `TOriginal` in their type. This is technically a breaking change, but it only affects custom types where `TRetrieved` is not assignable to `TOriginal`, which should be extremely rare. - **Breaking**: Minimal required React Router version is changed to `6.7.0` due to optional path segments support. - **Breaking**: Rename `ExtractRouteParams` to `PathParam` for parity with React Router. +- **Breaking**: In route object, `$` no longer contains undecorated child routes. Instead, it now contains routes that lack parent path template and path type objects, but inherit everything else. - `buildPath`/`buildRelativePath` now accept additional arguments and behave exactly like `buildUrl`/`buildRelativeUrl`. - `setTypedSearchParams` is switched to React Router implementation of functional updates. diff --git a/README.md b/README.md index 6742b26ee..1c2cbbef1 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ import { ROUTES } from "./path/to/routes"; {/* user/:id */} }> {/* details/:lang? */} - {/* $ effectively cuts everything to the left. */} + {/* $ effectively defines path pattern start. */} } /> ; @@ -130,10 +130,12 @@ import { ROUTES } from "./path/to/routes"; // Relative link - details/en + details/en?infoVisible=false#comments ; ``` @@ -199,22 +201,22 @@ console.log(USER.path); // "/user/:id" console.log(USER.DETAILS.path); // "/user/:id/details" ``` -They can also be uninlined beforehand or after the fact: +They can also be uninlined, most likely for usage in multiple places: ```tsx import { route } from "react-router-typesafe-routes/dom"; // Or /native const DETAILS = route("details"); + const USER = route("user/:id", {}, { DETAILS }); +const POST = route("post/:id", {}, { DETAILS }); -console.log(USER.$.DETAILS === DETAILS); // true console.log(USER.DETAILS.path); // "/user/:id/details" +console.log(POST.DETAILS.path); // "/post/:id/details" console.log(DETAILS.path); // "/details" ``` -That is, the `$` property of every route contains original routes, specified as children of that route. The mental model here is that `$` cuts everything to the left. The entire API works as if there is nothing there. - -Again, `DETAILS` (or `USER.$.DETAILS`) and `USER.DETAILS` are separate routes, which will usually behave differently. `DETAILS` doesn't know anything about `USER`, but `USER.DETAILS` does. `DETAILS` is a standalone route, but `USER.DETAILS` is a child of `USER`. +To reiterate, `DETAILS` and `USER.DETAILS` are separate routes, which will usually behave differently. `DETAILS` doesn't know anything about `USER`, but `USER.DETAILS` does. `DETAILS` is a standalone route, but `USER.DETAILS` is a child of `USER`. > ❗Child routes have to start with an uppercase letter to prevent overlapping with route API. @@ -256,7 +258,9 @@ import { Route, Routes } from "react-router-dom"; // Or -native ; ``` -That is, `path` contains a combined path with a leading slash (`/`), and `relativePath` contains a combined path **without intermediate stars (`*`)** and without a leading slash (`/`). +That is, the `$` property of every route contains child routes that lack parent path pattern. The mental model here is that `$` defines the path pattern start. + +The `path` property contains a combined path with a leading slash (`/`), and `relativePath` contains a combined path **without intermediate stars (`*`)** and without a leading slash (`/`). #### Nested `` @@ -391,6 +395,8 @@ Child routes inherit all type objects from their parent. For parameters with the Hash values are combined. If a parent allows any `string` to be a hash value, its children can't override that. +Child routes under `$` don't inherit parent type objects for path params. + #### Types composition It's pretty common to have completely unrelated routes that share the same set of params. One such example is pagination params. @@ -413,18 +419,9 @@ const [{ page }] = useTypedSearchParams(ROUTE); However, this approach has the following drawbacks: - All routes will have all common params, even if they don't need them. -- Common params are lost upon child uninlining. - All common params are defined in one place, which may get cluttered. - We can't share path params this way, because they require the corresponding path pattern. -```tsx -// This is allowed, but makes no sense since there is no pagination on this route. -ROUTE.ABOUT.buildPath({}, { page: 1 }); - -// This won't work, but we need this param. -ROUTE.$.POST.buildPath({}, { page: 1 }); -``` - To mitigate these issues, we can use type composition via the [`types()`](#types) helper: ```tsx @@ -493,7 +490,7 @@ The `route()` helper returns a route object, which has the following fields: - `getUntypedParams()`, `getUntypedSearchParams()`, and `getUntypedState()` for retrieving untyped params from React Router primitives. Typed params are omitted. Note that the hash is always typed. - `getPlainParams()` and `getPlainSearchParams()` for building React Router primitives from typed params. Note how hash and state don't need these functions because `buildHash()` and `buildState()` can be used instead. - `types`, which contains type objects and hash values of the route. Can be used for sharing types with other routes, though normally you should use the [`types()`](#types) helper instead. -- `$`, which contains the original child routes. These routes are unaffected by the parent route. +- `$`, which contains child routes that lack the parent path pattern and the corresponding type objects. - Any number of child routes starting with an uppercase letter. ### Built-in types diff --git a/src/common/createRoute/createRoute.ts b/src/common/createRoute/createRoute.ts index 686963ca5..887d99f45 100644 --- a/src/common/createRoute/createRoute.ts +++ b/src/common/createRoute/createRoute.ts @@ -17,7 +17,9 @@ type RouteWithChildren< THash extends string[], TStateTypes > = DecoratedChildren & - Route & { $: TChildren }; + Route & { + $: DecoratedChildren; + }; type DecoratedChildren< TChildren, @@ -25,7 +27,8 @@ type DecoratedChildren< TPathTypes, TSearchTypes, THash extends string[], - TStateTypes + TStateTypes, + TExcludePath extends boolean = false > = { [TKey in keyof TChildren]: TChildren[TKey] extends RouteWithChildren< infer TChildChildren, @@ -37,8 +40,14 @@ type DecoratedChildren< > ? RouteWithChildren< TChildChildren, - TPath extends "" ? TChildPath : TChildPath extends "" ? TPath : `${TPath}/${TChildPath}`, - TPathTypes & TChildPathTypes, + TExcludePath extends true + ? TChildPath + : TPath extends "" + ? TChildPath + : TChildPath extends "" + ? TPath + : `${TPath}/${TChildPath}`, + TExcludePath extends true ? TChildPathTypes : TPathTypes & TChildPathTypes, TSearchTypes & TChildSearchTypes, THash | TChildHash, TStateTypes & TChildStateTypes @@ -192,12 +201,10 @@ const createRoute = types: RouteTypes = {}, children?: SanitizedChildren ): RouteWithChildren => { - const decoratedChildren = decorateChildren(path, types, creatorOptions, children); - return { - ...decoratedChildren, + ...decorateChildren(path, types, creatorOptions, children, false), ...getRoute(path, types, creatorOptions), - $: children, + $: decorateChildren(path, types, creatorOptions, children, true), } as RouteWithChildren; }; @@ -207,13 +214,15 @@ function decorateChildren< TSearchTypes, THash extends string[], TStateTypes, - TChildren + TChildren, + TExcludePath extends boolean >( path: SanitizedPath, typesObj: RouteTypes, creatorOptions: RouteOptions, - children?: TChildren -): DecoratedChildren { + children: TChildren | undefined, + excludePath: TExcludePath +): DecoratedChildren { const result: Record = {}; if (children) { @@ -222,18 +231,23 @@ function decorateChildren< result[key] = isRoute(value) ? { - ...decorateChildren(path, typesObj, creatorOptions, value), + ...decorateChildren(path, typesObj, creatorOptions, value, excludePath), ...getRoute( - path === "" ? value.path.substring(1) : value.path === "/" ? path : `${path}${value.path}`, - types(typesObj)(value.types), + excludePath || path === "" + ? value.path.substring(1) + : value.path === "/" + ? path + : `${path}${value.path}`, + types(excludePath ? { ...typesObj, params: undefined } : typesObj)(value.types), creatorOptions ), + $: decorateChildren(path, typesObj, creatorOptions, value.$, true), } : value; }); } - return result as DecoratedChildren; + return result as DecoratedChildren; } function getRoute< diff --git a/src/dom/route.test.ts b/src/dom/route.test.ts index 2043edfe0..85f34012f 100644 --- a/src/dom/route.test.ts +++ b/src/dom/route.test.ts @@ -12,20 +12,6 @@ import { } from "../common/index.js"; import { assert, IsExact } from "conditional-type-checks"; -it("allows to uninline children", () => { - const GRANDCHILD = route("grand"); - const CHILD = route("child", {}, { GRANDCHILD }); - const TEST_ROUTE = route("test", {}, { CHILD }); - - assert>(true); - assert>(true); - assert>(true); - - expect(TEST_ROUTE.path).toEqual("/test"); - expect(TEST_ROUTE.$.CHILD.path).toEqual("/child"); - expect(TEST_ROUTE.CHILD.$.GRANDCHILD.path).toEqual("/grand"); -}); - it("provides absolute path", () => { const GRANDCHILD = route("grand"); const CHILD = route("child", {}, { GRANDCHILD }); @@ -974,3 +960,124 @@ it("allows types composition", () => { expect(ROUTE.buildState({ fromList: true, hidden: true })).toStrictEqual({ fromList: "true", hidden: "true" }); }); + +it("allows to trim path template", () => { + const GRANDCHILD = route("grand"); + const CHILD = route("child", {}, { GRANDCHILD }); + const TEST_ROUTE = route("test", {}, { CHILD }); + + assert>(true); + assert>(true); + assert>(true); + assert>(true); + + expect(TEST_ROUTE.path).toEqual("/test"); + expect(TEST_ROUTE.$.CHILD.path).toEqual("/child"); + expect(TEST_ROUTE.CHILD.$.GRANDCHILD.path).toEqual("/grand"); + expect(TEST_ROUTE.$.CHILD.GRANDCHILD.path).toEqual("/child/grand"); +}); + +it("allows to inherit non-path params in trimmed children", () => { + const GRANDCHILD = route("grand", { + searchParams: { baz: booleanType }, + state: { stateBaz: stringType }, + hash: hashValues("hashBaz"), + }); + const CHILD = route( + "child", + { searchParams: { bar: stringType }, state: { stateBar: numberType }, hash: hashValues("hashBar") }, + { GRANDCHILD } + ); + const TEST_ROUTE = route( + "test", + { searchParams: { foo: numberType }, state: { stateFoo: booleanType }, hash: hashValues("hashFoo") }, + { CHILD } + ); + + assert[0], Record>>(true); + assert[1], { foo?: number } | undefined>>(true); + assert[2], "hashFoo" | undefined>>(true); + + assert[0], Record>>(true); + assert[1], { foo?: number; bar?: string } | undefined>>( + true + ); + assert[2], "hashFoo" | "hashBar" | undefined>>(true); + + assert[0], Record>>(true); + assert< + IsExact< + Parameters[1], + { foo?: number; bar?: string; baz?: boolean } | undefined + > + >(true); + assert< + IsExact< + Parameters[2], + "hashFoo" | "hashBar" | "hashBaz" | undefined + > + >(true); + + assert[0], Record>>(true); + assert< + IsExact< + Parameters[1], + { foo?: number; bar?: string; baz?: boolean } | undefined + > + >(true); + assert< + IsExact< + Parameters[2], + "hashFoo" | "hashBar" | "hashBaz" | undefined + > + >(true); + + expect(TEST_ROUTE.buildPath({}, { foo: 1 }, "hashFoo")).toEqual("/test?foo=1#hashFoo"); + expect(TEST_ROUTE.$.CHILD.buildPath({}, { foo: 1, bar: "test" }, "hashBar")).toEqual( + "/child?foo=1&bar=test#hashBar" + ); + expect(TEST_ROUTE.CHILD.$.GRANDCHILD.buildPath({}, { foo: 1, bar: "test", baz: false }, "hashBaz")).toEqual( + "/grand?foo=1&bar=test&baz=false#hashBaz" + ); + expect(TEST_ROUTE.$.CHILD.GRANDCHILD.buildPath({}, { foo: 1, bar: "test", baz: false }, "hashBaz")).toEqual( + "/child/grand?foo=1&bar=test&baz=false#hashBaz" + ); + + const testSearchParams = createSearchParams({ foo: "1", bar: "test", baz: "false" }); + + expect(TEST_ROUTE.getTypedSearchParams(testSearchParams)).toStrictEqual({ foo: 1 }); + expect(TEST_ROUTE.$.CHILD.getTypedSearchParams(testSearchParams)).toStrictEqual({ foo: 1, bar: "test" }); + expect(TEST_ROUTE.CHILD.$.GRANDCHILD.getTypedSearchParams(testSearchParams)).toStrictEqual({ + foo: 1, + bar: "test", + baz: false, + }); + expect(TEST_ROUTE.$.CHILD.GRANDCHILD.getTypedSearchParams(testSearchParams)).toStrictEqual({ + foo: 1, + bar: "test", + baz: false, + }); +}); + +it("prevents path param inheritance in trimmed children", () => { + const GRANDCHILD = route("grand/:id", {}); + const CHILD = route("child/:subId", { params: { subId: booleanType } }, { GRANDCHILD }); + const TEST_ROUTE = route("test/:id", { params: { id: numberType } }, { CHILD }); + + assert[0], { id: number }>>(true); + assert[0], { subId: boolean }>>(true); + assert[0], { id: string }>>(true); + assert[0], { id: string; subId: boolean }>>( + true + ); + + expect(TEST_ROUTE.buildPath({ id: 1 })).toEqual("/test/1"); + expect(TEST_ROUTE.$.CHILD.buildPath({ subId: true })).toEqual("/child/true"); + expect(TEST_ROUTE.CHILD.$.GRANDCHILD.buildPath({ id: "test" })).toEqual("/grand/test"); + expect(TEST_ROUTE.$.CHILD.GRANDCHILD.buildPath({ subId: true, id: "test" })).toEqual("/child/true/grand/test"); + + expect(TEST_ROUTE.getTypedParams({ id: "1", subId: "true" })).toEqual({ id: 1 }); + expect(TEST_ROUTE.$.CHILD.getTypedParams({ id: "1", subId: "true" })).toEqual({ subId: true }); + expect(TEST_ROUTE.CHILD.$.GRANDCHILD.getTypedParams({ id: "1", subId: "true" })).toEqual({ id: "1" }); + expect(TEST_ROUTE.$.CHILD.GRANDCHILD.getTypedParams({ id: "1", subId: "true" })).toEqual({ id: "1", subId: true }); +});