@rsc-kit/mcp
Version:
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.
195 lines (149 loc) • 8.31 kB
Markdown
# Navigation
> What happens when someone clicks a link, and why state survives it.
`Link` does not reload the page. It asks the server for the part of the tree
that actually changed, and swaps it in.
```tsx
import Link from '@rsc-kit/core/Link'
<Link href="/about">About</Link>
```
## What a navigation actually sends
Going from `/posts/one` to `/posts/two`, the root layout and the posts layout
are already mounted and identical. The client says which layouts it holds, the
server compares that with the new route's chain, and answers with the deepest
piece that differs — the page alone.
You get this for free. There is nothing to configure and no `loading` prop to
thread.
Two things follow from it, and they are the reason it works this way:
**State above the swap survives.** A sidebar's scroll position, an open menu, a
half-typed form in a layout — none of it is touched, because none of it was
re-rendered.
**A page you return to is still there.** Recently visited pages are kept mounted
and hidden rather than thrown away, so going back restores a half-filled form
exactly as you left it. Only a handful are kept, ordered by last visit.
## Programmatic navigation
```tsx
'use client'
import { visit, prefetch } from '@rsc-kit/core/router'
<button onClick={() => visit('/checkout')}>Checkout</button>
```
Links prefetch on their own — below. `prefetch` is for when you know where
someone is going before they show it:
```tsx
useEffect(() => { prefetch('/step-2') }, [])
useEffect(() => { prefetch('/step-2', undefined, { intent: true }) }, []) // and decode it now
```
## What a click finds already there
A navigation is as fast as what is in the browser when the click lands, and
a click that has to wait for the network is what a single-page app never
does. So a `<Link>` fetches its page before it is asked to, in two steps,
because the two cost differently:
- **The bytes, as the link comes into view.** The first dozen links on
screen per page, on every device, when the browser is idle, once per link,
at low priority so a real navigation overtakes the queue — the way Next
prefetches. A dozen, because a payload carries the whole page: a product
page with its related products is 30 KB, and a listing shows twenty-four of
them. A link past the budget is fetched on touch or on a settled hover, a
round trip before the click rather than before the scroll. The payload is
kept undecoded: thirty seconds for a page rendered per visitor, five
minutes for one the build made (the host marks it `public`, and only a
deploy changes it — which the [version handshake](/hosts/deployment#pages-across-a-deploy)
catches on the click). `cacheFor` on the link sets its own.
- **The pictures, as the bytes land.** Every `<img>` the page shows is a row
in its payload, so the ones it would load at once — not `loading="lazy"` —
are asked for at low priority the moment the payload arrives, the first six a
page — a product's own picture comes before its related ones. On a phone a product picture takes longer than the touch-to-click
a hidden render has, and the click should find it decoded. On intent the
same pictures are asked for again as urgent: a listing's tiles each
preload their product's picture, and the one tapped first was queued
behind the rest; asked for as high priority it moves to the front.
- **The code for its client components, as the bytes land.** A payload names
each client component it renders, and the first eight a page renders are
loaded at idle the moment the payload arrives — the code only, nothing
rendered. Loaded at the touch instead, the first page of a visit waited on
its form's chunk *after* it had arrived, and showed its skeleton for a
quarter of a second; every later page found the chunk cached. Measured on an
iPhone, tapping through a fresh session: the first product page went from
~325 ms with a skeleton to ~80 ms without one. Bounded per document, so a
landing page with a link to a heavy editor does not load the editor for
everyone.
- **The decode, on intent.** Decoding the payload itself waits for the signal
that says which link is meant: a pointer that has settled on the link for
100 ms, a press, a touch. Each of those leads its click by 60–300 ms — about
a round trip — which is when the page is rendered hidden, so the click
reveals it.
The click then reads a decoded tree, changes the url, and hands the page to
React. Nothing on the network is between the click and the page unless the
link was never on screen, or the visitor asked for less data (Save-Data
turns the viewport prefetch off; the hover and the touch still fetch).
A link to a guarded page — every "Sign in" on a landing page points at the
app, and a signed-out visitor is sent to `/login` — is prefetched the same
way: the guard's answer is kept, and the destination is prefetched too, so
the tap asks for nothing.
### Measuring one
"Slow on my phone" needs a number, and the number it needs is the click to
the page on screen — not a request in a network panel. Every navigation
leaves it on the browser's timeline, in the User Timing API the Performance
panel of any dev tools draws:
| mark | when |
| --- | --- |
| `rsc-kit:navigate:start` | the click |
| `rsc-kit:navigate:decoded` | the payload decoded and the chunks it names loaded |
| `rsc-kit:navigate:applied` | handed to React |
| `rsc-kit:navigate` (measure) | start to the commit that put the page on screen |
In a console, after a few clicks:
```js
performance.getEntriesByName('rsc-kit:navigate').map((m) => Math.round(m.duration))
```
A document load the router chose — a route.ts, a foreign origin, a newer
build, a payload that was not one, a chunk the deploy no longer serves — is
announced before it happens, as a `rsc-kit:document-load` event on `window`
with the url and the reason, so a reload under a tester has a name:
```js
window.addEventListener('rsc-kit:document-load', (e) => console.log(e.detail))
```
A gap before `decoded` is the network or the chunks: the page was not on
screen to be prefetched, or was prefetched and never decoded — a scripted
click, a link with `prefetch={false}`. A gap after `applied` is the render,
which is the page's own size on the device's own CPU. On a phone through
Safari's Web Inspector (Develop → your iPhone → the page), the same entries
are in the Timelines tab.
## Showing progress
```tsx
'use client'
import { useLinkStatus } from '@rsc-kit/core/useLinkStatus'
function Spinner() {
const { pending } = useLinkStatus()
return pending ? <span>Loading…</span> : null
}
```
A `loading.tsx` beside a page is shown while that page's data resolves:
```tsx title="src/app/posts/loading.tsx"
export default function Loading() {
return <p>Loading posts…</p>
}
```
## A tap before the page can navigate
The document arrives well before the runtime hydrates — on a phone, with a
tag manager or two on the main thread, seconds before. A tap on a link in
that window used to be the browser's: a document load of a page the router
would have swapped in, which is why a first tap on a slow connection felt
like a full reload. The bootstrap script now listens before anything else
loads: a tap on a `<Link>` is held, and the moment the router is wired it is
navigated to. If the runtime never arrives, the browser gets the tap back
after four seconds as a document load. Only `<Link>`s are held — a plain
anchor, a `route.ts`, a file, a `mailto:` are the browser's as before.
A form whose action is a server action is held the same way. Before
hydration React renders it to post natively, so a page reloads with the
action applied — right with no JavaScript, and a full reload with JavaScript
a second away. Submitted in that window, the form waits for hydration and is
submitted again to the handler React then has on it; a tap on a link after
it wins, since the visitor left the form. With no runtime after three
seconds, it posts natively as before.
## Refreshing the current page
```tsx
'use client'
import { refresh } from '@rsc-kit/core/router'
<button onClick={() => refresh()}>Reload</button>
```
Scroll positions are restored afterwards — the window's and any element with
its own overflow, such as a sidebar.