Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@remix-run/cloudflare,@remix-run/deno,@remix-run/node): SerializeFrom utility for loader and action type inference #4013

Merged
merged 19 commits into from
Aug 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2adec26
docs(@remix-run/react): reunite `useLoaderData` with its jsdoc
pcattori Aug 17, 2022
de502a5
refactor(@remix-run/react): factor out `SerializeType`
pcattori Aug 17, 2022
6e54352
refactor(@remix-run/react): singularize union types
pcattori Aug 17, 2022
d7aaac6
refactor(@remix-run/react): properly constrain objects in typescript
pcattori Aug 17, 2022
6a6f624
refactor(@remix-run/react): remove redundant "Type" suffix from types…
pcattori Aug 17, 2022
275a170
refactor(@remix-run/react): rename `UndefinedOptionals` to `Undefined…
pcattori Aug 17, 2022
665e0db
refactor(@remix-run/react): factor out tuple serialization into type …
pcattori Aug 17, 2022
a6cd1c5
fix(@remix-run/react): `Serialize<any>` should return `any`
pcattori Aug 17, 2022
f6de023
fix(@remix-run/react): serialize classes
pcattori Aug 17, 2022
978ab3d
style(@remix-run/react): do not auto-format typescript ternaries
pcattori Aug 17, 2022
86e6d52
refactor(@remix-run/react): move serialize type utilities into their …
pcattori Aug 17, 2022
a14846c
refactor(@remix-run/react): inline `DataOrFunction` type since its on…
pcattori Aug 17, 2022
8cdedf4
refactor(@remix-run/react): rename `UseDataFunctionReturn` to `Serial…
pcattori Aug 17, 2022
f72d48e
chore(lint): ignore eslint warning about unused typescript generic th…
pcattori Aug 17, 2022
72e0c93
docs(@remix-run/react): jsdoc for `SerializeFrom`
pcattori Aug 17, 2022
439866c
chore(@remix-run/react): add comment explaining `IsAny` implementation
pcattori Aug 17, 2022
638fd10
refactor(serialize): move serialize type utilities into `@remix-run/s…
pcattori Aug 18, 2022
b97e972
feat(@remix-run/deno,@remix-run/cloudflare,@remix-run/node): export `…
pcattori Aug 18, 2022
65aceb4
Create neat-beds-unite.md
pcattori Aug 18, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateRequestHandlerFunction is a utility for authoring Remix runtime packages and was incorrectly re-exported before.

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,
MichaelDeBoey marked this conversation as resolved.
Show resolved Hide resolved
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,
MichaelDeBoey marked this conversation as resolved.
Show resolved Hide resolved
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>>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this test is for testing useLoaderData's types, so we actually grab types from useLoaderData instead of assuming its using UseDataFunctionReturn or SerializeFrom.


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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interface section of exports is for types related to values/functions that should be exported by every server runtime pkg. We use the fetch API for request/response, so we don't export our own request/response implementations from server runtime pkgs.

Therefore, TypedResponse should not be considered part of the server runtime interface.


TBH the distinction between "interface" and "re-export" is subtle and I'd much prefer to not need re-exports at all once we have a pkg for exporting runtime-agnostic stuff.

} 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