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 #9893

Merged
merged 17 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from 16 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
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 `function` got serialized as `undefined`:
pcattori marked this conversation as resolved.
Show resolved Hide resolved

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:
Copy link
Member

Choose a reason for hiding this comment

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

@pcattori Couldn't Remix call classInstance.toJSON()? If it's not defined then grab the serializable properties automatically

Copy link
Contributor Author

@pcattori pcattori Aug 28, 2024

Choose a reason for hiding this comment

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

It could but that's what json(...) is for. The mental model for Single Fetch is that types are preserved as much as possible. We don't want a class with a bunch of serializable properties (Date, bigint, RegExp, etc.) to show up as a bag of strings on the client.


```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