diff --git a/.changeset/stale-apples-reply.md b/.changeset/stale-apples-reply.md new file mode 100644 index 00000000000..17bff0bcf6e --- /dev/null +++ b/.changeset/stale-apples-reply.md @@ -0,0 +1,11 @@ +--- +"@remix-run/react": patch +"@remix-run/server-runtime": patch +--- + +Emulate types for `JSON.parse(JSON.stringify(x))` in `SerializeFrom` + +Notably, type fields that are only assignable to `undefined` after serialization are now omitted since +`JSON.stringify |> JSON.parse` will omit them. See test cases for examples. + +Also fixes type errors when upgrading to v2 from 1.19 diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx deleted file mode 100644 index 28ffd592f00..00000000000 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import type { - TypedDeferredData, - TypedResponse, -} from "@remix-run/server-runtime"; - -import type { useLoaderData, useRouteLoaderData } from "../components"; - -function isEqual( - arg: A extends B ? (B extends A ? true : false) : false -): void {} - -type LoaderData = ReturnType>; -type RouteLoaderData = ReturnType>; - -describe("useLoaderData", () => { - it("supports plain data type", () => { - type AppData = { hello: string }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports plain Response", () => { - type Loader = (args: any) => Response; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("infers type regardless of redirect", () => { - type Loader = ( - args: any - ) => TypedResponse<{ id: string }> | TypedResponse; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports Response-returning loader", () => { - type Loader = (args: any) => TypedResponse<{ hello: string }>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports async Response-returning loader", () => { - type Loader = (args: any) => Promise>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports data-returning loader", () => { - type Loader = (args: any) => { hello: string }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports async data-returning loader", () => { - type Loader = (args: any) => Promise<{ hello: string }>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); -}); - -describe("type serializer", () => { - it("converts Date to string", () => { - type AppData = { hello: Date }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports custom toJSON", () => { - type AppData = { toJSON(): { data: string[] } }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports recursion", () => { - type AppData = { dob: Date; parent: AppData }; - type SerializedAppData = { dob: string; parent: SerializedAppData }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports tuples and arrays", () => { - type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual< - response, - { arr: string[]; tuple: [string, number, string]; empty: [] } - >(true); - isEqual(true); - }); - - it("transforms unserializables to null in arrays", () => { - type AppData = [Function, symbol, undefined]; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("transforms unserializables to never in objects", () => { - type AppData = { arg1: Function; arg2: symbol; arg3: undefined }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("supports class instances", () => { - class Test { - arg: string; - speak: () => string; - } - type Loader = (args: any) => TypedResponse; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("makes keys optional if the value is undefined", () => { - type AppData = { - arg1: string; - arg2: number | undefined; - arg3: undefined; - }; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual(true); - isEqual(true); - }); - - it("allows data key in value", () => { - type AppData = { data: { hello: string } }; - type response = LoaderData; - isEqual(true); - }); -}); - -describe("deferred type serializer", () => { - it("supports synchronous loader", () => { - type Loader = ( - args: any - ) => TypedDeferredData<{ hello: string; lazy: Promise }>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual }>(true); - isEqual(true); - }); - - it("supports asynchronous loader", () => { - type Loader = ( - args: any - ) => Promise }>>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual }>(true); - isEqual(true); - }); - - it("supports synchronous loader with deferred object result", () => { - type Loader = ( - args: any - ) => TypedDeferredData<{ hello: string; lazy: Promise<{ a: number }> }>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual }>(true); - isEqual(true); - }); - - it("supports asynchronous loader with deferred object result", () => { - type Loader = ( - args: any - ) => Promise< - TypedDeferredData<{ hello: string; lazy: Promise<{ a: number }> }> - >; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual }>(true); - isEqual(true); - }); - - it("converts Date to string", () => { - type Loader = ( - args: any - ) => Promise< - TypedDeferredData<{ hello: Date; lazy: Promise<{ a: Date }> }> - >; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual }>(true); - isEqual(true); - }); - - it("supports custom toJSON", () => { - type AppData = { toJSON(): { data: string[] } }; - type Loader = ( - args: any - ) => Promise< - TypedDeferredData<{ hello: AppData; lazy: Promise<{ a: AppData }> }> - >; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual< - response, - { hello: { data: string[] }; lazy: Promise<{ a: { data: string[] } }> } - >(true); - isEqual(true); - }); - - it("supports recursion", () => { - type AppData = { dob: Date; parent: AppData }; - type SerializedAppData = { dob: string; parent: SerializedAppData }; - type Loader = ( - args: any - ) => Promise }>>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual< - response, - { hello: SerializedAppData; lazy: Promise } - >(true); - isEqual(true); - }); - - it("supports tuples and arrays", () => { - type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] }; - type SerializedAppData = { - arr: string[]; - tuple: [string, number, string]; - empty: []; - }; - type Loader = ( - args: any - ) => Promise }>>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual< - response, - { hello: SerializedAppData; lazy: Promise } - >(true); - isEqual(true); - }); - - it("transforms unserializables to null in arrays", () => { - type AppData = [Function, symbol, undefined]; - type SerializedAppData = [null, null, null]; - type Loader = ( - args: any - ) => Promise }>>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual< - response, - { hello: SerializedAppData; lazy: Promise } - >(true); - isEqual(true); - }); - - it("transforms unserializables to never in objects", () => { - type AppData = { arg1: Function; arg2: symbol; arg3: undefined }; - type Loader = ( - args: any - ) => Promise }>>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual }>(true); - isEqual(true); - }); - - it("supports class instances", () => { - class Test { - arg: string; - speak: () => string; - } - type Loader = ( - args: any - ) => Promise }>>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual< - response, - { hello: { arg: string }; lazy: Promise<{ arg: string }> } - >(true); - isEqual(true); - }); - - it("makes keys optional if the value is undefined", () => { - type AppData = { - arg1: string; - arg2: number | undefined; - arg3: undefined; - }; - type SerializedAppData = { arg1: string; arg2?: number }; - type Loader = ( - args: any - ) => Promise }>>; - type response = LoaderData; - type routeResponse = RouteLoaderData; - isEqual< - response, - { hello: SerializedAppData; lazy: Promise } - >(true); - isEqual(true); - }); - - it("allows data key in value", () => { - type AppData = { data: Promise<{ hello: string }> }; - type response = LoaderData; - isEqual }>(true); - }); -}); diff --git a/packages/remix-server-runtime/jsonify.ts b/packages/remix-server-runtime/jsonify.ts new file mode 100644 index 00000000000..80a6c2b5afd --- /dev/null +++ b/packages/remix-server-runtime/jsonify.ts @@ -0,0 +1,257 @@ +import { + expectType, + type Equal, + type Expect, + type MutualExtends, +} from "./typecheck"; + +// prettier-ignore +// `Jsonify` emulates `let y = JSON.parse(JSON.stringify(x))`, but for types +// so that we can infer the shape of the data sent over the network. +export type Jsonify = + // any + IsAny extends true ? any : + + // toJSON + T extends { toJSON(): infer U } ? (U extends JsonValue ? U : unknown) : + + // primitives + T extends JsonPrimitive ? T : + T extends String ? string : + T extends Number ? number : + T extends Boolean ? boolean : + + // Map & Set + T extends Map ? EmptyObject : + T extends Set ? EmptyObject : + + // TypedArray + T extends TypedArray ? Record : + + // Not JSON serializable + T extends NotJson ? never : + + // tuple & array + T extends [] ? [] : + T extends readonly [infer F, ...infer R] ? [NeverToNull>, ...Jsonify] : + T extends readonly unknown[] ? Array>>: + + // object + T extends Record ? JsonifyObject : + + // unknown + unknown extends T ? unknown : + + never + +// value is always not JSON => true +// value is always JSON => false +// value is somtimes JSON, sometimes not JSON => boolean +// note: cannot be inlined as logic requires union distribution +type ValueIsNotJson = T extends NotJson ? true : false; + +// note: remove optionality so that produced values are never `undefined`, +// only `true`, `false`, or `boolean` +type IsNotJson = { [K in keyof T]-?: ValueIsNotJson }; + +type JsonifyValues = { [K in keyof T]: Jsonify }; + +// prettier-ignore +type JsonifyObject> = + // required + { [K in keyof T as + unknown extends T[K] ? never : + IsNotJson[K] extends false ? K : + never + ]: JsonifyValues[K] } & + // optional + { [K in keyof T as + unknown extends T[K] ? K : + // if the value is always JSON, then it's not optional + IsNotJson[K] extends false ? never : + // if the value is always not JSON, omit it entirely + IsNotJson[K] extends true ? never : + // if the value is mixed, then it's optional + K + ]? : JsonifyValues[K]} + +// types ------------------------------------------------------------ + +type JsonPrimitive = string | number | boolean | null; + +type JsonArray = JsonValue[] | readonly JsonValue[]; + +// prettier-ignore +type JsonObject = + { [K in string]: JsonValue } & + { [K in string]?: JsonValue } + +type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +type NotJson = undefined | symbol | AnyFunction; + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; + +// tests ------------------------------------------------------------ + +// prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type _tests = [ + // any + Expect, any>>, + + // primitives + Expect, string>>, + Expect, number>>, + Expect, boolean>>, + Expect, null>>, + Expect, string>>, + Expect, number>>, + Expect, boolean>>, + + // Map & Set + Expect>, EmptyObject>>, + Expect>, EmptyObject>>, + + // TypedArray + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + + // Not Json + Expect, never>>, + Expect, never>>, + Expect void>, never>>, + Expect, never>>, + + // toJson + Expect, "stuff">>, + Expect, string>>, + Expect, unknown>>, + Expect, unknown>>, + Expect, string>>, + + + // tuple & array + Expect, []>>, + Expect, [1, 'two', string, null, false]>>, + Expect, (string | number)[]>>, + Expect, null[]>>, + Expect, [1,2,3]>>, + + // object + Expect>, {}>>, + Expect>, {a: string}>>, + Expect>, {a?: string}>>, + Expect>, {a?: string}>>, + Expect>, {a: string, b?: string}>>, + Expect>, {}>>, + Expect>>, Record>>, + Expect>>, Record>>, + Expect}>, { payload: Record}>>, + Expect any); + optionalFunctionUnion?: string | (() => any); + optionalFunctionUnionUndefined: string | (() => any) | undefined; + + // Should be omitted + requiredFunction: () => any; + optionalFunction?: () => any; + optionalFunctionUndefined: (() => any) | undefined; + }>>, { + requiredString: string + requiredUnion: number | boolean + + optionalString?: string; + optionalUnion?: number | string; + optionalStringUndefined?: string | undefined; + optionalUnionUndefined?: number | string | undefined; + requiredFunctionUnion?: string + optionalFunctionUnion?: string; + optionalFunctionUnionUndefined?: string + }>>, + + // unknown + Expect, unknown>>, + Expect, unknown[]>>, + Expect, [unknown, 1]>>, + Expect>, {a?: unknown}>>, + Expect>, {a?: unknown, b: 'hello'}>>, + + // never + Expect, never>>, + Expect>, {a: never}>>, + Expect>, {a: never, b:string}>>, + Expect>, {a: never, b: string} | {a: string, b: never}>>, + + // class + Expect>, {a: string}>>, +]; + +class MyClass { + a: string; + b: () => string; + + constructor() { + this.a = "hello"; + this.b = () => "world"; + } +} + +// real-world example: `InvoiceLineItem` from `stripe` +type Recursive = { + a: Date; + recur?: Recursive; +}; +declare const recursive: Jsonify; +expectType<{ a: string; recur?: Jsonify }>( + recursive.recur!.recur!.recur! +); + +// real-world example: `Temporal` from `@js-temporal/polyfill` +interface BooleanWithToJson extends Boolean { + toJSON(): string; +} + +// utils ------------------------------------------------------------ + +type Pretty = { [K in keyof T]: T[K] }; + +type AnyFunction = (...args: any[]) => unknown; + +type NeverToNull = [T] extends [never] ? null : T; + +// adapted from https://github.com/sindresorhus/type-fest/blob/main/source/empty-object.d.ts +declare const emptyObjectSymbol: unique symbol; +type EmptyObject = { [emptyObjectSymbol]?: never }; + +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +type IsAny = 0 extends 1 & T ? true : false; diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 9dff84ea853..62819f396d7 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -21,8 +21,7 @@ "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.4.1", "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3", - "type-fest": "^4.0.0" + "source-map": "^0.7.3" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/packages/remix-server-runtime/serialize.ts b/packages/remix-server-runtime/serialize.ts index fb35d54cace..4a2a8a3a79c 100644 --- a/packages/remix-server-runtime/serialize.ts +++ b/packages/remix-server-runtime/serialize.ts @@ -1,10 +1,7 @@ -import type { Jsonify } from "type-fest"; - -import type { AppData } from "./data"; +import type { Jsonify } from "./jsonify"; import type { TypedDeferredData, TypedResponse } from "./responses"; - -// Note: The return value has to be `any` and not `unknown` so it can match `void`. -type Fn = (...args: any[]) => any; +import { expectType } from "./typecheck"; +import { type Expect, type Equal } from "./typecheck"; // prettier-ignore /** @@ -13,29 +10,80 @@ type Fn = (...args: any[]) => any; * For example: * `type LoaderData = SerializeFrom` */ -export type SerializeFrom = - T extends (...args: any[]) => infer Output ? - Awaited extends TypedResponse ? Jsonify : - Awaited extends TypedDeferredData ? - // top-level promises - & { - [K in keyof U as - K extends symbol ? never : - Promise extends U[K] ? K : - never - ]: - // use generic to distribute over union - DeferValue - } - // non-promises - & Jsonify<{ [K in keyof U as Promise extends U[K] ? never : K]: U[K] }> - : - Jsonify> : +export type SerializeFrom = + T extends (...args: any[]) => infer Output ? Serialize> : + // Back compat: manually defined data type, not inferred from loader nor action Jsonify> ; +// note: cannot be inlined as logic requires union distribution +// prettier-ignore +type Serialize = + Output extends TypedDeferredData ? + // top-level promises + & { + [K in keyof U as + K extends symbol ? never : + Promise extends U[K] ? K : + never + ]: DeferValue; // use generic to distribute over union + } + // non-promises + & Jsonify<{ + [K in keyof U as + Promise extends U[K] ? never : + K + ]: U[K]; + }> + : + Output extends TypedResponse ? Jsonify : + Jsonify; + // prettier-ignore type DeferValue = T extends undefined ? undefined : T extends Promise ? Promise>> : Jsonify; + +// tests ------------------------------------------------------------ + +type Pretty = { [K in keyof T]: T[K] }; + +type Loader = () => Promise< + | TypedResponse // returned responses + | TypedResponse // thrown responses +>; + +type LoaderDefer> = () => Promise< + | TypedDeferredData // returned responses + | TypedResponse // thrown responses +>; + +// prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type _tests = [ + // back compat: plain object + Expect>, {a: string}>>, + + // only thrown responses (e.g. redirects) + Expect>, never>>, + + // basic loader data + Expect>>, {a: string}>>, + + // infer data type from `toJSON` + Expect>>, {a: string}>>, + + // regression test for specific field names + Expect>>, {a: string, name: number, data: boolean}>>, + + // defer top-level promises + Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false> +]; + +// recursive +type Recursive = { a: string; recur?: Recursive }; +declare const recursive: SerializeFrom>; +expectType<{ a: string; recur?: Jsonify }>( + recursive.recur!.recur!.recur! +); diff --git a/packages/remix-server-runtime/typecheck.ts b/packages/remix-server-runtime/typecheck.ts new file mode 100644 index 00000000000..7f6ffeab236 --- /dev/null +++ b/packages/remix-server-runtime/typecheck.ts @@ -0,0 +1,15 @@ +// typecheck that expression is assignable to type +export function expectType(_expression: T) {} + +// prettier-ignore +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +export type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false + +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +export type Expect = T; + +// looser, lazy equality check for recursive types +// prettier-ignore +export type MutualExtends = [A] extends [B] ? [B] extends [A] ? true : false : false