From 52f9320accfd82b1305e57f679ba0877ce4d9cce Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 24 Sep 2024 14:23:59 -0700 Subject: [PATCH 01/14] Implement `cookies()` as an API that returns an exotic promise --- packages/next/headers.d.ts | 1 + packages/next/headers.js | 1 + packages/next/src/api/headers.ts | 1 + .../server/app-render/dynamic-rendering.ts | 114 +++- .../prerender-async-storage.external.ts | 4 + .../src/server/dynamic-rendering-utils.ts | 12 + packages/next/src/server/request/cookies.ts | 544 ++++++++++++++++++ packages/next/src/server/request/headers.ts | 34 +- packages/next/src/server/request/utils.ts | 46 ++ .../adapters/request-cookies.ts | 3 + .../actions/app/redirect-target/page.js | 2 +- .../app-dir/dynamic-data/dynamic-data.test.ts | 57 +- .../fixtures/main/app/client-page/page.js | 5 +- .../fixtures/main/app/force-dynamic/page.js | 33 +- .../fixtures/main/app/force-static/page.js | 33 +- .../fixtures/main/app/getSentinelValue.tsx | 22 + .../dynamic-data/fixtures/main/app/layout.js | 5 +- .../fixtures/main/app/setenv/route.js | 4 - .../fixtures/main/app/top-level/page.js | 33 +- .../cases/dynamic_api_cookies_async/page.tsx | 28 + .../dynamic_api_cookies_boundary/page.tsx | 2 +- .../cases/dynamic_api_cookies_root/page.tsx | 2 +- .../app/cookies/exercise/async/page.tsx | 20 + .../app/cookies/exercise/commponents.tsx | 271 +++++++++ .../app/cookies/exercise/sync/page.tsx | 19 + .../static-behavior/async_boundary/page.tsx | 38 ++ .../static-behavior/async_root/page.tsx | 25 + .../static-behavior/pass-deeply/page.tsx | 66 +++ .../static-behavior/sync_boundary/page.tsx | 40 ++ .../static-behavior/sync_root/page.tsx | 27 + .../e2e/app-dir/dynamic-io/dynamic-io.test.ts | 275 ++++++++- test/e2e/app-dir/dynamic-io/middleware.ts | 16 + 32 files changed, 1641 insertions(+), 142 deletions(-) create mode 100644 packages/next/src/server/dynamic-rendering-utils.ts create mode 100644 packages/next/src/server/request/cookies.ts create mode 100644 packages/next/src/server/request/utils.ts create mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/getSentinelValue.tsx delete mode 100644 test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_async/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/exercise/commponents.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/pass-deeply/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_root/page.tsx diff --git a/packages/next/headers.d.ts b/packages/next/headers.d.ts index f48ca4e26c287..505b866a1bd76 100644 --- a/packages/next/headers.d.ts +++ b/packages/next/headers.d.ts @@ -1 +1,2 @@ export * from './dist/server/request/headers' +export * from './dist/server/request/cookies' diff --git a/packages/next/headers.js b/packages/next/headers.js index 3fc596b76c475..0eab170e989bd 100644 --- a/packages/next/headers.js +++ b/packages/next/headers.js @@ -1 +1,2 @@ module.exports = require('./dist/server/request/headers') +module.exports.cookies = require('./dist/server/request/cookies').cookies diff --git a/packages/next/src/api/headers.ts b/packages/next/src/api/headers.ts index 80f57fa8f5f8d..5580811dd44e4 100644 --- a/packages/next/src/api/headers.ts +++ b/packages/next/src/api/headers.ts @@ -1 +1,2 @@ export * from '../server/request/headers' +export * from '../server/request/cookies' diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index 76cb82c0dab96..f521974530cc6 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -27,7 +27,10 @@ import React from 'react' import { DynamicServerError } from '../../client/components/hooks-server-context' import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' -import { prerenderAsyncStorage } from './prerender-async-storage.external' +import { + prerenderAsyncStorage, + type PrerenderStore, +} from './prerender-async-storage.external' const hasPostpone = typeof React.unstable_postpone === 'function' @@ -107,7 +110,7 @@ export function markCurrentScopeAsDynamic( // current render because something dynamic is being used. // This won't throw so we still need to fall through to determine if/how we handle // this specific dynamic request. - abortRSCRender(prerenderStore.controller, store.route, expression) + abortRender(prerenderStore.controller, store.route, expression) errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else if (prerenderStore.cacheSignal) { // we're prerendering with dynamicIO but we don't want to eagerly abort this @@ -116,9 +119,9 @@ export function markCurrentScopeAsDynamic( errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else { postponeWithTracking( - prerenderStore.dynamicTracking, store.route, - expression + expression, + prerenderStore.dynamicTracking ) } } else { @@ -152,7 +155,7 @@ export function trackFallbackParamAccessed( const prerenderStore = prerenderAsyncStorage.getStore() if (!prerenderStore) return - postponeWithTracking(prerenderStore.dynamicTracking, store.route, expression) + postponeWithTracking(store.route, expression, prerenderStore.dynamicTracking) } /** @@ -185,7 +188,7 @@ export function trackDynamicDataAccessed( // current render because something dynamic is being used. // This won't throw so we still need to fall through to determine if/how we handle // this specific dynamic request. - abortRSCRender(prerenderStore.controller, store.route, expression) + abortRender(prerenderStore.controller, store.route, expression) errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else if (prerenderStore.cacheSignal) { // we're prerendering with dynamicIO but we don't want to eagerly abort this @@ -194,9 +197,9 @@ export function trackDynamicDataAccessed( errorWithTracking(prerenderStore.dynamicTracking, store.route, expression) } else { postponeWithTracking( - prerenderStore.dynamicTracking, store.route, - expression + expression, + prerenderStore.dynamicTracking ) } } else { @@ -215,6 +218,90 @@ export function trackDynamicDataAccessed( } } +/** + * This function is meant to be used when prerendering without dynamicIO or PPR. + * When called during a build it will cause Next.js to consider the route as dynamic. + * + * @internal + */ +export function throwToInterruptStaticGeneration( + expression: string, + store: StaticGenerationStore +): never { + store.revalidate = 0 + + // We aren't prerendering but we are generating a static page. We need to bail out of static generation + const err = new DynamicServerError( + `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 + + throw err +} + +/** + * This function should be used to track whether something dynamic happened even when + * we are in a dynamic render. This is useful for Dev where all renders are dynamic but + * we still track whether dynamic APIs were accessed for helpful messaging + * + * @internal + */ +export function trackDynamicDataInDynamicRender(store: StaticGenerationStore) { + store.revalidate = 0 +} + +// Despite it's name we don't actually abort unless we have a controller to call abort on +// There are times when we let a prerender run long to discover caches where we want the semantics +// of tracking dynamic access without terminating the prerender early +function abortOnSynchronousDynamicDataAccess( + route: string, + expression: string, + prerenderStore: PrerenderStore +): void { + const reason = `Route ${route} needs to bail out of prerendering at this point because it used ${expression}.` + + const error = createPrerenderInterruptedError(reason) + + if (prerenderStore.controller) { + prerenderStore.controller.abort(error) + } + + const dynamicTracking = prerenderStore.dynamicTracking + if (dynamicTracking) { + dynamicTracking.dynamicAccesses.push({ + // When we aren't debugging, we don't need to create another error for the + // stack trace. + stack: dynamicTracking.isDebugDynamicAccesses + ? new Error().stack + : undefined, + expression, + }) + } +} + +/** + * use this function when prerendering with dynamicIO. If we are doing a + * prospective prerender we don't actually abort because we want to discover + * all caches for the shell. If this is the actual prerender we do abort. + * + * This function accepts a prerenderStore but the caller should ensure we're + * actually running in dynamicIO mode. + * + * + * @internal + */ +export function abortAndThrowOnSynchronousDynamicDataAccess( + route: string, + expression: string, + prerenderStore: PrerenderStore +): never { + abortOnSynchronousDynamicDataAccess(route, expression, prerenderStore) + throw new Error( + `Route ${route} needs to bail out of prerendering at this point because it used ${expression}.` + ) +} + /** * This component will call `React.postpone` that throws the postponed error. */ @@ -225,7 +312,7 @@ type PostponeProps = { export function Postpone({ reason, route }: PostponeProps): never { const prerenderStore = prerenderAsyncStorage.getStore() const dynamicTracking = prerenderStore?.dynamicTracking || null - postponeWithTracking(dynamicTracking, route, reason) + postponeWithTracking(route, reason, dynamicTracking) } function errorWithTracking( @@ -251,11 +338,12 @@ function errorWithTracking( throw createPrerenderInterruptedError(reason) } -function postponeWithTracking( - dynamicTracking: null | DynamicTrackingState, +export function postponeWithTracking( route: string, - expression: string + expression: string, + dynamicTracking: null | DynamicTrackingState ): never { + console.log('postponeWithTracking', Error().stack) assertPostpone() if (dynamicTracking) { dynamicTracking.dynamicAccesses.push({ @@ -323,7 +411,7 @@ export function isPrerenderInterruptedError(error: unknown) { ) } -function abortRSCRender( +function abortRender( controller: AbortController, route: string, expression: string diff --git a/packages/next/src/server/app-render/prerender-async-storage.external.ts b/packages/next/src/server/app-render/prerender-async-storage.external.ts index 3e6e2cf946431..14f5d9bb2cab8 100644 --- a/packages/next/src/server/app-render/prerender-async-storage.external.ts +++ b/packages/next/src/server/app-render/prerender-async-storage.external.ts @@ -35,5 +35,9 @@ export type PrerenderStore = { readonly dynamicTracking: null | DynamicTrackingState } +export function isDynamicIOPrerender(prerenderStore: PrerenderStore): boolean { + return !!(prerenderStore.controller || prerenderStore.cacheSignal) +} + export type PrerenderAsyncStorage = AsyncLocalStorage export { prerenderAsyncStorage } diff --git a/packages/next/src/server/dynamic-rendering-utils.ts b/packages/next/src/server/dynamic-rendering-utils.ts new file mode 100644 index 0000000000000..fd333d3a2bdd7 --- /dev/null +++ b/packages/next/src/server/dynamic-rendering-utils.ts @@ -0,0 +1,12 @@ +function hangForever() {} + +/** + * This function constructs a promise that will never resolve. This is primarily + * useful for dynamicIO where we use promise resolution timing to determine which + * parts of a render can be included in a prerender. + * + * @internal + */ +export function makeHangingPromise(): Promise { + return new Promise(hangForever) +} diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts new file mode 100644 index 0000000000000..2ed9a152cd4ef --- /dev/null +++ b/packages/next/src/server/request/cookies.ts @@ -0,0 +1,544 @@ +import { + type ReadonlyRequestCookies, + type ResponseCookies, + RequestCookiesAdapter, +} from '../../server/web/spec-extension/adapters/request-cookies' +import { RequestCookies } from '../../server/web/spec-extension/cookies' +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { + postponeWithTracking, + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + trackDynamicDataInDynamicRender, +} from '../../server/app-render/dynamic-rendering' +import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' +import { actionAsyncStorage } from '../../client/components/action-async-storage.external' +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' +import { makeResolvedReactPromise } from './utils' +import { makeHangingPromise } from '../dynamic-rendering-utils' + +/** + * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object + * synchronously to facilitate migration. The `UnsafeUnwrappedCookies` type is added to your code by a codemod that attempts to automatically + * updates callsites to reflect the new Promise return type. There are some cases where `cookies()` cannot be automatically converted, namely + * when it is used inside a synchronous function and we can't be sure the function can be made async automatically. In these cases we add an + * explicit type case to `UnsafeUnwrappedCookies` to enable typescript to allow for the synchronous usage only where it is actually necessary. + * + * You should should update these callsites to either be async functions where the `cookies()` value can be awaited or you should call `cookies()` + * from outside and await the return value before passing it into this function. + * + * You can find instances that require manual migration by searching for `UnsafeUnwrappedCookies` in your codebase or by search for a comment that + * starts with: + * + * ``` + * // TODO [sync-cookies-usage] + * ``` + * In a future version of Next.js `cookies()` will only return a Promise and you will not be able to access the underlying cookies object directly + * without awaiting the return value first. When this change happens the type `UnsafeUnwrappedCookies` will be updated to reflect that is it no longer + * usable. + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedCookies = ReadonlyRequestCookies + +export function cookies(): Promise { + const callingExpression = 'cookies' + const requestStore = getExpectedRequestStore(callingExpression) + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + const prerenderStore = prerenderAsyncStorage.getStore() + + if (staticGenerationStore) { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // cookies object without tracking + const underlyingCookies = createEmptyCookies() + return makeUntrackedExoticCookies(underlyingCookies) + } + + if (staticGenerationStore.isUnstableCacheCallback) { + throw new Error( + `Route ${staticGenerationStore.route} used "cookies" 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 "cookies" 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 (staticGenerationStore.dynamicShouldError) { + throw new StaticGenBailoutError( + `Route ${staticGenerationStore.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`cookies\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) + } + + if (prerenderStore) { + // We are in PPR and/or dynamicIO mode and prerendering + + if (isDynamicIOPrerender(prerenderStore)) { + // We use the controller and cacheSignal as an indication we are in dynamicIO mode. + // When resolving cookies for a prerender with dynamic IO we return a forever promise + // along with property access tracked synchronous cookies. + + // We don't track dynamic access here because access will be tracked when you access + // one of the properties of the cookies object. + return makeDynamicallyTrackedExoticCookies( + staticGenerationStore.route, + prerenderStore + ) + } else { + // We are prerendering with PPR. We need track dynamic access here eagerly + // to keep continuity with how cookies has worked in PPR without dynamicIO. + // TODO consider switching the semantic to throw on property access instead + postponeWithTracking( + staticGenerationStore.route, + callingExpression, + prerenderStore.dynamicTracking + ) + } + } else if (staticGenerationStore.isStaticGeneration) { + // We are in a legacy static generation mode while prerendering + // We track dynamic access here so we don't need to wrap the cookies in + // individual property access tracking. + throwToInterruptStaticGeneration(callingExpression, staticGenerationStore) + } + // We fall through to the dynamic context below but we still track dynamic access + // because in dev we can still error for things like using cookies inside a cache context + trackDynamicDataInDynamicRender(staticGenerationStore) + } + + // cookies is being called in a dynamic context + const actionStore = actionAsyncStorage.getStore() + + let underlyingCookies: ReadonlyRequestCookies + + // The current implementation of cookies will return Response cookies + // for a server action during the render phase of a server action. + // This is not correct b/c the type of cookies during render is ReadOnlyRequestCookies + // where as the type of cookies during action is ResponseCookies + // This was found because RequestCookies is iterable and ResponseCookies is not + if (actionStore?.isAction || actionStore?.isAppRoute) { + // We can't conditionally return different types here based on the context. + // To avoid confusion, we always return the readonly type here. + underlyingCookies = + requestStore.mutableCookies as unknown as ReadonlyRequestCookies + } else { + underlyingCookies = requestStore.cookies + } + + if (process.env.NODE_ENV === 'development') { + return makeUntrackedExoticCookiesWithDevWarnings( + underlyingCookies, + staticGenerationStore?.route + ) + } else { + return makeUntrackedExoticCookies(underlyingCookies) + } +} + +function createEmptyCookies(): ReadonlyRequestCookies { + return RequestCookiesAdapter.seal(new RequestCookies(new Headers({}))) +} + +interface CacheLifetime {} +const CachedCookies = new WeakMap< + CacheLifetime, + Promise +>() + +function makeDynamicallyTrackedExoticCookies( + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedPromise = CachedCookies.get(prerenderStore) + if (cachedPromise) { + return cachedPromise + } + + const promise = makeHangingPromise() + CachedCookies.set(prerenderStore, promise) + + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: function () { + const expression = 'cookies()[Symbol.iterator]()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + size: { + get() { + const expression = `cookies().size` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + get: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().get()' + } else { + expression = `cookies().get(${describeNameArg(arguments[0])})` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + getAll: { + value: function getAll() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().getAll()` + } else { + expression = `cookies().getAll(${describeNameArg(arguments[0])})` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + has: { + value: function has() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().has()` + } else { + expression = `cookies().has(${describeNameArg(arguments[0])})` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + set: { + value: function set() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().set()' + } else { + const arg = arguments[0] + if (arg) { + expression = `cookies().set(${describeNameArg(arg)}, ...)` + } else { + expression = `cookies().set(...)` + } + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + delete: { + value: function () { + let expression: string + if (arguments.length === 0) { + expression = `cookies().delete()` + } else if (arguments.length === 1) { + expression = `cookies().delete(${describeNameArg(arguments[0])})` + } else { + expression = `cookies().delete(${describeNameArg(arguments[0])}, ...)` + } + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + clear: { + value: function clear() { + const expression = 'cookies().clear()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + toString: { + value: function toString() { + const expression = 'cookies().toString()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + } satisfies CookieExtensions) + + return promise +} + +function makeUntrackedExoticCookies( + underlyingCookies: ReadonlyRequestCookies +): Promise { + const cachedCookies = CachedCookies.get(underlyingCookies) + if (cachedCookies) { + return cachedCookies + } + + const promise = makeResolvedReactPromise(underlyingCookies) + CachedCookies.set(underlyingCookies, promise) + + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: underlyingCookies[Symbol.iterator] + ? underlyingCookies[Symbol.iterator].bind(underlyingCookies) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesIterator.bind(underlyingCookies), + }, + size: { + get(): number { + return underlyingCookies.size + }, + }, + get: { + value: underlyingCookies.get.bind(underlyingCookies), + }, + getAll: { + value: underlyingCookies.getAll.bind(underlyingCookies), + }, + has: { + value: underlyingCookies.has.bind(underlyingCookies), + }, + set: { + value: underlyingCookies.set.bind(underlyingCookies), + }, + delete: { + value: underlyingCookies.delete.bind(underlyingCookies), + }, + clear: { + value: + // @ts-expect-error clear is defined in RequestCookies implementation but not in the type + typeof underlyingCookies.clear === 'function' + ? // @ts-expect-error clear is defined in RequestCookies implementation but not in the type + underlyingCookies.clear.bind(underlyingCookies) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesClear.bind(underlyingCookies, promise), + }, + toString: { + value: underlyingCookies.toString.bind(underlyingCookies), + }, + } satisfies CookieExtensions) + + return promise +} + +function makeUntrackedExoticCookiesWithDevWarnings( + underlyingCookies: ReadonlyRequestCookies, + route?: string +): Promise { + const cachedCookies = CachedCookies.get(underlyingCookies) + if (cachedCookies) { + return cachedCookies + } + + const promise = makeResolvedReactPromise(underlyingCookies) + CachedCookies.set(underlyingCookies, promise) + + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: function () { + warnForSyncIteration(route) + return underlyingCookies[Symbol.iterator] + ? underlyingCookies[Symbol.iterator].apply( + underlyingCookies, + arguments as any + ) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesIterator.call(underlyingCookies) + }, + writable: false, + }, + size: { + get(): number { + const expression = 'cookies().size' + warnForSyncAccess(route, expression) + return underlyingCookies.size + }, + }, + get: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().get()' + } else { + expression = `cookies().get(${describeNameArg(arguments[0])})` + } + warnForSyncAccess(route, expression) + return underlyingCookies.get.apply(underlyingCookies, arguments as any) + }, + writable: false, + }, + getAll: { + value: function getAll() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().getAll()` + } else { + expression = `cookies().getAll(${describeNameArg(arguments[0])})` + } + warnForSyncAccess(route, expression) + return underlyingCookies.getAll.apply( + underlyingCookies, + arguments as any + ) + }, + writable: false, + }, + has: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = `cookies().has()` + } else { + expression = `cookies().has(${describeNameArg(arguments[0])})` + } + warnForSyncAccess(route, expression) + return underlyingCookies.has.apply(underlyingCookies, arguments as any) + }, + writable: false, + }, + set: { + value: function set() { + let expression: string + if (arguments.length === 0) { + expression = 'cookies().set()' + } else { + const arg = arguments[0] + if (arg) { + expression = `cookies().set(${describeNameArg(arg)}, ...)` + } else { + expression = `cookies().set(...)` + } + } + warnForSyncAccess(route, expression) + return underlyingCookies.set.apply(underlyingCookies, arguments as any) + }, + writable: false, + }, + delete: { + value: function () { + let expression: string + if (arguments.length === 0) { + expression = `cookies().delete()` + } else if (arguments.length === 1) { + expression = `cookies().delete(${describeNameArg(arguments[0])})` + } else { + expression = `cookies().delete(${describeNameArg(arguments[0])}, ...)` + } + warnForSyncAccess(route, expression) + return underlyingCookies.delete.apply( + underlyingCookies, + arguments as any + ) + }, + writable: false, + }, + clear: { + value: function clear() { + const expression = 'cookies().clear()' + warnForSyncAccess(route, expression) + // @ts-ignore clear is defined in RequestCookies implementation but not in the type + return typeof underlyingCookies.clear === 'function' + ? // @ts-ignore clear is defined in RequestCookies implementation but not in the type + underlyingCookies.clear.apply(underlyingCookies, arguments) + : // TODO this is a polyfill for when the underlying type is ResponseCookies + // We should remove this and unify our cookies types. We could just let this continue to throw lazily + // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with + // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it + // has extra properties not available on RequestCookie instances. + polyfilledResponseCookiesClear.call(underlyingCookies, promise) + }, + writable: false, + }, + toString: { + value: function toString() { + const expression = 'cookies().toString()' + warnForSyncAccess(route, expression) + return underlyingCookies.toString.apply( + underlyingCookies, + arguments as any + ) + }, + writable: false, + }, + } satisfies CookieExtensions) + + return promise +} + +function describeNameArg(arg: unknown) { + return typeof arg === 'object' && + arg !== null && + typeof (arg as any).name === 'string' + ? `'${(arg as any).name}'` + : typeof arg === 'string' + ? `'${arg}'` + : '...' +} + +function warnForSyncIteration(route?: string) { + const prefix = route ? ` In route ${route} ` : '' + console.error( + `${prefix}cookies were iterated implicitly with something like \`for...of cookies())\` or \`[...cookies()]\`, or explicitly with \`cookies()[Symbol.iterator]()\`. \`cookies()\` now returns a Promise and the return value should be awaited before attempting to iterate over cookies. In this version of Next.js iterating cookies without awaiting first is still supported to faciliate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}cookie property was accessed directly with \`${expression}\`. \`cookies()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying cookies instance. In this version of Next.js direct access to \`${expression}\` is still supported to faciliate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} + +function polyfilledResponseCookiesIterator( + this: ResponseCookies +): ReturnType { + return this.getAll() + .map((c) => [c.name, c] as [string, any]) + .values() +} + +function polyfilledResponseCookiesClear( + this: ResponseCookies, + returnable: Promise +): typeof returnable { + for (const cookie of this.getAll()) { + this.delete(cookie.name) + } + return returnable +} + +type CookieExtensions = { + [K in keyof ReadonlyRequestCookies | 'clear']: unknown +} diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index 540b199ce6961..24a25c3e6b6b5 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -1,10 +1,4 @@ -import { - type ReadonlyRequestCookies, - RequestCookiesAdapter, -} from '../web/spec-extension/adapters/request-cookies' -import { HeadersAdapter } from '../web/spec-extension/adapters/headers' -import { RequestCookies } from '../web/spec-extension/cookies' -import { actionAsyncStorage } from '../../client/components/action-async-storage.external' +import { HeadersAdapter } from '../../server/web/spec-extension/adapters/headers' import { DraftMode } from './draft-mode' import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' @@ -36,32 +30,6 @@ export function headers() { return getExpectedRequestStore(callingExpression).headers } -export function cookies() { - const callingExpression = 'cookies' - const staticGenerationStore = staticGenerationAsyncStorage.getStore() - - if (staticGenerationStore) { - if (staticGenerationStore.forceStatic) { - // When we are forcing static we don't mark this as a Dynamic read and we return an empty cookies object - return RequestCookiesAdapter.seal(new RequestCookies(new Headers({}))) - } else { - // We will return a real headers object below so we mark this call as reading from a dynamic data source - trackDynamicDataAccessed(staticGenerationStore, callingExpression) - } - } - - const requestStore = getExpectedRequestStore(callingExpression) - - const asyncActionStore = actionAsyncStorage.getStore() - if (asyncActionStore?.isAction || asyncActionStore?.isAppRoute) { - // We can't conditionally return different types here based on the context. - // To avoid confusion, we always return the readonly type here. - return requestStore.mutableCookies as unknown as ReadonlyRequestCookies - } - - return requestStore.cookies -} - export function draftMode() { const callingExpression = 'draftMode' const requestStore = getExpectedRequestStore(callingExpression) diff --git a/packages/next/src/server/request/utils.ts b/packages/next/src/server/request/utils.ts new file mode 100644 index 0000000000000..991e8e1033d2f --- /dev/null +++ b/packages/next/src/server/request/utils.ts @@ -0,0 +1,46 @@ +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' + +/** + * React annotates Promises with extra properties to make unwrapping them synchronous + * after they have resolved. We sometimes create promises that are compatible with this + * internal implementation detail when we want to construct a promise that is already resolved. + * + * @internal + */ +export function makeResolvedReactPromise(value: T): Promise { + const promise = Promise.resolve(value) + ;(promise as any).status = 'fulfilled' + ;(promise as any).value = value + return promise +} + +// This regex will have fast negatives meaning valid identifiers may not pass +// this test. However this is only used during static generation to provide hints +// about why a page bailed out of some or all prerendering and we can use bracket notation +// for example while `ಠ_ಠ` is a valid identifier it's ok to print `searchParams['ಠ_ಠ']` +// even if this would have been fine too `searchParams.ಠ_ಠ` +const isDefinitelyAValidIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +export function describeStringPropertyAccess(target: string, prop: string) { + if (isDefinitelyAValidIdentifier.test(prop)) { + return `\`${target}.${prop}\`` + } + return `\`${target}[${JSON.stringify(prop)}]\`` +} + +export function describeHasCheckingStringProperty( + target: string, + prop: string +) { + const stringifiedProp = JSON.stringify(prop) + return `\`Reflect.has(${target}, ${stringifiedProp}\`, \`${stringifiedProp} in ${target}\`, or similar` +} + +export function throwWithStaticGenerationBailoutError( + route: string, + expression: string +): never { + throw new StaticGenBailoutError( + `Route ${route} 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` + ) +} diff --git a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts index 659b7073d9622..e6dde748ebbdc 100644 --- a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts +++ b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts @@ -19,6 +19,9 @@ export class ReadonlyRequestCookiesError extends Error { } } +// We use this to type some APIs but we don't construct instances directly +export type { ResponseCookies } + // The `cookies()` API is a mix of request and response cookies. For `.get()` methods, // we want to return the request cookie if it exists. For mutative methods like `.set()`, // we want to return the response cookie. diff --git a/test/e2e/app-dir/actions/app/redirect-target/page.js b/test/e2e/app-dir/actions/app/redirect-target/page.js index 2c1fda7ef6518..1c5ef6c69f92f 100644 --- a/test/e2e/app-dir/actions/app/redirect-target/page.js +++ b/test/e2e/app-dir/actions/app/redirect-target/page.js @@ -1,4 +1,4 @@ -import { cookies } from 'next/dist/server/request/headers' +import { cookies } from 'next/headers' export default function Page() { const redirectCookie = cookies().get('redirect') diff --git a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts index 587fb5dc04f36..a4697e17a8ece 100644 --- a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts +++ b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts @@ -1,12 +1,11 @@ import { nextTestSetup } from 'e2e-utils' import { assertHasRedbox, getRedboxHeader } from 'next-test-utils' -process.env.__TEST_SENTINEL = 'build' +process.env.__TEST_SENTINEL = 'at buildtime' describe('dynamic-data', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: __dirname + '/fixtures/main', - skipStart: true, skipDeployment: true, }) @@ -14,12 +13,6 @@ describe('dynamic-data', () => { return } - beforeAll(async () => { - await next.start() - // This will update the __TEST_SENTINEL value to "run" - await next.render('/setenv?value=run') - }) - it('should render the dynamic apis dynamically when used in a top-level scope', async () => { const $ = await next.render$( '/top-level?foo=foosearch', @@ -33,20 +26,20 @@ describe('dynamic-data', () => { ) if (isNextDev) { // in dev we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be no suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { // in PPR we expect the shell to be rendered at build and the page to be rendered at runtime - expect($('#layout').text()).toBe('build') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be a suspense boundary in fallback state expect($('#boundary').html()).not.toBeNull() } else { // in static generation we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be no suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } @@ -69,21 +62,21 @@ describe('dynamic-data', () => { ) if (isNextDev) { // in dev we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be no suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { // @TODO this should actually be build but there is a bug in how we do segment level dynamic in PPR at the moment // see note in create-component-tree - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be a suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } else { // in static generation we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be no suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } @@ -106,20 +99,20 @@ describe('dynamic-data', () => { ) if (isNextDev) { // in dev we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be no suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { // in PPR we expect the shell to be rendered at build and the page to be rendered at runtime - expect($('#layout').text()).toBe('build') - expect($('#page').text()).toBe('build') + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') // we expect there to be a suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } else { // in static generation we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('build') - expect($('#page').text()).toBe('build') + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') // we expect there to be no suspense boundary in fallback state expect($('#boundary').html()).toBeNull() } @@ -142,20 +135,20 @@ describe('dynamic-data', () => { ) if (isNextDev) { // in dev we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we don't assert the state of the fallback because it can depend on the timing // of when streaming starts and how fast the client references resolve } else if (process.env.__NEXT_EXPERIMENTAL_PPR) { // in PPR we expect the shell to be rendered at build and the page to be rendered at runtime - expect($('#layout').text()).toBe('build') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at runtime') // we expect there to be a suspense boundary in fallback state expect($('#boundary').html()).not.toBeNull() } else { // in static generation we expect the entire page to be rendered at runtime - expect($('#layout').text()).toBe('run') - expect($('#page').text()).toBe('run') + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') // we don't assert the state of the fallback because it can depend on the timing // of when streaming starts and how fast the client references resolve } diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js index 6f996293c640b..5600a7ea3413f 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js @@ -1,10 +1,11 @@ 'use client' +import { PageSentinel } from '../getSentinelValue' + export default async function Page({ searchParams }) { - const { __TEST_SENTINEL } = process.env return (
-
{__TEST_SENTINEL}
+
This example uses headers/cookies/searchParams directly in a Page configured with `dynamic = 'force-dynamic'`. This should cause the page diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js index 2600585338d87..bdd7587d80b5a 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js @@ -1,12 +1,13 @@ import { headers, cookies } from 'next/headers' +import { PageSentinel } from '../getSentinelValue' + export const dynamic = 'force-dynamic' export default async function Page({ searchParams }) { - const { __TEST_SENTINEL } = process.env return (
-
{__TEST_SENTINEL}
+
This example uses headers/cookies/searchParams directly in a Page configured with `dynamic = 'force-dynamic'`. This should cause the page @@ -26,22 +27,20 @@ export default async function Page({ searchParams }) {

cookies

- {cookies() - .getAll() - .map((cookie) => { - const key = cookie.name - let value = cookie.value + {(await cookies()).getAll().map((cookie) => { + const key = cookie.name + let value = cookie.value - if (key === 'userCache') { - value = value.slice(0, 10) + '...' - } - return ( -
-

{key}

-
{value}
-
- ) - })} + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })}

searchParams

diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js index 386920e307fbc..00c34b48e2fef 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js @@ -1,12 +1,13 @@ import { headers, cookies } from 'next/headers' +import { PageSentinel } from '../getSentinelValue' + export const dynamic = 'force-static' export default async function Page({ searchParams }) { - const { __TEST_SENTINEL } = process.env return (
-
{__TEST_SENTINEL}
+
This example uses headers/cookies/searchParams directly in a Page configured with `dynamic = 'force-static'`. This should cause the page @@ -26,22 +27,20 @@ export default async function Page({ searchParams }) {

cookies

- {cookies() - .getAll() - .map((cookie) => { - const key = cookie.name - let value = cookie.value + {(await cookies()).getAll().map((cookie) => { + const key = cookie.name + let value = cookie.value - if (key === 'userCache') { - value = value.slice(0, 10) + '...' - } - return ( -
-

{key}

-
{value}
-
- ) - })} + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })}

searchParams

diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/getSentinelValue.tsx b/test/e2e/app-dir/dynamic-data/fixtures/main/app/getSentinelValue.tsx new file mode 100644 index 0000000000000..ce3fa5c78ad5c --- /dev/null +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/getSentinelValue.tsx @@ -0,0 +1,22 @@ +const { PHASE_PRODUCTION_BUILD } = require('next/constants') + +export function getSentinelValue() { + if (typeof process === 'object') { + if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) { + return 'at buildtime' + } + } + return 'at runtime' +} + +export function LayoutSentinel() { + return
{getSentinelValue()}
+} + +export function PageSentinel() { + return
{getSentinelValue()}
+} + +export function InnerSentinel() { + return
{getSentinelValue()}
+} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js index 0146d8e26a029..4de03e3bb97f2 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/layout.js @@ -1,7 +1,8 @@ import { Suspense } from 'react' +import { LayoutSentinel } from './getSentinelValue' + export default async function Layout({ children }) { - const { __TEST_SENTINEL } = process.env return ( @@ -14,7 +15,7 @@ export default async function Layout({ children }) { intended

-
{__TEST_SENTINEL}
+ loading...
}> {children} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js deleted file mode 100644 index b5f2c27c1a19d..0000000000000 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/setenv/route.js +++ /dev/null @@ -1,4 +0,0 @@ -export async function GET(request) { - process.env.__TEST_SENTINEL = request.nextUrl.searchParams.get('value') - return new Response('ok') -} diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js index 5c84fa636af01..34b586073b167 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js @@ -1,10 +1,11 @@ import { headers, cookies } from 'next/headers' +import { PageSentinel } from '../getSentinelValue' + export default async function Page({ searchParams }) { - const { __TEST_SENTINEL } = process.env return (
-
{__TEST_SENTINEL}
+
This example uses headers/cookies/searchParams directly. In static generation we'd expect this to bail out to dynamic. In PPR we expect @@ -24,22 +25,20 @@ export default async function Page({ searchParams }) {

cookies

- {cookies() - .getAll() - .map((cookie) => { - const key = cookie.name - let value = cookie.value + {(await cookies()).getAll().map((cookie) => { + const key = cookie.name + let value = cookie.value - if (key === 'userCache') { - value = value.slice(0, 10) + '...' - } - return ( -
-

{key}

-
{value}
-
- ) - })} + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })}

searchParams

diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_async/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_async/page.tsx new file mode 100644 index 0000000000000..6cda978c99068 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_async/page.tsx @@ -0,0 +1,28 @@ +import { cookies } from 'next/headers' + +export default async function Page() { + await 1 + return ( + <> +

+ This page calls `cookies()` in a child component. Even though the page + doesn't do any IO it should still bail out of static generation. +

+ + + + ) +} + +async function ComponentOne() { + try { + ;(await cookies()).get('test') + } catch (e) { + // swallow any throw. We should still not be static + } + return
This component read cookies
+} + +async function ComponentTwo() { + return
This component didn't read cookies
+} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_boundary/page.tsx index 60f542a9779f2..814487d98b5eb 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_boundary/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_boundary/page.tsx @@ -30,7 +30,7 @@ export default async function Page() { async function ComponentThatReadsCookies() { let sentinelCookie try { - const cookie = cookies().get('x-sentinel') + const cookie = (await cookies()).get('x-sentinel') if (cookie) { sentinelCookie = cookie.value } else { diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_root/page.tsx index 0e225901a2145..180c9cbcefa9d 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_root/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_cookies_root/page.tsx @@ -26,7 +26,7 @@ export default async function Page() { async function ComponentThatReadsCookies() { let sentinelCookie try { - const cookie = cookies().get('x-sentinel') + const cookie = (await cookies()).get('x-sentinel') if (cookie) { sentinelCookie = cookie.value } else { diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx new file mode 100644 index 0000000000000..034e2acdf4cd7 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx @@ -0,0 +1,20 @@ +import { cookies } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +import { AllComponents } from '../commponents' + +export default async function Page() { + const allCookies = await cookies() + return ( + <> +

+ This page will exercise a number of APIs on the cookies() instance by + first awaiting it. This is the correct way to consume cookies() and this + test partially exists to ensure the behavior between sync and async + access is consistent for the time where you are permitted to do either +

+ +
{getSentinelValue()}
+ + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/commponents.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/commponents.tsx new file mode 100644 index 0000000000000..44611d7ae4bad --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/commponents.tsx @@ -0,0 +1,271 @@ +import { UnsafeUnwrappedCookies } from 'next/headers' + +// We don't export this type (why) but we can exfiltrate it through our exported typecase type +type ReadonlyRequestCookies = UnsafeUnwrappedCookies + +export function AllComponents({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + return ( + <> + + + + + + + + + + + ) +} + +function ForOf({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + let output = [] + for (let [cookieName, cookie] of cookies) { + if (cookieName.startsWith('x-sentinel')) { + output.push( +
+
{print(cookie)}
+
+ ) + } + } + + return ( +
+

for...of cookies()

+ {output.length ? output :
no cookies found
} +
+ ) +} + +function Spread({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + let output = [...cookies] + .filter(([cookieName]) => cookieName.startsWith('x-sentinel')) + .map((v) => { + return ( +
+
{print(v[1])}
+
+ ) + }) + + return ( +
+

...cookies()

+ {output.length ? output :
no cookies found
} +
+ ) +} + +function Size({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + return ( +
+

cookies().size

+
{cookies.size}
+
+ ) +} + +function GetAndGetAll({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + return ( +
+

cookies().get('...')

+
+
{print(cookies.get('x-sentinel'))}
+
+

{"cookies().get({ name: '...' })"}

+
+
+          {print(cookies.get({ name: 'x-sentinel-path', value: undefined }))}
+        
+
+

cookies().getAll('...')

+
+
{print(cookies.getAll('x-sentinel-rand'))}
+
+
+ ) +} + +function Has({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + return ( +
+

cookies().has('...')

+
    +
  • + + : {'' + cookies.has('x-sentinel')} +
  • +
  • + + + : {'' + cookies.has('x-sentinel-foobar')} + +
  • +
+
+ ) +} + +function Set({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + let result = 'no error' + try { + cookies.set('x-sentinel', 'goodbye') + } catch (e) { + result = e.message + } + return ( +
+

cookies().set('...')

+
    +
  • + + : {result} +
  • +
  • + + + : {cookies.get('x-sentinel').value} + +
  • +
+
+ ) +} + +function Delete({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + let result = 'no error' + try { + cookies.delete('x-sentinel') + } catch (e) { + result = e.message + } + return ( +
+

cookies().delete('...')

+
    +
  • + + : {result} +
  • +
  • + + + : {cookies.get('x-sentinel').value} + +
  • +
+
+ ) +} + +function Clear({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + let result = 'no error' + try { + ;(cookies as any).clear() + } catch (e) { + result = e.message + } + return ( +
+

cookies().clear()

+
    +
  • + + : {result} +
  • +
  • + + + : {cookies.get('x-sentinel').value} + +
  • +
+
+ ) +} + +function ToString({ + cookies, + expression, +}: { + cookies: ReadonlyRequestCookies + expression: string +}) { + // filter out real cookies, no point in leaking and not stable for testing + let result = cookies + .toString() + .split('; ') + .filter((p) => p.startsWith('x-sentinel')) + .join('; ') + return ( +
+

cookies().toString()

+
    +
  • + +
    {result}
    +
  • +
+
+ ) +} + +function print(model: any) { + return JSON.stringify(model, null, 2) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx new file mode 100644 index 0000000000000..d31f9f622fd6d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx @@ -0,0 +1,19 @@ +import { cookies, type UnsafeUnwrappedCookies } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +import { AllComponents } from '../commponents' + +export default async function Page() { + const allCookies = cookies() as unknown as UnsafeUnwrappedCookies + return ( + <> +

+ This page will exercise a number of APIs on the cookies() instance + directly (without await it as a promise). It should not produce runtime + errors but it will warn in dev +

+ +
{getSentinelValue()}
+ + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_boundary/page.tsx new file mode 100644 index 0000000000000..fef4e3a78bfda --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_boundary/page.tsx @@ -0,0 +1,38 @@ +import { Suspense } from 'react' +import { cookies } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +/** + * This test case is constructed to demonstrate how using the async form of cookies can lead to a better + * prerender with dynamic IO when PPR is on. There is no difference when PPR is off. When PPR is on the second component + * can finish rendering before the prerender completes and so we can produce a static shell where the Fallback closest + * to Cookies access is read + */ +export default async function Page() { + return ( + <> + + + + +
{getSentinelValue()}
+ + ) +} + +async function Component() { + const cookie = (await cookies()).get('x-sentinel') + if (cookie && cookie.value) { + return ( +
+ cookie {cookie.value} +
+ ) + } else { + return
no cookie found
+ } +} + +function ComponentTwo() { + return

footer

+} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_root/page.tsx new file mode 100644 index 0000000000000..0027e215a0ecd --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/async_root/page.tsx @@ -0,0 +1,25 @@ +import { cookies } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> + +
{getSentinelValue()}
+ + ) +} + +async function Component() { + const cookie = (await cookies()).get('x-sentinel') + if (cookie && cookie.value) { + return ( +
+ cookie {cookie.value} +
+ ) + } else { + return
no cookie found
+ } +} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/pass-deeply/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/pass-deeply/page.tsx new file mode 100644 index 0000000000000..1c68e18b9844b --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/pass-deeply/page.tsx @@ -0,0 +1,66 @@ +import { Suspense } from 'react' +import { cookies } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + const pendingCookies = cookies() + return ( +
+

Deep Cookie Reader

+

+ This component was passed the cookies promise returned by `cookies()`. + It is rendered inside a Suspense boundary and it takes a second to + resolve so when rendering the page you should see the Suspense fallback + content before revealing the cookie value even though cookies was called + at the page root. +

+

+ If dynamicIO is turned off the `cookies()` call would trigger a dynamic + point at the callsite and the suspense boundary would also be blocked + for over one second +

+ +

loading cookie data...

+
{getSentinelValue()}
+ + } + > + +
+
+ ) +} + +async function DeepCookieReader({ + pendingCookies, +}: { + pendingCookies: ReturnType +}) { + let output: Array = [] + for (const [name, cookie] of await pendingCookies) { + if (name.startsWith('x-sentinel')) { + output.push( + + {name} + {cookie.value} + + ) + } + } + await new Promise((r) => setTimeout(r, 1000)) + return ( + <> + + + + + + {output} +
Cookie nameCookie Value
+
{getSentinelValue()}
+ + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_boundary/page.tsx new file mode 100644 index 0000000000000..c4a5e2a63dda6 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_boundary/page.tsx @@ -0,0 +1,40 @@ +import { Suspense } from 'react' +import { cookies, type UnsafeUnwrappedCookies } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +/** + * This test case is constructed to demonstrate the deopting behavior of synchronously + * accesing dynamic data like cookies. won't be able to render before we abort + * to it will bubble up to the root and mark the whoe page as dynamic when PPR is one. There + * is no real change in behavior when PPR is off. + */ +export default async function Page() { + return ( + <> + + + + +
{getSentinelValue()}
+ + ) +} + +function Component() { + const cookie = (cookies() as unknown as UnsafeUnwrappedCookies).get( + 'x-sentinel' + ) + if (cookie && cookie.value) { + return ( +
+ cookie {cookie.value} +
+ ) + } else { + return
no cookie found
+ } +} + +function ComponentTwo() { + return

footer

+} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_root/page.tsx new file mode 100644 index 0000000000000..910a25fd39a08 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cookies/static-behavior/sync_root/page.tsx @@ -0,0 +1,27 @@ +import { cookies, type UnsafeUnwrappedCookies } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> + +
{getSentinelValue()}
+ + ) +} + +function Component() { + const cookie = (cookies() as unknown as UnsafeUnwrappedCookies).get( + 'x-sentinel' + ) + if (cookie && cookie.value) { + return ( +
+ cookie {cookie.value} +
+ ) + } else { + return
no cookie found
+ } +} diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts index 72fe9805bf1b8..ab797a4d1fca5 100644 --- a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts @@ -1,7 +1,5 @@ import { nextTestSetup } from 'e2e-utils' -process.env.__TEST_SENTINEL = 'at buildtime' - const WITH_PPR = !!process.env.__NEXT_EXPERIMENTAL_PPR describe('dynamic-io', () => { @@ -547,4 +545,277 @@ describe('dynamic-io', () => { } }) } + + if (WITH_PPR) { + it('should partially prerender pages that use async cookies', async () => { + let $ = await next.render$('/cookies/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + + it('should partially prerender pages that use sync cookies', async () => { + let $ = await next.render$('/cookies/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } else { + it('should produce dynamic pages when using async or sync cookies', async () => { + let $ = await next.render$('/cookies/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } + + if (WITH_PPR) { + it('should be able to pass cookies as a promise to another component and trigger an intermediate Suspense boundary', async () => { + const $ = await next.render$('/cookies/static-behavior/pass-deeply') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#fallback').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#fallback').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at runtime') + } + }) + } + + it('should be able to access cookie properties asynchronously', async () => { + let $ = await next.render$('/cookies/exercise/async', {}) + let cookieWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /cookies/exercise')) + + expect(cookieWarnings).toHaveLength(0) + + // For...of iteration + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/cookies/exercise/async' + ) + expect($('#for-of-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // ...spread iteration + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/cookies/exercise/async' + ) + expect($('#spread-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // cookies().size + expect(parseInt($('#size-cookies').text())).toBeGreaterThanOrEqual(3) + + // cookies().get('...') && cookies().getAll('...') + expect($('#get-x-sentinel').text()).toContain('hello') + expect($('#get-x-sentinel-path').text()).toContain( + '/cookies/exercise/async' + ) + expect($('#get-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + + // cookies().set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + + // cookies().delete('...', '...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + + // cookies().clear() + expect($('#clear-result').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#clear-value-x-sentinel').text()).toContain('hello') + + // cookies().toString() + expect($('#toString').text()).toContain('x-sentinel=hello') + expect($('#toString').text()).toContain('x-sentinel-path') + expect($('#toString').text()).toContain('x-sentinel-rand=') + }) + + it('should be able to access cookie properties synchronously', async () => { + let $ = await next.render$('/cookies/exercise/sync', {}) + let cookieWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /cookies/exercise')) + + if (!isNextDev) { + expect(cookieWarnings).toHaveLength(0) + } + let i = 0 + + // For...of iteration + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/cookies/exercise/sync' + ) + expect($('#for-of-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('for...of cookies()') + } + + // ...spread iteration + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/cookies/exercise/sync' + ) + expect($('#spread-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('[...cookies()]') + } + + // cookies().size + expect(parseInt($('#size-cookies').text())).toBeGreaterThanOrEqual(3) + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('cookies().size') + } + + // cookies().get('...') && cookies().getAll('...') + expect($('#get-x-sentinel').text()).toContain('hello') + expect($('#get-x-sentinel-path').text()).toContain('/cookies/exercise/sync') + expect($('#get-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel-path')") + expect(cookieWarnings[i++]).toContain( + "cookies().getAll('x-sentinel-rand')" + ) + } + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().has('x-sentinel')") + expect(cookieWarnings[i++]).toContain( + "cookies().has('x-sentinel-foobar')" + ) + } + + // cookies().set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().set('x-sentinel', ...)") + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + } + + // cookies().delete('...', '...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().delete('x-sentinel')") + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + } + + // cookies().clear() + expect($('#clear-result').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#clear-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('cookies().clear()') + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + } + + // cookies().toString() + expect($('#toString').text()).toContain('x-sentinel=hello') + expect($('#toString').text()).toContain('x-sentinel-path') + expect($('#toString').text()).toContain('x-sentinel-rand=') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('cookies().toString()') + } + + if (isNextDev) { + expect(i).toBe(cookieWarnings.length) + } + }) }) diff --git a/test/e2e/app-dir/dynamic-io/middleware.ts b/test/e2e/app-dir/dynamic-io/middleware.ts index 1fca880b2f031..872cb5fd8581b 100644 --- a/test/e2e/app-dir/dynamic-io/middleware.ts +++ b/test/e2e/app-dir/dynamic-io/middleware.ts @@ -9,5 +9,21 @@ export function middleware(request: NextRequest) { maxAge: 60 * 60 * 24 * 7, // 1 week path: '/', }) + response.cookies.set('x-sentinel-path', request.nextUrl.pathname, { + httpOnly: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, // 1 week + path: '/', + }) + response.cookies.set( + 'x-sentinel-rand', + ((Math.random() * 100000) | 0).toString(16), + { + httpOnly: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, // 1 week + path: '/', + } + ) return response } From 886799ab3e3afde0bab7d85405d21ee91b21e9c5 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 26 Aug 2024 10:29:23 -0700 Subject: [PATCH 02/14] Updates tests to use async cookies --- .../acceptance-app/dynamic-error.test.ts | 4 +-- .../e2e/app-dir/actions/app/client/actions.js | 2 +- test/e2e/app-dir/actions/app/handler/route.js | 9 +++--- .../e2e/app-dir/actions/app/header/actions.ts | 11 +++---- .../app-dir/actions/app/header/validator.js | 2 +- .../actions/app/mutate-cookie/page-2/page.js | 4 +-- .../app-dir/actions/app/mutate-cookie/page.js | 6 ++-- .../actions/app/redirect-target/page.js | 4 +-- .../app/redirects/action-redirect/page.js | 10 +++---- .../action-redirect/redirect-target/page.js | 6 ++-- .../app-dir/actions/app/revalidate-2/page.js | 2 +- .../app-dir/actions/app/revalidate/page.js | 4 +-- .../custom-server/app/page.js | 2 +- .../app-edge/app/edge-apis/cookies/page.tsx | 4 +-- .../app-dir/app-esm-js/app/app/hooks-ext.js | 3 +- test/e2e/app-dir/app-esm-js/app/app/hooks.js | 3 +- .../app-middleware/app-middleware.test.ts | 2 +- .../app/rsc-cookies-delete/page.js | 8 ++--- .../app/rsc-cookies/cookie-options/page.js | 6 ++-- .../app-middleware/app/rsc-cookies/page.js | 10 +++---- .../(protected)/layout.js | 2 +- .../app-routes/app-custom-routes.test.ts | 2 +- .../app-dir/app-routes/app/dynamic/route.ts | 4 +-- .../app-routes/app/hooks/cookies/has/route.ts | 2 +- .../app-routes/app/hooks/cookies/route.ts | 2 +- .../app-routes/app/hooks/rewrite/route.ts | 2 +- .../app-routes/app/status/500/next/route.ts | 2 +- .../app-routes/app/status/500/route.ts | 2 +- .../app-static/app/dynamic-error/[id]/page.js | 4 +-- .../app/flight/[slug]/[slug2]/page.js | 4 +-- .../force-dynamic-prerender/[slug]/page.js | 6 ++-- test/e2e/app-dir/app/app/skeleton/page.js | 2 +- .../draft-mode/app/with-cookies/page.tsx | 4 +-- .../app/with-edge/with-cookies/page.tsx | 4 +-- .../fixtures/cache-scoped/app/cookies/page.js | 29 +++++++++---------- .../require-static/app/cookies/page.js | 28 +++++++++--------- .../app/routes/dynamic-cookies/route.ts | 2 +- .../headers-static-bailout.test.ts | 15 +++------- .../hooks/app/hooks/use-cookies/page.js | 4 +-- .../app/nodejs/[id]/setting-cookies/page.js | 15 +++++----- test/e2e/app-dir/next-after-app/middleware.js | 4 +-- .../app-dir/next-after-pages/middleware.js | 4 +-- .../next-dynamic-css/app/page/page.tsx | 4 +-- .../ppr-errors/app/logging-error/page.jsx | 4 +-- .../page.jsx | 2 +- .../app/no-suspense-boundary/page.jsx | 2 +- test/e2e/app-dir/ppr-errors/app/page.jsx | 2 +- .../ppr-errors/app/re-throwing-error/page.jsx | 2 +- test/e2e/app-dir/ppr/app/api/cookie/route.js | 8 ++--- test/e2e/app-dir/ppr/components/dynamic.jsx | 4 +-- .../unstable-rethrow/app/cause/page.tsx | 6 ++-- .../app/dynamic-error/page.tsx | 2 +- .../app/page.tsx | 4 +-- .../somewhere-else/src/i18n.ts | 6 ++-- .../required-server-files/app/delayed/page.js | 4 +-- 55 files changed, 146 insertions(+), 149 deletions(-) diff --git a/test/development/acceptance-app/dynamic-error.test.ts b/test/development/acceptance-app/dynamic-error.test.ts index beccee29e6ca9..65be42a03566d 100644 --- a/test/development/acceptance-app/dynamic-error.test.ts +++ b/test/development/acceptance-app/dynamic-error.test.ts @@ -21,8 +21,8 @@ describe('dynamic = "error" in devmode', () => { import Component from '../../index' - export default function Page() { - cookies() + export default async function Page() { + await cookies() return } diff --git a/test/e2e/app-dir/actions/app/client/actions.js b/test/e2e/app-dir/actions/app/client/actions.js index d9d37f41b5ad3..612a450487187 100644 --- a/test/e2e/app-dir/actions/app/client/actions.js +++ b/test/e2e/app-dir/actions/app/client/actions.js @@ -7,7 +7,7 @@ import { headers, cookies } from 'next/headers' export async function getHeaders() { console.log('accept header:', headers().get('accept')) - cookies().set('test-cookie', Date.now()) + ;(await cookies()).set('test-cookie', Date.now()) } export async function inc(value) { diff --git a/test/e2e/app-dir/actions/app/handler/route.js b/test/e2e/app-dir/actions/app/handler/route.js index 9b6b78a56730a..fe0bc562ccb85 100644 --- a/test/e2e/app-dir/actions/app/handler/route.js +++ b/test/e2e/app-dir/actions/app/handler/route.js @@ -3,14 +3,15 @@ import { cookies } from 'next/headers' export const revalidate = 1 export const GET = async () => { - cookies().set('foo', 'foo1') - cookies().set('bar', 'bar1') + const localCookies = await cookies() + localCookies.set('foo', 'foo1') + localCookies.set('bar', 'bar1') // Key, value, options - cookies().set('test1', 'value1', { secure: true }) + localCookies.set('test1', 'value1', { secure: true }) // One object - cookies().set({ + localCookies.set({ name: 'test2', value: 'value2', httpOnly: true, diff --git a/test/e2e/app-dir/actions/app/header/actions.ts b/test/e2e/app-dir/actions/app/header/actions.ts index 19dcc06be8cd4..4f2e1cc1a40b1 100644 --- a/test/e2e/app-dir/actions/app/header/actions.ts +++ b/test/e2e/app-dir/actions/app/header/actions.ts @@ -4,7 +4,7 @@ import { headers, cookies } from 'next/headers' import { redirect } from 'next/navigation' export async function setCookieWithMaxAge() { - cookies().set({ + ;(await cookies()).set({ name: 'foo', value: 'bar', maxAge: 1000, @@ -12,7 +12,7 @@ export async function setCookieWithMaxAge() { } export async function getCookie(name) { - return cookies().get(name) + return (await cookies()).get(name) } export async function getHeader(name) { @@ -20,11 +20,12 @@ export async function getHeader(name) { } export async function setCookie(name, value) { - cookies().set(name, value) - return cookies().get(name) + const localCookies = await cookies() + localCookies.set(name, value) + return localCookies.get(name) } export async function setCookieAndRedirect(name, value, path, type) { - cookies().set(name, value) + ;(await cookies()).set(name, value) redirect(path, type) } diff --git a/test/e2e/app-dir/actions/app/header/validator.js b/test/e2e/app-dir/actions/app/header/validator.js index 6c4bc22bac053..2528a5e768c67 100644 --- a/test/e2e/app-dir/actions/app/header/validator.js +++ b/test/e2e/app-dir/actions/app/header/validator.js @@ -3,7 +3,7 @@ import { cookies } from 'next/headers' export function validator(action) { return async function (arg) { 'use server' - const auth = cookies().get('auth') + const auth = (await cookies()).get('auth') if (auth?.value !== '1') { throw new Error('Unauthorized request') } diff --git a/test/e2e/app-dir/actions/app/mutate-cookie/page-2/page.js b/test/e2e/app-dir/actions/app/mutate-cookie/page-2/page.js index 24bdf80ac982b..1e73b8f479044 100644 --- a/test/e2e/app-dir/actions/app/mutate-cookie/page-2/page.js +++ b/test/e2e/app-dir/actions/app/mutate-cookie/page-2/page.js @@ -1,13 +1,13 @@ import { cookies } from 'next/headers' import Link from 'next/link' -export default function Page() { +export default async function Page() { return ( <> back -

{cookies().get('test-cookie2')?.value}

+

{(await cookies()).get('test-cookie2')?.value}

) } diff --git a/test/e2e/app-dir/actions/app/mutate-cookie/page.js b/test/e2e/app-dir/actions/app/mutate-cookie/page.js index 64448612cee47..36e4f4db27b54 100644 --- a/test/e2e/app-dir/actions/app/mutate-cookie/page.js +++ b/test/e2e/app-dir/actions/app/mutate-cookie/page.js @@ -3,16 +3,16 @@ import Link from 'next/link' async function updateCookie() { 'use server' - cookies().set('test-cookie2', Date.now()) + ;(await cookies()).set('test-cookie2', Date.now()) } -export default function Page() { +export default async function Page() { return ( <> to page2 -

{cookies().get('test-cookie2')?.value}

+

{(await cookies()).get('test-cookie2')?.value}

cookies

- {cookies() - .getAll() - .map((cookie) => { - const key = cookie.name - let value = cookie.value + {(await cookies()).getAll().map((cookie) => { + const key = cookie.name + let value = cookie.value - if (key === 'userCache') { - value = value.slice(0, 10) + '...' - } - return ( -
-

{key}

-
{value}
-
- ) - })} + if (key === 'userCache') { + value = value.slice(0, 10) + '...' + } + return ( +
+

{key}

+
{value}
+
+ ) + })}
) diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts index 584fb3ac47cbb..3ed857b10b844 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts @@ -5,7 +5,7 @@ import { cookies } from 'next/headers' import { getSentinelValue } from '../../getSentinelValue' export async function GET(request: NextRequest, { params }: { params: {} }) { - const sentinel = cookies().get('x-sentinel') + const sentinel = (await cookies()).get('x-sentinel') return new Response( JSON.stringify({ value: getSentinelValue(), diff --git a/test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts b/test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts index 90ebc18fe8994..8e5191d5c91aa 100644 --- a/test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts +++ b/test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts @@ -46,19 +46,12 @@ describe('headers-static-bailout', () => { outdent` import { cookies } from 'next/headers' - async function foo() { - return new Promise((resolve) => - // break out of the expected async context, causing an uncaught build-time error - setTimeout(() => { - resolve(cookies().getAll()) - }, 1000) - ) - } - export default async function Page() { - await foo() + setTimeout(() => { + cookies().then(c => c.getAll()) + }, 0) return
Hello World
- } + } ` ) const { cliOutput } = await next.build() diff --git a/test/e2e/app-dir/hooks/app/hooks/use-cookies/page.js b/test/e2e/app-dir/hooks/app/hooks/use-cookies/page.js index f8095a3682a2d..4b2311f62e3ef 100644 --- a/test/e2e/app-dir/hooks/app/hooks/use-cookies/page.js +++ b/test/e2e/app-dir/hooks/app/hooks/use-cookies/page.js @@ -1,7 +1,7 @@ import { cookies } from 'next/headers' -export default function Page() { - const cookiesList = cookies() +export default async function Page() { + const cookiesList = await cookies() const hasCookie = cookiesList.has('use-cookies') return ( diff --git a/test/e2e/app-dir/next-after-app/app/nodejs/[id]/setting-cookies/page.js b/test/e2e/app-dir/next-after-app/app/nodejs/[id]/setting-cookies/page.js index 3ba685f617ba3..ceab49df457d2 100644 --- a/test/e2e/app-dir/next-after-app/app/nodejs/[id]/setting-cookies/page.js +++ b/test/e2e/app-dir/next-after-app/app/nodejs/[id]/setting-cookies/page.js @@ -1,17 +1,17 @@ import { unstable_after as after } from 'next/server' import { cookies } from 'next/headers' -export default function Index() { - after(() => { - cookies().set('testCookie', 'after-render', { path: '/' }) +export default async function Index() { + after(async () => { + ;(await cookies()).set('testCookie', 'after-render', { path: '/' }) }) const action = async () => { 'use server' - cookies().set('testCookie', 'action', { path: '/' }) + ;(await cookies()).set('testCookie', 'action', { path: '/' }) - after(() => { - cookies().set('testCookie', 'after-action', { path: '/' }) + after(async () => { + ;(await cookies()).set('testCookie', 'after-action', { path: '/' }) }) } @@ -19,7 +19,8 @@ export default function Index() {

Page with after() that tries to set cookies

diff --git a/test/e2e/app-dir/next-after-app/middleware.js b/test/e2e/app-dir/next-after-app/middleware.js index f18a04fb0943f..5bdcc74aab85f 100644 --- a/test/e2e/app-dir/next-after-app/middleware.js +++ b/test/e2e/app-dir/next-after-app/middleware.js @@ -13,11 +13,11 @@ export function middleware( if (match) { const prefix = match.groups.prefix const requestId = url.searchParams.get('requestId') - after(() => { + after(async () => { cliLog({ source: '[middleware] /middleware/redirect-source', requestId, - cookies: { testCookie: cookies().get('testCookie')?.value }, + cookies: { testCookie: (await cookies()).get('testCookie')?.value }, }) }) return NextResponse.redirect( diff --git a/test/e2e/app-dir/next-after-pages/middleware.js b/test/e2e/app-dir/next-after-pages/middleware.js index 6a1c60397228b..de4361e319318 100644 --- a/test/e2e/app-dir/next-after-pages/middleware.js +++ b/test/e2e/app-dir/next-after-pages/middleware.js @@ -8,11 +8,11 @@ export function middleware( const url = new URL(request.url) if (url.pathname.startsWith('/middleware/redirect-source')) { const requestId = url.searchParams.get('requestId') - after(() => { + after(async () => { cliLog({ source: '[middleware] /middleware/redirect-source', requestId, - cookies: { testCookie: cookies().get('testCookie')?.value }, + cookies: { testCookie: (await cookies()).get('testCookie')?.value }, }) }) return NextResponse.redirect(new URL('/middleware/redirect', request.url)) diff --git a/test/e2e/app-dir/next-dynamic-css/app/page/page.tsx b/test/e2e/app-dir/next-dynamic-css/app/page/page.tsx index 7cfcb5205d2c8..66857032c6ce5 100644 --- a/test/e2e/app-dir/next-dynamic-css/app/page/page.tsx +++ b/test/e2e/app-dir/next-dynamic-css/app/page/page.tsx @@ -3,8 +3,8 @@ import './global2.css' import Inner2 from './inner2' import { cookies } from 'next/headers' -export default function Page() { - cookies() +export default async function Page() { + await cookies() return ( <>

Hello Global

diff --git a/test/e2e/app-dir/ppr-errors/app/logging-error/page.jsx b/test/e2e/app-dir/ppr-errors/app/logging-error/page.jsx index 44fae73f42377..1f7081992115a 100644 --- a/test/e2e/app-dir/ppr-errors/app/logging-error/page.jsx +++ b/test/e2e/app-dir/ppr-errors/app/logging-error/page.jsx @@ -11,10 +11,10 @@ export default async function Page() { async function Foobar() { try { - cookies() + await cookies() } catch (err) { console.log('User land logged error: ' + err.message) } - cookies() // still postpones so doesn't fail build + await cookies() // still postpones so doesn't fail build return null } diff --git a/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary-re-throwing-error/page.jsx b/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary-re-throwing-error/page.jsx index bbe107b6b3b16..ba2d0b21f7540 100644 --- a/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary-re-throwing-error/page.jsx +++ b/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary-re-throwing-error/page.jsx @@ -3,7 +3,7 @@ import { cookies } from 'next/headers' export default async function Page() { try { - cookies() + await cookies() } catch (err) { throw new Error( "Throwing a new error from 'no-suspense-boundary-re-throwing-error'" diff --git a/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary/page.jsx b/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary/page.jsx index 0e1a8716d0e0d..a978635749200 100644 --- a/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary/page.jsx +++ b/test/e2e/app-dir/ppr-errors/app/no-suspense-boundary/page.jsx @@ -3,7 +3,7 @@ import { cookies } from 'next/headers' export default async function Page() { try { - cookies() + await cookies() } catch (err) {} return
Hello World
} diff --git a/test/e2e/app-dir/ppr-errors/app/page.jsx b/test/e2e/app-dir/ppr-errors/app/page.jsx index b903061df31ea..9f0c62321ac17 100644 --- a/test/e2e/app-dir/ppr-errors/app/page.jsx +++ b/test/e2e/app-dir/ppr-errors/app/page.jsx @@ -11,7 +11,7 @@ export default async function Page() { async function Foobar() { try { - cookies() + await cookies() } catch (err) {} return null } diff --git a/test/e2e/app-dir/ppr-errors/app/re-throwing-error/page.jsx b/test/e2e/app-dir/ppr-errors/app/re-throwing-error/page.jsx index 82b466d99fe1e..324ae9c2f43a0 100644 --- a/test/e2e/app-dir/ppr-errors/app/re-throwing-error/page.jsx +++ b/test/e2e/app-dir/ppr-errors/app/re-throwing-error/page.jsx @@ -11,7 +11,7 @@ export default async function Page() { async function Foobar() { try { - cookies() + await cookies() } catch (err) { throw new Error('The original error was caught and rethrown.') } diff --git a/test/e2e/app-dir/ppr/app/api/cookie/route.js b/test/e2e/app-dir/ppr/app/api/cookie/route.js index 5da302d5a8497..203eb56b67d5e 100644 --- a/test/e2e/app-dir/ppr/app/api/cookie/route.js +++ b/test/e2e/app-dir/ppr/app/api/cookie/route.js @@ -1,21 +1,21 @@ import { cookies } from 'next/headers' -export function POST(request) { +export async function POST(request) { const url = new URL(request.url) const name = url.searchParams.get('name') if (!name) { return new Response(null, { status: 400 }) } - cookies().set(name, '1') + ;(await cookies()).set(name, '1') return new Response(null, { status: 204 }) } -export function DELETE(request) { +export async function DELETE(request) { const url = new URL(request.url) const name = url.searchParams.get('name') if (!name) { return new Response(null, { status: 400 }) } - cookies().delete(name) + ;(await cookies()).delete(name) return new Response(null, { status: 204 }) } diff --git a/test/e2e/app-dir/ppr/components/dynamic.jsx b/test/e2e/app-dir/ppr/components/dynamic.jsx index d2f562c00219c..c7760b26af981 100644 --- a/test/e2e/app-dir/ppr/components/dynamic.jsx +++ b/test/e2e/app-dir/ppr/components/dynamic.jsx @@ -8,8 +8,8 @@ export async function Dynamic({ fallback }) { let signedIn let active if (dynamic) { - signedIn = cookies().has('session') - active = cookies().has('delay') + signedIn = (await cookies()).has('session') + active = (await cookies()).has('delay') if (active) { await new Promise((resolve) => setTimeout(resolve, 1000)) } diff --git a/test/e2e/app-dir/unstable-rethrow/app/cause/page.tsx b/test/e2e/app-dir/unstable-rethrow/app/cause/page.tsx index a2f959a17bd88..f34da50a27568 100644 --- a/test/e2e/app-dir/unstable-rethrow/app/cause/page.tsx +++ b/test/e2e/app-dir/unstable-rethrow/app/cause/page.tsx @@ -1,9 +1,9 @@ import { cookies } from 'next/headers' import { unstable_rethrow } from 'next/navigation' -function someFunction() { +async function someFunction() { try { - cookies() + await cookies() } catch (err) { throw new Error('Oopsy', { cause: err }) } @@ -11,7 +11,7 @@ function someFunction() { export default async function Page() { try { - someFunction() + await someFunction() } catch (err) { console.log('[test assertion]: checking error') unstable_rethrow(err) diff --git a/test/e2e/app-dir/unstable-rethrow/app/dynamic-error/page.tsx b/test/e2e/app-dir/unstable-rethrow/app/dynamic-error/page.tsx index 2934e68fde129..926b3eae65fd8 100644 --- a/test/e2e/app-dir/unstable-rethrow/app/dynamic-error/page.tsx +++ b/test/e2e/app-dir/unstable-rethrow/app/dynamic-error/page.tsx @@ -3,7 +3,7 @@ import { unstable_rethrow } from 'next/navigation' export default async function Page() { try { - cookies() + await cookies() } catch (err) { console.log('[test assertion]: checking error') unstable_rethrow(err) diff --git a/test/production/app-dir/deopted-into-client-rendering-warning/app/page.tsx b/test/production/app-dir/deopted-into-client-rendering-warning/app/page.tsx index 8f2c030122961..30f8ea16dcce5 100644 --- a/test/production/app-dir/deopted-into-client-rendering-warning/app/page.tsx +++ b/test/production/app-dir/deopted-into-client-rendering-warning/app/page.tsx @@ -1,5 +1,5 @@ import { cookies } from 'next/headers' -export default function Home() { - cookies() +export default async function Home() { + await cookies() return null } diff --git a/test/production/app-dir/symbolic-file-links/somewhere-else/src/i18n.ts b/test/production/app-dir/symbolic-file-links/somewhere-else/src/i18n.ts index 4c1d75aaa1325..c3c1f342aeaee 100644 --- a/test/production/app-dir/symbolic-file-links/somewhere-else/src/i18n.ts +++ b/test/production/app-dir/symbolic-file-links/somewhere-else/src/i18n.ts @@ -1,9 +1,11 @@ -import { cookies } from 'next/headers' +import { cookies, type UnsafeUnwrappedCookies } from 'next/headers' // The purpose of this file is to demonstrate that without proper symbolic file checking // next accidentally marks files in the root of the project as client files. export default function () { - const locale = cookies().get('locale')?.value ?? 'en' + const locale = + (cookies() as unknown as UnsafeUnwrappedCookies).get('locale')?.value ?? + 'en' return { locale, diff --git a/test/production/standalone-mode/required-server-files/app/delayed/page.js b/test/production/standalone-mode/required-server-files/app/delayed/page.js index 04d1f9ec01106..5875e84ed2163 100644 --- a/test/production/standalone-mode/required-server-files/app/delayed/page.js +++ b/test/production/standalone-mode/required-server-files/app/delayed/page.js @@ -18,13 +18,13 @@ export default function Page() { } async function Time() { - cookies() + await cookies() await new Promise((resolve) => setTimeout(resolve, 1000)) return

{Date.now()}

} async function Random() { - cookies() + await cookies() await new Promise((resolve) => setTimeout(resolve, 4000)) return

{Math.random()}

} From 3a65c6130b1dff03887e089b852096f14f3eef8a Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 27 Aug 2024 11:54:00 -0700 Subject: [PATCH 03/14] `headers()` now returns an exotic promise of Headers Similar to `cookies()`, `headers()` provides access to an underlying Request in Next.js. To be optimally compatible with dynamicIO we need request access to be implemented through async APIs and have udpated this function to return a promise of Headers rather than Headers directly. To facilitate migration this API will augment the Promise with properties that allow direct access to headers. In a future version of Next.js we will remove the exotic nature of this Promise and all access will require awaiting the result. --- packages/next/headers.d.ts | 3 +- packages/next/headers.js | 3 +- packages/next/src/api/headers.ts | 3 +- .../next/src/server/request/draft-mode.ts | 8 + packages/next/src/server/request/headers.ts | 433 +++++++++++++++++- .../dynamic_api_headers_boundary/page.tsx | 2 +- .../cases/dynamic_api_headers_root/page.tsx | 2 +- .../app/headers/exercise/async/page.tsx | 32 ++ .../app/headers/exercise/commponents.tsx | 341 ++++++++++++++ .../app/headers/exercise/sync/page.tsx | 32 ++ .../static-behavior/async_boundary/page.tsx | 39 ++ .../static-behavior/async_root/page.tsx | 26 ++ .../static-behavior/pass-deeply/page.tsx | 66 +++ .../static-behavior/sync_boundary/page.tsx | 39 ++ .../static-behavior/sync_root/page.tsx | 26 ++ .../e2e/app-dir/dynamic-io/dynamic-io.test.ts | 376 +++++++++++++-- test/e2e/app-dir/dynamic-io/middleware.ts | 17 +- 17 files changed, 1383 insertions(+), 65 deletions(-) create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/exercise/async/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/exercise/commponents.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/exercise/sync/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/static-behavior/pass-deeply/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_root/page.tsx diff --git a/packages/next/headers.d.ts b/packages/next/headers.d.ts index 505b866a1bd76..e9317d037a84b 100644 --- a/packages/next/headers.d.ts +++ b/packages/next/headers.d.ts @@ -1,2 +1,3 @@ -export * from './dist/server/request/headers' export * from './dist/server/request/cookies' +export * from './dist/server/request/headers' +export * from './dist/server/request/draft-mode' diff --git a/packages/next/headers.js b/packages/next/headers.js index 0eab170e989bd..8e77c79174870 100644 --- a/packages/next/headers.js +++ b/packages/next/headers.js @@ -1,2 +1,3 @@ -module.exports = require('./dist/server/request/headers') module.exports.cookies = require('./dist/server/request/cookies').cookies +module.exports.headers = require('./dist/server/request/headers').headers +module.exports.draftMode = require('./dist/server/request/draft-mode').draftMode diff --git a/packages/next/src/api/headers.ts b/packages/next/src/api/headers.ts index 5580811dd44e4..f264546cb19ff 100644 --- a/packages/next/src/api/headers.ts +++ b/packages/next/src/api/headers.ts @@ -1,2 +1,3 @@ -export * from '../server/request/headers' export * from '../server/request/cookies' +export * from '../server/request/headers' +export * from '../server/request/draft-mode' diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index a2c225ba77123..c6cfafd9451eb 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -2,6 +2,7 @@ import type { DraftModeProvider } from '../async-storage/draft-mode-provider' import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' +import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' export class DraftMode { /** @@ -34,3 +35,10 @@ export class DraftMode { return this._provider.disable() } } + +export function draftMode() { + const callingExpression = 'draftMode' + const requestStore = getExpectedRequestStore(callingExpression) + + return new DraftMode(requestStore.draftMode) +} diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index 24a25c3e6b6b5..9d8dfbf9908e0 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -1,8 +1,49 @@ -import { HeadersAdapter } from '../../server/web/spec-extension/adapters/headers' -import { DraftMode } from './draft-mode' -import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' +import { + HeadersAdapter, + type ReadonlyHeaders, +} from '../../server/web/spec-extension/adapters/headers' import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { + postponeWithTracking, + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + trackDynamicDataInDynamicRender, +} from '../app-render/dynamic-rendering' +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' +import { makeResolvedReactPromise } from './utils' +import { makeHangingPromise } from '../dynamic-rendering-utils' + +/** + * In this version of Next.js `headers()` returns a Promise however you can still reference the properties of the underlying Headers instance + * synchronously to facilitate migration. The `UnsafeUnwrappedHeaders` type is added to your code by a codemod that attempts to automatically + * updates callsites to reflect the new Promise return type. There are some cases where `headers()` cannot be automatically converted, namely + * when it is used inside a synchronous function and we can't be sure the function can be made async automatically. In these cases we add an + * explicit type case to `UnsafeUnwrappedHeaders` to enable typescript to allow for the synchronous usage only where it is actually necessary. + * + * You should should update these callsites to either be async functions where the `headers()` value can be awaited or you should call `headers()` + * from outside and await the return value before passing it into this function. + * + * You can find instances that require manual migration by searching for `UnsafeUnwrappedHeaders` in your codebase or by search for a comment that + * starts with: + * + * ``` + * // TODO [sync-headers-usage] + * ``` + * In a future version of Next.js `headers()` will only return a Promise and you will not be able to access the underlying Headers instance + * without awaiting the return value first. When this change happens the type `UnsafeUnwrappedHeaders` will be updated to reflect that is it no longer + * usable. + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedHeaders = ReadonlyHeaders /** * This function allows you to read the HTTP incoming request headers in @@ -13,26 +54,388 @@ import { getExpectedRequestStore } from '../../client/components/request-async-s * * Read more: [Next.js Docs: `headers`](https://nextjs.org/docs/app/api-reference/functions/headers) */ -export function headers() { - const callingExpression = 'headers' +export function headers(): Promise { + const requestStore = getExpectedRequestStore('headers') const staticGenerationStore = staticGenerationAsyncStorage.getStore() + const prerenderStore = prerenderAsyncStorage.getStore() if (staticGenerationStore) { if (staticGenerationStore.forceStatic) { - // When we are forcing static we don't mark this as a Dynamic read and we return an empty headers object - return HeadersAdapter.seal(new Headers({})) - } else { - // We will return a real headers object below so we mark this call as reading from a dynamic data source - trackDynamicDataAccessed(staticGenerationStore, callingExpression) + // When using forceStatic we override all other logic and always just return an empty + // headers object without tracking + const underlyingHeaders = HeadersAdapter.seal(new Headers({})) + return makeUntrackedExoticHeaders(underlyingHeaders) + } + + if (staticGenerationStore.isUnstableCacheCallback) { + throw new Error( + `Route ${staticGenerationStore.route} used "headers" 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 "headers" 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 (staticGenerationStore.dynamicShouldError) { + throw new StaticGenBailoutError( + `Route ${staticGenerationStore.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`headers\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) } + + if (prerenderStore) { + // We are in PPR and/or dynamicIO mode and prerendering + + if (isDynamicIOPrerender(prerenderStore)) { + // We use the controller and cacheSignal as an indication we are in dynamicIO mode. + // When resolving headers for a prerender with dynamic IO we return a forever promise + // along with property access tracked synchronous headers. + + // We don't track dynamic access here because access will be tracked when you access + // one of the properties of the headers object. + return makeDynamicallyTrackedExoticHeaders( + staticGenerationStore.route, + prerenderStore + ) + } else { + // We are prerendering with PPR. We need track dynamic access here eagerly + // to keep continuity with how headers has worked in PPR without dynamicIO. + // TODO consider switching the semantic to throw on property access instead + postponeWithTracking( + staticGenerationStore.route, + 'headers', + prerenderStore.dynamicTracking + ) + } + } else if (staticGenerationStore.isStaticGeneration) { + // We are in a legacy static generation mode while prerendering + // We track dynamic access here so we don't need to wrap the headers in + // individual property access tracking. + throwToInterruptStaticGeneration('headers', staticGenerationStore) + } + // We fall through to the dynamic context below but we still track dynamic access + // because in dev we can still error for things like using headers inside a cache context + trackDynamicDataInDynamicRender(staticGenerationStore) } - return getExpectedRequestStore(callingExpression).headers + if (process.env.NODE_ENV === 'development') { + return makeUntrackedExoticHeadersWithDevWarnings( + requestStore.headers, + staticGenerationStore?.route + ) + } else { + return makeUntrackedExoticHeaders(requestStore.headers) + } } -export function draftMode() { - const callingExpression = 'draftMode' - const requestStore = getExpectedRequestStore(callingExpression) +interface CacheLifetime {} +const CachedHeaders = new WeakMap>() + +function makeDynamicallyTrackedExoticHeaders( + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedHeaders = CachedHeaders.get(prerenderStore) + if (cachedHeaders) { + return cachedHeaders + } + + const promise = makeHangingPromise() + CachedHeaders.set(prerenderStore, promise) + + Object.defineProperties(promise, { + append: { + value: function append() { + const expression = `headers().append(${describeNameArg(arguments[0])}, ...)` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + delete: { + value: function _delete() { + const expression = `headers().delete(${describeNameArg(arguments[0])})` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + get: { + value: function get() { + const expression = `headers().get(${describeNameArg(arguments[0])})` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + has: { + value: function has() { + const expression = `headers().has(${describeNameArg(arguments[0])})` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + set: { + value: function set() { + const expression = `headers().set(${describeNameArg(arguments[0])}, ...)` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + getSetCookie: { + value: function getSetCookie() { + const expression = `headers().getSetCookie()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + forEach: { + value: function forEach() { + const expression = `headers().forEach(...)` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + keys: { + value: function keys() { + const expression = `headers().keys()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + values: { + value: function values() { + const expression = `headers().values()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + entries: { + value: function entries() { + const expression = `headers().entries()` + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + [Symbol.iterator]: { + value: function () { + const expression = 'headers()[Symbol.iterator]()' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }, + } satisfies HeadersExtensions) + + return promise +} + +function makeUntrackedExoticHeaders( + underlyingHeaders: ReadonlyHeaders +): Promise { + const cachedHeaders = CachedHeaders.get(underlyingHeaders) + if (cachedHeaders) { + return cachedHeaders + } + + const promise = makeResolvedReactPromise(underlyingHeaders) + CachedHeaders.set(underlyingHeaders, promise) + + Object.defineProperties(promise, { + append: { + value: underlyingHeaders.append.bind(underlyingHeaders), + }, + delete: { + value: underlyingHeaders.delete.bind(underlyingHeaders), + }, + get: { + value: underlyingHeaders.get.bind(underlyingHeaders), + }, + has: { + value: underlyingHeaders.has.bind(underlyingHeaders), + }, + set: { + value: underlyingHeaders.set.bind(underlyingHeaders), + }, + getSetCookie: { + value: underlyingHeaders.getSetCookie.bind(underlyingHeaders), + }, + forEach: { + value: underlyingHeaders.forEach.bind(underlyingHeaders), + }, + keys: { + value: underlyingHeaders.keys.bind(underlyingHeaders), + }, + values: { + value: underlyingHeaders.values.bind(underlyingHeaders), + }, + entries: { + value: underlyingHeaders.entries.bind(underlyingHeaders), + }, + [Symbol.iterator]: { + value: underlyingHeaders[Symbol.iterator].bind(underlyingHeaders), + }, + } satisfies HeadersExtensions) + + return promise +} + +function makeUntrackedExoticHeadersWithDevWarnings( + underlyingHeaders: ReadonlyHeaders, + route?: string +): Promise { + const cachedHeaders = CachedHeaders.get(underlyingHeaders) + if (cachedHeaders) { + return cachedHeaders + } + + const promise = makeResolvedReactPromise(underlyingHeaders) + CachedHeaders.set(underlyingHeaders, promise) + + Object.defineProperties(promise, { + append: { + value: function append() { + const expression = `headers().append(${describeNameArg(arguments[0])}, ...)` + warnForSyncAccess(route, expression) + return underlyingHeaders.append.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + delete: { + value: function _delete() { + const expression = `headers().delete(${describeNameArg(arguments[0])})` + warnForSyncAccess(route, expression) + return underlyingHeaders.delete.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + get: { + value: function get() { + const expression = `headers().get(${describeNameArg(arguments[0])})` + warnForSyncAccess(route, expression) + return underlyingHeaders.get.apply(underlyingHeaders, arguments as any) + }, + }, + has: { + value: function has() { + const expression = `headers().has(${describeNameArg(arguments[0])})` + warnForSyncAccess(route, expression) + return underlyingHeaders.has.apply(underlyingHeaders, arguments as any) + }, + }, + set: { + value: function set() { + const expression = `headers().set(${describeNameArg(arguments[0])}, ...)` + warnForSyncAccess(route, expression) + return underlyingHeaders.set.apply(underlyingHeaders, arguments as any) + }, + }, + getSetCookie: { + value: function getSetCookie() { + const expression = `headers().getSetCookie()` + warnForSyncAccess(route, expression) + return underlyingHeaders.getSetCookie.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + forEach: { + value: function forEach() { + const expression = `headers().forEach(...)` + warnForSyncAccess(route, expression) + return underlyingHeaders.forEach.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + keys: { + value: function keys() { + const expression = `headers().keys()` + warnForSyncAccess(route, expression) + return underlyingHeaders.keys.apply(underlyingHeaders, arguments as any) + }, + }, + values: { + value: function values() { + const expression = `headers().values()` + warnForSyncAccess(route, expression) + return underlyingHeaders.values.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + entries: { + value: function entries() { + const expression = `headers().entries()` + warnForSyncAccess(route, expression) + return underlyingHeaders.entries.apply( + underlyingHeaders, + arguments as any + ) + }, + }, + [Symbol.iterator]: { + value: function () { + warnForSyncIteration(route) + return underlyingHeaders[Symbol.iterator].apply( + underlyingHeaders, + arguments as any + ) + }, + }, + } satisfies HeadersExtensions) + + return promise +} + +function describeNameArg(arg: unknown) { + return typeof arg === 'string' ? `'${arg}'` : '...' +} + +function warnForSyncIteration(route?: string) { + const prefix = route ? ` In route ${route} ` : '' + console.error( + `${prefix}headers were iterated implicitly with something like \`for...of headers())\` or \`[...headers()]\`, or explicitly with \`headers()[Symbol.iterator]()\`. \`headers()\` now returns a Promise and the return value should be awaited before attempting to iterate over headers. In this version of Next.js iterating headers without awaiting first is still supported to facilitate migration but in a future version you will be required to await the result. If this \`headers()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}header property was accessed directly with \`${expression}\`. \`headers()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying headers instance. In this version of Next.js direct access to \`${expression}\` is still supported to facilitate migration but in a future version you will be required to await the result. If this \`headers()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) +} - return new DraftMode(requestStore.draftMode) +type HeadersExtensions = { + [K in keyof ReadonlyHeaders]: unknown } diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_boundary/page.tsx index 885890ca6cfbc..f4c6c64dd902b 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_boundary/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_boundary/page.tsx @@ -30,7 +30,7 @@ export default async function Page() { async function ComponentThatReadsHeaders() { let sentinelHeader try { - sentinelHeader = headers().get('x-sentinel') + sentinelHeader = (await headers()).get('x-sentinel') if (!sentinelHeader) { sentinelHeader = '~not-found~' } diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_root/page.tsx index 6228c4531413a..8842f34697f0c 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_root/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_headers_root/page.tsx @@ -26,7 +26,7 @@ export default async function Page() { async function ComponentThatReadsHeaders() { let sentinelHeader try { - sentinelHeader = headers().get('x-sentinel') + sentinelHeader = (await headers()).get('x-sentinel') if (!sentinelHeader) { sentinelHeader = '~not-found~' } diff --git a/test/e2e/app-dir/dynamic-io/app/headers/exercise/async/page.tsx b/test/e2e/app-dir/dynamic-io/app/headers/exercise/async/page.tsx new file mode 100644 index 0000000000000..482027458ba38 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/exercise/async/page.tsx @@ -0,0 +1,32 @@ +import { headers } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +import { AllComponents } from '../commponents' + +export default async function Page() { + const allHeaders = await headers() + + const xSentinelValues = new Set() + for (let [headerName, headerValue] of allHeaders.entries()) { + if (headerName.startsWith('x-sentinel')) { + xSentinelValues.add(headerValue) + } + } + + return ( + <> +

+ This page will exercise a number of APIs on the headers() instance by + first awaiting it. This is the correct way to consume headers() and this + test partially exists to ensure the behavior between sync and async + access is consistent for the time where you are permitted to do either +

+ +
{getSentinelValue()}
+ + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/headers/exercise/commponents.tsx b/test/e2e/app-dir/dynamic-io/app/headers/exercise/commponents.tsx new file mode 100644 index 0000000000000..17fc75026f668 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/exercise/commponents.tsx @@ -0,0 +1,341 @@ +export function AllComponents({ + headers, + xSentinelValues, + expression, +}: { + headers: T + xSentinelValues: Set + expression: string +}) { + return ( + <> + + + + + + + + + + + + + + ) +} + +function Append({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let result: string + try { + headers.append('x-sentinel', ' world') + result = 'no error' + } catch (e) { + result = e.message + } + return ( +
+

{expression}.append('...')

+
    +
  • + + : {result} +
  • +
  • + + + : {headers.get('x-sentinel')} + +
  • +
+
+ ) +} + +function Delete({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let result = 'no error' + try { + headers.delete('x-sentinel') + } catch (e) { + result = e.message + } + return ( +
+

{expression}.delete('...')

+
    +
  • + + : {result} +
  • +
  • + + + : {headers.get('x-sentinel')} + +
  • +
+
+ ) +} + +function Get({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + return ( +
+

{expression}.get('...')

+
+
{headers.get('x-sentinel')}
+
+
+ ) +} + +function Has({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + return ( +
+

{expression}.has('...')

+
    +
  • + + : {'' + headers.has('x-sentinel')} +
  • +
  • + + + : {'' + headers.has('x-sentinel-foobar')} + +
  • +
+
+ ) +} + +function SetExercise({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let result = 'no error' + try { + headers.set('x-sentinel', 'goodbye') + } catch (e) { + result = e.message + } + return ( +
+

{expression}.set('...')

+
    +
  • + + : {result} +
  • +
  • + + : {headers.get('x-sentinel')} +
  • +
+
+ ) +} + +function GetSetCookie({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + const result = headers.getSetCookie() + return ( +
+

{expression}.getSetCookie()

+ +
+ ) +} + +function ForEach({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let output = [] + headers.forEach((value, header) => { + if (header.startsWith('x-sentinel')) { + output.push( +
+
{value}
+
+ ) + } + }) + + return ( +
+

{expression}.forEach(...)

+ {output.length ? output :
no headers found
} +
+ ) +} + +function Keys({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let output = [] + for (let header of headers.keys()) { + if (header.startsWith('x-sentinel')) { + output.push( +
  • + {header} +
  • + ) + } + } + + return ( +
    +

    {expression}.keys(...)

    + {output.length ?
      {output}
    :
    no headers found
    } +
    + ) +} + +function Values({ + headers, + expression, + xSentinelValues, +}: { + headers: Headers + expression: string + xSentinelValues: Set +}) { + let output = [] + for (let value of headers.values()) { + if (xSentinelValues.has(value)) { + output.push( +
  • + {value} +
  • + ) + } + } + + return ( +
    +

    {expression}.values()

    + {output.length ?
      {output}
    :
    no headers found
    } +
    + ) +} + +function Entries({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let output = [] + for (let entry of headers.entries()) { + if (entry[0].startsWith('x-sentinel')) { + output.push( +
  • + {entry[1]} +
  • + ) + } + } + + return ( +
    +

    {expression}.entries()

    + {output.length ?
      {output}
    :
    no headers found
    } +
    + ) +} + +function ForOf({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let output = [] + for (let [headerName, value] of headers) { + if (headerName.startsWith('x-sentinel')) { + output.push( +
    +
    {value}
    +
    + ) + } + } + + return ( +
    +

    for...of {expression}

    + {output.length ? output :
    no headers found
    } +
    + ) +} + +function Spread({ + headers, + expression, +}: { + headers: Headers + expression: string +}) { + let output = [...headers] + .filter(([headerName]) => headerName.startsWith('x-sentinel')) + .map((v) => { + return ( +
    +
    {v[1]}
    +
    + ) + }) + + return ( +
    +

    ...{expression}

    + {output.length ? output :
    no headers found
    } +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/headers/exercise/sync/page.tsx b/test/e2e/app-dir/dynamic-io/app/headers/exercise/sync/page.tsx new file mode 100644 index 0000000000000..56e783bd96816 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/exercise/sync/page.tsx @@ -0,0 +1,32 @@ +import { headers, type UnsafeUnwrappedHeaders } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +import { AllComponents } from '../commponents' + +export default async function Page() { + const xSentinelValues = new Set() + // We use the async form here to avoid triggering dev warnings. this is not direclty being + // aserted, it just helps us do assertions in our AllComponents + for (let [headerName, headerValue] of (await headers()).entries()) { + if (headerName.startsWith('x-sentinel')) { + xSentinelValues.add(headerValue) + } + } + + const allHeaders = headers() as unknown as UnsafeUnwrappedHeaders + return ( + <> +

    + This page will exercise a number of APIs on the headers() instance + directly (without awaiting it as a promise). It should not produce + runtime errors but it will warn in dev +

    + +
    {getSentinelValue()}
    + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_boundary/page.tsx new file mode 100644 index 0000000000000..0d43fc61f315d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_boundary/page.tsx @@ -0,0 +1,39 @@ +import { Suspense } from 'react' +import { headers } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +/** + * This test case is constructed to demonstrate how using the async form of cookies can lead to a better + * prerender with dynamic IO when PPR is on. There is no difference when PPR is off. When PPR is on the second component + * can finish rendering before the prerender completes and so we can produce a static shell where the Fallback closest + * to Cookies access is read + */ +export default async function Page() { + return ( + <> + + + + +
    {getSentinelValue()}
    + + ) +} + +async function Component() { + const hasHeader = (await headers()).has('x-sentinel') + if (hasHeader) { + return ( +
    + header{' '} + {(await headers()).get('x-sentinel')} +
    + ) + } else { + return
    no header found
    + } +} + +function ComponentTwo() { + return

    footer

    +} diff --git a/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_root/page.tsx new file mode 100644 index 0000000000000..39a354a81d4e8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/async_root/page.tsx @@ -0,0 +1,26 @@ +import { headers } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> + +
    {getSentinelValue()}
    + + ) +} + +async function Component() { + const hasHeader = (await headers()).has('x-sentinel') + if (hasHeader) { + return ( +
    + header{' '} + {(await headers()).get('x-sentinel')} +
    + ) + } else { + return
    no header found
    + } +} diff --git a/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/pass-deeply/page.tsx b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/pass-deeply/page.tsx new file mode 100644 index 0000000000000..f267e7a37c5d7 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/pass-deeply/page.tsx @@ -0,0 +1,66 @@ +import { Suspense } from 'react' +import { headers } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + const pendingHeaders = headers() + return ( +
    +

    Deep Header Reader

    +

    + This component was passed the headers promise returned by `headers()`. + It is rendered inside a Suspense boundary and it takes a second to + resolve so when rendering the page you should see the Suspense fallback + content before revealing the header value even though headers was called + at the page root. +

    +

    + If dynamicIO is turned off the `headers()` call would trigger a dynamic + point at the callsite and the suspense boundary would also be blocked + for over one second +

    + +

    loading header data...

    +
    {getSentinelValue()}
    + + } + > + +
    +
    + ) +} + +async function DeepHeaderReader({ + pendingHeaders, +}: { + pendingHeaders: ReturnType +}) { + let output: Array = [] + for (const [name, value] of await pendingHeaders) { + if (name.startsWith('x-sentinel')) { + output.push( + + {name} + {value} + + ) + } + } + await new Promise((r) => setTimeout(r, 1000)) + return ( + <> + + + + + + {output} +
    Header NameHeader Value
    +
    {getSentinelValue()}
    + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_boundary/page.tsx new file mode 100644 index 0000000000000..272c5a791200d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_boundary/page.tsx @@ -0,0 +1,39 @@ +import { Suspense } from 'react' +import { headers, type UnsafeUnwrappedHeaders } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' +/** + * This test case is constructed to demonstrate the deopting behavior of synchronously + * accesing dynamic data like headers. won't be able to render before we abort + * to it will bubble up to the root and mark the whoe page as dynamic when PPR is one. There + * is no real change in behavior when PPR is off. + */ +export default async function Page() { + return ( + <> + + + + +
    {getSentinelValue()}
    + + ) +} + +function Component() { + const _headers = headers() as unknown as UnsafeUnwrappedHeaders + const hasHeader = _headers.has('x-sentinel') + if (hasHeader) { + return ( +
    + header {_headers.get('x-sentinel')} +
    + ) + } else { + return
    no header found
    + } +} + +function ComponentTwo() { + return

    footer

    +} diff --git a/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_root/page.tsx new file mode 100644 index 0000000000000..b0e4a1ab1c2c0 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/headers/static-behavior/sync_root/page.tsx @@ -0,0 +1,26 @@ +import { headers, type UnsafeUnwrappedHeaders } from 'next/headers' + +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> + +
    {getSentinelValue()}
    + + ) +} + +function Component() { + const _headers = headers() as unknown as UnsafeUnwrappedHeaders + const hasHeader = _headers.has('x-sentinel') + if (hasHeader) { + return ( +
    + header {_headers.get('x-sentinel')} +
    + ) + } else { + return
    no header found
    + } +} diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts index ab797a4d1fca5..f32ca9d544059 100644 --- a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts @@ -284,86 +284,54 @@ describe('dynamic-io', () => { if (WITH_PPR) { it('should partially prerender pages that use `headers()`', async () => { - let $ = await next.render$( - '/cases/dynamic_api_headers_boundary', - {}, - { - headers: { - 'x-sentinel': 'my sentinel', - }, - } - ) + let $ = await next.render$('/cases/dynamic_api_headers_boundary') if (isNextDev) { expect($('#layout').text()).toBe('at runtime') expect($('#page').text()).toBe('at runtime') expect($('#inner').text()).toBe('at runtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } else { expect($('#layout').text()).toBe('at buildtime') expect($('#page').text()).toBe('at buildtime') expect($('#inner').text()).toBe('at buildtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } - $ = await next.render$( - '/cases/dynamic_api_headers_root', - {}, - { - headers: { - 'x-sentinel': 'my sentinel', - }, - } - ) + $ = await next.render$('/cases/dynamic_api_headers_root') if (isNextDev) { expect($('#layout').text()).toBe('at runtime') expect($('#page').text()).toBe('at runtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } else { expect($('#layout').text()).toBe('at runtime') expect($('#page').text()).toBe('at runtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } }) } else { it('should not prerender pages that use `headers()`', async () => { - let $ = await next.render$( - '/cases/dynamic_api_headers_boundary', - {}, - { - headers: { - 'x-sentinel': 'my sentinel', - }, - } - ) + let $ = await next.render$('/cases/dynamic_api_headers_boundary') if (isNextDev) { expect($('#layout').text()).toBe('at runtime') expect($('#page').text()).toBe('at runtime') expect($('#inner').text()).toBe('at runtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } else { expect($('#layout').text()).toBe('at runtime') expect($('#page').text()).toBe('at runtime') expect($('#inner').text()).toBe('at runtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } - $ = await next.render$( - '/cases/dynamic_api_headers_root', - {}, - { - headers: { - 'x-sentinel': 'my sentinel', - }, - } - ) + $ = await next.render$('/cases/dynamic_api_headers_root') if (isNextDev) { expect($('#layout').text()).toBe('at runtime') expect($('#page').text()).toBe('at runtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } else { expect($('#layout').text()).toBe('at runtime') expect($('#page').text()).toBe('at runtime') - expect($('#value').text()).toBe('my sentinel') + expect($('#value').text()).toBe('hello') } }) } @@ -818,4 +786,324 @@ describe('dynamic-io', () => { expect(i).toBe(cookieWarnings.length) } }) + + if (WITH_PPR) { + it('should partially prerender pages that use async headers', async () => { + let $ = await next.render$('/headers/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + + it('should partially prerender pages that use sync headers', async () => { + let $ = await next.render$('/headers/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } else { + it('should produce dynamic pages when using async or sync headers', async () => { + let $ = await next.render$('/headers/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } + + if (WITH_PPR) { + it('should be able to pass headers as a promise to another component and trigger an intermediate Suspense boundary', async () => { + const $ = await next.render$('/headers/static-behavior/pass-deeply') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#fallback').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#fallback').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at runtime') + } + }) + } + + it('should be able to access headers properties asynchronously', async () => { + let $ = await next.render$('/headers/exercise/async', {}) + let cookieWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /headers/exercise')) + + expect(cookieWarnings).toHaveLength(0) + + // (await headers()).append('...', '...') + expect($('#append-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#append-value-x-sentinel').text()).toContain('hello') + + // (await headers()).delete('...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + + // (await headers()).get('...') + expect($('#get-x-sentinel').text()).toContain('hello') + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + + // (await headers()).set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + + // (await headers()).getSetCookie() + // This is always empty because headers() represents Request headers + // not response headers and is not mutable. + expect($('#get-set-cookie').text()).toEqual('[]') + + // (await headers()).forEach(...) + expect($('#for-each-x-sentinel').text()).toContain('hello') + expect($('#for-each-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#for-each-x-sentinel-rand').length).toBe(1) + + // (await headers()).keys(...) + expect($('#keys-x-sentinel').text()).toContain('x-sentinel') + expect($('#keys-x-sentinel-path').text()).toContain('x-sentinel-path') + expect($('#keys-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // (await headers()).values(...) + expect($('[data-class="values"]').text()).toContain('hello') + expect($('[data-class="values"]').text()).toContain( + '/headers/exercise/async' + ) + expect($('[data-class="values"]').length).toBe(3) + + // (await headers()).entries(...) + expect($('#entries-x-sentinel').text()).toContain('hello') + expect($('#entries-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#entries-x-sentinel-rand').length).toBe(1) + + // for...of (await headers()) + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#for-of-x-sentinel-rand').length).toBe(1) + + // ...(await headers()) + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#spread-x-sentinel-rand').length).toBe(1) + }) + + it('should be able to access headers properties synchronously', async () => { + let $ = await next.render$('/headers/exercise/sync', {}) + let headerWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /headers/exercise')) + + if (!isNextDev) { + expect(headerWarnings).toHaveLength(0) + } + let i = 0 + + // headers().append('...', '...') + expect($('#append-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#append-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain( + "headers().append('x-sentinel', ...)" + ) + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // headers().delete('...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().delete('x-sentinel')") + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // headers().get('...') + expect($('#get-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().has('x-sentinel')") + expect(headerWarnings[i++]).toContain( + "headers().has('x-sentinel-foobar')" + ) + } + + // headers().set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().set('x-sentinel', ...)") + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // headers().getSetCookie() + // This is always empty because headers() represents Request headers + // not response headers and is not mutable. + expect($('#get-set-cookie').text()).toEqual('[]') + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().getSetCookie()') + } + + // headers().forEach(...) + expect($('#for-each-x-sentinel').text()).toContain('hello') + expect($('#for-each-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#for-each-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().forEach(...)') + } + + // headers().keys(...) + expect($('#keys-x-sentinel').text()).toContain('x-sentinel') + expect($('#keys-x-sentinel-path').text()).toContain('x-sentinel-path') + expect($('#keys-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().keys()') + } + + // headers().values(...) + expect($('[data-class="values"]').text()).toContain('hello') + expect($('[data-class="values"]').text()).toContain( + '/headers/exercise/sync' + ) + expect($('[data-class="values"]').length).toBe(3) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().values()') + } + + // headers().entries(...) + expect($('#entries-x-sentinel').text()).toContain('hello') + expect($('#entries-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#entries-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().entries()') + } + + // for...of headers() + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#for-of-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('for...of headers()') + } + + // ...headers() + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#spread-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('...headers()') + } + + if (isNextDev) { + expect(i).toBe(headerWarnings.length) + } + }) }) diff --git a/test/e2e/app-dir/dynamic-io/middleware.ts b/test/e2e/app-dir/dynamic-io/middleware.ts index 872cb5fd8581b..ff76b1470b874 100644 --- a/test/e2e/app-dir/dynamic-io/middleware.ts +++ b/test/e2e/app-dir/dynamic-io/middleware.ts @@ -2,7 +2,21 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' export function middleware(request: NextRequest) { - const response = NextResponse.next() + // Clone the request headers and set a new header `x-hello-from-middleware1` + const requestHeaders = new Headers(request.headers) + requestHeaders.set('x-sentinel', 'hello') + requestHeaders.set('x-sentinel-path', request.nextUrl.pathname) + requestHeaders.set( + 'x-sentinel-rand', + ((Math.random() * 100000) | 0).toString(16) + ) + + const response = NextResponse.next({ + request: { + // New request headers + headers: requestHeaders, + }, + }) response.cookies.set('x-sentinel', 'hello', { httpOnly: true, sameSite: 'strict', @@ -25,5 +39,6 @@ export function middleware(request: NextRequest) { path: '/', } ) + return response } From e79036f58d8fc67ac905c8b1ad66242edb054d28 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 27 Aug 2024 17:01:50 -0700 Subject: [PATCH 04/14] Update tests to use new async form --- test/e2e/app-dir/actions/app/client/actions.js | 2 +- test/e2e/app-dir/actions/app/header/actions.ts | 2 +- .../app/redirects/action-redirect/redirect-target/page.js | 2 +- test/e2e/app-dir/app-routes/app/dynamic/route.ts | 2 +- test/e2e/app-dir/app-routes/app/edge/headers/route.ts | 4 ++-- test/e2e/app-dir/app-routes/app/hooks/headers/route.ts | 2 +- .../app-dir/dynamic-io/app/routes/dynamic-headers/route.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/e2e/app-dir/actions/app/client/actions.js b/test/e2e/app-dir/actions/app/client/actions.js index 612a450487187..0da3fc36e220f 100644 --- a/test/e2e/app-dir/actions/app/client/actions.js +++ b/test/e2e/app-dir/actions/app/client/actions.js @@ -6,7 +6,7 @@ import { redirect } from 'next/navigation' import { headers, cookies } from 'next/headers' export async function getHeaders() { - console.log('accept header:', headers().get('accept')) + console.log('accept header:', (await headers()).get('accept')) ;(await cookies()).set('test-cookie', Date.now()) } diff --git a/test/e2e/app-dir/actions/app/header/actions.ts b/test/e2e/app-dir/actions/app/header/actions.ts index 4f2e1cc1a40b1..1c210ca46faf9 100644 --- a/test/e2e/app-dir/actions/app/header/actions.ts +++ b/test/e2e/app-dir/actions/app/header/actions.ts @@ -16,7 +16,7 @@ export async function getCookie(name) { } export async function getHeader(name) { - return headers().get(name) + return (await headers()).get(name) } export async function setCookie(name, value) { diff --git a/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js b/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js index d60f23a6cd04f..f522e3eb08a52 100644 --- a/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js +++ b/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js @@ -3,7 +3,7 @@ import { cookies, headers } from 'next/headers' export default async function Page({ searchParams }) { const foo = (await cookies()).get('foo') const bar = (await cookies()).get('bar') - const actionHeader = headers().get('next-action') + const actionHeader = (await headers()).get('next-action') if (actionHeader) { throw new Error('Action header should not be present') } diff --git a/test/e2e/app-dir/app-routes/app/dynamic/route.ts b/test/e2e/app-dir/app-routes/app/dynamic/route.ts index 3f2f42d200617..148cfd6b137e4 100644 --- a/test/e2e/app-dir/app-routes/app/dynamic/route.ts +++ b/test/e2e/app-dir/app-routes/app/dynamic/route.ts @@ -15,7 +15,7 @@ export async function GET(req: Request) { url: req.url, headers: req.headers.get('accept'), }, - headers: headers().get('accept'), + headers: (await headers()).get('accept'), cookies: (await cookies()).get('session')?.value ?? null, }) } diff --git a/test/e2e/app-dir/app-routes/app/edge/headers/route.ts b/test/e2e/app-dir/app-routes/app/edge/headers/route.ts index 619b3c21b7ad1..7a6833be3d22a 100644 --- a/test/e2e/app-dir/app-routes/app/edge/headers/route.ts +++ b/test/e2e/app-dir/app-routes/app/edge/headers/route.ts @@ -4,7 +4,7 @@ import { getRequestMeta } from '../../../helpers' export const runtime = 'experimental-edge' -export function GET() { - const meta = getRequestMeta(headers()) +export async function GET() { + const meta = getRequestMeta(await headers()) return NextResponse.json(meta) } diff --git a/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts b/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts index 23ecc800a23b3..ed97f21309afe 100644 --- a/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts +++ b/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts @@ -2,7 +2,7 @@ import { headers } from 'next/headers' import { getRequestMeta, withRequestMeta } from '../../../helpers' export async function GET() { - const h = headers() + const h = await headers() // Put the request meta in the response directly as meta again. const meta = getRequestMeta(h) diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts index c64224be376f6..685d3a96111c1 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts @@ -5,7 +5,7 @@ import { headers } from 'next/headers' import { getSentinelValue } from '../../getSentinelValue' export async function GET(request: NextRequest, { params }: { params: {} }) { - const sentinel = headers().get('x-sentinel') + const sentinel = (await headers()).get('x-sentinel') return new Response( JSON.stringify({ value: getSentinelValue(), From d388b00302b958041e5a023037c9d742a87678e6 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 24 Sep 2024 14:28:36 -0700 Subject: [PATCH 05/14] `searchParams` is now exotic --- packages/next/server.d.ts | 4 + .../plugins/next-types-plugin/index.ts | 2 +- .../src/client/components/client-page.tsx | 70 +- packages/next/src/lib/metadata/metadata.tsx | 17 +- .../next/src/server/app-render/app-render.tsx | 52 +- .../app-render/create-component-tree.tsx | 33 +- .../server/app-render/dynamic-rendering.ts | 15 + .../next/src/server/app-render/entry-base.ts | 10 +- .../server/request/search-params.browser.ts | 132 +++ .../next/src/server/request/search-params.ts | 662 +++++++++++++-- packages/next/src/server/request/utils.ts | 9 + .../app-dir/dynamic-data/dynamic-data.test.ts | 2 +- .../page.tsx | 19 +- .../page.tsx | 17 +- .../page.tsx | 21 +- .../page.tsx | 16 +- test/e2e/app-dir/dynamic-io/app/layout.tsx | 2 +- .../search/async/client/use_boundary/page.tsx | 52 ++ .../app/search/async/client/use_root/page.tsx | 48 ++ .../async/server/await_boundary/page.tsx | 50 ++ .../search/async/server/await_root/page.tsx | 44 + .../search/async/server/use_boundary/page.tsx | 49 ++ .../app/search/async/server/use_root/page.tsx | 46 + .../sync/client/access_boundary/page.tsx | 48 ++ .../search/sync/client/access_root/page.tsx | 42 + .../search/sync/client/has_boundary/page.tsx | 54 ++ .../app/search/sync/client/has_root/page.tsx | 48 ++ .../sync/client/spread_boundary/page.tsx | 60 ++ .../search/sync/client/spread_root/page.tsx | 54 ++ .../sync/server/access_boundary/page.tsx | 46 + .../search/sync/server/access_root/page.tsx | 40 + .../search/sync/server/has_boundary/page.tsx | 52 ++ .../app/search/sync/server/has_root/page.tsx | 46 + .../sync/server/spread_boundary/page.tsx | 58 ++ .../search/sync/server/spread_root/page.tsx | 52 ++ .../dynamic-io/dynamic-io.cookies.test.ts | 287 +++++++ .../dynamic-io/dynamic-io.headers.test.ts | 334 ++++++++ .../dynamic-io/dynamic-io.search.test.ts | 802 ++++++++++++++++++ .../e2e/app-dir/dynamic-io/dynamic-io.test.ts | 597 +------------ 39 files changed, 3254 insertions(+), 738 deletions(-) create mode 100644 packages/next/src/server/request/search-params.browser.ts create mode 100644 test/e2e/app-dir/dynamic-io/app/search/async/client/use_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/async/client/use_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/async/server/await_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/async/server/await_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/async/server/use_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/async/server/use_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/client/access_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/client/access_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/client/has_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/client/has_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/server/access_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/server/access_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/server/has_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/server/has_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_boundary/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_root/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/dynamic-io.cookies.test.ts create mode 100644 test/e2e/app-dir/dynamic-io/dynamic-io.headers.test.ts create mode 100644 test/e2e/app-dir/dynamic-io/dynamic-io.search.test.ts diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index 872bbc3899ff1..3b2c664e719c0 100644 --- a/packages/next/server.d.ts +++ b/packages/next/server.d.ts @@ -14,3 +14,7 @@ export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url' export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response' export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types' export { unstable_after } from 'next/dist/server/after' +export type { + SearchParams, + UnsafeUnwrappedSearchParams, +} from 'next/dist/server/request/search-params' diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index 2199634bc4938..1b494e2c2cb2c 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -165,7 +165,7 @@ if ('generateStaticParams' in entry) { type PageParams = any export interface PageProps { params?: any - searchParams?: any + searchParams?: Promise } export interface LayoutProps { children?: React.ReactNode diff --git a/packages/next/src/client/components/client-page.tsx b/packages/next/src/client/components/client-page.tsx index bafacc9eb9e90..b50afcb16f902 100644 --- a/packages/next/src/client/components/client-page.tsx +++ b/packages/next/src/client/components/client-page.tsx @@ -1,30 +1,66 @@ 'use client' +import type { ParsedUrlQuery } from 'querystring' +import { use } from 'react' +import { InvariantError } from '../../shared/lib/invariant-error' + +import type { Params } from '../../server/request/params' + export function ClientPageRoot({ Component, - props, + params, + searchParams, }: { Component: React.ComponentType - props: { [props: string]: any } + params: Params + searchParams: Promise }) { if (typeof window === 'undefined') { + const { staticGenerationAsyncStorage } = + require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external') + + let clientSearchParams: Promise + let trackedParams: Params + // We are going to instrument the searchParams prop with tracking for the + // appropriate context. We wrap differently in prerendering vs rendering + const store = staticGenerationAsyncStorage.getStore() + if (!store) { + throw new InvariantError( + 'Expected staticGenerationStore to exist when handling searchParams in a client Page.' + ) + } + + if (store.isStaticGeneration) { + // We are in a prerender context + // We need to recover the underlying searchParams from the server + const { reifyClientPrerenderSearchParams } = + require('../../server/request/search-params') as typeof import('../../server/request/search-params') + clientSearchParams = reifyClientPrerenderSearchParams(store) + } else { + // We are in a dynamic context and need to unwrap the underlying searchParams + + // We can't type that searchParams is passed but since we control both the definition + // of this component and the usage of it we can assume it + const underlying = use(searchParams) + + const { reifyClientRenderSearchParams } = + require('../../server/request/search-params') as typeof import('../../server/request/search-params') + clientSearchParams = reifyClientRenderSearchParams(underlying, store) + } + const { createDynamicallyTrackedParams } = require('../../server/request/fallback-params') as typeof import('../../server/request/fallback-params') - const { createDynamicallyTrackedSearchParams } = - require('../../server/request/search-params') as typeof import('../../server/request/search-params') - - // We expect to be passed searchParams but even if we aren't we can construct one from - // an empty object. We only do this if we are in a static generation as a performance - // optimization. Ideally we'd unconditionally construct the tracked params but since - // this creates a proxy which is slow and this would happen even for client navigations - // that are done entirely dynamically and we know there the dynamic tracking is a noop - // in this dynamic case we can safely elide it. - props.searchParams = createDynamicallyTrackedSearchParams( - props.searchParams || {} + + trackedParams = createDynamicallyTrackedParams(params) + return ( + ) - props.params = props.params - ? createDynamicallyTrackedParams(props.params) - : {} + } else { + const underlying = use(searchParams) + + const { reifyClientRenderSearchParams } = + require('../../server/request/search-params.browser') as typeof import('../../server/request/search-params.browser') + const clientSearchParams = reifyClientRenderSearchParams(underlying) + return } - return } diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index ba79f472dad0f..aef17fee0bb40 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -84,24 +84,20 @@ export function createTrackedMetadataContext( // and the error will be caught by the error boundary and trigger fallbacks. export function createMetadataComponents({ tree, - query, + searchParams, metadataContext, getDynamicParamFromSegment, appUsingSizeAdjustment, errorType, - createDynamicallyTrackedSearchParams, createDynamicallyTrackedParams, }: { tree: LoaderTree - query: ParsedUrlQuery + searchParams: Promise metadataContext: MetadataContext getDynamicParamFromSegment: GetDynamicParamFromSegment appUsingSizeAdjustment: boolean errorType?: 'not-found' | 'redirect' createDynamicallyTrackedParams: CreateDynamicallyTrackedParams - createDynamicallyTrackedSearchParams: ( - searchParams: ParsedUrlQuery - ) => ParsedUrlQuery }): [React.ComponentType, () => Promise] { let currentMetadataReady: | null @@ -113,10 +109,9 @@ export function createMetadataComponents({ async function MetadataTree() { const pendingMetadata = getResolvedMetadata( tree, - query, + searchParams, getDynamicParamFromSegment, metadataContext, - createDynamicallyTrackedSearchParams, createDynamicallyTrackedParams, errorType ) @@ -177,18 +172,14 @@ export function createMetadataComponents({ async function getResolvedMetadata( tree: LoaderTree, - query: ParsedUrlQuery, + searchParams: Promise, getDynamicParamFromSegment: GetDynamicParamFromSegment, metadataContext: MetadataContext, - createDynamicallyTrackedSearchParams: ( - searchParams: ParsedUrlQuery - ) => ParsedUrlQuery, createDynamicallyTrackedParams: CreateDynamicallyTrackedParams, errorType?: 'not-found' | 'redirect' ): Promise<[any, Array]> { const errorMetadataItem: [null, null, null] = [null, null, null] const errorConvention = errorType === 'redirect' ? undefined : errorType - const searchParams = createDynamicallyTrackedSearchParams(query) const [error, metadata, viewport] = await resolveMetadata({ tree, diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 71252f1cd9f76..d9feb22829336 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -385,7 +385,7 @@ async function generateDynamicRSCPayload( const { componentMod: { tree: loaderTree, - createDynamicallyTrackedSearchParams, + createServerSearchParamsForMetadata, createDynamicallyTrackedParams, }, getDynamicParamFromSegment, @@ -400,9 +400,13 @@ async function generateDynamicRSCPayload( if (!options?.skipFlight) { const preloadCallbacks: PreloadCallbacks = [] + const searchParams = createServerSearchParamsForMetadata( + query, + staticGenerationStore + ) const [MetadataTree, getMetadataReady] = createMetadataComponents({ tree: loaderTree, - query, + searchParams, metadataContext: createTrackedMetadataContext( url.pathname, ctx.renderOpts, @@ -410,7 +414,6 @@ async function generateDynamicRSCPayload( ), getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedSearchParams, createDynamicallyTrackedParams, }) flightData = ( @@ -547,7 +550,7 @@ async function getRSCPayload( appUsingSizeAdjustment, componentMod: { GlobalError, - createDynamicallyTrackedSearchParams, + createServerSearchParamsForMetadata, createDynamicallyTrackedParams, }, requestStore: { url }, @@ -559,10 +562,14 @@ async function getRSCPayload( query ) + const searchParams = createServerSearchParamsForMetadata( + query, + staticGenerationStore + ) const [MetadataTree, getMetadataReady] = createMetadataComponents({ tree, errorType: is404 ? 'not-found' : undefined, - query, + searchParams, metadataContext: createTrackedMetadataContext( url.pathname, ctx.renderOpts, @@ -570,7 +577,6 @@ async function getRSCPayload( ), getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedSearchParams, createDynamicallyTrackedParams, }) @@ -643,23 +649,27 @@ async function getErrorRSCPayload( appUsingSizeAdjustment, componentMod: { GlobalError, - createDynamicallyTrackedSearchParams, + createServerSearchParamsForMetadata, createDynamicallyTrackedParams, }, requestStore: { url }, requestId, + staticGenerationStore, } = ctx + const searchParams = createServerSearchParamsForMetadata( + query, + staticGenerationStore + ) const [MetadataTree] = createMetadataComponents({ tree, + searchParams, // We create an untracked metadata context here because we can't postpone // again during the error render. metadataContext: createMetadataContext(url.pathname, ctx.renderOpts), errorType, - query, getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedSearchParams, createDynamicallyTrackedParams, }) @@ -1824,7 +1834,9 @@ async function prerenderToStream( let flightController = new AbortController() // We're not going to use the result of this render because the only time it could be used // is if it completes in a microtask and that's likely very rare for any non-trivial app - const firstAttemptRSCPayload = await getRSCPayload( + const firstAttemptRSCPayload = await prerenderAsyncStorage.run( + prospectiveRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 @@ -1891,7 +1903,9 @@ async function prerenderToStream( reactServerIsDynamic = true } } - const finalAttemptRSCPayload = await getRSCPayload( + const finalAttemptRSCPayload = await prerenderAsyncStorage.run( + finalRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 @@ -2132,7 +2146,9 @@ async function prerenderToStream( dynamicTracking, } - const firstAttemptRSCPayload = await getRSCPayload( + const firstAttemptRSCPayload = await prerenderAsyncStorage.run( + prospectiveRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 @@ -2197,7 +2213,9 @@ async function prerenderToStream( dynamicTracking, } - const finalAttemptRSCPayload = await getRSCPayload( + const finalAttemptRSCPayload = await prerenderAsyncStorage.run( + finalRenderPrerenderStore, + getRSCPayload, tree, ctx, res.statusCode === 404 @@ -2353,7 +2371,13 @@ async function prerenderToStream( controller: null, dynamicTracking, } - const RSCPayload = await getRSCPayload(tree, ctx, res.statusCode === 404) + const RSCPayload = await prerenderAsyncStorage.run( + reactServerPrerenderStore, + getRSCPayload, + tree, + ctx, + res.statusCode === 404 + ) const reactServerResult = (reactServerPrerenderResult = await createReactServerPrerenderResultFromRender( prerenderAsyncStorage.run( diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index e624de6177ee1..edbb67e98bc4b 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -91,8 +91,8 @@ async function createComponentTreeInternal({ RenderFromTemplateContext, ClientPageRoot, ClientSegmentRoot, - createUntrackedSearchParams, - createDynamicallyTrackedSearchParams, + createServerSearchParamsForServerPage, + createServerSearchParamsForClientPage, createDynamicallyTrackedParams, serverHooks: { DynamicServerError }, Postpone, @@ -532,20 +532,27 @@ async function createComponentTreeInternal({ // Assign searchParams to props if this is a page let pageElement: React.ReactNode if (isClientComponent) { - // When we are passing searchParams to a client component Page we don't want to track the dynamic access - // here in the RSC layer because the serialization will trigger a dynamic API usage. - // Instead we pass the searchParams untracked but we wrap the Page in a root client component - // which can among other things adds the dynamic tracking before rendering the page. - // @TODO make the root wrapper part of next-app-loader so we don't need the extra client component - props.params = currentParams - props.searchParams = createUntrackedSearchParams(query) - pageElement = + const params = currentParams + const searchParams = createServerSearchParamsForClientPage( + query, + staticGenerationStore + ) + pageElement = ( + + ) } else { // If we are passing searchParams to a server component Page we need to track their usage in case // the current render mode tracks dynamic API usage. - props.params = createDynamicallyTrackedParams(currentParams) - props.searchParams = createDynamicallyTrackedSearchParams(query) - pageElement = + const params = createDynamicallyTrackedParams(currentParams) + const searchParams = createServerSearchParamsForServerPage( + query, + staticGenerationStore + ) + pageElement = } return [ actualSegment, diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index f521974530cc6..62ea097c973a4 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -497,3 +497,18 @@ export function createPostponedAbortSignal(reason: string): AbortSignal { } return controller.signal } + +export function annotateDynamicAccess( + expression: string, + prerenderStore: PrerenderStore +) { + const dynamicTracking = prerenderStore.dynamicTracking + if (dynamicTracking) { + dynamicTracking.dynamicAccesses.push({ + stack: dynamicTracking.isDebugDynamicAccesses + ? new Error().stack + : undefined, + expression, + }) + } +} diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index f4e0ce9fe0146..86d10df430613 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -18,8 +18,9 @@ import { actionAsyncStorage } from '../../client/components/action-async-storage import { ClientPageRoot } from '../../client/components/client-page' import { ClientSegmentRoot } from '../../client/components/client-segment' import { - createUntrackedSearchParams, - createDynamicallyTrackedSearchParams, + createServerSearchParamsForServerPage, + createServerSearchParamsForClientPage, + createServerSearchParamsForMetadata, } from '../request/search-params' import { createDynamicallyTrackedParams } from '../request/fallback-params' import * as serverHooks from '../../client/components/hooks-server-context' @@ -48,8 +49,9 @@ export { staticGenerationAsyncStorage, requestAsyncStorage, actionAsyncStorage, - createUntrackedSearchParams, - createDynamicallyTrackedSearchParams, + createServerSearchParamsForServerPage, + createServerSearchParamsForClientPage, + createServerSearchParamsForMetadata, createDynamicallyTrackedParams, serverHooks, preloadStyle, diff --git a/packages/next/src/server/request/search-params.browser.ts b/packages/next/src/server/request/search-params.browser.ts new file mode 100644 index 0000000000000..e6599bd1cd579 --- /dev/null +++ b/packages/next/src/server/request/search-params.browser.ts @@ -0,0 +1,132 @@ +import type { SearchParams } from './search-params' + +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { + describeStringPropertyAccess, + describeHasCheckingStringProperty, +} from './utils' + +export function reifyClientRenderSearchParams( + underlyingSearchParams: SearchParams +): Promise { + if (process.env.NODE_ENV === 'development') { + return makeUntrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams + ) + } else { + return makeUntrackedExoticSearchParams(underlyingSearchParams) + } +} + +interface CacheLifetime {} +const CachedSearchParams = new WeakMap>() + +function makeUntrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams: SearchParams +): Promise { + const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams) + if (cachedSearchParams) { + return cachedSearchParams + } + + const promise = Promise.resolve(underlyingSearchParams) + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + }, + value: { + value: underlyingSearchParams, + }, + }) + + Object.keys(underlyingSearchParams).forEach((prop) => { + if (Reflect.has(promise, prop)) { + // We can't assign a value over a property on the promise. The only way to + // access this is if you await the promise and recover the underlying searchParams object. + } else { + Object.defineProperty(promise, prop, { + value: underlyingSearchParams[prop], + writable: false, + enumerable: true, + }) + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Reflect.has(target, prop)) { + return ReflectAdapter.get(target, prop, receiver) + } else if (typeof prop === 'symbol') { + return undefined + } else { + const expression = describeStringPropertyAccess('searchParams', prop) + warnForSyncAccess(expression) + return underlyingSearchParams[prop] + } + }, + has(target, prop) { + if (Reflect.has(target, prop)) { + return true + } else if (typeof prop === 'symbol') { + // searchParams never has symbol properties containing searchParam data + // and we didn't match above so we just return false here. + return false + } else { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + warnForSyncAccess(expression) + return Reflect.has(underlyingSearchParams, prop) + } + }, + ownKeys(target) { + warnForSyncSpread() + return Reflect.ownKeys(target) + }, + }) + + CachedSearchParams.set(underlyingSearchParams, proxiedPromise) + return proxiedPromise +} + +function makeUntrackedExoticSearchParams( + underlyingSearchParams: SearchParams +): Promise { + const promise = Promise.resolve(underlyingSearchParams) + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + }, + value: { + value: underlyingSearchParams, + }, + }) + + Object.keys(underlyingSearchParams).forEach((prop) => { + if (Reflect.has(promise, prop)) { + // We can't assign a value over a property on the promise. The only way to + // access this is if you await the promise and recover the underlying searchParams object. + } else { + Object.defineProperty(promise, prop, { + value: underlyingSearchParams[prop], + writable: false, + enumerable: true, + }) + } + }) + + return promise +} + +function warnForSyncAccess(expression: string) { + console.error( + `A searchParam property was accessed directly with ${expression}. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to faciliate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForSyncSpread() { + console.error( + `the keys of \`searchParams\` were accessed through something like \`Object.keys(searchParams)\` or \`{...searchParams}\`. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to faciliate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index 1a8a7fe5e98a6..dbe6a4b551d7e 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -1,73 +1,621 @@ -import type { ParsedUrlQuery } from 'querystring' +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' -import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' -import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + postponeWithTracking, + trackDynamicDataInDynamicRender, + annotateDynamicAccess, +} from '../app-render/dynamic-rendering' + +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { InvariantError } from '../../shared/lib/invariant-error' +import { makeHangingPromise } from '../dynamic-rendering-utils' +import { + describeStringPropertyAccess, + describeHasCheckingStringProperty, + throwWithStaticGenerationBailoutErrorWithDynamicError, +} from './utils' + +export type SearchParams = { [key: string]: string | string[] | undefined } /** - * Takes a ParsedUrlQuery object and either returns it unmodified or returns an empty object + * In this version of Next.js the `params` prop passed to Layouts, Pages, and other Segments is a Promise. + * However to facilitate migration to this new Promise type you can currently still access params directly on the Promise instance passed to these Segments. + * The `UnsafeUnwrappedSearchParams` type is available if you need to temporarily access the underlying params without first awaiting or `use`ing the Promise. + * + * In a future version of Next.js the `params` prop will be a plain Promise and this type will be removed. + * + * Typically instances of `params` can be updated automatically to be treated as a Promise by a codemod published alongside this Next.js version however if you + * have not yet run the codemod of the codemod cannot detect certain instances of `params` usage you should first try to refactor your code to await `params`. + * + * If refactoring is not possible but you still want to be able to access params directly without typescript errors you can cast the params Promise to this type * - * Even though we do not track read access on the returned searchParams we need to - * return an empty object if we are doing a 'force-static' render. This is to ensure - * we don't encode the searchParams into the flight data. + * ```tsx + * type Props = { searchParams: Promise<{ foo: string }> } + * + * export default async function Page(props: Props) { + * const { searchParams } = (props.searchParams as unknown as UnsafeUnwrappedSearchParams) + * return ... + * } + * ``` + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated */ -export function createUntrackedSearchParams( - searchParams: ParsedUrlQuery -): ParsedUrlQuery { - const store = staticGenerationAsyncStorage.getStore() - if (store && store.forceStatic) { - return {} +export type UnsafeUnwrappedSearchParams

    = + P extends Promise ? Omit : never + +export function reifyClientPrerenderSearchParams( + staticGenerationStore: StaticGenerationStore +) { + return createPrerenderSearchParams(staticGenerationStore) +} + +export function reifyClientRenderSearchParams( + underlyingSearchParams: SearchParams, + staticGenerationStore: StaticGenerationStore +) { + return createRenderSearchParams(underlyingSearchParams, staticGenerationStore) +} + +// generateMetadata always runs in RSC context so it is equivalent to a Server Page Component +export const createServerSearchParamsForMetadata = + createServerSearchParamsForServerPage + +export function createServerSearchParamsForServerPage( + underlyingSearchParams: SearchParams, + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.isStaticGeneration) { + return createPrerenderSearchParams(staticGenerationStore) } else { - return searchParams + return createRenderSearchParams( + underlyingSearchParams, + staticGenerationStore + ) } } -/** - * Takes a ParsedUrlQuery object and returns a Proxy that tracks read access to the object - * - * If running in the browser will always return the provided searchParams object. - * When running during SSR will return empty during a 'force-static' render and - * otherwise it returns a searchParams object which tracks reads to trigger dynamic rendering - * behavior if appropriate - */ -export function createDynamicallyTrackedSearchParams( - searchParams: ParsedUrlQuery -): ParsedUrlQuery { - const store = staticGenerationAsyncStorage.getStore() - if (!store) { - // we assume we are in a route handler or page render. just return the searchParams - return searchParams - } else if (store.forceStatic) { - // If we forced static we omit searchParams entirely. This is true both during SSR - // and browser render because we need there to be parity between these environments - return {} +export function createServerSearchParamsForClientPage( + underlying: SearchParams, + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.isStaticGeneration) { + return createPassthroughPrerenderSearchParams(staticGenerationStore) + } else { + return Promise.resolve(underlying) + } +} + +function createPrerenderSearchParams( + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // dictionary object. + return Promise.resolve({}) + } + + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (isDynamicIOPrerender(prerenderStore)) { + // We are in a dynamicIO (PPR or otherwise) prerender + return makeAbortingExoticSearchParams( + staticGenerationStore.route, + prerenderStore + ) + } + } + + // We are in a legacy static generation and need to interrupt the prerender + // when search params are accessed. + return makeErroringExoticSearchParams(staticGenerationStore, prerenderStore) +} + +function createPassthroughPrerenderSearchParams( + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // dictionary object. + return Promise.resolve({}) + } + + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (prerenderStore.controller || prerenderStore.cacheSignal) { + // We're prerendering in a mode that aborts (dynamicIO) and should stall + // the promise to ensure the RSC side is considered dynamic + return makeHangingPromise() + } + } + // We're prerendering in a mode that does not aborts. We resolve the promise without + // any tracking because we're just transporting a value from server to client where the tracking + // will be applied. + return Promise.resolve({}) +} + +function createRenderSearchParams( + underlyingSearchParams: SearchParams, + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.forceStatic) { + // When using forceStatic we override all other logic and always just return an empty + // dictionary object. + return Promise.resolve({}) } else { - // We need to track dynamic access with a Proxy. If `dynamic = "error"`, we use this information - // to fail the build. This also signals to the patched fetch that it's inside - // of a dynamic render and should bail from data cache. We implement get, has, and ownKeys because - // these can all be used to exfiltrate information about searchParams. - - const trackedSearchParams: ParsedUrlQuery = store.isStaticGeneration - ? {} - : searchParams - - return new Proxy(trackedSearchParams, { - get(target, prop, receiver) { - if (typeof prop === 'string') { - trackDynamicDataAccessed(store, `searchParams.${prop}`) + if (process.env.NODE_ENV === 'development') { + return makeDynamicallyTrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams, + staticGenerationStore + ) + } else { + return makeUntrackedExoticSearchParams( + underlyingSearchParams, + staticGenerationStore + ) + } + } +} + +interface CacheLifetime {} +const CachedSearchParams = new WeakMap>() + +function makeAbortingExoticSearchParams( + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(prerenderStore) + if (cachedSearchParams) { + return cachedSearchParams + } + + const promise = makeHangingPromise() + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Object.hasOwn(promise, prop)) { + // The promise has this property directly. we must return it. + // We know it isn't a dynamic access because it can only be something + // that was previously written to the promise and thus not an underlying searchParam value + return ReflectAdapter.get(target, prop, receiver) + } + + switch (prop) { + case 'then': { + const expression = + '`await searchParams`, `searchParams.then`, or similar' + annotateDynamicAccess(expression, prerenderStore) + return ReflectAdapter.get(target, prop, receiver) + } + case 'status': { + const expression = + '`use(searchParams)`, `searchParams.status`, or similar' + annotateDynamicAccess(expression, prerenderStore) + return ReflectAdapter.get(target, prop, receiver) + } + default: { + if (typeof prop === 'string') { + const expression = describeStringPropertyAccess( + 'searchParams', + prop + ) + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + } + return ReflectAdapter.get(target, prop, receiver) } + } + }, + has(target, prop) { + // We don't expect key checking to be used except for testing the existence of + // searchParams so we make all has tests trigger dynamic. this means that `promise.then` + // can resolve to the then function on the Promise prototype but 'then' in promise will assume + // you are testing whether the searchParams has a 'then' property. + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + } + return ReflectAdapter.has(target, prop) + }, + ownKeys() { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + }) + + CachedSearchParams.set(prerenderStore, proxiedPromise) + return proxiedPromise +} + +function makeErroringExoticSearchParams( + staticGenerationStore: StaticGenerationStore, + prerenderStore: undefined | PrerenderStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(staticGenerationStore) + if (cachedSearchParams) { + return cachedSearchParams + } + + const underlyingSearchParams = {} + // For search params we don't construct a ReactPromise because we want to interrupt + // rendering on any property access that was not set from outside and so we only want + // to have properties like value and status if React sets them. + const promise = Promise.resolve(underlyingSearchParams) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Object.hasOwn(promise, prop)) { + // The promise has this property directly. we must return it. + // We know it isn't a dynamic access because it can only be something + // that was previously written to the promise and thus not an underlying searchParam value return ReflectAdapter.get(target, prop, receiver) - }, - has(target, prop) { - if (typeof prop === 'string') { - trackDynamicDataAccessed(store, `searchParams.${prop}`) + } + + switch (prop) { + case 'then': { + const expression = + '`await searchParams`, `searchParams.then`, or similar' + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + return + } + case 'status': { + const expression = + '`use(searchParams)`, `searchParams.status`, or similar' + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + return + } + default: { + if (typeof prop === 'string') { + const expression = describeStringPropertyAccess( + 'searchParams', + prop + ) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration( + expression, + staticGenerationStore + ) + } + } + return ReflectAdapter.get(target, prop, receiver) + } + } + }, + has(target, prop) { + // We don't expect key checking to be used except for testing the existence of + // searchParams so we make all has tests trigger dynamic. this means that `promise.then` + // can resolve to the then function on the Promise prototype but 'then' in promise will assume + // you are testing whether the searchParams has a 'then' property. + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + return false + } + return ReflectAdapter.has(target, prop) + }, + ownKeys() { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + }, + }) + + CachedSearchParams.set(staticGenerationStore, proxiedPromise) + return proxiedPromise +} + +function makeUntrackedExoticSearchParams( + underlyingSearchParams: SearchParams, + store: StaticGenerationStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams) + if (cachedSearchParams) { + return cachedSearchParams + } + + // We don't use makeResolvedReactPromise here because searchParams + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(underlyingSearchParams) + CachedSearchParams.set(underlyingSearchParams, promise) + + Object.keys(underlyingSearchParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + break + } + default: { + Object.defineProperty(promise, prop, { + get() { + trackDynamicDataInDynamicRender(store) + return underlyingSearchParams[prop] + }, + set(value) { + Object.defineProperty(promise, prop, { + value, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } + } + }) + + return promise +} + +function makeDynamicallyTrackedExoticSearchParamsWithDevWarnings( + underlyingSearchParams: SearchParams, + store: StaticGenerationStore +): Promise { + const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams) + if (cachedSearchParams) { + return cachedSearchParams + } + + const proxiedProperties = new Set() + const unproxiedProperties: Array = [] + + // We have an unfortunate sequence of events that requires this initialization logic. We want to instrument the underlying + // searchParams object to detect if you are accessing values in dev. This is used for warnings and for things like the static prerender + // indicator. However when we pass this proxy to our Promise.resolve() below the VM checks if the resolved value is a promise by looking + // at the `.then` property. To our dynamic tracking logic this is indistinguishable from a `then` searchParam and so we would normally trigger + // dynamic tracking. However we know that this .then is not real dynamic access, it's just how thenables resolve in sequence. So we introduce + // this initialization concept so we omit the dynamic check until after we've constructed our resolved promise. + let promiseInitialized = false + const proxiedUnderlying = new Proxy(underlyingSearchParams, { + get(target, prop, receiver) { + if (typeof prop === 'string' && promiseInitialized) { + if (store.dynamicShouldError) { + const expression = describeStringPropertyAccess('searchParams', prop) + throwWithStaticGenerationBailoutErrorWithDynamicError( + store.route, + expression + ) + } + trackDynamicDataInDynamicRender(store) + } + return ReflectAdapter.get(target, prop, receiver) + }, + has(target, prop) { + if (typeof prop === 'string') { + if (store.dynamicShouldError) { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + throwWithStaticGenerationBailoutErrorWithDynamicError( + store.route, + expression + ) + } + } + return Reflect.has(target, prop) + }, + ownKeys(target) { + if (store.dynamicShouldError) { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + throwWithStaticGenerationBailoutErrorWithDynamicError( + store.route, + expression + ) + } + return Reflect.ownKeys(target) + }, + }) + + // We don't use makeResolvedReactPromise here because searchParams + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(proxiedUnderlying) + promise.then(() => { + promiseInitialized = true + }) + + Object.keys(underlyingSearchParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + unproxiedProperties.push(prop) + break + } + default: { + proxiedProperties.add(prop) + Object.defineProperty(promise, prop, { + get() { + return proxiedUnderlying[prop] + }, + set(newValue) { + Object.defineProperty(promise, prop, { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (typeof prop === 'string') { + if ( + // We are accessing a property that was proxied to the promise instance + proxiedProperties.has(prop) || + // We are accessing a property that doesn't exist on the promise nor the underlying + Reflect.has(target, prop) === false + ) { + const expression = describeStringPropertyAccess('searchParams', prop) + warnForSyncAccess(store.route, expression) } - return Reflect.has(target, prop) - }, - ownKeys(target) { - trackDynamicDataAccessed(store, 'searchParams') - return Reflect.ownKeys(target) - }, - }) + } + return ReflectAdapter.get(target, prop, receiver) + }, + has(target, prop) { + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + warnForSyncAccess(store.route, expression) + } + return Reflect.has(target, prop) + }, + ownKeys(target) { + warnForEnumeration(store.route, unproxiedProperties) + return Reflect.ownKeys(target) + }, + }) + + CachedSearchParams.set(underlyingSearchParams, proxiedPromise) + return proxiedPromise +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}searchParam property was accessed directly with ${expression}. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to facilitate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForEnumeration( + route: undefined | string, + missingProperties: Array +) { + const prefix = route ? ` In route ${route} ` : '' + if (missingProperties.length) { + const describedMissingProperties = + describeListOfPropertyNames(missingProperties) + console.error( + `${prefix}searchParams are being enumerated incompletely with \`{...searchParams}\`, \`Object.keys(searchParams)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`searchParams\` is now a Promise, however in the current version of Next.js direct access to the underlying searchParams object is still supported to facilitate migration to the new type. search parameter names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`searchParams\` promise.` + ) + } else { + console.error( + `${prefix}searchParams are being enumerated with \`{...searchParams}\`, \`Object.keys(searchParams)\`, or similar. \`searchParams\` is now a Promise, however in the current version of Next.js direct access to the underlying searchParams object is still supported to facilitate migration to the new type. You should update your code to await \`searchParams\` before accessing its properties.` + ) + } +} + +function describeListOfPropertyNames(properties: Array) { + switch (properties.length) { + case 0: + throw new InvariantError( + 'Expected describeListOfPropertyNames to be called with a non-empty list of strings.' + ) + case 1: + return `\`${properties[0]}\`` + case 2: + return `\`${properties[0]}\` and \`${properties[1]}\`` + default: { + let description = '' + for (let i = 0; i < properties.length - 1; i++) { + description += `\`${properties[i]}\`, ` + } + description += `, and \`${properties[properties.length - 1]}\`` + return description + } } } diff --git a/packages/next/src/server/request/utils.ts b/packages/next/src/server/request/utils.ts index 991e8e1033d2f..ebfac7d5be2ca 100644 --- a/packages/next/src/server/request/utils.ts +++ b/packages/next/src/server/request/utils.ts @@ -44,3 +44,12 @@ export function throwWithStaticGenerationBailoutError( `Route ${route} 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` ) } + +export function throwWithStaticGenerationBailoutErrorWithDynamicError( + route: string, + expression: string +): never { + throw new StaticGenBailoutError( + `Route ${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` + ) +} diff --git a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts index a4697e17a8ece..3d9f4fb5f4029 100644 --- a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts +++ b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts @@ -234,7 +234,7 @@ describe('dynamic-data with dynamic = "error"', () => { 'Error: Route /headers with `dynamic = "error"` couldn\'t be rendered statically because it used `headers`' ) expect(next.cliOutput).toMatch( - 'Error: Route /search with `dynamic = "error"` couldn\'t be rendered statically because it used `searchParams`.' + "Route /search couldn't be rendered statically because it used `await searchParams`, `searchParams.then`, or similar." ) expect(next.cliOutput).toMatch( 'Error: Route /routes/form-data/error with `dynamic = "error"` couldn\'t be rendered statically because it used `request.formData`.' diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_boundary/page.tsx index 8ce5aa040a597..67001146ae5b6 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_boundary/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_boundary/page.tsx @@ -1,10 +1,10 @@ 'use client' -import { Suspense } from 'react' +import { Suspense, use } from 'react' import { getSentinelValue } from '../../getSentinelValue' -export default function Page({ searchParams }) { +export default function Page({ searchParams }: { searchParams: Promise }) { return ( <>

    @@ -23,18 +23,18 @@ export default function Page({ searchParams }) { loading too...}> -

    {getSentinelValue()}
    {getSentinelValue()}
    ) } -function ComponentOne({ searchParams }) { +function ComponentOne({ searchParams }: { searchParams: Promise }) { let sentinelSearch + const sp = use(searchParams) try { - if (searchParams.sentinel) { - sentinelSearch = searchParams.sentinel + if (sp.sentinel) { + sentinelSearch = sp.sentinel } else { sentinelSearch = '~not-found~' } @@ -51,7 +51,12 @@ function ComponentOne({ searchParams }) { } function ComponentTwo() { - return
    This component didn't access any searchParams properties
    + return ( + <> +
    This component didn't access any searchParams properties
    +
    {getSentinelValue()}
    + + ) } function Fallback({ children }) { diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_root/page.tsx index c751012125b4a..eb0fe324b3acc 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_root/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_client_root/page.tsx @@ -1,8 +1,9 @@ 'use client' import { getSentinelValue } from '../../getSentinelValue' +import { use } from 'react' -export default function Page({ searchParams }) { +export default function Page({ searchParams }: { searchParams: Promise }) { return ( <>

    @@ -23,11 +24,12 @@ export default function Page({ searchParams }) { ) } -function ComponentOne({ searchParams }) { +function ComponentOne({ searchParams }: { searchParams: Promise }) { let sentinelSearch + const sp = use(searchParams) try { - if (searchParams.sentinel) { - sentinelSearch = searchParams.sentinel + if (sp.sentinel) { + sentinelSearch = sp.sentinel } else { sentinelSearch = '~not-found~' } @@ -44,5 +46,10 @@ function ComponentOne({ searchParams }) { } function ComponentTwo() { - return

    This component didn't access any searchParams properties
    + return ( + <> +
    This component didn't access any searchParams properties
    +
    {getSentinelValue()}
    + + ) } diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_boundary/page.tsx index 269a27f4ed74a..76b7fc585cc41 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_boundary/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_boundary/page.tsx @@ -2,7 +2,11 @@ import { Suspense } from 'react' import { getSentinelValue } from '../../getSentinelValue' -export default async function Page({ searchParams }) { +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { return ( <>

    @@ -21,18 +25,18 @@ export default async function Page({ searchParams }) { -

    {getSentinelValue()}
    {getSentinelValue()}
    ) } -function ComponentOne({ searchParams }) { +async function ComponentOne({ searchParams }: { searchParams: Promise }) { let sentinelSearch + const sp = await searchParams try { - if (searchParams.sentinel) { - sentinelSearch = searchParams.sentinel + if (sp.sentinel) { + sentinelSearch = sp.sentinel } else { sentinelSearch = '~not-found~' } @@ -49,5 +53,10 @@ function ComponentOne({ searchParams }) { } function ComponentTwo() { - return
    This component didn't access any searchParams properties
    + return ( + <> +
    This component didn't access any searchParams properties
    +
    {getSentinelValue()}
    + + ) } diff --git a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_root/page.tsx index f083f46ad150c..1a1ecc6ea4670 100644 --- a/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_root/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cases/dynamic_api_search_params_server_root/page.tsx @@ -1,6 +1,6 @@ import { getSentinelValue } from '../../getSentinelValue' -export default function Page({ searchParams }) { +export default function Page({ searchParams }: { searchParams: Promise }) { return ( <>

    @@ -21,11 +21,12 @@ export default function Page({ searchParams }) { ) } -function ComponentOne({ searchParams }) { +async function ComponentOne({ searchParams }: { searchParams: Promise }) { let sentinelSearch + const sp = await searchParams try { - if (searchParams.sentinel) { - sentinelSearch = searchParams.sentinel + if (sp.sentinel) { + sentinelSearch = sp.sentinel } else { sentinelSearch = '~not-found~' } @@ -42,5 +43,10 @@ function ComponentOne({ searchParams }) { } function ComponentTwo() { - return

    This component didn't access any searchParams properties
    + return ( + <> +
    This component didn't access any searchParams properties
    +
    {getSentinelValue()}
    + + ) } diff --git a/test/e2e/app-dir/dynamic-io/app/layout.tsx b/test/e2e/app-dir/dynamic-io/app/layout.tsx index 9dee378b15779..296260c65cd9a 100644 --- a/test/e2e/app-dir/dynamic-io/app/layout.tsx +++ b/test/e2e/app-dir/dynamic-io/app/layout.tsx @@ -4,7 +4,7 @@ export default function Root({ children }: { children: React.ReactNode }) { return ( - {children} +
    {children}
    {getSentinelValue()}
    diff --git a/test/e2e/app-dir/dynamic-io/app/search/async/client/use_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/async/client/use_boundary/page.tsx new file mode 100644 index 0000000000000..995e40421fe60 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/async/client/use_boundary/page.tsx @@ -0,0 +1,52 @@ +'use client' + +import { Suspense, use } from 'react' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return ( + <> +

    + This page `use`'s the searchParams promise before accessing a property + on it. +

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ + searchParams, +}: { + searchParams: Promise +}) { + const params = use(searchParams) + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {params.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/async/client/use_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/async/client/use_root/page.tsx new file mode 100644 index 0000000000000..e119d05d89067 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/async/client/use_root/page.tsx @@ -0,0 +1,48 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return ( + <> +

    + This page `use`'s the searchParams promise before accessing a property + on it. +

    +

    There use is not wrapped in a Suspense boundary

    +

    With PPR we expect the page to have an empty shell

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ + searchParams, +}: { + searchParams: Promise +}) { + const params = use(searchParams) + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {params.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/async/server/await_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/async/server/await_boundary/page.tsx new file mode 100644 index 0000000000000..3c6ecc5cecd8c --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/async/server/await_boundary/page.tsx @@ -0,0 +1,50 @@ +import { Suspense } from 'react' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return ( + <> +

    + This page awaits the searchParams promise before accessing a property on + it. +

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +async function Component({ + searchParams, +}: { + searchParams: Promise +}) { + const params = await searchParams + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {params.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/async/server/await_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/async/server/await_root/page.tsx new file mode 100644 index 0000000000000..2242f56e05bb8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/async/server/await_root/page.tsx @@ -0,0 +1,44 @@ +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return ( + <> +

    + This page awaits the searchParams promise before accessing a property on + it. +

    +

    There use is not wrapped in a Suspense boundary

    +

    With PPR we expect the page to have an empty shell

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +async function Component({ + searchParams, +}: { + searchParams: Promise +}) { + const params = await searchParams + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {params.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/async/server/use_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/async/server/use_boundary/page.tsx new file mode 100644 index 0000000000000..b0c10ac3119d2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/async/server/use_boundary/page.tsx @@ -0,0 +1,49 @@ +import { getSentinelValue } from '../../../../getSentinelValue' + +import { Suspense, use } from 'react' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return ( + <> +

    + This page `use`'s the searchParams promise before accessing a property + on it. +

    +

    With PPR we expect the page to have an empty shell

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ + searchParams, +}: { + searchParams: Promise +}) { + const params = use(searchParams) + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {params.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/async/server/use_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/async/server/use_root/page.tsx new file mode 100644 index 0000000000000..0a597159e79f9 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/async/server/use_root/page.tsx @@ -0,0 +1,46 @@ +import { getSentinelValue } from '../../../../getSentinelValue' + +import { use } from 'react' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + return ( + <> +

    + This page `use`'s the searchParams promise before accessing a property + on it. +

    +

    There use is not wrapped in a Suspense boundary

    +

    With PPR we expect the page to have an empty shell

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ + searchParams, +}: { + searchParams: Promise +}) { + const params = use(searchParams) + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {params.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/client/access_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/client/access_boundary/page.tsx new file mode 100644 index 0000000000000..8eec267109b2f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/client/access_boundary/page.tsx @@ -0,0 +1,48 @@ +'use client' + +import { Suspense } from 'react' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {searchParams.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/client/access_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/client/access_root/page.tsx new file mode 100644 index 0000000000000..a9d13573c12bc --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/client/access_root/page.tsx @@ -0,0 +1,42 @@ +'use client' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {searchParams.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/client/has_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/client/has_boundary/page.tsx new file mode 100644 index 0000000000000..0f61a1d082d6f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/client/has_boundary/page.tsx @@ -0,0 +1,54 @@ +'use client' + +import { Suspense } from 'react' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component checks if the sentinel search param is defined +

    + sentinel:{' '} + {String('sentinel' in searchParams)} +

    +

    + foo: {String('foo' in searchParams)} +

    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/client/has_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/client/has_root/page.tsx new file mode 100644 index 0000000000000..16a4545347fdc --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/client/has_root/page.tsx @@ -0,0 +1,48 @@ +'use client' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component checks if the sentinel search param is defined +

    + sentinel:{' '} + {String('sentinel' in searchParams)} +

    +

    + foo: {String('foo' in searchParams)} +

    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_boundary/page.tsx new file mode 100644 index 0000000000000..7a766a0204e09 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_boundary/page.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Suspense } from 'react' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + const copied = { ...searchParams } + return ( + <> +
    +

    This component clones search params and then prints

    +
      + {Object.keys(copied).map((k) => { + return ( +
    • + {k}:{' '} + + {copied[k]} + +
    • + ) + })} +
    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_root/page.tsx new file mode 100644 index 0000000000000..adbaf90a219c1 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/client/spread_root/page.tsx @@ -0,0 +1,54 @@ +'use client' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + const copied = { ...searchParams } + return ( + <> +
    +

    This component clones search params and then prints

    +
      + {Object.keys(copied).map((k) => { + return ( +
    • + {k}:{' '} + + {copied[k]} + +
    • + ) + })} +
    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/server/access_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/server/access_boundary/page.tsx new file mode 100644 index 0000000000000..10390951a4bdb --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/server/access_boundary/page.tsx @@ -0,0 +1,46 @@ +import { Suspense } from 'react' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {searchParams.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/server/access_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/server/access_root/page.tsx new file mode 100644 index 0000000000000..0d7f2ff0173d7 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/server/access_root/page.tsx @@ -0,0 +1,40 @@ +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component accessed `searchParams.sentinel`: " + {searchParams.sentinel}" +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/server/has_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/server/has_boundary/page.tsx new file mode 100644 index 0000000000000..df1f18aa137f2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/server/has_boundary/page.tsx @@ -0,0 +1,52 @@ +import { Suspense } from 'react' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component checks if the sentinel search param is defined +

    + sentinel:{' '} + {String('sentinel' in searchParams)} +

    +

    + foo: {String('foo' in searchParams)} +

    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/server/has_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/server/has_root/page.tsx new file mode 100644 index 0000000000000..d8f296b9b3690 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/server/has_root/page.tsx @@ -0,0 +1,46 @@ +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + return ( + <> +
    + This component checks if the sentinel search param is defined +

    + sentinel:{' '} + {String('sentinel' in searchParams)} +

    +

    + foo: {String('foo' in searchParams)} +

    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_boundary/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_boundary/page.tsx new file mode 100644 index 0000000000000..796d66a1b35c6 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_boundary/page.tsx @@ -0,0 +1,58 @@ +import { Suspense } from 'react' + +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + const copied = { ...searchParams } + return ( + <> +
    +

    This component clones search params and then prints

    +
      + {Object.keys(copied).map((k) => { + return ( +
    • + {k}:{' '} + + {copied[k]} + +
    • + ) + })} +
    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_root/page.tsx b/test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_root/page.tsx new file mode 100644 index 0000000000000..5aa663c8677e8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/search/sync/server/spread_root/page.tsx @@ -0,0 +1,52 @@ +import type { UnsafeUnwrappedSearchParams } from 'next/server' + +import { getSentinelValue } from '../../../../getSentinelValue' + +type AnySearchParams = { [key: string]: string | string[] | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { + const castedSearchParams = + searchParams as unknown as UnsafeUnwrappedSearchParams + return ( + <> +

    This page access a search param synchonrously

    +

    The `use` is inside a Suspense boundary

    +

    With PPR we expect the page to have a partially static page

    +

    Without PPR we expect the page to be dynamic

    + + + + ) +} + +function Component({ searchParams }: { searchParams: AnySearchParams }) { + const copied = { ...searchParams } + return ( + <> +
    +

    This component clones search params and then prints

    +
      + {Object.keys(copied).map((k) => { + return ( +
    • + {k}:{' '} + + {copied[k]} + +
    • + ) + })} +
    +
    + {getSentinelValue()} + + ) +} + +function ComponentTwo() { + return null +} diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.cookies.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.cookies.test.ts new file mode 100644 index 0000000000000..ec488bd810b6b --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.cookies.test.ts @@ -0,0 +1,287 @@ +import { nextTestSetup } from 'e2e-utils' + +const WITH_PPR = !!process.env.__NEXT_EXPERIMENTAL_PPR + +describe('dynamic-io', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + if (WITH_PPR) { + it('should partially prerender pages that use async cookies', async () => { + let $ = await next.render$('/cookies/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + + it('should partially prerender pages that use sync cookies', async () => { + let $ = await next.render$('/cookies/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } else { + it('should produce dynamic pages when using async or sync cookies', async () => { + let $ = await next.render$('/cookies/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/cookies/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } + + if (WITH_PPR) { + it('should be able to pass cookies as a promise to another component and trigger an intermediate Suspense boundary', async () => { + const $ = await next.render$('/cookies/static-behavior/pass-deeply') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#fallback').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#fallback').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at runtime') + } + }) + } + + it('should be able to access cookie properties asynchronously', async () => { + let $ = await next.render$('/cookies/exercise/async', {}) + let cookieWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /cookies/exercise')) + + expect(cookieWarnings).toHaveLength(0) + + // For...of iteration + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/cookies/exercise/async' + ) + expect($('#for-of-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // ...spread iteration + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/cookies/exercise/async' + ) + expect($('#spread-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // cookies().size + expect(parseInt($('#size-cookies').text())).toBeGreaterThanOrEqual(3) + + // cookies().get('...') && cookies().getAll('...') + expect($('#get-x-sentinel').text()).toContain('hello') + expect($('#get-x-sentinel-path').text()).toContain( + '/cookies/exercise/async' + ) + expect($('#get-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + + // cookies().set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + + // cookies().delete('...', '...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + + // cookies().clear() + expect($('#clear-result').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#clear-value-x-sentinel').text()).toContain('hello') + + // cookies().toString() + expect($('#toString').text()).toContain('x-sentinel=hello') + expect($('#toString').text()).toContain('x-sentinel-path') + expect($('#toString').text()).toContain('x-sentinel-rand=') + }) + + it('should be able to access cookie properties synchronously', async () => { + let $ = await next.render$('/cookies/exercise/sync', {}) + let cookieWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /cookies/exercise')) + + if (!isNextDev) { + expect(cookieWarnings).toHaveLength(0) + } + let i = 0 + + // For...of iteration + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/cookies/exercise/sync' + ) + expect($('#for-of-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('for...of cookies()') + } + + // ...spread iteration + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/cookies/exercise/sync' + ) + expect($('#spread-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('[...cookies()]') + } + + // cookies().size + expect(parseInt($('#size-cookies').text())).toBeGreaterThanOrEqual(3) + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('cookies().size') + } + + // cookies().get('...') && cookies().getAll('...') + expect($('#get-x-sentinel').text()).toContain('hello') + expect($('#get-x-sentinel-path').text()).toContain('/cookies/exercise/sync') + expect($('#get-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel-path')") + expect(cookieWarnings[i++]).toContain( + "cookies().getAll('x-sentinel-rand')" + ) + } + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().has('x-sentinel')") + expect(cookieWarnings[i++]).toContain( + "cookies().has('x-sentinel-foobar')" + ) + } + + // cookies().set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().set('x-sentinel', ...)") + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + } + + // cookies().delete('...', '...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain("cookies().delete('x-sentinel')") + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + } + + // cookies().clear() + expect($('#clear-result').text()).toContain( + 'Cookies can only be modified in a Server Action' + ) + expect($('#clear-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('cookies().clear()') + expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") + } + + // cookies().toString() + expect($('#toString').text()).toContain('x-sentinel=hello') + expect($('#toString').text()).toContain('x-sentinel-path') + expect($('#toString').text()).toContain('x-sentinel-rand=') + if (isNextDev) { + expect(cookieWarnings[i++]).toContain('cookies().toString()') + } + + if (isNextDev) { + expect(i).toBe(cookieWarnings.length) + } + }) +}) diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.headers.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.headers.test.ts new file mode 100644 index 0000000000000..bc23e4353ce89 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.headers.test.ts @@ -0,0 +1,334 @@ +import { nextTestSetup } from 'e2e-utils' + +const WITH_PPR = !!process.env.__NEXT_EXPERIMENTAL_PPR + +describe('dynamic-io', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + if (WITH_PPR) { + it('should partially prerender pages that use async headers', async () => { + let $ = await next.render$('/headers/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + + it('should partially prerender pages that use sync headers', async () => { + let $ = await next.render$('/headers/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } else { + it('should produce dynamic pages when using async or sync headers', async () => { + let $ = await next.render$('/headers/static-behavior/sync_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/sync_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/async_boundary', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + + $ = await next.render$('/headers/static-behavior/async_root', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#x-sentinel').text()).toBe('hello') + } + }) + } + + if (WITH_PPR) { + it('should be able to pass headers as a promise to another component and trigger an intermediate Suspense boundary', async () => { + const $ = await next.render$('/headers/static-behavior/pass-deeply') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#fallback').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#fallback').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at runtime') + } + }) + } + + it('should be able to access headers properties asynchronously', async () => { + let $ = await next.render$('/headers/exercise/async', {}) + let cookieWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /headers/exercise')) + + expect(cookieWarnings).toHaveLength(0) + + // (await headers()).append('...', '...') + expect($('#append-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#append-value-x-sentinel').text()).toContain('hello') + + // (await headers()).delete('...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + + // (await headers()).get('...') + expect($('#get-x-sentinel').text()).toContain('hello') + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + + // (await headers()).set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + + // (await headers()).getSetCookie() + // This is always empty because headers() represents Request headers + // not response headers and is not mutable. + expect($('#get-set-cookie').text()).toEqual('[]') + + // (await headers()).forEach(...) + expect($('#for-each-x-sentinel').text()).toContain('hello') + expect($('#for-each-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#for-each-x-sentinel-rand').length).toBe(1) + + // (await headers()).keys(...) + expect($('#keys-x-sentinel').text()).toContain('x-sentinel') + expect($('#keys-x-sentinel-path').text()).toContain('x-sentinel-path') + expect($('#keys-x-sentinel-rand').text()).toContain('x-sentinel-rand') + + // (await headers()).values(...) + expect($('[data-class="values"]').text()).toContain('hello') + expect($('[data-class="values"]').text()).toContain( + '/headers/exercise/async' + ) + expect($('[data-class="values"]').length).toBe(3) + + // (await headers()).entries(...) + expect($('#entries-x-sentinel').text()).toContain('hello') + expect($('#entries-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#entries-x-sentinel-rand').length).toBe(1) + + // for...of (await headers()) + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#for-of-x-sentinel-rand').length).toBe(1) + + // ...(await headers()) + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/headers/exercise/async' + ) + expect($('#spread-x-sentinel-rand').length).toBe(1) + }) + + it('should be able to access headers properties synchronously', async () => { + let $ = await next.render$('/headers/exercise/sync', {}) + let headerWarnings = next.cliOutput + .split('\n') + .filter((l) => l.includes('In route /headers/exercise')) + + if (!isNextDev) { + expect(headerWarnings).toHaveLength(0) + } + let i = 0 + + // headers().append('...', '...') + expect($('#append-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#append-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain( + "headers().append('x-sentinel', ...)" + ) + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // headers().delete('...') + expect($('#delete-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#delete-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().delete('x-sentinel')") + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // headers().get('...') + expect($('#get-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // cookies().has('...') + expect($('#has-x-sentinel').text()).toContain('true') + expect($('#has-x-sentinel-foobar').text()).toContain('false') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().has('x-sentinel')") + expect(headerWarnings[i++]).toContain( + "headers().has('x-sentinel-foobar')" + ) + } + + // headers().set('...', '...') + expect($('#set-result-x-sentinel').text()).toContain( + 'Headers cannot be modified' + ) + expect($('#set-value-x-sentinel').text()).toContain('hello') + if (isNextDev) { + expect(headerWarnings[i++]).toContain("headers().set('x-sentinel', ...)") + expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") + } + + // headers().getSetCookie() + // This is always empty because headers() represents Request headers + // not response headers and is not mutable. + expect($('#get-set-cookie').text()).toEqual('[]') + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().getSetCookie()') + } + + // headers().forEach(...) + expect($('#for-each-x-sentinel').text()).toContain('hello') + expect($('#for-each-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#for-each-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().forEach(...)') + } + + // headers().keys(...) + expect($('#keys-x-sentinel').text()).toContain('x-sentinel') + expect($('#keys-x-sentinel-path').text()).toContain('x-sentinel-path') + expect($('#keys-x-sentinel-rand').text()).toContain('x-sentinel-rand') + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().keys()') + } + + // headers().values(...) + expect($('[data-class="values"]').text()).toContain('hello') + expect($('[data-class="values"]').text()).toContain( + '/headers/exercise/sync' + ) + expect($('[data-class="values"]').length).toBe(3) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().values()') + } + + // headers().entries(...) + expect($('#entries-x-sentinel').text()).toContain('hello') + expect($('#entries-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#entries-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('headers().entries()') + } + + // for...of headers() + expect($('#for-of-x-sentinel').text()).toContain('hello') + expect($('#for-of-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#for-of-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('for...of headers()') + } + + // ...headers() + expect($('#spread-x-sentinel').text()).toContain('hello') + expect($('#spread-x-sentinel-path').text()).toContain( + '/headers/exercise/sync' + ) + expect($('#spread-x-sentinel-rand').length).toBe(1) + if (isNextDev) { + expect(headerWarnings[i++]).toContain('...headers()') + } + + if (isNextDev) { + expect(i).toBe(headerWarnings.length) + } + }) +}) diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.search.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.search.test.ts new file mode 100644 index 0000000000000..f0b6c65afb6c8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.search.test.ts @@ -0,0 +1,802 @@ +import { nextTestSetup } from 'e2e-utils' + +const WITH_PPR = !!process.env.__NEXT_EXPERIMENTAL_PPR + +describe('dynamic-io', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + let cliIndex = 0 + beforeEach(() => { + cliIndex = next.cliOutput.length + }) + function getLines(containing: string): Array { + const warnings = next.cliOutput + .slice(cliIndex) + .split('\n') + .filter((l) => l.includes(containing)) + + cliIndex = next.cliOutput.length + return warnings + } + + if (WITH_PPR) { + it('should partially prerender pages that await searchParams in a server component', async () => { + let $ = await next.render$( + '/search/async/server/await_boundary?sentinel=hello' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).toContain('inner loading...') + expect($('main').text()).not.toContain('outer loading...') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/async/server/await_root?sentinel=hello') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should partially prerender pages that `use` searchParams in a server component', async () => { + let $ = await next.render$( + '/search/async/server/use_boundary?sentinel=hello' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).toContain('inner loading...') + expect($('main').text()).not.toContain('outer loading...') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/async/server/use_root?sentinel=hello') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should partially prerender pages that `use` searchParams in a client component', async () => { + let $ = await next.render$( + '/search/async/client/use_boundary?sentinel=hello' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).toContain('inner loading...') + expect($('main').text()).not.toContain('outer loading...') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/async/client/use_root?sentinel=hello') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + } else { + it('should not prerender pages that await searchParams in a server component', async () => { + let $ = await next.render$( + '/search/async/server/await_boundary?sentinel=hello' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/async/server/await_root?sentinel=hello') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should not prerender pages that `use` searchParams in a server component', async () => { + let $ = await next.render$( + '/search/async/server/use_boundary?sentinel=hello' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/async/server/use_root?sentinel=hello') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should not prerender pages that `use` searchParams in a client component', async () => { + let $ = await next.render$( + '/search/async/client/use_boundary?sentinel=hello' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/async/client/use_root?sentinel=hello') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + } + + if (WITH_PPR) { + it('should partially prerender pages that access a searchParam property synchronously in a server component', async () => { + let $ = await next.render$( + '/search/sync/server/access_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).not.toContain('inner loading...') + // This test case aborts synchronously and the later component render + // triggers the outer boundary + expect($('main').text()).toContain('outer loading...') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/server/access_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should partially prerender pages that access a searchParam property synchronously in a client component', async () => { + let $ = await next.render$( + '/search/sync/client/access_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).toContain('inner loading...') + expect($('main').text()).not.toContain('outer loading...') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/client/access_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should partially prerender pages that checks for the existence of a searchParam property synchronously in a server component', async () => { + let $ = await next.render$( + '/search/sync/server/has_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar' + ), + expect.stringContaining( + '`Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).not.toContain('inner loading...') + // This test case aborts synchronously and the later component render + // triggers the outer boundary + expect($('main').text()).toContain('outer loading...') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/server/has_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar' + ), + expect.stringContaining( + '`Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should partially prerender pages that checks for the existence of a searchParam property synchronously in a client component', async () => { + let $ = await next.render$( + '/search/sync/client/has_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar' + ), + expect.stringContaining( + '`Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).toContain('inner loading...') + expect($('main').text()).not.toContain('outer loading...') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/client/has_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar' + ), + expect.stringContaining( + '`Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should partially prerender pages that spreads ...searchParam synchronously in a server component', async () => { + let $ = await next.render$( + '/search/sync/server/spread_boundary?sentinel=hello&foo=foo&then=bar&value=baz' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated incompletely with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).not.toContain('inner loading...') + // This test case aborts synchronously and the later component render + // triggers the outer boundary + expect($('main').text()).toContain('outer loading...') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$( + '/search/sync/server/spread_root?sentinel=hello&foo=foo' + ) + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should partially prerender pages that spreads ...searchParam synchronously in a client component', async () => { + let $ = await next.render$( + '/search/sync/client/spread_boundary?sentinel=hello&foo=foo&then=bar&value=baz' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated incompletely with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at buildtime') + expect($('main').text()).toContain('inner loading...') + expect($('main').text()).not.toContain('outer loading...') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$( + '/search/sync/client/spread_root?sentinel=hello&foo=foo' + ) + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + }) + } else { + it('should not prerender a page that accesses a searchParam property synchronously in a server component', async () => { + let $ = await next.render$( + '/search/sync/server/access_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/server/access_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should not prerender a page that accesses a searchParam property synchronously in a client component', async () => { + let $ = await next.render$( + '/search/sync/client/access_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/client/access_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'searchParam property was accessed directly with `searchParams.sentinel`' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#value').text()).toBe('hello') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should not prerender a page that checks for the existence of a searchParam property synchronously in a server component', async () => { + let $ = await next.render$( + '/search/sync/server/has_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar.' + ), + expect.stringContaining( + 'Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar.' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/server/has_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar.' + ), + expect.stringContaining( + 'Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar.' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should not prerender a page that checks for the existence of a searchParam property synchronously in a client component', async () => { + let $ = await next.render$( + '/search/sync/client/has_boundary?sentinel=hello' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar.' + ), + expect.stringContaining( + 'Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar.' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$('/search/sync/client/has_root?sentinel=hello') + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + '`Reflect.has(searchParams, "sentinel"`, `"sentinel" in searchParams`, or similar.' + ), + expect.stringContaining( + 'Reflect.has(searchParams, "foo"`, `"foo" in searchParams`, or similar.' + ), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('#has-sentinel').text()).toBe('true') + expect($('#has-foo').text()).toBe('false') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should not prerender a page that spreads ...searchParam synchronously in a server component', async () => { + let $ = await next.render$( + '/search/sync/server/spread_boundary?sentinel=hello&foo=foo&then=bar&value=baz' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated incompletely with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$( + '/search/sync/server/spread_root?sentinel=hello&foo=foo' + ) + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + }) + + it('should not prerender a page that spreads ...searchParam synchronously in a client component', async () => { + let $ = await next.render$( + '/search/sync/client/spread_boundary?sentinel=hello&foo=foo&then=bar&value=baz' + ) + let searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated incompletely with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + + $ = await next.render$( + '/search/sync/client/spread_root?sentinel=hello&foo=foo' + ) + searchWarnings = getLines('In route /search') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + expect(searchWarnings).toEqual([ + expect.stringContaining( + 'enumerated with `{...searchParams}`, `Object.keys(searchParams)`, or similar.' + ), + expect.stringContaining( + 'accessed directly with `searchParams.sentinel`' + ), + expect.stringContaining('accessed directly with `searchParams.foo`'), + ]) + } else { + expect(searchWarnings).toHaveLength(0) + expect($('#layout').text()).toBe('at runtime') + expect($('[data-value]').length).toBe(2) + expect($('#value-sentinel').text()).toBe('hello') + expect($('#value-foo').text()).toBe('foo') + expect($('#page').text()).toBe('at runtime') + } + }) + } +}) diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts index f32ca9d544059..f66bb0c0c5dcc 100644 --- a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts @@ -462,7 +462,9 @@ describe('dynamic-io', () => { } else { expect($('#layout').text()).toBe('at buildtime') expect($('#page').text()).toBe('at buildtime') - expect($('#inner').text()).toBe('at runtime') + // The second component renders before the first one aborts so we end up + // capturing the static value during buildtime + expect($('#inner').text()).toBe('at buildtime') expect($('#value').text()).toBe('my sentinel') } @@ -513,597 +515,4 @@ describe('dynamic-io', () => { } }) } - - if (WITH_PPR) { - it('should partially prerender pages that use async cookies', async () => { - let $ = await next.render$('/cookies/static-behavior/async_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at buildtime') - expect($('#page').text()).toBe('at buildtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/cookies/static-behavior/async_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - }) - - it('should partially prerender pages that use sync cookies', async () => { - let $ = await next.render$('/cookies/static-behavior/sync_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/cookies/static-behavior/sync_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - }) - } else { - it('should produce dynamic pages when using async or sync cookies', async () => { - let $ = await next.render$('/cookies/static-behavior/sync_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/cookies/static-behavior/sync_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/cookies/static-behavior/async_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/cookies/static-behavior/async_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - }) - } - - if (WITH_PPR) { - it('should be able to pass cookies as a promise to another component and trigger an intermediate Suspense boundary', async () => { - const $ = await next.render$('/cookies/static-behavior/pass-deeply') - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#fallback').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - } else { - expect($('#layout').text()).toBe('at buildtime') - expect($('#fallback').text()).toBe('at buildtime') - expect($('#page').text()).toBe('at runtime') - } - }) - } - - it('should be able to access cookie properties asynchronously', async () => { - let $ = await next.render$('/cookies/exercise/async', {}) - let cookieWarnings = next.cliOutput - .split('\n') - .filter((l) => l.includes('In route /cookies/exercise')) - - expect(cookieWarnings).toHaveLength(0) - - // For...of iteration - expect($('#for-of-x-sentinel').text()).toContain('hello') - expect($('#for-of-x-sentinel-path').text()).toContain( - '/cookies/exercise/async' - ) - expect($('#for-of-x-sentinel-rand').text()).toContain('x-sentinel-rand') - - // ...spread iteration - expect($('#spread-x-sentinel').text()).toContain('hello') - expect($('#spread-x-sentinel-path').text()).toContain( - '/cookies/exercise/async' - ) - expect($('#spread-x-sentinel-rand').text()).toContain('x-sentinel-rand') - - // cookies().size - expect(parseInt($('#size-cookies').text())).toBeGreaterThanOrEqual(3) - - // cookies().get('...') && cookies().getAll('...') - expect($('#get-x-sentinel').text()).toContain('hello') - expect($('#get-x-sentinel-path').text()).toContain( - '/cookies/exercise/async' - ) - expect($('#get-x-sentinel-rand').text()).toContain('x-sentinel-rand') - - // cookies().has('...') - expect($('#has-x-sentinel').text()).toContain('true') - expect($('#has-x-sentinel-foobar').text()).toContain('false') - - // cookies().set('...', '...') - expect($('#set-result-x-sentinel').text()).toContain( - 'Cookies can only be modified in a Server Action' - ) - expect($('#set-value-x-sentinel').text()).toContain('hello') - - // cookies().delete('...', '...') - expect($('#delete-result-x-sentinel').text()).toContain( - 'Cookies can only be modified in a Server Action' - ) - expect($('#delete-value-x-sentinel').text()).toContain('hello') - - // cookies().clear() - expect($('#clear-result').text()).toContain( - 'Cookies can only be modified in a Server Action' - ) - expect($('#clear-value-x-sentinel').text()).toContain('hello') - - // cookies().toString() - expect($('#toString').text()).toContain('x-sentinel=hello') - expect($('#toString').text()).toContain('x-sentinel-path') - expect($('#toString').text()).toContain('x-sentinel-rand=') - }) - - it('should be able to access cookie properties synchronously', async () => { - let $ = await next.render$('/cookies/exercise/sync', {}) - let cookieWarnings = next.cliOutput - .split('\n') - .filter((l) => l.includes('In route /cookies/exercise')) - - if (!isNextDev) { - expect(cookieWarnings).toHaveLength(0) - } - let i = 0 - - // For...of iteration - expect($('#for-of-x-sentinel').text()).toContain('hello') - expect($('#for-of-x-sentinel-path').text()).toContain( - '/cookies/exercise/sync' - ) - expect($('#for-of-x-sentinel-rand').text()).toContain('x-sentinel-rand') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain('for...of cookies()') - } - - // ...spread iteration - expect($('#spread-x-sentinel').text()).toContain('hello') - expect($('#spread-x-sentinel-path').text()).toContain( - '/cookies/exercise/sync' - ) - expect($('#spread-x-sentinel-rand').text()).toContain('x-sentinel-rand') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain('[...cookies()]') - } - - // cookies().size - expect(parseInt($('#size-cookies').text())).toBeGreaterThanOrEqual(3) - if (isNextDev) { - expect(cookieWarnings[i++]).toContain('cookies().size') - } - - // cookies().get('...') && cookies().getAll('...') - expect($('#get-x-sentinel').text()).toContain('hello') - expect($('#get-x-sentinel-path').text()).toContain('/cookies/exercise/sync') - expect($('#get-x-sentinel-rand').text()).toContain('x-sentinel-rand') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") - expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel-path')") - expect(cookieWarnings[i++]).toContain( - "cookies().getAll('x-sentinel-rand')" - ) - } - - // cookies().has('...') - expect($('#has-x-sentinel').text()).toContain('true') - expect($('#has-x-sentinel-foobar').text()).toContain('false') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain("cookies().has('x-sentinel')") - expect(cookieWarnings[i++]).toContain( - "cookies().has('x-sentinel-foobar')" - ) - } - - // cookies().set('...', '...') - expect($('#set-result-x-sentinel').text()).toContain( - 'Cookies can only be modified in a Server Action' - ) - expect($('#set-value-x-sentinel').text()).toContain('hello') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain("cookies().set('x-sentinel', ...)") - expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") - } - - // cookies().delete('...', '...') - expect($('#delete-result-x-sentinel').text()).toContain( - 'Cookies can only be modified in a Server Action' - ) - expect($('#delete-value-x-sentinel').text()).toContain('hello') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain("cookies().delete('x-sentinel')") - expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") - } - - // cookies().clear() - expect($('#clear-result').text()).toContain( - 'Cookies can only be modified in a Server Action' - ) - expect($('#clear-value-x-sentinel').text()).toContain('hello') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain('cookies().clear()') - expect(cookieWarnings[i++]).toContain("cookies().get('x-sentinel')") - } - - // cookies().toString() - expect($('#toString').text()).toContain('x-sentinel=hello') - expect($('#toString').text()).toContain('x-sentinel-path') - expect($('#toString').text()).toContain('x-sentinel-rand=') - if (isNextDev) { - expect(cookieWarnings[i++]).toContain('cookies().toString()') - } - - if (isNextDev) { - expect(i).toBe(cookieWarnings.length) - } - }) - - if (WITH_PPR) { - it('should partially prerender pages that use async headers', async () => { - let $ = await next.render$('/headers/static-behavior/async_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at buildtime') - expect($('#page').text()).toBe('at buildtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/headers/static-behavior/async_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - }) - - it('should partially prerender pages that use sync headers', async () => { - let $ = await next.render$('/headers/static-behavior/sync_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/headers/static-behavior/sync_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - }) - } else { - it('should produce dynamic pages when using async or sync headers', async () => { - let $ = await next.render$('/headers/static-behavior/sync_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/headers/static-behavior/sync_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/headers/static-behavior/async_boundary', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - - $ = await next.render$('/headers/static-behavior/async_root', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#x-sentinel').text()).toBe('hello') - } - }) - } - - if (WITH_PPR) { - it('should be able to pass headers as a promise to another component and trigger an intermediate Suspense boundary', async () => { - const $ = await next.render$('/headers/static-behavior/pass-deeply') - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#fallback').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - } else { - expect($('#layout').text()).toBe('at buildtime') - expect($('#fallback').text()).toBe('at buildtime') - expect($('#page').text()).toBe('at runtime') - } - }) - } - - it('should be able to access headers properties asynchronously', async () => { - let $ = await next.render$('/headers/exercise/async', {}) - let cookieWarnings = next.cliOutput - .split('\n') - .filter((l) => l.includes('In route /headers/exercise')) - - expect(cookieWarnings).toHaveLength(0) - - // (await headers()).append('...', '...') - expect($('#append-result-x-sentinel').text()).toContain( - 'Headers cannot be modified' - ) - expect($('#append-value-x-sentinel').text()).toContain('hello') - - // (await headers()).delete('...') - expect($('#delete-result-x-sentinel').text()).toContain( - 'Headers cannot be modified' - ) - expect($('#delete-value-x-sentinel').text()).toContain('hello') - - // (await headers()).get('...') - expect($('#get-x-sentinel').text()).toContain('hello') - - // cookies().has('...') - expect($('#has-x-sentinel').text()).toContain('true') - expect($('#has-x-sentinel-foobar').text()).toContain('false') - - // (await headers()).set('...', '...') - expect($('#set-result-x-sentinel').text()).toContain( - 'Headers cannot be modified' - ) - expect($('#set-value-x-sentinel').text()).toContain('hello') - - // (await headers()).getSetCookie() - // This is always empty because headers() represents Request headers - // not response headers and is not mutable. - expect($('#get-set-cookie').text()).toEqual('[]') - - // (await headers()).forEach(...) - expect($('#for-each-x-sentinel').text()).toContain('hello') - expect($('#for-each-x-sentinel-path').text()).toContain( - '/headers/exercise/async' - ) - expect($('#for-each-x-sentinel-rand').length).toBe(1) - - // (await headers()).keys(...) - expect($('#keys-x-sentinel').text()).toContain('x-sentinel') - expect($('#keys-x-sentinel-path').text()).toContain('x-sentinel-path') - expect($('#keys-x-sentinel-rand').text()).toContain('x-sentinel-rand') - - // (await headers()).values(...) - expect($('[data-class="values"]').text()).toContain('hello') - expect($('[data-class="values"]').text()).toContain( - '/headers/exercise/async' - ) - expect($('[data-class="values"]').length).toBe(3) - - // (await headers()).entries(...) - expect($('#entries-x-sentinel').text()).toContain('hello') - expect($('#entries-x-sentinel-path').text()).toContain( - '/headers/exercise/async' - ) - expect($('#entries-x-sentinel-rand').length).toBe(1) - - // for...of (await headers()) - expect($('#for-of-x-sentinel').text()).toContain('hello') - expect($('#for-of-x-sentinel-path').text()).toContain( - '/headers/exercise/async' - ) - expect($('#for-of-x-sentinel-rand').length).toBe(1) - - // ...(await headers()) - expect($('#spread-x-sentinel').text()).toContain('hello') - expect($('#spread-x-sentinel-path').text()).toContain( - '/headers/exercise/async' - ) - expect($('#spread-x-sentinel-rand').length).toBe(1) - }) - - it('should be able to access headers properties synchronously', async () => { - let $ = await next.render$('/headers/exercise/sync', {}) - let headerWarnings = next.cliOutput - .split('\n') - .filter((l) => l.includes('In route /headers/exercise')) - - if (!isNextDev) { - expect(headerWarnings).toHaveLength(0) - } - let i = 0 - - // headers().append('...', '...') - expect($('#append-result-x-sentinel').text()).toContain( - 'Headers cannot be modified' - ) - expect($('#append-value-x-sentinel').text()).toContain('hello') - if (isNextDev) { - expect(headerWarnings[i++]).toContain( - "headers().append('x-sentinel', ...)" - ) - expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") - } - - // headers().delete('...') - expect($('#delete-result-x-sentinel').text()).toContain( - 'Headers cannot be modified' - ) - expect($('#delete-value-x-sentinel').text()).toContain('hello') - if (isNextDev) { - expect(headerWarnings[i++]).toContain("headers().delete('x-sentinel')") - expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") - } - - // headers().get('...') - expect($('#get-x-sentinel').text()).toContain('hello') - if (isNextDev) { - expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") - } - - // cookies().has('...') - expect($('#has-x-sentinel').text()).toContain('true') - expect($('#has-x-sentinel-foobar').text()).toContain('false') - if (isNextDev) { - expect(headerWarnings[i++]).toContain("headers().has('x-sentinel')") - expect(headerWarnings[i++]).toContain( - "headers().has('x-sentinel-foobar')" - ) - } - - // headers().set('...', '...') - expect($('#set-result-x-sentinel').text()).toContain( - 'Headers cannot be modified' - ) - expect($('#set-value-x-sentinel').text()).toContain('hello') - if (isNextDev) { - expect(headerWarnings[i++]).toContain("headers().set('x-sentinel', ...)") - expect(headerWarnings[i++]).toContain("headers().get('x-sentinel')") - } - - // headers().getSetCookie() - // This is always empty because headers() represents Request headers - // not response headers and is not mutable. - expect($('#get-set-cookie').text()).toEqual('[]') - if (isNextDev) { - expect(headerWarnings[i++]).toContain('headers().getSetCookie()') - } - - // headers().forEach(...) - expect($('#for-each-x-sentinel').text()).toContain('hello') - expect($('#for-each-x-sentinel-path').text()).toContain( - '/headers/exercise/sync' - ) - expect($('#for-each-x-sentinel-rand').length).toBe(1) - if (isNextDev) { - expect(headerWarnings[i++]).toContain('headers().forEach(...)') - } - - // headers().keys(...) - expect($('#keys-x-sentinel').text()).toContain('x-sentinel') - expect($('#keys-x-sentinel-path').text()).toContain('x-sentinel-path') - expect($('#keys-x-sentinel-rand').text()).toContain('x-sentinel-rand') - if (isNextDev) { - expect(headerWarnings[i++]).toContain('headers().keys()') - } - - // headers().values(...) - expect($('[data-class="values"]').text()).toContain('hello') - expect($('[data-class="values"]').text()).toContain( - '/headers/exercise/sync' - ) - expect($('[data-class="values"]').length).toBe(3) - if (isNextDev) { - expect(headerWarnings[i++]).toContain('headers().values()') - } - - // headers().entries(...) - expect($('#entries-x-sentinel').text()).toContain('hello') - expect($('#entries-x-sentinel-path').text()).toContain( - '/headers/exercise/sync' - ) - expect($('#entries-x-sentinel-rand').length).toBe(1) - if (isNextDev) { - expect(headerWarnings[i++]).toContain('headers().entries()') - } - - // for...of headers() - expect($('#for-of-x-sentinel').text()).toContain('hello') - expect($('#for-of-x-sentinel-path').text()).toContain( - '/headers/exercise/sync' - ) - expect($('#for-of-x-sentinel-rand').length).toBe(1) - if (isNextDev) { - expect(headerWarnings[i++]).toContain('for...of headers()') - } - - // ...headers() - expect($('#spread-x-sentinel').text()).toContain('hello') - expect($('#spread-x-sentinel-path').text()).toContain( - '/headers/exercise/sync' - ) - expect($('#spread-x-sentinel-rand').length).toBe(1) - if (isNextDev) { - expect(headerWarnings[i++]).toContain('...headers()') - } - - if (isNextDev) { - expect(i).toBe(headerWarnings.length) - } - }) }) From 9c1faa6ea7e5656fa3e0b7be0eb3127ac6fb8e5a Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 3 Sep 2024 11:26:09 -0700 Subject: [PATCH 06/14] Updates tests to use async form of `searchParams` --- .../acceptance-app/hydration-error.test.ts | 8 ++++---- .../actions/app/redirects/action-redirect/page.js | 2 +- .../redirects/action-redirect/redirect-target/page.js | 2 +- .../fixtures/regular/app/[id]/page.js | 3 ++- .../fixtures/regular/app/without-loading/[id]/page.js | 3 ++- .../app/force-dynamic/search-params/page.js | 4 ++-- .../app/revalidate-0/search-params/page.js | 4 ++-- .../app/default-cache-search-params/page.js | 8 +++++--- .../app-dir/app/app/dynamic/[category]/[id]/page.js | 5 ++--- .../app-dir/app/app/navigation/searchparams/page.js | 4 ++-- .../app-dir/app/app/param-and-query/[slug]/page.js | 6 +++--- test/e2e/app-dir/app/app/search-params-prop/page.js | 11 +++++++---- .../app-dir/app/app/search-params-prop/server/page.js | 11 ++++++----- test/e2e/app-dir/dynamic-data/dynamic-data.test.ts | 8 ++++---- .../fixtures/main/app/client-page/page.js | 2 +- .../fixtures/main/app/force-dynamic/page.js | 2 +- .../fixtures/main/app/force-static/page.js | 2 +- .../dynamic-data/fixtures/main/app/top-level/page.js | 2 +- .../fixtures/require-static/app/search/page.js | 2 +- .../metadata-navigation/app/async/[slug]/page.tsx | 8 ++++---- test/e2e/app-dir/metadata/app/dynamic/[slug]/page.tsx | 10 +++++----- .../e2e/app-dir/navigation/app/assertion/page/page.js | 5 +++-- test/e2e/app-dir/navigation/app/search-params/page.js | 4 ++-- .../app-dir/next-form/basepath/app/search/page.tsx | 2 +- .../app/forms/button-formaction-unsupported/page.tsx | 6 ++++-- .../default/app/redirected-from-action/page.tsx | 4 ++-- .../e2e/app-dir/next-form/default/app/search/page.tsx | 2 +- .../[dynamic]/@modal/(.)login/page.tsx | 5 ++++- .../app/dynamic-refresh/[dynamic]/page.tsx | 5 +++-- .../app/refreshing/@modal/(.)login/page.tsx | 5 ++++- .../app/refreshing/page.tsx | 4 ++-- test/e2e/app-dir/ppr-full/components/optimistic.jsx | 2 +- .../ppr-navigations/search-params/app/page.tsx | 6 ++++-- test/e2e/app-dir/ppr/app/search/page.jsx | 4 ++-- test/e2e/app-dir/prefetch-searchparam/app/page.tsx | 4 ++-- test/e2e/app-dir/rsc-basic/app/next-api/link/page.js | 4 ++-- test/e2e/app-dir/search-params-react-key/app/page.tsx | 4 ++-- .../app/search-params/page.tsx | 5 +++-- .../searchparams-reuse-loading/app/search/page.tsx | 10 ++++++++-- .../with-middleware/search-params/someValue/page.tsx | 5 +++-- .../app/client-component-page/page.tsx | 11 +++++++++-- .../app/client-component/component.tsx | 11 +++++++++-- .../app/server-component-page/page.tsx | 4 ++-- .../app/isr-unexpected-error/page.tsx | 8 ++++++-- .../app/ssr-unexpected-error-after-streaming/page.tsx | 10 ++++++++-- .../app/ssr-unexpected-error/page.tsx | 10 ++++++++-- 46 files changed, 152 insertions(+), 95 deletions(-) diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index 7926221ebb064..cf560106cbd28 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -875,8 +875,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + +
    @@ -895,8 +895,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + +
    diff --git a/test/e2e/app-dir/actions/app/redirects/action-redirect/page.js b/test/e2e/app-dir/actions/app/redirects/action-redirect/page.js index f8dc2c8f61a22..0b3dc7fb49316 100644 --- a/test/e2e/app-dir/actions/app/redirects/action-redirect/page.js +++ b/test/e2e/app-dir/actions/app/redirects/action-redirect/page.js @@ -21,7 +21,7 @@ export default async function Page({ searchParams }) { Set Cookies and Redirect -

    baz={searchParams.baz ?? ''}

    +

    baz={(await searchParams).baz ?? ''}

    { 'use server' diff --git a/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js b/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js index f522e3eb08a52..e22f3b2cb3170 100644 --- a/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js +++ b/test/e2e/app-dir/actions/app/redirects/action-redirect/redirect-target/page.js @@ -12,7 +12,7 @@ export default async function Page({ searchParams }) {

    foo={foo ? foo.value : ''}; bar={bar ? bar.value : ''}

    -

    baz={searchParams.baz ?? ''}

    +

    baz={(await searchParams).baz ?? ''}

    ) } diff --git a/test/e2e/app-dir/app-client-cache/fixtures/regular/app/[id]/page.js b/test/e2e/app-dir/app-client-cache/fixtures/regular/app/[id]/page.js index 34ebdb761501d..97750cdfb757a 100644 --- a/test/e2e/app-dir/app-client-cache/fixtures/regular/app/[id]/page.js +++ b/test/e2e/app-dir/app-client-cache/fixtures/regular/app/[id]/page.js @@ -1,6 +1,7 @@ import Link from 'next/link' -export default async function Page({ searchParams: { timeout } }) { +export default async function Page({ searchParams }) { + const timeout = (await searchParams).timeout const randomNumber = await new Promise((resolve) => { setTimeout( () => { diff --git a/test/e2e/app-dir/app-client-cache/fixtures/regular/app/without-loading/[id]/page.js b/test/e2e/app-dir/app-client-cache/fixtures/regular/app/without-loading/[id]/page.js index f2292666f8cf3..09083d8278a36 100644 --- a/test/e2e/app-dir/app-client-cache/fixtures/regular/app/without-loading/[id]/page.js +++ b/test/e2e/app-dir/app-client-cache/fixtures/regular/app/without-loading/[id]/page.js @@ -1,6 +1,7 @@ import Link from 'next/link' -export default async function Page({ searchParams: { timeout } }) { +export default async function Page({ searchParams }) { + const timeout = (await searchParams).timeout const randomNumber = await new Promise((resolve) => { setTimeout( () => { diff --git a/test/e2e/app-dir/app-prefetch/app/force-dynamic/search-params/page.js b/test/e2e/app-dir/app-prefetch/app/force-dynamic/search-params/page.js index be0b02addb54d..3b404ab05e448 100644 --- a/test/e2e/app-dir/app-prefetch/app/force-dynamic/search-params/page.js +++ b/test/e2e/app-dir/app-prefetch/app/force-dynamic/search-params/page.js @@ -1,9 +1,9 @@ import Link from 'next/link' -export default function Home({ searchParams }) { +export default async function Home({ searchParams }) { return ( <> -
    {JSON.stringify(searchParams)}
    +
    {JSON.stringify(await searchParams)}
    Add search params Clear Params diff --git a/test/e2e/app-dir/app-prefetch/app/revalidate-0/search-params/page.js b/test/e2e/app-dir/app-prefetch/app/revalidate-0/search-params/page.js index 6faaf156f4fdc..5b05cfdb657b5 100644 --- a/test/e2e/app-dir/app-prefetch/app/revalidate-0/search-params/page.js +++ b/test/e2e/app-dir/app-prefetch/app/revalidate-0/search-params/page.js @@ -1,9 +1,9 @@ import Link from 'next/link' -export default function Home({ searchParams }) { +export default async function Home({ searchParams }) { return ( <> -
    {JSON.stringify(searchParams)}
    +
    {JSON.stringify(await searchParams)}
    Add search params Clear Params diff --git a/test/e2e/app-dir/app-static/app/default-cache-search-params/page.js b/test/e2e/app-dir/app-static/app/default-cache-search-params/page.js index 15f698fb3039d..32a89d51768ef 100644 --- a/test/e2e/app-dir/app-static/app/default-cache-search-params/page.js +++ b/test/e2e/app-dir/app-static/app/default-cache-search-params/page.js @@ -1,8 +1,10 @@ -export default async function Page({ searchParams }) { - // this page is using searchParams to opt into dynamic rendering +import { unstable_noStore as noStore } from 'next/cache' + +export default async function Page() { + // this page is using unstable_noStore() to opt into dynamic rendering // meaning the page will bail from ISR cache and hint to patch-fetch // that it's in a dynamic scope and shouldn't cache the fetch. - console.log(searchParams.q) + noStore() const data1 = await fetch( 'https://next-data-api-endpoint.vercel.app/api/random?1', { cache: 'default' } diff --git a/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js b/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js index 80029dd814759..a9551752a6ec3 100644 --- a/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js +++ b/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js @@ -1,4 +1,4 @@ -export default function IdPage({ children, params, searchParams }) { +export default async function IdPage({ children, params, searchParams }) { return ( <>

    @@ -6,8 +6,7 @@ export default function IdPage({ children, params, searchParams }) { {JSON.stringify(params)}

    {children} - -

    {JSON.stringify(searchParams)}

    +

    {JSON.stringify(await searchParams)}

    ) } diff --git a/test/e2e/app-dir/app/app/navigation/searchparams/page.js b/test/e2e/app-dir/app/app/navigation/searchparams/page.js index 22515e25bc466..81298833973ea 100644 --- a/test/e2e/app-dir/app/app/navigation/searchparams/page.js +++ b/test/e2e/app-dir/app/app/navigation/searchparams/page.js @@ -1,9 +1,9 @@ import Link from 'next/link' -export default function Page({ searchParams }) { +export default async function Page({ searchParams }) { return ( <> -

    {JSON.stringify(searchParams)}

    +

    {JSON.stringify(await searchParams)}

    To A
    diff --git a/test/e2e/app-dir/app/app/param-and-query/[slug]/page.js b/test/e2e/app-dir/app/app/param-and-query/[slug]/page.js index fab82d7a8a270..36a2c54250015 100644 --- a/test/e2e/app-dir/app/app/param-and-query/[slug]/page.js +++ b/test/e2e/app-dir/app/app/param-and-query/[slug]/page.js @@ -1,13 +1,13 @@ 'use client' -export default function Page({ params, searchParams }) { +export default async function Page({ params, searchParams }) { return (

    - hello from /param-and-query/{params.slug}?slug={searchParams.slug} + hello from /param-and-query/{params.slug}?slug={(await searchParams).slug}

    ) } diff --git a/test/e2e/app-dir/app/app/search-params-prop/page.js b/test/e2e/app-dir/app/app/search-params-prop/page.js index f6535877d785f..3d4e98320f59c 100644 --- a/test/e2e/app-dir/app/app/search-params-prop/page.js +++ b/test/e2e/app-dir/app/app/search-params-prop/page.js @@ -1,13 +1,16 @@ 'use client' +import { use } from 'react' + export default function Page({ searchParams }) { + const sp = use(searchParams) return (

    hello from searchParams prop client

    diff --git a/test/e2e/app-dir/app/app/search-params-prop/server/page.js b/test/e2e/app-dir/app/app/search-params-prop/server/page.js index 1a809657563c4..6c9d50870b2ce 100644 --- a/test/e2e/app-dir/app/app/search-params-prop/server/page.js +++ b/test/e2e/app-dir/app/app/search-params-prop/server/page.js @@ -1,11 +1,12 @@ -export default function Page({ searchParams }) { +export default async function Page({ searchParams }) { + const sp = await searchParams return (

    hello from searchParams prop server

    diff --git a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts index 3d9f4fb5f4029..783d65fb23f8c 100644 --- a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts +++ b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts @@ -213,7 +213,7 @@ describe('dynamic-data with dynamic = "error"', () => { try { await assertHasRedbox(browser) expect(await getRedboxHeader(browser)).toMatch( - 'Error: Route /search with `dynamic = "error"` couldn\'t be rendered statically because it used `searchParams`' + 'Error: Route /search with `dynamic = "error"` couldn\'t be rendered statically because it used `searchParams.then`' ) } finally { await browser.close() @@ -234,13 +234,13 @@ describe('dynamic-data with dynamic = "error"', () => { 'Error: Route /headers with `dynamic = "error"` couldn\'t be rendered statically because it used `headers`' ) expect(next.cliOutput).toMatch( - "Route /search couldn't be rendered statically because it used `await searchParams`, `searchParams.then`, or similar." + "Error: Route /search couldn't be rendered statically because it used `await searchParams`, `use(searchParams)`, or similar" ) expect(next.cliOutput).toMatch( - 'Error: Route /routes/form-data/error with `dynamic = "error"` couldn\'t be rendered statically because it used `request.formData`.' + 'Error: Route /routes/form-data/error with `dynamic = "error"` couldn\'t be rendered statically because it used `request.formData`' ) expect(next.cliOutput).toMatch( - 'Error: Route /routes/next-url/error with `dynamic = "error"` couldn\'t be rendered statically because it used `nextUrl.toString`.' + 'Error: Route /routes/next-url/error with `dynamic = "error"` couldn\'t be rendered statically because it used `nextUrl.toString`' ) }) } diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js index 5600a7ea3413f..1157206edf8a4 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/client-page/page.js @@ -21,7 +21,7 @@ export default async function Page({ searchParams }) {

    searchParams

    - {Object.entries(searchParams).map(([key, value]) => { + {Object.entries(await searchParams).map(([key, value]) => { return (

    {key}

    diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js index bdd7587d80b5a..6cf7cbf960760 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-dynamic/page.js @@ -44,7 +44,7 @@ export default async function Page({ searchParams }) {

    searchParams

    - {Object.entries(searchParams).map(([key, value]) => { + {Object.entries(await searchParams).map(([key, value]) => { return (

    {key}

    diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js index 00c34b48e2fef..1137df1923ddd 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/force-static/page.js @@ -44,7 +44,7 @@ export default async function Page({ searchParams }) {

    searchParams

    - {Object.entries(searchParams).map(([key, value]) => { + {Object.entries(await searchParams).map(([key, value]) => { return (

    {key}

    diff --git a/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js index 34b586073b167..73e446eb15aa4 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/main/app/top-level/page.js @@ -42,7 +42,7 @@ export default async function Page({ searchParams }) {

    searchParams

    - {Object.entries(searchParams).map(([key, value]) => { + {Object.entries(await searchParams).map(([key, value]) => { return (

    {key}

    diff --git a/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js index c87718537996b..7ddde22ecb811 100644 --- a/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js +++ b/test/e2e/app-dir/dynamic-data/fixtures/require-static/app/search/page.js @@ -9,7 +9,7 @@ export default async function Page({ searchParams }) {

    searchParams

    - {Object.entries(searchParams).map(([key, value]) => { + {Object.entries(await searchParams).map(([key, value]) => { return (

    {key}

    diff --git a/test/e2e/app-dir/metadata-navigation/app/async/[slug]/page.tsx b/test/e2e/app-dir/metadata-navigation/app/async/[slug]/page.tsx index aea4fa3fb4c56..99d9aa2a88047 100644 --- a/test/e2e/app-dir/metadata-navigation/app/async/[slug]/page.tsx +++ b/test/e2e/app-dir/metadata-navigation/app/async/[slug]/page.tsx @@ -1,11 +1,11 @@ -function format({ params, searchParams }) { +async function format({ params, searchParams }) { const { slug } = params const { q } = searchParams return `params - ${slug}${q ? ` query - ${q}` : ''}` } -export default function page(props) { - return

    {format(props)}

    +export default async function page(props) { + return

    {await format(props)}

    } export async function generateMetadata(props, parent) { @@ -13,7 +13,7 @@ export async function generateMetadata(props, parent) { /* mutating */ return { ...parentMetadata, - title: format(props), + title: await format(props), keywords: parentMetadata.keywords.concat(['child']), } } diff --git a/test/e2e/app-dir/metadata/app/dynamic/[slug]/page.tsx b/test/e2e/app-dir/metadata/app/dynamic/[slug]/page.tsx index aea4fa3fb4c56..20b8a84978dd0 100644 --- a/test/e2e/app-dir/metadata/app/dynamic/[slug]/page.tsx +++ b/test/e2e/app-dir/metadata/app/dynamic/[slug]/page.tsx @@ -1,11 +1,11 @@ -function format({ params, searchParams }) { +async function format({ params, searchParams }) { const { slug } = params - const { q } = searchParams + const { q } = await searchParams return `params - ${slug}${q ? ` query - ${q}` : ''}` } -export default function page(props) { - return

    {format(props)}

    +export default async function page(props) { + return

    {await format(props)}

    } export async function generateMetadata(props, parent) { @@ -13,7 +13,7 @@ export async function generateMetadata(props, parent) { /* mutating */ return { ...parentMetadata, - title: format(props), + title: await format(props), keywords: parentMetadata.keywords.concat(['child']), } } diff --git a/test/e2e/app-dir/navigation/app/assertion/page/page.js b/test/e2e/app-dir/navigation/app/assertion/page/page.js index 778c5c0807b30..9418ae7669833 100644 --- a/test/e2e/app-dir/navigation/app/assertion/page/page.js +++ b/test/e2e/app-dir/navigation/app/assertion/page/page.js @@ -2,8 +2,9 @@ import { strict as assert } from 'node:assert' import Link from 'next/link' import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers' -export default function Page({ searchParams }) { - assert(searchParams[NEXT_RSC_UNION_QUERY] === undefined) +export default async function Page({ searchParams }) { + const rscUnionQuery = (await searchParams)[NEXT_RSC_UNION_QUERY] + assert(rscUnionQuery === undefined) return ( <> diff --git a/test/e2e/app-dir/navigation/app/search-params/page.js b/test/e2e/app-dir/navigation/app/search-params/page.js index fb6eb4627272f..155711233bc00 100644 --- a/test/e2e/app-dir/navigation/app/search-params/page.js +++ b/test/e2e/app-dir/navigation/app/search-params/page.js @@ -1,9 +1,9 @@ import Link from 'next/link' -export default function page({ searchParams }) { +export default async function page({ searchParams }) { return (
    -

    {searchParams.name ?? ''}

    +

    {(await searchParams).name ?? ''}

    home diff --git a/test/e2e/app-dir/next-form/basepath/app/search/page.tsx b/test/e2e/app-dir/next-form/basepath/app/search/page.tsx index 67ee169cc717e..0f0d71ea5f65b 100644 --- a/test/e2e/app-dir/next-form/basepath/app/search/page.tsx +++ b/test/e2e/app-dir/next-form/basepath/app/search/page.tsx @@ -1,7 +1,7 @@ import * as React from 'react' export default async function SearchPage({ searchParams }) { - const query = searchParams.query as string + const query = (await searchParams).query as string await sleep(1000) return
    query: {JSON.stringify(query)}
    } diff --git a/test/e2e/app-dir/next-form/default/app/forms/button-formaction-unsupported/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/button-formaction-unsupported/page.tsx index 50e079e2310ca..85ab243747478 100644 --- a/test/e2e/app-dir/next-form/default/app/forms/button-formaction-unsupported/page.tsx +++ b/test/e2e/app-dir/next-form/default/app/forms/button-formaction-unsupported/page.tsx @@ -2,12 +2,14 @@ import * as React from 'react' import Form from 'next/form' +type AnySearchParams = { [key: string]: string | Array | undefined } + export default function Home({ searchParams, }: { - searchParams: Record + searchParams: Promise }) { - const attribute = searchParams.attribute as string | undefined + const attribute = React.use(searchParams).attribute return (
    { diff --git a/test/e2e/app-dir/next-form/default/app/redirected-from-action/page.tsx b/test/e2e/app-dir/next-form/default/app/redirected-from-action/page.tsx index 83fef4979e14c..42078f95b5204 100644 --- a/test/e2e/app-dir/next-form/default/app/redirected-from-action/page.tsx +++ b/test/e2e/app-dir/next-form/default/app/redirected-from-action/page.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -export default function RedirectedPage({ searchParams }) { - const query = searchParams.query as string +export default async function RedirectedPage({ searchParams }) { + const query = (await searchParams).query as string return
    query: {JSON.stringify(query)}
    } diff --git a/test/e2e/app-dir/next-form/default/app/search/page.tsx b/test/e2e/app-dir/next-form/default/app/search/page.tsx index 67ee169cc717e..0f0d71ea5f65b 100644 --- a/test/e2e/app-dir/next-form/default/app/search/page.tsx +++ b/test/e2e/app-dir/next-form/default/app/search/page.tsx @@ -1,7 +1,7 @@ import * as React from 'react' export default async function SearchPage({ searchParams }) { - const query = searchParams.query as string + const query = (await searchParams).query as string await sleep(1000) return
    query: {JSON.stringify(query)}
    } diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx index fd46b4c8e2513..29d63371927b8 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx @@ -17,7 +17,10 @@ export default async function Page({ params, searchParams }) {
    - +
    ) diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/page.tsx index c6411141bfd44..07a2856a5ef44 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/page.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' import { UpdateSearchParamsButton } from '../../components/UpdateSearchParamsButton' -export default function Home({ searchParams }) { +export default async function Home({ searchParams }) { + const sp = await searchParams return (
    @@ -10,7 +11,7 @@ export default function Home({ searchParams }) {
    Random # from Root Page: {Math.random()}
    - +
    ) } diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/(.)login/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/(.)login/page.tsx index b681fe548b14c..dac2630832c05 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/(.)login/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/(.)login/page.tsx @@ -16,7 +16,10 @@ export default async function Page({ searchParams }) {
    - +
    ) diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/page.tsx index 4d4e976bcdfd0..68f5fb29b99c5 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/page.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import { UpdateSearchParamsButton } from '../components/UpdateSearchParamsButton' -export default function Home({ searchParams }) { +export default async function Home({ searchParams }) { return (
    @@ -10,7 +10,7 @@ export default function Home({ searchParams }) {
    Random # from Root Page: {Math.random()}
    - +
    ) } diff --git a/test/e2e/app-dir/ppr-full/components/optimistic.jsx b/test/e2e/app-dir/ppr-full/components/optimistic.jsx index eb954194c9ffc..7905809d46b96 100644 --- a/test/e2e/app-dir/ppr-full/components/optimistic.jsx +++ b/test/e2e/app-dir/ppr-full/components/optimistic.jsx @@ -1,6 +1,6 @@ export async function Optimistic({ searchParams }) { try { - return
    foo search: {searchParams.foo}
    + return
    foo search: {(await searchParams).foo}
    } catch (err) { return
    foo search: optimistic
    } diff --git a/test/e2e/app-dir/ppr-navigations/search-params/app/page.tsx b/test/e2e/app-dir/ppr-navigations/search-params/app/page.tsx index 51bc57820d938..0a537075c1d41 100644 --- a/test/e2e/app-dir/ppr-navigations/search-params/app/page.tsx +++ b/test/e2e/app-dir/ppr-navigations/search-params/app/page.tsx @@ -1,9 +1,11 @@ import Link from 'next/link' +type AnySearchParams = { [key: string]: string | Array | undefined } + export default async function Page({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined } + searchParams: Promise }) { const hasParams = Object.keys(searchParams).length > 0 return ( @@ -11,7 +13,7 @@ export default async function Page({ Go {hasParams ? (
    - Search params: {JSON.stringify(searchParams)} + Search params: {JSON.stringify(await searchParams)}
    ) : null} diff --git a/test/e2e/app-dir/ppr/app/search/page.jsx b/test/e2e/app-dir/ppr/app/search/page.jsx index 606b144c7ef1a..99d32893ffdc6 100644 --- a/test/e2e/app-dir/ppr/app/search/page.jsx +++ b/test/e2e/app-dir/ppr/app/search/page.jsx @@ -1,3 +1,3 @@ -export default function Page({ searchParams }) { - return searchParams.query ?? null +export default async function Page({ searchParams }) { + return (await searchParams).query ?? null } diff --git a/test/e2e/app-dir/prefetch-searchparam/app/page.tsx b/test/e2e/app-dir/prefetch-searchparam/app/page.tsx index 932fdc612a540..0a154a0a695b4 100644 --- a/test/e2e/app-dir/prefetch-searchparam/app/page.tsx +++ b/test/e2e/app-dir/prefetch-searchparam/app/page.tsx @@ -1,11 +1,11 @@ import Link from 'next/link' -export default function Page({ searchParams }: { searchParams: any }) { +export default async function Page({ searchParams }: { searchParams: any }) { return ( <> / /?q=bar -

    {JSON.stringify(searchParams)}

    +

    {JSON.stringify(await searchParams)}

    ) } diff --git a/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js b/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js index 6f6893b2c31de..65434f455b67e 100644 --- a/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js +++ b/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js @@ -1,8 +1,8 @@ import Link from 'next/link' import Nav from '../../../components/nav' -export default function LinkPage({ searchParams }) { - const queryId = searchParams.id || '0' +export default async function LinkPage({ searchParams }) { + const queryId = (await searchParams).id || '0' const id = parseInt(queryId) return ( <> diff --git a/test/e2e/app-dir/search-params-react-key/app/page.tsx b/test/e2e/app-dir/search-params-react-key/app/page.tsx index 0ae16647a0888..0c8029cc647d8 100644 --- a/test/e2e/app-dir/search-params-react-key/app/page.tsx +++ b/test/e2e/app-dir/search-params-react-key/app/page.tsx @@ -1,10 +1,10 @@ import React from 'react' import ClientComponent from './client-component' -export default ({ searchParams }) => { +export default async ({ searchParams }) => { return (
    -
    {JSON.stringify(searchParams)}
    +
    {JSON.stringify(await searchParams)}
    ) diff --git a/test/e2e/app-dir/searchparams-reuse-loading/app/search-params/page.tsx b/test/e2e/app-dir/searchparams-reuse-loading/app/search-params/page.tsx index 4a21d655f124d..60741b047ecbf 100644 --- a/test/e2e/app-dir/searchparams-reuse-loading/app/search-params/page.tsx +++ b/test/e2e/app-dir/searchparams-reuse-loading/app/search-params/page.tsx @@ -1,15 +1,16 @@ import Link from 'next/link' +type AnySearchParams = { [key: string]: string | Array | undefined } export default async function Page({ searchParams, }: { - searchParams: Record + searchParams: Promise }) { // sleep for 500ms await new Promise((resolve) => setTimeout(resolve, 500)) return ( <> -

    {JSON.stringify(searchParams)}

    +

    {JSON.stringify(await searchParams)}

    Back /id=1 diff --git a/test/e2e/app-dir/searchparams-reuse-loading/app/search/page.tsx b/test/e2e/app-dir/searchparams-reuse-loading/app/search/page.tsx index 3539642741874..44c64ba799718 100644 --- a/test/e2e/app-dir/searchparams-reuse-loading/app/search/page.tsx +++ b/test/e2e/app-dir/searchparams-reuse-loading/app/search/page.tsx @@ -1,13 +1,19 @@ import Link from 'next/link' import { Search } from './search' -export default async function Page({ searchParams }: { searchParams: any }) { +type AnySearchParams = { [key: string]: string | Array | undefined } + +export default async function Page({ + searchParams, +}: { + searchParams: Promise +}) { await new Promise((resolve) => setTimeout(resolve, 3000)) return (
    -

    Search Value: {searchParams.q ?? 'None'}

    +

    Search Value: {(await searchParams).q ?? 'None'}

    Home diff --git a/test/e2e/app-dir/searchparams-reuse-loading/app/with-middleware/search-params/someValue/page.tsx b/test/e2e/app-dir/searchparams-reuse-loading/app/with-middleware/search-params/someValue/page.tsx index 4c40b77c01014..17726845bc044 100644 --- a/test/e2e/app-dir/searchparams-reuse-loading/app/with-middleware/search-params/someValue/page.tsx +++ b/test/e2e/app-dir/searchparams-reuse-loading/app/with-middleware/search-params/someValue/page.tsx @@ -1,15 +1,16 @@ import Link from 'next/link' +type AnySearchParams = { [key: string]: string | Array | undefined } export default async function Page({ searchParams, }: { - searchParams: Record + searchParams: Promise }) { // sleep for 500ms await new Promise((resolve) => setTimeout(resolve, 500)) return ( <> -

    {JSON.stringify(searchParams)}

    +

    {JSON.stringify(await searchParams)}

    Back ) diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/client-component-page/page.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/client-component-page/page.tsx index 0baa056921d92..c68dcc22ae736 100644 --- a/test/e2e/app-dir/searchparams-static-bailout/app/client-component-page/page.tsx +++ b/test/e2e/app-dir/searchparams-static-bailout/app/client-component-page/page.tsx @@ -1,9 +1,16 @@ 'use client' +import { use } from 'react' import { nanoid } from 'nanoid' -export default function Page({ searchParams }) { +type AnySearchParams = { [key: string]: string | Array | undefined } + +export default function Page({ + searchParams, +}: { + searchParams: Promise +}) { return ( <> -

    Parameter: {searchParams.search}

    +

    Parameter: {use(searchParams).search}

    {nanoid()}

    ) diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx index 7afd6d9a32716..248cb3eb9babe 100644 --- a/test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx +++ b/test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx @@ -1,8 +1,15 @@ 'use client' -export default function ClientComponent({ searchParams }) { + +import { use } from 'react' + +export default function ClientComponent({ + searchParams, +}: { + searchParams: Promise +}) { return ( <> -

    Parameter: {searchParams.search}

    +

    Parameter: {use(searchParams).search}

    ) } diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx index 54aacb3456c48..737a68db29e4e 100644 --- a/test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx +++ b/test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx @@ -1,9 +1,9 @@ import { nanoid } from 'nanoid' -export default function Page({ searchParams }) { +export default async function Page({ searchParams }) { return ( <> -

    Parameter: {searchParams.search}

    +

    Parameter: {(await searchParams).search}

    {nanoid()}

    ) diff --git a/test/production/app-dir/unexpected-error/app/isr-unexpected-error/page.tsx b/test/production/app-dir/unexpected-error/app/isr-unexpected-error/page.tsx index f0b8eadf5127e..2e6e0a9b2fe8f 100644 --- a/test/production/app-dir/unexpected-error/app/isr-unexpected-error/page.tsx +++ b/test/production/app-dir/unexpected-error/app/isr-unexpected-error/page.tsx @@ -1,8 +1,12 @@ +type AnySearchParams = { [key: string]: string | Array | undefined } + export const revalidate = 1 -export default function UnexpectedErrorPage(props) { +export default async function UnexpectedErrorPage(props: { + searchParams: Promise +}) { // use query param to only throw error during runtime, not build time - if (props.searchParams.error) { + if ((await props.searchParams).error) { throw new Error('Oh no') } return

    /unexpected-error

    diff --git a/test/production/app-dir/unexpected-error/app/ssr-unexpected-error-after-streaming/page.tsx b/test/production/app-dir/unexpected-error/app/ssr-unexpected-error-after-streaming/page.tsx index 615a7bfb5bc8d..65fb3815f4c02 100644 --- a/test/production/app-dir/unexpected-error/app/ssr-unexpected-error-after-streaming/page.tsx +++ b/test/production/app-dir/unexpected-error/app/ssr-unexpected-error-after-streaming/page.tsx @@ -1,6 +1,12 @@ -export default function UnexpectedErrorPage(props) { +import { use } from 'react' + +type AnySearchParams = { [key: string]: string | Array | undefined } + +export default function UnexpectedErrorPage(props: { + searchParams: Promise +}) { // use query param to only throw error during runtime, not build time - if (props.searchParams.error) { + if (use(props.searchParams).error) { throw new Error('Oh no') } return

    /unexpected-error

    diff --git a/test/production/app-dir/unexpected-error/app/ssr-unexpected-error/page.tsx b/test/production/app-dir/unexpected-error/app/ssr-unexpected-error/page.tsx index 615a7bfb5bc8d..65fb3815f4c02 100644 --- a/test/production/app-dir/unexpected-error/app/ssr-unexpected-error/page.tsx +++ b/test/production/app-dir/unexpected-error/app/ssr-unexpected-error/page.tsx @@ -1,6 +1,12 @@ -export default function UnexpectedErrorPage(props) { +import { use } from 'react' + +type AnySearchParams = { [key: string]: string | Array | undefined } + +export default function UnexpectedErrorPage(props: { + searchParams: Promise +}) { // use query param to only throw error during runtime, not build time - if (props.searchParams.error) { + if (use(props.searchParams).error) { throw new Error('Oh no') } return

    /unexpected-error

    From 0e61cc0739acba2717d812049c33f79c6660dc37 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 24 Sep 2024 14:31:01 -0700 Subject: [PATCH 07/14] Support params as Promise --- packages/next/server.d.ts | 6 +- .../plugins/next-types-plugin/index.ts | 10 +- .../src/client/components/client-page.tsx | 62 +- .../src/client/components/client-segment.tsx | 61 +- .../next/src/client/components/navigation.ts | 35 +- packages/next/src/lib/metadata/metadata.tsx | 20 +- .../next/src/lib/metadata/resolve-metadata.ts | 49 +- .../next/src/server/app-render/app-render.tsx | 15 +- .../app-render/create-component-tree.tsx | 271 +- .../server/app-render/dynamic-rendering.ts | 40 +- .../next/src/server/app-render/entry-base.ts | 14 +- packages/next/src/server/base-server.ts | 2 +- .../src/server/request/fallback-params.ts | 58 - .../next/src/server/request/params.browser.ts | 161 ++ packages/next/src/server/request/params.ts | 455 ++- .../server/request/search-params.browser.ts | 2 +- .../next/src/server/request/search-params.ts | 51 +- .../acceptance-app/hydration-error.test.ts | 8 +- .../async/layout-access/client/layout.tsx | 30 + .../async/layout-access/client/page.tsx | 3 + .../async/layout-access/server/layout.tsx | 27 + .../async/layout-access/server/page.tsx | 3 + .../async/layout-has/client/layout.tsx | 39 + .../async/layout-has/client/page.tsx | 3 + .../async/layout-has/server/layout.tsx | 35 + .../async/layout-has/server/page.tsx | 3 + .../async/layout-spread/client/layout.tsx | 34 + .../async/layout-spread/client/page.tsx | 3 + .../async/layout-spread/server/layout.tsx | 30 + .../async/layout-spread/server/page.tsx | 3 + .../async/page-access/client/page.tsx | 27 + .../async/page-access/server/page.tsx | 24 + .../[highcard]/async/page-has/client/page.tsx | 36 + .../[highcard]/async/page-has/server/page.tsx | 32 + .../async/page-spread/client/page.tsx | 29 + .../async/page-spread/server/page.tsx | 25 + .../semantics/[lowcard]/[highcard]/layout.tsx | 28 + .../sync/layout-access/client/layout.tsx | 31 + .../sync/layout-access/client/page.tsx | 3 + .../sync/layout-access/server/layout.tsx | 29 + .../sync/layout-access/server/page.tsx | 3 + .../sync/layout-has/client/layout.tsx | 41 + .../sync/layout-has/client/page.tsx | 3 + .../sync/layout-has/server/layout.tsx | 39 + .../sync/layout-has/server/page.tsx | 3 + .../sync/layout-spread/client/layout.tsx | 36 + .../sync/layout-spread/client/page.tsx | 3 + .../sync/layout-spread/server/layout.tsx | 35 + .../sync/layout-spread/server/page.tsx | 3 + .../sync/page-access/client/page.tsx | 28 + .../sync/page-access/server/page.tsx | 26 + .../[highcard]/sync/page-has/client/page.tsx | 38 + .../[highcard]/sync/page-has/server/page.tsx | 36 + .../sync/page-spread/client/page.tsx | 33 + .../sync/page-spread/server/page.tsx | 31 + .../app/params/semantics/[lowcard]/layout.tsx | 28 + .../[status]/async/layout/client/layout.tsx | 54 + .../[status]/async/layout/client/page.tsx | 3 + .../[status]/async/layout/server/layout.tsx | 53 + .../[status]/async/layout/server/page.tsx | 3 + .../[status]/async/page/client/page.tsx | 51 + .../[status]/async/page/server/page.tsx | 50 + .../[status]/sync/layout/client/layout.tsx | 70 + .../[status]/sync/layout/client/page.tsx | 3 + .../[status]/sync/layout/server/layout.tsx | 68 + .../[status]/sync/layout/server/page.tsx | 3 + .../[status]/sync/page/client/page.tsx | 67 + .../[status]/sync/page/server/page.tsx | 65 + .../dynamic-io/dynamic-io.params.test.ts | 2483 +++++++++++++++++ test/e2e/app-dir/dynamic-io/next.config.js | 3 +- 70 files changed, 4872 insertions(+), 287 deletions(-) create mode 100644 packages/next/src/server/request/params.browser.ts create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/client/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/server/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index 3b2c664e719c0..e3ba1fa950e07 100644 --- a/packages/next/server.d.ts +++ b/packages/next/server.d.ts @@ -14,7 +14,5 @@ export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url' export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response' export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types' export { unstable_after } from 'next/dist/server/after' -export type { - SearchParams, - UnsafeUnwrappedSearchParams, -} from 'next/dist/server/request/search-params' +export type { UnsafeUnwrappedSearchParams } from 'next/dist/server/request/search-params' +export type { UnsafeUnwrappedParams } from 'next/dist/server/request/params' diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index 1b494e2c2cb2c..891b15f74498d 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -103,7 +103,7 @@ if ('${method}' in entry) { >() checkFields< Diff< - ParamCheck, + ParamCheck, { __tag__: '${method}' __param_position__: 'second' @@ -158,13 +158,13 @@ if ('generateViewport' in entry) { } // Check the arguments and return type of the generateStaticParams function if ('generateStaticParams' in entry) { - checkFields>, 'generateStaticParams'>>() + checkFields>, 'generateStaticParams'>>() checkFields }, { __tag__: 'generateStaticParams', __return_type__: ReturnType> }>>() } -type PageParams = any +type SegmentParams = {[param: string]: string | string[] | undefined} export interface PageProps { - params?: any + params?: Promise searchParams?: Promise } export interface LayoutProps { @@ -174,7 +174,7 @@ ${ ? options.slots.map((slot) => ` ${slot}: React.ReactNode`).join('\n') : '' } - params?: any + params?: Promise } // ============= diff --git a/packages/next/src/client/components/client-page.tsx b/packages/next/src/client/components/client-page.tsx index b50afcb16f902..29cb3be77308b 100644 --- a/packages/next/src/client/components/client-page.tsx +++ b/packages/next/src/client/components/client-page.tsx @@ -1,26 +1,36 @@ 'use client' import type { ParsedUrlQuery } from 'querystring' -import { use } from 'react' import { InvariantError } from '../../shared/lib/invariant-error' import type { Params } from '../../server/request/params' +/** + * When the Page is a client component we send the params and searchParams to this client wrapper + * where they are turned into dynamically tracked values before being passed to the actual Page component. + * + * additionally we may send promises representing the params and searchParams. We don't ever use these passed + * values but it can be necessary for the sender to send a Promise that doesn't resolve in certain situations. + * It is up to the caller to decide if the promises are needed. + */ export function ClientPageRoot({ Component, - params, searchParams, + params, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + promises, }: { Component: React.ComponentType + searchParams: ParsedUrlQuery params: Params - searchParams: Promise + promises?: Array> }) { if (typeof window === 'undefined') { const { staticGenerationAsyncStorage } = require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external') let clientSearchParams: Promise - let trackedParams: Params + let clientParams: Promise // We are going to instrument the searchParams prop with tracking for the // appropriate context. We wrap differently in prerendering vs rendering const store = staticGenerationAsyncStorage.getStore() @@ -32,35 +42,35 @@ export function ClientPageRoot({ if (store.isStaticGeneration) { // We are in a prerender context - // We need to recover the underlying searchParams from the server - const { reifyClientPrerenderSearchParams } = + const { createPrerenderSearchParamsFromClient } = require('../../server/request/search-params') as typeof import('../../server/request/search-params') - clientSearchParams = reifyClientPrerenderSearchParams(store) - } else { - // We are in a dynamic context and need to unwrap the underlying searchParams + clientSearchParams = createPrerenderSearchParamsFromClient(store) - // We can't type that searchParams is passed but since we control both the definition - // of this component and the usage of it we can assume it - const underlying = use(searchParams) + const { createPrerenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') - const { reifyClientRenderSearchParams } = + clientParams = createPrerenderParamsFromClient(params, store) + } else { + const { createRenderSearchParamsFromClient } = require('../../server/request/search-params') as typeof import('../../server/request/search-params') - clientSearchParams = reifyClientRenderSearchParams(underlying, store) + clientSearchParams = createRenderSearchParamsFromClient( + searchParams, + store + ) + const { createRenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') + clientParams = createRenderParamsFromClient(params, store) } - const { createDynamicallyTrackedParams } = - require('../../server/request/fallback-params') as typeof import('../../server/request/fallback-params') - - trackedParams = createDynamicallyTrackedParams(params) - return ( - - ) + return } else { - const underlying = use(searchParams) - - const { reifyClientRenderSearchParams } = + const { createRenderSearchParamsFromClient } = require('../../server/request/search-params.browser') as typeof import('../../server/request/search-params.browser') - const clientSearchParams = reifyClientRenderSearchParams(underlying) - return + const clientSearchParams = createRenderSearchParamsFromClient(searchParams) + const { createRenderParamsFromClient } = + require('../../server/request/params.browser') as typeof import('../../server/request/params.browser') + const clientParams = createRenderParamsFromClient(params) + + return } } diff --git a/packages/next/src/client/components/client-segment.tsx b/packages/next/src/client/components/client-segment.tsx index a99bb021a50fc..f7bcf8a907937 100644 --- a/packages/next/src/client/components/client-segment.tsx +++ b/packages/next/src/client/components/client-segment.tsx @@ -1,21 +1,58 @@ 'use client' -type ClientSegmentRootProps = { - Component: React.ComponentType - props: { [props: string]: any } -} +import { InvariantError } from '../../shared/lib/invariant-error' + +import type { Params } from '../../server/request/params' +/** + * When the Page is a client component we send the params to this client wrapper + * where they are turned into dynamically tracked values before being passed to the actual Segment component. + * + * additionally we may send a promise representing params. We don't ever use this passed + * value but it can be necessary for the sender to send a Promise that doesn't resolve in certain situations + * such as when dynamicIO is enabled. It is up to the caller to decide if the promises are needed. + */ export function ClientSegmentRoot({ Component, - props, -}: ClientSegmentRootProps) { + slots, + params, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + promise, +}: { + Component: React.ComponentType + slots: { [key: string]: React.ReactNode } + params: Params + promise?: Promise +}) { if (typeof window === 'undefined') { - const { createDynamicallyTrackedParams } = - require('../../server/request/fallback-params') as typeof import('../../server/request/fallback-params') + const { staticGenerationAsyncStorage } = + require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external') + + let clientParams: Promise + // We are going to instrument the searchParams prop with tracking for the + // appropriate context. We wrap differently in prerendering vs rendering + const store = staticGenerationAsyncStorage.getStore() + if (!store) { + throw new InvariantError( + 'Expected staticGenerationStore to exist when handling params in a client segment such as a Layout or Template.' + ) + } + + const { createPrerenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') - props.params = props.params - ? createDynamicallyTrackedParams(props.params) - : {} + if (store.isStaticGeneration) { + clientParams = createPrerenderParamsFromClient(params, store) + } else { + const { createRenderParamsFromClient } = + require('../../server/request/params') as typeof import('../../server/request/params') + clientParams = createRenderParamsFromClient(params, store) + } + return + } else { + const { createRenderParamsFromClient } = + require('../../server/request/params.browser') as typeof import('../../server/request/params.browser') + const clientParams = createRenderParamsFromClient(params) + return } - return } diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts index b7ed000325179..ff1a6b88caa80 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -15,7 +15,7 @@ import { import { getSegmentValue } from './router-reducer/reducers/get-segment-value' import { PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment' import { ReadonlyURLSearchParams } from './navigation.react-server' -import { trackFallbackParamAccessed } from '../../server/app-render/dynamic-rendering' +import { useDynamicRouteParams } from '../../server/app-render/dynamic-rendering' /** * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook @@ -65,27 +65,6 @@ export function useSearchParams(): ReadonlyURLSearchParams { return readonlySearchParams } -function trackParamsAccessed(expression: string) { - if (typeof window === 'undefined') { - // AsyncLocalStorage should not be included in the client bundle. - const { staticGenerationAsyncStorage } = - require('./static-generation-async-storage.external') as typeof import('./static-generation-async-storage.external') - - const staticGenerationStore = staticGenerationAsyncStorage.getStore() - - if ( - staticGenerationStore && - staticGenerationStore.isStaticGeneration && - staticGenerationStore.fallbackRouteParams && - staticGenerationStore.fallbackRouteParams.size > 0 - ) { - // There are fallback route params, we should track these as dynamic - // accesses. - trackFallbackParamAccessed(staticGenerationStore, expression) - } - } -} - /** * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook * that lets you read the current URL's pathname. @@ -105,7 +84,7 @@ function trackParamsAccessed(expression: string) { */ // Client components API export function usePathname(): string { - trackParamsAccessed('usePathname()') + useDynamicRouteParams('usePathname()') // In the case where this is `null`, the compat types added in `next-env.d.ts` // will add a new overload that changes the return type to include `null`. @@ -165,21 +144,19 @@ export function useRouter(): AppRouterInstance { */ // Client components API export function useParams(): T { - trackParamsAccessed('useParams()') + useDynamicRouteParams('useParams()') return useContext(PathParamsContext) as T } /** Get the canonical parameters from the current level to the leaf node. */ // Client components API -export function getSelectedLayoutSegmentPath( +function getSelectedLayoutSegmentPath( tree: FlightRouterState, parallelRouteKey: string, first = true, segmentPath: string[] = [] ): string[] { - trackParamsAccessed('getSelectedLayoutSegmentPath()') - let node: FlightRouterState if (first) { // Use the provided parallel route key on the first parallel route @@ -238,7 +215,7 @@ export function getSelectedLayoutSegmentPath( export function useSelectedLayoutSegments( parallelRouteKey: string = 'children' ): string[] { - trackParamsAccessed('useSelectedLayoutSegments()') + useDynamicRouteParams('useSelectedLayoutSegments()') const context = useContext(LayoutRouterContext) // @ts-expect-error This only happens in `pages`. Type is overwritten in navigation.d.ts @@ -269,7 +246,7 @@ export function useSelectedLayoutSegments( export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' ): string | null { - trackParamsAccessed('useSelectedLayoutSegment()') + useDynamicRouteParams('useSelectedLayoutSegment()') const selectedLayoutSegments = useSelectedLayoutSegments(parallelRouteKey) diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index aef17fee0bb40..bc982d5e66ab4 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -4,6 +4,7 @@ import type { GetDynamicParamFromSegment, } from '../../server/app-render/app-render' import type { LoaderTree } from '../../server/lib/app-dir-module' +import type { CreateServerParamsForMetadata } from '../../server/request/params' import React from 'react' import { @@ -30,7 +31,6 @@ import type { } from './types/metadata-interface' import { isNotFoundError } from '../../client/components/not-found' import type { MetadataContext } from './types/resolvers' -import type { CreateDynamicallyTrackedParams } from '../../server/request/fallback-params' import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' import { trackFallbackParamAccessed } from '../../server/app-render/dynamic-rendering' @@ -89,7 +89,8 @@ export function createMetadataComponents({ getDynamicParamFromSegment, appUsingSizeAdjustment, errorType, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }: { tree: LoaderTree searchParams: Promise @@ -97,7 +98,8 @@ export function createMetadataComponents({ getDynamicParamFromSegment: GetDynamicParamFromSegment appUsingSizeAdjustment: boolean errorType?: 'not-found' | 'redirect' - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams + createServerParamsForMetadata: CreateServerParamsForMetadata + staticGenerationStore: StaticGenerationStore }): [React.ComponentType, () => Promise] { let currentMetadataReady: | null @@ -112,7 +114,8 @@ export function createMetadataComponents({ searchParams, getDynamicParamFromSegment, metadataContext, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, errorType ) @@ -175,7 +178,8 @@ async function getResolvedMetadata( searchParams: Promise, getDynamicParamFromSegment: GetDynamicParamFromSegment, metadataContext: MetadataContext, - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams, + createServerParamsForMetadata: CreateServerParamsForMetadata, + staticGenerationStore: StaticGenerationStore, errorType?: 'not-found' | 'redirect' ): Promise<[any, Array]> { const errorMetadataItem: [null, null, null] = [null, null, null] @@ -190,7 +194,8 @@ async function getResolvedMetadata( getDynamicParamFromSegment, errorConvention, metadataContext, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) if (!error) { return [null, createMetadataElements(metadata, viewport)] @@ -210,7 +215,8 @@ async function getResolvedMetadata( getDynamicParamFromSegment, errorConvention: 'not-found', metadataContext, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) return [ notFoundMetadataError || error, diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index e0eede8485590..508643c49c98c 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -49,7 +49,11 @@ import { getTracer } from '../../server/lib/trace/tracer' import { ResolveMetadataSpan } from '../../server/lib/trace/constants' import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment' import * as Log from '../../build/output/log' -import type { CreateDynamicallyTrackedParams } from '../../server/request/fallback-params' +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' +import type { + Params, + CreateServerParamsForMetadata, +} from '../../server/request/params' type StaticIcons = Pick @@ -479,10 +483,11 @@ export async function resolveMetadataItems({ getDynamicParamFromSegment, searchParams, errorConvention, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }: { tree: LoaderTree - parentParams: { [key: string]: any } + parentParams: Params metadataItems: MetadataItems errorMetadataItem: MetadataItems[number] /** Provided tree can be nested subtree, this argument says what is the path of such subtree */ @@ -490,7 +495,8 @@ export async function resolveMetadataItems({ getDynamicParamFromSegment: GetDynamicParamFromSegment searchParams: ParsedUrlQuery errorConvention: 'not-found' | undefined - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams + createServerParamsForMetadata: CreateServerParamsForMetadata + staticGenerationStore: StaticGenerationStore }): Promise { const [segment, parallelRoutes, { page }] = tree const currentTreePrefix = [...treePrefix, segment] @@ -501,17 +507,18 @@ export async function resolveMetadataItems({ /** * Create object holding the parent params and current params */ - const currentParams = - // Handle null case where dynamic param is optional - segmentParam && segmentParam.value !== null - ? { - ...parentParams, - [segmentParam.param]: segmentParam.value, - } - : // Pass through parent params to children - parentParams + let currentParams = parentParams + if (segmentParam && segmentParam.value !== null) { + currentParams = { + ...parentParams, + [segmentParam.param]: segmentParam.value, + } + } - const params = createDynamicallyTrackedParams(currentParams) + const params = createServerParamsForMetadata( + currentParams, + staticGenerationStore + ) let layerProps: LayoutProps | PageProps if (isPage) { @@ -548,7 +555,8 @@ export async function resolveMetadataItems({ searchParams, getDynamicParamFromSegment, errorConvention, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) } @@ -891,10 +899,11 @@ export async function resolveMetadata({ searchParams, errorConvention, metadataContext, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }: { tree: LoaderTree - parentParams: { [key: string]: any } + parentParams: Params metadataItems: MetadataItems errorMetadataItem: MetadataItems[number] /** Provided tree can be nested subtree, this argument says what is the path of such subtree */ @@ -903,7 +912,8 @@ export async function resolveMetadata({ searchParams: { [key: string]: any } errorConvention: 'not-found' | undefined metadataContext: MetadataContext - createDynamicallyTrackedParams: CreateDynamicallyTrackedParams + createServerParamsForMetadata: CreateServerParamsForMetadata + staticGenerationStore: StaticGenerationStore }): Promise<[any, ResolvedMetadata, ResolvedViewport]> { const resolvedMetadataItems = await resolveMetadataItems({ tree, @@ -913,7 +923,8 @@ export async function resolveMetadata({ getDynamicParamFromSegment, searchParams, errorConvention, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) let error let metadata: ResolvedMetadata = createDefaultMetadata() diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index d9feb22829336..a9833c949451b 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -386,7 +386,7 @@ async function generateDynamicRSCPayload( componentMod: { tree: loaderTree, createServerSearchParamsForMetadata, - createDynamicallyTrackedParams, + createServerParamsForMetadata, }, getDynamicParamFromSegment, appUsingSizeAdjustment, @@ -414,7 +414,8 @@ async function generateDynamicRSCPayload( ), getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) flightData = ( await walkTreeWithFlightRouterState({ @@ -551,7 +552,7 @@ async function getRSCPayload( componentMod: { GlobalError, createServerSearchParamsForMetadata, - createDynamicallyTrackedParams, + createServerParamsForMetadata, }, requestStore: { url }, staticGenerationStore, @@ -577,7 +578,8 @@ async function getRSCPayload( ), getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) const preloadCallbacks: PreloadCallbacks = [] @@ -650,7 +652,7 @@ async function getErrorRSCPayload( componentMod: { GlobalError, createServerSearchParamsForMetadata, - createDynamicallyTrackedParams, + createServerParamsForMetadata, }, requestStore: { url }, requestId, @@ -670,7 +672,8 @@ async function getErrorRSCPayload( errorType, getDynamicParamFromSegment, appUsingSizeAdjustment, - createDynamicallyTrackedParams, + createServerParamsForMetadata, + staticGenerationStore, }) const initialHead = ( diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index edbb67e98bc4b..dfb0c2d39cae5 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -47,7 +47,10 @@ export function createComponentTree(props: { ) } -function errorMissingDefaultExport(pagePath: string, convention: string) { +function errorMissingDefaultExport( + pagePath: string, + convention: string +): never { throw new Error( `The default export is not a React Component in "${pagePath}/${convention}"` ) @@ -92,8 +95,9 @@ async function createComponentTreeInternal({ ClientPageRoot, ClientSegmentRoot, createServerSearchParamsForServerPage, - createServerSearchParamsForClientPage, - createDynamicallyTrackedParams, + createPrerenderSearchParamsForClientPage, + createServerParamsForServerSegment, + createPrerenderParamsForClientSegment, serverHooks: { DynamicServerError }, Postpone, }, @@ -308,15 +312,13 @@ async function createComponentTreeInternal({ const segmentParam = getDynamicParamFromSegment(segment) // Create object holding the parent params and current params - const currentParams = - // Handle null case where dynamic param is optional - segmentParam && segmentParam.value !== null - ? { - ...parentParams, - [segmentParam.param]: segmentParam.value, - } - : // Pass through parent params to children - parentParams + let currentParams: Params = parentParams + if (segmentParam && segmentParam.value !== null) { + currentParams = { + ...parentParams, + [segmentParam.param]: segmentParam.value, + } + } // Resolve the segment param const actualSegment = segmentParam ? segmentParam.treeSegment : segment @@ -514,10 +516,6 @@ async function createComponentTreeInternal({ const isClientComponent = isClientReference(layoutOrPageMod) - // We avoid cloning this object because it gets consumed here exclusively. - const props: { [prop: string]: any } = parallelRouteProps - - // Assign params to props if ( process.env.NODE_ENV === 'development' && 'params' in parallelRouteProps @@ -529,30 +527,49 @@ async function createComponentTreeInternal({ } if (isPage) { + const PageComponent = Component // Assign searchParams to props if this is a page let pageElement: React.ReactNode if (isClientComponent) { - const params = currentParams - const searchParams = createServerSearchParamsForClientPage( - query, - staticGenerationStore - ) - pageElement = ( - - ) + if (isStaticGeneration) { + const promiseOfParams = createPrerenderParamsForClientSegment( + currentParams, + staticGenerationStore + ) + const promiseOfSearchParams = createPrerenderSearchParamsForClientPage( + staticGenerationStore + ) + pageElement = ( + + ) + } else { + pageElement = ( + + ) + } } else { // If we are passing searchParams to a server component Page we need to track their usage in case // the current render mode tracks dynamic API usage. - const params = createDynamicallyTrackedParams(currentParams) + const params = createServerParamsForServerSegment( + currentParams, + staticGenerationStore + ) const searchParams = createServerSearchParamsForServerPage( query, staticGenerationStore ) - pageElement = + pageElement = ( + + ) } return [ actualSegment, @@ -571,65 +588,165 @@ async function createComponentTreeInternal({ loadingData, ] } else { + const SegmentComponent = Component + const isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot = rootLayoutAtThisLevel && 'children' in parallelRoutes && Object.keys(parallelRoutes).length > 1 - let serverSegment: React.ReactNode + let segmentNode: React.ReactNode + if (isClientComponent) { - props.params = currentParams - serverSegment = - } else { - props.params = createDynamicallyTrackedParams(currentParams) - serverSegment = - } + let clientSegment: React.ReactNode - let segmentNode: React.ReactNode - if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { - // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. - // This ensures that a `NotFoundBoundary` is available for when that happens, - // but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice. - // We should instead look into handling the fallback behavior differently in development mode so that it doesn't - // rely on the `NotFound` behavior. - segmentNode = ( - - - {layerAssets} - - {notFoundStyles} - - - - ) : undefined - } + if (isStaticGeneration) { + const promiseOfParams = createPrerenderParamsForClientSegment( + currentParams, + staticGenerationStore + ) + + clientSegment = ( + + ) + } else { + clientSegment = ( + + ) + } + + if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { + // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. + // This ensures that a `NotFoundBoundary` is available for when that happens, + // but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice. + // We should instead look into handling the fallback behavior differently in development mode so that it doesn't + // rely on the `NotFound` behavior. + if (NotFound) { + const notFoundParallelRouteProps = { + children: ( + <> + {notFoundStyles} + + + ), + } + const notfoundClientSegment = ( + + ) + + segmentNode = ( + + + {layerAssets} + {notfoundClientSegment} + + } + > + {layerAssets} + {clientSegment} + + + ) + } else { + segmentNode = ( + + + {layerAssets} + {clientSegment} + + + ) + } + } else { + segmentNode = ( + {layerAssets} - {serverSegment} - - - ) + {clientSegment} + + ) + } } else { - segmentNode = ( - - {layerAssets} - {serverSegment} - + const params = createServerParamsForServerSegment( + currentParams, + staticGenerationStore + ) + + let serverSegment = ( + ) - } + if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { + // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. + // This ensures that a `NotFoundBoundary` is available for when that happens, + // but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice. + // We should instead look into handling the fallback behavior differently in development mode so that it doesn't + // rely on the `NotFound` behavior. + segmentNode = ( + + + {layerAssets} + + {notFoundStyles} + + + + ) : undefined + } + > + {layerAssets} + {serverSegment} + + + ) + } else { + segmentNode = ( + + {layerAssets} + {serverSegment} + + ) + } + } // For layouts we just render the component return [ actualSegment, diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index 62ea097c973a4..c0ac2cebd6ee3 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -28,9 +28,12 @@ import React from 'react' import { DynamicServerError } from '../../client/components/hooks-server-context' import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' import { + isDynamicIOPrerender, prerenderAsyncStorage, type PrerenderStore, } from './prerender-async-storage.external' +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' +import { makeHangingPromise } from '../dynamic-rendering-utils' const hasPostpone = typeof React.unstable_postpone === 'function' @@ -343,7 +346,6 @@ export function postponeWithTracking( expression: string, dynamicTracking: null | DynamicTrackingState ): never { - console.log('postponeWithTracking', Error().stack) assertPostpone() if (dynamicTracking) { dynamicTracking.dynamicAccesses.push({ @@ -512,3 +514,39 @@ export function annotateDynamicAccess( }) } } + +export function useDynamicRouteParams(expression: string) { + if (typeof window === 'undefined') { + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + + if ( + staticGenerationStore && + staticGenerationStore.isStaticGeneration && + staticGenerationStore.fallbackRouteParams && + staticGenerationStore.fallbackRouteParams.size > 0 + ) { + // There are fallback route params, we should track these as dynamic + // accesses. + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + // We're prerendering with dynamicIO or PPR or both + if (isDynamicIOPrerender(prerenderStore)) { + // We are in a prerender with dynamicIO semantics + // We are going to hang here and never resolve. This will cause the currently + // rendering component to effectively be a dynamic hole + React.use(makeHangingPromise()) + } else { + // We're prerendering with PPR + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } + } else { + // We're prerendering in legacy mode + throwToInterruptStaticGeneration(expression, staticGenerationStore) + } + } + } +} diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index 86d10df430613..d4d2c37e4c3b8 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -19,10 +19,14 @@ import { ClientPageRoot } from '../../client/components/client-page' import { ClientSegmentRoot } from '../../client/components/client-segment' import { createServerSearchParamsForServerPage, - createServerSearchParamsForClientPage, + createPrerenderSearchParamsForClientPage, createServerSearchParamsForMetadata, } from '../request/search-params' -import { createDynamicallyTrackedParams } from '../request/fallback-params' +import { + createServerParamsForServerSegment, + createServerParamsForMetadata, + createPrerenderParamsForClientSegment, +} from '../request/params' import * as serverHooks from '../../client/components/hooks-server-context' import { NotFoundBoundary } from '../../client/components/not-found-boundary' import { patchFetch as _patchFetch } from '../lib/patch-fetch' @@ -50,9 +54,11 @@ export { requestAsyncStorage, actionAsyncStorage, createServerSearchParamsForServerPage, - createServerSearchParamsForClientPage, createServerSearchParamsForMetadata, - createDynamicallyTrackedParams, + createPrerenderSearchParamsForClientPage, + createServerParamsForServerSegment, + createServerParamsForMetadata, + createPrerenderParamsForClientSegment, serverHooks, preloadStyle, preloadFont, diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index c67f9737a800a..534fe45b83545 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1,7 +1,7 @@ import type { __ApiPreviewProps } from './api-utils' import type { LoadComponentsReturnType } from './load-components' import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' -import type { Params } from '../server/request/params' +import type { Params } from './request/params' import { type FallbackRouteParams, getFallbackRouteParams, diff --git a/packages/next/src/server/request/fallback-params.ts b/packages/next/src/server/request/fallback-params.ts index 67b0e5f70f10d..e3af245362e80 100644 --- a/packages/next/src/server/request/fallback-params.ts +++ b/packages/next/src/server/request/fallback-params.ts @@ -1,9 +1,5 @@ -import { trackFallbackParamAccessed } from '../app-render/dynamic-rendering' -import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' -import type { Params } from './params' -import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' export type FallbackRouteParams = ReadonlyMap @@ -41,57 +37,3 @@ export function getFallbackRouteParams( return params } - -export type CreateDynamicallyTrackedParams = - typeof createDynamicallyTrackedParams - -export function createDynamicallyTrackedParams(params: Params): Params { - const staticGenerationStore = staticGenerationAsyncStorage.getStore() - if ( - !staticGenerationStore || - !staticGenerationStore.isStaticGeneration || - !staticGenerationStore.fallbackRouteParams || - staticGenerationStore.fallbackRouteParams.size === 0 - ) { - return params - } - - // If there are no unknown route params, we can just return the params. - const { fallbackRouteParams } = staticGenerationStore - - return new Proxy(params as Params, { - get(target, prop, receiver) { - // If the property is in the params object, we should track the access if - // it's an unknown dynamic param. - if ( - typeof prop === 'string' && - prop in params && - fallbackRouteParams.has(prop) - ) { - trackFallbackParamAccessed(staticGenerationStore, `params.${prop}`) - } - - return ReflectAdapter.get(target, prop, receiver) - }, - has(target, prop) { - if ( - typeof prop === 'string' && - prop in params && - fallbackRouteParams.has(prop) - ) { - trackFallbackParamAccessed(staticGenerationStore, `params.${prop}`) - } - - return ReflectAdapter.has(target, prop) - }, - ownKeys(target) { - for (const key in params) { - if (fallbackRouteParams.has(key)) { - trackFallbackParamAccessed(staticGenerationStore, 'params') - } - } - - return Reflect.ownKeys(target) - }, - }) -} diff --git a/packages/next/src/server/request/params.browser.ts b/packages/next/src/server/request/params.browser.ts new file mode 100644 index 0000000000000..d72268ca50875 --- /dev/null +++ b/packages/next/src/server/request/params.browser.ts @@ -0,0 +1,161 @@ +import type { Params } from './params' + +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { InvariantError } from '../../shared/lib/invariant-error' +import { describeStringPropertyAccess } from './utils' + +export function createRenderParamsFromClient(underlyingParams: Params) { + if (process.env.NODE_ENV === 'development') { + return makeDynamicallyTrackedExoticParamsWithDevWarnings(underlyingParams) + } else { + return makeUntrackedExoticParams(underlyingParams) + } +} + +interface CacheLifetime {} +const CachedParams = new WeakMap>() + +function makeUntrackedExoticParams(underlyingParams: Params): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const promise = Promise.resolve(underlyingParams) + CachedParams.set(underlyingParams, promise) + + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + writable: true, + }, + value: { + value: underlyingParams, + writable: true, + }, + }) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + break + } + default: { + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + return promise +} + +function makeDynamicallyTrackedExoticParamsWithDevWarnings( + underlyingParams: Params +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const promise = Promise.resolve(underlyingParams) + + Object.defineProperties(promise, { + status: { + value: 'fulfilled', + writable: true, + }, + value: { + value: underlyingParams, + writable: true, + }, + }) + + const proxiedProperties = new Set() + const unproxiedProperties: Array = [] + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + unproxiedProperties.push(prop) + break + } + default: { + proxiedProperties.add(prop) + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (typeof prop === 'string') { + if ( + // We are accessing a property that was proxied to the promise instance + proxiedProperties.has(prop) || + // We are accessing a property that doesn't exist on the promise nor the underlying + Reflect.has(target, prop) === false + ) { + const expression = describeStringPropertyAccess('params', prop) + warnForSyncAccess(expression) + } + } + return ReflectAdapter.get(target, prop, receiver) + }, + ownKeys(target) { + warnForEnumeration(unproxiedProperties) + return Reflect.ownKeys(target) + }, + }) + + CachedParams.set(underlyingParams, proxiedPromise) + return proxiedPromise +} + +function warnForSyncAccess(expression: string) { + console.error( + `A param property was accessed directly with ${expression}. \`params\` is now a Promise and should be awaited before accessing properties of the underlying params object. In this version of Next.js direct access to param properties is still supported to faciliate migration but in a future version you will be required to await \`params\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForEnumeration(missingProperties: Array) { + if (missingProperties.length) { + const describedMissingProperties = + describeListOfPropertyNames(missingProperties) + console.error( + `params are being enumerated incompletely with \`{...params}\`, \`Object.keys(params)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to faciliate migration to the new type. param names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`params\` promise.` + ) + } else { + console.error( + `params are being enumerated with \`{...params}\`, \`Object.keys(params)\`, or similar. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to faciliate migration to the new type. You should update your code to await \`params\` before accessing its properties.` + ) + } +} + +function describeListOfPropertyNames(properties: Array) { + switch (properties.length) { + case 0: + throw new InvariantError( + 'Expected describeListOfPropertyNames to be called with a non-empty list of strings.' + ) + case 1: + return `\`${properties[0]}\`` + case 2: + return `\`${properties[0]}\` and \`${properties[1]}\`` + default: { + let description = '' + for (let i = 0; i < properties.length - 1; i++) { + description += `\`${properties[i]}\`, ` + } + description += `, and \`${properties[properties.length - 1]}\`` + return description + } + } +} diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index a3e8c24c9cd3d..1882806865498 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -1 +1,454 @@ -export type Params = Record +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' +import type { FallbackRouteParams } from './fallback-params' + +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { + abortAndThrowOnSynchronousDynamicDataAccess, + throwToInterruptStaticGeneration, + postponeWithTracking, +} from '../app-render/dynamic-rendering' + +import { + isDynamicIOPrerender, + prerenderAsyncStorage, + type PrerenderStore, +} from '../app-render/prerender-async-storage.external' +import { InvariantError } from '../../shared/lib/invariant-error' +import { + makeResolvedReactPromise, + describeStringPropertyAccess, + throwWithStaticGenerationBailoutErrorWithDynamicError, +} from './utils' +import { makeHangingPromise } from '../dynamic-rendering-utils' + +export type Params = Record | undefined> + +/** + * In this version of Next.js the `params` prop passed to Layouts, Pages, and other Segments is a Promise. + * However to facilitate migration to this new Promise type you can currently still access params directly on the Promise instance passed to these Segments. + * The `UnsafeUnwrappedParams` type is available if you need to temporarily access the underlying params without first awaiting or `use`ing the Promise. + * + * In a future version of Next.js the `params` prop will be a plain Promise and this type will be removed. + * + * Typically instances of `params` can be updated automatically to be treated as a Promise by a codemod published alongside this Next.js version however if you + * have not yet run the codemod of the codemod cannot detect certain instances of `params` usage you should first try to refactor your code to await `params`. + * + * If refactoring is not possible but you still want to be able to access params directly without typescript errors you can cast the params Promise to this type + * + * ```tsx + * type Props = { params: Promise<{ id: string }>} + * + * export default async function Layout(props: Props) { + * const directParams = (props.params as unknown as UnsafeUnwrappedParams) + * return ... + * } + * ``` + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedParams

    = + P extends Promise ? Omit : never + +export function createPrerenderParamsFromClient( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +) { + return createPrerenderParams(underlyingParams, staticGenerationStore) +} + +export function createRenderParamsFromClient( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +) { + return createRenderParams(underlyingParams, staticGenerationStore) +} + +// generateMetadata always runs in RSC context so it is equivalent to a Server Page Component +export type CreateServerParamsForMetadata = typeof createServerParamsForMetadata +export const createServerParamsForMetadata = createServerParamsForServerSegment + +// routes always runs in RSC context so it is equivalent to a Server Page Component +export function createServerParamsForRoute( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +) { + if (staticGenerationStore.isStaticGeneration) { + return createPrerenderParams(underlyingParams, staticGenerationStore) + } else { + return createRenderParams(underlyingParams, staticGenerationStore) + } +} + +export function createServerParamsForServerSegment( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + if (staticGenerationStore.isStaticGeneration) { + return createPrerenderParams(underlyingParams, staticGenerationStore) + } else { + return createRenderParams(underlyingParams, staticGenerationStore) + } +} + +export function createPrerenderParamsForClientSegment( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (isDynamicIOPrerender(prerenderStore)) { + const fallbackParams = staticGenerationStore.fallbackRouteParams + if (fallbackParams) { + for (let key in underlyingParams) { + if (fallbackParams.has(key)) { + // This params object has one of more fallback params so we need to consider + // the awaiting of this params object "dynamic". Since we are in dynamicIO mode + // we encode this as a promise that never resolves + return makeHangingPromise() + } + } + } + } + } + // We're prerendering in a mode that does not abort. We resolve the promise without + // any tracking because we're just transporting a value from server to client where the tracking + // will be applied. + return makeResolvedReactPromise(underlyingParams) +} + +function createPrerenderParams( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + const fallbackParams = staticGenerationStore.fallbackRouteParams + if (fallbackParams) { + let hasSomeFallbackParams = false + for (const key in underlyingParams) { + if (fallbackParams.has(key)) { + hasSomeFallbackParams = true + break + } + } + + if (hasSomeFallbackParams) { + // params need to be treated as dynamic because we have at least one fallback param + const prerenderStore = prerenderAsyncStorage.getStore() + if (prerenderStore) { + if (isDynamicIOPrerender(prerenderStore)) { + // We are in a dynamicIO (PPR or otherwise) prerender + return makeAbortingExoticParams( + underlyingParams, + staticGenerationStore.route, + prerenderStore + ) + } + } + // We aren't in a dynamicIO prerender but we do have fallback params at this + // level so we need to make an erroring exotic params object which will postpone + // if you access the fallback params + return makeErroringExoticParams( + underlyingParams, + fallbackParams, + staticGenerationStore, + prerenderStore + ) + } + } + + // We don't have any fallback params so we have an entirely static safe params object + return makeUntrackedExoticParams(underlyingParams) +} + +function createRenderParams( + underlyingParams: Params, + staticGenerationStore: StaticGenerationStore +): Promise { + if (process.env.NODE_ENV === 'development') { + return makeDynamicallyTrackedExoticParamsWithDevWarnings( + underlyingParams, + staticGenerationStore + ) + } else { + return makeUntrackedExoticParams(underlyingParams) + } +} + +interface CacheLifetime {} +const CachedParams = new WeakMap>() + +function makeAbortingExoticParams( + underlyingParams: Params, + route: string, + prerenderStore: PrerenderStore +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const promise = makeHangingPromise() + CachedParams.set(underlyingParams, promise) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'status': { + // We can't assign params over these properties because the VM and React use + // them to reason about the Promise. + break + } + default: { + Object.defineProperty(promise, prop, { + get() { + const expression = describeStringPropertyAccess('params', prop) + abortAndThrowOnSynchronousDynamicDataAccess( + route, + expression, + prerenderStore + ) + }, + set(newValue) { + Object.defineProperty(promise, prop, { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } + } + }) + + return promise +} + +function makeErroringExoticParams( + underlyingParams: Params, + fallbackParams: FallbackRouteParams, + staticGenerationStore: StaticGenerationStore, + prerenderStore: undefined | PrerenderStore +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + const augmentedUnderlying = { ...underlyingParams } + + // We don't use makeResolvedReactPromise here because params + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(augmentedUnderlying) + CachedParams.set(underlyingParams, promise) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'status': + case 'value': { + // We can't assign params over these properties because the VM and React use + // them to reason about the Promise. + break + } + default: { + if (fallbackParams.has(prop)) { + Object.defineProperty(augmentedUnderlying, prop, { + get() { + const expression = describeStringPropertyAccess('params', prop) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration( + expression, + staticGenerationStore + ) + } + }, + enumerable: true, + }) + Object.defineProperty(promise, prop, { + get() { + const expression = describeStringPropertyAccess('params', prop) + if (staticGenerationStore.dynamicShouldError) { + throwWithStaticGenerationBailoutErrorWithDynamicError( + staticGenerationStore.route, + expression + ) + } else if (prerenderStore) { + postponeWithTracking( + staticGenerationStore.route, + expression, + prerenderStore.dynamicTracking + ) + } else { + throwToInterruptStaticGeneration( + expression, + staticGenerationStore + ) + } + }, + set(newValue) { + Object.defineProperty(promise, prop, { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + } else { + ;(promise as any)[prop] = underlyingParams[prop] + } + } + } + }) + + return promise +} + +function makeUntrackedExoticParams(underlyingParams: Params): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + // We don't use makeResolvedReactPromise here because params + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(underlyingParams) + CachedParams.set(underlyingParams, promise) + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + break + } + default: { + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + return promise +} + +function makeDynamicallyTrackedExoticParamsWithDevWarnings( + underlyingParams: Params, + store: StaticGenerationStore +): Promise { + const cachedParams = CachedParams.get(underlyingParams) + if (cachedParams) { + return cachedParams + } + + // We don't use makeResolvedReactPromise here because params + // supports copying with spread and we don't want to unnecessarily + // instrument the promise with spreadable properties of ReactPromise. + const promise = Promise.resolve(underlyingParams) + + const proxiedProperties = new Set() + const unproxiedProperties: Array = [] + + Object.keys(underlyingParams).forEach((prop) => { + switch (prop) { + case 'then': + case 'value': + case 'status': { + // These properties cannot be shadowed with a search param because they + // are necessary for ReactPromise's to work correctly with `use` + unproxiedProperties.push(prop) + break + } + default: { + proxiedProperties.add(prop) + ;(promise as any)[prop] = underlyingParams[prop] + } + } + }) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (typeof prop === 'string') { + if ( + // We are accessing a property that was proxied to the promise instance + proxiedProperties.has(prop) + ) { + const expression = describeStringPropertyAccess('params', prop) + warnForSyncAccess(store.route, expression) + } + } + return ReflectAdapter.get(target, prop, receiver) + }, + ownKeys(target) { + warnForEnumeration(store.route, unproxiedProperties) + return Reflect.ownKeys(target) + }, + }) + + CachedParams.set(underlyingParams, proxiedPromise) + return proxiedPromise +} + +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}param property was accessed directly with ${expression}. \`params\` is now a Promise and should be awaited before accessing properties of the underlying params object. In this version of Next.js direct access to param properties is still supported to facilitate migration but in a future version you will be required to await \`params\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + ) +} + +function warnForEnumeration( + route: undefined | string, + missingProperties: Array +) { + const prefix = route ? ` In route ${route} ` : '' + if (missingProperties.length) { + const describedMissingProperties = + describeListOfPropertyNames(missingProperties) + console.error( + `${prefix}params are being enumerated incompletely with \`{...params}\`, \`Object.keys(params)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. param names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`params\` promise.` + ) + } else { + console.error( + `${prefix}params are being enumerated with \`{...params}\`, \`Object.keys(params)\`, or similar. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. You should update your code to await \`params\` before accessing its properties.` + ) + } +} + +function describeListOfPropertyNames(properties: Array) { + switch (properties.length) { + case 0: + throw new InvariantError( + 'Expected describeListOfPropertyNames to be called with a non-empty list of strings.' + ) + case 1: + return `\`${properties[0]}\`` + case 2: + return `\`${properties[0]}\` and \`${properties[1]}\`` + default: { + let description = '' + for (let i = 0; i < properties.length - 1; i++) { + description += `\`${properties[i]}\`, ` + } + description += `, and \`${properties[properties.length - 1]}\`` + return description + } + } +} diff --git a/packages/next/src/server/request/search-params.browser.ts b/packages/next/src/server/request/search-params.browser.ts index e6599bd1cd579..eaaa3c788c5a7 100644 --- a/packages/next/src/server/request/search-params.browser.ts +++ b/packages/next/src/server/request/search-params.browser.ts @@ -6,7 +6,7 @@ import { describeHasCheckingStringProperty, } from './utils' -export function reifyClientRenderSearchParams( +export function createRenderSearchParamsFromClient( underlyingSearchParams: SearchParams ): Promise { if (process.env.NODE_ENV === 'development') { diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index dbe6a4b551d7e..b0171480e26a8 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -52,13 +52,13 @@ export type SearchParams = { [key: string]: string | string[] | undefined } export type UnsafeUnwrappedSearchParams

    = P extends Promise ? Omit : never -export function reifyClientPrerenderSearchParams( +export function createPrerenderSearchParamsFromClient( staticGenerationStore: StaticGenerationStore ) { return createPrerenderSearchParams(staticGenerationStore) } -export function reifyClientRenderSearchParams( +export function createRenderSearchParamsFromClient( underlyingSearchParams: SearchParams, staticGenerationStore: StaticGenerationStore ) { @@ -83,18 +83,7 @@ export function createServerSearchParamsForServerPage( } } -export function createServerSearchParamsForClientPage( - underlying: SearchParams, - staticGenerationStore: StaticGenerationStore -): Promise { - if (staticGenerationStore.isStaticGeneration) { - return createPassthroughPrerenderSearchParams(staticGenerationStore) - } else { - return Promise.resolve(underlying) - } -} - -function createPrerenderSearchParams( +export function createPrerenderSearchParamsForClientPage( staticGenerationStore: StaticGenerationStore ): Promise { if (staticGenerationStore.forceStatic) { @@ -106,20 +95,18 @@ function createPrerenderSearchParams( const prerenderStore = prerenderAsyncStorage.getStore() if (prerenderStore) { if (isDynamicIOPrerender(prerenderStore)) { - // We are in a dynamicIO (PPR or otherwise) prerender - return makeAbortingExoticSearchParams( - staticGenerationStore.route, - prerenderStore - ) + // We're prerendering in a mode that aborts (dynamicIO) and should stall + // the promise to ensure the RSC side is considered dynamic + return makeHangingPromise() } } - - // We are in a legacy static generation and need to interrupt the prerender - // when search params are accessed. - return makeErroringExoticSearchParams(staticGenerationStore, prerenderStore) + // We're prerendering in a mode that does not aborts. We resolve the promise without + // any tracking because we're just transporting a value from server to client where the tracking + // will be applied. + return Promise.resolve({}) } -function createPassthroughPrerenderSearchParams( +function createPrerenderSearchParams( staticGenerationStore: StaticGenerationStore ): Promise { if (staticGenerationStore.forceStatic) { @@ -131,15 +118,17 @@ function createPassthroughPrerenderSearchParams( const prerenderStore = prerenderAsyncStorage.getStore() if (prerenderStore) { if (prerenderStore.controller || prerenderStore.cacheSignal) { - // We're prerendering in a mode that aborts (dynamicIO) and should stall - // the promise to ensure the RSC side is considered dynamic - return makeHangingPromise() + // We are in a dynamicIO (PPR or otherwise) prerender + return makeAbortingExoticSearchParams( + staticGenerationStore.route, + prerenderStore + ) } } - // We're prerendering in a mode that does not aborts. We resolve the promise without - // any tracking because we're just transporting a value from server to client where the tracking - // will be applied. - return Promise.resolve({}) + + // We are in a legacy static generation and need to interrupt the prerender + // when search params are accessed. + return makeErroringExoticSearchParams(staticGenerationStore, prerenderStore) } function createRenderSearchParams( diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index cf560106cbd28..62cba0745e591 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -875,8 +875,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + +

    @@ -895,8 +895,8 @@ describe('Error overlay for hydration errors in App router', () => { - - + +
    diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/layout.tsx new file mode 100644 index 0000000000000..b69a494163dc1 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/layout.tsx @@ -0,0 +1,30 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + return ( +
    +

    + This Layout accesses params in a client component inside a high + cardinality and low cardinality dynamic params +

    +
    + page lowcard: {use(params).lowcard} +
    +
    + page highcard: {use(params).highcard} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/layout.tsx new file mode 100644 index 0000000000000..02b3fafc5bf47 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/layout.tsx @@ -0,0 +1,27 @@ +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + return ( +
    +

    + This Layout accesses params in a server component inside a high + cardinality and low cardinality dynamic params +

    +
    + page lowcard: {(await params).lowcard} +
    +
    + page highcard:{' '} + {(await params).highcard} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-access/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/layout.tsx new file mode 100644 index 0000000000000..711b2c51d28a8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/layout.tsx @@ -0,0 +1,39 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + return ( +
    +

    + This Layout does key checking of the params prop in a client component +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(use(params), 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(use(params), 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(use(params), 'foo')} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/layout.tsx new file mode 100644 index 0000000000000..06b68ab26d1f2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/layout.tsx @@ -0,0 +1,35 @@ +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + return ( +
    +

    + This Layout does key checking of the params prop in a server component +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(await params, 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(await params, 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(await params, 'foo')} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-has/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/layout.tsx new file mode 100644 index 0000000000000..726e577b6a767 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/layout.tsx @@ -0,0 +1,34 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const copied = { ...use(params) } + return ( +
    +

    + This Layout spreads params in a client component after `use`ing them +

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/layout.tsx new file mode 100644 index 0000000000000..dbc9b1cb4ece2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/layout.tsx @@ -0,0 +1,30 @@ +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const copied = { ...(await params) } + return ( +
    +

    + This Layout spreads params in a server component after awaiting them +

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/layout-spread/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/client/page.tsx new file mode 100644 index 0000000000000..18b29bc8f0c78 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/client/page.tsx @@ -0,0 +1,27 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + return ( +
    +

    + This Page access params in a page component inside a high cardinality + and low cardinality dynamic params +

    +
    + page lowcard: {use(params).lowcard} +
    +
    + page highcard: {use(params).highcard} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/server/page.tsx new file mode 100644 index 0000000000000..55118a7b629d0 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-access/server/page.tsx @@ -0,0 +1,24 @@ +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + return ( +
    +

    + This Page access params in a page component inside a high cardinality + and low cardinality dynamic params +

    +
    + page lowcard: {(await params).lowcard} +
    +
    + page highcard:{' '} + {(await params).highcard} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/client/page.tsx new file mode 100644 index 0000000000000..ba605a4503f06 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/client/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + return ( +
    +

    + This Page does key checking of the params prop in a client component +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(use(params), 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(use(params), 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(use(params), 'foo')} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/server/page.tsx new file mode 100644 index 0000000000000..270d0afbc921a --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-has/server/page.tsx @@ -0,0 +1,32 @@ +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + return ( +
    +

    + This Page does key checking of the params prop in a server component +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(await params, 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(await params, 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(await params, 'foo')} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/client/page.tsx new file mode 100644 index 0000000000000..ff61f2c9902a6 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/client/page.tsx @@ -0,0 +1,29 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const copied = { ...use(params) } + return ( +
    +

    This Page spreads params in a client component after `use`ing them

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/server/page.tsx new file mode 100644 index 0000000000000..d7be544fc7856 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/async/page-spread/server/page.tsx @@ -0,0 +1,25 @@ +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const copied = { ...(await params) } + return ( +
    +

    This Page spreads params in a server component after `use`ing them

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/layout.tsx new file mode 100644 index 0000000000000..5b0e9b76119b9 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/layout.tsx @@ -0,0 +1,28 @@ +import { Suspense } from 'react' + +import { getSentinelValue } from '../../../../getSentinelValue' + +export async function generateStaticParams() { + return [ + { + highcard: 'build', + }, + ] +} + +export default function HighardLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + loading highcard children
    } + > + {children} + + {getSentinelValue()} + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/layout.tsx new file mode 100644 index 0000000000000..89fb9d3da541f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/layout.tsx @@ -0,0 +1,31 @@ +'use client' + +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Layout accesses params directly in a client component inside a high + cardinality and low cardinality dynamic params +

    +
    + page lowcard: {syncParams.lowcard} +
    +
    + page highcard: {syncParams.highcard} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/layout.tsx new file mode 100644 index 0000000000000..2d28608d7fe48 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/layout.tsx @@ -0,0 +1,29 @@ +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Layout accesses params directly in a server component inside a high + cardinality and low cardinality dynamic params +

    +
    + page lowcard: {syncParams.lowcard} +
    +
    + page highcard: {syncParams.highcard} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-access/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/layout.tsx new file mode 100644 index 0000000000000..eebd0ddcfab66 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/layout.tsx @@ -0,0 +1,41 @@ +'use client' + +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Layout does key checking of the params prop in a client component + without `use`ing first +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(syncParams, 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(syncParams, 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(syncParams, 'foo')} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/layout.tsx new file mode 100644 index 0000000000000..937b756bcde76 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/layout.tsx @@ -0,0 +1,39 @@ +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Layout does key checking of the params prop in a server component + without awaiting first +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(syncParams, 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(syncParams, 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(syncParams, 'foo')} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-has/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/layout.tsx new file mode 100644 index 0000000000000..183e02961577b --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/layout.tsx @@ -0,0 +1,36 @@ +'use client' + +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Layout spreads params in a client component without awaiting or + `use`ing it first +

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/layout.tsx new file mode 100644 index 0000000000000..8724b1001e121 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/layout.tsx @@ -0,0 +1,35 @@ +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' +import React from 'react' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ lowcard: string; highcard: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Layout spreads params in a server component without awaiting or + `use`ing it first +

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} + {children} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/layout-spread/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/client/page.tsx new file mode 100644 index 0000000000000..490bc3e73435c --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/client/page.tsx @@ -0,0 +1,28 @@ +'use client' + +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Page accesses params directly in a client component inside a high + cardinality and low cardinality dynamic params +

    +
    + page lowcard: {syncParams.lowcard} +
    +
    + page highcard: {syncParams.highcard} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/server/page.tsx new file mode 100644 index 0000000000000..18d6ebbb61dbd --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-access/server/page.tsx @@ -0,0 +1,26 @@ +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Page accesses params directly in a server component inside a high + cardinality and low cardinality dynamic params +

    +
    + page lowcard: {syncParams.lowcard} +
    +
    + page highcard: {syncParams.highcard} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/client/page.tsx new file mode 100644 index 0000000000000..d76a426661a24 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/client/page.tsx @@ -0,0 +1,38 @@ +'use client' + +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Page does key checking of the params prop in a client component + without `use`ing first +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(syncParams, 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(syncParams, 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(syncParams, 'foo')} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/server/page.tsx new file mode 100644 index 0000000000000..fcee9c7be7383 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-has/server/page.tsx @@ -0,0 +1,36 @@ +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + return ( +
    +

    + This Page does key checking of the params prop in a server component + without awaiting first +

    +
    + page lowcard:{' '} + + {'' + Reflect.has(syncParams, 'lowcard')} + +
    +
    + page highcard:{' '} + + {'' + Reflect.has(syncParams, 'highcard')} + +
    +
    + page foo:{' '} + {'' + Reflect.has(syncParams, 'foo')} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/client/page.tsx new file mode 100644 index 0000000000000..a4cb1e5bb7fa4 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/client/page.tsx @@ -0,0 +1,33 @@ +'use client' + +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Page spreads params in a client component without awaiting or + `use`ing it first +

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/server/page.tsx new file mode 100644 index 0000000000000..505873d1a6dc4 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/[highcard]/sync/page-spread/server/page.tsx @@ -0,0 +1,31 @@ +import { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ lowcard: string; highcard: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Page spreads params in a server component without awaiting or + `use`ing it first +

    +
    + page lowcard: {copied.lowcard} +
    +
    + page highcard: {copied.highcard} +
    +
    + param key count:{' '} + {Object.keys(copied).length} +
    + {getSentinelValue()} +
    + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/layout.tsx new file mode 100644 index 0000000000000..d4d8f4a43040f --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/semantics/[lowcard]/layout.tsx @@ -0,0 +1,28 @@ +import { Suspense } from 'react' + +import { getSentinelValue } from '../../../getSentinelValue' + +export async function generateStaticParams() { + return [ + { + lowcard: 'one', + }, + ] +} + +export default function LowCardLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + loading lowcard children
    } + > + {children} + + {getSentinelValue()} + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/layout.tsx new file mode 100644 index 0000000000000..aa09117780a4d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/layout.tsx @@ -0,0 +1,54 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default function Layout({ + params, + children, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> + children: React.ReactNode +}) { + const copied = { ...use(params) } + return ( +
    +

    + This Layout accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn: {getValueAsString(use(params).dyn)} +
    • +
    • + then:{' '} + {getValueAsString(use(params).then)} +
    • +
    • + value:{' '} + {getValueAsString(use(params).value)} +
    • +
    • + status:{' '} + {getValueAsString(use(params).status)} +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} + {children} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/layout.tsx new file mode 100644 index 0000000000000..4ef52d7d2c93d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/layout.tsx @@ -0,0 +1,53 @@ +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> + children: React.ReactNode +}) { + const copied = { ...(await params) } + return ( +
    +

    + This Layout accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn:{' '} + {getValueAsString((await params).dyn)} +
    • +
    • + then:{' '} + {getValueAsString((await params).then)} +
    • +
    • + value:{' '} + {getValueAsString((await params).value)} +
    • +
    • + status:{' '} + + {getValueAsString((await params).status)} + +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} + {children} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/layout/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/client/page.tsx new file mode 100644 index 0000000000000..bf6d073584681 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/client/page.tsx @@ -0,0 +1,51 @@ +'use client' + +import { use } from 'react' + +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default function Page({ + params, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> +}) { + const copied = { ...use(params) } + return ( +
    +

    + This Page accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn: {getValueAsString(use(params).dyn)} +
    • +
    • + then:{' '} + {getValueAsString(use(params).then)} +
    • +
    • + value:{' '} + {getValueAsString(use(params).value)} +
    • +
    • + status:{' '} + {getValueAsString(use(params).status)} +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/server/page.tsx new file mode 100644 index 0000000000000..cf451725f662d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/async/page/server/page.tsx @@ -0,0 +1,50 @@ +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> +}) { + const copied = { ...(await params) } + return ( +
    +

    + This Page accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn:{' '} + {getValueAsString((await params).dyn)} +
    • +
    • + then:{' '} + {getValueAsString((await params).then)} +
    • +
    • + value:{' '} + {getValueAsString((await params).value)} +
    • +
    • + status:{' '} + + {getValueAsString((await params).status)} + +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/layout.tsx new file mode 100644 index 0000000000000..071abe498e76d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/layout.tsx @@ -0,0 +1,70 @@ +'use client' + +import type { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default function Layout({ + params, + children, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Layout accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn: {getValueAsString(syncParams.dyn)} +
    • +
    • + then:{' '} + + {getValueAsString( + // @ts-expect-error we expect then to be unavailable on the syncParams type + syncParams.then + )} + +
    • +
    • + value:{' '} + + {getValueAsString( + // @ts-expect-error we expect value to be unavailable on the syncParams type + syncParams.value + )} + +
    • +
    • + status:{' '} + + {getValueAsString( + // @ts-expect-error we expect status to be unavailable on the syncParams type + syncParams.status + )} + +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} + {children} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/client/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/layout.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/layout.tsx new file mode 100644 index 0000000000000..eb3ff00c4cab3 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/layout.tsx @@ -0,0 +1,68 @@ +import type { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default async function Page({ + params, + children, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> + children: React.ReactNode +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Layout accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn: {getValueAsString(syncParams.dyn)} +
    • +
    • + then:{' '} + + {getValueAsString( + // @ts-expect-error we expect then to be unavailable on the syncParams type + syncParams.then + )} + +
    • +
    • + value:{' '} + + {getValueAsString( + // @ts-expect-error we expect value to be unavailable on the syncParams type + syncParams.value + )} + +
    • +
    • + status:{' '} + + {getValueAsString( + // @ts-expect-error we expect status to be unavailable on the syncParams type + syncParams.status + )} + +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} + {children} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/page.tsx new file mode 100644 index 0000000000000..d0b3a9ca484f8 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/layout/server/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return 'page' +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/client/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/client/page.tsx new file mode 100644 index 0000000000000..a041d143a81b2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/client/page.tsx @@ -0,0 +1,67 @@ +'use client' + +import type { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default function Page({ + params, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Page accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn: {getValueAsString(syncParams.dyn)} +
    • +
    • + then:{' '} + + {getValueAsString( + // @ts-expect-error we expect then to be unavailable on the syncParams type + syncParams.then + )} + +
    • +
    • + value:{' '} + + {getValueAsString( + // @ts-expect-error we expect value to be unavailable on the syncParams type + syncParams.value + )} + +
    • +
    • + status:{' '} + + {getValueAsString( + // @ts-expect-error we expect status to be unavailable on the syncParams type + syncParams.status + )} + +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/server/page.tsx b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/server/page.tsx new file mode 100644 index 0000000000000..b4f46edffaffb --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/params/shadowing/[dyn]/[then]/[value]/[status]/sync/page/server/page.tsx @@ -0,0 +1,65 @@ +import type { UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../../../../../../../getSentinelValue' + +export default async function Page({ + params, +}: { + params: Promise<{ dyn: string; then: string; value: string; status: string }> +}) { + const syncParams = params as unknown as UnsafeUnwrappedParams + const copied = { ...syncParams } + return ( +
    +

    + This Page accesses params that have name collisions with Promise + properties. When synchronous access is available we assert that you can + access non colliding param names directly and all params if you await +

    +
      +
    • + dyn: {getValueAsString(syncParams.dyn)} +
    • +
    • + then:{' '} + + {getValueAsString( + // @ts-expect-error we expect then to be unavailable on the syncParams type + syncParams.then + )} + +
    • +
    • + value:{' '} + + {getValueAsString( + // @ts-expect-error we expect value to be unavailable on the syncParams type + syncParams.value + )} + +
    • +
    • + status:{' '} + + {getValueAsString( + // @ts-expect-error we expect status to be unavailable on the syncParams type + syncParams.status + )} + +
    • +
    +
    + copied:
    {JSON.stringify(copied)}
    +
    + {getSentinelValue()} +
    + ) +} + +function getValueAsString(value: any) { + if (typeof value === 'string') { + return value + } + + return String(value) +} diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts new file mode 100644 index 0000000000000..dd3a33f43ec05 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts @@ -0,0 +1,2483 @@ +import { nextTestSetup } from 'e2e-utils' + +const WITH_PPR = !!process.env.__NEXT_EXPERIMENTAL_PPR + +describe('dynamic-io', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + let cliIndex = 0 + beforeEach(() => { + cliIndex = next.cliOutput.length + }) + function getLines(containing: string): Array { + const warnings = next.cliOutput + .slice(cliIndex) + .split('\n') + .filter((l) => l.includes(containing)) + + cliIndex = next.cliOutput.length + return warnings + } + + describe('Async Params', () => { + if (WITH_PPR) { + it('should partially prerender pages that await params in a server components', async () => { + expect(getLines('In route /params')).toEqual([]) + + let $ = await next.render$( + '/params/semantics/one/build/async/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + } + + $ = await next.render$( + '/params/semantics/one/run/async/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should partially prerender pages that use params in a client components', async () => { + expect(getLines('In route /params')).toEqual([]) + + let $ = await next.render$( + '/params/semantics/one/build/async/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + } else { + it('should prerender pages that await params in a server component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should prerender pages that `use` params in a client component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } else { + // TODO at the moment pages receive searchParams which are not know at build time + // and always dynamic. We have to pessimistically assume they are accessed and thus + // we cannot actually produce a static shell without PPR. + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should render pages that await params in a server component when not prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/run/async/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should render pages that `use` params in a client component when not prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/run/async/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + } + + it('should fully prerender pages that check individual param keys after awaiting params in a server component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-has/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-has/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/layout-has/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is still partially prerendered + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // without PPR the first visit is dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + + $ = await next.render$('/params/semantics/one/run/async/page-has/server') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is still partially prerendered + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // without PPR the first visit is dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + }) + + it('should fully prerender pages that check individual param keys after `use`ing params in a client component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-has/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-has/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // When dynamicIO is on and PPR is off the search params passed to a client page + // are enough to mark the whole route as dynamic. This is becuase we can't know if + // you are going to use those searchParams in an update on the client so we can't infer + // anything about your lack of use during SSR. In the future we will update searchParams + // written to the client to actually derive those params from location and thus not + // require dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + + $ = await next.render$( + '/params/semantics/one/run/async/layout-has/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is still partially prerendered + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // without PPR the first visit is dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + + $ = await next.render$('/params/semantics/one/run/async/page-has/client') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is still partially prerendered + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // When dynamicIO is on and PPR is off the search params passed to a client page + // are enough to mark the whole route as dynamic. This is becuase we can't know if + // you are going to use those searchParams in an update on the client so we can't infer + // anything about your lack of use during SSR. In the future we will update searchParams + // written to the client to actually derive those params from location and thus not + // require dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + }) + + if (WITH_PPR) { + it('should partially prerender pages that spread awaited params in a server component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should partially prerender pages that spread `use`ed params in a client component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + } else { + it('should prerender pages that spread awaited params in a server component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should prerender pages that spread `use`ed params in a client component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/async/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/async/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } else { + // TODO at the moment pages receive searchParams which are not know at build time + // and always dynamic. We have to pessimistically assume they are accessed and thus + // we cannot actually produce a static shell without PPR. + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should render pages that spread awaited params in a server component when not prebuilt', async () => { + let $ = await next.render$( + '/params/semantics/one/run/async/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } else { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } else { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } + }) + + it('should render pages that spread `use`ed params in a client component when not prebuilt', async () => { + let $ = await next.render$( + '/params/semantics/one/run/async/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } + + $ = await next.render$( + '/params/semantics/one/run/async/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + } + }) + } + }) + + describe('Synchronous Params access', () => { + if (WITH_PPR) { + it('should partially prerender pages that access params synchronously in a server components', async () => { + expect(getLines('In route /params')).toEqual([]) + + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + } + + $ = await next.render$( + '/params/semantics/one/run/sync/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should partially prerender pages that access params synchronously in a client components', async () => { + expect(getLines('In route /params')).toEqual([]) + + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + } else { + it('should prerender pages that access params synchronously in a server component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should prerender pages that access params synchronously in a client component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + // TODO at the moment pages receive searchParams which are not know at build time + // and always dynamic. We have to pessimistically assume they are accessed and thus + // we cannot actually produce a static shell without PPR. + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('build') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should render pages that access params synchronouslyin a server component when not prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/run/sync/layout-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-access/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should render pages that access params synchronously in a client component when not prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/run/sync/layout-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-access/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-lowcard').text()).toBe('one') + expect($('#param-highcard').text()).toBe('run') + expect(getLines('In route /params')).toEqual([]) + } + }) + } + + it('should fully prerender pages that check individual param keys directly on the params prop in a server component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-has/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$('/params/semantics/one/build/sync/page-has/server') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$('/params/semantics/one/run/sync/layout-has/server') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is fulluy prerendered + // because has-checking doesn't postpone even with ppr fallbacks + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // without PPR the first visit is dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + + $ = await next.render$('/params/semantics/one/run/sync/page-has/server') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is fulluy prerendered + // because has-checking doesn't postpone even with ppr fallbacks + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // without PPR the first visit is dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + }) + + it('should fully prerender pages that check individual param keys directly on the params prop in a client component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-has/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$('/params/semantics/one/build/sync/page-has/client') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // When dynamicIO is on and PPR is off the search params passed to a client page + // are enough to mark the whole route as dynamic. This is becuase we can't know if + // you are going to use those searchParams in an update on the client so we can't infer + // anything about your lack of use during SSR. In the future we will update searchParams + // written to the client to actually derive those params from location and thus not + // require dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + + $ = await next.render$('/params/semantics/one/run/sync/layout-has/client') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is fulluy prerendered + // because has-checking doesn't postpone even with ppr fallbacks + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // without PPR the first visit is dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + + $ = await next.render$('/params/semantics/one/run/sync/page-has/client') + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + if (WITH_PPR) { + // With PPR fallbacks the first visit is still fully prerendered + // has-checking keys isn't dynamic and since we aren't awaiting the + // whole params object we end up with a complete prerender even + // for the fallback page. + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } else { + // When dynamicIO is on and PPR is off the search params passed to a client page + // are enough to mark the whole route as dynamic. This is becuase we can't know if + // you are going to use those searchParams in an update on the client so we can't infer + // anything about your lack of use during SSR. In the future we will update searchParams + // written to the client to actually derive those params from location and thus not + // require dynamic + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-has-lowcard').text()).toBe('true') + expect($('#param-has-highcard').text()).toBe('true') + expect($('#param-has-foo').text()).toBe('false') + expect(getLines('In route /params')).toEqual([]) + } + } + }) + + if (WITH_PPR) { + it('should partially prerender pages that spread params without awaiting first in a server component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should partially prerender pages that spread params without `use`ing them first in a client component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#highcard-fallback').text()).toBe( + 'loading highcard children' + ) + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + } else { + it('should prerender pages that spread params without awaiting first in a server component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should prerender pages that spread params without `use`ing first in a client component when prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/build/sync/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#lowcard').text()).toBe('at buildtime') + expect($('#highcard').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/build/sync/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + // TODO at the moment pages receive searchParams which are not know at build time + // and always dynamic. We have to pessimistically assume they are accessed and thus + // we cannot actually produce a static shell without PPR. + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('build') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should prerender pages that spread params without awaiting first in a server component when not prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/run/sync/layout-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-spread/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should prerender pages that spread params without `use`ing first in a client component when not prebuilt', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/semantics/one/run/sync/layout-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/semantics/one/run/sync/page-spread/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'enumerated with `{...params}`, `Object.keys(params)`, or similar.' + ), + expect.stringContaining('accessed directly with `params.lowcard`'), + expect.stringContaining('accessed directly with `params.highcard`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#lowcard').text()).toBe('at runtime') + expect($('#highcard').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-copied-lowcard').text()).toBe('one') + expect($('#param-copied-highcard').text()).toBe('run') + expect($('#param-key-count').text()).toBe('2') + expect(getLines('In route /params')).toEqual([]) + } + }) + } + }) + + describe('Param Shadowing', () => { + it('should correctly allow param names like then, value, and status when awaiting params in a server component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/async/layout/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/async/page/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should correctly allow param names like then, value, and status when `use`ing params in a client component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/async/layout/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/async/page/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toBe('bar') + expect($('#param-value').text()).toBe('baz') + expect($('#param-status').text()).toBe('qux') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should not allow param names like then, value, and status when accessing params directly in a server component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/sync/layout/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'The following properties were not copied: `then`, `value`, , and `status`.' + ), + expect.stringContaining('accessed directly with `params.dyn`'), + expect.stringContaining('accessed directly with `params.dyn`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/sync/page/server' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'The following properties were not copied: `then`, `value`, , and `status`.' + ), + expect.stringContaining('accessed directly with `params.dyn`'), + expect.stringContaining('accessed directly with `params.dyn`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([]) + } + }) + + it('should not allow param names like then, value, and status when accessing params directly in a client component', async () => { + expect(getLines('In route /params')).toEqual([]) + let $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/sync/layout/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'The following properties were not copied: `then`, `value`, , and `status`.' + ), + expect.stringContaining('accessed directly with `params.dyn`'), + expect.stringContaining('accessed directly with `params.dyn`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([]) + } + + $ = await next.render$( + '/params/shadowing/foo/bar/baz/qux/sync/page/client' + ) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([ + expect.stringContaining( + 'The following properties were not copied: `then`, `value`, , and `status`.' + ), + expect.stringContaining('accessed directly with `params.dyn`'), + expect.stringContaining('accessed directly with `params.dyn`'), + ]) + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#param-dyn').text()).toBe('foo') + expect($('#param-then').text()).toEqual( + expect.stringContaining('native code') + ) + expect($('#param-value').text()).toBe('undefined') + expect($('#param-status').text()).toBe('undefined') + expect(getLines('In route /params')).toEqual([]) + } + }) + }) +}) diff --git a/test/e2e/app-dir/dynamic-io/next.config.js b/test/e2e/app-dir/dynamic-io/next.config.js index 3b64afa4c4bb3..650be7f1ecb99 100644 --- a/test/e2e/app-dir/dynamic-io/next.config.js +++ b/test/e2e/app-dir/dynamic-io/next.config.js @@ -3,7 +3,8 @@ */ const nextConfig = { experimental: { - ppr: !!process.env.__NEXT_EXPERIMENTAL_PPR, + ppr: process.env.__NEXT_EXPERIMENTAL_PPR === 'true', + pprFallbacks: process.env.__NEXT_EXPERIMENTAL_PPR === 'true', dynamicIO: true, }, } From 979ca9e2558009df9e6209ea3eac0b6ebd62ab93 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 9 Sep 2024 10:20:23 -0700 Subject: [PATCH 08/14] Update tests to use new promise form for `params` --- .../e2e/app-dir/app-basepath/app/dynamic/[id]/page.js | 3 ++- .../app-dir/app-static/app/articles/[slug]/page.tsx | 8 ++++---- .../app-static/app/dynamic-param-edge/[slug]/page.tsx | 4 ++-- .../autoscroll-with-css-modules/app/[num]/page.tsx | 7 ++++++- .../app-dir/disable-logging-route/app/[id]/page.tsx | 4 ++-- test/e2e/app-dir/dynamic-data/dynamic-data.test.ts | 2 +- .../app/[locale]/@modal/(.)photos/[id]/view/page.tsx | 8 ++++++-- .../app/[locale]/photos/[id]/view/page.tsx | 8 ++++---- .../edge-route-catchall/app/edge/[[...slug]]/route.ts | 6 +++--- .../app/dynamic/[slug]/page.tsx | 11 ++++++++--- test/e2e/app-dir/fallback-prefetch/app/[id]/page.tsx | 5 +++-- .../catch-all/app/[lang]/[...slug]/page.js | 2 +- test/e2e/app-dir/i18n-hybrid/app/blog/[slug]/page.js | 7 +++++-- .../app/[lang]/@modal/(.)photos/[id]/page.tsx | 7 ++++--- .../app/[lang]/photos/[id]/page.tsx | 7 ++++--- .../app/bar/@modal/(...)post/[id]/page.tsx | 9 +++++---- .../app/foo/@modal/(...)post/[id]/page.tsx | 9 +++++---- .../app/post/[id]/page.tsx | 9 +++++---- .../app/@modal/(.)items/[...ids]/page.tsx | 9 +++++++-- .../app/items/[...ids]/page.tsx | 9 +++++++-- .../app/base/[param1]/[param2]/layout.tsx | 6 +++--- .../layout-params/app/base/[param1]/layout.tsx | 6 +++--- test/e2e/app-dir/layout-params/app/base/layout.tsx | 6 +++--- .../layout-params/app/catchall/[...params]/layout.tsx | 6 +++--- test/e2e/app-dir/layout-params/app/layout.tsx | 6 +++--- .../app/optional-catchall/[[...params]]/layout.tsx | 6 +++--- .../navigation/app/external-push/[storageKey]/page.js | 4 +++- .../[categorySlug]/[subCategorySlug]/page.js | 2 +- .../app/nested-navigation/[categorySlug]/layout.js | 5 ++--- .../app/nested-navigation/[categorySlug]/page.js | 5 ++--- .../app/redirect/external-log/[storageKey]/page.js | 5 ++++- .../navigation/app/router/dynamic-gsp/[slug]/page.js | 4 ++-- .../app/[locale]/layout.tsx | 6 +++--- .../app/[locale]/show/page.tsx | 6 ++---- .../[username]/@feed/page.js | 4 ++-- .../[username]/@modal/(..)photo/[id]/page.js | 5 +++-- .../intercepting-parallel-modal/[username]/layout.js | 4 ++-- .../intercepting-parallel-modal/photo/[id]/page.js | 5 +++-- .../intercepting-routes/feed/(.)photos/[id]/page.js | 7 +++---- .../app/intercepting-routes/feed/photos/[id]/page.js | 5 +++-- .../app/intercepting-siblings/@modal/(.)[id]/page.js | 4 ++-- .../app/intercepting-siblings/[id]/page.js | 3 ++- .../@intercept/(.)some-page/page.tsx | 4 ++-- .../[this-is-my-route]/some-page/page.tsx | 4 ++-- .../app/@slot/[...catchAll]/page.tsx | 3 ++- .../app/[artist]/[album]/[track]/page.tsx | 7 ++++--- .../app/[artist]/[album]/page.tsx | 9 ++++----- .../parallel-routes-breadcrumbs/app/[artist]/page.tsx | 7 ++++--- .../app/comments/[productId]/page.tsx | 8 ++++---- .../app/[locale]/@modal/(.)interception/[id]/page.tsx | 7 ++++--- .../app/[locale]/@modal/no-interception/[id]/page.tsx | 7 ++++--- .../app/[locale]/interception/[id]/page.tsx | 7 ++++++- .../app/[locale]/no-interception/[id]/page.tsx | 7 ++++++- .../app/[locale]/page.tsx | 7 ++++--- .../catchall/@interception2/(.)[...dynamic]/page.tsx | 9 +++++++-- .../app/catchall/[...dynamic]/page.tsx | 9 +++++++-- .../[dynamic]/@modal/(.)login/page.tsx | 2 +- .../app/dynamic/@interception2/(.)[dynamic]/page.tsx | 9 +++++++-- .../app/dynamic/[dynamic]/page.tsx | 9 +++++++-- .../app/[dataKey]/page.tsx | 5 +++-- .../rewrites-redirects/app/[...params]/page.tsx | 11 ++++++++--- .../[pageWidth]/[pageHeight]/[param]/layout.tsx | 10 ++++++---- .../app-dir/search-params-react-key/app/layout.tsx | 7 +------ 63 files changed, 234 insertions(+), 158 deletions(-) diff --git a/test/e2e/app-dir/app-basepath/app/dynamic/[id]/page.js b/test/e2e/app-dir/app-basepath/app/dynamic/[id]/page.js index 8d9491c2518e7..f1e828c33a12e 100644 --- a/test/e2e/app-dir/app-basepath/app/dynamic/[id]/page.js +++ b/test/e2e/app-dir/app-basepath/app/dynamic/[id]/page.js @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation' -export default async function Page({ params: { id } }) { +export default async function Page({ params }) { + const id = (await params).id if (id === 'source') redirect('/dynamic/dest') return ( diff --git a/test/e2e/app-dir/app-static/app/articles/[slug]/page.tsx b/test/e2e/app-dir/app-static/app/articles/[slug]/page.tsx index 934953119260d..4b482790e6f12 100644 --- a/test/e2e/app-dir/app-static/app/articles/[slug]/page.tsx +++ b/test/e2e/app-dir/app-static/app/articles/[slug]/page.tsx @@ -1,13 +1,13 @@ import { notFound } from 'next/navigation' export interface Props { - params: { + params: Promise<{ slug: string - } + }> } -const Article = ({ params }: Props) => { - const { slug } = params +const Article = async ({ params }: Props) => { + const { slug } = await params if (slug !== 'works') { return notFound() diff --git a/test/e2e/app-dir/app-static/app/dynamic-param-edge/[slug]/page.tsx b/test/e2e/app-dir/app-static/app/dynamic-param-edge/[slug]/page.tsx index 74f404d91c3ed..4da62f81383f1 100644 --- a/test/e2e/app-dir/app-static/app/dynamic-param-edge/[slug]/page.tsx +++ b/test/e2e/app-dir/app-static/app/dynamic-param-edge/[slug]/page.tsx @@ -1,5 +1,5 @@ -export default function Hello({ params }) { - return

    {params.slug}

    +export default async function Hello({ params }) { + return

    {(await params).slug}

    } export function generateStaticParams() { diff --git a/test/e2e/app-dir/autoscroll-with-css-modules/app/[num]/page.tsx b/test/e2e/app-dir/autoscroll-with-css-modules/app/[num]/page.tsx index 43377f8ff473d..8fe0bc8f1196e 100644 --- a/test/e2e/app-dir/autoscroll-with-css-modules/app/[num]/page.tsx +++ b/test/e2e/app-dir/autoscroll-with-css-modules/app/[num]/page.tsx @@ -1,7 +1,12 @@ import Link from 'next/link' import styles from './styles.module.css' -export default function Page({ params: { num } }) { +export default async function Page({ + params, +}: { + params: Promise<{ num: string }> +}) { + const { num } = await params return (
    {new Array(100).fill(0).map((_, i) => ( diff --git a/test/e2e/app-dir/disable-logging-route/app/[id]/page.tsx b/test/e2e/app-dir/disable-logging-route/app/[id]/page.tsx index 568e1d2981e5f..c30cc7d119144 100644 --- a/test/e2e/app-dir/disable-logging-route/app/[id]/page.tsx +++ b/test/e2e/app-dir/disable-logging-route/app/[id]/page.tsx @@ -1,3 +1,3 @@ -export default function Page({ params: { id } }) { - return

    {id}

    +export default async function Page({ params }: { params: Promise<{ id }> }) { + return

    {(await params).id}

    } diff --git a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts index 783d65fb23f8c..9b627b2480234 100644 --- a/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts +++ b/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts @@ -234,7 +234,7 @@ describe('dynamic-data with dynamic = "error"', () => { 'Error: Route /headers with `dynamic = "error"` couldn\'t be rendered statically because it used `headers`' ) expect(next.cliOutput).toMatch( - "Error: Route /search couldn't be rendered statically because it used `await searchParams`, `use(searchParams)`, or similar" + 'Error: Route /search with `dynamic = "error"` couldn\'t be rendered statically because it used `await searchParams`, `searchParams.then`, or similar' ) expect(next.cliOutput).toMatch( 'Error: Route /routes/form-data/error with `dynamic = "error"` couldn\'t be rendered statically because it used `request.formData`' diff --git a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/@modal/(.)photos/[id]/view/page.tsx b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/@modal/(.)photos/[id]/view/page.tsx index dafc823d510b6..10b805683bd0c 100644 --- a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/@modal/(.)photos/[id]/view/page.tsx +++ b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/@modal/(.)photos/[id]/view/page.tsx @@ -1,10 +1,14 @@ import Modal from '../../../../modal' -export default function Page({ params }: { params: { id: string } }) { +export default async function Page({ + params, +}: { + params: Promise<{ id: string }> +}) { return (

    Intercepted Page

    - +
    ) } diff --git a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/photos/[id]/view/page.tsx b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/photos/[id]/view/page.tsx index 30b7a8fdfb381..2be02936fb486 100644 --- a/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/photos/[id]/view/page.tsx +++ b/test/e2e/app-dir/dynamic-interception-route-revalidate/app/[locale]/photos/[id]/view/page.tsx @@ -1,15 +1,15 @@ import Modal from '../../../modal' -export default function PhotoPage({ - params: { id }, +export default async function PhotoPage({ + params, }: { - params: { id: string } + params: Promise<{ id: string }> }) { return (

    Full Page

    - +
    ) diff --git a/test/e2e/app-dir/edge-route-catchall/app/edge/[[...slug]]/route.ts b/test/e2e/app-dir/edge-route-catchall/app/edge/[[...slug]]/route.ts index 74f20092b782b..89c9306d2c5df 100644 --- a/test/e2e/app-dir/edge-route-catchall/app/edge/[[...slug]]/route.ts +++ b/test/e2e/app-dir/edge-route-catchall/app/edge/[[...slug]]/route.ts @@ -3,11 +3,11 @@ import { NextRequest, NextResponse } from 'next/server' export const runtime = 'edge' type Context = { - params: { + params: Promise<{ slug: string[] - } + }> } export const GET = async (req: NextRequest, { params }: Context) => { - return NextResponse.json(params) + return NextResponse.json(await params) } diff --git a/test/e2e/app-dir/error-boundary-navigation/app/dynamic/[slug]/page.tsx b/test/e2e/app-dir/error-boundary-navigation/app/dynamic/[slug]/page.tsx index 5b4acc6e75bfb..1a13790d1b179 100644 --- a/test/e2e/app-dir/error-boundary-navigation/app/dynamic/[slug]/page.tsx +++ b/test/e2e/app-dir/error-boundary-navigation/app/dynamic/[slug]/page.tsx @@ -1,9 +1,14 @@ import { notFound } from 'next/navigation' -export default function DynamicPage({ params }) { - if (params.slug === '404') { +export default async function DynamicPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + if (slug === '404') { notFound() } - return

    Dynamic page: {params.slug}

    + return

    Dynamic page: {slug}

    } diff --git a/test/e2e/app-dir/fallback-prefetch/app/[id]/page.tsx b/test/e2e/app-dir/fallback-prefetch/app/[id]/page.tsx index 04faa8ab2b8d9..6c9d359f4455b 100644 --- a/test/e2e/app-dir/fallback-prefetch/app/[id]/page.tsx +++ b/test/e2e/app-dir/fallback-prefetch/app/[id]/page.tsx @@ -7,12 +7,13 @@ export async function generateStaticParams() { } export default async function IdPage({ - params: { id }, + params, }: { - params: { id: string } + params: Promise<{ id: string }> }) { await new Promise((resolve) => setTimeout(resolve, Math.random() * 5000)) + const { id } = await params return (

    {id} page

    diff --git a/test/e2e/app-dir/global-error/catch-all/app/[lang]/[...slug]/page.js b/test/e2e/app-dir/global-error/catch-all/app/[lang]/[...slug]/page.js index 8ed52fb421dab..1fe0f936ff38e 100644 --- a/test/e2e/app-dir/global-error/catch-all/app/[lang]/[...slug]/page.js +++ b/test/e2e/app-dir/global-error/catch-all/app/[lang]/[...slug]/page.js @@ -1,5 +1,5 @@ export default async function Page({ params }) { - if (params.slug[0] === 'error') { + if ((await params).slug[0] === 'error') { throw new Error('trigger error') } return 'catch-all page' diff --git a/test/e2e/app-dir/i18n-hybrid/app/blog/[slug]/page.js b/test/e2e/app-dir/i18n-hybrid/app/blog/[slug]/page.js index 2ab7baf9fdd88..3783e9d7a75eb 100644 --- a/test/e2e/app-dir/i18n-hybrid/app/blog/[slug]/page.js +++ b/test/e2e/app-dir/i18n-hybrid/app/blog/[slug]/page.js @@ -1,7 +1,10 @@ import { Debug } from '../../../components/debug' -export default function Page({ params }) { +export default async function Page({ params }) { return ( - + ) } diff --git a/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/@modal/(.)photos/[id]/page.tsx b/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/@modal/(.)photos/[id]/page.tsx index 97e9206db9830..59bf3980b80cd 100644 --- a/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/@modal/(.)photos/[id]/page.tsx +++ b/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/@modal/(.)photos/[id]/page.tsx @@ -1,7 +1,8 @@ -export default function PhotoModal({ - params: { id: photoId }, +export default async function PhotoModal({ + params, }: { - params: { id: string } + params: Promise<{ id: string }> }) { + const { id: photoId } = await params return
    Intercepted Photo ID: {photoId}
    } diff --git a/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/photos/[id]/page.tsx b/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/photos/[id]/page.tsx index d7f45379cfc72..3021503b2b37b 100644 --- a/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/photos/[id]/page.tsx +++ b/test/e2e/app-dir/interception-middleware-rewrite/app/[lang]/photos/[id]/page.tsx @@ -1,7 +1,8 @@ -export default function PhotoPage({ - params: { id }, +export default async function PhotoPage({ + params, }: { - params: { id: string } + params: Promise<{ id: string }> }) { + const { id } = await params return
    Page Photo ID: {id}
    } diff --git a/test/e2e/app-dir/interception-route-prefetch-cache/app/bar/@modal/(...)post/[id]/page.tsx b/test/e2e/app-dir/interception-route-prefetch-cache/app/bar/@modal/(...)post/[id]/page.tsx index 6b34948578785..60c4098e081c7 100644 --- a/test/e2e/app-dir/interception-route-prefetch-cache/app/bar/@modal/(...)post/[id]/page.tsx +++ b/test/e2e/app-dir/interception-route-prefetch-cache/app/bar/@modal/(...)post/[id]/page.tsx @@ -1,11 +1,12 @@ import { Modal } from '../../../../Modal' -export default function BarPagePostInterceptSlot({ - params: { id }, +export default async function BarPagePostInterceptSlot({ + params, }: { - params: { + params: Promise<{ id: string - } + }> }) { + const { id } = await params return } diff --git a/test/e2e/app-dir/interception-route-prefetch-cache/app/foo/@modal/(...)post/[id]/page.tsx b/test/e2e/app-dir/interception-route-prefetch-cache/app/foo/@modal/(...)post/[id]/page.tsx index d484b27cf4b75..6db3d23af3d9e 100644 --- a/test/e2e/app-dir/interception-route-prefetch-cache/app/foo/@modal/(...)post/[id]/page.tsx +++ b/test/e2e/app-dir/interception-route-prefetch-cache/app/foo/@modal/(...)post/[id]/page.tsx @@ -1,11 +1,12 @@ import { Modal } from '../../../../Modal' -export default function FooPagePostInterceptSlot({ - params: { id }, +export default async function FooPagePostInterceptSlot({ + params, }: { - params: { + params: Promise<{ id: string - } + }> }) { + const { id } = await params return } diff --git a/test/e2e/app-dir/interception-route-prefetch-cache/app/post/[id]/page.tsx b/test/e2e/app-dir/interception-route-prefetch-cache/app/post/[id]/page.tsx index ed53cc26baeee..666f4c214a560 100644 --- a/test/e2e/app-dir/interception-route-prefetch-cache/app/post/[id]/page.tsx +++ b/test/e2e/app-dir/interception-route-prefetch-cache/app/post/[id]/page.tsx @@ -1,10 +1,11 @@ -export default function PostPage({ - params: { id }, +export default async function PostPage({ + params, }: { - params: { + params: Promise<{ id: string - } + }> }) { + const { id } = await params return (

    Post {id}

    diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/(.)items/[...ids]/page.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/(.)items/[...ids]/page.tsx index 134e51794e1b8..1acb5b8d6bb9c 100644 --- a/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/(.)items/[...ids]/page.tsx +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/(.)items/[...ids]/page.tsx @@ -1,3 +1,8 @@ -export default function Page({ params }: { params: { ids: string[] } }) { - return
    Intercepted Modal Page. Id: {params.ids}
    +export default async function Page({ + params, +}: { + params: Promise<{ ids: string[] }> +}) { + const { ids } = await params + return
    Intercepted Modal Page. Id: {ids}
    } diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/items/[...ids]/page.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/items/[...ids]/page.tsx index 85a01be7881f4..901f7c9496e28 100644 --- a/test/e2e/app-dir/interception-routes-root-catchall/app/items/[...ids]/page.tsx +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/items/[...ids]/page.tsx @@ -1,3 +1,8 @@ -export default function Page({ params }: { params: { ids: string[] } }) { - return
    Regular Item Page. Id: {params.ids}
    +export default async function Page({ + params, +}: { + params: Promise<{ ids: string[] }> +}) { + const { ids } = await params + return
    Regular Item Page. Id: {ids}
    } diff --git a/test/e2e/app-dir/layout-params/app/base/[param1]/[param2]/layout.tsx b/test/e2e/app-dir/layout-params/app/base/[param1]/[param2]/layout.tsx index 921ceb5efa172..a86d479a88905 100644 --- a/test/e2e/app-dir/layout-params/app/base/[param1]/[param2]/layout.tsx +++ b/test/e2e/app-dir/layout-params/app/base/[param1]/[param2]/layout.tsx @@ -1,16 +1,16 @@ import React from 'react' import ShowParams from '../../../show-params' -export default function Layout({ +export default async function Layout({ children, params, }: { children: React.ReactNode - params: {} + params: Promise<{}> }) { return (
    - + {children}
    ) diff --git a/test/e2e/app-dir/layout-params/app/base/[param1]/layout.tsx b/test/e2e/app-dir/layout-params/app/base/[param1]/layout.tsx index 0a9cdf7458ff1..6bbacf3a656e3 100644 --- a/test/e2e/app-dir/layout-params/app/base/[param1]/layout.tsx +++ b/test/e2e/app-dir/layout-params/app/base/[param1]/layout.tsx @@ -1,16 +1,16 @@ import React from 'react' import ShowParams from '../../show-params' -export default function Layout({ +export default async function Layout({ children, params, }: { children: React.ReactNode - params: {} + params: Promise<{}> }) { return (
    - + {children}
    ) diff --git a/test/e2e/app-dir/layout-params/app/base/layout.tsx b/test/e2e/app-dir/layout-params/app/base/layout.tsx index 4cd549d0f5c3b..98b9f3ca2385f 100644 --- a/test/e2e/app-dir/layout-params/app/base/layout.tsx +++ b/test/e2e/app-dir/layout-params/app/base/layout.tsx @@ -1,16 +1,16 @@ import React from 'react' import ShowParams from '../show-params' -export default function Layout({ +export default async function Layout({ children, params, }: { children: React.ReactNode - params: {} + params: Promise<{}> }) { return (
    - + {children}
    ) diff --git a/test/e2e/app-dir/layout-params/app/catchall/[...params]/layout.tsx b/test/e2e/app-dir/layout-params/app/catchall/[...params]/layout.tsx index 0a9cdf7458ff1..6bbacf3a656e3 100644 --- a/test/e2e/app-dir/layout-params/app/catchall/[...params]/layout.tsx +++ b/test/e2e/app-dir/layout-params/app/catchall/[...params]/layout.tsx @@ -1,16 +1,16 @@ import React from 'react' import ShowParams from '../../show-params' -export default function Layout({ +export default async function Layout({ children, params, }: { children: React.ReactNode - params: {} + params: Promise<{}> }) { return (
    - + {children}
    ) diff --git a/test/e2e/app-dir/layout-params/app/layout.tsx b/test/e2e/app-dir/layout-params/app/layout.tsx index 13d2936414696..8fdcdc6cb8b61 100644 --- a/test/e2e/app-dir/layout-params/app/layout.tsx +++ b/test/e2e/app-dir/layout-params/app/layout.tsx @@ -1,19 +1,19 @@ import React from 'react' import ShowParams from './show-params' -export default function Layout({ +export default async function Layout({ children, params, }: { children: React.ReactNode - params: {} + params: Promise<{}> }) { return (
    - + {children}
    diff --git a/test/e2e/app-dir/layout-params/app/optional-catchall/[[...params]]/layout.tsx b/test/e2e/app-dir/layout-params/app/optional-catchall/[[...params]]/layout.tsx index 0a9cdf7458ff1..6bbacf3a656e3 100644 --- a/test/e2e/app-dir/layout-params/app/optional-catchall/[[...params]]/layout.tsx +++ b/test/e2e/app-dir/layout-params/app/optional-catchall/[[...params]]/layout.tsx @@ -1,16 +1,16 @@ import React from 'react' import ShowParams from '../../show-params' -export default function Layout({ +export default async function Layout({ children, params, }: { children: React.ReactNode - params: {} + params: Promise<{}> }) { return (
    - + {children}
    ) diff --git a/test/e2e/app-dir/navigation/app/external-push/[storageKey]/page.js b/test/e2e/app-dir/navigation/app/external-push/[storageKey]/page.js index 46db7c95403f0..87ac4c90f14be 100644 --- a/test/e2e/app-dir/navigation/app/external-push/[storageKey]/page.js +++ b/test/e2e/app-dir/navigation/app/external-push/[storageKey]/page.js @@ -1,17 +1,19 @@ /*global navigation*/ 'use client' +import { use } from 'react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useEffect, useTransition } from 'react' let listening = false let startedNavigating = false -export default function Page({ params: { storageKey } }) { +export default function Page({ params }) { if (typeof window === 'undefined') { throw new Error('Client render only') } + const { storageKey } = use(params) let router = useRouter() let path = usePathname() let searchParams = useSearchParams().toString() diff --git a/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/[subCategorySlug]/page.js b/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/[subCategorySlug]/page.js index 87b64214106c9..5128961af2935 100644 --- a/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/[subCategorySlug]/page.js +++ b/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/[subCategorySlug]/page.js @@ -3,7 +3,7 @@ import { fetchSubCategory } from '../../getCategories' export default function Page({ params }) { const category = use( - fetchSubCategory(params.categorySlug, params.subCategorySlug) + fetchSubCategory(use(params).categorySlug, use(params).subCategorySlug) ) if (!category) return null diff --git a/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/layout.js b/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/layout.js index 7e4117a4c66a6..01bfe77bc00f9 100644 --- a/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/layout.js +++ b/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/layout.js @@ -1,9 +1,8 @@ -import { use } from 'react' import { fetchCategoryBySlug } from '../getCategories' import SubCategoryNav from './SubCategoryNav' -export default function Layout({ children, params }) { - const category = use(fetchCategoryBySlug(params.categorySlug)) +export default async function Layout({ children, params }) { + const category = await fetchCategoryBySlug((await params).categorySlug) if (!category) return null return ( <> diff --git a/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/page.js b/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/page.js index 2b8e7512dceae..17d0ad0270bf7 100644 --- a/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/page.js +++ b/test/e2e/app-dir/navigation/app/nested-navigation/[categorySlug]/page.js @@ -1,8 +1,7 @@ -import { use } from 'react' import { fetchCategoryBySlug } from '../getCategories' -export default function Page({ params }) { - const category = use(fetchCategoryBySlug(params.categorySlug)) +export default async function Page({ params }) { + const category = await fetchCategoryBySlug((await params).categorySlug) if (!category) return null return

    All {category.name}

    diff --git a/test/e2e/app-dir/navigation/app/redirect/external-log/[storageKey]/page.js b/test/e2e/app-dir/navigation/app/redirect/external-log/[storageKey]/page.js index eb0c659890add..5bef5a756e42a 100644 --- a/test/e2e/app-dir/navigation/app/redirect/external-log/[storageKey]/page.js +++ b/test/e2e/app-dir/navigation/app/redirect/external-log/[storageKey]/page.js @@ -1,15 +1,18 @@ /*global navigation*/ 'use client' +import { use } from 'react' + import { redirect, useSearchParams } from 'next/navigation' let listening = false -export default function Page({ params: { storageKey } }) { +export default function Page({ params }) { if (typeof window === 'undefined') { throw new Error('Client render only') } + const { storageKey } = use(params) let searchParams = useSearchParams() if (searchParams.get('read')) { diff --git a/test/e2e/app-dir/navigation/app/router/dynamic-gsp/[slug]/page.js b/test/e2e/app-dir/navigation/app/router/dynamic-gsp/[slug]/page.js index d46142399e584..29e4affe9a762 100644 --- a/test/e2e/app-dir/navigation/app/router/dynamic-gsp/[slug]/page.js +++ b/test/e2e/app-dir/navigation/app/router/dynamic-gsp/[slug]/page.js @@ -1,5 +1,5 @@ -export default function Page({ params }) { - return
    {'slug:' + params.slug}
    +export default async function Page({ params }) { + return
    {'slug:' + (await params).slug}
    } export function generateStaticParams() { diff --git a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx index 4d5d73fa0d9c9..79700b1a883c9 100644 --- a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx +++ b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/layout.tsx @@ -1,13 +1,13 @@ -export default function Layout(props: { +export default async function Layout(props: { children: React.ReactNode - params: { locale: string } + params: Promise<{ locale: string }> modal: React.ReactNode }) { return (
    {props.children}
    -
    Locale: {props.params.locale}
    +
    Locale: {(await props.params).locale}
    {props.modal} diff --git a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx index 4f055fe36e1ac..615aba8164593 100644 --- a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx +++ b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx @@ -1,10 +1,8 @@ 'use client' import { notFound } from 'next/navigation' -export default function Page({ params }) { - console.log(params) - - if (params.locale !== 'en') { +export default async function Page({ params }) { + if ((await params).locale !== 'en') { notFound() } diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/page.js index 3a4880674d188..ac29bd63ed805 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/page.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@feed/page.js @@ -1,9 +1,9 @@ import Link from 'next/link' -export default function Page({ params }) { +export default async function Page({ params }) { return ( <> -

    Feed for {params.username}

    +

    Feed for {(await params).username}

      {Array.from({ length: 10 }).map((_, i) => (
    • diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@modal/(..)photo/[id]/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@modal/(..)photo/[id]/page.js index 215d018dfec2b..6ebaae2d6ff33 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@modal/(..)photo/[id]/page.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/@modal/(..)photo/[id]/page.js @@ -1,3 +1,4 @@ -export default function Page({ params }) { - return

      Photo MODAL {params.id}

      +export default async function Page({ params }) { + const { id } = await params + return

      Photo MODAL {id}

      } diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/layout.js b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/layout.js index 3f48dcec11529..487e48471b39e 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/layout.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/[username]/layout.js @@ -1,7 +1,7 @@ -export default function FeedLayout({ params, feed, modal }) { +export default async function FeedLayout({ params, feed, modal }) { return ( <> -

      User: {params.username}

      +

      User: {(await params).username}

      {feed} {modal} diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/photo/[id]/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/photo/[id]/page.js index 975bd8023b237..82738e4874455 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/photo/[id]/page.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/(group)/intercepting-parallel-modal/photo/[id]/page.js @@ -1,3 +1,4 @@ -export default function Page({ params }) { - return

      Photo PAGE {params.id}

      +export default async function Page({ params }) { + const { id } = await params + return

      Photo PAGE {id}

      } diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/(.)photos/[id]/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/(.)photos/[id]/page.js index 480009bb223de..d3b1a14161f8b 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/(.)photos/[id]/page.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/(.)photos/[id]/page.js @@ -1,5 +1,4 @@ -export default function Page({ params }) { - return ( -

      Photo INTERCEPTED {params.id}

      - ) +export default async function Page({ params }) { + const { id } = await params + return

      Photo INTERCEPTED {id}

      } diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/photos/[id]/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/photos/[id]/page.js index 975bd8023b237..82738e4874455 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/photos/[id]/page.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-routes/feed/photos/[id]/page.js @@ -1,3 +1,4 @@ -export default function Page({ params }) { - return

      Photo PAGE {params.id}

      +export default async function Page({ params }) { + const { id } = await params + return

      Photo PAGE {id}

      } diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/@modal/(.)[id]/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/@modal/(.)[id]/page.js index e8215d46e3fe5..a3b7147b261bb 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/@modal/(.)[id]/page.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/@modal/(.)[id]/page.js @@ -1,8 +1,8 @@ -export default function Page({ params: { id } }) { +export default async function Page({ params }) { return (

      intercepting-siblings

      -

      {id}

      +

      {(await params).id}

      ) } diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/[id]/page.js b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/[id]/page.js index f0b1c752caf49..ed1dcdc2712e7 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/[id]/page.js +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/intercepting-siblings/[id]/page.js @@ -1,4 +1,5 @@ -export default function Page({ params: { id } }) { +export default async function Page({ params }) { + const { id } = await params return (

      main slot

      diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/@intercept/(.)some-page/page.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/@intercept/(.)some-page/page.tsx index 134e0f7f9de15..172ab5245a29c 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/@intercept/(.)some-page/page.tsx +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/@intercept/(.)some-page/page.tsx @@ -1,8 +1,8 @@ -export default function Page({ params }) { +export default async function Page({ params }) { return (
      Hello from [this-is-my-route]/@intercept/some-page. Param:{' '} - {params['this-is-my-route']} + {(await params)['this-is-my-route']}
      ) } diff --git a/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/some-page/page.tsx b/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/some-page/page.tsx index 41ec03cf05bb5..5b5aff5fc94cb 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/some-page/page.tsx +++ b/test/e2e/app-dir/parallel-routes-and-interception/app/interception-route-special-params/[this-is-my-route]/some-page/page.tsx @@ -1,8 +1,8 @@ -export default function Page({ params }) { +export default async function Page({ params }) { return (
      Hello from [this-is-my-route]/some-page. Param:{' '} - {params['this-is-my-route']} + {(await params)['this-is-my-route']}
      ) } diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/[...catchAll]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/[...catchAll]/page.tsx index bb230623f6c6a..4999e320e0f34 100644 --- a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/[...catchAll]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/@slot/[...catchAll]/page.tsx @@ -1,4 +1,5 @@ -export default function Page({ params: { catchAll = [] } }) { +export default async function Page({ params }) { + const { catchAll = [] } = await params return (

      Parallel Route!

      diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/[track]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/[track]/page.tsx index c957176c3daf6..c52f48c9a1ef0 100644 --- a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/[track]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/[track]/page.tsx @@ -1,10 +1,11 @@ import Link from 'next/link' -export default function Page({ params }) { +export default async function Page({ params }) { + const { artist, album, track } = await params return (
      -

      Track: {params.track}

      - Back to album +

      Track: {track}

      + Back to album
      ) } diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/page.tsx index 29184ed8c515f..bbff22e628317 100644 --- a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/[album]/page.tsx @@ -1,20 +1,19 @@ import Link from 'next/link' -export default function Page({ params }) { +export default async function Page({ params }) { const tracks = ['track1', 'track2', 'track3'] + const { artist, album } = await params return (

      Album: {params.album}

        {tracks.map((track) => (
      • - - {track} - + {track}
      • ))}
      - Back to artist + Back to artist
      ) } diff --git a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx index 7396f3914b7fd..4e366064ff9e4 100644 --- a/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-breadcrumbs/app/[artist]/page.tsx @@ -1,14 +1,15 @@ import Link from 'next/link' -export default function Page({ params }) { +export default async function Page({ params }) { const albums = ['album1', 'album2', 'album3'] + const { artist } = await params return (
      -

      Artist: {params.artist}

      +

      Artist: {artist}

        {albums.map((album) => (
      • - {album} + {album}
      • ))}
      diff --git a/test/e2e/app-dir/parallel-routes-catchall-specificity/app/comments/[productId]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-specificity/app/comments/[productId]/page.tsx index 72edb2c3b37a3..52e23658a1a95 100644 --- a/test/e2e/app-dir/parallel-routes-catchall-specificity/app/comments/[productId]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-catchall-specificity/app/comments/[productId]/page.tsx @@ -1,7 +1,7 @@ -export default function Page({ - params: { productId }, +export default async function Page({ + params, }: { - params: { productId: string } + params: Promise<{ productId: string }> }) { - return

      {productId}

      + return

      {(await params).productId}

      } diff --git a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/(.)interception/[id]/page.tsx b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/(.)interception/[id]/page.tsx index 8a2f0c4e5dffd..6acac08855bbf 100644 --- a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/(.)interception/[id]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/(.)interception/[id]/page.tsx @@ -1,8 +1,9 @@ -export default function ModalPage({ - params: { id }, +export default async function ModalPage({ + params, }: { - params: { id: string } + params: Promise<{ id: string }> }) { + const { id } = await params return (

      Modal for Interception Page

      diff --git a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/no-interception/[id]/page.tsx b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/no-interception/[id]/page.tsx index 700145437c108..0e440ef587289 100644 --- a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/no-interception/[id]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/@modal/no-interception/[id]/page.tsx @@ -1,8 +1,9 @@ -export default function ModalPage({ - params: { id }, +export default async function ModalPage({ + params, }: { - params: { id: string } + params: Promise<{ id: string }> }) { + const { id } = await params return (

      Modal for No Interception Page

      diff --git a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/interception/[id]/page.tsx b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/interception/[id]/page.tsx index d3a107cef7c83..8297b32a6f046 100644 --- a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/interception/[id]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/interception/[id]/page.tsx @@ -1,6 +1,11 @@ import Link from 'next/link' -export default function Page({ params: { id } }: { params: { id: string } }) { +export default async function Page({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params return (

      Interception Page

      diff --git a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/no-interception/[id]/page.tsx b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/no-interception/[id]/page.tsx index c15d20108d20d..95e59db1b3ed7 100644 --- a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/no-interception/[id]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/no-interception/[id]/page.tsx @@ -1,6 +1,11 @@ import Link from 'next/link' -export default function Page({ params: { id } }: { params: { id: string } }) { +export default async function Page({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params return (

      No Interception Page

      diff --git a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/page.tsx b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/page.tsx index 495bde09dbf16..75a958515d353 100644 --- a/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-generate-static-params/app/[locale]/page.tsx @@ -1,10 +1,11 @@ import Link from 'next/link' -export default function Home({ - params: { locale }, +export default async function Home({ + params, }: { - params: { locale: string } + params: Promise<{ locale: string }> }) { + const { locale } = await params return (

      Home Page

      diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/@interception2/(.)[...dynamic]/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/@interception2/(.)[...dynamic]/page.tsx index 47dcf3aa0177a..0d443f67b9e97 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/@interception2/(.)[...dynamic]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/@interception2/(.)[...dynamic]/page.tsx @@ -1,12 +1,17 @@ 'use client' +import { use } from 'react' import { useRouter } from 'next/navigation' -export default function Page({ params }: { params: { dynamic: string } }) { +export default function Page({ + params, +}: { + params: Promise<{ dynamic: string }> +}) { const router = useRouter() return (

      Detail Page (Intercepted)

      -

      {params.dynamic}

      +

      {use(params).dynamic}

      {Math.random()} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/[...dynamic]/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/[...dynamic]/page.tsx index 136c15f3b1782..e45c9564c3d11 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/[...dynamic]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/catchall/[...dynamic]/page.tsx @@ -1,12 +1,17 @@ 'use client' +import { use } from 'react' import { useRouter } from 'next/navigation' -export default function Page({ params }: { params: { dynamic: string } }) { +export default function Page({ + params, +}: { + params: Promise<{ dynamic: string }> +}) { const router = useRouter() return (

      Detail Page (Non-Intercepted)

      -

      {params.dynamic}

      +

      {use(params).dynamic}

      {Math.random()} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx index 29d63371927b8..ad8a4c280bd52 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic-refresh/[dynamic]/@modal/(.)login/page.tsx @@ -9,7 +9,7 @@ export default async function Page({ params, searchParams }) { return ( -
      {params.dynamic}
      +
      {(await params).dynamic}
      Modal Page diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/@interception2/(.)[dynamic]/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/@interception2/(.)[dynamic]/page.tsx index 47dcf3aa0177a..0d443f67b9e97 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/@interception2/(.)[dynamic]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/@interception2/(.)[dynamic]/page.tsx @@ -1,12 +1,17 @@ 'use client' +import { use } from 'react' import { useRouter } from 'next/navigation' -export default function Page({ params }: { params: { dynamic: string } }) { +export default function Page({ + params, +}: { + params: Promise<{ dynamic: string }> +}) { const router = useRouter() return (

      Detail Page (Intercepted)

      -

      {params.dynamic}

      +

      {use(params).dynamic}

      {Math.random()} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/[dynamic]/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/[dynamic]/page.tsx index 136c15f3b1782..e45c9564c3d11 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/[dynamic]/page.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/dynamic/[dynamic]/page.tsx @@ -1,12 +1,17 @@ 'use client' +import { use } from 'react' import { useRouter } from 'next/navigation' -export default function Page({ params }: { params: { dynamic: string } }) { +export default function Page({ + params, +}: { + params: Promise<{ dynamic: string }> +}) { const router = useRouter() return (

      Detail Page (Non-Intercepted)

      -

      {params.dynamic}

      +

      {use(params).dynamic}

      {Math.random()} diff --git a/test/e2e/app-dir/ppr-navigations/loading-tsx-no-partial-rendering/app/[dataKey]/page.tsx b/test/e2e/app-dir/ppr-navigations/loading-tsx-no-partial-rendering/app/[dataKey]/page.tsx index a8b6367819421..446408839e65b 100644 --- a/test/e2e/app-dir/ppr-navigations/loading-tsx-no-partial-rendering/app/[dataKey]/page.tsx +++ b/test/e2e/app-dir/ppr-navigations/loading-tsx-no-partial-rendering/app/[dataKey]/page.tsx @@ -13,10 +13,11 @@ async function Static({ dataKey }) { } export default async function Page({ - params: { dataKey }, + params, }: { - params: { dataKey: string } + params: Promise<{ dataKey: string }> }) { + const { dataKey } = await params return ( <>
      diff --git a/test/e2e/app-dir/rewrites-redirects/app/[...params]/page.tsx b/test/e2e/app-dir/rewrites-redirects/app/[...params]/page.tsx index 013b668d1e8eb..c6b42b1965224 100644 --- a/test/e2e/app-dir/rewrites-redirects/app/[...params]/page.tsx +++ b/test/e2e/app-dir/rewrites-redirects/app/[...params]/page.tsx @@ -1,7 +1,12 @@ -export default function Page({ params: { params } }) { +export default async function Page({ + params, +}: { + params: Promise<{ params: Array }> +}) { + const { params: catchAllParams } = await params return ( -
      - {params.join('/')} +
      + {catchAllParams.join('/')}
      ) } diff --git a/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/layout.tsx b/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/layout.tsx index 569dcc349114f..cb3ce43ee6b6f 100644 --- a/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/layout.tsx +++ b/test/e2e/app-dir/router-autoscroll/app/[layoutPaddingWidth]/[layoutPaddingHeight]/[pageWidth]/[pageHeight]/[param]/layout.tsx @@ -1,17 +1,19 @@ import React from 'react' -export default function Layout({ +export default async function Layout({ children, - params: { layoutPaddingHeight, layoutPaddingWidth, pageWidth, pageHeight }, + params, }: { children: React.ReactNode - params: { + params: Promise<{ layoutPaddingWidth: string layoutPaddingHeight: string pageWidth: string pageHeight: string - } + }> }) { + const { layoutPaddingHeight, layoutPaddingWidth, pageWidth, pageHeight } = + await params return (
      From c0de945c19c3dd7064cd3e68ce1b3007657843d7 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 10 Sep 2024 21:07:17 -0700 Subject: [PATCH 09/14] Updates `draftMode().isEnabled` to be a Promise --- .../next/src/server/request/draft-mode.ts | 140 +++++++++++++++++- .../app/draftmode/async/ToggleButton.tsx | 17 +++ .../dynamic-io/app/draftmode/async/page.tsx | 29 ++++ .../app/draftmode/async/toggle/route.ts | 12 ++ .../app/draftmode/sync/ToggleButton.tsx | 17 +++ .../dynamic-io/app/draftmode/sync/page.tsx | 30 ++++ .../app/draftmode/sync/toggle/route.ts | 13 ++ .../dynamic-io/dynamic-io.draft-mode.test.ts | 59 ++++++++ 8 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 test/e2e/app-dir/dynamic-io/app/draftmode/async/ToggleButton.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/draftmode/async/toggle/route.ts create mode 100644 test/e2e/app-dir/dynamic-io/app/draftmode/sync/ToggleButton.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/draftmode/sync/toggle/route.ts create mode 100644 test/e2e/app-dir/dynamic-io/dynamic-io.draft-mode.test.ts diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index c6cfafd9451eb..2ecdb55f06188 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -1,10 +1,134 @@ -import type { DraftModeProvider } from '../async-storage/draft-mode-provider' +import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' + +import type { DraftModeProvider } from '../../server/async-storage/draft-mode-provider' import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' import { trackDynamicDataAccessed } from '../app-render/dynamic-rendering' -import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' -export class DraftMode { +/** + * In this version of Next.js `draftMode()` returns a Promise however you can still reference the properties of the underlying draftMode object + * synchronously to facilitate migration. The `UnsafeUnwrappedDraftMode` type is added to your code by a codemod that attempts to automatically + * updates callsites to reflect the new Promise return type. There are some cases where `draftMode()` cannot be automatically converted, namely + * when it is used inside a synchronous function and we can't be sure the function can be made async automatically. In these cases we add an + * explicit type case to `UnsafeUnwrappedDraftMode` to enable typescript to allow for the synchronous usage only where it is actually necessary. + * + * You should should update these callsites to either be async functions where the `draftMode()` value can be awaited or you should call `draftMode()` + * from outside and await the return value before passing it into this function. + * + * You can find instances that require manual migration by searching for `UnsafeUnwrappedDraftMode` in your codebase or by search for a comment that + * starts with: + * + * ``` + * // TODO [sync-draftMode-usage] + * ``` + * In a future version of Next.js `draftMode()` will only return a Promise and you will not be able to access the underlying draftMode object directly + * without awaiting the return value first. When this change happens the type `UnsafeUnwrappedDraftMode` will be updated to reflect that is it no longer + * usable. + * + * This type is marked deprecated to help identify it as target for refactoring away. + * + * @deprecated + */ +export type UnsafeUnwrappedDraftMode = DraftMode + +export function draftMode(): Promise { + const callingExpression = 'draftMode' + const requestStore = getExpectedRequestStore(callingExpression) + + if (process.env.NODE_ENV === 'development') { + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + const route = staticGenerationStore?.route + return createExoticDraftModeWithDevWarnings(requestStore.draftMode, route) + } else { + return createExoticDraftMode(requestStore.draftMode) + } +} + +interface CacheLifetime {} +const CachedDraftModes = new WeakMap>() + +function createExoticDraftMode( + underlyingProvider: DraftModeProvider +): Promise { + const cachedDraftMode = CachedDraftModes.get(underlyingProvider) + if (cachedDraftMode) { + return cachedDraftMode + } + + const instance = new DraftMode(underlyingProvider) + const promise = Promise.resolve(instance) + CachedDraftModes.set(underlyingProvider, promise) + + Object.defineProperty(promise, 'isEnabled', { + get() { + return instance.isEnabled + }, + set(newValue) { + Object.defineProperty(promise, 'isEnabled', { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + ;(promise as any).enable = instance.enable.bind(instance) + ;(promise as any).disable = instance.disable.bind(instance) + + return promise +} + +function createExoticDraftModeWithDevWarnings( + underlyingProvider: DraftModeProvider, + route: undefined | string +): Promise { + const cachedDraftMode = CachedDraftModes.get(underlyingProvider) + if (cachedDraftMode) { + return cachedDraftMode + } + + const instance = new DraftMode(underlyingProvider) + const promise = Promise.resolve(instance) + CachedDraftModes.set(underlyingProvider, promise) + + Object.defineProperty(promise, 'isEnabled', { + get() { + const expression = 'draftMode().isEnabled' + warnForSyncAccess(route, expression) + return instance.isEnabled + }, + set(newValue) { + Object.defineProperty(promise, 'isEnabled', { + value: newValue, + writable: true, + enumerable: true, + }) + }, + enumerable: true, + configurable: true, + }) + + Object.defineProperty(promise, 'enable', { + value: function get() { + const expression = 'draftMode().enable()' + warnForSyncAccess(route, expression) + return instance.enable.apply(instance, arguments as any) + }, + }) + + Object.defineProperty(promise, 'disable', { + value: function get() { + const expression = 'draftMode().disable()' + warnForSyncAccess(route, expression) + return instance.disable.apply(instance, arguments as any) + }, + }) + + return promise +} + +class DraftMode { /** * @internal - this declaration is stripped via `tsc --stripInternal` */ @@ -36,9 +160,9 @@ export class DraftMode { } } -export function draftMode() { - const callingExpression = 'draftMode' - const requestStore = getExpectedRequestStore(callingExpression) - - return new DraftMode(requestStore.draftMode) +function warnForSyncAccess(route: undefined | string, expression: string) { + const prefix = route ? ` In route ${route} a ` : 'A ' + console.error( + `${prefix}\`draftMode()\` property was accessed directly with \`${expression}\`. \`draftMode()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying draftMode object. In this version of Next.js direct access to \`${expression}\` is still supported to facilitate migration but in a future version you will be required to await the result. If this \`draftMode()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + ) } diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/async/ToggleButton.tsx b/test/e2e/app-dir/dynamic-io/app/draftmode/async/ToggleButton.tsx new file mode 100644 index 0000000000000..d598350179b02 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/async/ToggleButton.tsx @@ -0,0 +1,17 @@ +'use client' + +import { useRouter } from 'next/navigation' + +export default function ToggleButton() { + const router = useRouter() + + return ( + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx b/test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx new file mode 100644 index 0000000000000..3c9d352f30e2a --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx @@ -0,0 +1,29 @@ +import { draftMode } from 'next/headers' + +import { getSentinelValue } from '../../getSentinelValue' + +import ToggleButton from './ToggleButton' + +export default async function Page() { + return ( + <> +

      + This page uses `(await draftMode()).isEnabled`. This is now a promise + however reading it during prerender is fine becuase it is always false + during prerender so we don't expect it to trigger any dynamic behavior +

      + + +
      {getSentinelValue()}
      + + ) +} + +async function Component() { + return ( +
      + draftMode enabled?{' '} + {'' + (await draftMode()).isEnabled} +
      + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/async/toggle/route.ts b/test/e2e/app-dir/dynamic-io/app/draftmode/async/toggle/route.ts new file mode 100644 index 0000000000000..1efb29d3d6067 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/async/toggle/route.ts @@ -0,0 +1,12 @@ +import { draftMode } from 'next/headers' + +export async function GET(request: Request) { + const isEnabled = (await draftMode()).isEnabled + if (isEnabled) { + ;(await draftMode()).disable() + return new Response('Draft mode is disabled') + } else { + ;(await draftMode()).enable() + return new Response('Draft mode is enabled') + } +} diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/sync/ToggleButton.tsx b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/ToggleButton.tsx new file mode 100644 index 0000000000000..b318afbbcc303 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/ToggleButton.tsx @@ -0,0 +1,17 @@ +'use client' + +import { useRouter } from 'next/navigation' + +export default function ToggleButton() { + const router = useRouter() + + return ( + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx new file mode 100644 index 0000000000000..bec14cac231d9 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx @@ -0,0 +1,30 @@ +import { draftMode, type UnsafeUnwrappedDraftMode } from 'next/headers' + +import { getSentinelValue } from '../../getSentinelValue' + +import ToggleButton from './ToggleButton' + +export default async function Page() { + return ( + <> +

      + This page uses `draftMode().isEnabled`. This is now a promise however + reading it during prerender is fine becuase it is always false during + prerender so we don't expect it to trigger any dynamic behavior. +

      + + +
      {getSentinelValue()}
      + + ) +} + +async function Component() { + const syncDraftMode = draftMode() as unknown as UnsafeUnwrappedDraftMode + return ( +
      + draftMode enabled?{' '} + {'' + syncDraftMode.isEnabled} +
      + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/sync/toggle/route.ts b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/toggle/route.ts new file mode 100644 index 0000000000000..6a126e9ceead9 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/toggle/route.ts @@ -0,0 +1,13 @@ +import { draftMode, type UnsafeUnwrappedDraftMode } from 'next/headers' + +export async function GET(request: Request) { + const syncDraftMode = draftMode() as unknown as UnsafeUnwrappedDraftMode + const isEnabled = syncDraftMode.isEnabled + if (isEnabled) { + syncDraftMode.disable() + return new Response('Draft mode is disabled') + } else { + syncDraftMode.enable() + return new Response('Draft mode is enabled') + } +} diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.draft-mode.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.draft-mode.test.ts new file mode 100644 index 0000000000000..f63ab35229849 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.draft-mode.test.ts @@ -0,0 +1,59 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('dynamic-io', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) { + return + } + + let cliIndex = 0 + beforeEach(() => { + cliIndex = next.cliOutput.length + }) + function getLines(containing: string): Array { + const warnings = next.cliOutput + .slice(cliIndex) + .split('\n') + .filter((l) => l.includes(containing)) + + cliIndex = next.cliOutput.length + return warnings + } + + it('should fully prerender pages that use draftMode', async () => { + expect(getLines('In route /draftmode')).toEqual([]) + let $ = await next.render$('/draftmode/async', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#draft-mode').text()).toBe('false') + expect(getLines('In route /draftmode')).toEqual([]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#draft-mode').text()).toBe('false') + expect(getLines('In route /draftmode')).toEqual([]) + } + + $ = await next.render$('/draftmode/sync', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#draft-mode').text()).toBe('false') + expect(getLines('In route /draftmode')).toEqual([ + expect.stringContaining( + 'a `draftMode()` property was accessed directly with `draftMode().isEnabled`.' + ), + ]) + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#draft-mode').text()).toBe('false') + expect(getLines('In route /draftmode')).toEqual([]) + } + }) +}) From 70ce74a42d73efddb3ae8ba019537c3516aef495 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 11 Sep 2024 14:13:39 -0700 Subject: [PATCH 10/14] Update tests to use the ew Promise API for `isEnabled` --- test/e2e/app-dir/app-middleware/middleware.js | 2 +- test/e2e/app-dir/app-static/app/api/draft-mode/route.ts | 6 +++--- .../draft-mode-middleware/app/api/disable-draft/route.ts | 2 +- .../app-dir/draft-mode-middleware/app/api/draft/route.ts | 2 +- .../app-dir/draft-mode-middleware/app/preview-page/page.tsx | 4 ++-- test/e2e/app-dir/draft-mode-middleware/middleware.ts | 4 ++-- test/e2e/app-dir/draft-mode/app/disable/route.ts | 6 +++--- .../e2e/app-dir/draft-mode/app/enable-and-redirect/route.ts | 4 ++-- test/e2e/app-dir/draft-mode/app/enable/route.ts | 6 +++--- test/e2e/app-dir/draft-mode/app/page.tsx | 4 ++-- test/e2e/app-dir/draft-mode/app/state/route.ts | 4 ++-- test/e2e/app-dir/draft-mode/app/with-cookies/page.tsx | 2 +- test/e2e/app-dir/draft-mode/app/with-edge/disable/route.ts | 6 +++--- .../draft-mode/app/with-edge/enable-and-redirect/route.ts | 4 ++-- test/e2e/app-dir/draft-mode/app/with-edge/enable/route.ts | 6 +++--- test/e2e/app-dir/draft-mode/app/with-edge/page.tsx | 4 ++-- test/e2e/app-dir/draft-mode/app/with-edge/state/route.ts | 4 ++-- .../app-dir/draft-mode/app/with-edge/with-cookies/page.tsx | 2 +- test/e2e/app-dir/hooks/app/enable/route.js | 4 ++-- 19 files changed, 38 insertions(+), 38 deletions(-) diff --git a/test/e2e/app-dir/app-middleware/middleware.js b/test/e2e/app-dir/app-middleware/middleware.js index 7c6123536f339..e8409c227b947 100644 --- a/test/e2e/app-dir/app-middleware/middleware.js +++ b/test/e2e/app-dir/app-middleware/middleware.js @@ -21,7 +21,7 @@ export async function middleware(request) { } if (request.nextUrl.searchParams.get('draft')) { - draftMode().enable() + ;(await draftMode()).enable() } const removeHeaders = request.nextUrl.searchParams.get('remove-headers') diff --git a/test/e2e/app-dir/app-static/app/api/draft-mode/route.ts b/test/e2e/app-dir/app-static/app/api/draft-mode/route.ts index 72ad8ac9704d0..e6e6fadc294a2 100644 --- a/test/e2e/app-dir/app-static/app/api/draft-mode/route.ts +++ b/test/e2e/app-dir/app-static/app/api/draft-mode/route.ts @@ -1,13 +1,13 @@ import { draftMode } from 'next/headers' import { NextRequest, NextResponse } from 'next/server' -export function GET(req: NextRequest) { +export async function GET(req: NextRequest) { const status = req.nextUrl.searchParams.get('status') if (status === 'enable') { - draftMode().enable() + ;(await draftMode()).enable() } else { - draftMode().disable() + ;(await draftMode()).disable() } return NextResponse.json({ diff --git a/test/e2e/app-dir/draft-mode-middleware/app/api/disable-draft/route.ts b/test/e2e/app-dir/draft-mode-middleware/app/api/disable-draft/route.ts index 40282bae836f0..3c12fdd00f477 100644 --- a/test/e2e/app-dir/draft-mode-middleware/app/api/disable-draft/route.ts +++ b/test/e2e/app-dir/draft-mode-middleware/app/api/disable-draft/route.ts @@ -1,6 +1,6 @@ import { draftMode } from 'next/headers' export async function GET(request: Request) { - draftMode().disable() + ;(await draftMode()).disable() return new Response('Draft mode is disabled') } diff --git a/test/e2e/app-dir/draft-mode-middleware/app/api/draft/route.ts b/test/e2e/app-dir/draft-mode-middleware/app/api/draft/route.ts index 223af2f3822b5..776eb89674bdb 100644 --- a/test/e2e/app-dir/draft-mode-middleware/app/api/draft/route.ts +++ b/test/e2e/app-dir/draft-mode-middleware/app/api/draft/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { } // Enable Draft Mode by setting the cookie - draftMode().enable() + ;(await draftMode()).enable() // Redirect to the path redirect(`/${slug}`) diff --git a/test/e2e/app-dir/draft-mode-middleware/app/preview-page/page.tsx b/test/e2e/app-dir/draft-mode-middleware/app/preview-page/page.tsx index 4fbead3096ba1..c4257b6f1bf8a 100644 --- a/test/e2e/app-dir/draft-mode-middleware/app/preview-page/page.tsx +++ b/test/e2e/app-dir/draft-mode-middleware/app/preview-page/page.tsx @@ -1,6 +1,6 @@ import { draftMode } from 'next/headers' -export default function PreviewPage() { - const { isEnabled } = draftMode() +export default async function PreviewPage() { + const { isEnabled } = await draftMode() return

      {isEnabled ? 'draft' : 'none'}

      } diff --git a/test/e2e/app-dir/draft-mode-middleware/middleware.ts b/test/e2e/app-dir/draft-mode-middleware/middleware.ts index d441cfd199d9b..8c88c591391f8 100644 --- a/test/e2e/app-dir/draft-mode-middleware/middleware.ts +++ b/test/e2e/app-dir/draft-mode-middleware/middleware.ts @@ -1,8 +1,8 @@ import { NextResponse, type NextRequest } from 'next/server' import { draftMode } from 'next/headers' -export function middleware(req: NextRequest) { - const { isEnabled } = draftMode() +export async function middleware(req: NextRequest) { + const { isEnabled } = await draftMode() console.log('draftMode().isEnabled from middleware:', isEnabled) return NextResponse.next() } diff --git a/test/e2e/app-dir/draft-mode/app/disable/route.ts b/test/e2e/app-dir/draft-mode/app/disable/route.ts index 0d142b236ba24..287b03e8b0826 100644 --- a/test/e2e/app-dir/draft-mode/app/disable/route.ts +++ b/test/e2e/app-dir/draft-mode/app/disable/route.ts @@ -1,8 +1,8 @@ import { draftMode } from 'next/headers' -export function GET() { - draftMode().disable() +export async function GET() { + ;(await draftMode()).disable() return new Response( - 'Disabled in Route Handler using draftMode().enable(), check cookies' + 'Disabled in Route Handler using `(await draftMode()).disable()`, check cookies' ) } diff --git a/test/e2e/app-dir/draft-mode/app/enable-and-redirect/route.ts b/test/e2e/app-dir/draft-mode/app/enable-and-redirect/route.ts index 79cd35454c57c..14bec9f8944b0 100644 --- a/test/e2e/app-dir/draft-mode/app/enable-and-redirect/route.ts +++ b/test/e2e/app-dir/draft-mode/app/enable-and-redirect/route.ts @@ -1,8 +1,8 @@ import { draftMode } from 'next/headers' import { redirect } from 'next/navigation' -export function GET(req: Request) { - draftMode().enable() +export async function GET(req: Request) { + ;(await draftMode()).enable() const to = new URL(req.url).searchParams.get('to') ?? '/some-other-page' return redirect(to) } diff --git a/test/e2e/app-dir/draft-mode/app/enable/route.ts b/test/e2e/app-dir/draft-mode/app/enable/route.ts index d921b50f2c30c..7dd464edfb686 100644 --- a/test/e2e/app-dir/draft-mode/app/enable/route.ts +++ b/test/e2e/app-dir/draft-mode/app/enable/route.ts @@ -1,8 +1,8 @@ import { draftMode } from 'next/headers' -export function GET() { - draftMode().enable() +export async function GET() { + ;(await draftMode()).enable() return new Response( - 'Enabled in Route Handler using draftMode().enable(), check cookies' + 'Enabled in Route Handler using `(await draftMode()).enable()`, check cookies' ) } diff --git a/test/e2e/app-dir/draft-mode/app/page.tsx b/test/e2e/app-dir/draft-mode/app/page.tsx index 65b59b64e32a3..3672959c79ad1 100644 --- a/test/e2e/app-dir/draft-mode/app/page.tsx +++ b/test/e2e/app-dir/draft-mode/app/page.tsx @@ -1,8 +1,8 @@ import React from 'react' import { draftMode } from 'next/headers' -export default function Page() { - const { isEnabled } = draftMode() +export default async function Page() { + const { isEnabled } = await draftMode() return ( <> diff --git a/test/e2e/app-dir/draft-mode/app/state/route.ts b/test/e2e/app-dir/draft-mode/app/state/route.ts index c5568ed06b89c..fa8afeb80671a 100644 --- a/test/e2e/app-dir/draft-mode/app/state/route.ts +++ b/test/e2e/app-dir/draft-mode/app/state/route.ts @@ -1,6 +1,6 @@ import { draftMode } from 'next/headers' -export function GET() { - const { isEnabled } = draftMode() +export async function GET() { + const { isEnabled } = await draftMode() return new Response(isEnabled ? 'ENABLED' : 'DISABLED') } diff --git a/test/e2e/app-dir/draft-mode/app/with-cookies/page.tsx b/test/e2e/app-dir/draft-mode/app/with-cookies/page.tsx index 2d964cc0d0bbf..ed080451cfccd 100644 --- a/test/e2e/app-dir/draft-mode/app/with-cookies/page.tsx +++ b/test/e2e/app-dir/draft-mode/app/with-cookies/page.tsx @@ -2,7 +2,7 @@ import React from 'react' import { cookies, draftMode } from 'next/headers' export default async function Page() { - const { isEnabled } = draftMode() + const { isEnabled } = await draftMode() let data: string | undefined if (isEnabled) { data = (await cookies()).get('data')?.value diff --git a/test/e2e/app-dir/draft-mode/app/with-edge/disable/route.ts b/test/e2e/app-dir/draft-mode/app/with-edge/disable/route.ts index 0d142b236ba24..897e18110a413 100644 --- a/test/e2e/app-dir/draft-mode/app/with-edge/disable/route.ts +++ b/test/e2e/app-dir/draft-mode/app/with-edge/disable/route.ts @@ -1,8 +1,8 @@ import { draftMode } from 'next/headers' -export function GET() { - draftMode().disable() +export async function GET() { + ;(await draftMode()).disable() return new Response( - 'Disabled in Route Handler using draftMode().enable(), check cookies' + 'Disabled in Route Handler using `(await draftMode()).enable()`, check cookies' ) } diff --git a/test/e2e/app-dir/draft-mode/app/with-edge/enable-and-redirect/route.ts b/test/e2e/app-dir/draft-mode/app/with-edge/enable-and-redirect/route.ts index 79cd35454c57c..14bec9f8944b0 100644 --- a/test/e2e/app-dir/draft-mode/app/with-edge/enable-and-redirect/route.ts +++ b/test/e2e/app-dir/draft-mode/app/with-edge/enable-and-redirect/route.ts @@ -1,8 +1,8 @@ import { draftMode } from 'next/headers' import { redirect } from 'next/navigation' -export function GET(req: Request) { - draftMode().enable() +export async function GET(req: Request) { + ;(await draftMode()).enable() const to = new URL(req.url).searchParams.get('to') ?? '/some-other-page' return redirect(to) } diff --git a/test/e2e/app-dir/draft-mode/app/with-edge/enable/route.ts b/test/e2e/app-dir/draft-mode/app/with-edge/enable/route.ts index d921b50f2c30c..7dd464edfb686 100644 --- a/test/e2e/app-dir/draft-mode/app/with-edge/enable/route.ts +++ b/test/e2e/app-dir/draft-mode/app/with-edge/enable/route.ts @@ -1,8 +1,8 @@ import { draftMode } from 'next/headers' -export function GET() { - draftMode().enable() +export async function GET() { + ;(await draftMode()).enable() return new Response( - 'Enabled in Route Handler using draftMode().enable(), check cookies' + 'Enabled in Route Handler using `(await draftMode()).enable()`, check cookies' ) } diff --git a/test/e2e/app-dir/draft-mode/app/with-edge/page.tsx b/test/e2e/app-dir/draft-mode/app/with-edge/page.tsx index 45e28e88cfee2..91a38d2a748cd 100644 --- a/test/e2e/app-dir/draft-mode/app/with-edge/page.tsx +++ b/test/e2e/app-dir/draft-mode/app/with-edge/page.tsx @@ -1,8 +1,8 @@ import React from 'react' import { draftMode } from 'next/headers' -export default function Page() { - const { isEnabled } = draftMode() +export default async function Page() { + const { isEnabled } = await draftMode() return ( <> diff --git a/test/e2e/app-dir/draft-mode/app/with-edge/state/route.ts b/test/e2e/app-dir/draft-mode/app/with-edge/state/route.ts index c5568ed06b89c..fa8afeb80671a 100644 --- a/test/e2e/app-dir/draft-mode/app/with-edge/state/route.ts +++ b/test/e2e/app-dir/draft-mode/app/with-edge/state/route.ts @@ -1,6 +1,6 @@ import { draftMode } from 'next/headers' -export function GET() { - const { isEnabled } = draftMode() +export async function GET() { + const { isEnabled } = await draftMode() return new Response(isEnabled ? 'ENABLED' : 'DISABLED') } diff --git a/test/e2e/app-dir/draft-mode/app/with-edge/with-cookies/page.tsx b/test/e2e/app-dir/draft-mode/app/with-edge/with-cookies/page.tsx index 2d964cc0d0bbf..ed080451cfccd 100644 --- a/test/e2e/app-dir/draft-mode/app/with-edge/with-cookies/page.tsx +++ b/test/e2e/app-dir/draft-mode/app/with-edge/with-cookies/page.tsx @@ -2,7 +2,7 @@ import React from 'react' import { cookies, draftMode } from 'next/headers' export default async function Page() { - const { isEnabled } = draftMode() + const { isEnabled } = await draftMode() let data: string | undefined if (isEnabled) { data = (await cookies()).get('data')?.value diff --git a/test/e2e/app-dir/hooks/app/enable/route.js b/test/e2e/app-dir/hooks/app/enable/route.js index 8b5b9ed565966..0865343fa533a 100644 --- a/test/e2e/app-dir/hooks/app/enable/route.js +++ b/test/e2e/app-dir/hooks/app/enable/route.js @@ -1,7 +1,7 @@ import { draftMode } from 'next/headers' -export function GET() { - draftMode().enable() +export async function GET() { + ;(await draftMode()).enable() return new Response( 'Enabled in Route Handler with draftMode().enable(), check cookies' ) From 0885970d3c8070e703de83620afc3efa5596c566 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 18 Sep 2024 08:59:25 -0700 Subject: [PATCH 11/14] Convert the route context object to use Proimse --- .../plugins/next-types-plugin/index.ts | 3 +- .../server/route-modules/app-route/module.ts | 10 ++- .../app/routes/[dyn]/async/route.ts | 25 ++++++ .../dynamic-io/app/routes/[dyn]/sync/route.ts | 27 ++++++ .../app/routes/dynamic-cookies/route.ts | 2 +- .../app/routes/dynamic-headers/route.ts | 2 +- .../app/routes/dynamic-stream/route.ts | 2 +- .../app/routes/dynamic-url/route.ts | 2 +- .../app/routes/fetch-cached/route.ts | 2 +- .../app/routes/fetch-mixed/route.ts | 2 +- .../dynamic-io/app/routes/io-cached/route.ts | 2 +- .../dynamic-io/app/routes/io-mixed/route.ts | 2 +- .../dynamic-io/app/routes/microtask/route.ts | 2 +- .../app/routes/static-stream-async/route.ts | 2 +- .../app/routes/static-stream-sync/route.ts | 2 +- .../app/routes/static-string-async/route.ts | 2 +- .../app/routes/static-string-sync/route.ts | 2 +- .../dynamic-io/app/routes/task/route.ts | 2 +- .../dynamic-io/dynamic-io.routes.test.ts | 88 +++++++++++++++++++ 19 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 test/e2e/app-dir/dynamic-io/app/routes/[dyn]/async/route.ts create mode 100644 test/e2e/app-dir/dynamic-io/app/routes/[dyn]/sync/route.ts diff --git a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts index 891b15f74498d..17cdbcd0f767f 100644 --- a/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts +++ b/packages/next/src/build/webpack/plugins/next-types-plugin/index.ts @@ -85,6 +85,7 @@ checkFields>() +${options.type === 'route' ? `type RouteContext = { params: Promise }` : ''} ${ options.type === 'route' ? HTTP_METHODS.map( @@ -103,7 +104,7 @@ if ('${method}' in entry) { >() checkFields< Diff< - ParamCheck, + ParamCheck, { __tag__: '${method}' __param_position__: 'second' diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index 9112f8b5c1142..74a70bc354895 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -68,6 +68,7 @@ import { ReflectAdapter } from '../../web/spec-extension/adapters/reflect' import type { RenderOptsPartial } from '../../app-render/types' import { CacheSignal } from '../../app-render/cache-signal' import { scheduleImmediate } from '../../../lib/scheduler' +import { createServerParamsForRoute } from '../../request/params' /** * The AppRouteModule is the type of the module exported by the bundled App @@ -90,7 +91,7 @@ export interface AppRouteRouteHandlerContext extends RouteModuleHandleContext { * second argument. */ type AppRouteHandlerFnContext = { - params?: Record + params?: Promise> } /** @@ -402,9 +403,12 @@ export class AppRouteRouteModule extends RouteModule< prerenderAsyncStorage: this.prerenderAsyncStorage, }) - const handlerContext = { + const handlerContext: AppRouteHandlerFnContext = { params: context.params - ? parsedUrlQueryToParams(context.params) + ? createServerParamsForRoute( + parsedUrlQueryToParams(context.params), + staticGenerationStore + ) : undefined, } diff --git a/test/e2e/app-dir/dynamic-io/app/routes/[dyn]/async/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/[dyn]/async/route.ts new file mode 100644 index 0000000000000..614412a738dd2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/[dyn]/async/route.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from 'next/server' + +import { getSentinelValue } from '../../../getSentinelValue' + +export async function generateStaticParams() { + return [ + { + dyn: '1', + }, + ] +} + +export async function GET( + request: NextRequest, + props: { params: Promise<{ dyn: string }> } +) { + const { dyn } = await props.params + return new Response( + JSON.stringify({ + value: getSentinelValue(), + type: 'dynamic params', + param: dyn, + }) + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/[dyn]/sync/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/[dyn]/sync/route.ts new file mode 100644 index 0000000000000..41260d96c724d --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/routes/[dyn]/sync/route.ts @@ -0,0 +1,27 @@ +import type { NextRequest, UnsafeUnwrappedParams } from 'next/server' + +import { getSentinelValue } from '../../../getSentinelValue' + +export async function generateStaticParams() { + return [ + { + dyn: '1', + }, + ] +} + +export async function GET( + request: NextRequest, + props: { params: Promise<{ dyn: string }> } +) { + const dyn = ( + props.params as unknown as UnsafeUnwrappedParams + ).dyn + return new Response( + JSON.stringify({ + value: getSentinelValue(), + type: 'dynamic params', + param: dyn, + }) + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts index 3ed857b10b844..7b5589b0624e4 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-cookies/route.ts @@ -4,7 +4,7 @@ import { cookies } from 'next/headers' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const sentinel = (await cookies()).get('x-sentinel') return new Response( JSON.stringify({ diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts index 685d3a96111c1..c6dd41da9a0c0 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-headers/route.ts @@ -4,7 +4,7 @@ import { headers } from 'next/headers' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const sentinel = (await headers()).get('x-sentinel') return new Response( JSON.stringify({ diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts index 31f8a49679d61..e54cbc64b4306 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-stream/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const result = JSON.stringify({ value: getSentinelValue(), message: 'dynamic stream', diff --git a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts index a541adcef1ed4..9d7b9f5e23f0e 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/dynamic-url/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const search = request.nextUrl.search return new Response( JSON.stringify({ diff --git a/test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts index 056897cc8fe9b..7b1f9a1861c25 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/fetch-cached/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const fetcheda = await fetchRandomCached('a') const fetchedb = await fetchRandomCached('b') return new Response( diff --git a/test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts index 43199c37abbeb..a1fb31abe7725 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/fetch-mixed/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const fetcheda = await fetchRandomCached('a') const fetchedb = await fetchRandomUncached('b') return new Response( diff --git a/test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts index e464d99b9c66c..5cc7b833bfd6c 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/io-cached/route.ts @@ -4,7 +4,7 @@ import { unstable_cache as cache } from 'next/cache' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const messagea = await getCachedMessage('hello cached fast', 0) const messageb = await getCachedMessage('hello cached slow', 20) return new Response( diff --git a/test/e2e/app-dir/dynamic-io/app/routes/io-mixed/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/io-mixed/route.ts index 0ac5ab4cdbbf0..3122950535fab 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/io-mixed/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/io-mixed/route.ts @@ -4,7 +4,7 @@ import { unstable_cache as cache } from 'next/cache' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const messagea = await getCachedMessage('hello cached fast', 0) const messageb = await getMessage('hello uncached slow', 20) return new Response( diff --git a/test/e2e/app-dir/dynamic-io/app/routes/microtask/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/microtask/route.ts index b198063d7a42c..42783101b8ae8 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/microtask/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/microtask/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { await Promise.resolve() const response = JSON.stringify({ value: getSentinelValue(), diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-stream-async/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-async/route.ts index bea5713974cac..904065fc44684 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/static-stream-async/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-async/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const response = JSON.stringify({ value: getSentinelValue(), message: 'stream response', diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-stream-sync/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-sync/route.ts index 2c9347f48a764..05c77a4c42243 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/static-stream-sync/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-stream-sync/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export function GET(request: NextRequest, { params }: { params: {} }) { +export function GET(request: NextRequest) { const response = JSON.stringify({ value: getSentinelValue(), message: 'stream response', diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-string-async/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-string-async/route.ts index b4019a7e588da..0b5be7d35d2fb 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/static-string-async/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-string-async/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { const response = JSON.stringify({ value: getSentinelValue(), message: 'string response', diff --git a/test/e2e/app-dir/dynamic-io/app/routes/static-string-sync/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/static-string-sync/route.ts index 1e986b9db467c..5680b9777dffe 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/static-string-sync/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/static-string-sync/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export function GET(request: NextRequest, { params }: { params: {} }) { +export function GET(request: NextRequest) { const response = JSON.stringify({ value: getSentinelValue(), message: 'string response', diff --git a/test/e2e/app-dir/dynamic-io/app/routes/task/route.ts b/test/e2e/app-dir/dynamic-io/app/routes/task/route.ts index 34e00162b49b0..be36e958011af 100644 --- a/test/e2e/app-dir/dynamic-io/app/routes/task/route.ts +++ b/test/e2e/app-dir/dynamic-io/app/routes/task/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server' import { getSentinelValue } from '../../getSentinelValue' -export async function GET(request: NextRequest, { params }: { params: {} }) { +export async function GET(request: NextRequest) { await new Promise((r) => setTimeout(r, 10)) const response = JSON.stringify({ value: getSentinelValue(), diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts index 585d6b96671bb..089268c77d583 100644 --- a/test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.routes.test.ts @@ -10,6 +10,20 @@ describe('dynamic-io', () => { return } + let cliIndex = 0 + beforeEach(() => { + cliIndex = next.cliOutput.length + }) + function getLines(containing: string): Array { + const warnings = next.cliOutput + .slice(cliIndex) + .split('\n') + .filter((l) => l.includes(containing)) + + cliIndex = next.cliOutput.length + return warnings + } + it('should not prerender GET route handlers that use dynamic APIs', async () => { let str = await next.render('/routes/dynamic-cookies', {}) let json = JSON.parse(str) @@ -224,4 +238,78 @@ describe('dynamic-io', () => { expect(json.value).toEqual('at runtime') expect(json.message).toBe('task') }) + + it('should prerender GET route handlers when accessing awaited params', async () => { + expect(getLines('In route /routes/[dyn]')).toEqual([]) + let str = await next.render('/routes/1/async', {}) + let json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('1') + expect(getLines('In route /routes/[dyn]')).toEqual([]) + } else { + expect(json.value).toEqual('at buildtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('1') + expect(getLines('In route /routes/[dyn]')).toEqual([]) + } + + str = await next.render('/routes/2/async', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('2') + expect(getLines('In route /routes/[dyn]')).toEqual([]) + } else { + expect(json.value).toEqual('at runtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('2') + expect(getLines('In route /routes/[dyn]')).toEqual([]) + } + }) + + it('should prerender GET route handlers when accessing params without awaiting first', async () => { + expect(getLines('In route /routes/[dyn]')).toEqual([]) + let str = await next.render('/routes/1/sync', {}) + let json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('1') + expect(getLines('In route /routes/[dyn]')).toEqual([ + expect.stringContaining( + 'a param property was accessed directly with `params.dyn`.' + ), + ]) + } else { + expect(json.value).toEqual('at buildtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('1') + expect(getLines('In route /routes/[dyn]')).toEqual([]) + } + + str = await next.render('/routes/2/sync', {}) + json = JSON.parse(str) + + if (isNextDev) { + expect(json.value).toEqual('at runtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('2') + expect(getLines('In route /routes/[dyn]')).toEqual([ + expect.stringContaining( + 'a param property was accessed directly with `params.dyn`.' + ), + ]) + } else { + expect(json.value).toEqual('at runtime') + expect(json.type).toBe('dynamic params') + expect(json.param).toBe('2') + expect(getLines('In route /routes/[dyn]')).toEqual([]) + } + }) }) From a9f75d6c01cb660766122937d3309f2bb74c2cc7 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 18 Sep 2024 12:32:05 -0700 Subject: [PATCH 12/14] Updates tests to account for params as Promise in route context --- .../app/api/app-redirect/[path]/route.ts | 6 +++--- .../app/[locale]/show/page.tsx | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/test/e2e/app-dir/front-redirect-issue/app/api/app-redirect/[path]/route.ts b/test/e2e/app-dir/front-redirect-issue/app/api/app-redirect/[path]/route.ts index fb2fcc9d15b03..19422f0f987ad 100644 --- a/test/e2e/app-dir/front-redirect-issue/app/api/app-redirect/[path]/route.ts +++ b/test/e2e/app-dir/front-redirect-issue/app/api/app-redirect/[path]/route.ts @@ -7,12 +7,12 @@ export async function GET( { params, }: { - params: { + params: Promise<{ path: string - } + }> } ): Promise { - request.nextUrl.pathname = `/app-future/en/${params.path}` + request.nextUrl.pathname = `/app-future/en/${(await params).path}` return fetch(request.nextUrl, { headers: new Headers({ cookie: request.headers.get('cookie') ?? '', diff --git a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx index 615aba8164593..aa96658fda2c7 100644 --- a/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx +++ b/test/e2e/app-dir/parallel-route-not-found-params/app/[locale]/show/page.tsx @@ -1,8 +1,15 @@ 'use client' + +import { use } from 'react' + import { notFound } from 'next/navigation' -export default async function Page({ params }) { - if ((await params).locale !== 'en') { +export default function Page({ + params, +}: { + params: Promise<{ locale: string }> +}) { + if (use(params).locale !== 'en') { notFound() } From 37bc28be6afa60bb8b51693e204c4e21d40f5372 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 19 Sep 2024 08:39:33 -0700 Subject: [PATCH 13/14] Fix spelling --- .vscode/settings.json | 15 +++++++++++++++ packages/next/src/server/request/cookies.ts | 4 ++-- .../next/src/server/request/params.browser.ts | 6 +++--- .../src/server/request/search-params.browser.ts | 4 ++-- .../dynamic-io/app/draftmode/async/page.tsx | 2 +- .../dynamic-io/app/draftmode/sync/page.tsx | 2 +- .../dynamic-io/dynamic-io.params.test.ts | 17 +++++++++-------- 7 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f0fc53bd6cd74..697fc022c01ee 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -58,14 +58,29 @@ }, "cSpell.words": [ "Destructuring", + "buildtime", + "callsites", + "codemod", + "datastream", + "deduped", + "draftmode", "Entrypoints", "jscodeshift", "napi", + "navigations", "nextjs", "opentelemetry", + "Preinit", "prerendered", + "prerendering", + "proxied", + "renderable", + "revalidates", + "subresource", + "thenables", "Threadsafe", "Turbopack", + "unproxied", "zipkin" ], "grammarly.selectors": [ diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 2ed9a152cd4ef..fe1f77ecd33ca 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -510,14 +510,14 @@ function describeNameArg(arg: unknown) { function warnForSyncIteration(route?: string) { const prefix = route ? ` In route ${route} ` : '' console.error( - `${prefix}cookies were iterated implicitly with something like \`for...of cookies())\` or \`[...cookies()]\`, or explicitly with \`cookies()[Symbol.iterator]()\`. \`cookies()\` now returns a Promise and the return value should be awaited before attempting to iterate over cookies. In this version of Next.js iterating cookies without awaiting first is still supported to faciliate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + `${prefix}cookies were iterated implicitly with something like \`for...of cookies())\` or \`[...cookies()]\`, or explicitly with \`cookies()[Symbol.iterator]()\`. \`cookies()\` now returns a Promise and the return value should be awaited before attempting to iterate over cookies. In this version of Next.js iterating cookies without awaiting first is still supported to facilitate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` ) } function warnForSyncAccess(route: undefined | string, expression: string) { const prefix = route ? ` In route ${route} a ` : 'A ' console.error( - `${prefix}cookie property was accessed directly with \`${expression}\`. \`cookies()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying cookies instance. In this version of Next.js direct access to \`${expression}\` is still supported to faciliate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` + `${prefix}cookie property was accessed directly with \`${expression}\`. \`cookies()\` now returns a Promise and the return value should be awaited before accessing properties of the underlying cookies instance. In this version of Next.js direct access to \`${expression}\` is still supported to facilitate migration but in a future version you will be required to await the result. If this \`cookies()\` use is inside an async function await the return value before accessing attempting iteration. If this use is inside a synchronous function then convert the function to async or await the call from outside this function and pass the result in.` ) } diff --git a/packages/next/src/server/request/params.browser.ts b/packages/next/src/server/request/params.browser.ts index d72268ca50875..6a678fa6834a0 100644 --- a/packages/next/src/server/request/params.browser.ts +++ b/packages/next/src/server/request/params.browser.ts @@ -121,7 +121,7 @@ function makeDynamicallyTrackedExoticParamsWithDevWarnings( function warnForSyncAccess(expression: string) { console.error( - `A param property was accessed directly with ${expression}. \`params\` is now a Promise and should be awaited before accessing properties of the underlying params object. In this version of Next.js direct access to param properties is still supported to faciliate migration but in a future version you will be required to await \`params\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + `A param property was accessed directly with ${expression}. \`params\` is now a Promise and should be awaited before accessing properties of the underlying params object. In this version of Next.js direct access to param properties is still supported to facilitate migration but in a future version you will be required to await \`params\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` ) } @@ -130,11 +130,11 @@ function warnForEnumeration(missingProperties: Array) { const describedMissingProperties = describeListOfPropertyNames(missingProperties) console.error( - `params are being enumerated incompletely with \`{...params}\`, \`Object.keys(params)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to faciliate migration to the new type. param names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`params\` promise.` + `params are being enumerated incompletely with \`{...params}\`, \`Object.keys(params)\`, or similar. The following properties were not copied: ${describedMissingProperties}. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. param names that conflict with Promise properties cannot be accessed directly and must be accessed by first awaiting the \`params\` promise.` ) } else { console.error( - `params are being enumerated with \`{...params}\`, \`Object.keys(params)\`, or similar. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to faciliate migration to the new type. You should update your code to await \`params\` before accessing its properties.` + `params are being enumerated with \`{...params}\`, \`Object.keys(params)\`, or similar. \`params\` is now a Promise, however in the current version of Next.js direct access to the underlying params object is still supported to facilitate migration to the new type. You should update your code to await \`params\` before accessing its properties.` ) } } diff --git a/packages/next/src/server/request/search-params.browser.ts b/packages/next/src/server/request/search-params.browser.ts index eaaa3c788c5a7..94548cd55af79 100644 --- a/packages/next/src/server/request/search-params.browser.ts +++ b/packages/next/src/server/request/search-params.browser.ts @@ -121,12 +121,12 @@ function makeUntrackedExoticSearchParams( function warnForSyncAccess(expression: string) { console.error( - `A searchParam property was accessed directly with ${expression}. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to faciliate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + `A searchParam property was accessed directly with ${expression}. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to facilitate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` ) } function warnForSyncSpread() { console.error( - `the keys of \`searchParams\` were accessed through something like \`Object.keys(searchParams)\` or \`{...searchParams}\`. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to faciliate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` + `the keys of \`searchParams\` were accessed through something like \`Object.keys(searchParams)\` or \`{...searchParams}\`. \`searchParams\` is now a Promise and should be awaited before accessing properties of the underlying searchParams object. In this version of Next.js direct access to searchParam properties is still supported to facilitate migration but in a future version you will be required to await \`searchParams\`. If this use is inside an async function await it. If this use is inside a synchronous function then convert the function to async or await it from outside this function and pass the result in.` ) } diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx b/test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx index 3c9d352f30e2a..1d07ba23ae12d 100644 --- a/test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/async/page.tsx @@ -9,7 +9,7 @@ export default async function Page() { <>

      This page uses `(await draftMode()).isEnabled`. This is now a promise - however reading it during prerender is fine becuase it is always false + however reading it during prerender is fine because it is always false during prerender so we don't expect it to trigger any dynamic behavior

      diff --git a/test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx index bec14cac231d9..abf66e42122b6 100644 --- a/test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/draftmode/sync/page.tsx @@ -9,7 +9,7 @@ export default async function Page() { <>

      This page uses `draftMode().isEnabled`. This is now a promise however - reading it during prerender is fine becuase it is always false during + reading it during prerender is fine because it is always false during prerender so we don't expect it to trigger any dynamic behavior.

      diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts index dd3a33f43ec05..517a994479231 100644 --- a/test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.params.test.ts @@ -2,6 +2,7 @@ import { nextTestSetup } from 'e2e-utils' const WITH_PPR = !!process.env.__NEXT_EXPERIMENTAL_PPR +// cSpell:words lowcard highcard describe('dynamic-io', () => { const { next, isNextDev, skipped } = nextTestSetup({ files: __dirname, @@ -578,7 +579,7 @@ describe('dynamic-io', () => { expect(getLines('In route /params')).toEqual([]) } else { // When dynamicIO is on and PPR is off the search params passed to a client page - // are enough to mark the whole route as dynamic. This is becuase we can't know if + // are enough to mark the whole route as dynamic. This is because we can't know if // you are going to use those searchParams in an update on the client so we can't infer // anything about your lack of use during SSR. In the future we will update searchParams // written to the client to actually derive those params from location and thus not @@ -659,7 +660,7 @@ describe('dynamic-io', () => { expect(getLines('In route /params')).toEqual([]) } else { // When dynamicIO is on and PPR is off the search params passed to a client page - // are enough to mark the whole route as dynamic. This is becuase we can't know if + // are enough to mark the whole route as dynamic. This is because we can't know if // you are going to use those searchParams in an update on the client so we can't infer // anything about your lack of use during SSR. In the future we will update searchParams // written to the client to actually derive those params from location and thus not @@ -1395,7 +1396,7 @@ describe('dynamic-io', () => { } }) - it('should render pages that access params synchronouslyin a server component when not prebuilt', async () => { + it('should render pages that access params synchronously in a server component when not prebuilt', async () => { expect(getLines('In route /params')).toEqual([]) let $ = await next.render$( '/params/semantics/one/run/sync/layout-access/server' @@ -1556,7 +1557,7 @@ describe('dynamic-io', () => { expect(getLines('In route /params')).toEqual([]) } else { if (WITH_PPR) { - // With PPR fallbacks the first visit is fulluy prerendered + // With PPR fallbacks the first visit is fully prerendered // because has-checking doesn't postpone even with ppr fallbacks expect($('#layout').text()).toBe('at buildtime') expect($('#lowcard').text()).toBe('at buildtime') @@ -1591,7 +1592,7 @@ describe('dynamic-io', () => { expect(getLines('In route /params')).toEqual([]) } else { if (WITH_PPR) { - // With PPR fallbacks the first visit is fulluy prerendered + // With PPR fallbacks the first visit is fully prerendered // because has-checking doesn't postpone even with ppr fallbacks expect($('#layout').text()).toBe('at buildtime') expect($('#lowcard').text()).toBe('at buildtime') @@ -1662,7 +1663,7 @@ describe('dynamic-io', () => { expect(getLines('In route /params')).toEqual([]) } else { // When dynamicIO is on and PPR is off the search params passed to a client page - // are enough to mark the whole route as dynamic. This is becuase we can't know if + // are enough to mark the whole route as dynamic. This is because we can't know if // you are going to use those searchParams in an update on the client so we can't infer // anything about your lack of use during SSR. In the future we will update searchParams // written to the client to actually derive those params from location and thus not @@ -1690,7 +1691,7 @@ describe('dynamic-io', () => { expect(getLines('In route /params')).toEqual([]) } else { if (WITH_PPR) { - // With PPR fallbacks the first visit is fulluy prerendered + // With PPR fallbacks the first visit is fully prerendered // because has-checking doesn't postpone even with ppr fallbacks expect($('#layout').text()).toBe('at buildtime') expect($('#lowcard').text()).toBe('at buildtime') @@ -1739,7 +1740,7 @@ describe('dynamic-io', () => { expect(getLines('In route /params')).toEqual([]) } else { // When dynamicIO is on and PPR is off the search params passed to a client page - // are enough to mark the whole route as dynamic. This is becuase we can't know if + // are enough to mark the whole route as dynamic. This is because we can't know if // you are going to use those searchParams in an update on the client so we can't infer // anything about your lack of use during SSR. In the future we will update searchParams // written to the client to actually derive those params from location and thus not From 77f7792d4b6657b2ab583ef222043dcaa2637c07 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 23 Sep 2024 07:20:53 -0700 Subject: [PATCH 14/14] Adds tests for parallel routes and dynamicIO --- .../app/cases/parallel/@slot/cookies/page.tsx | 16 ++++ .../cases/parallel/@slot/microtask/page.tsx | 11 +++ .../cases/parallel/@slot/no-store/page.tsx | 13 +++ .../app/cases/parallel/@slot/static/page.tsx | 10 +++ .../app/cases/parallel/@slot/task/page.tsx | 11 +++ .../app/cases/parallel/cookies/page.tsx | 10 +++ .../dynamic-io/app/cases/parallel/layout.tsx | 16 ++++ .../app/cases/parallel/microtask/page.tsx | 10 +++ .../app/cases/parallel/no-store/page.tsx | 10 +++ .../app/cases/parallel/static/page.tsx | 10 +++ .../app/cases/parallel/task/page.tsx | 10 +++ .../app/cookies/exercise/async/page.tsx | 2 +- .../{commponents.tsx => components.tsx} | 0 .../app/cookies/exercise/sync/page.tsx | 2 +- .../e2e/app-dir/dynamic-io/dynamic-io.test.ts | 88 +++++++++++++++++++ 15 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/cookies/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/microtask/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/no-store/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/static/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/task/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/cookies/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/layout.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/microtask/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/no-store/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/static/page.tsx create mode 100644 test/e2e/app-dir/dynamic-io/app/cases/parallel/task/page.tsx rename test/e2e/app-dir/dynamic-io/app/cookies/exercise/{commponents.tsx => components.tsx} (100%) diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/cookies/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/cookies/page.tsx new file mode 100644 index 0000000000000..3f2a3884c4dca --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/cookies/page.tsx @@ -0,0 +1,16 @@ +import { cookies } from 'next/headers' + +import { getSentinelValue } from '../../../../getSentinelValue' + +export default async function Page() { + const sentinel = (await cookies()).get('sentinel') + return ( + <> +

      + cookie slot (sentinel):{' '} + {sentinel ? sentinel.value : 'no cookie'} +

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/microtask/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/microtask/page.tsx new file mode 100644 index 0000000000000..2f7e7ef218985 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/microtask/page.tsx @@ -0,0 +1,11 @@ +import { getSentinelValue } from '../../../../getSentinelValue' + +export default async function Page() { + await 1 + return ( + <> +

      microtask slot

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/no-store/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/no-store/page.tsx new file mode 100644 index 0000000000000..dbdf1fa30e110 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/no-store/page.tsx @@ -0,0 +1,13 @@ +import { unstable_noStore as noStore } from 'next/cache' + +import { getSentinelValue } from '../../../../getSentinelValue' + +export default async function Page() { + noStore() + return ( + <> +

      noStore slot

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/static/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/static/page.tsx new file mode 100644 index 0000000000000..68feb0f3f2dfb --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/static/page.tsx @@ -0,0 +1,10 @@ +import { getSentinelValue } from '../../../../getSentinelValue' + +export default async function Page() { + return ( + <> +

      static slot

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/task/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/task/page.tsx new file mode 100644 index 0000000000000..1bae92cae1d37 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/@slot/task/page.tsx @@ -0,0 +1,11 @@ +import { getSentinelValue } from '../../../../getSentinelValue' + +export default async function Page() { + await new Promise((r) => setTimeout(r, 0)) + return ( + <> +

      task slot

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/cookies/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/cookies/page.tsx new file mode 100644 index 0000000000000..4383916b89a88 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/cookies/page.tsx @@ -0,0 +1,10 @@ +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> +

      static children

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/layout.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/layout.tsx new file mode 100644 index 0000000000000..76cb3b7cf43d2 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/layout.tsx @@ -0,0 +1,16 @@ +import { Suspense } from 'react' + +export default async function Layout({ children, slot }) { + return ( + <> +
      +

      slot

      + {slot} +
      +
      +

      children

      + {children} +
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/microtask/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/microtask/page.tsx new file mode 100644 index 0000000000000..4383916b89a88 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/microtask/page.tsx @@ -0,0 +1,10 @@ +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> +

      static children

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/no-store/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/no-store/page.tsx new file mode 100644 index 0000000000000..4383916b89a88 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/no-store/page.tsx @@ -0,0 +1,10 @@ +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> +

      static children

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/static/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/static/page.tsx new file mode 100644 index 0000000000000..4383916b89a88 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/static/page.tsx @@ -0,0 +1,10 @@ +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> +

      static children

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cases/parallel/task/page.tsx b/test/e2e/app-dir/dynamic-io/app/cases/parallel/task/page.tsx new file mode 100644 index 0000000000000..4383916b89a88 --- /dev/null +++ b/test/e2e/app-dir/dynamic-io/app/cases/parallel/task/page.tsx @@ -0,0 +1,10 @@ +import { getSentinelValue } from '../../../getSentinelValue' + +export default async function Page() { + return ( + <> +

      static children

      +
      {getSentinelValue()}
      + + ) +} diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx index 034e2acdf4cd7..59125a01bf25b 100644 --- a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/async/page.tsx @@ -1,7 +1,7 @@ import { cookies } from 'next/headers' import { getSentinelValue } from '../../../getSentinelValue' -import { AllComponents } from '../commponents' +import { AllComponents } from '../components' export default async function Page() { const allCookies = await cookies() diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/commponents.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/components.tsx similarity index 100% rename from test/e2e/app-dir/dynamic-io/app/cookies/exercise/commponents.tsx rename to test/e2e/app-dir/dynamic-io/app/cookies/exercise/components.tsx diff --git a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx index d31f9f622fd6d..f950cc191221b 100644 --- a/test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx +++ b/test/e2e/app-dir/dynamic-io/app/cookies/exercise/sync/page.tsx @@ -1,7 +1,7 @@ import { cookies, type UnsafeUnwrappedCookies } from 'next/headers' import { getSentinelValue } from '../../../getSentinelValue' -import { AllComponents } from '../commponents' +import { AllComponents } from '../components' export default async function Page() { const allCookies = cookies() as unknown as UnsafeUnwrappedCookies diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts index f66bb0c0c5dcc..6b655621bf15c 100644 --- a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts @@ -515,4 +515,92 @@ describe('dynamic-io', () => { } }) } + + it('can prerender pages with parallel routes that are static', async () => { + const $ = await next.render$('/cases/parallel/static', {}) + + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page-slot').text()).toBe('at buildtime') + expect($('#page-children').text()).toBe('at buildtime') + } + }) + + it('can prerender pages with parallel routes that resolve in a microtask', async () => { + const $ = await next.render$('/cases/parallel/microtask', {}) + + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page-slot').text()).toBe('at buildtime') + expect($('#page-children').text()).toBe('at buildtime') + } + }) + + it('does not prerender pages with parallel routes that resolve in a task', async () => { + const $ = await next.render$('/cases/parallel/task', {}) + + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } else { + if (WITH_PPR) { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at buildtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } + } + }) + + it('does not prerender pages with parallel routes that uses a dynamic API', async () => { + let $ = await next.render$('/cases/parallel/no-store', {}) + + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } else { + if (WITH_PPR) { + // When using a sync dynamic API like noStore the prerender aborts + // before the shell can complete + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } + } + + $ = await next.render$('/cases/parallel/cookies', {}) + + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } else { + if (WITH_PPR) { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at buildtime') + } else { + expect($('#layout').text()).toBe('at runtime') + expect($('#page-slot').text()).toBe('at runtime') + expect($('#page-children').text()).toBe('at runtime') + } + } + }) })