Skip to content

fix(headers): preserve config Link headers alongside React preload links - #2791

Merged
james-elicx merged 7 commits into
cloudflare:mainfrom
MaxtuneLee:fix/config-link-header
Aug 14, 2026
Merged

james-elicx merged 7 commits into
cloudflare:mainfrom
MaxtuneLee:fix/config-link-header

Conversation

@MaxtuneLee

@MaxtuneLee MaxtuneLee commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #2788.

  • treat Link as an appendable response header when applying matching next.config.js headers() rules in the App Router
  • preserve framework-generated React and next/font preload links when a config rule contributes another Link relation
  • add regression coverage at the response-finalizer, development-server, and production-server levels

Problem

App Router response finalization applies matching config headers after React has emitted preload headers. The config-header merger only appended Vary and Set-Cookie; for every other existing header name it skipped the config value. As a result, a React or next/font preload Link caused an unrelated config Link, such as rel="describedby", to disappear from the final response.

Link is 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 link in the set of response headers that use Headers.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.ts setting Link: </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:

Expected: </llms.txt>; rel="describedby"; type="text/plain"
Received: </agent-test.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"

After the fix, the following checks pass:

  • vp test run tests/app-rsc-response-finalizer.test.ts tests/request-pipeline.test.ts
  • vp test run tests/app-router-next-config-dev.test.ts
  • vp test run tests/app-router-production-server.test.ts -t "preserves config Link headers alongside React preload links"
  • vp test run tests/routing.test.ts tests/route-sorting.test.ts
  • vp check

@pkg-pr-new

pkg-pr-new Bot commented Aug 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2791
npm i https://pkg.pr.new/create-vinext-app@2791
npm i https://pkg.pr.new/@vinext/types@2791
npm i https://pkg.pr.new/vinext@2791

commit: edf7612

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared edf7612 against base ee1d857 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 135.4 KB 135.4 KB ⚫ +0.0%
Client entry size (gzip) vinext 122.8 KB 122.8 KB ⚫ +0.0%
Dev server cold start vinext 3.11 s 3.17 s 🔴 +1.9%
Production build time vinext 3.40 s 3.39 s ⚫ -0.3%
RSC entry closure size (gzip) vinext 114.6 KB 114.9 KB ⚫ +0.2%
Server bundle size (gzip) vinext 194.8 KB 195.2 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@MaxtuneLee
MaxtuneLee force-pushed the fix/config-link-header branch from 913d4c8 to 00d8062 Compare August 4, 2026 05:20
@MaxtuneLee
MaxtuneLee marked this pull request as ready for review August 4, 2026 05:44
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues and parity

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues and parity

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues and parity

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. Config Link + framework preload coexist. Next.js applies matched config headers via plain assignment into resHeaders at the router layer (resolve-routes.ts:850-852), then the App renderer calls res.appendHeader('Link', ...) for React/font preloads (app-render.tsx:4140-4144). So both a config Link and framework preload Link end up on the response. This PR's comma-joined Headers.get() output is the Web-API-equivalent of Next.js's multiple header lines. ✅

  2. Middleware Link replaces config Link, empty values are ignored. Next.js stages middleware response headers with if (value) { resHeaders[key] = value } (resolve-routes.ts:725-728) — a plain overwrite, gated on truthiness. This matches both the if (options.middlewareHeaders?.get('link')) continue; skip in config-headers.ts and the empty-Link guard in mergeMiddlewareResponseHeaders. ✅

  3. Node route-handler Link loses to config; Edge route-handler Link is appended after config. send-response.ts:56-65 only 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 include link. So a Node route handler's Link is 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 gating appendResponseLink on handler.runtime === 'edge' is a reasonable distinction. ✅

  4. Last-matching config value wins. retainLastSingularConfigValues mirrors Next.js's per-rule resHeaders[key] = value (last write wins), while keeping set-cookie/vary additive. ✅

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's if (value) guard at resolve-routes.ts:725 applies 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 a Response (new Headers) before finalization must remember to call copyLinkHeaderProvenance, or framework-Link provenance is silently lost. See the inline note on applyMiddlewareContextToResponse.

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.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues and parity

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Config Link + framework preload coexist. Next.js sets config headers via resHeaders[key] = value at the router layer, then the App renderer calls res.appendHeader('Link', ...) for React/font preloads. Both end up on the response. The comma-joined Headers.get() output here is the Web-API equivalent of Next.js's multiple header lines. ✅

  2. retainLastSingularConfigValues = last-wins. Next.js's resHeaders[key] = value is plain assignment, so the last matching rule wins for every non-set-cookie header. Note this is a behavioral change beyond Link: the previous vinext code used !responseHeaders.has(lowerName) which produced first-wins for any non-overwriteExisting singular 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 the Link fix (and is a correctness improvement, not a regression). ✅

  3. Middleware Link replaces config, empty values ignored. Next.js stages middleware headers with if (value) { resHeaders[key] = value }. This matches both the middlewareHeaders?.get('link') skip in config-headers.ts and the empty-Link guard in mergeMiddlewareResponseHeaders. ✅

  4. Node route-handler Link loses to config; Edge appends after config. send-response.ts only appends a route-handler header when not already present or in a fixed multi-value list that excludes link. Gating appendResponseLink on handler.runtime === 'edge' mirrors that the Edge response path differs. ✅

  5. ISR/KV cache boundary. Provenance is a WeakSet<Headers> (non-serializable), but the cached value persists headers.link and re-marks via markFrameworkLinkHeaders on 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 call copyLinkHeaderProvenance, this helper builds a fresh Response/Headers without copying provenance. Harmless today (its two callsites — metadata routes at :917 and route-tree prefetch at :1457 — never carry framework preload Link), but the WeakSet model is invisible at the type level, so a future path that routes a framework-Link response through here would silently drop the config Link append. Consider mirroring the other sites for defense-in-depth.
  • Empty-value guard is link-only. See inline comment on middleware-response-headers.ts — Next.js ignores empty middleware values for all header names, not just Link.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of what I found and reviewed on PR #2791:

Review summary

I verified the fix against Next.js canary source (resolve-routes.ts, send-response.ts) and ran the affected unit suites locally (249 tests, all green).

The fix is correct and well-scoped. Confirmed parity on all five key behaviors:

  1. Config Link + framework preload coexist (matches Next.js's set-then-append pattern)
  2. retainLastSingularConfigValues correctly implements last-wins — and I flagged that this is actually a broader correctness fix than the PR describes: the old !responseHeaders.has() logic was first-wins for all non-overwriteExisting singular config headers; the new code matches Next.js last-wins semantics
  3. Middleware Link precedence + empty-value skip
  4. Node vs Edge route-handler Link handling
  5. ISR/KV cache boundary correctly re-marks provenance from persisted headers.link

Two non-blocking notes (both pre-existing/defense-in-depth, not regressions):

  • applyMiddlewareContextToResponse reconstructs a Response without copyLinkHeaderProvenance — harmless at current callsites (metadata routes, route-tree prefetch), but fragile given the invisible WeakSet model
  • The empty-value guard in mergeMiddlewareResponseHeaders is link-only, whereas Next.js ignores empty values for all middleware header names (posted as an inline comment)

One inline comment failed to post (line 191 of app-rsc-handler.ts was unchanged context outside the diff), so I folded that note into the review body instead.

github run

@james-elicx
james-elicx merged commit 88ea15a into cloudflare:main Aug 14, 2026
56 checks passed
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.

App Router: next.config Link header is dropped when a preload Link exists

2 participants