Skip to content

Commit

Permalink
Single-fetch typesafety (#9893)
Browse files Browse the repository at this point in the history
  • Loading branch information
pcattori authored Aug 28, 2024
1 parent 9c33057 commit 5a38eba
Show file tree
Hide file tree
Showing 17 changed files with 423 additions and 256 deletions.
40 changes: 40 additions & 0 deletions .changeset/moody-cups-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@remix-run/cloudflare": patch
"@remix-run/deno": patch
"@remix-run/node": patch
"@remix-run/react": patch
"@remix-run/server-runtime": patch
---

(unstable) Improved typesafety for single-fetch

If you were already using single-fetch types:

- Remove `"@remix-run/react/future/single-fetch.d.ts"` override from `tsconfig.json` > `compilerOptions` > `types`
- Remove `defineLoader`, `defineAction`, `defineClientLoader`, `defineClientAction` helpers from your route modules
- Replace `UIMatch_SingleFetch` type helper with `UIMatch`
- Replace `MetaArgs_SingleFetch` type helper with `MetaArgs`

Then you are ready for the new typesafety setup:

```ts
// vite.config.ts

declare module "@remix-run/server-runtime" {
interface Future {
unstable_singleFetch: true // 👈 enable _types_ for single-fetch
}
}

export default defineConfig({
plugins: [
remix({
future: {
unstable_singleFetch: true // 👈 enable single-fetch
}
})
]
})
```

For more information, see [Guides > Single Fetch](https://remix.run/docs/en/dev/guides/single-fetch) in our docs.
181 changes: 78 additions & 103 deletions docs/guides/single-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,145 +145,120 @@ Without Single Fetch, any plain Javascript object returned from a `loader` or `a

With Single Fetch, naked objects will be streamed directly, so the built-in type inference is no longer accurate once you have opted-into Single Fetch. For example, they would assume that a `Date` would be serialized to a string on the client 😕.

In order to ensure you get the proper types when using Single Fetch, we've included a set of type overrides that you can include in your `tsconfig.json`'s `compilerOptions.types` array which aligns the types with the Single Fetch behavior:

```json
{
"compilerOptions": {
//...
"types": [
// ...
"@remix-run/react/future/single-fetch.d.ts"
]
#### Enable Single Fetch types

To switch over to Single Fetch types, you should augment Remix's `Future` interface with `unstable_singleFetch: true`:

```ts filename=vite.config.ts
declare module "@remix-run/server-runtime" {
interface Future {
unstable_singleFetch: true;
}
}
```

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

#### Loader/Action Definition Utilities

To enhance type-safety when defining loaders and actions with Single Fetch, you can use the new `unstable_defineLoader` and `unstable_defineAction` utilities:
Now `useLoaderData`, `useActionData`, and any other utilities that use a `typeof loader` generic should be using Single Fetch types:

```ts
import { unstable_defineLoader as defineLoader } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = defineLoader(({ request }) => {
// ^? Request
});
export function loader() {
return {
planet: "world",
date: new Date(),
};
}

export default function Component() {
const data = useLoaderData<typeof loader>();
// ^? { planet: string, date: Date }
}
```

Not only does this give you types for arguments (and deprecates `LoaderFunctionArgs`), but it also ensures you are returning single-fetch compatible types:
#### Functions and class instances

```ts
export const loader = defineLoader(() => {
return { hello: "world", badData: () => 1 };
// ^^^^^^^ Type error: `badData` is not serializable
});
In general, functions cannot be reliably sent over the network, so they get serialized as `undefined`:

export const action = defineAction(() => {
return { hello: "world", badData: new CustomType() };
// ^^^^^^^ Type error: `badData` is not serializable
});
```
```ts
import { useLoaderData } from "@remix-run/react";

Single-fetch supports the following return types:
export function loader() {
return {
planet: "world",
date: new Date(),
notSoRandom: () => 7,
};
}

```ts
type Serializable =
| undefined
| null
| boolean
| string
| symbol
| number
| bigint
| Date
| URL
| RegExp
| Error
| Array<Serializable>
| { [key: PropertyKey]: Serializable } // objects with serializable values
| Map<Serializable, Serializable>
| Set<Serializable>
| Promise<Serializable>;
export default function Component() {
const data = useLoaderData<typeof loader>();
// ^? { planet: string, date: Date, notSoRandom: undefined }
}
```

There are also client-side equivalents un `defineClientLoader`/`defineClientAction` that don't have the same return value restrictions because data returned from `clientLoader`/`clientAction` does not need to be serialized over the wire:
Methods are also not serializable, so class instances get slimmed down to just their serializable properties:

```ts
import { unstable_defineLoader as defineLoader } from "@remix-run/node";
import { unstable_defineClientLoader as defineClientLoader } from "@remix-run/react";
import { useLoaderData } from "@remix-run/react";

export const loader = defineLoader(() => {
return { msg: "Hello!", date: new Date() };
});
class Dog {
name: string;
age: number;

export const clientLoader = defineClientLoader(
async ({ serverLoader }) => {
const data = await serverLoader<typeof loader>();
// ^? { msg: string, date: Date }
return {
...data,
client: "World!",
};
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
);

export default function Component() {
const data = useLoaderData<typeof clientLoader>();
// ^? { msg: string, date: Date, client: string }
bark() {
console.log("woof");
}
}
```

<docs-info>These utilities are primarily for type inference on `useLoaderData` and its equivalents. If you have a resource route that returns a `Response` and is not consumed by Remix APIs (such as `useFetcher`), then you can just stick with your normal `loader`/`action` definitions. Converting those routes to use `defineLoader`/`defineAction` would cause type errors because `turbo-stream` cannot serialize a `Response` instance.</docs-info>

#### `useLoaderData`, `useActionData`, `useRouteLoaderData`, `useFetcher`

These methods do not require any code changes on your part - adding the Single Fetch types will cause their generics to deserialize correctly:

```ts
export const loader = defineLoader(async () => {
const data = await fetchSomeData();
export function loader() {
return {
message: data.message, // <- string
date: data.date, // <- Date
planet: "world",
date: new Date(),
spot: new Dog("Spot", 3),
};
});
}

export default function Component() {
// ❌ Before Single Fetch, types were serialized via JSON.stringify
const data = useLoaderData<typeof loader>();
// ^? { message: string, date: string }

// ✅ With Single Fetch, types are serialized via turbo-stream
const data = useLoaderData<typeof loader>();
// ^? { message: string, date: Date }
// ^? { planet: string, date: Date, spot: { name: string, age: number, bark: undefined } }
}
```

#### `useMatches`
#### `clientLoader` and `clientAction`

`useMatches` requires a manual cast to specify the loader type in order to get proper type inference on a given `match.data`. When using Single Fetch, you will need to replace the `UIMatch` type with `UIMatch_SingleFetch`:
<docs-warning>Make sure to include types for the `clientLoader` args and `clientAction` args as that is how our types detect client data functions.</docs-warning>

```diff
let matches = useMatches();
- let rootMatch = matches[0] as UIMatch<typeof loader>;
+ let rootMatch = matches[0] as UIMatch_SingleFetch<typeof loader>;
```
Data from client-side loaders and actions are never serialized so types for those are preserved:

#### `meta` Function
```ts
import {
useLoaderData,
type ClientLoaderFunctionArgs,
} from "@remix-run/react";

`meta` functions also require a generic to indicate the current and ancestor route loader types in order to properly type the `data` and `matches` parameters. When using Single Fetch, you will need to replace the `MetaArgs` type with `MetaArgs_SingleFetch`:
class Dog {
/* ... */
}

```diff
export function meta({
data,
matches,
- }: MetaArgs<typeof loader, { root: typeof rootLoader }>) {
+ }: MetaArgs_SingleFetch<typeof loader, { root: typeof rootLoader }>) {
// ...
}
// Make sure to annotate the types for the args! 👇
export function clientLoader(_: ClientLoaderFunctionArgs) {
return {
planet: "world",
date: new Date(),
notSoRandom: () => 7,
spot: new Dog("Spot", 3),
};
}

export default function Component() {
const data = useLoaderData<typeof clientLoader>();
// ^? { planet: string, date: Date, notSoRandom: () => number, spot: Dog }
}
```

### Headers
Expand Down
50 changes: 48 additions & 2 deletions integration/single-fetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,29 @@ const files = {
};
}
class MyClass {
a: string
b: bigint
constructor(a: string, b: bigint) {
this.a = a
this.b = b
}
c() {}
}
export function loader({ request }) {
if (new URL(request.url).searchParams.has("error")) {
throw new Error("Loader Error");
}
return {
message: "DATA",
date: new Date("${ISO_DATE}"),
unserializable: {
function: () => {},
class: new MyClass("hello", BigInt(1)),
},
};
}
Expand Down Expand Up @@ -113,13 +129,29 @@ const files = {
}, { status: 201, headers: { 'X-Action': 'yes' }});
}
class MyClass {
a: string
b: Date
constructor(a: string, b: Date) {
this.a = a
this.b = b
}
c() {}
}
export function loader({ request }) {
if (new URL(request.url).searchParams.has("error")) {
throw new Error("Loader Error");
}
return data({
message: "DATA",
date: new Date("${ISO_DATE}"),
unserializable: {
function: () => {},
class: new MyClass("hello", BigInt(1)),
},
}, { status: 206, headers: { 'X-Loader': 'yes' }});
}
Expand Down Expand Up @@ -175,7 +207,7 @@ test.describe("single-fetch", () => {
expect(res.headers.get("Content-Type")).toBe("text/x-script");

res = await fixture.requestSingleFetchData("/data.data");
expect(res.data).toEqual({
expect(res.data).toStrictEqual({
root: {
data: {
message: "ROOT",
Expand All @@ -185,6 +217,13 @@ test.describe("single-fetch", () => {
data: {
message: "DATA",
date: new Date(ISO_DATE),
unserializable: {
function: undefined,
class: {
a: "hello",
b: BigInt(1),
},
},
},
},
});
Expand Down Expand Up @@ -255,7 +294,7 @@ test.describe("single-fetch", () => {
let res = await fixture.requestSingleFetchData("/data-with-response.data");
expect(res.status).toEqual(206);
expect(res.headers.get("X-Loader")).toEqual("yes");
expect(res.data).toEqual({
expect(res.data).toStrictEqual({
root: {
data: {
message: "ROOT",
Expand All @@ -265,6 +304,13 @@ test.describe("single-fetch", () => {
data: {
message: "DATA",
date: new Date(ISO_DATE),
unserializable: {
function: undefined,
class: {
a: "hello",
b: BigInt(1),
},
},
},
},
});
Expand Down
2 changes: 0 additions & 2 deletions packages/remix-cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ export {
createRequestHandler,
createSession,
unstable_data,
unstable_defineLoader,
unstable_defineAction,
defer,
broadcastDevReady,
logDevReady,
Expand Down
2 changes: 0 additions & 2 deletions packages/remix-deno/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ export {
unstable_composeUploadHandlers,
unstable_createMemoryUploadHandler,
unstable_data,
unstable_defineAction,
unstable_defineLoader,
unstable_parseMultipartFormData,
} from "@remix-run/server-runtime";

Expand Down
2 changes: 0 additions & 2 deletions packages/remix-node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ export {
createRequestHandler,
createSession,
unstable_data,
unstable_defineLoader,
unstable_defineAction,
defer,
broadcastDevReady,
logDevReady,
Expand Down
Loading

0 comments on commit 5a38eba

Please sign in to comment.