diff --git a/.changeset/rotten-geckos-yawn.md b/.changeset/rotten-geckos-yawn.md new file mode 100644 index 00000000000..72862e112a7 --- /dev/null +++ b/.changeset/rotten-geckos-yawn.md @@ -0,0 +1,50 @@ +--- +"@remix-run/cloudflare": patch +"@remix-run/deno": patch +"@remix-run/node": patch +"@remix-run/react": patch +"@remix-run/server-runtime": patch +--- + +Typesafety for single-fetch: defineLoader, defineClientLoader, defineAction, defineClientAction + +`defineLoader` and `defineAction` are helpers for authoring `loader`s and `action`s. +They are identity functions; they don't modify your loader or action at runtime. +Rather, they exist solely for typesafety by providing types for args and by ensuring valid return types. + +```ts +export let loader = defineLoader(({ request }) => { + // ^? Request + return {a:1, b: () => 2} + // ^ type error: `b` is not serializable +}) +``` + +Note that `defineLoader` and `defineAction` are not technically necessary for defining loaders and actions if you aren't concerned with typesafety: + +```ts +// this totally works! and typechecking is happy too! +export let loader = () => { + return {a: 1} +} +``` + +This means that you can opt-in to `defineLoader` incrementally, one loader at a time. + +You can return custom responses via the `json`/`defer` utilities, but doing so will revert back to the old JSON-based typesafety mechanism: + +```ts +let loader1 = () => { + return {a: 1, b: new Date()} +} +let data1 = useLoaderData() +// ^? {a: number, b: Date} + +let loader2 = () => { + return json({a: 1, b: new Date()}) // this opts-out of turbo-stream +} +let data2 = useLoaderData() +// ^? JsonifyObject<{a: number, b: Date}> which is really {a: number, b: string} +``` + +You can also continue to return totally custom responses with `Response` though this continues to be outside of the typesystem since the built-in `Response` type is not generic diff --git a/docs/guides/single-fetch.md b/docs/guides/single-fetch.md index 86bd2b590a9..8a7d396dbcc 100644 --- a/docs/guides/single-fetch.md +++ b/docs/guides/single-fetch.md @@ -98,13 +98,66 @@ In order to ensure you get the proper types when using Single Fetch, we've inclu ```json { - "include": [ - // ... - "node_modules/@remix-run/react/future/single-fetch.d.ts" - ] + "compilerOptions": { + "types": ["@remix-run/react/future/single-fetch.d.ts"] + } } ``` +🚨 Make sure the single-fetch types come after any other Remix packages in `types` so that they override those existing types. + +** `defineLoader`, `defineAction`, `defineClientLoader`, `defineClientAction` ** + +To get typesafety when defining loaders and actions, you can use the `defineLoader` and `defineAction` utilities: + +```ts +import { defineLoader } from "@remix-run/node"; + +export const loader = defineLoader(({ request }) => { + // ^? Request +}); + +export const action = defineAction(({ context }) => { + // ^? AppLoadContext +}); +``` + +Not only does this give you types for any arguments, but it also ensures you are returning single-fetch compatible types: + +```ts +export const loader = defineLoader(() => { + return { hello: "world", badData: () => 1 }; + // ^^^^^^^ Type error: `badData` is not serializable +}); + +export const action = defineAction(() => { + return { hello: "world", badData: new CustomType() }; + // ^^^^^^^ Type error: `badData` is not serializable +}); +``` + +Single-fetch supports the following return types: + +```ts +type Serializable = + | undefined + | null + | boolean + | string + | symbol + | number + | bigint + | Date + | URL + | RegExp + | Error + | Array + | { [key: PropertyKey]: Serializable } // objects with serializable values + | Map + | Set + | Promise; +``` + **`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: diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts index e9937361263..d1463dbcaf7 100644 --- a/packages/remix-cloudflare/index.ts +++ b/packages/remix-cloudflare/index.ts @@ -12,6 +12,10 @@ export { export { createRequestHandler, createSession, + unstable_defineLoader, + unstable_defineClientLoader, + unstable_defineAction, + unstable_defineClientAction, defer, broadcastDevReady, logDevReady, diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts index 08c67e64f07..160c21d9d50 100644 --- a/packages/remix-deno/index.ts +++ b/packages/remix-deno/index.ts @@ -16,6 +16,10 @@ export { export { broadcastDevReady, createSession, + unstable_defineLoader, + unstable_defineClientLoader, + unstable_defineAction, + unstable_defineClientAction, defer, isCookie, isSession, diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts index 1caccaf7ba0..e1587d6dcbc 100644 --- a/packages/remix-node/index.ts +++ b/packages/remix-node/index.ts @@ -24,6 +24,10 @@ export { export { createRequestHandler, createSession, + unstable_defineLoader, + unstable_defineClientLoader, + unstable_defineAction, + unstable_defineClientAction, defer, broadcastDevReady, logDevReady, diff --git a/packages/remix-react/future/single-fetch.d.ts b/packages/remix-react/future/single-fetch.d.ts index ddef3d7098c..fa37a8a338b 100644 --- a/packages/remix-react/future/single-fetch.d.ts +++ b/packages/remix-react/future/single-fetch.d.ts @@ -1,100 +1,46 @@ import type { MetaArgs, UIMatch, UNSAFE_MetaMatch } from "@remix-run/react"; import type { - LoaderFunctionArgs, - ActionFunctionArgs, - SerializeFrom, - TypedDeferredData, - TypedResponse, + unstable_Loader as Loader, + unstable_Action as Action, + unstable_Serialize as Serialize, } 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 | DataFunctionReturnValue; -type ActionFunction_SingleFetch = ( - args: ActionFunctionArgs -) => Promise | DataFunctionReturnValue; - -// 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 useLoaderData(): Serialize; - export function useActionData(): T extends ActionFunction_SingleFetch - ? SingleFetchSerialize_V2 | undefined - : never; + export function useActionData(): Serialize | undefined; - export function useRouteLoaderData( + export function useRouteLoaderData( routeId: string - ): T extends LoaderFunction_SingleFetch ? SingleFetchSerialize_V2 : never; + ): Serialize; - export function useFetcher( + export function useFetcher( opts?: Parameters[0] - ): FetcherWithComponents< - TData extends LoaderFunction_SingleFetch | ActionFunction_SingleFetch - ? SingleFetchSerialize_V2 - : never - >; + ): FetcherWithComponents>; export type UIMatch_SingleFetch = Omit< UIMatch, "data" > & { - data: D extends LoaderFunction_SingleFetch - ? SingleFetchSerialize_V2 - : never; + data: D extends Loader ? Serialize : 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; + L extends Loader | unknown = unknown + > extends Omit, "data"> { + data: L extends Loader ? Serialize : unknown; } type MetaMatches_SingleFetch< - MatchLoaders extends Record< + MatchLoaders extends Record = Record< string, - LoaderFunction_SingleFetch | unknown - > = Record + unknown + > > = Array< { [K in keyof MatchLoaders]: MetaMatch_SingleFetch< @@ -105,17 +51,13 @@ declare module "@remix-run/react" { >; export interface MetaArgs_SingleFetch< - Loader extends LoaderFunction_SingleFetch | unknown = unknown, - MatchLoaders extends Record< + L extends Loader | unknown = unknown, + MatchLoaders extends Record = Record< string, - LoaderFunction_SingleFetch | unknown - > = Record - > extends Omit, "data" | "matches"> { - data: - | (Loader extends LoaderFunction_SingleFetch - ? SingleFetchSerialize_V2 - : unknown) - | undefined; + unknown + > + > extends Omit, "data" | "matches"> { + data: (L extends Loader ? Serialize : unknown) | undefined; matches: MetaMatches_SingleFetch; } } diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index 7744129cdb7..2840bdc6a33 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -5,11 +5,22 @@ export { parseMultipartFormData as unstable_parseMultipartFormData, } from "./formData"; export { defer, json, redirect, redirectDocument } from "./responses"; + +export { + SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol, + defineLoader as unstable_defineLoader, + defineClientLoader as unstable_defineClientLoader, + defineAction as unstable_defineAction, + defineClientAction as unstable_defineClientAction, +} from "./single-fetch"; export type { + Loader as unstable_Loader, + Action as unstable_Action, + Serialize as unstable_Serialize, SingleFetchResult as UNSAFE_SingleFetchResult, SingleFetchResults as UNSAFE_SingleFetchResults, } from "./single-fetch"; -export { SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol } from "./single-fetch"; + export { createRequestHandler } from "./server"; export { createSession, diff --git a/packages/remix-server-runtime/single-fetch.ts b/packages/remix-server-runtime/single-fetch.ts index c9303e79be0..a0dd631dc20 100644 --- a/packages/remix-server-runtime/single-fetch.ts +++ b/packages/remix-server-runtime/single-fetch.ts @@ -1,4 +1,6 @@ import type { + ActionFunctionArgs as RRActionArgs, + LoaderFunctionArgs as RRLoaderArgs, StaticHandler, unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs, unstable_DataStrategyFunction as DataStrategyFunction, @@ -19,7 +21,9 @@ import type { ResponseStubOperation, } from "./routeModules"; import { ResponseStubOperationsSymbol } from "./routeModules"; +import type { TypedDeferredData, TypedResponse } from "./responses"; import { isDeferredData, isRedirectStatusCode, isResponse } from "./responses"; +import type { SerializeFrom } from "./serialize"; export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); const ResponseStubActionSymbol = Symbol("ResponseStubAction"); @@ -504,3 +508,77 @@ export function encodeViaTurboStream( ], }); } + +type MaybePromise = T | Promise; + +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>; + +// 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 +export type Serialize = + Awaited> extends TypedDeferredData ? D : + Awaited> extends TypedResponse> ? SerializeFrom : + Awaited>; + +// loader +type LoaderArgs = RRLoaderArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + context: AppLoadContext; + response: ResponseStub; +}; +export type Loader = ( + args: LoaderArgs +) => MaybePromise; +export let defineLoader = (loader: T): T => loader; + +// clientLoader +type ClientLoaderArgs = RRLoaderArgs & { + serverLoader: () => Promise>; +}; +type ClientLoader = (args: ClientLoaderArgs) => MaybePromise; +export let defineClientLoader = ( + clientLoader: T +): T & { hydrate?: boolean } => clientLoader; + +// action +type ActionArgs = RRActionArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + context: AppLoadContext; + response: ResponseStub; +}; +export type Action = ( + args: ActionArgs +) => MaybePromise; +export let defineAction = (action: T): T => action; + +// clientAction +type ClientActionArgs = RRActionArgs & { + serverAction: () => Promise>; +}; +type ClientAction = (args: ClientActionArgs) => MaybePromise; +export let defineClientAction = (clientAction: T): T => + clientAction;