From 67bed87e622d7e3f93a8e6eb1e1d467705110590 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 20 May 2022 20:10:09 -0700 Subject: [PATCH 01/14] Make json generic --- .../__tests__/responses-test.ts | 8 ++++++++ packages/remix-server-runtime/__tests__/utils.ts | 4 ++++ packages/remix-server-runtime/responses.ts | 13 +++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/remix-server-runtime/__tests__/responses-test.ts b/packages/remix-server-runtime/__tests__/responses-test.ts index d680717af0b..115197bcdf8 100644 --- a/packages/remix-server-runtime/__tests__/responses-test.ts +++ b/packages/remix-server-runtime/__tests__/responses-test.ts @@ -1,4 +1,5 @@ import { json, redirect } from "../index"; +import { isEqual } from "./utils"; describe("json", () => { it("sets the Content-Type header", () => { @@ -34,6 +35,13 @@ 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" }); + }); }); 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/responses.ts b/packages/remix-server-runtime/responses.ts index b571d587415..2927c776bbd 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -1,7 +1,13 @@ export type JsonFunction = ( data: Data, init?: number | ResponseInit -) => Response; +) => Response; + +declare global { + interface Response { + json(): Promise; + } +} /** * This is a shortcut for creating `application/json` responses. Converts `data` @@ -9,7 +15,10 @@ export type JsonFunction = ( * * @see https://remix.run/api/remix#json */ -export const json: JsonFunction = (data, init = {}) => { +export const json = ( + data: T, + init: number | ResponseInit = {} +): Response => { let responseInit = typeof init === "number" ? { status: init } : init; let headers = new Headers(responseInit.headers); From 0a981774de4727ae200450742087ec9186ce8d98 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 20 May 2022 20:52:53 -0700 Subject: [PATCH 02/14] Add aliases for LoaderArgs and ActionArgs --- packages/remix-server-runtime/responses.ts | 5 +---- packages/remix-server-runtime/routeModules.ts | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts index 2927c776bbd..3c2f74b1080 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -15,10 +15,7 @@ declare global { * * @see https://remix.run/api/remix#json */ -export const json = ( - data: T, - init: number | ResponseInit = {} -): Response => { +export const json: JsonFunction = (data, init = {}) => { let responseInit = typeof init === "number" ? { status: init } : init; let headers = new Headers(responseInit.headers); 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. */ From afb8e2b41d7dba3967090c26af146d3998720ae6 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 20 May 2022 21:13:23 -0700 Subject: [PATCH 03/14] Support loader type in useLoaderData --- .../remix-react/__tests__/hook-types-test.tsx | 37 +++++++++++++++++++ packages/remix-react/components.tsx | 11 +++++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/remix-react/__tests__/hook-types-test.tsx 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..b9ff6e180e9 --- /dev/null +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -0,0 +1,37 @@ +import type { UseLoaderData } 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 = UseLoaderData; + isEqual(true); + }); + + it("supports Response-returning loader", () => { + type Loader = (args: any) => Response<{ hello: string }>; + type response = UseLoaderData; + isEqual(true); + }); + + it("supports async Response-returning loader", () => { + type Loader = (args: any) => Promise>; + type response = UseLoaderData; + isEqual(true); + }); + + it("supports data-returning loader", () => { + type Loader = (args: any) => { hello: string }; + type response = UseLoaderData; + isEqual(true); + }); + + it("supports async data-returning loader", () => { + type Loader = (args: any) => Promise<{ hello: string }>; + type response = UseLoaderData; + isEqual(true); + }); +}); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 721e5a49ae3..aefefe399de 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -20,6 +20,7 @@ import { useResolvedPath, } from "react-router-dom"; import type { LinkProps, NavLinkProps } from "react-router-dom"; +import type { LoaderFunction } from "@remix-run/server-runtime"; import type { AppData, FormEncType, FormMethod } from "./data"; import type { EntryContext, AssetsManifest } from "./entry"; @@ -1320,7 +1321,15 @@ export function useMatches(): RouteMatch[] { * * @see https://remix.run/api/remix#useloaderdata */ -export function useLoaderData(): T { +type DataOrLoader = AppData | LoaderFunction; +export type UseLoaderData = T extends ( + ...args: any[] +) => any + ? Awaited> extends Response + ? U + : Awaited> + : T; +export function useLoaderData(): UseLoaderData { return useRemixRouteContext().data; } From 671a6cb2fa4a1be26156a4f209c812dd523d5a9e Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Sat, 21 May 2022 14:56:28 -0700 Subject: [PATCH 04/14] Switch from generic Response to TypedResponse --- .../remix-react/__tests__/hook-types-test.tsx | 6 +++--- packages/remix-react/components.tsx | 17 ++++++++++------- packages/remix-server-runtime/responses.ts | 8 +++++--- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx index b9ff6e180e9..01ea5c69bbf 100644 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -1,4 +1,4 @@ -import type { UseLoaderData } from "../components"; +import type { TypedResponse, UseLoaderData } from "../components"; function isEqual( arg: A extends B ? (B extends A ? true : false) : false @@ -12,13 +12,13 @@ describe("useLoaderData", () => { }); it("supports Response-returning loader", () => { - type Loader = (args: any) => Response<{ hello: string }>; + type Loader = (args: any) => TypedResponse<{ hello: string }>; type response = UseLoaderData; isEqual(true); }); it("supports async Response-returning loader", () => { - type Loader = (args: any) => Promise>; + type Loader = (args: any) => Promise>; type response = UseLoaderData; isEqual(true); }); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index aefefe399de..6c384e7747d 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -20,7 +20,6 @@ import { useResolvedPath, } from "react-router-dom"; import type { LinkProps, NavLinkProps } from "react-router-dom"; -import type { LoaderFunction } from "@remix-run/server-runtime"; import type { AppData, FormEncType, FormMethod } from "./data"; import type { EntryContext, AssetsManifest } from "./entry"; @@ -1321,14 +1320,18 @@ export function useMatches(): RouteMatch[] { * * @see https://remix.run/api/remix#useloaderdata */ -type DataOrLoader = AppData | LoaderFunction; -export type UseLoaderData = T extends ( - ...args: any[] -) => any - ? Awaited> extends Response + +// matches any function +export type TypedResponse = Response & { + json(): Promise; +}; +type Loader = (...args: any[]) => any; +type DataOrLoader = AppData | Loader; +export type UseLoaderData = T extends Loader + ? Awaited> extends TypedResponse ? U : Awaited> - : T; + : Awaited; export function useLoaderData(): UseLoaderData { return useRemixRouteContext().data; } diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts index 3c2f74b1080..9e56ccc5f66 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -1,12 +1,14 @@ export type JsonFunction = ( data: Data, init?: number | ResponseInit -) => Response; +) => TypedResponse; declare global { - interface Response { + // must be a type since this is a subtype of response + // interfaces must conform to the types they extend + type TypedResponse = Response & { json(): Promise; - } + }; } /** From 57741fd18cc29e35291b0f04f56c5e2fb17f3400 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 31 May 2022 13:57:29 -0700 Subject: [PATCH 05/14] Clarify naming, add typing to useActionData --- .../remix-react/__tests__/hook-types-test.tsx | 12 ++++---- packages/remix-react/components.tsx | 10 +++---- packages/remix-server-runtime/responses.ts | 29 ++++++++++++++----- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx index 01ea5c69bbf..f2389208737 100644 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -1,4 +1,4 @@ -import type { TypedResponse, UseLoaderData } from "../components"; +import type { TypedResponse, UseDataFunction } from "../components"; function isEqual( arg: A extends B ? (B extends A ? true : false) : false @@ -7,31 +7,31 @@ function isEqual( describe("useLoaderData", () => { it("supports plain data type", () => { type AppData = { hello: string }; - type response = UseLoaderData; + type response = UseDataFunction; isEqual(true); }); it("supports Response-returning loader", () => { type Loader = (args: any) => TypedResponse<{ hello: string }>; - type response = UseLoaderData; + type response = UseDataFunction; isEqual(true); }); it("supports async Response-returning loader", () => { type Loader = (args: any) => Promise>; - type response = UseLoaderData; + type response = UseDataFunction; isEqual(true); }); it("supports data-returning loader", () => { type Loader = (args: any) => { hello: string }; - type response = UseLoaderData; + type response = UseDataFunction; isEqual(true); }); it("supports async data-returning loader", () => { type Loader = (args: any) => Promise<{ hello: string }>; - type response = UseLoaderData; + type response = UseDataFunction; isEqual(true); }); }); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 6c384e7747d..66a7a7242ac 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1325,14 +1325,14 @@ export function useMatches(): RouteMatch[] { export type TypedResponse = Response & { json(): Promise; }; -type Loader = (...args: any[]) => any; -type DataOrLoader = AppData | Loader; -export type UseLoaderData = T extends Loader +type DataFunction = (...args: any[]) => any; +type DataOrFunction = AppData | DataFunction; +export type UseDataFunction = T extends DataFunction ? Awaited> extends TypedResponse ? U : Awaited> : Awaited; -export function useLoaderData(): UseLoaderData { +export function useLoaderData(): UseDataFunction { return useRemixRouteContext().data; } @@ -1341,7 +1341,7 @@ export function useLoaderData(): UseLoaderData { * * @see https://remix.run/api/remix#useactiondata */ -export function useActionData(): T | undefined { +export function useActionData(): UseDataFunction | undefined { let { id: routeId } = useRemixRouteContext(); let { transitionManager } = useRemixEntryContext(); let { actionData } = transitionManager.getState(); diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts index 9e56ccc5f66..dd86ee0aa03 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -1,16 +1,31 @@ -export type JsonFunction = ( +type JsonInputScalar = string | number | boolean | Date | null | undefined; +type Json = JsonInputScalar | { [key: string]: Json } | Json[]; + +export type JsonFunction = ( data: Data, init?: number | ResponseInit ) => TypedResponse; -declare global { - // must be a type since this is a subtype of response - // interfaces must conform to the types they extend - type TypedResponse = Response & { - json(): Promise; - }; +// must be a type since this is a subtype of response +// interfaces must conform to the types they extend +type TypedResponse = Response & { + json(): Promise; +}; + +// export interface Resp { +// json(): any; +// } +declare global {} +export interface Response { + json(): T; } +const jsonFunction = (arg: T): Response => { + return arg as any; +}; +const arg = jsonFunction("asdf"); +const out = arg.json(); // any + /** * This is a shortcut for creating `application/json` responses. Converts `data` * to JSON and sets the `Content-Type` header. From a05bfcc2389129e74787a3998e9897d89be0026f Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 31 May 2022 15:04:25 -0700 Subject: [PATCH 06/14] Implement serialization type logic --- .../remix-react/__tests__/hook-types-test.tsx | 58 +++++++++++++++++++ packages/remix-react/components.tsx | 36 ++++++++++-- packages/remix-react/errors.ts | 6 +- .../__tests__/responses-test.ts | 10 +++- packages/remix-server-runtime/errors.ts | 10 ++-- packages/remix-server-runtime/index.ts | 1 + packages/remix-server-runtime/interface.ts | 6 +- packages/remix-server-runtime/responses.ts | 34 +++++------ 8 files changed, 129 insertions(+), 32 deletions(-) diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx index f2389208737..bf3b3e129b1 100644 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -11,6 +11,12 @@ describe("useLoaderData", () => { isEqual(true); }); + it("supports plain Response", () => { + type Loader = (args: any) => Response; + type response = UseDataFunction; + isEqual(true); + }); + it("supports Response-returning loader", () => { type Loader = (args: any) => TypedResponse<{ hello: string }>; type response = UseDataFunction; @@ -35,3 +41,55 @@ describe("useLoaderData", () => { isEqual(true); }); }); + +describe("type serializer", () => { + it("converts Date to string", () => { + type AppData = { hello: Date }; + type response = UseDataFunction; + isEqual(true); + }); + + it("supports custom toJSON", () => { + type AppData = { toJSON(): { data: string[] } }; + type response = UseDataFunction; + isEqual(true); + }); + + it("supports recursion", () => { + type AppData = { dob: Date; parent: AppData }; + type SerializedAppData = { dob: string; parent: SerializedAppData }; + type response = UseDataFunction; + isEqual(true); + }); + + it("supports tuples and arrays", () => { + type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] }; + type response = UseDataFunction; + isEqual< + response, + { arr: string[]; tuple: [string, number, string]; empty: [] } + >(true); + }); + + it("transforms unserializables to null in arrays", () => { + type AppData = [Function, symbol, undefined]; + type response = UseDataFunction; + isEqual(true); + }); + + it("transforms unserializables to never in objects", () => { + type AppData = { arg1: Function; arg2: symbol; arg3: undefined }; + type response = UseDataFunction; + isEqual(true); + }); + + it("supports class instances", () => { + class Test { + arg: string; + speak: () => string; + } + type Loader = (args: any) => TypedResponse; + type response = UseDataFunction; + isEqual(true); + }); +}); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 66a7a7242ac..f569982eebf 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1327,11 +1327,37 @@ export type TypedResponse = Response & { }; type DataFunction = (...args: any[]) => any; type DataOrFunction = AppData | DataFunction; -export type UseDataFunction = T extends DataFunction - ? Awaited> extends TypedResponse - ? U - : Awaited> - : Awaited; +type JsonPrimitives = string | number | boolean | null; +type NonJsonPrimitives = undefined | Function | symbol; +type SerializeType = T extends JsonPrimitives + ? T + : T extends undefined + ? undefined + : T extends { toJSON(): infer U } + ? U + : T extends [] + ? [] + : T extends [any, ...any[]] + ? { + [k in keyof T]: T[k] extends NonJsonPrimitives + ? null + : SerializeType; + } + : T extends (infer U)[] + ? SerializeType[] + : { + [k in keyof T as T[k] extends NonJsonPrimitives + ? never + : k]: SerializeType; + }; + +export type UseDataFunction = T extends ( + ...args: any[] +) => infer Output + ? Awaited extends TypedResponse + ? SerializeType + : SerializeType>> + : SerializeType>; export function useLoaderData(): UseDataFunction { return useRemixRouteContext().data; } 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 115197bcdf8..a6b92b311f0 100644 --- a/packages/remix-server-runtime/__tests__/responses-test.ts +++ b/packages/remix-server-runtime/__tests__/responses-test.ts @@ -1,3 +1,4 @@ +import type { TypedResponse } from "../index"; import { json, redirect } from "../index"; import { isEqual } from "./utils"; @@ -38,10 +39,17 @@ describe("json", () => { it("infers input type", async () => { let response = json({ hello: "remix" }); - isEqual>(true); + 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/errors.ts b/packages/remix-server-runtime/errors.ts index ffd5bd7f22c..cac9eba568f 100644 --- a/packages/remix-server-runtime/errors.ts +++ b/packages/remix-server-runtime/errors.ts @@ -57,14 +57,16 @@ 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 { +export async function serializeError(error: Error) { return { message: error.message, stack: error.stack, - }; + } as SerializedError; } diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index 6af6134390b..31a5e433843 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -28,6 +28,7 @@ export type { IsSessionFunction, JsonFunction, RedirectFunction, + TypedResponse, } from "./interface"; // Remix server runtime packages should re-export these types 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/responses.ts b/packages/remix-server-runtime/responses.ts index dd86ee0aa03..f434103bc54 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -1,31 +1,27 @@ -type JsonInputScalar = string | number | boolean | Date | null | undefined; -type Json = JsonInputScalar | { [key: string]: Json } | Json[]; - -export type JsonFunction = ( +type SerializablePrimitives = + | string + | number + | boolean + | null + | { toJSON(): any } + | undefined + | Function + | symbol; +type Serializable = + | SerializablePrimitives + | { [key: string | number | symbol]: Serializable } + | Serializable[]; +export type JsonFunction = ( data: Data, init?: number | ResponseInit ) => TypedResponse; // must be a type since this is a subtype of response // interfaces must conform to the types they extend -type TypedResponse = Response & { +export type TypedResponse = Response & { json(): Promise; }; -// export interface Resp { -// json(): any; -// } -declare global {} -export interface Response { - json(): T; -} - -const jsonFunction = (arg: T): Response => { - return arg as any; -}; -const arg = jsonFunction("asdf"); -const out = arg.json(); // any - /** * This is a shortcut for creating `application/json` responses. Converts `data` * to JSON and sets the `Content-Type` header. From eae9459c0bdcc7c8191ee1a39d3b5e4782427999 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 14 Jun 2022 16:24:46 -0700 Subject: [PATCH 07/14] UseDataFunction -> UseDataFunctionReturn --- .../remix-react/__tests__/hook-types-test.tsx | 28 +++++++++---------- packages/remix-react/components.tsx | 8 ++++-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx index bf3b3e129b1..205c50605b8 100644 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -1,4 +1,4 @@ -import type { TypedResponse, UseDataFunction } from "../components"; +import type { TypedResponse, UseDataFunctionReturn } from "../components"; function isEqual( arg: A extends B ? (B extends A ? true : false) : false @@ -7,37 +7,37 @@ function isEqual( describe("useLoaderData", () => { it("supports plain data type", () => { type AppData = { hello: string }; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports plain Response", () => { type Loader = (args: any) => Response; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports Response-returning loader", () => { type Loader = (args: any) => TypedResponse<{ hello: string }>; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports async Response-returning loader", () => { type Loader = (args: any) => Promise>; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports data-returning loader", () => { type Loader = (args: any) => { hello: string }; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports async data-returning loader", () => { type Loader = (args: any) => Promise<{ hello: string }>; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); }); @@ -45,26 +45,26 @@ describe("useLoaderData", () => { describe("type serializer", () => { it("converts Date to string", () => { type AppData = { hello: Date }; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports custom toJSON", () => { type AppData = { toJSON(): { data: string[] } }; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports recursion", () => { type AppData = { dob: Date; parent: AppData }; type SerializedAppData = { dob: string; parent: SerializedAppData }; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("supports tuples and arrays", () => { type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] }; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual< response, { arr: string[]; tuple: [string, number, string]; empty: [] } @@ -73,13 +73,13 @@ describe("type serializer", () => { it("transforms unserializables to null in arrays", () => { type AppData = [Function, symbol, undefined]; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); it("transforms unserializables to never in objects", () => { type AppData = { arg1: Function; arg2: symbol; arg3: undefined }; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); @@ -89,7 +89,7 @@ describe("type serializer", () => { speak: () => string; } type Loader = (args: any) => TypedResponse; - type response = UseDataFunction; + type response = UseDataFunctionReturn; isEqual(true); }); }); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index f569982eebf..0ef3cac50b2 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1351,14 +1351,14 @@ type SerializeType = T extends JsonPrimitives : k]: SerializeType; }; -export type UseDataFunction = T extends ( +export type UseDataFunctionReturn = T extends ( ...args: any[] ) => infer Output ? Awaited extends TypedResponse ? SerializeType : SerializeType>> : SerializeType>; -export function useLoaderData(): UseDataFunction { +export function useLoaderData(): UseDataFunctionReturn { return useRemixRouteContext().data; } @@ -1367,7 +1367,9 @@ export function useLoaderData(): UseDataFunction { * * @see https://remix.run/api/remix#useactiondata */ -export function useActionData(): UseDataFunction | undefined { +export function useActionData(): + | UseDataFunctionReturn + | undefined { let { id: routeId } = useRemixRouteContext(); let { transitionManager } = useRemixEntryContext(); let { actionData } = transitionManager.getState(); From dd5c8de1972303d31bddd2a694a535b0f461d26a Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 15 Jun 2022 16:25:03 -0700 Subject: [PATCH 08/14] Update redirect to return TypedResponse --- packages/remix-react/__tests__/hook-types-test.tsx | 8 ++++++++ packages/remix-server-runtime/responses.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx index 205c50605b8..8e7d998f8fa 100644 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -17,6 +17,14 @@ describe("useLoaderData", () => { 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; diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts index f434103bc54..70a66ab8ca3 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -45,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. @@ -67,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 { From 31d0d3e13e5ba2386d566eb28a1aaa7467775e0f Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 17 Jun 2022 15:33:32 -0700 Subject: [PATCH 09/14] Fix comment --- packages/remix-react/components.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 0ef3cac50b2..7884fad5481 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1321,11 +1321,11 @@ export function useMatches(): RouteMatch[] { * @see https://remix.run/api/remix#useloaderdata */ -// matches any function export type TypedResponse = Response & { json(): Promise; }; -type DataFunction = (...args: any[]) => any; + +type DataFunction = (...args: any[]) => any; // matches any function type DataOrFunction = AppData | DataFunction; type JsonPrimitives = string | number | boolean | null; type NonJsonPrimitives = undefined | Function | symbol; From 5b04a79b5db3d47a360464af992abb4e50dc0000 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 21 Jun 2022 13:33:51 -0700 Subject: [PATCH 10/14] Change any to unknown --- packages/remix-react/components.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 7884fad5481..cf3e81f13d1 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1325,7 +1325,7 @@ export type TypedResponse = Response & { json(): Promise; }; -type DataFunction = (...args: any[]) => any; // matches any function +type DataFunction = (...args: any[]) => unknown; // matches any function type DataOrFunction = AppData | DataFunction; type JsonPrimitives = string | number | boolean | null; type NonJsonPrimitives = undefined | Function | symbol; @@ -1337,7 +1337,7 @@ type SerializeType = T extends JsonPrimitives ? U : T extends [] ? [] - : T extends [any, ...any[]] + : T extends [unknown, ...unknown[]] ? { [k in keyof T]: T[k] extends NonJsonPrimitives ? null From 1dbde2671e2ad7ac0d6a6b818ef323d55fccc7b8 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 23 Jun 2022 16:16:54 -0700 Subject: [PATCH 11/14] Add re-exports --- packages/remix-cloudflare/index.ts | 2 + packages/remix-deno/index.ts | 2 + packages/remix-node/index.ts | 2 + packages/remix-server-runtime/index.ts | 2 + .../magicExports/remix.ts | 46 +++++++++++++++++++ packages/remix-server-runtime/reexport.ts | 2 + 6 files changed, 56 insertions(+) create mode 100644 packages/remix-server-runtime/magicExports/remix.ts 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-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index 31a5e433843..7a9b2d63328 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -33,6 +33,7 @@ export type { // Remix server runtime packages should re-export these types export type { + ActionArgs, ActionFunction, AppData, AppLoadContext, @@ -51,6 +52,7 @@ export type { HtmlMetaDescriptor, LinkDescriptor, LinksFunction, + LoaderArgs, LoaderFunction, MetaDescriptor, MetaFunction, diff --git a/packages/remix-server-runtime/magicExports/remix.ts b/packages/remix-server-runtime/magicExports/remix.ts new file mode 100644 index 00000000000..c0f2eb55067 --- /dev/null +++ b/packages/remix-server-runtime/magicExports/remix.ts @@ -0,0 +1,46 @@ +/* eslint-disable import/no-extraneous-dependencies */ + +// Re-export everything from this package that is available in `remix`. + +export type { + ServerBuild, + ServerEntryModule, + HandleDataRequestFunction, + HandleDocumentRequestFunction, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, + CookieOptions, + Cookie, + AppLoadContext, + AppData, + EntryContext, + LinkDescriptor, + HtmlLinkDescriptor, + PageLinkDescriptor, + ErrorBoundaryComponent, + ActionArgs, + ActionFunction, + HeadersFunction, + LinksFunction, + LoaderArgs, + LoaderFunction, + MetaDescriptor, + HtmlMetaDescriptor, + MetaFunction, + RouteComponent, + RouteHandle, + RequestHandler, + SessionData, + Session, + SessionStorage, + SessionIdStorageStrategy, +} from "@remix-run/server-runtime"; + +export { + isCookie, + createSession, + isSession, + json, + redirect, +} from "@remix-run/server-runtime"; 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, From 1d6a19983032b69a8f99e3e79f1d20f8217084a5 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 5 Jul 2022 20:56:31 -0700 Subject: [PATCH 12/14] Address issues --- package.json | 4 ++-- packages/remix-react/components.tsx | 19 ++++++++++++++----- packages/remix-server-runtime/errors.ts | 4 ++-- packages/remix-server-runtime/responses.ts | 4 ++-- rollup.config.js | 2 ++ yarn.lock | 7 ++++++- 6 files changed, 28 insertions(+), 12 deletions(-) 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-react/components.tsx b/packages/remix-react/components.tsx index cf3e81f13d1..806d8bd8412 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1327,11 +1327,18 @@ export type TypedResponse = Response & { type DataFunction = (...args: any[]) => unknown; // matches any function type DataOrFunction = AppData | DataFunction; -type JsonPrimitives = string | number | boolean | null; +type JsonPrimitives = + | string + | number + | boolean + | String + | Number + | Boolean + | null; type NonJsonPrimitives = undefined | Function | symbol; type SerializeType = T extends JsonPrimitives ? T - : T extends undefined + : T extends NonJsonPrimitives ? undefined : T extends { toJSON(): infer U } ? U @@ -1344,12 +1351,14 @@ type SerializeType = T extends JsonPrimitives : SerializeType; } : T extends (infer U)[] - ? SerializeType[] - : { + ? (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[] diff --git a/packages/remix-server-runtime/errors.ts b/packages/remix-server-runtime/errors.ts index cac9eba568f..05a717e99d1 100644 --- a/packages/remix-server-runtime/errors.ts +++ b/packages/remix-server-runtime/errors.ts @@ -64,9 +64,9 @@ export type SerializedError = { stack?: string; }; -export async function serializeError(error: Error) { +export async function serializeError(error: Error): Promise { return { message: error.message, stack: error.stack, - } as SerializedError; + }; } diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts index 70a66ab8ca3..de7cdcb0e95 100644 --- a/packages/remix-server-runtime/responses.ts +++ b/packages/remix-server-runtime/responses.ts @@ -3,7 +3,7 @@ type SerializablePrimitives = | number | boolean | null - | { toJSON(): any } + | { toJSON(): unknown } | undefined | Function | symbol; @@ -18,7 +18,7 @@ export type JsonFunction = ( // must be a type since this is a subtype of response // interfaces must conform to the types they extend -export type TypedResponse = Response & { +export type TypedResponse = Response & { json(): Promise; }; 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" From 7ee7fe695332ec3e6cebe71b4e5021f0888ad813 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 6 Jul 2022 10:48:58 -0700 Subject: [PATCH 13/14] Update NonJsonPrimitives behavior --- packages/remix-react/components.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 806d8bd8412..225bfdf74d0 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -1339,7 +1339,7 @@ type NonJsonPrimitives = undefined | Function | symbol; type SerializeType = T extends JsonPrimitives ? T : T extends NonJsonPrimitives - ? undefined + ? never : T extends { toJSON(): infer U } ? U : T extends [] From 8ab5e244621a6fbd26393e962d21a8b01b24d6cd Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 6 Jul 2022 13:18:29 -0700 Subject: [PATCH 14/14] Remove magicExports --- .../magicExports/remix.ts | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 packages/remix-server-runtime/magicExports/remix.ts diff --git a/packages/remix-server-runtime/magicExports/remix.ts b/packages/remix-server-runtime/magicExports/remix.ts deleted file mode 100644 index c0f2eb55067..00000000000 --- a/packages/remix-server-runtime/magicExports/remix.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ - -// Re-export everything from this package that is available in `remix`. - -export type { - ServerBuild, - ServerEntryModule, - HandleDataRequestFunction, - HandleDocumentRequestFunction, - CookieParseOptions, - CookieSerializeOptions, - CookieSignatureOptions, - CookieOptions, - Cookie, - AppLoadContext, - AppData, - EntryContext, - LinkDescriptor, - HtmlLinkDescriptor, - PageLinkDescriptor, - ErrorBoundaryComponent, - ActionArgs, - ActionFunction, - HeadersFunction, - LinksFunction, - LoaderArgs, - LoaderFunction, - MetaDescriptor, - HtmlMetaDescriptor, - MetaFunction, - RouteComponent, - RouteHandle, - RequestHandler, - SessionData, - Session, - SessionStorage, - SessionIdStorageStrategy, -} from "@remix-run/server-runtime"; - -export { - isCookie, - createSession, - isSession, - json, - redirect, -} from "@remix-run/server-runtime";