test(next): preview Next.js 16.3 instant navigations [DO NOT MERGE] - #259
test(next): preview Next.js 16.3 instant navigations [DO NOT MERGE]#259arzafran wants to merge 31 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Throwaway, branch-only routes under app/instant-nav-demo/ (hub + fast/slow/[item]) to exercise 16.3 shell prefetching + partial streaming on the preview deploy. Also enables experimental turbopackFileSystemCacheForBuild and turbopackRustReactCompiler. Verified: build + typecheck + lint green on 16.3.0-preview.5.
# Conflicts: # CHANGELOG.md # bun.lock # package.json
Brings the oklch colour system, the style drift guard, the contrast gate and its baseline, the palette prune, and the dependency roll-up onto the preview branch. package.json conflicts resolved by taking main and re-pinning only next and @next/bundle-analyzer to the preview tag; bun.lock regenerated rather than hand-merged.
There was a problem hiding this comment.
Pull request overview
Previews Next.js 16.3 “Instant Navigations” behavior in a deploy preview by pinning next to a preview dist-tag, enabling shell-based prefetching, and adding demo routes + a real loading shell to evaluate navigation feel and streaming behavior.
Changes:
- Bump
next/@next/bundle-analyzerto16.3.0-preview.6for preview testing. - Enable
partialPrefetching: trueand update Next 16.3-related experimental flags innext.config.ts. - Replace the root
app/loading.tsxplaceholder with an accessible skeleton and add a throwawayapp/instant-nav-demo/*route set to exercise instant navigations.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Pins Next.js + analyzer to 16.3 preview versions for deploy preview testing. |
| next.config.ts | Enables partialPrefetching and updates the 16.3 preview experimental flags. |
| CHANGELOG.md | Documents the Next 16.3 preview changes and the throwaway demo routes. |
| app/loading.tsx | Upgrades root loading UI to an accessible skeleton shell. |
| app/instant-nav-demo/README.md | Documents the demo routes and the “do not merge” intent. |
| app/instant-nav-demo/page.tsx | Demo hub route to exercise shell-prefetch navigation. |
| app/instant-nav-demo/fast/page.tsx | Static demo route for “shell == page” navigation. |
| app/instant-nav-demo/slow/page.tsx | Streaming demo route showing partial rendering under <Suspense>. |
| app/instant-nav-demo/[item]/page.tsx | Dynamic demo route that suspends and shows a shared loading shell. |
| app/instant-nav-demo/[item]/loading.tsx | Loading shell for the dynamic demo route. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
React's own types now document FormEvent as deprecated and point at SubmitEvent, and DOMAttributes already types onSubmit as SubmitEventHandler, so this matches what React expects today rather than only future-proofing. React 19.2.10 turns it into a deprecation warning.
Picks up the client-navigation <title> regression fix that landed in canary.76.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (6)
app/instant-nav-demo/[item]/page.tsx:14
paramsis typed as aPromiseand thenawaited, but Next.js page props passparamsas a plain object. Keeping it as aPromisemakes the type misleading and adds an unnecessaryawait(even though it happens to work at runtime).
}: {
params: Promise<{ item: string }>
}) {
const { item } = await params
app/instant-nav-demo/page.tsx:13
- House style prefers
h-dvh/min-h-dvhoverh-screen/min-h-screento avoid mobile viewport issues (AGENTS.md:95).
<section className="dr-layout-grid min-h-screen content-center py-24 font-mono">
app/instant-nav-demo/fast/page.tsx:11
- House style prefers
h-dvh/min-h-dvhoverh-screen/min-h-screento avoid mobile viewport issues (AGENTS.md:95).
<section className="dr-layout-grid min-h-screen content-center py-24 font-mono">
app/instant-nav-demo/slow/page.tsx:38
- House style prefers
h-dvh/min-h-dvhoverh-screen/min-h-screento avoid mobile viewport issues (AGENTS.md:95).
<section className="dr-layout-grid min-h-screen content-center py-24 font-mono">
app/instant-nav-demo/[item]/page.tsx:20
- House style prefers
h-dvh/min-h-dvhoverh-screen/min-h-screento avoid mobile viewport issues (AGENTS.md:95).
<section className="dr-layout-grid min-h-screen content-center py-24 font-mono">
app/instant-nav-demo/[item]/loading.tsx:6
- House style prefers
h-dvh/min-h-dvhoverh-screen/min-h-screento avoid mobile viewport issues (AGENTS.md:95).
className="flex min-h-screen flex-col items-center justify-center gap-3 font-mono"
AGENTS.md calls for dvh over screen units; 100vh leaves the section short by the browser chrome on mobile. Five demo routes were still on min-h-screen.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
app/instant-nav-demo/README.md:13
- The example URLs for the dynamic
[item]route are inconsistent:/bravoand/charlieare missing the/instant-nav-demo/prefix, which could confuse readers/testing.
- `[item]/` — dynamic route that suspends at the top level, so its `loading.tsx`
shell is shown on the way in and reused identically for every param value
(`/instant-nav-demo/alpha`, `/bravo`, `/charlie`)
…gions Two real accessibility bugs and three semantic upgrades. Accessibility findings are now at zero. InputField and TextareaField only rendered <Field.Label> when a `label` prop was passed, which is optional. A field given just a placeholder reached screen readers with no accessible name at all. There is now a fallback chain: the visible label if there is one, otherwise the placeholder, otherwise the field name. The aria-label is deliberately NOT set when a visible label exists — aria-label overrides it, and the announced name could then drift from the text on screen. The three role="status" loading regions become <output>, which carries that role implicitly and gives screen readers more reliable semantics. Two of them already had `flex` in their class list, which overrides <output>'s inline default, so the layout is untouched. The third (the demo panel skeleton) had only `w-40`, and width does nothing on an inline element, so it gains an explicit `block`. Two suppressions remain on the form controls, on the inner elements passed to Base UI's `render` prop. Base UI merges id, name and the aria-label onto those at runtime, so a static read of that line sees an unlabelled input that never reaches the DOM. The name is always present. react-doctor: 45 issues -> 40. Accessibility 5 -> 0. check passes, clean build.
react-doctor is right that these two awaits are independent, so they run concurrently now. The 1200ms delay below stays sequential — a slow route is what the demo exists to show.
…t by folder Uses react-doctor's ignore.overrides so these stop polluting every local run and every CI log, without going blind in those files: the suppression lists specific rules, so a genuine bug of some other kind is still reported. No blanket "skip this folder" anywhere. lib/dev/theatre is an integration with an external, imperative animation editor. React does not own that data flow — values change because someone dragged a keyframe in the studio UI, and the hooks exist to subscribe and mirror it back. All eight rules presuppose React owning the flow, so each asks for a rewrite that cannot exist. no-event-handler is the clearest: it says to move the side effect into the event handler that triggers it, and there is no React event handler, only Theatre's own notifications. It is also dev-only, lazily imported, behind a dev toggle, and deleted by setup:project when Theatre is dropped. components/layout/theme already implements React's documented pattern for adjusting state when a prop changes, render-phase re-sync with a prevTheme sentinel, docs link in the file. The three rules there each ask for something impossible: computing currentTheme inline (it is runtime-overridable via setTheme), holding it in a ref (it is rendered, through context), or moving the data-theme write off an effect (it synchronises an external system, which is what effects are for). Verified the scoping does not leak: exhaustive-deps still fires in lib/integrations/hubspot/embed, so it is genuinely path-limited. react-doctor: 39 issues -> 12, still zero errors.
The callbacks ran in an effect that watched formState and listed onSuccess and onError as dependencies. Callers pass those inline, so a parent re-render handed the effect fresh function identities, it re-ran, and it fired the callbacks a second time for a submission that had already been handled. Anything non-idempotent behind onSuccess — an analytics event, a redirect, a toast — ran twice. They now fire inside a wrapper around the action, where the result is already known. Exactly once per submission, and there is nothing left to observe after the fact. The reset effect that survives also had a cleanup hole. `resetTimer` is assigned inside the rAF write-queue callback, so unmounting before that queue drained left the timer to be created after teardown, with setKey firing on a component that no longer existed. A `cancelled` flag now covers that window. Found by react-doctor's no-prop-callback-in-effect. Worth noting the rule pointed at a lint concern and the actual payoff was two latent bugs. check passes, clean build. react-doctor: 12 issues -> 10.
Lighthouse audits one cold page load, which cannot see the instant-navigation
work at all: partialPrefetching and cachedNavigations change how fast an
already-loaded app swaps routes, and a load audit never navigates. Confirmed
that the hard way — a 3x3 Lighthouse pass over this branch moved nothing
outside noise, because none of it was measuring the thing that changed.
`bun run bench:nav` clicks a real <Link> and records two numbers per run: time
to the target route's shell, and time to its streamed content. Content is the
control — it is server-bound by design, so if it moves, something other than
prefetching did it.
It throttles to 100ms RTT by default, and that default is load-bearing. On an
unthrottled localhost the RTT is ~0, and hiding round trips is the entire
reason prefetching exists, so the flags measure as useless-to-harmful. The
first version had no throttling and said exactly that.
What it found, 7 runs each with all four instant-nav flags toggled together:
flags off flags on
shell 110ms (spread 84-250) 90ms (spread 78-105)
content 1420ms 1915ms
The shell median barely moves; the spread is the story. An on-demand shell
fetch ranges 84-250ms, a prefetched one 78-105ms — prefetching buys
predictability more than raw speed, which is what "instant" actually means to
someone clicking a link.
The content cost is real, not noise: +537ms unthrottled and +495ms at 100ms
RTT. Splitting shell from data serialises them, so this route's 1.5s server
sleep starts only after the shell paints. That is a trade — an instant shell
plus skeleton against a shorter blank wait — and it is written into the
script's header so it does not get rediscovered.
Reports the full sample alongside the median, because a 3-run median on a dev
machine can be moved by a single outlier. That already bit once in this
session: a 3x3 Lighthouse pass appeared to show +285ms TTI, and at n=5 it was
one cold run in a small sample.
Third measurement angle, covering what the other two structurally cannot:
lighthouse audits one cold load and never re-renders; bench:nav measures route
swaps, not the work a mounted tree does when state changes. This drives
viewport changes and reads main-thread cost from CDP's Performance domain, so
it works against a production build rather than React's dev-only profiling
hooks.
It also corrects something I claimed earlier in this branch. Reverting only
lib/hooks/use-device-detection.ts to its pre-fix shape, 5 runs each, 30
viewport changes, 4x CPU throttle:
pre-fix fixed
script 115ms (spread 106-122) 121ms (spread 99-157)
task 850ms 931ms
No improvement. I described that fix as the highest-value one of the set on the
grounds that a compiler bailout costs auto-memoization for the whole function.
That reasoning holds, but the magnitude does not: the hook's body is a few
media-query reads and boolean derivations, and losing memoization on work that
cheap costs nothing measurable. The resize cascade is dominated by style
recalculation (~250ms of ~900ms) and the WebGL canvas.
The fix stays — it removes a real cascading-render pattern and a genuine
bailout, and useSyncExternalStore is the correct shape for a
session-constant synchronous read. But it is hygiene, not a speedup, and the
script header says so, so the next person does not reach for a bailout fix
expecting a number to move.
Scope is documented too: the fluid and flowmap bailout fixes are not reachable
from any route (only FlowmapProvider consumes them, and nothing mounts it), so
no browser harness can measure them and this one does not pretend to.
bench:nav was hardcoded to the instant-nav demo routes, which live only on this branch, so it could not run against main at all — it just timed out waiting for a heading that does not exist. It now takes --scenario, with the demo flow as the default and a fallback that works on any build. The fallback is the 404's "Go Home" link, because that is the only real client-side navigation main has. Main's happy path has no navigable surface: the only internal <Link>s are in error.tsx and not-found.tsx, there is no header nav, and nothing links / to /sanity. Prefetching is a <Link> behaviour, so with no links there is nothing to measure. It deliberately reports no `content` number. / is statically prerendered and has nothing streaming, and a second noisier reading of the same shell timing dressed up as a different metric would be worse than no metric. Comparing main (16.2.12, no flags) against this branch (16.3.0-preview.10, all four flags): main shell 116ms (spread 83-223) branch shell 107ms (spread 81-327) No signal — 9ms apart with ~40ms of run-to-run variation and one large outlier each side. That is the expected result and it is the useful half: a static route has no shell-versus-data split for partialPrefetching to make, so it is equally prefetchable before and after. The flags target dynamic routes, which is why the demo scenario shows a difference and this one does not. The header says that explicitly, so nobody reads this scenario as proof the flags are inert.
main now carries the nine version-independent commits from this branch under different SHAs (#316), so this merge is mostly git recognising the same content arriving from both sides. Two conflicts, both from the deliberate divergences made while cherry-picking: - package.json: main took bench:rerender only, since bench:nav's useful scenario needs the instant-nav demo routes, which are branch-only. Resolved to keep both here, because here both exist. - bench-rerender.ts: its header lists the sibling tools, and on main that line had to say bench:nav lives elsewhere. Restored the branch wording. app/loading.tsx did not conflict, which was the point of keeping main's `Cooking...` version during the cherry-pick rather than dragging over an <output> conversion for markup main does not have. Branch-only work is intact: the <output> loading regions, both instant-nav flags, and the demo routes.
Rides the
@previewnpm tag, which is pre-production. Stays a draft until 16.3 ships stable.What this does
Tracks Next.js 16.3.0-preview.10 and turns on the instant-navigation config, so the deploy preview exercises shell-based prefetching against a real deployment.
next+@next/bundle-analyzerpinned to16.3.0-preview.10partialPrefetching: truealongside thecacheComponents: truethe starter already had. Prefetching moves from per-link to per-route: one reusable shell per route, cached client-side and shared by every link pointing at it, instead of a request per link in the viewportapp/loading.tsxis a real accessible skeleton shell rather than a placeholder word, so navigations land on something meaningfulmain, which now includes the Biome to oxlint + oxfmt migration (build: migrate from Biome to oxlint + oxfmt #309). This branch lints and formats exactly likemain; the demo routes underapp/instant-nav-demo/were written under Biome and are reformatted by oxfmt hereDev server got measurably faster in preview.9
preview.9 picks up React's stack-trace-limit optimisation, which Vercel measured at up to 73% off RSC render time in dev. Measured here on satus's home route, warm, 25 requests each, A/B/A to rule out ordering effects:
About 38% off the home route. Less than the headline 73% because satus's landing page is small and a chunk of the remaining time is fixed request overhead, not RSC render.
One thing worth knowing: the win is V8-only. It does not apply under JSC, so a Bun-hosted dev server sees none of it. satus is safe today because
bun next devhonours the#!/usr/bin/env nodeshebang and hands off to Node, verified in the process tree:If anyone ever "tidies"
lib/scripts/dev.tsto force Bun's runtime, dev RSC rendering silently gets slower. Worth a comment onmainguarding that.New in preview.9:
next devwrites to AGENTS.mdnext devnow appends a managed<!-- BEGIN:nextjs-agent-rules -->block toAGENTS.md(or scaffoldsAGENTS.md+CLAUDE.mdif neither exists). It fires only when@vercel/detect-agentsees an AI coding agent in the environment, so a human runningbun run devnever sees it, but any agent-driven dev run does. There is no config opt-out; the gate is inapp-info-log.js, the writer inserver/lib/generate-agent-files.js.Reverted on this branch rather than committed. satus's
AGENTS.mdis the curated house-standards doc that every fork inherits, so accepting Next's boilerplate into it is a call the team should make deliberately, not one a dev-server side effect makes for us. Added to the merge checklist below.What to look at on the preview
Known issues in the 16.3 line
Verified against the changelog and the tracking discussion, current as of 2026-07-23:
cacheComponents: truebreaks AWS/SST v4.17 deploys entirely. Only matters for a client not deploying to Vercel, but it is a hard failure, not a degradation — worth knowing before this becomes a starter defaultturbopackIgnorefails on nested expressions, which can oversize standalone bundlesprefetch='allow-runtime', contradicting the docs. Unresolved upstreamNo breaking changes in the 16.3 line affect this stack. The 16.0 renames (
middleware→proxy,dynamicIO→cacheComponents, image defaults) are already absorbed.Worth trialling separately
experimental.turbopackFileSystemCacheForBuild— CI build-time win, no code-pattern changeexperimental.turbopackRustReactCompiler— drop-in swap for the Babel React Compiler plugin, worth benchmarkingBefore this could merge
@previewto the exact stable versionapp/instant-nav-demo/— demo routes are branch-only and must never reachmainAGENTS.mdblockpartialPrefetchingbecomes a starter default, given the AWS/SST caveatVerified
bun run checkpasses → 385 tests, on the oxc toolchainbun run buildsucceeds on preview.10, from a cleared.next