fix(app-route): merge middleware headers with override semantics#789
Conversation
commit: |
There was a problem hiding this comment.
Pull request overview
This PR updates app route handler middleware header application to match Next-like merge semantics, allowing middleware to override singular response headers while preserving additive multi-value headers.
Changes:
- Update
applyRouteHandlerMiddlewareContexttoset()most middleware headers (override semantics) whileappend()ingset-cookieandvary. - Update/extend unit tests to cover singular override behavior and additive merge behavior for
Set-CookieandVary.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/app-route-handler-response.test.ts | Adjusts existing expectations for singular header overrides and adds a regression test for additive Set-Cookie/Vary behavior. |
| packages/vinext/src/server/app-route-handler-response.ts | Implements override semantics for most middleware headers while preserving additive behavior for set-cookie and vary. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const responseHeaders = new Headers(response.headers); | ||
| if (middlewareContext.headers) { | ||
| for (const [key, value] of middlewareContext.headers) { | ||
| responseHeaders.append(key, value); | ||
| const lowerKey = key.toLowerCase(); | ||
| if (lowerKey === "set-cookie" || lowerKey === "vary") { | ||
| responseHeaders.append(key, value); | ||
| } else { | ||
| responseHeaders.set(key, value); | ||
| } | ||
| } |
There was a problem hiding this comment.
The header merge logic here duplicates mergeMiddlewareResponseHeaders() in server/app-page-response.ts (same set-cookie/vary append vs other headers set semantics). To avoid the two implementations drifting over time, consider reusing the shared helper (or extracting a small shared utility) rather than re-implementing the merge loop in this file.
| it("appends additive middleware headers for Set-Cookie and Vary", async () => { | ||
| const response = new Response("hello", { | ||
| status: 200, | ||
| headers: [ | ||
| ["vary", "RSC, Accept"], | ||
| ["set-cookie", "existing=1; Path=/"], | ||
| ], | ||
| }); | ||
|
|
||
| const middlewareHeaders = new Headers(); | ||
| middlewareHeaders.append("vary", "Next-Router-State-Tree"); | ||
| middlewareHeaders.append("set-cookie", "mw=1; Path=/"); | ||
|
|
||
| const result = applyRouteHandlerMiddlewareContext(response, { | ||
| headers: middlewareHeaders, | ||
| status: null, | ||
| }); | ||
|
|
||
| expect(result.headers.get("vary")).toBe("RSC, Accept, Next-Router-State-Tree"); | ||
| expect(result.headers.getSetCookie()).toEqual(["existing=1; Path=/", "mw=1; Path=/"]); | ||
| await expect(result.text()).resolves.toBe("hello"); | ||
| }); |
There was a problem hiding this comment.
The new additive behavior is only exercised with a single middleware set-cookie value. It would be valuable to add a regression case where middlewareHeaders.append('set-cookie', ...) is called multiple times to ensure applyRouteHandlerMiddlewareContext() preserves multiple middleware cookies as distinct Set-Cookie headers (i.e., they don’t get flattened into a single comma-separated value).
|
/bigbonk review |
There was a problem hiding this comment.
Good change — extracting the shared helper and fixing the route handler path to use set() instead of append() for singular headers is the right call. Tests are solid and cover both override and additive semantics.
Bug: buildAppPageHtmlResponse still uses the old append-only loop
buildAppPageHtmlResponse in app-page-response.ts:233-237 was not updated and still uses the old append-only loop for middleware headers:
if (options.middlewareContext.headers) {
for (const [key, value] of options.middlewareContext.headers) {
headers.append(key, value);
}
}This means middleware can't override singular headers (like Cache-Control) on HTML page responses — the exact same bug this PR fixes for route handlers. Since the whole point of extracting mergeMiddlewareResponseHeaders into a shared module was to avoid divergence, this callsite should use the shared helper too:
mergeMiddlewareResponseHeaders(headers, options.middlewareContext.headers);Per the AGENTS.md guidance: "When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as follow-ups — fix them in the same PR."
| @@ -0,0 +1,19 @@ | |||
| const ADDITIVE_RESPONSE_HEADER_NAMES = new Set(["set-cookie", "vary"]); | |||
|
|
|||
| export function mergeMiddlewareResponseHeaders( | |||
There was a problem hiding this comment.
Nit: the old code in app-page-response.ts had a useful JSDoc comment explaining the merge contract that got dropped during the move. Since this is now the canonical shared implementation, it'd be worth carrying that doc forward:
| export function mergeMiddlewareResponseHeaders( | |
| /** | |
| * Merge middleware response headers into a target Headers object. | |
| * | |
| * Set-Cookie and Vary are accumulated (append) since multiple sources can | |
| * contribute values. All other headers use set() so middleware owns singular | |
| * response headers like Cache-Control. | |
| */ | |
| export function mergeMiddlewareResponseHeaders( |
| } | ||
| } | ||
| } | ||
| export { mergeMiddlewareResponseHeaders }; |
There was a problem hiding this comment.
The re-export keeps the public API surface stable for existing consumers (entry templates import from this module). Good.
|
Review posted. Here's a summary of my feedback: Overall: Good change. The extraction of Key finding — bug in scope of this PR:
Minor nit: The JSDoc comment from the original function was dropped during the extraction to the new module. Worth carrying it forward since this is now the canonical implementation. |
|
/bigbonk review |
There was a problem hiding this comment.
Clean PR. The extraction of the merge logic into middleware-response-headers.ts is well done, and the fix correctly applies to both the route handler and the HTML page response paths. Tests cover the key behaviors (singular override, additive Set-Cookie/Vary, multiple Set-Cookie preservation). The ask-bonk concern about buildAppPageHtmlResponse still using the old append-only loop is already addressed in this diff — line 233 now calls the shared helper.
One minor nit below, otherwise LGTM.
| } | ||
| } | ||
| } | ||
| export { mergeMiddlewareResponseHeaders }; |
There was a problem hiding this comment.
Nit: this re-export means consumers that import from app-page-response.ts still get the function, which is good for backward compat. But it also means there are now two valid import paths for the same function (app-page-response and middleware-response-headers). If new code in the codebase accidentally imports from here instead of the canonical module, there's no signal that this is just a re-export.
Consider adding a brief comment:
| export { mergeMiddlewareResponseHeaders }; | |
| /** @see middleware-response-headers.ts — re-exported for backward compatibility with entry templates */ | |
| export { mergeMiddlewareResponseHeaders }; |
Very minor — not blocking.
|
Review submitted. Here's a summary: Approved with one minor nit. The PR correctly extracts the middleware header merge logic into a shared The only suggestion was adding a brief comment on the re-export in |
Summary
applyRouteHandlerMiddlewareContextto merge middleware response headers with Next-like semanticsset-cookieandvaryas additive headers (append)set), so middleware can override route handler values likecache-controlVerification
vp test run tests/app-route-handler-response.test.ts tests/app-route-handler-cache.test.ts tests/app-route-handler-execution.test.ts tests/app-route-handler-policy.test.ts tests/app-route-handler-runtime.test.ts