Repository navigation
perf(app-router): preload only the rendered route's client references during SSR - #4114
james-elicx wants to merge 3 commits into
Conversation
… during SSR The first App Router SSR render after start imported the client code of every page before rendering anything, so a cold server loaded the whole app's client graph for one page. The RSC build now plans, per route, the client references its page, layouts, templates, boundaries and parallel slots reach, through static or dynamic imports, using the route graph walk that groups client chunks. References reached outside route files (vinext's runtime, server actions), global-error's and any the walk can't place load for every route. A render without a route uses the root layouts and boundaries and global-not-found. The SSR build exports the plan from the client-references module, and handleSsr awaits the matched route's set before it renders, so a cold render shows the same document as a warm one without reading the Flight stream. PPR fallback shells, and patterns the plan doesn't know, still load every reference.
@vinext/cloudflare
create-vinext-app
@vinext/types
vinext
@cloudflare/workers-response-store
commit: |
Performance benchmarksCompared 0 improved · 0 regressed · 7 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
… the Flight client React's Flight client waits a tick for a module promise that doesn't carry React's thenable status, so a route's first render after a preload emitted plugin-rsc's stylesheet ahead of next/dynamic's nonce'd preload link. Also add routeClientReferences to the dispatch test's client-references mock.
|
… route files in route preloads - Export a null preload plan when the app registers @vitejs/plugin-rsc itself, which the SSR entry's import needs to build. - Always preload the root not-found, forbidden and unauthorized boundaries, which routes without their own fall back to. - Leave route files that are client references (every error.tsx) to their routes instead of preloading them everywhere. - Skip awaiting a warm render's already loaded references.
|
/bigbonk please review this PR at head 5ed0ee7. You have an 8-minute time limit. |
|
LGTM! |
|
💬 Approved with comments — The per-route SSR preload plan, the runtime lookup and the call-site wiring look correct, and every fallback still loads all client references. The remaining notes are optional: test coverage for the Findings: 2 suggestions The decision is advisory: this comment never approves, blocks or requests changes on the pull request. 🔧 Fix with your agentPaste the following into your coding agent to investigate and fix these issues. 📖 AGENTS.md: 💬 1 suggestionAGENTS.md could mention the new route-scoped SSR preload plan next to its client-reference grouping note. Recommendation: 💬 Optional Nits
View full review
📋 Code Quality: 💬 1 suggestionThe plan, runtime lookup and call-site wiring look correct. The Recommendation: 💬 Optional Nits
View full review
Checked, no issue found
✅ All other checks passed🔒 Security: ✅ LGTMNo security concerns:
⚡ Performance: ✅ LGTMNo performance concerns. Each SSR render now preloads only its route's client references instead of every client reference in the app. 📝 Documentation: ✅ LGTMNo documentation issues found. Reviewed commit: |
What
App Router SSR no longer loads every client reference in the app before its first render. Each render waits only for the client references of the route it renders, planned at build time from the RSC module graph.
This is the per-route approach suggested in this comment on #3913. It builds on the route graph walk that #3697 added for grouping client chunks.
How
collectClientReferencePreloadPlanwalks each route's files (page, layouts, templates, boundaries, parallel slots) through static and dynamic imports. Dynamic imports count because anext/dynamiccomponent renders on the server. The result is the client references each route can render.error.tsx, stay with their own routes.virtual:vite-rsc/client-references, as indices into one list of reference keys.handleSsr: page renders, boundary renders, ISR regeneration and the RSC render-status pass.handleSsrawaits that route's set before rendering.The render still waits for its client references, as it does today, so a cold render produces the same document as a warm one. Once a module loads, the preloader marks its promise settled the way React tracks thenables (
status/value). Without that, React's Flight client waits a tick for the module on the first render after the load, and plugin-rsc's stylesheet beatnext/dynamic's nonce'd preload link into the head. It doesn't read the Flight stream, and stylesheet and script hint order doesn't depend on load timing.Fallbacks load every reference, as before. They cover PPR fallback shells (they abort on the next tick), a route pattern the plan doesn't know, development (where plugin-rsc loads references on demand), and apps that register
@vitejs/plugin-rscthemselves withvinext({ rsc: false }), which get a null plan. This doesn't depend on client chunk grouping:disableNextLikeChunkingstill gets per-route SSR preloads.Known limitations
A client reference that a route renders but doesn't reach in the module graph, and that isn't reached from outside route files either, isn't preloaded. One example is a server component that builds a specifier at runtime and loads it with
import(). That reference loads during the render, the way any client reference did before the global preload. Server action modules are reached through plugin-rsc's server-references module, so the components their results render are in the always-preloaded set.The always-preloaded set includes every client reference reached from modules outside route files: route handlers, metadata files, middleware, instrumentation and server action modules. If one of those imports a barrel that re-exports client components, those components load for every route. That is never more than main loads today.
Tests
tests/app-route-client-preloads-production.test.ts(integration) buildstests/fixtures/app-route-client-preloadsand starts the production server. Each check runs on a cold server:next/dynamicstylesheet keeps its nonce anddynamicprecedence on the first request;On main's global preload the "not evaluated" checks fail. With no preload at all, the warm-equality and
next/dynamicchecks fail.tests/client-reference-groups.test.tscovers the plan:tests/app-client-reference-preloader.test.tscovers resolving a route's set, the fallbacks, and the settled-promise marking.