router icon indicating copy to clipboard operation
router copied to clipboard

ToOptions doesnt't satisfy <Link> props.

Open leqwasd opened this issue 5 months ago • 28 comments

Describe the bug

  1. thing I need - a Component, that wraps <Link>, so I can add predefined styles to all of my links. To use "more advanced" paths with them, I found a Type - ToOptions (https://tanstack.com/router/v1/docs/framework/react/api/router/ToOptionsType), that I can pass to Link. So I created my own Link component that in its basic form looks like this:
import { Link, ToOptions } from '@tanstack/react-router';
import * as React from 'react';
type MyLinkProps = {
  toOptions: ToOptions;
};
const MyLink: React.FC<React.PropsWithChildren<MyLinkProps>> = ({
  children,
  toOptions,
}) => {
  return <Link {...toOptions}>{children}</Link>;
};
export default MyLink;

It works - I get a nice help from TypeScript autocompletes. It says - that I need "from" property in some situations.

  1. I want routes that looks like this: posts/$id posts/$id/A posts/$id/B

In posts/$id.tsx - I display <Outlet /> so I can see the subroutes.

If you run attached project like this - it works, MyLink component is fine. (See here) https://stackblitz.com/edit/github-rb4ewj?file=src%2FMyLink.tsx

When I navigate to posts/$id - <Outlet /> contains nothing... So - I was wondering - and added a new file posts/$id/index.tsx. (I created a Fork from the previous stackblitz here: https://stackblitz.com/edit/github-rb4ewj-tg9tx2?file=src%2FMyLink.tsx) And this is where things go wrong - Link now complains about something not satisfying something...

Type '{ children: ReactNode; to: "/" | "/posts/$id" | "/posts/$id/A" | "/posts/$id/B" | "/posts/$id/"; hash?: true | Updater<string> | undefined; state?: true | NonNullableUpdater<HistoryState> | undefined; from?: RoutePathsAutoComplete<...> | undefined; search?: true | ... 1 more ... | undefined; params?: true | ... 1 mo...' is not assignable to type 'IntrinsicAttributes & ({ to?: ToPathOption<Route<any, "/", "/", string, "__root__", RootSearchSchema, RootSearchSchema, RootSearchSchema, ... 12 more ..., any>, string, "/" | ... 3 more ... | "/posts/$id/"> | undefined; hash?: true | ... 1 more ... | undefined; state?: true | ... 1 more ... | undefined; from?: Route...'.
  Type '{ children: ReactNode; to: "/" | "/posts/$id" | "/posts/$id/A" | "/posts/$id/B" | "/posts/$id/"; hash?: true | Updater<string> | undefined; state?: true | NonNullableUpdater<HistoryState> | undefined; from?: RoutePathsAutoComplete<...> | undefined; search?: true | ... 1 more ... | undefined; params?: true | ... 1 mo...' is not assignable to type 'MakePathParamOptions<true | ParamsReducer<{} | {} | { id: string; } | ({ id: string; } & {}), {} | {} | ({ id: string; } & {})>>'.
    Types of property 'params' are incompatible.
      Type 'true | ((current: {} | {} | { id: string; } | ({ id: string; } & {})) => never) | undefined' is not assignable to type 'true | ParamsReducer<{} | {} | { id: string; } | ({ id: string; } & {}), {} | {} | ({ id: string; } & {})>'.
        Type 'undefined' is not assignable to type 'true | ParamsReducer<{} | {} | { id: string; } | ({ id: string; } & {}), {} | {} | ({ id: string; } & {})>'.(2322)

All I did - was just create an index.tsx under posts/$id folder.

maybe I am just doing this wrong? How else I can make a default subroute? I do need a common component that wraps children (in this case, it is /src/routes/posts/$id.tsx

Your Example Website or App

https://stackblitz.com/edit/github-rb4ewj?file=src%2FMyLink.tsx

Steps to Reproduce the Bug or Issue

  1. Go here: https://stackblitz.com/edit/github-rb4ewj?file=src%2FMyLink.tsx
  2. Add index.tsx file under /src/routes/posts/$id/ folder (you might neeed to restart vite dev so it regenreates the content for the file)
  3. Open MyLink component and see the issue.

Expected behavior

I expect Link to not complain about ToOptions passed to it.

Screenshots or Videos

No response

Platform

  • OS: ?
  • Browser: ? "@tanstack/react-router": "^1.19.0",

Additional context

No response

leqwasd avatar Mar 07 '24 11:03 leqwasd

ToOptions wouldn't satisfy all the possible options that a <Link> could take.

Try this.

import { LinkProps, Registered router } from "@tanstack/react-router"

interface MyLinkProps {
  linkProps: LinkProps<RegisteredRouter['routeTree']>
}

...
<Link params {...props.linkProps}>

SeanCassiere avatar Mar 07 '24 14:03 SeanCassiere

  1. LinkProps includes RegisteredRouter by default
export type LinkProps<TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], TFrom ....

And it doesn't work either.

Type '{ children: ((string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | ((state: { ...; }) => ReactNode)) & (string | ... 4 more ... | ReactPortal)) | null | undefined; ... 287 more ...; onTransitionEndCapture?: TransitionEventHandler<...> | undefined; }' is not assignable to type 'IntrinsicAttributes & ({ to?: ToPathOption<Route<any, "/", "/", string, "__root__", RootSearchSchema, RootSearchSchema, RootSearchSchema, ... 12 more ..., any>, string, "/" | ... 3 more ... | "/posts/$id/"> | undefined; hash?: true | ... 1 more ... | undefined; state?: true | ... 1 more ... | undefined; from?: Route...'.
  Type '{ children: ((string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | ((state: { ...; }) => ReactNode)) & (string | ... 4 more ... | ReactPortal)) | null | undefined; ... 287 more ...; onTransitionEndCapture?: TransitionEventHandler<...> | undefined; }' is not assignable to type 'MakePathParamOptions<true | ParamsReducer<{} | {} | { id: string; } | ({ id: string; } & {}), {} | {} | ({ id: string; } & {})>>'.
    Types of property 'params' are incompatible.
      Type 'true | ((current: {} | {} | { id: string; } | ({ id: string; } & {})) => never) | undefined' is not assignable to type 'true | ParamsReducer<{} | {} | { id: string; } | ({ id: string; } & {}), {} | {} | ({ id: string; } & {})>'.
        Type 'undefined' is not assignable to type 'true | ParamsReducer<{} | {} | { id: string; } | ({ id: string; } & {}), {} | {} | ({ id: string; } & {})>'.

leqwasd avatar Mar 07 '24 14:03 leqwasd

@schiller-manuel did anything change here? LinkProps used to work just fine.

SeanCassiere avatar Mar 08 '24 01:03 SeanCassiere

Adding

{...toOptions}
from={"/"}

satisfies props.. But this will overwrite the "from" from the toOptions

leqwasd avatar Mar 11 '24 09:03 leqwasd

Not sure if it covers all your use-cases, but I was able to get a Link wrapper working with something like this. Personally I find it cleaner passing the whole route object as opposed to just the to. That also allowed me to get by without having to pass a generic type to my custom Link component since typescript can directly infer it from the route prop.

// helper types
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
type ExcludeEmpty<T> = T extends AtLeastOne<T> ? T : never;

// seem to be as safe/strict as the props of the tanstack-provided Link component
type RouteParams<T extends AnyRoute = RegisteredRouter['routeTree']> = ExcludeEmpty<
  T['types']['params']
> extends never
  ? { params?: never }
  : { params: T['types']['params'] };
type RouteSearch<T extends AnyRoute = RegisteredRouter['routeTree']> = ExcludeEmpty<
  T['types']['searchSchema']
> extends never
  ? { search?: never }
  : {
      search?: T['types']['searchSchema'] | ((args: T['types']['fullSearchSchema']) => T['types']['searchSchema']);
    };

// component props
type MyLinkProps<T extends AnyRoute = RegisteredRouter['routeTree']> = {
  route: T;
  children: React.ReactNode;
  className?: string;
  disabled?: boolean;
} & RouteParams<T> &
  RouteSearch<T>;

// custom Link component
function MyLink<T extends AnyRoute = RegisteredRouter['routeTree']>({
  route,
  children,
  ...other
}: MyLinkProps<T>) {
  return (
    <Link<AnyRoute> to={route?.to} {...other}>
      {children}
    </Link>
  );
}


As a note - there might be a better way to skin this cat, but the docs seem to be pretty limited on the topic. Just figured I would share what worked for me in case you were still stuck here.

jfehrman avatar Mar 13 '24 17:03 jfehrman

So turns out this is what works.

const MyLink = <
  TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
  TFrom extends RoutePaths<TRouteTree> | string = string,
  TTo extends string = '',
  TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
  TMaskTo extends string = '',
>(
  props: React.PropsWithoutRef<
    LinkProps<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo> &
      Omit<React.HTMLProps<'a'>, 'children' | 'preload'>
  >
) => <Link {...props} />

SeanCassiere avatar Mar 19 '24 12:03 SeanCassiere

can we make use of this in createLink?

I don't want to expose all of these generics as public API, otherwise we cannot modify them without causing breaking changes

schiller-manuel avatar Mar 19 '24 12:03 schiller-manuel

So did anyone inspected - what's wrong with ToOptions? In my optinion - that is a user friendly type to be used in this place!

leqwasd avatar Mar 19 '24 12:03 leqwasd

This code was working

type MenuItem = { label: string; route: LinkProps; }; const MENUS: MenuItem[] = [ { label: 'Test', route: { params: (old) => old, search: (old) => ({...old, test: true}), }, }];

But I updated from 1.16.6 to 1.20.1 and it does not work anymore. I got the error : Type '{ params: (old: never) => never; search: (old: {}) => { test: boolean }; }' is not assignable to type 'LinkProps'. Property 'to' is missing in type '{ params: (old: never) => never; search: (old: {}) => { test: boolean }; }' but required in type 'CheckPathError<Route<any, "/", "/", string, "__root__", RootSearchSchema, RootSearchSchema, RootSearchSchema, RootSearchSchema, ... 11 more ..., any>>'.ts(2322) link.d.ts(72, 5): 'to' is declared here. Test.tsx(14, 3): The expected type comes from property 'route' which is declared here on type 'MenuItem'

Maquinours avatar Mar 19 '24 14:03 Maquinours

I've seen many similar issues/discussions and stackoverflow posts. At this point, we could really use some official document / examples on how to wrap <Link> components in a type safe way. Even the customized solutions that work, only the to prop is typed but things like search and params do not get type safety that match to.

ziw avatar Mar 24 '24 05:03 ziw

Yeah, this is basically the same as #1194. I proposed contributing a solution but there was no response from the maintainers at that time.

Here's the workaround I use currently:

(It uses a wrapper function to create the link options, so it can be used as a regular object. This also works for passing it into a component without requiring a ton of boilerplate generics everywhere.)

export type RouterLinkProps = Parameters<RegisteredRouter["navigate"]>[0];

/**
 * Validate a router link as type-safe and return a generic {@link RouterLinkProps}.
 * @example link({ to: "/view/$id", params: { id: "1" } })
 */
export function link<
    TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
    TFrom extends RoutePaths<TRouteTree> | string = string,
    TTo extends string = "",
    TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
    TMaskTo extends string = "",
>(options: UseLinkPropsOptions<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo>) {
    return options as RouterLinkProps;
}

The type can also be spread directly:

const someLink = link({ .... });
...
function MyComponent({ someLink } : { someLink: RouterLinkProps }) {
	return <Link {...someLink} />;
}

jaens avatar Mar 24 '24 22:03 jaens

I tried to update my typings based on @jaens example, but unfortunately it does not work in 1.22.0. My version looks like this:

function MyLink<
  TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
  TFrom extends RoutePaths<TRouteTree> | string = string,
  TTo extends string = "",
  TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
  TMaskTo extends string = "",
>({
  variant,
  testId,
  linkProps,
  children,
}: {
  variant?: StyledLinkProps["variant"];
  testId?: StyledLinkProps["testId"];
  linkProps: UseLinkPropsOptions<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo>;
  children?: ReactNode;
}) {
  return (
    <StyledLink variant={variant} testId={testId}>
      <Link {...linkProps}>{children}</Link>
    </StyledLink>
  );
}

I also tried this version by @SeanCassiere. This was interesting because my editor is happy but npm run build still breaks:

function MyLink<
  TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
  TFrom extends RoutePaths<TRouteTree> | string = string,
  TTo extends string = "",
  TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
  TMaskTo extends string = "",
>({
  variant,
  testId,
  linkProps,
  children,
}: {
  variant?: StyledLinkProps["variant"];
  testId?: StyledLinkProps["testId"];
  linkProps: React.PropsWithoutRef<
    LinkProps<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo> &
      Omit<React.HTMLProps<'a'>, 'children' | 'preload'>
  >;
  children?: ReactNode;
}) {
  return (
    <StyledLink variant={variant} testId={testId}>
      <Link {...linkProps}>{children}</Link>
    </StyledLink>
  );
}

This previous version worked fine in 1.19.4:

function MyLink<
  TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
  TFrom extends RoutePaths<TRouteTree> | string = string,
  TTo extends string = "",
  TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
  TMaskTo extends string = "",
>({
  variant,
  testId,
  linkProps,
  children,
}: {
  variant?: StyledLinkProps["variant"];
  testId?: StyledLinkProps["testId"];
  linkProps: LinkProps<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo> & React.RefAttributes<HTMLAnchorElement>;
  children?: ReactNode;
}) {
  return (
    <StyledLink variant={variant} testId={testId}>
      <Link {...linkProps}>{children}</Link>
    </StyledLink>
  );
}

I've seen many similar issues/discussions and stackoverflow posts. At this point, we could really use some official document / examples on how to wrap components in a type safe way

@ziw 100% this. There is a clear need to create a custom wrapper for the provided Link component and it becomes a big problem if the typings of the library do not remain stable.

Jarzka avatar Mar 25 '24 13:03 Jarzka

I also tried this version by @SeanCassiere. This was interesting because my editor is happy but npm run build still breaks:

Not sure if this helps, but there's a variation of this, that I'm using that works.

const AppNavigationLink = <
  TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
  TFrom extends RoutePaths<TRouteTree> | string = string,
  TTo extends string = "",
  TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
  TMaskTo extends string = "",
>(props: {
  name: string;
  props: Omit<
    React.PropsWithoutRef<
      LinkProps<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo> &
        Omit<React.ComponentPropsWithoutRef<"a">, "preload">
    >,
    "children" | "className" | "activeProps" | "inactiveProps"
  >;
}) => {
  const { name, props: linkProps } = props;
  return (
    <li>
      <Link
        className="..."
        activeProps={{ className: "..." }}
        inactiveProps={{ className: "..." }}
        {...linkProps}
      >
        {name}
      </Link>
    </li>
  );
};

Source

SeanCassiere avatar Mar 25 '24 21:03 SeanCassiere

I've seen many similar issues/discussions and stackoverflow posts. At this point, we could really use some official document / examples on how to wrap components in a type safe way

@ziw 100% this. There is a clear need to create a custom wrapper for the provided Link component and it becomes a big problem if the typings of the library do not remain stable.

We are working on this!

From what I can gather, most of the questions on the Discord questions channel and here related to this topic, is around having a type which can be applied into a custom component's props, to then later be applied on the TSR provided <Link> component.

import { Link, type SomeNewLinkPropsType } from "@tanstack/react-router";

function CustomLink({ linkProps } : { linkProps: SomeNewLinkPropsType }) {
  const { className, ...rest } = linkProps;
  return <Link className={`foo-bar ${className}`} {...rest} />
}

Once we figure out the correct story for the user-facing types for this, we'll make sure the documentation is updated to reflect the correct way forward.

SeanCassiere avatar Mar 25 '24 21:03 SeanCassiere

So I looked at createLink, and I think it works pretty much the way we want it to, regarding type-safety and everything. Here's a TS playground:

https://tsplay.dev/Wzq93m

I've tested with additional properties, searchParams validation etc, it all looks good. One issue is that createLink freezes the browser at runtime. No kidding, this was weird, but I have a PR open that fixes it.

Once that is merged, I think createLink is a good abstraction to use in user-land, because it's type-safe and you can compose over every component that you want.

Only requirement is that props are being spread onto an actual a tag. This is something that is not type-safe, and maybe something that we should document.

Let me know if there's anything I'm missing

TkDodo avatar Mar 26 '24 13:03 TkDodo

So I looked at createLink, and I think it works pretty much the way we want it to, regarding type-safety and everything. Here's a TS playground:

https://tsplay.dev/Wzq93m

ok, cool, that seems to mostly work..

One issue though is that once the component is wrapped in createLink it has some props which are set to undefined or an empty object. When deconstructing these into the component they overwrite any props set by the component (as intended, if the props were actually set by the consumer).

eg the className set in the example below is overwritten with undefined from the props.

const MyStyledRouterLink = createLink(MyStyledLink);

function MyStyledLink({ children, ...props }: React.ComponentProps<"a">) {
  return (
    <a className="foo-bar" {...props}>
      {children}
    </a>
  );
}

if I stringify & parse the props to remove the undefined it works as intended.

{...JSON.parse(JSON.stringify(props))}
Screenshot 2024-03-28 at 11 01 16 pm

lecstor avatar Mar 28 '24 13:03 lecstor

also.. I was pleasantly surprised when I initially used Link for external links and it all seemed to work fine. While testing createLink I noticed that it complained about the absolute url not being assignable, then I tested Link and it did the same so I thought I must have been tripping, but looking at the code it looks like it's intended to work, so I include the props I see for those as well..

Screenshot 2024-03-28 at 10 58 02 pm

and submit a PR which I believe resolves the props issue.. (but not the type error on to with absolute urls) https://github.com/TanStack/router/pull/1386

lecstor avatar Mar 28 '24 14:03 lecstor

It's probably not relevant anymore, but LinkProps broke for me in 1.17.5. It still worked in 1.17.4.

vixducis avatar Apr 04 '24 12:04 vixducis

I am using 1.32.5 and the custom component linkProps does throw some TS issues:

Type TRouteTree does not satisfy the constraint AnyRouter

I think this is related to the same issue, I had following code which does not satisfy TS any longer:

function TabbedNavigationItem<
  TRouteTree extends AnyRoute = RegisteredRouter["routeTree"],
  TFrom extends RoutePaths<TRouteTree> | string = string,
  TTo extends string = "",
  TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
  TMaskTo extends string = ""
>({
  label,
  activeHref,
  linkProps
}: {
  linkProps: LinkProps<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo> &
    Omit<React.HTMLProps<"a">, "children" | "preload">
  label: string
  activeHref: LinkProps["to"]
}) {

dohomi avatar May 14 '24 05:05 dohomi