Skip to content

Commit

Permalink
Use inheritance for non-path params in $ children
Browse files Browse the repository at this point in the history
  • Loading branch information
fenok committed Jan 23, 2023
1 parent 85e1615 commit 4c69cdf
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 49 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
37 changes: 17 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ import { ROUTES } from "./path/to/routes";
{/* user/:id */}
<Route path={ROUTES.USER.relativePath} element={<User />}>
{/* details/:lang? */}
{/* $ effectively cuts everything to the left. */}
{/* $ effectively defines path pattern start. */}
<Route path={ROUTES.USER.$.DETAILS.relativePath} element={<UserDetails />} />
</Route>
</Routes>;
Expand All @@ -130,10 +130,12 @@ import { ROUTES } from "./path/to/routes";
// Relative link
<Link
// Path params: { lang?: string } -- optionality is governed by the path pattern.
// $ effectively cuts everything to the left.
to={ROUTES.USER.$.DETAILS.buildRelativePath({ lang: "en" })}
// Other params remain the same.
// $ effectively defines path pattern start.
to={ROUTES.USER.$.DETAILS.buildRelativePath({ lang: "en" }, { infoVisible: false }, "comments")}
state={ROUTES.USER.DETAILS.buildState({ fromUserList: false })}
>
details/en
details/en?infoVisible=false#comments
</Link>;
```

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -256,7 +258,9 @@ import { Route, Routes } from "react-router-dom"; // Or -native
</Routes>;
```

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 `<Routes />`

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 29 additions & 15 deletions src/common/createRoute/createRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@ type RouteWithChildren<
THash extends string[],
TStateTypes
> = DecoratedChildren<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes> &
Route<TPath, TPathTypes, TSearchTypes, THash, TStateTypes> & { $: TChildren };
Route<TPath, TPathTypes, TSearchTypes, THash, TStateTypes> & {
$: DecoratedChildren<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes, true>;
};

type DecoratedChildren<
TChildren,
TPath extends string,
TPathTypes,
TSearchTypes,
THash extends string[],
TStateTypes
TStateTypes,
TExcludePath extends boolean = false
> = {
[TKey in keyof TChildren]: TChildren[TKey] extends RouteWithChildren<
infer TChildChildren,
Expand All @@ -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
Expand Down Expand Up @@ -192,12 +201,10 @@ const createRoute =
types: RouteTypes<TPathTypes, TSearchTypes, THash, TStateTypes> = {},
children?: SanitizedChildren<TChildren>
): RouteWithChildren<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes> => {
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<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes>;
};

Expand All @@ -207,13 +214,15 @@ function decorateChildren<
TSearchTypes,
THash extends string[],
TStateTypes,
TChildren
TChildren,
TExcludePath extends boolean
>(
path: SanitizedPath<TPath>,
typesObj: RouteTypes<TPathTypes, TSearchTypes, THash, TStateTypes>,
creatorOptions: RouteOptions,
children?: TChildren
): DecoratedChildren<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes> {
children: TChildren | undefined,
excludePath: TExcludePath
): DecoratedChildren<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes, TExcludePath> {
const result: Record<string, unknown> = {};

if (children) {
Expand All @@ -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<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes>;
return result as DecoratedChildren<TChildren, TPath, TPathTypes, TSearchTypes, THash, TStateTypes, TExcludePath>;
}

function getRoute<
Expand Down
135 changes: 121 additions & 14 deletions src/dom/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IsExact<typeof TEST_ROUTE.path, "/test">>(true);
assert<IsExact<typeof TEST_ROUTE.$.CHILD.path, "/child">>(true);
assert<IsExact<typeof TEST_ROUTE.CHILD.$.GRANDCHILD.path, "/grand">>(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 });
Expand Down Expand Up @@ -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<IsExact<typeof TEST_ROUTE.path, "/test">>(true);
assert<IsExact<typeof TEST_ROUTE.$.CHILD.path, "/child">>(true);
assert<IsExact<typeof TEST_ROUTE.CHILD.$.GRANDCHILD.path, "/grand">>(true);
assert<IsExact<typeof TEST_ROUTE.$.CHILD.GRANDCHILD.path, "/child/grand">>(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<IsExact<Parameters<typeof TEST_ROUTE.buildPath>[0], Record<never, never>>>(true);
assert<IsExact<Parameters<typeof TEST_ROUTE.buildPath>[1], { foo?: number } | undefined>>(true);
assert<IsExact<Parameters<typeof TEST_ROUTE.buildPath>[2], "hashFoo" | undefined>>(true);

assert<IsExact<Parameters<typeof TEST_ROUTE.$.CHILD.buildPath>[0], Record<never, never>>>(true);
assert<IsExact<Parameters<typeof TEST_ROUTE.$.CHILD.buildPath>[1], { foo?: number; bar?: string } | undefined>>(
true
);
assert<IsExact<Parameters<typeof TEST_ROUTE.$.CHILD.buildPath>[2], "hashFoo" | "hashBar" | undefined>>(true);

assert<IsExact<Parameters<typeof TEST_ROUTE.CHILD.$.GRANDCHILD.buildPath>[0], Record<never, never>>>(true);
assert<
IsExact<
Parameters<typeof TEST_ROUTE.CHILD.$.GRANDCHILD.buildPath>[1],
{ foo?: number; bar?: string; baz?: boolean } | undefined
>
>(true);
assert<
IsExact<
Parameters<typeof TEST_ROUTE.CHILD.$.GRANDCHILD.buildPath>[2],
"hashFoo" | "hashBar" | "hashBaz" | undefined
>
>(true);

assert<IsExact<Parameters<typeof TEST_ROUTE.$.CHILD.GRANDCHILD.buildPath>[0], Record<never, never>>>(true);
assert<
IsExact<
Parameters<typeof TEST_ROUTE.$.CHILD.GRANDCHILD.buildPath>[1],
{ foo?: number; bar?: string; baz?: boolean } | undefined
>
>(true);
assert<
IsExact<
Parameters<typeof TEST_ROUTE.$.CHILD.GRANDCHILD.buildPath>[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<IsExact<Parameters<typeof TEST_ROUTE.buildPath>[0], { id: number }>>(true);
assert<IsExact<Parameters<typeof TEST_ROUTE.$.CHILD.buildPath>[0], { subId: boolean }>>(true);
assert<IsExact<Parameters<typeof TEST_ROUTE.CHILD.$.GRANDCHILD.buildPath>[0], { id: string }>>(true);
assert<IsExact<Parameters<typeof TEST_ROUTE.$.CHILD.GRANDCHILD.buildPath>[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 });
});

0 comments on commit 4c69cdf

Please sign in to comment.