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

Single-fetch typesafety: defineLoader and defineAction #9372

Merged
merged 5 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 50 additions & 0 deletions .changeset/rotten-geckos-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@remix-run/cloudflare": patch
"@remix-run/deno": patch
"@remix-run/node": patch
"@remix-run/react": patch
"@remix-run/server-runtime": patch
---

Typesafety for single-fetch: defineLoader, defineClientLoader, defineAction, defineClientAction

`defineLoader` and `defineAction` are helpers for authoring `loader`s and `action`s.
They are identity functions; they don't modify your loader or action at runtime.
Rather, they exist solely for typesafety by providing types for args and by ensuring valid return types.

```ts
export let loader = defineLoader(({ request }) => {
// ^? Request
return {a:1, b: () => 2}
// ^ type error: `b` is not serializable
})
```

Note that `defineLoader` and `defineAction` are not technically necessary for defining loaders and actions if you aren't concerned with typesafety:

```ts
// this totally works! and typechecking is happy too!
export let loader = () => {
return {a: 1}
}
```

This means that you can opt-in to `defineLoader` incrementally, one loader at a time.

You can return custom responses via the `json`/`defer` utilities, but doing so will revert back to the old JSON-based typesafety mechanism:

```ts
let loader1 = () => {
return {a: 1, b: new Date()}
}
let data1 = useLoaderData<typeof loader1>()
// ^? {a: number, b: Date}

let loader2 = () => {
return json({a: 1, b: new Date()}) // this opts-out of turbo-stream
}
let data2 = useLoaderData<typeof loader2>()
// ^? JsonifyObject<{a: number, b: Date}> which is really {a: number, b: string}
```

You can also continue to return totally custom responses with `Response` though this continues to be outside of the typesystem since the built-in `Response` type is not generic
61 changes: 57 additions & 4 deletions docs/guides/single-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,66 @@ In order to ensure you get the proper types when using Single Fetch, we've inclu

```json
{
"include": [
// ...
"node_modules/@remix-run/react/future/single-fetch.d.ts"
]
"compilerOptions": {
"types": ["@remix-run/react/future/single-fetch.d.ts"]
}
}
```

🚨 Make sure the single-fetch types come after any other Remix packages in `types` so that they override those existing types.

** `defineLoader`, `defineAction`, `defineClientLoader`, `defineClientAction` **

To get typesafety when defining loaders and actions, you can use the `defineLoader` and `defineAction` utilities:

```ts
import { defineLoader } from "@remix-run/node";

export const loader = defineLoader(({ request }) => {
// ^? Request
});

export const action = defineAction(({ context }) => {
// ^? AppLoadContext
});
```

Not only does this give you types for any arguments, but it also ensures you are returning single-fetch compatible types:

```ts
export const loader = defineLoader(() => {
return { hello: "world", badData: () => 1 };
// ^^^^^^^ Type error: `badData` is not serializable
});

export const action = defineAction(() => {
return { hello: "world", badData: new CustomType() };
Copy link
Member

@sergiodxa sergiodxa May 8, 2024

Choose a reason for hiding this comment

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

What if CustomType class has a toJSON? Couldn't the badData.toJSON() method be called and then return as a plain JSON? So you will not get a CustomType client-side but at least the data.

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 contract for single-fetch (and turbo-stream which is what it is using under-the-hood) is that a type T in becomes a type T out. So CustomType -> string is not supported. If you want you can manually call toJSON():

export let loader = () => {
  return { a: myCustomTypeInstance.toJSON() }
}

The point is to have less coercion and magic. Having the serialization be basically a glorified ID function is the thing that unlocks a bunch of the simplified types features. Plus it means we can support recursive types much better than the current approach.

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Cross posting my response from discord as well for visibility

We're hesitant to add to much "custom" stuff like this to turbo-stream because it's a stopgap solution that will be replaced with the RSC wire streaming format once we land the RSC work. So the limitations of what can and can't be streamed are going to eventually be defined by React, not us

I.e., if we add toJson support now, folks will start relying on it. Then if we incorporate RSC and it doens't support toJSON, it would be a breaking change for your apps. So we're keeping this simpler now until we know in greater detail how the RSC story shakes out.

// ^^^^^^^ Type error: `badData` is not serializable
});
```

Single-fetch supports the following return types:

```ts
type Serializable =
Copy link
Member

Choose a reason for hiding this comment

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

Is this type exported somewhere?

| undefined
| null
| boolean
| string
| symbol
| number
| bigint
| Date
| URL
Copy link
Member

Choose a reason for hiding this comment

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

Could URLSearchParams be added to this too? I remember someone reporting in Discord that they tried to return this and it failed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We're using Jacob's turbo-stream library under-the-hood so support for URLSearchParams would need to be added there.

| RegExp
| Error
| Array<Serializable>
| { [key: PropertyKey]: Serializable } // objects with serializable values
| Map<Serializable, Serializable>
| Set<Serializable>
| Promise<Serializable>;
```

**`useLoaderData`, `useActionData`, `useRouteLoaderData`, and `useFetcher`**

These methods do not require any code changes on your part - adding the single fetch types will cause their generics to deserialize correctly:
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export {
export {
createRequestHandler,
createSession,
unstable_defineLoader,
unstable_defineClientLoader,
unstable_defineAction,
unstable_defineClientAction,
defer,
broadcastDevReady,
logDevReady,
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-deno/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export {
export {
broadcastDevReady,
createSession,
unstable_defineLoader,
unstable_defineClientLoader,
unstable_defineAction,
unstable_defineClientAction,
defer,
isCookie,
isSession,
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export {
export {
createRequestHandler,
createSession,
unstable_defineLoader,
unstable_defineClientLoader,
unstable_defineAction,
unstable_defineClientAction,
defer,
broadcastDevReady,
logDevReady,
Expand Down
102 changes: 22 additions & 80 deletions packages/remix-react/future/single-fetch.d.ts
Original file line number Diff line number Diff line change
@@ -1,100 +1,46 @@
import type { MetaArgs, UIMatch, UNSAFE_MetaMatch } from "@remix-run/react";
import type {
LoaderFunctionArgs,
ActionFunctionArgs,
SerializeFrom,
TypedDeferredData,
TypedResponse,
unstable_Loader as Loader,
unstable_Action as Action,
unstable_Serialize as Serialize,
} from "@remix-run/server-runtime";
import type {
useFetcher as useFetcherRR,
FetcherWithComponents,
} from "react-router-dom";

type Serializable =
| undefined
| null
| boolean
| string
| symbol
| number
| Array<Serializable>
| { [key: PropertyKey]: Serializable }
| bigint
| Date
| URL
| RegExp
| Error
| Map<Serializable, Serializable>
| Set<Serializable>
| Promise<Serializable>;

type DataFunctionReturnValue =
| Serializable
| TypedDeferredData<Record<string, unknown>>
| TypedResponse<Record<string, unknown>>;

type LoaderFunction_SingleFetch = (
args: LoaderFunctionArgs
) => Promise<DataFunctionReturnValue> | DataFunctionReturnValue;
type ActionFunction_SingleFetch = (
args: ActionFunctionArgs
) => Promise<DataFunctionReturnValue> | DataFunctionReturnValue;

// Backwards-compatible type for Remix v2 where json/defer still use the old types,
// and only non-json/defer returns use the new types. This allows for incremental
// migration of loaders to return naked objects. In the next major version,
// json/defer will be removed so everything will use the new simplified typings.
// prettier-ignore
type SingleFetchSerialize_V2<T extends LoaderFunction_SingleFetch | ActionFunction_SingleFetch> =
Awaited<ReturnType<T>> extends TypedDeferredData<infer D> ? D :
Awaited<ReturnType<T>> extends TypedResponse<Record<string, unknown>> ? SerializeFrom<T> :
Awaited<ReturnType<T>>;

declare module "@remix-run/react" {
export function useLoaderData<T>(): T extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<T>
: never;
export function useLoaderData<T extends Loader>(): Serialize<T>;

export function useActionData<T>(): T extends ActionFunction_SingleFetch
? SingleFetchSerialize_V2<T> | undefined
: never;
export function useActionData<T extends Action>(): Serialize<T> | undefined;

export function useRouteLoaderData<T>(
export function useRouteLoaderData<T extends Loader>(
routeId: string
): T extends LoaderFunction_SingleFetch ? SingleFetchSerialize_V2<T> : never;
): Serialize<T>;

export function useFetcher<TData = unknown>(
export function useFetcher<T extends Loader | Action>(
opts?: Parameters<typeof useFetcherRR>[0]
): FetcherWithComponents<
TData extends LoaderFunction_SingleFetch | ActionFunction_SingleFetch
? SingleFetchSerialize_V2<TData>
: never
>;
): FetcherWithComponents<Serialize<T>>;

export type UIMatch_SingleFetch<D = unknown, H = unknown> = Omit<
UIMatch<D, H>,
"data"
> & {
data: D extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<D>
: never;
data: D extends Loader ? Serialize<D> : never;
};

interface MetaMatch_SingleFetch<
RouteId extends string = string,
Loader extends LoaderFunction_SingleFetch | unknown = unknown
> extends Omit<UNSAFE_MetaMatch<RouteId, Loader>, "data"> {
data: Loader extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<Loader>
: unknown;
L extends Loader | unknown = unknown
> extends Omit<UNSAFE_MetaMatch<RouteId, L>, "data"> {
data: L extends Loader ? Serialize<L> : unknown;
}

type MetaMatches_SingleFetch<
MatchLoaders extends Record<
MatchLoaders extends Record<string, Loader | unknown> = Record<
string,
LoaderFunction_SingleFetch | unknown
> = Record<string, unknown>
unknown
>
> = Array<
{
[K in keyof MatchLoaders]: MetaMatch_SingleFetch<
Expand All @@ -105,17 +51,13 @@ declare module "@remix-run/react" {
>;

export interface MetaArgs_SingleFetch<
Loader extends LoaderFunction_SingleFetch | unknown = unknown,
MatchLoaders extends Record<
L extends Loader | unknown = unknown,
MatchLoaders extends Record<string, Loader | unknown> = Record<
string,
LoaderFunction_SingleFetch | unknown
> = Record<string, unknown>
> extends Omit<MetaArgs<Loader, MatchLoaders>, "data" | "matches"> {
data:
| (Loader extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<Loader>
: unknown)
| undefined;
unknown
>
> extends Omit<MetaArgs<L, MatchLoaders>, "data" | "matches"> {
data: (L extends Loader ? Serialize<L> : unknown) | undefined;
matches: MetaMatches_SingleFetch<MatchLoaders>;
}
}
13 changes: 12 additions & 1 deletion packages/remix-server-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@ export {
parseMultipartFormData as unstable_parseMultipartFormData,
} from "./formData";
export { defer, json, redirect, redirectDocument } from "./responses";

export {
SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol,
defineLoader as unstable_defineLoader,
defineClientLoader as unstable_defineClientLoader,
defineAction as unstable_defineAction,
defineClientAction as unstable_defineClientAction,
} from "./single-fetch";
export type {
Loader as unstable_Loader,
Action as unstable_Action,
Serialize as unstable_Serialize,
SingleFetchResult as UNSAFE_SingleFetchResult,
SingleFetchResults as UNSAFE_SingleFetchResults,
} from "./single-fetch";
export { SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol } from "./single-fetch";

export { createRequestHandler } from "./server";
export {
createSession,
Expand Down
Loading