Skip to content

Commit

Permalink
feat(@remix-run/cloudflare,@remix-run/deno,@remix-run/node): `Seriali…
Browse files Browse the repository at this point in the history
…zeFrom` utility for loader and action type inference (#4013)

* docs(@remix-run/react): reunite `useLoaderData` with its jsdoc

* refactor(@remix-run/react): factor out `SerializeType`

* refactor(@remix-run/react): singularize union types

convention is to use plurals for collections (e.g. arrays), not for unions

* refactor(@remix-run/react): properly constrain objects in typescript

arrays are technically `object`s, so we need something more specific. `Record<PropertyKey, unknown>`
should match any object, but not match arrays.

* refactor(@remix-run/react): remove redundant "Type" suffix from typescript utilities

* refactor(@remix-run/react): rename `UndefinedOptionals` to `UndefinedToOptional`

more obvious that this _converts_ `undefined` unions to optionals

* refactor(@remix-run/react): factor out tuple serialization into type utility

and to be consistent with `SerializeObject`

* fix(@remix-run/react): `Serialize<any>` should return `any`

* fix(@remix-run/react): serialize classes

and provide comments to explain what tuples are and how `object` matches classes

* style(@remix-run/react): do not auto-format typescript ternaries

makes each line in the type definition independent of other lines.
more readable and easier to comment/uncomment specific conditions.
plus less git noise when adding/removing/reordering conditions.

* refactor(@remix-run/react): move serialize type utilities into their own module

* refactor(@remix-run/react): inline `DataOrFunction` type since its only used once

* refactor(@remix-run/react): rename `UseDataFunctionReturn` to `SerializeFrom`

since the type can be used without `useLoaderData` or `useActionData` and just returns the JSON
serialized data from a loader or action

* chore(lint): ignore eslint warning about unused typescript generic that _is_ being used

* docs(@remix-run/react): jsdoc for `SerializeFrom`

* chore(@remix-run/react): add comment explaining `IsAny` implementation

* refactor(serialize): move serialize type utilities into `@remix-run/server-runtime`

* feat(@remix-run/deno,@remix-run/cloudflare,@remix-run/node): export `SerializeFrom` type utility

also, sync re-exports from `@remix-run/server-runtime`

* Create neat-beds-unite.md
  • Loading branch information
pcattori authored Aug 18, 2022
1 parent 874f159 commit 5c9539d
Show file tree
Hide file tree
Showing 11 changed files with 147 additions and 106 deletions.
20 changes: 20 additions & 0 deletions .changeset/neat-beds-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"remix": minor
"@remix-run/cloudflare": minor
"@remix-run/deno": minor
"@remix-run/node": minor
"@remix-run/react": minor
"@remix-run/serve": minor
"@remix-run/server-runtime": minor
---

Each runtime package (@remix-run/cloudflare,@remix-run/deno,@remix-run/node) now exports `SerializeFrom`, which is used to
infer the JSON-serialized return type of loaders and actions.

Example:
```ts
type MyLoaderData = SerializeFrom<typeof loader>
type MyActionData = SerializeFrom<typeof action>
```
This is what `useLoaderData<typeof loader>` and `useActionData<typeof action>` use under-the-hood.
7 changes: 5 additions & 2 deletions packages/remix-cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export type {
CookieParseOptions,
CookieSerializeOptions,
CookieSignatureOptions,
CreateRequestHandlerFunction,
DataFunctionArgs,
EntryContext,
ErrorBoundaryComponent,
Expand All @@ -53,12 +52,16 @@ export type {
RequestHandler,
RouteComponent,
RouteHandle,
SerializeFrom,
ServerBuild,
ServerEntryModule,
Session,
SessionData,
SessionIdStorageStrategy,
SessionStorage,
UploadHandler,
SignFunction,
TypedResponse,
UnsignFunction,
UploadHandlerPart,
UploadHandler,
} from "@remix-run/server-runtime";
7 changes: 5 additions & 2 deletions packages/remix-deno/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export type {
CookieParseOptions,
CookieSerializeOptions,
CookieSignatureOptions,
CreateRequestHandlerFunction,
DataFunctionArgs,
EntryContext,
ErrorBoundaryComponent,
Expand All @@ -56,12 +55,16 @@ export type {
RequestHandler,
RouteComponent,
RouteHandle,
SerializeFrom,
ServerBuild,
ServerEntryModule,
Session,
SessionData,
SessionIdStorageStrategy,
SessionStorage,
UploadHandler,
SignFunction,
TypedResponse,
UnsignFunction,
UploadHandlerPart,
UploadHandler,
} from "@remix-run/server-runtime";
8 changes: 5 additions & 3 deletions packages/remix-node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export {
isSession,
json,
JsonFunction,
TypedResponse,
MaxPartSizeExceededError,
redirect,
unstable_composeUploadHandlers,
Expand All @@ -60,7 +59,6 @@ export type {
CookieParseOptions,
CookieSerializeOptions,
CookieSignatureOptions,
CreateRequestHandlerFunction,
DataFunctionArgs,
EntryContext,
ErrorBoundaryComponent,
Expand All @@ -81,12 +79,16 @@ export type {
RequestHandler,
RouteComponent,
RouteHandle,
SerializeFrom,
ServerBuild,
ServerEntryModule,
Session,
SessionData,
SessionIdStorageStrategy,
SessionStorage,
UploadHandler,
SignFunction,
TypedResponse,
UnsignFunction,
UploadHandlerPart,
UploadHandler,
} from "@remix-run/server-runtime";
37 changes: 21 additions & 16 deletions packages/remix-react/__tests__/hook-types-test.tsx
Original file line number Diff line number Diff line change
@@ -1,78 +1,83 @@
import type { TypedResponse, UseDataFunctionReturn } from "../components";
import type { TypedResponse } from "../serialize";
import type { useLoaderData } from "../components";

function isEqual<A, B>(
arg: A extends B ? (B extends A ? true : false) : false
): void {}

// not sure why `eslint` thinks the `T` generic is not used...
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type LoaderData<T> = ReturnType<typeof useLoaderData<T>>;

describe("useLoaderData", () => {
it("supports plain data type", () => {
type AppData = { hello: string };
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<response, { hello: string }>(true);
});

it("supports plain Response", () => {
type Loader = (args: any) => Response;
type response = UseDataFunctionReturn<Loader>;
type response = LoaderData<Loader>;
isEqual<response, any>(true);
});

it("infers type regardless of redirect", () => {
type Loader = (
args: any
) => TypedResponse<{ id: string }> | TypedResponse<never>;
type response = UseDataFunctionReturn<Loader>;
type response = LoaderData<Loader>;
isEqual<response, { id: string }>(true);
});

it("supports Response-returning loader", () => {
type Loader = (args: any) => TypedResponse<{ hello: string }>;
type response = UseDataFunctionReturn<Loader>;
type response = LoaderData<Loader>;
isEqual<response, { hello: string }>(true);
});

it("supports async Response-returning loader", () => {
type Loader = (args: any) => Promise<TypedResponse<{ hello: string }>>;
type response = UseDataFunctionReturn<Loader>;
type response = LoaderData<Loader>;
isEqual<response, { hello: string }>(true);
});

it("supports data-returning loader", () => {
type Loader = (args: any) => { hello: string };
type response = UseDataFunctionReturn<Loader>;
type response = LoaderData<Loader>;
isEqual<response, { hello: string }>(true);
});

it("supports async data-returning loader", () => {
type Loader = (args: any) => Promise<{ hello: string }>;
type response = UseDataFunctionReturn<Loader>;
type response = LoaderData<Loader>;
isEqual<response, { hello: string }>(true);
});
});

describe("type serializer", () => {
it("converts Date to string", () => {
type AppData = { hello: Date };
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<response, { hello: string }>(true);
});

it("supports custom toJSON", () => {
type AppData = { toJSON(): { data: string[] } };
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<response, { data: string[] }>(true);
});

it("supports recursion", () => {
type AppData = { dob: Date; parent: AppData };
type SerializedAppData = { dob: string; parent: SerializedAppData };
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<response, SerializedAppData>(true);
});

it("supports tuples and arrays", () => {
type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] };
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<
response,
{ arr: string[]; tuple: [string, number, string]; empty: [] }
Expand All @@ -81,13 +86,13 @@ describe("type serializer", () => {

it("transforms unserializables to null in arrays", () => {
type AppData = [Function, symbol, undefined];
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<response, [null, null, null]>(true);
});

it("transforms unserializables to never in objects", () => {
type AppData = { arg1: Function; arg2: symbol; arg3: undefined };
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<response, {}>(true);
});

Expand All @@ -97,7 +102,7 @@ describe("type serializer", () => {
speak: () => string;
}
type Loader = (args: any) => TypedResponse<Test>;
type response = UseDataFunctionReturn<Loader>;
type response = LoaderData<Loader>;
isEqual<response, { arg: string }>(true);
});

Expand All @@ -107,7 +112,7 @@ describe("type serializer", () => {
arg2: number | undefined;
arg3: undefined;
};
type response = UseDataFunctionReturn<AppData>;
type response = LoaderData<AppData>;
isEqual<response, { arg1: string; arg2?: number }>(true);
});
});
78 changes: 3 additions & 75 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import {
useResolvedPath,
} from "react-router-dom";
import type { LinkProps, NavLinkProps } from "react-router-dom";
import type { Merge } from "type-fest";
import { createPath } from "history";
import type { SerializeFrom } from "@remix-run/server-runtime";

import type { AppData, FormEncType, FormMethod } from "./data";
import type { EntryContext, AssetsManifest } from "./entry";
Expand Down Expand Up @@ -1361,77 +1361,7 @@ export function useMatches(): RouteMatch[] {
*
* @see https://remix.run/api/remix#useloaderdata
*/

export type TypedResponse<T> = Response & {
json(): Promise<T>;
};

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> = 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[k]>;
}
: T extends ReadonlyArray<infer U>
? (U extends NonJsonPrimitives ? null : SerializeType<U>)[]
: T extends object
? SerializeObject<UndefinedOptionals<T>>
: never;

type SerializeObject<T> = {
[k in keyof T as T[k] extends NonJsonPrimitives ? never : k]: SerializeType<
T[k]
>;
};

/*
* For an object T, if it has any properties that are a union with `undefined`,
* make those into optional properties instead.
*
* Example: { a: string | undefined} --> { a?: string}
*/
type UndefinedOptionals<T extends object> = Merge<
{
// Property is not a union with `undefined`, keep as-is
[k in keyof T as undefined extends T[k] ? never : k]: T[k];
},
{
// Property _is_ a union with `defined`. Set as optional (via `?`) and remove `undefined` from the union
[k in keyof T as undefined extends T[k] ? k : never]?: Exclude<
T[k],
undefined
>;
}
>;

export type UseDataFunctionReturn<T extends DataOrFunction> = T extends (
...args: any[]
) => infer Output
? Awaited<Output> extends TypedResponse<infer U>
? SerializeType<U>
: SerializeType<Awaited<ReturnType<T>>>
: SerializeType<Awaited<T>>;
export function useLoaderData<T = AppData>(): UseDataFunctionReturn<T> {
export function useLoaderData<T = AppData>(): SerializeFrom<T> {
return useRemixRouteContext().data;
}

Expand All @@ -1440,9 +1370,7 @@ export function useLoaderData<T = AppData>(): UseDataFunctionReturn<T> {
*
* @see https://remix.run/api/remix#useactiondata
*/
export function useActionData<T = AppData>():
| UseDataFunctionReturn<T>
| undefined {
export function useActionData<T = AppData>(): SerializeFrom<T> | undefined {
let { id: routeId } = useRemixRouteContext();
let { transitionManager } = useRemixEntryContext();
let { actionData } = transitionManager.getState();
Expand Down
1 change: 1 addition & 0 deletions packages/remix-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"type-fest": "^2.17.0"
},
"devDependencies": {
"@remix-run/server-runtime": "*",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^13.3.0",
"abort-controller": "^3.0.0",
Expand Down
7 changes: 4 additions & 3 deletions packages/remix-server-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export type {
IsSessionFunction,
JsonFunction,
RedirectFunction,
TypedResponse,
} from "./interface";

// Remix server runtime packages should re-export these types
Expand All @@ -54,22 +53,24 @@ export type {
LinksFunction,
LoaderArgs,
LoaderFunction,
MemoryUploadHandlerFilterArgs,
MemoryUploadHandlerOptions,
MetaDescriptor,
MetaFunction,
PageLinkDescriptor,
RequestHandler,
RouteComponent,
RouteHandle,
SerializeFrom,
ServerBuild,
ServerEntryModule,
Session,
SessionData,
SessionIdStorageStrategy,
SessionStorage,
SignFunction,
TypedResponse,
UnsignFunction,
UploadHandlerPart,
UploadHandler,
MemoryUploadHandlerOptions,
MemoryUploadHandlerFilterArgs,
} from "./reexport";
6 changes: 1 addition & 5 deletions packages/remix-server-runtime/interface.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
export type { CreateCookieFunction, IsCookieFunction } from "./cookies";
export type {
JsonFunction,
RedirectFunction,
TypedResponse,
} from "./responses";
export type { JsonFunction, RedirectFunction } from "./responses";
export type { CreateRequestHandlerFunction } from "./server";
export type {
CreateSessionFunction,
Expand Down
Loading

0 comments on commit 5c9539d

Please sign in to comment.