Open benlongo opened 2 months ago
Hello! Best I can do is this:
import { useCallback } from "react";
import { Router } from "./router";
type EmptyRecord = Record<PropertyKey, never>;
type UseRouteReturnType = NonNullable<ReturnType<typeof Router.useRoute>>;
// we only want routes with params (this hook would be useless if you can't update the params)
type RouteWithParams = Exclude<
UseRouteReturnType,
Extract<UseRouteReturnType, { params: EmptyRecord }>
>;
// remove wildcards routes (you cannot a link to a "Area" route - they don't exist in `Router`)
type RoutesParams = {
[K in RouteWithParams as K["name"] extends keyof typeof Router
? K["name"]
: never]: K["params"];
};
export const useRouteParams = <const K extends keyof RoutesParams>(name: K) => {
const route = Router.useRoute([name]);
if (!route) {
throw new Error("Attempted to use route params on page with unknown route");
}
return [
route.params as RoutesParams[K], // this cast stay mandatory
useCallback(
(params: RoutesParams[K], options: { replace?: boolean } = {}) => {
const { replace = false } = options;
if (replace) {
Router.replace(route.name, params);
} else {
Router.push(route.name, params);
}
},
[route],
),
] as const;
};
But I find something like this is way easier to use and it does not introduce something new:
export const Repository = () => {
const route = Router.useRoute(["Repository"]);
if (!route) {
return <h1>404</h1>;
}
return (
<div>
<Link
to={Router[route.name]({ ...route.params })}
activeStyle={{ fontWeight: 700 }}
>
Home
</Link>
<button
onClick={() => {
Router.push(route.name, { ...route.params });
}}
>
Push
</button>
<button
onClick={() => {
Router.replace(route.name, { ...route.params });
}}
>
Replace
</button>
</div>
);
};
Thanks for the help! I should've provided a little more motivation as to why exactly I want this behavior. I have a a bunch of related routes that share a set of query parameters which I want to use to hold state. Although it's possible with .push
and .replace
, with multiple routes involved the ergonomics are a bit rough. The other additional behavior I wanted (which is out of scope for features of chicane IMO) is optional persistence of these parameters into session/local storage. Here's what I ended up with (far from perfect but working for now).
import { useCallback, useEffect } from 'react';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { Router } from '../router';
type EmptyRecord = Record<string | number | symbol, never>;
type AllRoutes = Exclude<ReturnType<typeof Router.useRoute>, undefined>;
export type RouteParams = {
[K in AllRoutes as K['params'] extends EmptyRecord
? never
: K['name']]: K['params'];
};
type JustOptionalRouteParams = {
[R in keyof RouteParams]: {
[K in keyof RouteParams[R] as undefined extends RouteParams[R][K]
? K
: never]: RouteParams[R][K];
};
};
export type OptionalRouteParams = {
[R in keyof JustOptionalRouteParams as JustOptionalRouteParams[R] extends EmptyRecord
? never
: R]: JustOptionalRouteParams[R];
};
type RouteName = keyof RouteParams;
export type RouteParamsSetterFn<R extends RouteName> = (
newValue: Partial<RouteParams[R]>,
opts?: { replace?: boolean },
) => void;
export type UseRouteParamsReturn<R extends RouteName> = readonly [
RouteParams[R],
RouteParamsSetterFn<R>,
];
type Simplify<T> = {
[KeyType in keyof T]: T[KeyType];
// eslint-disable-next-line @typescript-eslint/ban-types
} & {};
type ExpandRouteParamUnion<
TRoutes extends readonly unknown[],
A = never,
> = TRoutes extends [infer H, ...infer R]
? H extends RouteName
? ExpandRouteParamUnion<R, A | RouteParams[H]>
: never
: A;
type RouteParamUnion<TRoutes extends readonly RouteName[]> =
ExpandRouteParamUnion<TRoutes>;
type CommonRouteParamUnion<TRoutes extends readonly RouteName[]> = Simplify<
Pick<RouteParamUnion<TRoutes>, keyof ExpandRouteParamUnion<TRoutes>>
>;
export function useRouteParams<
TRoutes extends readonly [RouteName, ...RouteName[]],
>(
routes: TRoutes,
): readonly [
RouteParamUnion<TRoutes>,
(
params: Partial<CommonRouteParamUnion<TRoutes>>,
opts?: { replace?: boolean },
) => void,
] {
if (routes.length === 0) throw new Error('at least one route is required');
const currentRoute = Router.useRoute(routes);
if (!currentRoute) {
throw new Error('cannot use route params for different route');
}
const currentParams = currentRoute.params as RouteParamUnion<TRoutes>;
const setter = useCallback(
(
newParams: Partial<CommonRouteParamUnion<TRoutes>>,
opts?: { replace?: boolean },
) => {
const { replace } = opts ?? {};
const mergedParams = {
// this type is not technically correct, but the union fails to compile
...(currentParams as CommonRouteParamUnion<TRoutes>),
...newParams,
};
if (replace) {
Router.replace(currentRoute.name, mergedParams);
} else {
Router.push(currentRoute.name, mergedParams);
}
},
[currentParams, currentRoute.name],
);
return [currentParams, setter];
}
interface RouteParamStore {
routeParams: Partial<RouteParams>;
setParams: <R extends RouteName>(
routeName: R,
params: RouteParams[R],
) => void;
}
const useRouteParamStore = create<RouteParamStore>()(
persist(
(set) => ({
routeParams: {},
setParams: (routeName, params) => {
set((state) => ({
routeParams: { ...state.routeParams, [routeName]: params },
}));
},
}),
{
name: 'valis.route-params',
storage: createJSONStorage(() => sessionStorage),
},
),
);
export function usePersistentRouteParams<
TRoutes extends readonly [RouteName, ...RouteName[]],
>(routes: TRoutes) {
if (routes.length === 0) throw new Error('at least one route is required');
const storedParams = useRouteParamStore((s) => s.routeParams);
const setStoredParams = useRouteParamStore((s) => s.setParams);
const currentRoute = Router.useRoute(routes);
const [currentRouteParams, setCurrentRouteParams] = useRouteParams(routes);
if (currentRoute === undefined) throw new Error();
useEffect(() => {
const storedRoutedParams =
currentRoute.name in storedParams
? storedParams[currentRoute.name]
: undefined;
// Merge the route params with the URL taking precedence
const mergedParams = {
...storedRoutedParams,
...(currentRouteParams as CommonRouteParamUnion<TRoutes>),
} as RouteParamUnion<TRoutes>;
if (!paramEqual(currentRouteParams, mergedParams)) {
setCurrentRouteParams(mergedParams, { replace: true });
}
if (!paramEqual(storedRoutedParams ?? {}, mergedParams)) {
setStoredParams(currentRoute.name, mergedParams);
}
}, [
currentRoute.name,
storedParams,
currentRouteParams,
setCurrentRouteParams,
setStoredParams,
]);
const setter = useCallback(
(
newParams: Partial<CommonRouteParamUnion<TRoutes>>,
opts?: { replace?: boolean },
) => {
for (const route of routes) {
if (route === currentRoute.name) {
setCurrentRouteParams(newParams, opts);
} else {
setStoredParams(route, {
...storedParams[route],
...newParams,
});
}
}
},
[
routes,
currentRoute.name,
setCurrentRouteParams,
setStoredParams,
storedParams,
],
);
return [currentRouteParams, setter] as const;
}
type GenericParams = Record<string, string | string[] | undefined>;
function paramEqual(p1: GenericParams, p2: GenericParams) {
const p1Keys = new Set(Object.keys(p1));
const p2Keys = new Set(Object.keys(p2));
if (p1Keys.size !== p2Keys.size) return false;
if (![...p1Keys].every((k) => p2Keys.has(k))) return false;
for (const k of p1Keys) {
const p1Value = p1[k];
const p2Value = p2[k];
if (typeof p1Value !== typeof p2Value) return false;
if (typeof p1Value === 'string') {
if (p1Value !== p2Value) return false;
} else if (typeof p1Value === 'object') {
if (p1Value.length !== p2Value?.length) return false;
if (!p1Value.every((v, i) => v === p2Value[i])) return false;
} else {
if (p1Value !== p2Value) return false;
}
}
return true;
}
I'm happy to close this issue or if you have some thoughts about this approach I'd love to hear them :)
Also thanks for providing this wonderful library! It's been a pleasure to work with.
Also thanks for providing this wonderful library! It's been a pleasure to work with.
Thanks, this means a lot! 🙂
Not sure what your routes are, but what you describe is a really good job for createGroup
:
import { createGroup, createRouter } from "@swan-io/chicane";
const RepositoryGroup = createGroup(
"Repository",
"/:userId/:repositoryId?:env{live|sandbox}",
{
Area: "/*",
Home: "/",
Issues: "/issues",
PullRequests: "/pulls",
Discussions: "/discussions?:from", // createGroup is able to concat path and search params at type level
Actions: "/actions",
Settings: "/settings",
},
);
// RepositoryGroup TYPE is: {
// RepositoryArea: "/:userId/:repositoryId/*?:env{live|sandbox}";
// RepositoryHome: "/:userId/:repositoryId?:env{live|sandbox}";
// RepositoryIssues: "/:userId/:repositoryId/issues?:env{live|sandbox}";
// RepositoryPullRequests: "/:userId/:repositoryId/pulls?:env{live|sandbox}";
// RepositoryDiscussions: "/:userId/:repositoryId/discussions?:env{live|sandbox}&:from";
// RepositoryActions: "/:userId/:repositoryId/actions?:env{live|sandbox}";
// RepositorySettings: "/:userId/:repositoryId/settings?:env{live|sandbox}";
// }
export const Router = createRouter({
Home: "/",
...RepositoryGroup,
});
In this example, all routes prefixed with Repository*
share at least { userId: string; repositoryId: string; env?: "live" | "sandbox" }
params type (but you can define more per route).
This means that if you match RepositoryArea
in a parent and pass all params, they will be valid for all subroutes:
const App = () => {
const route = Router.useRoute(["RepositoryArea"]);
if (route == null) {
return <h1>404</h1>;
}
return <RepositoryArea params={route.params} />;
};
const RepositoryArea = ({
params,
}: {
params: {
userId: string;
repositoryId: string;
env?: "live" | "sandbox";
};
}) => {
// do what you want with params…
};
Why it is needed?
I'm trying to create a hook that behaves like
useState
for interacting with route params.Possible implementation
Here's my attempt at an implementation, but I can't figure out why I need the
@ts-expect-error
and theas
casting. Is this a limitation with the library or am I missing some typescript stuff?