diff --git a/.changeset/young-eagles-grab.md b/.changeset/young-eagles-grab.md new file mode 100644 index 00000000000..784b5b7c859 --- /dev/null +++ b/.changeset/young-eagles-grab.md @@ -0,0 +1,6 @@ +--- +"@remix-run/react": patch +--- + +Opt-in types for single-fetch +- To opt-in to type inference for single-fetch, add `./node_modules/@remix-run/react/future/single-fetch.d.ts` to `include` in your `tsconfig.json` diff --git a/docs/guides/single-fetch.md b/docs/guides/single-fetch.md index f63d928b744..8f66f81e651 100644 --- a/docs/guides/single-fetch.md +++ b/docs/guides/single-fetch.md @@ -51,11 +51,28 @@ You can control this by exporting a `streamTimeout` numeric value from your `ent ### Type Inference -The current generics support type inference but have a built-in assumption of a JSON-serialized response. With the new streaming format, this assumption no longer holds so `useLoaderData()` will _not_ return the proper types because it would assume that a `Date` would be a string on the client 😕. Unfortunately, we can't make these types aware of a runtime future flag and we do not want to introduce another hook just for this. Thankfully, the manual typing is much simpler without needing to think about JSON serialization, so the current recommendation is to skip the generics when opting into single fetch and manually cast the type yourself: +Without Single Fetch, any plain Javascript object returned from a `loader` or `action` is automatically serialized into a JSON response (as if you returned it via `json`). The type inference assumes this is the case and infer naked object returns as if they were JSON serialized. + +With Single Fetch, naked objects will be streamed directly, so the built-in type inference is no longer accurate once you have opted-into Single Fetch. For example, they would assume that a `Date` would be serialized to a string on the client 😕. + +In order to ensure you get the proper types when using Single Fetch, we've included a set of type overrides that you can include in your `tsconfig.json` which aligns the types with the Single Fetch behavior: + +```json +{ + "include": [ + // ... + "./node_modules/@remix-run/react/future/single-fetch.d.ts" + ] +} +``` + +**`useLoaderData`, `useActionData`, `useRouteLoaderData`, and `useFetcher`** + +These methods do not require any code changes on your part - adding the single fetch types will cause their generics to deserialize correctly: ```ts export async function loader() { - const data = await fetchSomeData(); // Assume this returns + const data = await fetchSomeData(); return { message: data.message, // <- string date: data.date, // <- Date @@ -63,26 +80,38 @@ export async function loader() { } export default function Component() { - // ❌ Before + // ❌ Before opting into single fetch types, types are serialized via JSON.stringify const data = useLoaderData(); // ^? { message: string, date: string } - // ✅ After - const data = useLoaderData() as unknown as Awaited< - ReturnType - >; + // ✅ After opting into single fetch types, types are serialized via turbo-stream + const data = useLoaderData; // ^? { message: string, date: Date } } ``` -In the next version of Remix, we may re-introduce this generic, but in the meantime you could wrap this up into your own utility: +**`useMatches`** -```ts -function useTypedLoaderData() { - return useLoaderData() as unknown as Awaited< - ReturnType - >; -} +`useMatches` requires a manual cast to specify the loader type in order to get proper type inference on a given `match.data`. When using Single Fetch, you will need to replace the `UIMatch` type with `UIMatch_SingleFetch`: + +```diff + let matches = useMatches(); +- let rootMatch = matches[0] as UIMatch; ++ let rootMatch = matches[0] as UIMatch_SingleFetch; +``` + +**`meta` Function** + +`meta` functions also require a generic to indicate the current and ancestor route loader types in order to properly type the `data` and `matches` parameters. When using Single Fetch, you will need to replace the `MetaArgs` type with `MetaArgs_SingleFetch`: + +```diff + export function meta({ + data, + matches, +- }: MetaArgs) { ++ }: MetaArgs_SingleFetch) { + // ... + } ``` ### Revalidations diff --git a/packages/remix-react/future/single-fetch.d.ts b/packages/remix-react/future/single-fetch.d.ts new file mode 100644 index 00000000000..3ea4d76b710 --- /dev/null +++ b/packages/remix-react/future/single-fetch.d.ts @@ -0,0 +1,121 @@ +import type { MetaArgs, UNSAFE_MetaMatch } from "@remix-run/react"; +import type { + LoaderFunctionArgs, + ActionFunctionArgs, + SerializeFrom, + TypedDeferredData, + TypedResponse, +} from "@remix-run/server-runtime"; +import type { + useFetcher as useFetcherRR, + FetcherWithComponents, +} from "react-router-dom"; + +type Serializable = + | undefined + | null + | boolean + | string + | symbol + | number + | Array + | { [key: PropertyKey]: Serializable } + | bigint + | Date + | URL + | RegExp + | Error + | Map + | Set + | Promise; + +type DataFunctionReturnValue = + | Serializable + | TypedDeferredData> + | TypedResponse>; + +type LoaderFunction_SingleFetch = ( + args: LoaderFunctionArgs +) => Promise; +type ActionFunction_SingleFetch = ( + args: ActionFunctionArgs +) => Promise; + +// Backwards-compatible type for Remix v2 where json/defer still use the old types, +// and only non-json/defer returns use the new types. This allows for incremental +// migration of loaders to return naked objects. In the next major version, +// json/defer will be removed so everything will use the new simplified typings. +// prettier-ignore +type SingleFetchSerialize_V2 = + Awaited> extends TypedDeferredData ? D : + Awaited> extends TypedResponse> ? SerializeFrom : + Awaited>; + +declare module "@remix-run/react" { + export function useLoaderData(): T extends LoaderFunction_SingleFetch + ? SingleFetchSerialize_V2 + : never; + + export function useActionData(): T extends ActionFunction_SingleFetch + ? SingleFetchSerialize_V2 + : never; + + export function useRouteLoaderData( + routeId: string + ): T extends LoaderFunction_SingleFetch ? SingleFetchSerialize_V2 : never; + + export function useFetcher( + opts?: Parameters[0] + ): FetcherWithComponents< + TData extends LoaderFunction_SingleFetch | ActionFunction_SingleFetch + ? SingleFetchSerialize_V2 + : never + >; + + export type UIMatch_SingleFetch = Omit< + UIMatch, + "data" + > & { + data: D extends LoaderFunction_SingleFetch + ? SingleFetchSerialize_V2 + : never; + }; + + interface MetaMatch_SingleFetch< + RouteId extends string = string, + Loader extends LoaderFunction_SingleFetch | unknown = unknown + > extends Omit, "data"> { + data: Loader extends LoaderFunction_SingleFetch + ? SingleFetchSerialize_V2 + : unknown; + } + + type MetaMatches_SingleFetch< + MatchLoaders extends Record< + string, + LoaderFunction_SingleFetch | unknown + > = Record + > = Array< + { + [K in keyof MatchLoaders]: MetaMatch_SingleFetch< + Exclude, + MatchLoaders[K] + >; + }[keyof MatchLoaders] + >; + + export interface MetaArgs_SingleFetch< + Loader extends LoaderFunction_SingleFetch | unknown = unknown, + MatchLoaders extends Record< + string, + LoaderFunction_SingleFetch | unknown + > = Record + > extends Omit, "data" | "matches"> { + data: + | (Loader extends LoaderFunction_SingleFetch + ? SingleFetchSerialize_V2 + : unknown) + | undefined; + matches: MetaMatches_SingleFetch; + } +} diff --git a/packages/remix-react/index.tsx b/packages/remix-react/index.tsx index b4336440722..ecb54165e15 100644 --- a/packages/remix-react/index.tsx +++ b/packages/remix-react/index.tsx @@ -101,6 +101,7 @@ export type { ClientLoaderFunction, ClientLoaderFunctionArgs, MetaArgs, + MetaMatch as UNSAFE_MetaMatch, MetaDescriptor, MetaFunction, RouteModules as UNSAFE_RouteModules, diff --git a/packages/remix-react/rollup.config.js b/packages/remix-react/rollup.config.js index 5ad9ecd1c06..20d9673a71f 100644 --- a/packages/remix-react/rollup.config.js +++ b/packages/remix-react/rollup.config.js @@ -51,6 +51,7 @@ module.exports = function rollup() { { src: "LICENSE.md", dest: [outputDir, sourceDir] }, { src: `${sourceDir}/package.json`, dest: outputDir }, { src: `${sourceDir}/README.md`, dest: outputDir }, + { src: `${sourceDir}/future`, dest: outputDir }, ], }), copyToPlaygrounds(),