Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typesafe useRouteLoaderData behind future flag #12535

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from

Conversation

jeyj0
Copy link

@jeyj0 jeyj0 commented Dec 13, 2024

This PR implements a new future flag: typesafeUseRouteLoaderData, which improves the type for useRouteLoaderData by making use of the new type generation infrastructure.

Note: I'm not sure if this affects library-mode. If it does, there's probably more work to be done.

Benefits

There are two benefits when enabling the flag:

Benefit 1: Type Inference

// instead of
const data = useRouteLoaderData<typeof rootLoader>("root");

// we just do
const data = useRouteLoaderData("root");

Benefit 2: Type Errors

// this throws a type-error if no route with id "doesnt_exist" exists
const data = useRouteLoaderData("doesnt_exist");

How does it work?

The current useRouteLoaderData type from its actual implementation is made impossible to reach, by requiring the parameter routeId to be of type never. The actual type is then declared using module augmentation within the user's .react-router/types directory. The actual generated type depends on the future flag.

When typesafeUseRouteLoaderData is disabled

Unfortunately, there is an additional generated file in .react-router/types for users who don't enable the flag (.react-router/types/useRouteLoaderData.d.ts). I'm not sure if that counts as changing the API. I wouldn't say so, but if you disagree I can also understand the reasoning behind that.

If the additional generated file counts as an API change, Benefit 2 will require a different approach to be implementable behind a future flag: namely, a type-level future flag via module augmentation. Benefit 1 (inference) can be implemented without that file in case the flag is disabled (if the parameter is not a RouteId, it would then fall back to the current type if nothing is done to enable Benefit 2).

When typesafeUseRouteLoaderData is enabled

react-router typegen generates two new files in .react-router/types when the future flag is enabled:

  • .react-router/types/routeManifest.d.ts exports two types: RouteManifest and RouteId. RouteManifest is an object type, which maps route ids to the Info export of that route's +types/... file. I chose to do this in a separate file, as these types might be useful for users as well.
  • .react-router/types/useRouteLoaderData.d.ts uses module augmentation to declare an new type for useRouteLoaderData, which uses the RouteManifest and RouteId types for .react-router/types/routeManifest.d.ts.

Copy link

changeset-bot bot commented Dec 13, 2024

🦋 Changeset detected

Latest commit: de8f6d7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@react-router/dev Major
react-router Major
@react-router/fs-routes Major
@react-router/remix-routes-option-adapter Major
@react-router/architect Major
@react-router/cloudflare Major
react-router-dom Major
@react-router/express Major
@react-router/node Major
@react-router/serve Major
create-react-router Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jeyj0 jeyj0 force-pushed the typesafe-useRouteLoaderData branch from 5649567 to cd237e3 Compare December 13, 2024 09:22
@remix-cla-bot
Copy link
Contributor

remix-cla-bot bot commented Dec 13, 2024

Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳

@jeyj0 jeyj0 force-pushed the typesafe-useRouteLoaderData branch from cd237e3 to 1bff006 Compare December 13, 2024 09:43
@jeyj0
Copy link
Author

jeyj0 commented Dec 13, 2024

I realized that I had accidentally deleted the undefined case in the new type at some point before committing.

Now everything should be good!

@remorses
Copy link

I really like this idea

@jeyj0
Copy link
Author

jeyj0 commented Dec 13, 2024

I have done some more experimenting because there's a hack that I quite disliked in the solution I wrote above. I came up with a more generally-useful approach that could be used in the future, to type any feature that depends on the user's project (and thus can't be known in the react-router repository).

It still has the issue of a file being generated in .react-router/types for the case that the future flag is disabled. I'm not sure if it's possible to avoid that with my idea.

The core improvement of the new technique is generating a file in .react-router/types that declares a new module that doesn't actually exist, but is useful for types. I've called this module react-router/user-types:

// .react-router/types/react-router-user-types.d.ts
declare module "react-router/user-types" {
  // This is just one way of doing things, and currently references .react-router/types/routeManifest.d.ts as with the implementation above.
  // You could just as well include the code from `.react-router/types/routeManifest.d.ts` in this file as well.
  export interface UserTypes {
    routeManifest: import("./routeManifest").RouteManifest;
    routeId: import("./routeManifest").RouteId;
  }  
}

Additionally, for making the future flag work on the type-level too, I'm also generating .react-router/types/react-router-future.d.ts with the same approach (this file could also simply be merged with the above).

// .react-router/types/react-router-future.d.ts
declare module "react-router/future" {
  export interface TypeLevelFutureFlagConfig {
    typesafeUseRouteLoaderData: false; // or true, if the flag is enabled in react-router.config.ts
  }
}

This allows referencing these types within the react router repository. For useRouteLoaderData, that looks for example like this:

// in hooks.tsx
import type { TypeLevelFutureFlagConfig } from "react-router/future";
import type { UserTypes } from "react-router/user-types";

// ...

type UseRouteLoaderData =
  TypeLevelFutureFlagConfig["typesafeUseRouteLoaderData"] extends true
    // this is the type if the future flag is enabled
    ? <RI extends UserTypes["routeId"]>(
        routeId: RI
      ) => UserTypes["routeManifest"][RI]["loaderData"]
    // this is the type if the future flag is disabled (the default)
    : <T = any>(routeId: string) => SerializeFrom<T> | undefined;
export var useRouteLoaderData: UseRouteLoaderData = (routeId) => {
  let state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData);
  return state.loaderData[routeId];
};

The react-router repo would just need a .d.ts file setting the defaults for a nicer experience during development. Since they aren't included in the user's tsconfig, they are ignored and don't affect the actual user types.

I'm sure the naming can be improved, and I'm not sure if the nesting into interfaces provides any advantages (they just resulted from my experimentation).

It might also be a good idea to provide empty runtime modules for "react-router/user-types" and "react-router/future" if users might import the modules by accident. Again, I'm not sure what the best approach for that would be.

But I think the concept is pretty clear.

This approach could also be used to deal with these issues, by making it possible to detect library vs. framework use on the type level: #12348 #12488

enable via typesafeUseRouteLoaderData future flag
@jeyj0 jeyj0 force-pushed the typesafe-useRouteLoaderData branch from 1bff006 to de8f6d7 Compare December 16, 2024 10:35
@jeyj0 jeyj0 marked this pull request as draft December 16, 2024 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants