swan-io / chicane

A simple and safe router for React and TypeScript.
https://swan-io.github.io/chicane/
MIT License
382 stars 9 forks source link

Type-safe hook for mutating route params #49

Open benlongo opened 2 months ago

benlongo commented 2 months ago

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 the as casting. Is this a limitation with the library or am I missing some typescript stuff?

import { useCallback } from 'react';
import { create } from 'zustand';
import { Router } from '../router';

type EmptyRecord = Record<string | number | symbol, never>;

type AllRoutes = Exclude<ReturnType<typeof Router.useRoute>, undefined>;

type RouteParams = {
  [K in AllRoutes as K['params'] extends EmptyRecord
    ? never
    : K['name']]: K['params'];
};
type RouteNames = keyof RouteParams;

export type RouteParamsSetterFn<R extends RouteNames> = (
  newValue: RouteParams[R],
  opts?: { replace?: boolean },
) => void;

export type UseRouteParamsReturn<R extends RouteNames> = readonly [
  RouteParams[R],
  RouteParamsSetterFn<R>,
];

export function useRouteParams<const R extends RouteNames>(
  route: R,
): UseRouteParamsReturn<R> {
  const currentRoute = Router.useRoute<R>([route]);
  if (!currentRoute) {
    throw new Error('Attempted to use route params on page with unknown route');
  }
  // It's not clear to me why this 'as' is required here, but it should be safe
  const currentParams = currentRoute.params as RouteParams[R];

  const setter: RouteParamsSetterFn<R> = useCallback(
    (newValue, opts) => {
      const { replace = false } = opts ?? {};
      if (replace) {
        Router.replace<R>(
          route,
          // @ts-expect-error the params type to .replace isn't happy but can't figure out why
          newValue,
        );
      } else {
        Router.push<R>(
          route,
          // @ts-expect-error the params type to .push isn't happy but can't figure out why
          newValue,
        );
      }
    },
    [route],
  );

  return [currentParams, setter];
}
zoontek commented 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>
  );
};
benlongo commented 2 months ago

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.

zoontek commented 2 months ago

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…
};