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: add loader type inference #3276

Merged
merged 14 commits into from
Jul 6, 2022
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
2 changes: 2 additions & 0 deletions packages/remix-cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
} from "@remix-run/server-runtime";

export type {
ActionArgs,
ActionFunction,
AppData,
AppLoadContext,
Expand All @@ -42,6 +43,7 @@ export type {
HtmlMetaDescriptor,
LinkDescriptor,
LinksFunction,
LoaderArgs,
LoaderFunction,
MemoryUploadHandlerFilterArgs,
MemoryUploadHandlerOptions,
Expand Down
2 changes: 2 additions & 0 deletions packages/remix-deno/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
} from "@remix-run/server-runtime";

export type {
ActionArgs,
ActionFunction,
AppData,
AppLoadContext,
Expand All @@ -45,6 +46,7 @@ export type {
HtmlMetaDescriptor,
LinkDescriptor,
LinksFunction,
LoaderArgs,
LoaderFunction,
MemoryUploadHandlerFilterArgs,
MemoryUploadHandlerOptions,
Expand Down
2 changes: 2 additions & 0 deletions packages/remix-node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export {
} from "@remix-run/server-runtime";

export type {
ActionArgs,
ActionFunction,
AppData,
AppLoadContext,
Expand All @@ -68,6 +69,7 @@ export type {
HtmlMetaDescriptor,
LinkDescriptor,
LinksFunction,
LoaderArgs,
LoaderFunction,
MemoryUploadHandlerFilterArgs,
MemoryUploadHandlerOptions,
Expand Down
103 changes: 103 additions & 0 deletions packages/remix-react/__tests__/hook-types-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { TypedResponse, UseDataFunctionReturn } from "../components";

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

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

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

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

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

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

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

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

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

it("supports custom toJSON", () => {
type AppData = { toJSON(): { data: string[] } };
type response = UseDataFunctionReturn<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>;
isEqual<response, SerializedAppData>(true);
});

it("supports tuples and arrays", () => {
type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] };
type response = UseDataFunctionReturn<AppData>;
isEqual<
response,
{ arr: string[]; tuple: [string, number, string]; empty: [] }
>(true);
});

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

it("supports class instances", () => {
class Test {
arg: string;
speak: () => string;
}
type Loader = (args: any) => TypedResponse<Test>;
type response = UseDataFunctionReturn<Loader>;
isEqual<response, { arg: string }>(true);
});
});
53 changes: 51 additions & 2 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1320,7 +1320,54 @@ export function useMatches(): RouteMatch[] {
*
* @see https://remix.run/api/remix#useloaderdata
*/
export function useLoaderData<T = AppData>(): T {

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
? undefined
: 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 (infer U)[]
? (U extends NonJsonPrimitives ? null : SerializeType<U>)[]
: T extends object
? {
[k in keyof T as T[k] extends NonJsonPrimitives
? never
: k]: SerializeType<T[k]>;
}
: never;

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> {
return useRemixRouteContext().data;
}

Expand All @@ -1329,7 +1376,9 @@ export function useLoaderData<T = AppData>(): T {
*
* @see https://remix.run/api/remix#useactiondata
*/
export function useActionData<T = AppData>(): T | undefined {
export function useActionData<T = AppData>():
| UseDataFunctionReturn<T>
| undefined {
let { id: routeId } = useRemixRouteContext();
let { transitionManager } = useRemixEntryContext();
let { actionData } = transitionManager.getState();
Expand Down
6 changes: 4 additions & 2 deletions packages/remix-react/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};
16 changes: 16 additions & 0 deletions packages/remix-server-runtime/__tests__/responses-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { TypedResponse } from "../index";
import { json, redirect } from "../index";
import { isEqual } from "./utils";

describe("json", () => {
it("sets the Content-Type header", () => {
Expand Down Expand Up @@ -34,6 +36,20 @@ describe("json", () => {
let response = json({}, 201);
expect(response.status).toEqual(201);
});

it("infers input type", async () => {
let response = json({ hello: "remix" });
isEqual<typeof response, TypedResponse<{ hello: string }>>(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", () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-server-runtime/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,7 @@ export function mockServerBuild(
export function prettyHtml(source: string): string {
return prettier.format(source, { parser: "html" });
}

export function isEqual<A, B>(
arg: A extends B ? (B extends A ? true : false) : false
): void {}
6 changes: 4 additions & 2 deletions packages/remix-server-runtime/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,12 @@ export interface ThrownResponse<T = any> {
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<SerializedError> {
return {
Expand Down
3 changes: 3 additions & 0 deletions packages/remix-server-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ export type {
IsSessionFunction,
JsonFunction,
RedirectFunction,
TypedResponse,
} from "./interface";

// Remix server runtime packages should re-export these types
export type {
ActionArgs,
ActionFunction,
AppData,
AppLoadContext,
Expand All @@ -50,6 +52,7 @@ export type {
HtmlMetaDescriptor,
LinkDescriptor,
LinksFunction,
LoaderArgs,
LoaderFunction,
MetaDescriptor,
MetaFunction,
Expand Down
6 changes: 5 additions & 1 deletion packages/remix-server-runtime/interface.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
46 changes: 46 additions & 0 deletions packages/remix-server-runtime/magicExports/remix.ts
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 2 additions & 0 deletions packages/remix-server-runtime/reexport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ export type {
} from "./links";

export type {
ActionArgs,
ActionFunction,
DataFunctionArgs,
ErrorBoundaryComponent,
HeadersFunction,
HtmlMetaDescriptor,
LinksFunction,
LoaderArgs,
LoaderFunction,
MetaDescriptor,
MetaFunction,
Expand Down
Loading