@rsc-kit/mcp 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/answers.d.ts +7 -0
- package/dist/answers.js +26 -0
- package/dist/answers.js.map +1 -1
- package/dist/bundleGuides.d.ts +22 -0
- package/dist/bundleGuides.js +130 -0
- package/dist/bundleGuides.js.map +1 -0
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +81 -6
- package/dist/recipes.js.map +1 -1
- package/dist/report.d.ts +11 -0
- package/dist/report.js +1 -1
- package/dist/report.js.map +1 -1
- package/guides/api-routes.md +168 -0
- package/guides/authorization.md +288 -0
- package/guides/caching.md +57 -0
- package/guides/connection.md +98 -0
- package/guides/edge-caching.md +159 -0
- package/guides/errors.md +109 -0
- package/guides/file-uploads.md +119 -0
- package/guides/fonts.md +117 -0
- package/guides/forms.md +528 -0
- package/guides/images.md +83 -0
- package/guides/index.json +162 -0
- package/guides/mcp.md +113 -0
- package/guides/metadata.md +289 -0
- package/guides/navigation.md +84 -0
- package/guides/no-javascript.md +39 -0
- package/guides/offline.md +215 -0
- package/guides/ppr.md +181 -0
- package/guides/pwa.md +260 -0
- package/guides/queries.md +340 -0
- package/guides/react-compiler.md +153 -0
- package/guides/redirects.md +143 -0
- package/guides/response-headers.md +66 -0
- package/guides/route-interception.md +206 -0
- package/guides/routing.md +458 -0
- package/guides/sections.md +74 -0
- package/guides/server-actions.md +444 -0
- package/guides/static-generation.md +347 -0
- package/guides/testing.md +158 -0
- package/guides/third-party-scripts.md +105 -0
- package/guides/typed-routes.md +139 -0
- package/guides/url-validation.md +143 -0
- package/guides/validation.md +175 -0
- package/guides/view-transitions.md +120 -0
- package/package.json +4 -3
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Typed routes
|
|
2
|
+
|
|
3
|
+
> Links that fail the typecheck instead of the browser.
|
|
4
|
+
|
|
5
|
+
Every build writes the urls it found to `.rsc-kit/rsc-routes.d.ts`.
|
|
6
|
+
`Link`, `visit`, `prefetch` and `Form` accept only those.
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
import Link from '@rsc-kit/core/Link'
|
|
10
|
+
|
|
11
|
+
<Link href="/about">About</Link> // ✅
|
|
12
|
+
<Link href="/abuot">About</Link> // ❌ typecheck fails
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Dynamic segments work through ordinary template literals:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<Link href={`/posts/${post.slug}`}>{post.title}</Link> // ✅
|
|
19
|
+
<Link href={`/postz/${post.slug}`}>{post.title}</Link> // ❌
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## There is no `route()` helper
|
|
23
|
+
|
|
24
|
+
Deliberately. A template literal is already checked the same way a builder
|
|
25
|
+
would check it, so a builder would only wrap what the language does for free.
|
|
26
|
+
One existed and was removed.
|
|
27
|
+
|
|
28
|
+
The one thing to watch is that a value you interpolate is url-safe.
|
|
29
|
+
`` `/posts/${'a / b'}` `` type-checks and means three path segments — use
|
|
30
|
+
`encodeURIComponent` when the value is not yours:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
<Link href={`/posts/${encodeURIComponent(slug)}`}>…</Link>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Search params, typed by the page
|
|
37
|
+
|
|
38
|
+
A page that [exports a `searchParams` schema](/guides/url-validation) has said
|
|
39
|
+
what its query string means. The same schema types every link to it:
|
|
40
|
+
|
|
41
|
+
```tsx title="src/app/search/page.tsx"
|
|
42
|
+
export const searchParams = z.object({
|
|
43
|
+
q: z.string().default(''),
|
|
44
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<Link href="/search" search={{ q: 'shoes', page: 2 }}>Page 2</Link>
|
|
50
|
+
|
|
51
|
+
<Link href="/search" search={{ sort: 'asc' }}>…</Link> // does not compile: the page never reads sort
|
|
52
|
+
<Link href="/search" search={{ page: '2' }}>…</Link> // does not compile: page is a number
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A key the page requires is required on the link — a `q: z.string()` with no
|
|
56
|
+
default makes `search` itself required, so the page's error boundary is not
|
|
57
|
+
where a missing `q` is found. A page with no schema takes any scalars, and so
|
|
58
|
+
does an href that is not one route (`path as Href`), because there is nothing
|
|
59
|
+
to check it against.
|
|
60
|
+
|
|
61
|
+
Values are typed by what the page will **see**, not what the schema accepts:
|
|
62
|
+
`z.coerce.number()` takes anything in, and a link typed by that would take
|
|
63
|
+
`page: 'two'`. Required-ness comes from the schema's input, the value from its
|
|
64
|
+
output. Arrays repeat the key — `tags: ['a', 'b']` is `?tags=a&tags=b`, which
|
|
65
|
+
is what `z.array()` parses back.
|
|
66
|
+
|
|
67
|
+
For `visit()`, `prefetch()` and anything else that wants the string,
|
|
68
|
+
`href()` runs the same check:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { href } from '@rsc-kit/core/routes';
|
|
72
|
+
|
|
73
|
+
visit(href('/search', { q: 'shoes', page: 2 }));
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The build writes one line per route into `rsc-routes.d.ts` that reads the
|
|
77
|
+
page module's `searchParams` export as a type. Nothing runs; a page without
|
|
78
|
+
the export costs nothing.
|
|
79
|
+
|
|
80
|
+
## Two limits
|
|
81
|
+
|
|
82
|
+
**A dynamic segment widens.** `/posts/[slug]` becomes `` `/posts/${string}` ``,
|
|
83
|
+
so `/posts/a/b` type-checks even though it does not match at runtime.
|
|
84
|
+
|
|
85
|
+
**A list widens to `string`** unless you say what it is:
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
const nav = [
|
|
89
|
+
{ href: '/', label: 'Home' },
|
|
90
|
+
{ href: '/about', label: 'About' },
|
|
91
|
+
] satisfies { href: Href; label: string }[]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Without `satisfies`, TypeScript infers `string` for `href` and you lose the
|
|
95
|
+
check.
|
|
96
|
+
|
|
97
|
+
## If you never run the generator
|
|
98
|
+
|
|
99
|
+
`.rsc-kit/rsc-routes.d.ts` is written by the build. Without it — or with a
|
|
100
|
+
tsconfig whose `include` does not cover `.rsc-kit` — nothing is registered,
|
|
101
|
+
every url-taking prop stays exactly as permissive as a plain `string`, and
|
|
102
|
+
nothing breaks. There is no flag to turn this on.
|
|
103
|
+
|
|
104
|
+
## `redirect()` is not typed
|
|
105
|
+
|
|
106
|
+
Its destination is usually computed — read from a cookie, handed over by
|
|
107
|
+
middleware — so typing it would make the common case a cast.
|
|
108
|
+
|
|
109
|
+
## Api routes
|
|
110
|
+
|
|
111
|
+
Every build writes the `route.ts` files it found as well, in their own union —
|
|
112
|
+
so a `fetch` to an endpoint that no longer exists stops compiling:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { apiUrl } from '@rsc-kit/core/routes'
|
|
116
|
+
|
|
117
|
+
await fetch(apiUrl(`/api/orders/${id}`))
|
|
118
|
+
await fetch(apiUrl('/api/ordrs')) // does not compile
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`apiUrl` returns what it was given. It exists because `fetch` takes any
|
|
122
|
+
`string`, so without somewhere to put the type there is nothing to check
|
|
123
|
+
against — the function is the place.
|
|
124
|
+
|
|
125
|
+
**Pages and api routes are separate unions on purpose.** `<Link href="/api/health">`
|
|
126
|
+
does not compile, because linking to an api route navigates the browser away to
|
|
127
|
+
a json document; and `apiUrl('/orders')` does not compile either, because
|
|
128
|
+
fetching a page gets html where json was expected. Each refuses the other's
|
|
129
|
+
urls, which is the pair of mistakes worth catching.
|
|
130
|
+
|
|
131
|
+
:::note[Paths, not response types]
|
|
132
|
+
This checks the **url**. It does not infer what the endpoint returns — that
|
|
133
|
+
would mean a typed `json()` helper of our own in place of `Response.json()`, and
|
|
134
|
+
api routes are deliberately web standards with nothing of ours required in them.
|
|
135
|
+
|
|
136
|
+
For end-to-end types without a fetch at all, a [server action or
|
|
137
|
+
query](/guides/queries/) is already typed across the boundary: the return type
|
|
138
|
+
is the function's, because it is the same function.
|
|
139
|
+
:::
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Typed URLs
|
|
2
|
+
|
|
3
|
+
> Checking and typing params, query strings and request bodies with a schema you already write.
|
|
4
|
+
|
|
5
|
+
A url is input from a stranger. `params` arrive as strings, `searchParams` as
|
|
6
|
+
more strings, and `?page=banana` reaches your database as `NaN` unless
|
|
7
|
+
something stopped it.
|
|
8
|
+
|
|
9
|
+
Export a schema beside the page and it stops there instead:
|
|
10
|
+
|
|
11
|
+
```tsx title="src/app/posts/[slug]/page.tsx"
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import type { PageProps } from '@rsc-kit/core/route-schema'
|
|
14
|
+
|
|
15
|
+
export const params = z.object({ slug: z.string().min(1) })
|
|
16
|
+
|
|
17
|
+
export const searchParams = z.object({
|
|
18
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
19
|
+
tag: z.array(z.string()).default([]),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export default async function PostPage({ params, searchParams }: PageProps<
|
|
23
|
+
typeof params,
|
|
24
|
+
typeof searchParams
|
|
25
|
+
>) {
|
|
26
|
+
const { slug } = await params // string
|
|
27
|
+
const { page, tag } = await searchParams // number, string[]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Both are ordinary exports, read by the build the same way `metadata` and
|
|
32
|
+
`generateStaticParams` are. Any [Standard Schema](https://standardschema.dev)
|
|
33
|
+
works — Zod, Valibot, ArkType — because the schema is asked to validate itself
|
|
34
|
+
and nothing here imports one.
|
|
35
|
+
|
|
36
|
+
Three things you get that types alone cannot give you:
|
|
37
|
+
|
|
38
|
+
- **`?page=3` arrives as `3`**, the number, not `"3"`.
|
|
39
|
+
- **A missing value arrives as its default**, so there is no `undefined` branch
|
|
40
|
+
to write.
|
|
41
|
+
- **A bad value is refused once**, at the edge, instead of surviving as `NaN`
|
|
42
|
+
into whatever the page does next.
|
|
43
|
+
|
|
44
|
+
Export neither and nothing changes: `params` is the record of strings it always
|
|
45
|
+
was, and `searchParams` is a `URLSearchParams`.
|
|
46
|
+
|
|
47
|
+
## They fail differently, on purpose
|
|
48
|
+
|
|
49
|
+
This is the part worth reading twice.
|
|
50
|
+
|
|
51
|
+
| what was wrong | answer | why |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `params` | **404**, `not-found.tsx` | the url does not describe a page |
|
|
54
|
+
| `searchParams` | nearest `error.tsx` | the page exists, the query was wrong |
|
|
55
|
+
|
|
56
|
+
`/posts/` with a slug your schema refuses is not a broken page, it is an
|
|
57
|
+
absent one — and a `500` tells a crawler to come back later while a `404` tells
|
|
58
|
+
it the thing is gone. Meanwhile refusing a bad `?page=` as a 404 would let one
|
|
59
|
+
bad link make a real page look deleted.
|
|
60
|
+
|
|
61
|
+
The error carries the fields, so a boundary can say which one:
|
|
62
|
+
|
|
63
|
+
```tsx title="src/app/posts/[slug]/error.tsx"
|
|
64
|
+
'use client'
|
|
65
|
+
|
|
66
|
+
import { isSearchParamsError } from '@rsc-kit/core/route-schema'
|
|
67
|
+
|
|
68
|
+
export default function Error({ error }) {
|
|
69
|
+
if (isSearchParamsError(error)) {
|
|
70
|
+
return <p>That link is not quite right: {Object.keys(error.errors).join(', ')}</p>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return <p>Something went wrong.</p>
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Repeated keys
|
|
78
|
+
|
|
79
|
+
`?tag=red&tag=blue` arrives as an array and `?q=shoes` as a string, without the
|
|
80
|
+
schema having to know which shape the url happened to take. So
|
|
81
|
+
`z.array(z.string())` and `z.string()` both work on the key you would expect.
|
|
82
|
+
|
|
83
|
+
## API routes take the same three
|
|
84
|
+
|
|
85
|
+
Plus the body, which is the one that matters for a `POST`:
|
|
86
|
+
|
|
87
|
+
```ts title="src/app/api/posts/[id]/route.ts"
|
|
88
|
+
import { z } from 'zod'
|
|
89
|
+
|
|
90
|
+
export const params = z.object({ id: z.coerce.number().int() })
|
|
91
|
+
export const searchParams = z.object({ fields: z.string().optional() })
|
|
92
|
+
export const body = z.object({ title: z.string().min(1), draft: z.boolean().default(false) })
|
|
93
|
+
|
|
94
|
+
export async function POST(request: Request, { params, body }) {
|
|
95
|
+
const { id } = await params // number
|
|
96
|
+
const { title } = await body // non-empty string
|
|
97
|
+
|
|
98
|
+
return Response.json(await createPost(id, title), { status: 201 })
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Awaited, the same way a page awaits its props. That is not only symmetry: a
|
|
103
|
+
route that never awaits `searchParams` provably does not vary by it, so the
|
|
104
|
+
build can store one answer and serve it for `?utm_source=anything`. Resolved
|
|
105
|
+
eagerly, that fact is unknowable and every tracking link misses the stored
|
|
106
|
+
answer.
|
|
107
|
+
|
|
108
|
+
The handler still takes a real `Request` and still returns a real `Response` —
|
|
109
|
+
the schemas add a second argument and take nothing away. A route that exports
|
|
110
|
+
none behaves exactly as it did before any of this existed.
|
|
111
|
+
|
|
112
|
+
Statuses follow the same reasoning as a page, plus one:
|
|
113
|
+
|
|
114
|
+
| what was wrong | answer |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `params` | `404` |
|
|
117
|
+
| `searchParams` | `400` with `{ message, errors }` |
|
|
118
|
+
| `body` | `422` with `{ message, errors }` |
|
|
119
|
+
|
|
120
|
+
`422` because that is what a [server action](/guides/validation/) already
|
|
121
|
+
returns for a refused field, so a client has one shape to handle rather than
|
|
122
|
+
two.
|
|
123
|
+
|
|
124
|
+
JSON and form encodings both parse, since a url is posted to by `fetch` and by
|
|
125
|
+
`<form>` alike. A body that is not valid JSON is a `422` about the body as a
|
|
126
|
+
whole rather than a crash.
|
|
127
|
+
|
|
128
|
+
:::caution[Exporting `body` reads the request]
|
|
129
|
+
The schema consumes the stream, so `await request.json()` inside the handler
|
|
130
|
+
will find it already read. Use the parsed value — that is the point of it.
|
|
131
|
+
|
|
132
|
+
Only when you export a `body` schema, and only for `POST`, `PUT`, `PATCH` and
|
|
133
|
+
`DELETE`. A `GET` handler is never touched.
|
|
134
|
+
:::
|
|
135
|
+
|
|
136
|
+
## What this is not
|
|
137
|
+
|
|
138
|
+
A schema here checks the **shape** of a request, not whether the person making
|
|
139
|
+
it may. `params.id` being a number says nothing about whose row it is — the
|
|
140
|
+
caller chooses the id, and trusting it is the whole of an IDOR.
|
|
141
|
+
|
|
142
|
+
Authorise on identity, in the handler, after the shape is known good. See
|
|
143
|
+
[Authorization](/guides/authorization/).
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
> Surfacing server-side validation errors in a form.
|
|
4
|
+
|
|
5
|
+
Validation errors have one shape everywhere: a field name mapped to the
|
|
6
|
+
messages for it.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
{ title: ['The title must be at least 3 characters.'], body: ['The body is required.'] }
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`ServerValidationError` carries that shape, and `<Form>`
|
|
13
|
+
catch it and put it in `errors`. Nothing in a component has to know where the
|
|
14
|
+
validation ran.
|
|
15
|
+
|
|
16
|
+
## In a form
|
|
17
|
+
|
|
18
|
+
```tsx title="src/components/PostForm.tsx"
|
|
19
|
+
"use client";
|
|
20
|
+
|
|
21
|
+
import { Form } from '@rsc-kit/core/Form';
|
|
22
|
+
import { createPost } from '../actions';
|
|
23
|
+
|
|
24
|
+
export function PostForm() {
|
|
25
|
+
return (
|
|
26
|
+
<Form action={createPost}>
|
|
27
|
+
{({ errors, pending }) => (
|
|
28
|
+
<>
|
|
29
|
+
<input name="title" />
|
|
30
|
+
{errors.title?.map((message) => <p key={message} className="error">{message}</p>)}
|
|
31
|
+
|
|
32
|
+
<textarea name="body" />
|
|
33
|
+
{errors.body?.map((message) => <p key={message} className="error">{message}</p>)}
|
|
34
|
+
|
|
35
|
+
<button disabled={pending}>Create</button>
|
|
36
|
+
</>
|
|
37
|
+
)}
|
|
38
|
+
</Form>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
No `try`/`catch`, and no state of your own. A successful submit clears
|
|
44
|
+
`errors`; a failed one fills them.
|
|
45
|
+
|
|
46
|
+
## Outside a form
|
|
47
|
+
|
|
48
|
+
Catch it yourself, wherever the call rejects with one:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
"use client";
|
|
52
|
+
|
|
53
|
+
import { ServerValidationError } from '@rsc-kit/core/errors';
|
|
54
|
+
import { createPost } from '../actions';
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await createPost(title, body);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof ServerValidationError) {
|
|
60
|
+
setErrors(error.errors);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Producing one
|
|
66
|
+
|
|
67
|
+
<Aside type="caution" title="The server half is the host's">
|
|
68
|
+
There is no validation library here, and no rule syntax to learn. What the
|
|
69
|
+
engine defines is the error the client understands — deciding that input is
|
|
70
|
+
invalid is your code's job, or your host's.
|
|
71
|
+
</Aside>
|
|
72
|
+
|
|
73
|
+
### Before it is submitted
|
|
74
|
+
|
|
75
|
+
`<Form>` takes any [Standard Schema](https://standardschema.dev) —
|
|
76
|
+
Zod, Valibot, ArkType, or anything else implementing it. There is no adapter
|
|
77
|
+
per library and no import of one: the schema is asked to validate itself.
|
|
78
|
+
|
|
79
|
+
```tsx title="src/components/PostForm.tsx"
|
|
80
|
+
"use client";
|
|
81
|
+
|
|
82
|
+
import { z } from 'zod';
|
|
83
|
+
import { Form } from '@rsc-kit/core/Form';
|
|
84
|
+
import { createPost } from '../actions';
|
|
85
|
+
|
|
86
|
+
const schema = z.object({
|
|
87
|
+
title: z.string().min(3, 'Too short'),
|
|
88
|
+
body: z.string().min(10, 'Too short'),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export function PostForm() {
|
|
92
|
+
return (
|
|
93
|
+
<Form action={createPost} schema={schema}>
|
|
94
|
+
{({ errors, pending }) => (
|
|
95
|
+
<>
|
|
96
|
+
<input name="title" />
|
|
97
|
+
{errors.title?.map((m) => <p key={m} className="error">{m}</p>)}
|
|
98
|
+
|
|
99
|
+
<textarea name="body" />
|
|
100
|
+
{errors.body?.map((m) => <p key={m} className="error">{m}</p>)}
|
|
101
|
+
|
|
102
|
+
<button disabled={pending}>Create</button>
|
|
103
|
+
</>
|
|
104
|
+
)}
|
|
105
|
+
</Form>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A failure fills the same `errors` you were already rendering and the action is
|
|
111
|
+
never called — so a mistake costs no round trip, and an optimistic update never
|
|
112
|
+
shows a row the server was going to refuse. Async schemas are awaited, so a
|
|
113
|
+
uniqueness check works the same way.
|
|
114
|
+
|
|
115
|
+
Field names follow the schema's paths: a nested field is `address.city`, and an
|
|
116
|
+
issue belonging to no field — a cross-field rule like "these must match" — is
|
|
117
|
+
under the empty string, so it has somewhere to be shown rather than being
|
|
118
|
+
dropped.
|
|
119
|
+
|
|
120
|
+
<Aside type="danger" title="A schema here is a courtesy, not a control">
|
|
121
|
+
It runs in the browser. The action is a public endpoint reachable without
|
|
122
|
+
this form — with `curl`, with the wrong values, in any order — so the server
|
|
123
|
+
still has to check. See [Authorization](/guides/authorization).
|
|
124
|
+
|
|
125
|
+
Use the same schema on both sides if you like; the point is that the one
|
|
126
|
+
running on the server is the one that decides.
|
|
127
|
+
</Aside>
|
|
128
|
+
|
|
129
|
+
### In a JavaScript action
|
|
130
|
+
|
|
131
|
+
The same schema, on the side that decides. An action **returns** its failure
|
|
132
|
+
rather than throwing one, so one line turns that into the error the form
|
|
133
|
+
already understands:
|
|
134
|
+
|
|
135
|
+
```ts title="src/actions.ts"
|
|
136
|
+
'use server'
|
|
137
|
+
|
|
138
|
+
import { schema } from './schema';
|
|
139
|
+
|
|
140
|
+
export async function createPost(title: string, body: string) {
|
|
141
|
+
const parsed = await schema['~standard'].validate({ title, body });
|
|
142
|
+
|
|
143
|
+
if (parsed.issues) {
|
|
144
|
+
return { ok: false as const, issues: parsed.issues };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { ok: true as const, post: await savePost(parsed.value) };
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```tsx title="src/components/PostForm.tsx"
|
|
152
|
+
import { ServerValidationError } from '@rsc-kit/core/errors';
|
|
153
|
+
import { issuesToErrors } from '@rsc-kit/core/standardSchema';
|
|
154
|
+
|
|
155
|
+
async function submit(formData: FormData) {
|
|
156
|
+
const result = await createPost(
|
|
157
|
+
formData.get('title') as string,
|
|
158
|
+
formData.get('body') as string,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (!result.ok) throw new ServerValidationError('Validation failed', issuesToErrors(result.issues));
|
|
162
|
+
|
|
163
|
+
return result.post;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`issuesToErrors` is the same mapping the client-side check uses, so the field
|
|
168
|
+
names match whichever side produced them.
|
|
169
|
+
|
|
170
|
+
## ServerValidationError
|
|
171
|
+
|
|
172
|
+
| Member | Type | What it holds |
|
|
173
|
+
| --- | --- | --- |
|
|
174
|
+
| `errors` | `Record<string, string[]>` | Field name to messages. |
|
|
175
|
+
| `message` | `string` | A summary, for when there is nowhere to put field errors. |
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# View transitions
|
|
2
|
+
|
|
3
|
+
> What React's ViewTransition animates in an app built with this, and what it does not.
|
|
4
|
+
|
|
5
|
+
React 19.3 ships [`<ViewTransition>`](https://react.dev/reference/react/ViewTransition),
|
|
6
|
+
which animates a change by handing it to the browser's View Transition API.
|
|
7
|
+
You can use it here. There is nothing to turn on, and no wrapper this package
|
|
8
|
+
provides — it is React's component, used directly.
|
|
9
|
+
|
|
10
|
+
It has one boundary worth knowing before you reach for it, because the failure
|
|
11
|
+
is silence: a transition that does not run looks exactly like one you styled
|
|
12
|
+
badly.
|
|
13
|
+
|
|
14
|
+
| the change comes from | animates |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| your own `useState`, inside `startTransition` | yes |
|
|
17
|
+
| a server action's result, put into state | yes |
|
|
18
|
+
| `<Form>` — errors, success, optimistic updates | yes |
|
|
19
|
+
| a streamed `<Suspense>` boundary arriving | yes, React does this on its own |
|
|
20
|
+
| navigating to another page | yes, behind a flag |
|
|
21
|
+
|
|
22
|
+
## What works
|
|
23
|
+
|
|
24
|
+
Ordinary React. Give the changing element a `key` so React sees a replacement
|
|
25
|
+
rather than an edit, and commit inside `startTransition`:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
'use client';
|
|
29
|
+
|
|
30
|
+
import { useState, startTransition, ViewTransition } from 'react';
|
|
31
|
+
import { addToTotal } from '../actions';
|
|
32
|
+
|
|
33
|
+
export function Total() {
|
|
34
|
+
const [total, setTotal] = useState<number | null>(null);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<ViewTransition>
|
|
39
|
+
<p key={String(total)}>{total ?? '—'}</p>
|
|
40
|
+
</ViewTransition>
|
|
41
|
+
|
|
42
|
+
<button
|
|
43
|
+
onClick={() =>
|
|
44
|
+
startTransition(async () => {
|
|
45
|
+
const next = await addToTotal(1);
|
|
46
|
+
|
|
47
|
+
startTransition(() => setTotal(next));
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
add
|
|
52
|
+
</button>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A server action is in the working column for a reason worth stating: its result
|
|
59
|
+
comes back as an ordinary return value, and what you do with it is `useState`.
|
|
60
|
+
That makes it a transition like any other. `<Form>` is built on `useState`
|
|
61
|
+
throughout, so everything it drives animates the same way.
|
|
62
|
+
|
|
63
|
+
## Navigating between pages
|
|
64
|
+
|
|
65
|
+
Off by default, because it changes how every navigation commits:
|
|
66
|
+
|
|
67
|
+
```ts title="vite.config.ts"
|
|
68
|
+
rscKit({ viewTransitions: true })
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A build-time constant rather than a runtime setting, so an app that does not
|
|
72
|
+
ask for it does not carry the boundary at all. What it animates is the segment
|
|
73
|
+
a navigation replaces; what a page does inside itself needs no flag.
|
|
74
|
+
|
|
75
|
+
## Coming back to a page you were just on
|
|
76
|
+
|
|
77
|
+
A navigation to a page still being held reveals it rather than refetching it,
|
|
78
|
+
so the form you were filling in is still filled in. That applies to a link, not
|
|
79
|
+
only the back button — having one keep your work and the other throw it away is
|
|
80
|
+
a distinction nobody makes while using an app.
|
|
81
|
+
|
|
82
|
+
It is bounded, because the two halves pull against each other: what comes back
|
|
83
|
+
is the tree from when you left, so its data is from then. Thirty seconds by
|
|
84
|
+
default, which covers leaving a form to check something and coming straight
|
|
85
|
+
back. Past that a link refetches.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { setRevealWindow } from '@rsc-kit/core/navigate';
|
|
89
|
+
|
|
90
|
+
setRevealWindow(0); // never reveal — every link is a fresh request
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The back button is not bounded. It names a moment, and the page from that
|
|
94
|
+
moment is the right answer however old it is.
|
|
95
|
+
|
|
96
|
+
<Aside type="caution" title="It can hide a server that is down">
|
|
97
|
+
Revealing a held page fetches nothing, so those navigations keep working
|
|
98
|
+
when the backend does not. Measured with the process killed: a page visited
|
|
99
|
+
moments ago still navigates, one never visited does not. That is a reveal
|
|
100
|
+
window rather than offline support, and it means a dead server can go
|
|
101
|
+
unnoticed for as long as the window lasts — [`useOffline`](/guides/offline)
|
|
102
|
+
is what surfaces it. Actually surviving with no network is
|
|
103
|
+
[`offline: true`](/guides/offline#surviving-without-one), which is a service
|
|
104
|
+
worker and a different thing entirely.
|
|
105
|
+
</Aside>
|
|
106
|
+
|
|
107
|
+
## Requirements
|
|
108
|
+
|
|
109
|
+
`react` and `react-dom` at **19.3 or newer**. A scaffolded app pins `^19.2`, so
|
|
110
|
+
this is an upgrade:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm install react@^19.3 react-dom@^19.3
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
React also documents a [`browser`](https://react.dev/reference/react-dom/browser)
|
|
117
|
+
api for rendering a component only in the browser — the right tool for a value
|
|
118
|
+
the server cannot know. It is not in 19.3 yet; see
|
|
119
|
+
[static generation](/guides/static-generation#a-value-that-must-not-be-frozen)
|
|
120
|
+
for what to do meanwhile.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rsc-kit/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"dist"
|
|
26
|
+
"dist",
|
|
27
|
+
"guides"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
30
|
"test": "bun test tests",
|
|
30
31
|
"typecheck": "tsc --noEmit",
|
|
31
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
32
|
+
"build": "rm -rf dist guides && tsc -p tsconfig.build.json && node scripts/bundle-guides.mjs",
|
|
32
33
|
"prepack": "bun run build"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|