Ensure urlPathname is always a pathname (#63846)

### What?

This modifies the static generation store to instead store a `url`
object with the `pathname` and `search` properties. This corrects the
previous behaviour which used the variable `urlPathname` which had
ambiguous meanings as it technically contained the search string as
well, not just the pathname.

In cases during the app render, this still grabs the contents of
`url.pathname + url.search` (where `url.search` always has a leading `?`
if it has any query parameters, [see the
docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/search)) so
that it emulates the current behaviour. This allows more specific access
though, where now additional parsing can be eliminated which had to
strip the query string off of the `urlPathname` in a few places, and
more worrisome, still accidentally contained the search string causing
errors.

### How?

This requires an upstream fix (#64088) which corrected a bug with the
store access which had caused some previous test failures (accessing
`store.url.pathname` was throwing as `store.url` was undefined on the
wrong return, check the upstream PR for more details on that).

This also changes out usage of `pagePath` with `route`, and lets it be
the fallback (for debugging and error messaging). During static
generation, we will provide a value for the page being rendered that's
correlated to the particular file on the filesystem that the route is
based on:

```
// rendering app/users/[userID]/page.tsx
page: /users/[userID]
pathname: /users/1, /users/2, etc
```

The `route` is used only for debugging, such as when
`generateStaticParams` incorrectly calls `headers()`.

This also moves the pathname from the `staticGenerationStore` into the
`requestStore`, as it's tied to a given request.

Closes NEXT-2965
This commit is contained in:
Wyatt Johnson 2024-06-11 12:49:07 -07:00 committed by GitHub
parent 0fee50ed6e
commit f0e4298f67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 253 additions and 222 deletions

View file

@ -81,7 +81,6 @@ pub async fn get_app_page_entry(
indexmap! {
"VAR_DEFINITION_PAGE" => page.to_string().into(),
"VAR_DEFINITION_PATHNAME" => pathname.clone(),
"VAR_ORIGINAL_PATHNAME" => original_name.clone(),
// TODO(alexkirsz) Support custom global error.
"VAR_MODULE_GLOBAL_ERROR" => "next/dist/client/components/error-boundary".into(),
},

View file

@ -84,8 +84,7 @@ pub async fn get_app_route_entry(
"VAR_DEFINITION_PATHNAME" => pathname.clone(),
"VAR_DEFINITION_FILENAME" => path.file_stem().await?.as_ref().unwrap().as_str().into(),
// TODO(alexkirsz) Is this necessary?
"VAR_DEFINITION_BUNDLE_PATH" => "".into(),
"VAR_ORIGINAL_PATHNAME" => original_name.clone(),
"VAR_DEFINITION_BUNDLE_PATH" => "".to_string().into(),
"VAR_RESOLVED_PAGE_PATH" => path.to_string().await?.clone_value(),
"VAR_USERLAND" => INNER.into(),
},

View file

@ -27,7 +27,6 @@ declare const __next_app_load_chunk__: any
// INJECT:__next_app_require__
// INJECT:__next_app_load_chunk__
export const originalPathname = 'VAR_ORIGINAL_PATHNAME'
export const __next_app__ = {
require: __next_app_require__,
loadChunk: __next_app_load_chunk__,

View file

@ -35,10 +35,8 @@ const routeModule = new AppRouteRouteModule({
const { requestAsyncStorage, staticGenerationAsyncStorage, serverHooks } =
routeModule
const originalPathname = 'VAR_ORIGINAL_PATHNAME'
function patchFetch() {
return _patchFetch({ staticGenerationAsyncStorage })
return _patchFetch({ staticGenerationAsyncStorage, requestAsyncStorage })
}
export {
@ -46,6 +44,5 @@ export {
requestAsyncStorage,
staticGenerationAsyncStorage,
serverHooks,
originalPathname,
patchFetch,
}

View file

@ -1384,9 +1384,8 @@ export async function buildAppStaticPaths({
return StaticGenerationAsyncStorageWrapper.wrap(
ComponentMod.staticGenerationAsyncStorage,
{
urlPathname: page,
page,
renderOpts: {
originalPathname: page,
incrementalCache,
supportsDynamicResponse: true,
isRevalidate: false,

View file

@ -144,7 +144,6 @@ async function createAppRouteCode({
VAR_DEFINITION_FILENAME: fileBaseName,
VAR_DEFINITION_BUNDLE_PATH: bundlePath,
VAR_RESOLVED_PAGE_PATH: resolvedPagePath,
VAR_ORIGINAL_PATHNAME: page,
},
{
nextConfigOutput: JSON.stringify(nextConfigOutput),
@ -772,7 +771,6 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
VAR_DEFINITION_PAGE: page,
VAR_DEFINITION_PATHNAME: pathname,
VAR_MODULE_GLOBAL_ERROR: treeCodeResult.globalError,
VAR_ORIGINAL_PATHNAME: page,
},
{
tree: treeCodeResult.treeCode,

View file

@ -10,6 +10,23 @@ import type { DeepReadonly } from '../../shared/lib/deep-readonly'
import type { AfterContext } from '../../server/after/after-context'
export interface RequestStore {
/**
* The URL of the request. This only specifies the pathname and the search
* part of the URL.
*/
readonly url: {
/**
* The pathname of the requested URL.
*/
readonly pathname: string
/**
* The search part of the requested URL. If the request did not provide a
* search part, this will be an empty string.
*/
readonly search: string
}
readonly headers: ReadonlyHeaders
readonly cookies: ReadonlyRequestCookies
readonly mutableCookies: ResponseCookies

View file

@ -10,8 +10,18 @@ import { staticGenerationAsyncStorage } from './static-generation-async-storage-
export interface StaticGenerationStore {
readonly isStaticGeneration: boolean
readonly pagePath?: string
readonly urlPathname: string
/**
* The page that is being rendered. This relates to the path to the page file.
*/
readonly page: string
/**
* The route that is being rendered. This is the page property without the
* trailing `/page` or `/route` suffix.
*/
readonly route: string
readonly incrementalCache?: IncrementalCache
readonly isOnDemandRevalidate?: boolean
readonly isPrerendering?: boolean

View file

@ -69,8 +69,7 @@ export async function exportAppRoute(
notFoundRoutes: [],
},
renderOpts: {
experimental: experimental,
originalPathname: page,
experimental,
nextExport: true,
supportsDynamicResponse: false,
incrementalCache,

View file

@ -269,7 +269,6 @@ async function exportPageImpl(
fontManifest: optimizeFonts ? requireFontManifest(distDir) : undefined,
locale,
supportsDynamicResponse: false,
originalPathname: page,
experimental: {
...input.renderOpts.experimental,
isRoutePPREnabled,

View file

@ -35,11 +35,11 @@ import { isNotFoundError } from '../../client/components/not-found'
import type { MetadataContext } from './types/resolvers'
export function createMetadataContext(
urlPathname: string,
pathname: string,
renderOpts: AppRenderContext['renderOpts']
): MetadataContext {
return {
pathname: urlPathname.split('?')[0],
pathname,
trailingSlash: renderOpts.trailingSlash,
isStandaloneMode: renderOpts.nextConfigOutput === 'standalone',
}

View file

@ -1,14 +1,6 @@
import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'
export const DUMMY_ORIGIN = 'http://n'
function getUrlWithoutHost(url: string) {
return new URL(url, DUMMY_ORIGIN)
}
export function getPathname(url: string) {
return getUrlWithoutHost(url).pathname
}
const DUMMY_ORIGIN = 'http://n'
export function isFullStringUrl(url: string) {
return /https?:\/\//.test(url)

View file

@ -463,13 +463,14 @@ describe('createAfterContext', () => {
const createMockRequestStore = (afterContext: AfterContext): RequestStore => {
const partialStore: Partial<RequestStore> = {
url: { pathname: '/', search: '' },
afterContext: afterContext,
assetPrefix: '',
reactLoadableManifest: {},
draftMode: undefined,
}
return new Proxy(partialStore, {
return new Proxy(partialStore as RequestStore, {
get(target, key) {
if (key in target) {
return target[key as keyof typeof target]
@ -478,5 +479,5 @@ const createMockRequestStore = (afterContext: AfterContext): RequestStore => {
`RequestStore property not mocked: '${typeof key === 'symbol' ? key.toString() : key}'`
)
},
}) as RequestStore
})
}

View file

@ -143,6 +143,7 @@ function wrapRequestStoreForAfterCallbacks(
requestStore: RequestStore
): RequestStore {
return {
url: requestStore.url,
get headers() {
return requestStore.headers
},

View file

@ -1,7 +1,6 @@
import { getExpectedRequestStore } from '../../client/components/request-async-storage.external'
import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external'
import { StaticGenBailoutError } from '../../client/components/static-generation-bailout'
import { getPathname } from '../../lib/url'
import { markCurrentScopeAsDynamic } from '../app-render/dynamic-rendering'
@ -27,9 +26,8 @@ export function unstable_after<T>(task: AfterTask<T>) {
if (staticGenerationStore) {
if (staticGenerationStore.forceStatic) {
const pathname = getPathname(staticGenerationStore.urlPathname)
throw new StaticGenBailoutError(
`Route ${pathname} with \`dynamic = "force-static"\` couldn't be rendered statically because it used \`${callingExpression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
`Route ${staticGenerationStore.route} with \`dynamic = "force-static"\` couldn't be rendered statically because it used \`${callingExpression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
)
} else {
markCurrentScopeAsDynamic(staticGenerationStore, callingExpression)

View file

@ -70,7 +70,6 @@ import {
import { getSegmentParam } from './get-segment-param'
import { getScriptNonceFromHeader } from './get-script-nonce-from-header'
import { parseAndValidateFlightRouterState } from './parse-and-validate-flight-router-state'
import { validateURL } from './validate-url'
import { createFlightRouterStateFromLoaderTree } from './create-flight-router-state-from-loader-tree'
import { handleAction } from './action-handler'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
@ -116,6 +115,7 @@ import {
import { createServerModuleMap } from './action-utils'
import { isNodeNextRequest } from '../base-http/helpers'
import { parseParameter } from '../../shared/lib/router/utils/route-regex'
import { parseRelativeUrl } from '../../shared/lib/router/utils/parse-relative-url'
export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
@ -319,7 +319,7 @@ async function generateFlight(
},
getDynamicParamFromSegment,
appUsingSizeAdjustment,
staticGenerationStore: { urlPathname },
requestStore: { url },
query,
requestId,
flightRouterState,
@ -329,7 +329,7 @@ async function generateFlight(
const [MetadataTree, MetadataOutlet] = createMetadataComponents({
tree: loaderTree,
query,
metadataContext: createMetadataContext(urlPathname, ctx.renderOpts),
metadataContext: createMetadataContext(url.pathname, ctx.renderOpts),
getDynamicParamFromSegment,
appUsingSizeAdjustment,
createDynamicallyTrackedSearchParams,
@ -441,7 +441,7 @@ async function ReactServerApp({ tree, ctx, asNotFound }: ReactServerAppProps) {
GlobalError,
createDynamicallyTrackedSearchParams,
},
staticGenerationStore: { urlPathname },
requestStore: { url },
} = ctx
const initialTree = createFlightRouterStateFromLoaderTree(
tree,
@ -453,7 +453,7 @@ async function ReactServerApp({ tree, ctx, asNotFound }: ReactServerAppProps) {
tree,
errorType: asNotFound ? 'not-found' : undefined,
query,
metadataContext: createMetadataContext(urlPathname, ctx.renderOpts),
metadataContext: createMetadataContext(url.pathname, ctx.renderOpts),
getDynamicParamFromSegment: getDynamicParamFromSegment,
appUsingSizeAdjustment: appUsingSizeAdjustment,
createDynamicallyTrackedSearchParams,
@ -485,7 +485,7 @@ async function ReactServerApp({ tree, ctx, asNotFound }: ReactServerAppProps) {
<AppRouter
buildId={ctx.renderOpts.buildId}
assetPrefix={ctx.assetPrefix}
initialCanonicalUrl={urlPathname}
initialCanonicalUrl={url.pathname + url.search}
// This is the router state tree.
initialTree={initialTree}
// This is the tree of React nodes that are seeded into the cache
@ -530,14 +530,14 @@ async function ReactServerError({
GlobalError,
createDynamicallyTrackedSearchParams,
},
staticGenerationStore: { urlPathname },
requestStore: { url },
requestId,
res,
} = ctx
const [MetadataTree] = createMetadataComponents({
tree,
metadataContext: createMetadataContext(urlPathname, ctx.renderOpts),
metadataContext: createMetadataContext(url.pathname, ctx.renderOpts),
errorType,
query,
getDynamicParamFromSegment,
@ -579,7 +579,7 @@ async function ReactServerError({
<AppRouter
buildId={ctx.renderOpts.buildId}
assetPrefix={ctx.assetPrefix}
initialCanonicalUrl={urlPathname}
initialCanonicalUrl={url.pathname + url.search}
initialTree={initialTree}
initialHead={head}
initialLayerAssets={null}
@ -1407,7 +1407,7 @@ async function renderToHTMLOrFlightImpl(
])
}
addImplicitTags(staticGenerationStore)
addImplicitTags(staticGenerationStore, requestStore)
if (staticGenerationStore.tags) {
metadata.fetchTags = staticGenerationStore.tags.join(',')
@ -1498,17 +1498,20 @@ export const renderToHTMLOrFlight: AppPageRender = (
query,
renderOpts
) => {
// TODO: this includes query string, should it?
const pathname = validateURL(req.url)
if (!req.url) {
throw new Error('Invalid URL')
}
const url = parseRelativeUrl(req.url, undefined, false)
return RequestAsyncStorageWrapper.wrap(
renderOpts.ComponentMod.requestAsyncStorage,
{ req, res, renderOpts },
{ req, url, res, renderOpts },
(requestStore) =>
StaticGenerationAsyncStorageWrapper.wrap(
renderOpts.ComponentMod.staticGenerationAsyncStorage,
{
urlPathname: pathname,
page: renderOpts.routeModule.definition.page,
renderOpts,
requestEndedState: { ended: false },
},

View file

@ -227,10 +227,7 @@ async function createComponentTreeInternal({
}
if (typeof layoutOrPageMod?.revalidate !== 'undefined') {
validateRevalidate(
layoutOrPageMod?.revalidate,
staticGenerationStore.urlPathname
)
validateRevalidate(layoutOrPageMod?.revalidate, staticGenerationStore.route)
}
if (typeof layoutOrPageMod?.revalidate === 'number') {
@ -537,7 +534,7 @@ async function createComponentTreeInternal({
<Postpone
prerenderState={staticGenerationStore.prerenderState}
reason='dynamic = "force-dynamic" was used'
pathname={staticGenerationStore.urlPathname}
route={staticGenerationStore.route}
/>,
loadingData,
],

View file

@ -26,7 +26,6 @@ import React from 'react'
import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external'
import { DynamicServerError } from '../../client/components/hooks-server-context'
import { StaticGenBailoutError } from '../../client/components/static-generation-bailout'
import { getPathname } from '../../lib/url'
const hasPostpone = typeof React.unstable_postpone === 'function'
@ -86,11 +85,9 @@ export function markCurrentScopeAsDynamic(
// or it's static and it should not throw or postpone here.
if (store.forceDynamic || store.forceStatic) return
const pathname = getPathname(store.urlPathname)
if (store.dynamicShouldError) {
throw new StaticGenBailoutError(
`Route ${pathname} with \`dynamic = "error"\` couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
`Route ${store.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
)
}
@ -101,7 +98,7 @@ export function markCurrentScopeAsDynamic(
// We track that we had a dynamic scope that postponed.
// This will be used by the renderer to decide whether
// the prerender requires a resume
postponeWithTracking(store.prerenderState, expression, pathname)
postponeWithTracking(store.prerenderState, expression, store.route)
}
store.revalidate = 0
@ -109,7 +106,7 @@ export function markCurrentScopeAsDynamic(
if (store.isStaticGeneration) {
// We aren't prerendering but we are generating a static page. We need to bail out of static generation
const err = new DynamicServerError(
`Route ${pathname} couldn't be rendered statically because it used ${expression}. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
`Route ${store.route} couldn't be rendered statically because it used ${expression}. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
)
store.dynamicUsageDescription = expression
store.dynamicUsageStack = err.stack
@ -131,14 +128,13 @@ export function trackDynamicDataAccessed(
store: StaticGenerationStore,
expression: string
): void {
const pathname = getPathname(store.urlPathname)
if (store.isUnstableCacheCallback) {
throw new Error(
`Route ${pathname} used "${expression}" inside a function cached with "unstable_cache(...)". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "${expression}" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache`
`Route ${store.route} used "${expression}" inside a function cached with "unstable_cache(...)". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "${expression}" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache`
)
} else if (store.dynamicShouldError) {
throw new StaticGenBailoutError(
`Route ${pathname} with \`dynamic = "error"\` couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
`Route ${store.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
)
} else if (
// We are in a prerender (PPR enabled, during build)
@ -147,14 +143,14 @@ export function trackDynamicDataAccessed(
// We track that we had a dynamic scope that postponed.
// This will be used by the renderer to decide whether
// the prerender requires a resume
postponeWithTracking(store.prerenderState, expression, pathname)
postponeWithTracking(store.prerenderState, expression, store.route)
} else {
store.revalidate = 0
if (store.isStaticGeneration) {
// We aren't prerendering but we are generating a static page. We need to bail out of static generation
const err = new DynamicServerError(
`Route ${pathname} couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
`Route ${store.route} couldn't be rendered statically because it used \`${expression}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`
)
store.dynamicUsageDescription = expression
store.dynamicUsageStack = err.stack
@ -170,24 +166,24 @@ export function trackDynamicDataAccessed(
type PostponeProps = {
reason: string
prerenderState: PrerenderState
pathname: string
route: string
}
export function Postpone({
reason,
prerenderState,
pathname,
route,
}: PostponeProps): never {
postponeWithTracking(prerenderState, reason, pathname)
postponeWithTracking(prerenderState, reason, route)
}
function postponeWithTracking(
prerenderState: PrerenderState,
expression: string,
pathname: string
route: string
): never {
assertPostpone()
const reason =
`Route ${pathname} needs to bail out of prerendering at this point because it used ${expression}. ` +
`Route ${route} needs to bail out of prerendering at this point because it used ${expression}. ` +
`React throws this special object to indicate where. It should not be caught by ` +
`your own try/catch. Learn more: https://nextjs.org/docs/messages/ppr-caught-error`

View file

@ -44,7 +44,7 @@ function patchCacheScopeSupportIntoReact() {
// patchFetch makes use of APIs such as `React.unstable_postpone` which are only available
// in the experimental channel of React, so export it from here so that it comes from the bundled runtime
function patchFetch() {
return _patchFetch({ staticGenerationAsyncStorage })
return _patchFetch({ staticGenerationAsyncStorage, requestAsyncStorage })
}
export {

View file

@ -143,7 +143,6 @@ export interface RenderOptsPartial {
nextExport?: boolean
nextConfigOutput?: 'standalone' | 'export'
appDirDevErrorLogger?: (err: any) => Promise<void>
originalPathname?: string
isDraftMode?: boolean
deploymentId?: string
onUpdateCookies?: (cookies: string[]) => void

View file

@ -1,18 +0,0 @@
const DUMMY_ORIGIN = 'http://n'
const INVALID_URL_MESSAGE = 'Invalid request URL'
export function validateURL(url: string | undefined): string {
if (!url) {
throw new Error(INVALID_URL_MESSAGE)
}
try {
const parsed = new URL(url, DUMMY_ORIGIN)
// Avoid origin change by extra slashes in pathname
if (parsed.origin !== DUMMY_ORIGIN) {
throw new Error(INVALID_URL_MESSAGE)
}
return url
} catch {
throw new Error(INVALID_URL_MESSAGE)
}
}

View file

@ -40,19 +40,39 @@ function getMutableCookies(
return MutableRequestCookiesAdapter.wrap(cookies, onUpdateCookies)
}
export type WrapperRenderOpts = Omit<RenderOpts, 'experimental'> &
RequestLifecycleOpts &
export type WrapperRenderOpts = RequestLifecycleOpts &
Partial<
Pick<
RenderOpts,
'ComponentMod' // can be undefined in a route handler
| 'ComponentMod'
| 'onUpdateCookies'
| 'assetPrefix'
| 'reactLoadableManifest'
>
> & {
experimental: Pick<RenderOpts['experimental'], 'after'>
previewProps?: __ApiPreviewProps
}
export type RequestContext = {
req: IncomingMessage | BaseNextRequest | NextRequest
/**
* The URL of the request. This only specifies the pathname and the search
* part of the URL. This is only undefined when generating static paths (ie,
* there is no request in progress, nor do we know one).
*/
url: {
/**
* The pathname of the requested URL.
*/
pathname: string
/**
* The search part of the requested URL. If the request did not provide a
* search part, this will be an empty string.
*/
search?: string
}
res?: ServerResponse | BaseNextResponse
renderOpts?: WrapperRenderOpts
}
@ -72,16 +92,9 @@ export const RequestAsyncStorageWrapper: AsyncStorageWrapper<
*/
wrap<Result>(
storage: AsyncLocalStorage<RequestStore>,
{ req, res, renderOpts }: RequestContext,
{ req, url, res, renderOpts }: RequestContext,
callback: (store: RequestStore) => Result
): Result {
let previewProps: __ApiPreviewProps | undefined = undefined
if (renderOpts && 'previewProps' in renderOpts) {
// TODO: investigate why previewProps isn't on RenderOpts
previewProps = (renderOpts as any).previewProps
}
const [wrapWithAfter, afterContext] = createAfterWrapper(renderOpts)
function defaultOnUpdateCookies(cookies: string[]) {
@ -98,6 +111,10 @@ export const RequestAsyncStorageWrapper: AsyncStorageWrapper<
} = {}
const store: RequestStore = {
// Rather than just using the whole `url` here, we pull the parts we want
// to ensure we don't use parts of the URL that we shouldn't. This also
// lets us avoid requiring an empty string for `search` in the type.
url: { pathname: url.pathname, search: url.search ?? '' },
get headers() {
if (!cache.headers) {
// Seal the headers object that'll freeze out any methods that could
@ -154,7 +171,7 @@ export const RequestAsyncStorageWrapper: AsyncStorageWrapper<
get draftMode() {
if (!cache.draftMode) {
cache.draftMode = new DraftModeProvider(
previewProps,
renderOpts?.previewProps,
req,
this.cookies,
this.mutableCookies

View file

@ -4,12 +4,16 @@ import type { AsyncLocalStorage } from 'async_hooks'
import type { IncrementalCache } from '../lib/incremental-cache'
import type { RenderOptsPartial } from '../app-render/types'
import { createPrerenderState } from '../../server/app-render/dynamic-rendering'
import { createPrerenderState } from '../app-render/dynamic-rendering'
import type { FetchMetric } from '../base-http'
import type { RequestLifecycleOpts } from '../base-server'
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
export type StaticGenerationContext = {
urlPathname: string
/**
* The page that is being rendered. This relates to the path to the page file.
*/
page: string
requestEndedState?: { ended?: boolean }
renderOpts: {
incrementalCache?: IncrementalCache
@ -40,7 +44,6 @@ export type StaticGenerationContext = {
// Pull some properties from RenderOptsPartial so that the docs are also
// mirrored.
RenderOptsPartial,
| 'originalPathname'
| 'supportsDynamicResponse'
| 'isRevalidate'
| 'nextExport'
@ -56,7 +59,7 @@ export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper<
> = {
wrap<Result>(
storage: AsyncLocalStorage<StaticGenerationStore>,
{ urlPathname, renderOpts, requestEndedState }: StaticGenerationContext,
{ page, renderOpts, requestEndedState }: StaticGenerationContext,
callback: (store: StaticGenerationStore) => Result
): Result {
/**
@ -88,8 +91,8 @@ export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper<
const store: StaticGenerationStore = {
isStaticGeneration,
urlPathname,
pagePath: renderOpts.originalPathname,
page,
route: normalizeAppPath(page),
incrementalCache:
// we fallback to a global incremental cache for edge-runtime locally
// so that it can access the fs cache without mocks

View file

@ -2340,7 +2340,6 @@ export default abstract class Server<
// it is not a dynamic RSC request then it is a revalidation
// request.
isRevalidate: isSSG && !postponed && !isDynamicRSCRequest,
originalPathname: components.ComponentMod.originalPathname,
serverActions: this.nextConfig.experimental.serverActions,
}
: {}),
@ -2409,7 +2408,6 @@ export default abstract class Server<
experimental: {
after: renderOpts.experimental.after,
},
originalPathname: components.ComponentMod.originalPathname,
supportsDynamicResponse,
incrementalCache,
isRevalidate: isSSG,

View file

@ -15,6 +15,10 @@ import * as Log from '../../build/output/log'
import { markCurrentScopeAsDynamic } from '../app-render/dynamic-rendering'
import type { FetchMetric } from '../base-http'
import { createDedupeFetch } from './dedupe-fetch'
import type {
RequestAsyncStorage,
RequestStore,
} from '../../client/components/request-async-storage.external'
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
@ -34,7 +38,7 @@ function isPatchedFetch(
export function validateRevalidate(
revalidateVal: unknown,
pathname: string
route: string
): undefined | number | false {
try {
let normalizedRevalidate: false | number | undefined = undefined
@ -49,7 +53,7 @@ export function validateRevalidate(
normalizedRevalidate = revalidateVal
} else if (typeof revalidateVal !== 'undefined') {
throw new Error(
`Invalid revalidate value "${revalidateVal}" on "${pathname}", must be a non-negative number or "false"`
`Invalid revalidate value "${revalidateVal}" on "${route}", must be a non-negative number or "false"`
)
}
return normalizedRevalidate
@ -127,35 +131,35 @@ const getDerivedTags = (pathname: string): string[] => {
return derivedTags
}
export function addImplicitTags(staticGenerationStore: StaticGenerationStore) {
export function addImplicitTags(
staticGenerationStore: StaticGenerationStore,
requestStore: RequestStore | undefined
) {
const newTags: string[] = []
const { pagePath, urlPathname } = staticGenerationStore
const { page } = staticGenerationStore
if (!Array.isArray(staticGenerationStore.tags)) {
staticGenerationStore.tags = []
}
// Ini the tags array if it doesn't exist.
staticGenerationStore.tags ??= []
if (pagePath) {
const derivedTags = getDerivedTags(pagePath)
for (let tag of derivedTags) {
tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`
if (!staticGenerationStore.tags?.includes(tag)) {
staticGenerationStore.tags.push(tag)
}
newTags.push(tag)
}
}
if (urlPathname) {
const parsedPathname = new URL(urlPathname, 'http://n').pathname
const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${parsedPathname}`
// Add the derived tags from the page.
const derivedTags = getDerivedTags(page)
for (let tag of derivedTags) {
tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}`
if (!staticGenerationStore.tags?.includes(tag)) {
staticGenerationStore.tags.push(tag)
}
newTags.push(tag)
}
// Add the tags from the pathname.
if (requestStore?.url.pathname) {
const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${requestStore.url.pathname}`
if (!staticGenerationStore.tags?.includes(tag)) {
staticGenerationStore.tags.push(tag)
}
newTags.push(tag)
}
return newTags
}
@ -210,11 +214,12 @@ function trackFetchMetric(
interface PatchableModule {
staticGenerationAsyncStorage: StaticGenerationAsyncStorage
requestAsyncStorage: RequestAsyncStorage
}
function createPatchedFetcher(
originFetch: Fetcher,
{ staticGenerationAsyncStorage }: PatchableModule
{ staticGenerationAsyncStorage, requestAsyncStorage }: PatchableModule
): PatchedFetcher {
// Create the patched fetch function. We don't set the type here, as it's
// verified as the return value of this function.
@ -260,6 +265,7 @@ function createPatchedFetcher(
}
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
const requestStore = requestAsyncStorage.getStore()
// If the staticGenerationStore is not available, we can't do any
// special treatment of fetch, therefore fallback to the original
@ -311,7 +317,10 @@ function createPatchedFetcher(
}
}
}
const implicitTags = addImplicitTags(staticGenerationStore)
const implicitTags = addImplicitTags(
staticGenerationStore,
requestStore
)
const pageFetchCacheMode = staticGenerationStore.fetchCache
const isUsingNoStore = !!staticGenerationStore.isUnstableNoStore
@ -327,7 +336,7 @@ function createPatchedFetcher(
// we only want to warn if the user is explicitly setting a cache value
if (!(isRequestInput && currentFetchCacheConfig === 'default')) {
Log.warn(
`fetch for ${fetchUrl} on ${staticGenerationStore.urlPathname} specified "cache: ${currentFetchCacheConfig}" and "revalidate: ${currentFetchRevalidate}", only one should be specified.`
`fetch for ${fetchUrl} on ${staticGenerationStore.route} specified "cache: ${currentFetchCacheConfig}" and "revalidate: ${currentFetchRevalidate}", only one should be specified.`
)
}
currentFetchCacheConfig = undefined
@ -359,7 +368,7 @@ function createPatchedFetcher(
finalRevalidate = validateRevalidate(
currentFetchRevalidate,
staticGenerationStore.urlPathname
staticGenerationStore.route
)
const _headers = getRequestMeta('headers')
@ -484,11 +493,7 @@ function createPatchedFetcher(
if (finalRevalidate === 0) {
markCurrentScopeAsDynamic(
staticGenerationStore,
`revalidate: 0 fetch ${input}${
staticGenerationStore.urlPathname
? ` ${staticGenerationStore.urlPathname}`
: ''
}`
`revalidate: 0 fetch ${input} ${staticGenerationStore.route}`
)
}
@ -711,11 +716,7 @@ function createPatchedFetcher(
// If enabled, we should bail out of static generation.
markCurrentScopeAsDynamic(
staticGenerationStore,
`no-store fetch ${input}${
staticGenerationStore.urlPathname
? ` ${staticGenerationStore.urlPathname}`
: ''
}`
`no-store fetch ${input} ${staticGenerationStore.route}`
)
}
@ -731,11 +732,7 @@ function createPatchedFetcher(
// If enabled, we should bail out of static generation.
markCurrentScopeAsDynamic(
staticGenerationStore,
`revalidate: 0 fetch ${input}${
staticGenerationStore.urlPathname
? ` ${staticGenerationStore.urlPathname}`
: ''
}`
`revalidate: 0 fetch ${input} ${staticGenerationStore.route}`
)
}

View file

@ -64,7 +64,7 @@ export type LoadComponentsReturnType<NextModule = any> = {
getStaticPaths?: GetStaticPaths
getServerSideProps?: GetServerSideProps
ComponentMod: NextModule
routeModule?: RouteModule
routeModule: RouteModule
isAppPath?: boolean
page: string
}

View file

@ -40,7 +40,7 @@ export type LoadComponentsReturnType = {
getStaticPaths?: GetStaticPaths
getServerSideProps?: GetServerSideProps
ComponentMod: any
routeModule?: RouteModule
routeModule: RouteModule
isAppPath?: boolean
page: string
}

View file

@ -257,19 +257,18 @@ export class AppRouteRouteModule extends RouteModule<
// Get the context for the request.
const requestContext: RequestContext = {
req: rawRequest,
}
requestContext.renderOpts = {
// @ts-expect-error TODO: types for renderOpts should include previewProps
previewProps: context.prerenderManifest.preview,
waitUntil: context.renderOpts.waitUntil,
onClose: context.renderOpts.onClose,
experimental: context.renderOpts.experimental,
url: rawRequest.nextUrl,
renderOpts: {
previewProps: context.prerenderManifest.preview,
waitUntil: context.renderOpts.waitUntil,
onClose: context.renderOpts.onClose,
experimental: context.renderOpts.experimental,
},
}
// Get the context for the static generation.
const staticGenerationContext: StaticGenerationContext = {
urlPathname: rawRequest.nextUrl.pathname,
page: this.definition.page,
renderOpts: context.renderOpts,
}
@ -288,7 +287,7 @@ export class AppRouteRouteModule extends RouteModule<
RequestAsyncStorageWrapper.wrap(
this.requestAsyncStorage,
requestContext,
() =>
(requestStore) =>
StaticGenerationAsyncStorageWrapper.wrap(
this.staticGenerationAsyncStorage,
staticGenerationContext,
@ -375,6 +374,7 @@ export class AppRouteRouteModule extends RouteModule<
patchFetch({
staticGenerationAsyncStorage:
this.staticGenerationAsyncStorage,
requestAsyncStorage: this.requestAsyncStorage,
})
const res = await handler(request, {
params: context.params
@ -398,14 +398,13 @@ export class AppRouteRouteModule extends RouteModule<
),
])
addImplicitTags(staticGenerationStore)
addImplicitTags(staticGenerationStore, requestStore)
;(context.renderOpts as any).fetchTags =
staticGenerationStore.tags?.join(',')
// It's possible cookies were set in the handler, so we need
// to merge the modified cookies and the returned response
// here.
const requestStore = this.requestAsyncStorage.getStore()
if (requestStore && requestStore.mutableCookies) {
const headers = new Headers(res.headers)
if (

View file

@ -240,20 +240,22 @@ export async function adapter(
},
async () => {
try {
const previewProps = prerenderManifest?.preview || {
previewModeId: 'development-id',
previewModeEncryptionKey: '',
previewModeSigningKey: '',
}
return await RequestAsyncStorageWrapper.wrap(
requestAsyncStorage,
{
req: request,
url: request.nextUrl,
renderOpts: {
onUpdateCookies: (cookies) => {
cookiesFromResponse = cookies
},
// @ts-expect-error TODO: investigate why previewProps isn't on RenderOpts
previewProps: prerenderManifest?.preview || {
previewModeId: 'development-id',
previewModeEncryptionKey: '',
previewModeSigningKey: '',
},
previewProps,
waitUntil,
onClose: closeController
? closeController.onClose.bind(closeController)

View file

@ -4,7 +4,6 @@ import {
NEXT_CACHE_IMPLICIT_TAG_ID,
NEXT_CACHE_SOFT_TAG_MAX_LENGTH,
} from '../../../lib/constants'
import { getPathname } from '../../../lib/url'
import { staticGenerationAsyncStorage } from '../../../client/components/static-generation-async-storage.external'
/**
@ -51,9 +50,7 @@ function revalidate(tag: string, expression: string) {
if (store.isUnstableCacheCallback) {
throw new Error(
`Route ${getPathname(
store.urlPathname
)} used "${expression}" inside a function cached with "unstable_cache(...)" which is unsupported. To ensure revalidation is performed consistently it must always happen outside of renders and cached functions. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
`Route ${store.route} used "${expression}" inside a function cached with "unstable_cache(...)" which is unsupported. To ensure revalidation is performed consistently it must always happen outside of renders and cached functions. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`
)
}

View file

@ -7,6 +7,7 @@ import {
validateTags,
} from '../../lib/patch-fetch'
import { staticGenerationAsyncStorage } from '../../../client/components/static-generation-async-storage.external'
import { requestAsyncStorage } from '../../../client/components/request-async-storage.external'
type Callback = (...args: any[]) => Promise<any>
@ -90,13 +91,15 @@ export function unstable_cache<T extends Callback>(
}`
const cachedCb = async (...args: any[]) => {
const store = staticGenerationAsyncStorage.getStore()
const staticGenerationStore = staticGenerationAsyncStorage.getStore()
const requestStore = requestAsyncStorage.getStore()
// We must be able to find the incremental cache otherwise we throw
const maybeIncrementalCache:
| import('../../lib/incremental-cache').IncrementalCache
| undefined =
store?.incrementalCache || (globalThis as any).__incrementalCache
staticGenerationStore?.incrementalCache ||
(globalThis as any).__incrementalCache
if (!maybeIncrementalCache) {
throw new Error(
@ -105,10 +108,14 @@ export function unstable_cache<T extends Callback>(
}
const incrementalCache = maybeIncrementalCache
const { pathname, searchParams } = new URL(
store?.urlPathname || '/',
'http://n'
)
// If there's no request store, we aren't in a request (or we're not in app
// router) and if there's no static generation store, we aren't in app
// router. Default to an empty pathname and search params when there's no
// request store or static generation store available.
const pathname =
requestStore?.url.pathname ?? staticGenerationStore?.route ?? ''
const searchParams = new URLSearchParams(requestStore?.url.search ?? '')
const sortedSearchKeys = [...searchParams.keys()].sort((a, b) => {
return a.localeCompare(b)
})
@ -123,10 +130,13 @@ export function unstable_cache<T extends Callback>(
const cacheKey = await incrementalCache.fetchCacheKey(invocationKey)
// $urlWithPath,$sortedQueryStringKeys,$hashOfEveryThingElse
const fetchUrl = `unstable_cache ${pathname}${sortedSearch.length ? '?' : ''}${sortedSearch} ${cb.name ? ` ${cb.name}` : cacheKey}`
const fetchIdx = (store ? store.nextFetchId : noStoreFetchIdx) ?? 1
const fetchIdx =
(staticGenerationStore
? staticGenerationStore.nextFetchId
: noStoreFetchIdx) ?? 1
if (store) {
store.nextFetchId = fetchIdx + 1
if (staticGenerationStore) {
staticGenerationStore.nextFetchId = fetchIdx + 1
// We are in an App Router context. We try to return the cached entry if it exists and is valid
// If the entry is fresh we return it. If the entry is stale we return it but revalidate the entry in
@ -135,43 +145,43 @@ export function unstable_cache<T extends Callback>(
// We update the store's revalidate property if the option.revalidate is a higher precedence
if (typeof options.revalidate === 'number') {
if (
typeof store.revalidate === 'number' &&
store.revalidate < options.revalidate
typeof staticGenerationStore.revalidate === 'number' &&
staticGenerationStore.revalidate < options.revalidate
) {
// The store is already revalidating on a shorter time interval, leave it alone
} else {
store.revalidate = options.revalidate
staticGenerationStore.revalidate = options.revalidate
}
} else if (
options.revalidate === false &&
typeof store.revalidate === 'undefined'
typeof staticGenerationStore.revalidate === 'undefined'
) {
// The store has not defined revalidate type so we can use the false option
store.revalidate = options.revalidate
staticGenerationStore.revalidate = options.revalidate
}
// We need to accumulate the tags for this invocation within the store
if (!store.tags) {
store.tags = tags.slice()
if (!staticGenerationStore.tags) {
staticGenerationStore.tags = tags.slice()
} else {
for (const tag of tags) {
// @TODO refactor tags to be a set to avoid this O(n) lookup
if (!store.tags.includes(tag)) {
store.tags.push(tag)
if (!staticGenerationStore.tags.includes(tag)) {
staticGenerationStore.tags.push(tag)
}
}
}
// @TODO check on this API. addImplicitTags mutates the store and returns the implicit tags. The naming
// of this function is potentially a little confusing
const implicitTags = addImplicitTags(store)
const implicitTags = addImplicitTags(staticGenerationStore, requestStore)
if (
// when we are nested inside of other unstable_cache's
// we should bypass cache similar to fetches
store.fetchCache !== 'force-no-store' &&
!store.isOnDemandRevalidate &&
staticGenerationStore.fetchCache !== 'force-no-store' &&
!staticGenerationStore.isOnDemandRevalidate &&
!incrementalCache.isOnDemandRevalidate &&
!store.isDraftMode
!staticGenerationStore.isDraftMode
) {
// We attempt to get the current cache entry from the incremental cache.
const cacheEntry = await incrementalCache.get(cacheKey, {
@ -203,15 +213,15 @@ export function unstable_cache<T extends Callback>(
: undefined
if (cacheEntry.isStale) {
// In App Router we return the stale result and revalidate in the background
if (!store.pendingRevalidates) {
store.pendingRevalidates = {}
if (!staticGenerationStore.pendingRevalidates) {
staticGenerationStore.pendingRevalidates = {}
}
// We run the cache function asynchronously and save the result when it completes
store.pendingRevalidates[invocationKey] =
staticGenerationStore.pendingRevalidates[invocationKey] =
staticGenerationAsyncStorage
.run(
{
...store,
...staticGenerationStore,
// force any nested fetches to bypass cache so they revalidate
// when the unstable_cache call is revalidated
fetchCache: 'force-no-store',
@ -248,7 +258,7 @@ export function unstable_cache<T extends Callback>(
// If we got this far then we had an invalid cache entry and need to generate a new one
const result = await staticGenerationAsyncStorage.run(
{
...store,
...staticGenerationStore,
// force any nested fetches to bypass cache so they revalidate
// when the unstable_cache call is revalidated
fetchCache: 'force-no-store',
@ -279,7 +289,9 @@ export function unstable_cache<T extends Callback>(
// @TODO check on this API. addImplicitTags mutates the store and returns the implicit tags. The naming
// of this function is potentially a little confusing
const implicitTags = store && addImplicitTags(store)
const implicitTags =
staticGenerationStore &&
addImplicitTags(staticGenerationStore, requestStore)
const cacheEntry = await incrementalCache.get(cacheKey, {
kindHint: 'fetch',
@ -325,7 +337,8 @@ export function unstable_cache<T extends Callback>(
// when the unstable_cache call is revalidated
fetchCache: 'force-no-store',
isUnstableCacheCallback: true,
urlPathname: '/',
route: '/',
page: '/',
isStaticGeneration: false,
prerenderState: null,
},

View file

@ -0,0 +1,20 @@
import { parseRelativeUrl } from './parse-relative-url'
describe('relative urls', () => {
it('should return valid pathname', () => {
expect(parseRelativeUrl('/').pathname).toBe('/')
expect(parseRelativeUrl('/abc').pathname).toBe('/abc')
})
it('should throw for invalid pathname', () => {
expect(() => parseRelativeUrl('//**y/\\')).toThrow()
expect(() => parseRelativeUrl('//google.com')).toThrow()
})
})
describe('query parsing', () => {
it('should parse query string', () => {
expect(parseRelativeUrl('/?a=1&b=2').query).toEqual({ a: '1', b: '2' })
expect(parseRelativeUrl('/').query).toEqual({})
})
})

View file

@ -18,8 +18,19 @@ export interface ParsedRelativeUrl {
*/
export function parseRelativeUrl(
url: string,
base?: string
): ParsedRelativeUrl {
base?: string,
parseQuery?: true
): ParsedRelativeUrl
export function parseRelativeUrl(
url: string,
base: string | undefined,
parseQuery: false
): Omit<ParsedRelativeUrl, 'query'>
export function parseRelativeUrl(
url: string,
base?: string,
parseQuery = true
): ParsedRelativeUrl | Omit<ParsedRelativeUrl, 'query'> {
const globalBase = new URL(
typeof window === 'undefined' ? 'http://n' : getLocationOrigin()
)
@ -36,14 +47,16 @@ export function parseRelativeUrl(
url,
resolvedBase
)
if (origin !== globalBase.origin) {
throw new Error(`invariant: invalid relative URL, router received ${url}`)
}
return {
pathname,
query: searchParamsToUrlQuery(searchParams),
query: parseQuery ? searchParamsToUrlQuery(searchParams) : undefined,
search,
hash,
href: href.slice(globalBase.origin.length),
href: href.slice(origin.length),
}
}

View file

@ -5,7 +5,7 @@ import { matchHas, prepareDestination } from './prepare-destination'
import { removeTrailingSlash } from './remove-trailing-slash'
import { normalizeLocalePath } from '../../i18n/normalize-locale-path'
import { removeBasePath } from '../../../../client/remove-base-path'
import { parseRelativeUrl } from './parse-relative-url'
import { parseRelativeUrl, type ParsedRelativeUrl } from './parse-relative-url'
export default function resolveRewrites(
asPath: string,
@ -20,7 +20,7 @@ export default function resolveRewrites(
locales?: string[]
): {
matchedPage: boolean
parsedAs: ReturnType<typeof parseRelativeUrl>
parsedAs: ParsedRelativeUrl
asPath: string
resolvedHref?: string
externalDest?: boolean

View file

@ -1,13 +0,0 @@
import { validateURL } from 'next/dist/server/app-render/validate-url'
describe('validateUrl', () => {
it('should return valid pathname', () => {
expect(validateURL('/')).toBe('/')
expect(validateURL('/abc')).toBe('/abc')
})
it('should throw for invalid pathname', () => {
expect(() => validateURL('//**y/\\')).toThrow()
expect(() => validateURL('//google.com')).toThrow()
})
})