Skip to content

Commit 85bb872

Browse files
committed
fix: ensure route handlers properly track dynamic access (#66446)
In #60645, dynamic access tracking was refactored but we erroneously stopped tracking dynamic access in route handlers. Request proxying, as well as tracking segment level configs (such as `export const dynamic = 'force-dynamic'`), were only enabled during static generation. This was an unintended breaking change that consequently caused dynamic access to not properly bail from data cache in various circumstances. This adds some more rigorous testing for route handlers, as this seems to be a fairly large gap in our fetch cache testing currently. This PR is easiest to review with [whitespace disabled](https://github.com/vercel/next.js/pull/66446/files?w=1).
1 parent 2e7a96a commit 85bb872

File tree

6 files changed

+204
-133
lines changed

6 files changed

+204
-133
lines changed

packages/next/src/server/app-render/dynamic-rendering.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export function trackDynamicDataAccessed(
146146
if (store.isStaticGeneration) {
147147
// We aren't prerendering but we are generating a static page. We need to bail out of static generation
148148
const err = new DynamicServerError(
149-
`Route ${pathname} couldn't be rendered statically because it used ${expression}. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
149+
`Route ${pathname} couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
150150
)
151151
store.dynamicUsageDescription = expression
152152
store.dynamicUsageStack = err.stack

packages/next/src/server/future/route-modules/app-route/module.ts

+128-128
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,17 @@ import * as serverHooks from '../../../../client/components/hooks-server-context
4242
import { DynamicServerError } from '../../../../client/components/hooks-server-context'
4343

4444
import { requestAsyncStorage } from '../../../../client/components/request-async-storage.external'
45-
import { staticGenerationAsyncStorage } from '../../../../client/components/static-generation-async-storage.external'
45+
import {
46+
staticGenerationAsyncStorage,
47+
type StaticGenerationStore,
48+
} from '../../../../client/components/static-generation-async-storage.external'
4649
import { actionAsyncStorage } from '../../../../client/components/action-async-storage.external'
4750
import * as sharedModules from './shared-modules'
4851
import { getIsServerAction } from '../../../lib/server-action-request-meta'
4952
import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies'
5053
import { cleanURL } from './helpers/clean-url'
5154
import { StaticGenBailoutError } from '../../../../client/components/static-generation-bailout'
55+
import { trackDynamicDataAccessed } from '../../../app-render/dynamic-rendering'
5256

5357
/**
5458
* The AppRouteModule is the type of the module exported by the bundled App
@@ -308,53 +312,36 @@ export class AppRouteRouteModule extends RouteModule<
308312
let request = rawRequest
309313

310314
// Update the static generation store based on the dynamic property.
311-
if (isStaticGeneration) {
312-
switch (this.dynamic) {
313-
case 'force-dynamic': {
314-
// Routes of generated paths should be dynamic
315-
staticGenerationStore.forceDynamic = true
316-
break
317-
}
318-
case 'force-static':
319-
// The dynamic property is set to force-static, so we should
320-
// force the page to be static.
321-
staticGenerationStore.forceStatic = true
322-
// We also Proxy the request to replace dynamic data on the request
323-
// with empty stubs to allow for safely executing as static
324-
request = new Proxy(
325-
rawRequest,
326-
forceStaticRequestHandlers
327-
)
328-
break
329-
case 'error':
330-
// The dynamic property is set to error, so we should throw an
331-
// error if the page is being statically generated.
332-
staticGenerationStore.dynamicShouldError = true
333-
if (isStaticGeneration)
334-
request = new Proxy(
335-
rawRequest,
336-
requireStaticRequestHandlers
337-
)
338-
break
339-
default:
340-
// When we are statically generating a route we want to bail out if anything dynamic
341-
// is accessed. We only create this proxy in the staticGenerationCase because it is overhead
342-
// for dynamic runtime executions
343-
request = new Proxy(
344-
rawRequest,
345-
staticGenerationRequestHandlers
346-
)
315+
switch (this.dynamic) {
316+
case 'force-dynamic': {
317+
// Routes of generated paths should be dynamic
318+
staticGenerationStore.forceDynamic = true
319+
break
347320
}
348-
} else {
349-
// Generally if we are in a dynamic render we don't have to modify much however for
350-
// force-static specifically we ensure the dynamic and static behavior is consistent
351-
// by proxying the request in the same way in both cases
352-
if (this.dynamic === 'force-static') {
321+
case 'force-static':
353322
// The dynamic property is set to force-static, so we should
354323
// force the page to be static.
355324
staticGenerationStore.forceStatic = true
325+
// We also Proxy the request to replace dynamic data on the request
326+
// with empty stubs to allow for safely executing as static
356327
request = new Proxy(rawRequest, forceStaticRequestHandlers)
357-
}
328+
break
329+
case 'error':
330+
// The dynamic property is set to error, so we should throw an
331+
// error if the page is being statically generated.
332+
staticGenerationStore.dynamicShouldError = true
333+
if (isStaticGeneration)
334+
request = new Proxy(
335+
rawRequest,
336+
requireStaticRequestHandlers
337+
)
338+
break
339+
default:
340+
// We proxy `NextRequest` to track dynamic access, and potentially bail out of static generation
341+
request = proxyNextRequest(
342+
rawRequest,
343+
staticGenerationStore
344+
)
358345
}
359346

360347
// If the static generation store does not have a revalidate value
@@ -661,92 +648,105 @@ const forceStaticNextUrlHandlers = {
661648
},
662649
}
663650

664-
const staticGenerationRequestHandlers = {
665-
get(
666-
target: NextRequest & RequestSymbolTarget,
667-
prop: string | symbol,
668-
receiver: any
669-
): unknown {
670-
switch (prop) {
671-
case 'nextUrl':
672-
return (
673-
target[nextURLSymbol] ||
674-
(target[nextURLSymbol] = new Proxy(
675-
target.nextUrl,
676-
staticGenerationNextUrlHandlers
677-
))
678-
)
679-
case 'headers':
680-
case 'cookies':
681-
case 'url':
682-
case 'body':
683-
case 'blob':
684-
case 'json':
685-
case 'text':
686-
case 'arrayBuffer':
687-
case 'formData':
688-
throw new DynamicServerError(
689-
`Route ${target.nextUrl.pathname} couldn't be rendered statically because it accessed \`request.${prop}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
690-
)
691-
case 'clone':
692-
return (
693-
target[requestCloneSymbol] ||
694-
(target[requestCloneSymbol] = () =>
695-
new Proxy(
696-
// This is vaguely unsafe but it's required since NextRequest does not implement
697-
// clone. The reason we might expect this to work in this context is the Proxy will
698-
// respond with static-amenable values anyway somewhat restoring the interface.
699-
// @TODO we need to rethink NextRequest and NextURL because they are not sufficientlly
700-
// sophisticated to adequately represent themselves in all contexts. A better approach is
701-
// to probably embed the static generation logic into the class itself removing the need
702-
// for any kind of proxying
703-
target.clone() as NextRequest,
704-
staticGenerationRequestHandlers
705-
))
706-
)
707-
default:
708-
const result = Reflect.get(target, prop, receiver)
709-
if (typeof result === 'function') {
710-
return result.bind(target)
651+
function proxyNextRequest(
652+
request: NextRequest,
653+
staticGenerationStore: StaticGenerationStore
654+
) {
655+
const nextUrlHandlers = {
656+
get(
657+
target: NextURL & UrlSymbolTarget,
658+
prop: string | symbol,
659+
receiver: any
660+
): unknown {
661+
switch (prop) {
662+
case 'search':
663+
case 'searchParams':
664+
case 'url':
665+
case 'href':
666+
case 'toJSON':
667+
case 'toString':
668+
case 'origin': {
669+
trackDynamicDataAccessed(staticGenerationStore, `nextUrl.${prop}`)
670+
const result = Reflect.get(target, prop, receiver)
671+
if (typeof result === 'function') {
672+
return result.bind(target)
673+
}
674+
return result
711675
}
712-
return result
713-
}
714-
},
715-
// We don't need to proxy set because all the properties we proxy are ready only
716-
// and will be ignored
717-
}
676+
case 'clone':
677+
return (
678+
target[urlCloneSymbol] ||
679+
(target[urlCloneSymbol] = () =>
680+
new Proxy(target.clone(), nextUrlHandlers))
681+
)
682+
default:
683+
const result = Reflect.get(target, prop, receiver)
684+
if (typeof result === 'function') {
685+
return result.bind(target)
686+
}
687+
return result
688+
}
689+
},
690+
}
718691

719-
const staticGenerationNextUrlHandlers = {
720-
get(
721-
target: NextURL & UrlSymbolTarget,
722-
prop: string | symbol,
723-
receiver: any
724-
): unknown {
725-
switch (prop) {
726-
case 'search':
727-
case 'searchParams':
728-
case 'url':
729-
case 'href':
730-
case 'toJSON':
731-
case 'toString':
732-
case 'origin':
733-
throw new DynamicServerError(
734-
`Route ${target.pathname} couldn't be rendered statically because it accessed \`nextUrl.${prop}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
735-
)
736-
case 'clone':
737-
return (
738-
target[urlCloneSymbol] ||
739-
(target[urlCloneSymbol] = () =>
740-
new Proxy(target.clone(), staticGenerationNextUrlHandlers))
741-
)
742-
default:
743-
const result = Reflect.get(target, prop, receiver)
744-
if (typeof result === 'function') {
745-
return result.bind(target)
692+
const nextRequestHandlers = {
693+
get(
694+
target: NextRequest & RequestSymbolTarget,
695+
prop: string | symbol,
696+
receiver: any
697+
): unknown {
698+
switch (prop) {
699+
case 'nextUrl':
700+
return (
701+
target[nextURLSymbol] ||
702+
(target[nextURLSymbol] = new Proxy(target.nextUrl, nextUrlHandlers))
703+
)
704+
case 'headers':
705+
case 'cookies':
706+
case 'url':
707+
case 'body':
708+
case 'blob':
709+
case 'json':
710+
case 'text':
711+
case 'arrayBuffer':
712+
case 'formData': {
713+
trackDynamicDataAccessed(staticGenerationStore, `request.${prop}`)
714+
715+
const result = Reflect.get(target, prop, receiver)
716+
if (typeof result === 'function') {
717+
return result.bind(target)
718+
}
719+
return result
746720
}
747-
return result
748-
}
749-
},
721+
case 'clone':
722+
return (
723+
target[requestCloneSymbol] ||
724+
(target[requestCloneSymbol] = () =>
725+
new Proxy(
726+
// This is vaguely unsafe but it's required since NextRequest does not implement
727+
// clone. The reason we might expect this to work in this context is the Proxy will
728+
// respond with static-amenable values anyway somewhat restoring the interface.
729+
// @TODO we need to rethink NextRequest and NextURL because they are not sufficientlly
730+
// sophisticated to adequately represent themselves in all contexts. A better approach is
731+
// to probably embed the static generation logic into the class itself removing the need
732+
// for any kind of proxying
733+
target.clone() as NextRequest,
734+
nextRequestHandlers
735+
))
736+
)
737+
default:
738+
const result = Reflect.get(target, prop, receiver)
739+
if (typeof result === 'function') {
740+
return result.bind(target)
741+
}
742+
return result
743+
}
744+
},
745+
// We don't need to proxy set because all the properties we proxy are ready only
746+
// and will be ignored
747+
}
748+
749+
return new Proxy(request, nextRequestHandlers)
750750
}
751751

752752
const requireStaticRequestHandlers = {
@@ -774,7 +774,7 @@ const requireStaticRequestHandlers = {
774774
case 'arrayBuffer':
775775
case 'formData':
776776
throw new StaticGenBailoutError(
777-
`Route ${target.nextUrl.pathname} with \`dynamic = "error"\` couldn't be rendered statically because it accessed \`request.${prop}\`.`
777+
`Route ${target.nextUrl.pathname} with \`dynamic = "error"\` couldn't be rendered statically because it used \`request.${prop}\`.`
778778
)
779779
case 'clone':
780780
return (
@@ -819,7 +819,7 @@ const requireStaticNextUrlHandlers = {
819819
case 'toString':
820820
case 'origin':
821821
throw new StaticGenBailoutError(
822-
`Route ${target.pathname} with \`dynamic = "error"\` couldn't be rendered statically because it accessed \`nextUrl.${prop}\`.`
822+
`Route ${target.pathname} with \`dynamic = "error"\` couldn't be rendered statically because it used \`nextUrl.${prop}\`.`
823823
)
824824
case 'clone':
825825
return (

test/e2e/app-dir/app-static/app-static.test.ts

+34
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,8 @@ createNextDescribe(
840840
"variable-revalidate/authorization.rsc",
841841
"variable-revalidate/authorization/page.js",
842842
"variable-revalidate/authorization/page_client-reference-manifest.js",
843+
"variable-revalidate/authorization/route-cookies/route.js",
844+
"variable-revalidate/authorization/route-request/route.js",
843845
"variable-revalidate/cookie.html",
844846
"variable-revalidate/cookie.rsc",
845847
"variable-revalidate/cookie/page.js",
@@ -2456,6 +2458,38 @@ createNextDescribe(
24562458
}, 'success')
24572459
})
24582460

2461+
it('should skip fetch cache when an authorization header is present after dynamic usage', async () => {
2462+
const initialReq = await next.fetch(
2463+
'/variable-revalidate/authorization/route-cookies'
2464+
)
2465+
const initialJson = await initialReq.json()
2466+
2467+
await retry(async () => {
2468+
const req = await next.fetch(
2469+
'/variable-revalidate/authorization/route-cookies'
2470+
)
2471+
const json = await req.json()
2472+
2473+
expect(json).not.toEqual(initialJson)
2474+
})
2475+
})
2476+
2477+
it('should skip fetch cache when accessing request properties', async () => {
2478+
const initialReq = await next.fetch(
2479+
'/variable-revalidate/authorization/route-request'
2480+
)
2481+
const initialJson = await initialReq.json()
2482+
2483+
await retry(async () => {
2484+
const req = await next.fetch(
2485+
'/variable-revalidate/authorization/route-request'
2486+
)
2487+
const json = await req.json()
2488+
2489+
expect(json).not.toEqual(initialJson)
2490+
})
2491+
})
2492+
24592493
it('should not cache correctly with POST method request init', async () => {
24602494
const res = await fetchViaHTTP(
24612495
next.url,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { cookies } from 'next/headers'
2+
import { NextResponse } from 'next/server'
3+
4+
export async function GET() {
5+
const cookieData = cookies()
6+
7+
const data = await fetch(
8+
'https://next-data-api-endpoint.vercel.app/api/random',
9+
{
10+
headers: {
11+
Authorization: 'Bearer token',
12+
},
13+
}
14+
).then((res) => res.text())
15+
16+
console.log('has cookie data', !!cookieData)
17+
18+
return NextResponse.json({ random: data })
19+
}

0 commit comments

Comments
 (0)