fix(headers): preserve config Link headers alongside React preload links - #2791
Conversation
commit: |
Performance benchmarksCompared 0 improved · 1 regressed · 5 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
913d4c8 to
00d8062
Compare
|
/bigbonk review for issues and parity |
|
UnknownError: ProviderInitError |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues and parity |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues and parity |
There was a problem hiding this comment.
Review: config Link header preservation
I verified this against the Next.js canary source and the behavior matches well. Summary of what I checked and a couple of small notes.
Parity verification (all confirmed correct)
-
Config
Link+ framework preload coexist. Next.js applies matched config headers via plain assignment intoresHeadersat the router layer (resolve-routes.ts:850-852), then the App renderer callsres.appendHeader('Link', ...)for React/font preloads (app-render.tsx:4140-4144). So both a configLinkand framework preloadLinkend up on the response. This PR's comma-joinedHeaders.get()output is the Web-API-equivalent of Next.js's multiple header lines. ✅ -
Middleware
Linkreplaces configLink, empty values are ignored. Next.js stages middleware response headers withif (value) { resHeaders[key] = value }(resolve-routes.ts:725-728) — a plain overwrite, gated on truthiness. This matches both theif (options.middlewareHeaders?.get('link')) continue;skip inconfig-headers.tsand the empty-Link guard inmergeMiddlewareResponseHeaders. ✅ -
Node route-handler
Linkloses to config; Edge route-handlerLinkis appended after config.send-response.ts:56-65only appends a route-handler header when it is not already present (config already set it) or is in a fixed multi-value list that does not includelink. So a Node route handler'sLinkis dropped in favor of config — matching the "does not treat a route-owned Link value as a framework preload" test. The Edge runtime uses a different response path, so gatingappendResponseLinkonhandler.runtime === 'edge'is a reasonable distinction. ✅ -
Last-matching config value wins.
retainLastSingularConfigValuesmirrors Next.js's per-ruleresHeaders[key] = value(last write wins), while keepingset-cookie/varyadditive. ✅
Tests all pass locally (app-rsc-response-finalizer, app-page-*, app-route-handler-response).
Minor notes (non-blocking)
- The empty-value guard was added only for
link, but Next.js'sif (value)guard atresolve-routes.ts:725applies to all middleware response headers. This is a pre-existing narrow divergence, not introduced here — just flagging it for awareness. - The
WeakSet<Headers>provenance model is correct for all current flows, but it is fragile: any future code path that reconstructs aResponse(newHeaders) before finalization must remember to callcopyLinkHeaderProvenance, or framework-Link provenance is silently lost. See the inline note onapplyMiddlewareContextToResponse.
Overall this is a well-researched fix that closely tracks Next.js behavior. Nice work including the Next.js source links in the test comments.
|
/bigbonk review for issues and parity |
There was a problem hiding this comment.
Review: preserve config Link headers alongside React preload links
Reviewed against Next.js canary (resolve-routes.ts, send-response.ts) and ran the affected unit suites locally — all pass (app-rsc-response-finalizer, app-page-stream, app-page-cache, app-page-execution, app-page-response, app-route-handler-response, app-page-render; 249 tests green).
The fix is well-scoped and closely tracks Next.js behavior. Summary of what I verified and a few non-blocking notes.
Parity — confirmed correct
-
Config
Link+ framework preload coexist. Next.js sets config headers viaresHeaders[key] = valueat the router layer, then the App renderer callsres.appendHeader('Link', ...)for React/font preloads. Both end up on the response. The comma-joinedHeaders.get()output here is the Web-API equivalent of Next.js's multiple header lines. ✅ -
retainLastSingularConfigValues= last-wins. Next.js'sresHeaders[key] = valueis plain assignment, so the last matching rule wins for every non-set-cookieheader. Note this is a behavioral change beyondLink: the previous vinext code used!responseHeaders.has(lowerName)which produced first-wins for any non-overwriteExistingsingular header. The new code now correctly matches Next.js last-wins for all singular config headers. Worth calling out in the PR description since it's broader than theLinkfix (and is a correctness improvement, not a regression). ✅ -
Middleware
Linkreplaces config, empty values ignored. Next.js stages middleware headers withif (value) { resHeaders[key] = value }. This matches both themiddlewareHeaders?.get('link')skip inconfig-headers.tsand the empty-Link guard inmergeMiddlewareResponseHeaders. ✅ -
Node route-handler
Linkloses to config; Edge appends after config.send-response.tsonly appends a route-handler header when not already present or in a fixed multi-value list that excludeslink. GatingappendResponseLinkonhandler.runtime === 'edge'mirrors that the Edge response path differs. ✅ -
ISR/KV cache boundary. Provenance is a
WeakSet<Headers>(non-serializable), but the cached value persistsheaders.linkand re-marks viamarkFrameworkLinkHeaderson read (app-page-cache.ts:298,app-page-cache-finalizer.ts:253). Provenance survives the cache round-trip correctly. ✅
Non-blocking notes
applyMiddlewareContextToResponse(app-rsc-handler.ts:183) drops Link provenance. Unlike the other reconstruction sites in this PR (applyDraftModeCachePolicy,mergeAppPageSpecialErrorHeaders, the metadata-status wrapper) which all callcopyLinkHeaderProvenance, this helper builds a freshResponse/Headerswithout copying provenance. Harmless today (its two callsites — metadata routes at :917 and route-tree prefetch at :1457 — never carry framework preload Link), but theWeakSetmodel is invisible at the type level, so a future path that routes a framework-Link response through here would silently drop the configLinkappend. Consider mirroring the other sites for defense-in-depth.- Empty-value guard is
link-only. See inline comment onmiddleware-response-headers.ts— Next.js ignores empty middleware values for all header names, not justLink.
Overall: solid, well-tested fix with good Next.js source citations in the test comments. Nice work.
| const lowerName = key.toLowerCase(); | ||
| // Next.js only stages truthy middleware response-header values. Keep an | ||
| // empty Link from erasing config or renderer-owned Link values. | ||
| if (lowerName === "link" && !value) { |
There was a problem hiding this comment.
The empty-value skip is scoped to link only, but Next.js's staging guard (if (value) { resHeaders[key] = value } in resolve-routes.ts) applies to every middleware response header — an empty middleware value for any header name is ignored, not written. Narrowing to link here leaves a pre-existing divergence for other header names (e.g. an empty middleware Cache-Control would still set() an empty value and clobber a config/framework value).
Not introduced by this PR and out of scope for the Link fix, but worth a follow-up to generalize the guard to if (!value) continue; for parity.
|
Review posted successfully. Here's a summary of what I found and reviewed on PR #2791: Review summaryI verified the fix against Next.js The fix is correct and well-scoped. Confirmed parity on all five key behaviors:
Two non-blocking notes (both pre-existing/defense-in-depth, not regressions):
One inline comment failed to post (line 191 of |
Summary
Fixes #2788.
Linkas an appendable response header when applying matchingnext.config.jsheaders()rules in the App Routernext/fontpreload links when a config rule contributes anotherLinkrelationProblem
App Router response finalization applies matching config headers after React has emitted preload headers. The config-header merger only appended
VaryandSet-Cookie; for every other existing header name it skipped the config value. As a result, a React ornext/fontpreloadLinkcaused an unrelated configLink, such asrel="describedby", to disappear from the final response.Linkis a list-valued field, so the configured relation and framework preload can coexist in one comma-combined field or separate fields. Next.js preserves both values.Fix
Include
linkin the set of response headers that useHeaders.append()during App Router config-header application. The existing precedence rules for singular response headers are unchanged.The fixture reproduces the reported behavior with:
ReactDOM.preload("/agent-test.woff2", ...)next.config.tssettingLink: </llms.txt>; rel="describedby"; type="text/plain"Both relations are now present in development and production responses.
This change is limited to config-header application. The separate ISR cache provenance issue reported in #2782 is not changed here.
Test plans
Add new finalizer regression test:
After the fix, the following checks pass: