Skip to content

Adopt partial prefetching - #1076

Merged
oBusk merged 29 commits into
mainfrom
partial-prefetching
Nov 7, 2026
Merged

oBusk merged 29 commits into
mainfrom
partial-prefetching

Conversation

@oBusk

@oBusk oBusk commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Adopts Partial Prefetching from Next.js 16.4. With it on, a default <Link> prefetches the route's static App Shell, which is shared by every link to that route.

A hard requirement for this PR is zero increase in server CPU, so it only uses prefetches that are served from build output. It does not use <Link prefetch={true}>, router.prefetch(..., { kind: "full" }) or prefetch() stages, because those render on the server once per link.

Changes

  • Enable partialPrefetching: Next 16.4 warns when cacheComponents is on without it set, and it becomes the default in Next 17.
  • Skeleton fallback for [...parts]: the shell used to be an empty <Suspense>. It now has a skeleton shaped like the top of the diff page, so clicking a diff link paints immediately.
  • Separate catalog route: Next serves one App Shell per route, so catalog pages move to an internal /-/catalog/[...parts] route with a catalog skeleton as their shell. Routing follows one rule:
    • A rewrite in next.config.ts sends canonical catalog URLs (/react, /@types/node) to the catalog route. The patterns live in src/lib/catalogRoutes.ts, and tests run them through Next's own path matcher.
    • Any other URL that parses as a catalog page (/react@*, /@types%2Fnode, /%40types/node) reaches [...parts], which redirects it to the canonical URL. A test checks that every such redirect target matches the rewrite, so the redirect can't loop.
    • The internal /-/catalog/... path redirects to the public URL.
    • The catalog components move into the catalog route's folder.
    • The catalog skeleton lives in the route's loading.tsx.
  • One error boundary for all routes: the diff route's error.tsx moves to src/app/error.tsx so the catalog route shares it. / and the /-/about pages now get the same error UI instead of Next's default.
  • Remove prefetch={false} from the catalog's suggested diffs: a default prefetch now only fetches the shared static shell.
  • ensureStatic = "shell" on the root layout: every route, including future ones, must keep its App Shell static.
  • ensureStatic = "navigation" on / and the /-/about pages: the build fails if they become dynamic, even inside <Suspense>. Verified by adding a connection() call inside <Suspense> on /-/about, which failed the build.

Server cost

Measured on the preview deployment, with request logs in the Vercel dashboard:

  • Every App Shell prefetch (_tree, _index and page segments) is served from build output (x-vercel-cache: HIT/PRERENDER, x-matched-path: /[...parts].segments/… or /-/catalog/[...parts].segments/…). This holds for unknown packages too, so it never invokes a function. Production already serves its default prefetches the same way.
  • Prefetches of / and the /-/about pages come from the ISR cache with no function invocation.
  • Clicking a catalog diff link makes one static prefetch and one navigation request, the same number of server renders as before.
  • New URLs: the Next docs describe rendering not-yet-built URLs again in the background and caching them per URL. That doesn't happen here. Three visits each to a new diff URL and a new catalog URL made exactly one function invocation per visit, and every visit, including the third, was served the route's shared fallback shell. There were no extra background invocations and no per-URL ISR writes. Each visit costs one invocation, the same as production.
  • The redirects and rewrites run in Vercel's routing layer. The [...parts] redirect for non-canonical catalog URLs only runs for requests that already invoked a function before this PR.

Testing

In DevTools, open the Network tab, filter on _rsc, and throttle to Slow 4G.

Check Production (before) Preview (expected)
Click a suggested diff on a catalog page (e.g. /is-even) Stays on the old page until the server responds URL changes and the diff skeleton shows immediately
Click a home page example Empty shell, then the diff Diff skeleton immediately, then the diff
Visit /react or /@types/node directly Catalog page Catalog page, with a catalog skeleton while streaming
Visit /react@*, /@types%2Fnode or /%40types/node Catalog page Redirects to /react or /@types/node
Visit /-/catalog/react n/a Redirects to /react
Toggle Split/Unified on a diff page No prefetch No prefetch (unchanged)

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
npm-diff Ready Ready Preview Nov 7, 2026 12:24pm UTC

oBusk and others added 5 commits November 5, 2026 11:59
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLHpPQrKPUewGdqNgK98B6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalog and diff pages shared the [...parts] route, so its App Shell
painted a diff skeleton before every catalog page. Rewrite catalog-shaped
paths to an internal /-/catalog/[...parts] route with the catalog
skeleton as its shell, and redirect the internal path to the public one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLHpPQrKPUewGdqNgK98B6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oBusk and others added 5 commits November 5, 2026 21:49
The (?!.+%40) lookahead skipped a %40 directly after the first character,
so /q%401.5.1 was rewritten to the catalog route and returned 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalog pages moved out of [...parts] and lost its maxDuration, falling
back to the platform default. Cold catalog renders take under 2s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rewrite regexes and isCatalogPage decide independently whether a path
is a catalog page. The test asserts they agree on encoded and unencoded
paths, and records the catalog paths left to [...parts].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The route wrapped CatalogPage, which has its own boundary with the same
fallback, in a second Suspense.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generateMetadata and the page both parsed the same params. React cache()
memoizes the parse, keyed on the serialized parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oBusk and others added 2 commits November 5, 2026 23:00
Paths like /react@* are catalog pages the rewrite doesn't match, so they rendered in [...parts] behind the diff skeleton. Redirect them to the bare package name, which the rewrite sends to the catalog route, and drop the catalog rendering from [...parts].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DiffPageFallback copied DiffIntro's markup and the list of services. DiffIntro now renders through a slot-based layout that DiffIntroSkeleton also uses, and DiffServices owns the service list and its skeleton.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oBusk and others added 4 commits November 6, 2026 01:41
Paths like /@types%2Fnode are catalog pages the rewrite doesn't match, and they returned 404 because the redirect target was the same path. Render the catalog in [...parts] in that case and only redirect the @* forms. getCatalogPackageName now runs once per render instead of after isCatalogPage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog name pattern only rejected a literal ... and %40, so /react%2E%2E%2E18.0.0 was rewritten to the catalog route and 404'd. npm package names never contain %, so reject it anywhere in the name and let [...parts] handle encoded paths. The test now matches with the same options Next uses for rewrites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the JSON round-trip and cast, and type the route props from Next's generated PageProps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oBusk and others added 3 commits November 6, 2026 23:25
Catalog URLs that the rewrite misses, such as /@types%2Fnode or
/re%61ct, were rendered by the diff route behind the diff page
skeleton. Redirect them to /<name> instead, which the rewrite always
sends to the catalog route, so catalog pages only render there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The diff route redirects every other catalog URL to its canonical form,
so the rewrite no longer has to recognise an encoded %40 scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oBusk and others added 2 commits November 6, 2026 23:45
Only the catalog route renders them now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set ensureStatic on the root layout so new routes inherit it, instead of
setting it on the diff and catalog pages only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the hand-written Suspense boundary and inner component. The
prerendered shell and prefetch segments are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oBusk and others added 2 commits November 7, 2026 02:28
The catalog route re-exported the diff route's error boundary. Move it
to the app root instead, with ErrorBox in src/components, so both
routes use it without importing from each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loading.tsx owns the route's fallback, so CatalogPage goes back to a
single default-exported component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog page is no longer rendered from the diff route, so the page
can carry "use cache" instead of an inner component. It resolves the
package name once, and the metadata takes that name directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Non-canonical catalog URLs (e.g. /react@*) don't match the catalog
rewrite and land on the diff route, so both generateMetadata and the
page must redirect them. One helper keeps the two from drifting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
npm allows scopes like @.foo, but the scope pattern reused the name
pattern, which rejects a leading period. Such URLs missed the rewrite,
and [...parts] redirected them to themselves in a loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@oBusk
oBusk merged commit 13122bb into main Nov 7, 2026
14 checks passed
@oBusk
oBusk deleted the partial-prefetching branch November 7, 2026 12:25

This branch was successfully deployed

1 active deployment
Preview — 6639c68d Deployed Nov 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant