diff --git a/package.json b/package.json index 9d3b161a627..c4057498ae9 100644 --- a/package.json +++ b/package.json @@ -110,10 +110,10 @@ "sort-package-json": "^1.54.0", "strip-indent": "^3.0.0", "to-vfile": "7.2.3", - "type-fest": "^2.11.1", + "type-fest": "^2.15.0", "typescript": "^4.5.5" }, "engines": { "node": ">=14" } -} +} \ No newline at end of file diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts index a91e6f307d2..0014967db94 100644 --- a/packages/remix-cloudflare/index.ts +++ b/packages/remix-cloudflare/index.ts @@ -23,6 +23,7 @@ export { } from "@remix-run/server-runtime"; export type { + ActionArgs, ActionFunction, AppData, AppLoadContext, @@ -42,6 +43,7 @@ export type { HtmlMetaDescriptor, LinkDescriptor, LinksFunction, + LoaderArgs, LoaderFunction, MemoryUploadHandlerFilterArgs, MemoryUploadHandlerOptions, diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts index 161cc02580d..13ce08afd21 100644 --- a/packages/remix-deno/index.ts +++ b/packages/remix-deno/index.ts @@ -26,6 +26,7 @@ export { } from "@remix-run/server-runtime"; export type { + ActionArgs, ActionFunction, AppData, AppLoadContext, @@ -45,6 +46,7 @@ export type { HtmlMetaDescriptor, LinkDescriptor, LinksFunction, + LoaderArgs, LoaderFunction, MemoryUploadHandlerFilterArgs, MemoryUploadHandlerOptions, diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts index a2fc5080f39..f58e273c35d 100644 --- a/packages/remix-node/index.ts +++ b/packages/remix-node/index.ts @@ -49,6 +49,7 @@ export { } from "@remix-run/server-runtime"; export type { + ActionArgs, ActionFunction, AppData, AppLoadContext, @@ -68,6 +69,7 @@ export type { HtmlMetaDescriptor, LinkDescriptor, LinksFunction, + LoaderArgs, LoaderFunction, MemoryUploadHandlerFilterArgs, MemoryUploadHandlerOptions, diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx new file mode 100644 index 00000000000..8e7d998f8fa --- /dev/null +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -0,0 +1,103 @@ +import type { TypedResponse, UseDataFunctionReturn } from "../components"; + +function isEqual( + arg: A extends B ? (B extends A ? true : false) : false +): void {} + +describe("useLoaderData", () => { + it("supports plain data type", () => { + type AppData = { hello: string }; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports plain Response", () => { + type Loader = (args: any) => Response; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("infers type regardless of redirect", () => { + type Loader = ( + args: any + ) => TypedResponse<{ id: string }> | TypedResponse; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports Response-returning loader", () => { + type Loader = (args: any) => TypedResponse<{ hello: string }>; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports async Response-returning loader", () => { + type Loader = (args: any) => Promise>; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports data-returning loader", () => { + type Loader = (args: any) => { hello: string }; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports async data-returning loader", () => { + type Loader = (args: any) => Promise<{ hello: string }>; + type response = UseDataFunctionReturn; + isEqual(true); + }); +}); + +describe("type serializer", () => { + it("converts Date to string", () => { + type AppData = { hello: Date }; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports custom toJSON", () => { + type AppData = { toJSON(): { data: string[] } }; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports recursion", () => { + type AppData = { dob: Date; parent: AppData }; + type SerializedAppData = { dob: string; parent: SerializedAppData }; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports tuples and arrays", () => { + type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] }; + type response = UseDataFunctionReturn; + isEqual< + response, + { arr: string[]; tuple: [string, number, string]; empty: [] } + >(true); + }); + + it("transforms unserializables to null in arrays", () => { + type AppData = [Function, symbol, undefined]; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("transforms unserializables to never in objects", () => { + type AppData = { arg1: Function; arg2: symbol; arg3: undefined }; + type response = UseDataFunctionReturn; + isEqual(true); + }); + + it("supports class instances", () => { + class Test { + arg: string; + speak: () => string; + } + type Loader = (args: any) => TypedResponse; + type response = UseDataFunctionReturn; + isEqual(true); + }); +}); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 721e5a49ae3..225bfdf74d0 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1320,7 +1320,54 @@ export function useMatches(): RouteMatch[] { * * @see https://remix.run/api/remix#useloaderdata */ -export function useLoaderData(): T { + +export type TypedResponse = Response & { + json(): Promise; +}; + +type DataFunction = (...args: any[]) => unknown; // matches any function +type DataOrFunction = AppData | DataFunction; +type JsonPrimitives = + | string + | number + | boolean + | String + | Number + | Boolean + | null; +type NonJsonPrimitives = undefined | Function | symbol; +type SerializeType = T extends JsonPrimitives + ? T + : T extends NonJsonPrimitives + ? never + : T extends { toJSON(): infer U } + ? U + : T extends [] + ? [] + : T extends [unknown, ...unknown[]] + ? { + [k in keyof T]: T[k] extends NonJsonPrimitives + ? null + : SerializeType; + } + : T extends (infer U)[] + ? (U extends NonJsonPrimitives ? null : SerializeType)[] + : T extends object + ? { + [k in keyof T as T[k] extends NonJsonPrimitives + ? never + : k]: SerializeType; + } + : never; + +export type UseDataFunctionReturn = T extends ( + ...args: any[] +) => infer Output + ? Awaited extends TypedResponse + ? SerializeType + : SerializeType>> + : SerializeType>; +export function useLoaderData(): UseDataFunctionReturn { return useRemixRouteContext().data; } @@ -1329,7 +1376,9 @@ export function useLoaderData(): T { * * @see https://remix.run/api/remix#useactiondata */ -export function useActionData(): T | undefined { +export function useActionData(): + | UseDataFunctionReturn + | undefined { let { id: routeId } = useRemixRouteContext(); let { transitionManager } = useRemixEntryContext(); let { actionData } = transitionManager.getState(); diff --git a/packages/remix-react/errors.ts b/packages/remix-react/errors.ts index 8c6b7b0cf28..7236ad16a45 100644 --- a/packages/remix-react/errors.ts +++ b/packages/remix-react/errors.ts @@ -20,7 +20,9 @@ export interface ThrownResponse< data: Data; } -export interface SerializedError { +// must be type alias due to inference issues on interfaces +// https://github.com/microsoft/TypeScript/issues/15300 +export type SerializedError = { message: string; stack?: string; -} +}; diff --git a/packages/remix-server-runtime/__tests__/responses-test.ts b/packages/remix-server-runtime/__tests__/responses-test.ts index d680717af0b..a6b92b311f0 100644 --- a/packages/remix-server-runtime/__tests__/responses-test.ts +++ b/packages/remix-server-runtime/__tests__/responses-test.ts @@ -1,4 +1,6 @@ +import type { TypedResponse } from "../index"; import { json, redirect } from "../index"; +import { isEqual } from "./utils"; describe("json", () => { it("sets the Content-Type header", () => { @@ -34,6 +36,20 @@ describe("json", () => { let response = json({}, 201); expect(response.status).toEqual(201); }); + + it("infers input type", async () => { + let response = json({ hello: "remix" }); + isEqual>(true); + let result = await response.json(); + expect(result).toMatchObject({ hello: "remix" }); + }); + + it("disallows unserializables", () => { + // @ts-expect-error + expect(() => json(124n)).toThrow(); + // @ts-expect-error + expect(() => json({ field: 124n })).toThrow(); + }); }); describe("redirect", () => { diff --git a/packages/remix-server-runtime/__tests__/utils.ts b/packages/remix-server-runtime/__tests__/utils.ts index ff9ec7681a6..9a75e5ce5c2 100644 --- a/packages/remix-server-runtime/__tests__/utils.ts +++ b/packages/remix-server-runtime/__tests__/utils.ts @@ -86,3 +86,7 @@ export function mockServerBuild( export function prettyHtml(source: string): string { return prettier.format(source, { parser: "html" }); } + +export function isEqual( + arg: A extends B ? (B extends A ? true : false) : false +): void {} diff --git a/packages/remix-server-runtime/errors.ts b/packages/remix-server-runtime/errors.ts index ffd5bd7f22c..05a717e99d1 100644 --- a/packages/remix-server-runtime/errors.ts +++ b/packages/remix-server-runtime/errors.ts @@ -57,10 +57,12 @@ export interface ThrownResponse { data: T; } -export interface SerializedError { +// must be type alias due to inference issues on interfaces +// https://github.com/microsoft/TypeScript/issues/15300 +export type SerializedError = { message: string; stack?: string; -} +}; export async function serializeError(error: Error): Promise { return { diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index 6af6134390b..7a9b2d63328 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -28,10 +28,12 @@ export type { IsSessionFunction, JsonFunction, RedirectFunction, + TypedResponse, } from "./interface"; // Remix server runtime packages should re-export these types export type { + ActionArgs, ActionFunction, AppData, AppLoadContext, @@ -50,6 +52,7 @@ export type { HtmlMetaDescriptor, LinkDescriptor, LinksFunction, + LoaderArgs, LoaderFunction, MetaDescriptor, MetaFunction, diff --git a/packages/remix-server-runtime/interface.ts b/packages/remix-server-runtime/interface.ts index be54c937c35..ba88aac63ec 100644 --- a/packages/remix-server-runtime/interface.ts +++ b/packages/remix-server-runtime/interface.ts @@ -1,5 +1,9 @@ export type { CreateCookieFunction, IsCookieFunction } from "./cookies"; -export type { JsonFunction, RedirectFunction } from "./responses"; +export type { + JsonFunction, + RedirectFunction, + TypedResponse, +} from "./responses"; export type { CreateRequestHandlerFunction } from "./server"; export type { CreateSessionFunction, diff --git a/packages/remix-server-runtime/reexport.ts b/packages/remix-server-runtime/reexport.ts index add615c4b6c..e6e360ba1c7 100644 --- a/packages/remix-server-runtime/reexport.ts +++ b/packages/remix-server-runtime/reexport.ts @@ -32,12 +32,14 @@ export type { } from "./links"; export type { + ActionArgs, ActionFunction, DataFunctionArgs, ErrorBoundaryComponent, HeadersFunction, HtmlMetaDescriptor, LinksFunction, + LoaderArgs, LoaderFunction, MetaDescriptor, MetaFunction, diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts index b571d587415..de7cdcb0e95 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -1,7 +1,26 @@ -export type JsonFunction = ( +type SerializablePrimitives = + | string + | number + | boolean + | null + | { toJSON(): unknown } + | undefined + | Function + | symbol; +type Serializable = + | SerializablePrimitives + | { [key: string | number | symbol]: Serializable } + | Serializable[]; +export type JsonFunction = ( data: Data, init?: number | ResponseInit -) => Response; +) => TypedResponse; + +// must be a type since this is a subtype of response +// interfaces must conform to the types they extend +export type TypedResponse = Response & { + json(): Promise; +}; /** * This is a shortcut for creating `application/json` responses. Converts `data` @@ -26,7 +45,7 @@ export const json: JsonFunction = (data, init = {}) => { export type RedirectFunction = ( url: string, init?: number | ResponseInit -) => Response; +) => TypedResponse; /** * A redirect response. Sets the status code and the `Location` header. @@ -48,7 +67,7 @@ export const redirect: RedirectFunction = (url, init = 302) => { return new Response(null, { ...responseInit, headers, - }); + }) as TypedResponse; }; export function isResponse(value: any): value is Response { diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index 98733bc073b..b6bdf23c74a 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -19,6 +19,9 @@ export interface DataFunctionArgs { params: Params; } +export type LoaderArgs = DataFunctionArgs; +export type ActionArgs = DataFunctionArgs; + /** * A function that handles data mutations for a route. */ diff --git a/rollup.config.js b/rollup.config.js index 9366f6d0ff3..3dfaa761c5d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -651,6 +651,7 @@ function getMagicExports(packageName) { "@remix-run/server-runtime": { values: ["createSession", "isCookie", "isSession", "json", "redirect"], types: [ + "ActionArgs", "ActionFunction", "AppData", "AppLoadContext", @@ -668,6 +669,7 @@ function getMagicExports(packageName) { "HtmlMetaDescriptor", "LinkDescriptor", "LinksFunction", + "LoaderArgs", "LoaderFunction", "MetaDescriptor", "MetaFunction", diff --git a/yarn.lock b/yarn.lock index a3ac799311b..bb679c44a03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10664,11 +10664,16 @@ type-fest@^1.2.2: resolved "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-fest@^2.11.1, type-fest@^2.12.2: +type-fest@^2.12.2: version "2.12.2" resolved "https://registry.npmjs.org/type-fest/-/type-fest-2.12.2.tgz" integrity sha512-qt6ylCGpLjZ7AaODxbpyBZSs9fCI9SkL3Z9q2oxMBQhs/uyY+VD8jHA8ULCGmWQJlBgqvO3EJeAngOHD8zQCrQ== +type-fest@^2.15.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.16.0.tgz#1250fbd64dafaf4c8e405e393ef3fb16d9651db2" + integrity sha512-qpaThT2HQkFb83gMOrdKVsfCN7LKxP26Yq+smPzY1FqoHRjqmjqHXA7n5Gkxi8efirtbeEUxzfEdePthQWCuHw== + type-is@^1.6.18, type-is@~1.6.18: version "1.6.18" resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"