mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
[Instant] Build-time validation (#90964)
Initial version of build-time instant validation.
During prerendering, if we have `unstable_instant` configs, we perform a
complete staged render of the page (including dynamic data). This render
mocks request data (`params`/`searchParams`/`cookies`/`headers`) using
the `samples` provided in `unstable_instant`. Then, we use the staged
chunks to run instant validation in mostly the same way that we would in
dev. Most changes in the validation flow are related to samples, which
also need to be passed into the client render to mock APIs like
`useParams`.
### Providing Mock data
We run everything in a fresh workStore+workUnitStore to (hopefully)
avoid any data from the prerender from sneaking in. This is quite
involved.
A big part of the trickiness is that if you do e.g. `(await
cookies()).get('myCookie')` but the `samples` don't contain `myCookie`,
we want the validation to error and require explicitly saying what state
`myCookie` is in.
If it's available, use
```ts
export const unstable_instant = {
// ...
samples: { cookies: [{ name: "myCookie", value: "someValue" }] }
}
```
If it's not available, use
```ts
export const unstable_instant = {
// ...
samples: { cookies: [{ name: "myCookie", value: null }] }
}
```
This explicitness makes it harder to e.g. unintentionally validate a
logged-out state.
Note that even validating a `prefetch: 'static'` page might require
samples, because we need the shared parent for the navigation to be
(mostly) complete. We might be able to relax this in the future, but
it's not straightforward.
Another issue is that a segment might access a missing sample in a
"benign" way, i.e. where it wouldn't block validation (or be inside a
suspense boundary). Ideally we wouldn't require providing a sample for
usages like that, but currently we do.
### Instrumenting APIs to detect missing samples
All request APIs are instrumented in this way.
- `params` throw when accessing a property that was not declared in
`samples`. same for the object from `useParams()`. `in` is allowed
because we know the shape of the params object statically, based on the
routing structure.
- `searchParams` throw when accessing a property that was not declared
in `samples` and `in` checks.
- objects returned from `cookies()`, `headers()`, `useSearchPrams()`
throw on `get()` and `has()`.
There's a lot of tests for this behavior in `instant-validation-build`.
One gap is that iteration/enumeration is currently not instrumented.
We're punting on it for now and will revisit in a follow-up.
### Relation to prerender
We kick off the validation after the prerender, in
`renderToHTMLOrFlightImpl`. Ideally, we should kick this off somewhere
higher up, separately from the prerender itself, but we're leaving that
for future work.
Note that if we do multiple prerenders for the same page (e.g. due to
`generateStaticParams`, we'll only run validation for the first
prerender for that route. `generateStaticParams` is not currently
integrated into the validation at all, i.e. we don't use those params to
validate, so this is safe. All params need to be provided via `samples`.
This will likely change in the future, but we're not sure how yet.
### Disabling build validation
Build-time instant validation is experimental, and has more potential
problems to deal with than dev validation. To allow incremental rollout
and testing, we allow disabling it per instant config:
```ts
export const unstable_instant = {
unstable_disableBuildValidation: true
// ...
}
```
for parity, there's also `unstable_disableBuildValidation`, but we don't
expect that to see much use.
This commit is contained in:
@@ -57,6 +57,7 @@ fn is_error_class_name(name: &str) -> bool {
|
||||
|| name == "ExportError"
|
||||
|| name == "FatalError"
|
||||
|| name == "ImageError"
|
||||
|| name == "InstantValidationError"
|
||||
|| name == "InvariantError"
|
||||
|| name == "ModuleBuildError"
|
||||
|| name == "NestedMiddlewareError"
|
||||
|
||||
@@ -1088,5 +1088,32 @@
|
||||
"1087": "Failed to parse \"%s\":\\n%s%s",
|
||||
"1088": "Expected RSC navigation response to have a body",
|
||||
"1089": "\\`experimental.cachedNavigations\\` requires \\`cacheComponents\\` to be enabled. Please update your %s accordingly.",
|
||||
"1090": "`css.lightning.featureNamesToMask` is not supported by the wasm bindings."
|
||||
"1090": "`css.lightning.featureNamesToMask` is not supported by the wasm bindings.",
|
||||
"1091": "Route \"%s\" accessed searchParam \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`searchParams\\` object, or \\`{ \"%s\": undefined }\\` if it should be absent.",
|
||||
"1092": "Draft mode cannot be enabled during build-time instant validation.",
|
||||
"1093": "Build-time instant validation failed for route \"%s\"%s:\\n%s",
|
||||
"1094": "Draft mode cannot be disabled during build-time instant validation.",
|
||||
"1095": "Route \"%s\" accessed param \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
|
||||
"1096": "Route \"%s\" accessed %s \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`%s\\` array%s",
|
||||
"1097": "An unexpected error occcured during instant validation",
|
||||
"1098": "Route \"%s\" accessed searchParam \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`searchParams\\` object, or \\`{ \"%s\": null }\\` if it should be absent.",
|
||||
"1099": "createPrerenderParamsForClientSegment should not be called in validation contexts.",
|
||||
"1100": "Stopping prerender due to instant validation errors.",
|
||||
"1101": "createServerParamsForServerSegment should not be called in client contexts.",
|
||||
"1102": "Cannot call \\`%s\\` during instant validation unless all params are provided in \\`samples\\`",
|
||||
"1103": "Expected sample param value for segment '%s' to a string, got %s",
|
||||
"1104": "Expected sample param value for segment '%s' to be an array of strings, got %s",
|
||||
"1105": "Route \"%s\" called usePathname() but param%s %s %s not defined in the \\`samples\\` of \\`unstable_instant\\`. usePathname() requires all route params to be provided.",
|
||||
"1106": "Not implemented: Validation of interception routes",
|
||||
"1107": "Invalid sample: Defining cookies via a \"cookie\" header is not supported. Use `cookies: [{ name: ..., value: ...}]` instead.",
|
||||
"1108": "Expected sample param value for segment '%s' to be a string, got %s",
|
||||
"1109": "Route \"%s\" called %s but param%s %s %s not defined in the \\`samples\\` of \\`unstable_instant\\`. %s requires all route params to be provided.",
|
||||
"1110": "Expected to have a workUnitStore that provides validationSampleTracking",
|
||||
"1111": "Invalid sample: Defining cookies via a \"cookie\" header is not supported. Use `cookies: [{ name: ..., value: ... }]` instead.",
|
||||
"1112": "Route \"%s\": Could not validate \\`unstable_instant\\` because an error prevented the target segment from rendering.",
|
||||
"1113": "Route \"%s\": Could not validate \\`unstable_instant\\` because an error prevented the page from rendering.",
|
||||
"1114": "Route \"%s\" accessed root param \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
|
||||
"1115": "Route \"%s\" accessed cookie \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`cookies\\` array, or \\`{ name: \"%s\", value: null }\\` if it should be absent.",
|
||||
"1116": "Route \"%s\" accessed header \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`headers\\` array, or \\`[\"%s\", null]\\` if it should be absent.",
|
||||
"1117": "Instant validation boundaries should never appear in browser bundles."
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -4,19 +4,17 @@ import { formatZodError } from '../../../shared/lib/zod'
|
||||
const CookieSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
httpOnly: z.boolean().optional(),
|
||||
path: z.string().optional(),
|
||||
value: z.string().or(z.null()),
|
||||
})
|
||||
.strict()
|
||||
|
||||
const RuntimeSampleSchema = z
|
||||
.object({
|
||||
cookies: z.array(CookieSchema).optional(),
|
||||
headers: z.array(z.tuple([z.string(), z.string()])).optional(),
|
||||
headers: z.array(z.tuple([z.string(), z.string().or(z.null())])).optional(),
|
||||
params: z.record(z.union([z.string(), z.array(z.string())])).optional(),
|
||||
searchParams: z
|
||||
.record(z.union([z.string(), z.array(z.string()), z.undefined()]))
|
||||
.record(z.union([z.string(), z.array(z.string()), z.null()]))
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
@@ -24,8 +22,11 @@ const RuntimeSampleSchema = z
|
||||
const InstantConfigStaticSchema = z
|
||||
.object({
|
||||
prefetch: z.literal('static'),
|
||||
samples: z.array(RuntimeSampleSchema).min(1).optional(),
|
||||
from: z.array(z.string()).optional(),
|
||||
unstable_disableValidation: z.boolean().optional(),
|
||||
unstable_disableValidation: z.literal(true).optional(),
|
||||
unstable_disableDevValidation: z.literal(true).optional(),
|
||||
unstable_disableBuildValidation: z.literal(true).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -34,7 +35,9 @@ const InstantConfigRuntimeSchema = z
|
||||
prefetch: z.literal('runtime'),
|
||||
samples: z.array(RuntimeSampleSchema).min(1),
|
||||
from: z.array(z.string()).optional(),
|
||||
unstable_disableValidation: z.boolean().optional(),
|
||||
unstable_disableValidation: z.literal(true).optional(),
|
||||
unstable_disableDevValidation: z.literal(true).optional(),
|
||||
unstable_disableBuildValidation: z.literal(true).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -47,6 +50,7 @@ const InstantConfigSchema = z.union([
|
||||
])
|
||||
|
||||
export type Instant = InstantConfigStatic | InstantConfigRuntime | false
|
||||
|
||||
export type InstantConfigForTypeCheckInternal = __GenericInstantConfig | Instant
|
||||
// the __GenericPrefetch type is used to avoid type widening issues with
|
||||
// our choice to make exports the medium for programming a Next.js application
|
||||
@@ -56,41 +60,46 @@ export type InstantConfigForTypeCheckInternal = __GenericInstantConfig | Instant
|
||||
// delete the __GenericPrefetch member.
|
||||
interface __GenericInstantConfig {
|
||||
prefetch: string
|
||||
samples?: Array<WideRuntimeSample>
|
||||
samples?: Array<WideInstantSample>
|
||||
from?: string[]
|
||||
unstable_disableValidation?: boolean
|
||||
unstable_disableDevValidation?: boolean
|
||||
unstable_disableBuildValidation?: boolean
|
||||
}
|
||||
|
||||
interface InstantConfigStatic {
|
||||
prefetch: 'static'
|
||||
samples?: Array<InstantSample>
|
||||
from?: string[]
|
||||
unstable_disableValidation?: boolean
|
||||
unstable_disableValidation?: true
|
||||
unstable_disableDevValidation?: true
|
||||
unstable_disableBuildValidation?: true
|
||||
}
|
||||
|
||||
interface InstantConfigRuntime {
|
||||
prefetch: 'runtime'
|
||||
samples: Array<RuntimeSample>
|
||||
samples: Array<InstantSample>
|
||||
from?: string[]
|
||||
unstable_disableValidation?: boolean
|
||||
unstable_disableValidation?: true
|
||||
unstable_disableDevValidation?: true
|
||||
unstable_disableBuildValidation?: true
|
||||
}
|
||||
|
||||
type WideRuntimeSample = {
|
||||
cookies?: RuntimeSample['cookies']
|
||||
type WideInstantSample = {
|
||||
cookies?: InstantSample['cookies']
|
||||
headers?: Array<string[]>
|
||||
params?: RuntimeSample['params']
|
||||
searchParams?: RuntimeSample['searchParams']
|
||||
params?: InstantSample['params']
|
||||
searchParams?: InstantSample['searchParams']
|
||||
}
|
||||
|
||||
type RuntimeSample = {
|
||||
export type InstantSample = {
|
||||
cookies?: Array<{
|
||||
name: string
|
||||
value: string
|
||||
httpOnly?: boolean
|
||||
path?: string
|
||||
value: string | null
|
||||
}>
|
||||
headers?: Array<[string, string]>
|
||||
headers?: Array<[string, string | null]>
|
||||
params?: { [key: string]: string | string[] }
|
||||
searchParams?: { [key: string]: string | string[] | undefined }
|
||||
searchParams?: { [key: string]: string | string[] | null }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
// This facade ensures that the boundary code is DCE'd in browser bundles.
|
||||
//
|
||||
// It also exists to satisfy `browser-chunks.test.ts`, which looks for
|
||||
// references to code in `packages/next/src/server` in browser bundles and errors if it finds any.
|
||||
// A "use client" module seems to always have always have an entry in the browser bundle,
|
||||
// so this module cannot be colocated with the rest of the instant validation code,
|
||||
// because it ends up looking like it's importing server code in the browser
|
||||
// even though all the server code inside is actually DCE'd.
|
||||
|
||||
const {
|
||||
InstantValidationBoundaryContext,
|
||||
PlaceValidationBoundaryBelowThisLevel,
|
||||
RenderValidationBoundaryAtThisLevel,
|
||||
} =
|
||||
typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS
|
||||
? (require('../../../server/app-render/instant-validation/boundary-impl') as typeof import('../../../server/app-render/instant-validation/boundary-impl'))
|
||||
: ({} as typeof import('../../../server/app-render/instant-validation/boundary-impl'))
|
||||
|
||||
export {
|
||||
InstantValidationBoundaryContext,
|
||||
PlaceValidationBoundaryBelowThisLevel,
|
||||
RenderValidationBoundaryAtThisLevel,
|
||||
}
|
||||
@@ -695,13 +695,9 @@ export default function OuterLayoutRouter({
|
||||
}
|
||||
|
||||
let maybeValidationBoundaryId: string | null = null
|
||||
if (
|
||||
typeof window === 'undefined' &&
|
||||
process.env.__NEXT_CACHE_COMPONENTS &&
|
||||
process.env.__NEXT_DEV_SERVER
|
||||
) {
|
||||
if (typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS) {
|
||||
const { InstantValidationBoundaryContext } =
|
||||
require('../../server/app-render/instant-validation/boundary') as typeof import('../../server/app-render/instant-validation/boundary')
|
||||
require('./instant-validation/boundary') as typeof import('./instant-validation/boundary')
|
||||
maybeValidationBoundaryId = use(InstantValidationBoundaryContext)
|
||||
}
|
||||
|
||||
@@ -838,11 +834,10 @@ export default function OuterLayoutRouter({
|
||||
if (
|
||||
typeof window === 'undefined' &&
|
||||
process.env.__NEXT_CACHE_COMPONENTS &&
|
||||
process.env.__NEXT_DEV_SERVER &&
|
||||
typeof maybeValidationBoundaryId === 'string'
|
||||
) {
|
||||
const { RenderValidationBoundaryAtThisLevel } =
|
||||
require('../../server/app-render/instant-validation/boundary') as typeof import('../../server/app-render/instant-validation/boundary')
|
||||
require('./instant-validation/boundary') as typeof import('./instant-validation/boundary')
|
||||
templateValue = (
|
||||
<RenderValidationBoundaryAtThisLevel id={maybeValidationBoundaryId}>
|
||||
{templateValue}
|
||||
|
||||
@@ -21,11 +21,9 @@ function hasFallbackRouteParams(): boolean {
|
||||
case 'prerender':
|
||||
case 'prerender-client':
|
||||
case 'prerender-ppr':
|
||||
case 'validation-client':
|
||||
const fallbackParams = workUnitStore.fallbackRouteParams
|
||||
return fallbackParams ? fallbackParams.size > 0 : false
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation): in build, this depends on samples
|
||||
return false
|
||||
case 'prerender-legacy':
|
||||
case 'request':
|
||||
case 'prerender-runtime':
|
||||
|
||||
@@ -32,6 +32,15 @@ const useDynamicSearchParams =
|
||||
).useDynamicSearchParams
|
||||
: undefined
|
||||
|
||||
const {
|
||||
instrumentParamsForClientValidation,
|
||||
instrumentSearchParamsForClientValidation,
|
||||
expectCompleteParamsInClientValidation,
|
||||
} =
|
||||
typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS
|
||||
? (require('../../server/app-render/instant-validation/instant-samples-client') as typeof import('../../server/app-render/instant-validation/instant-samples-client'))
|
||||
: {}
|
||||
|
||||
/**
|
||||
* A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook
|
||||
* that lets you *read* the current URL's search parameters.
|
||||
@@ -71,6 +80,16 @@ export function useSearchParams(): ReadonlyURLSearchParams {
|
||||
return new ReadonlyURLSearchParams(searchParams)
|
||||
}, [searchParams])
|
||||
|
||||
// During build-time instant validation, wrap with an proxy
|
||||
// so that accessing undeclared search params throws an error.
|
||||
if (
|
||||
typeof window === 'undefined' &&
|
||||
process.env.__NEXT_CACHE_COMPONENTS &&
|
||||
readonlySearchParams
|
||||
) {
|
||||
return instrumentSearchParamsForClientValidation!(readonlySearchParams)
|
||||
}
|
||||
|
||||
// Instrument with Suspense DevTools (dev-only)
|
||||
if (process.env.NODE_ENV !== 'production' && 'use' in React) {
|
||||
const navigationPromises = use(NavigationPromisesContext)
|
||||
@@ -107,6 +126,17 @@ export function usePathname(): string {
|
||||
// will add a new overload that changes the return type to include `null`.
|
||||
const pathname = useContext(PathnameContext) as string
|
||||
|
||||
// During build-time instant validation, error if fallback params exist
|
||||
// because usePathname() can't return a sensible value without all params.
|
||||
if (
|
||||
typeof window === 'undefined' &&
|
||||
process.env.__NEXT_CACHE_COMPONENTS &&
|
||||
pathname
|
||||
) {
|
||||
expectCompleteParamsInClientValidation!('usePathname()')
|
||||
return pathname
|
||||
}
|
||||
|
||||
// Instrument with Suspense DevTools (dev-only)
|
||||
if (process.env.NODE_ENV !== 'production' && 'use' in React) {
|
||||
const navigationPromises = use(NavigationPromisesContext)
|
||||
@@ -175,6 +205,16 @@ export function useParams<T extends Params = Params>(): T {
|
||||
|
||||
const params = useContext(PathParamsContext) as T
|
||||
|
||||
// During build-time instant validation, wrap with a proxy
|
||||
// so that accessing undeclared params throws an error.
|
||||
if (
|
||||
typeof window === 'undefined' &&
|
||||
process.env.__NEXT_CACHE_COMPONENTS &&
|
||||
params
|
||||
) {
|
||||
return instrumentParamsForClientValidation!(params)
|
||||
}
|
||||
|
||||
// Instrument with Suspense DevTools (dev-only)
|
||||
if (process.env.NODE_ENV !== 'production' && 'use' in React) {
|
||||
const navigationPromises = use(NavigationPromisesContext)
|
||||
@@ -221,6 +261,16 @@ export function useSelectedLayoutSegments(
|
||||
// @ts-expect-error This only happens in `pages`. Type is overwritten in navigation.d.ts
|
||||
if (!context) return null
|
||||
|
||||
// During build-time instant validation, error if fallback params exist
|
||||
// because useSelectedLayoutSegments() can't return a sensible value without all params.
|
||||
if (
|
||||
typeof window === 'undefined' &&
|
||||
process.env.__NEXT_CACHE_COMPONENTS &&
|
||||
context
|
||||
) {
|
||||
expectCompleteParamsInClientValidation!('useSelectedLayoutSegments()')
|
||||
}
|
||||
|
||||
// Instrument with Suspense DevTools (dev-only)
|
||||
if (process.env.NODE_ENV !== 'production' && 'use' in React) {
|
||||
const navigationPromises = use(NavigationPromisesContext)
|
||||
@@ -264,6 +314,12 @@ export function useSelectedLayoutSegment(
|
||||
const navigationPromises = useContext(NavigationPromisesContext)
|
||||
const selectedLayoutSegments = useSelectedLayoutSegments(parallelRouteKey)
|
||||
|
||||
// During build-time instant validation, error if fallback params exist
|
||||
// because useSelectedLayoutSegment() can't return a sensible value without all params.
|
||||
if (typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS) {
|
||||
expectCompleteParamsInClientValidation!('useSelectedLayoutSegment()')
|
||||
}
|
||||
|
||||
// Instrument with Suspense DevTools (dev-only)
|
||||
if (
|
||||
process.env.NODE_ENV !== 'production' &&
|
||||
|
||||
@@ -733,12 +733,21 @@ async function exportAppImpl(
|
||||
const finalPhaseExportPaths: ExportPathEntry[] = []
|
||||
|
||||
if (renderOpts.cacheComponents) {
|
||||
// Only run instant validation once per route, even if multiple param sets from generateStaticParams exist.
|
||||
const routesWithInstantValidation = new Set<string>()
|
||||
|
||||
for (const exportPath of allExportPaths) {
|
||||
if (exportPath._allowEmptyStaticShell) {
|
||||
finalPhaseExportPaths.push(exportPath)
|
||||
} else {
|
||||
initialPhaseExportPaths.push(exportPath)
|
||||
}
|
||||
|
||||
const route = exportPath.page
|
||||
if (!routesWithInstantValidation.has(route)) {
|
||||
exportPath._runInstantValidation = true
|
||||
routesWithInstantValidation.add(route)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
initialPhaseExportPaths = allExportPaths
|
||||
|
||||
@@ -117,6 +117,9 @@ async function exportPageImpl(
|
||||
// is generated while rendering using PPR and Cache Components.
|
||||
_allowEmptyStaticShell: allowEmptyStaticShell = false,
|
||||
|
||||
// When true, attempt to run build-time instant validation for this export path.
|
||||
_runInstantValidation: runInstantValidation = false,
|
||||
|
||||
// Pull the original query out.
|
||||
query: originalQuery = {},
|
||||
} = exportPath
|
||||
@@ -269,6 +272,7 @@ async function exportPageImpl(
|
||||
// If it's dynamic, then it can be handled when request hits the route.
|
||||
serveStreamingMetadata: true,
|
||||
allowEmptyStaticShell,
|
||||
runInstantValidation,
|
||||
experimental: {
|
||||
...commonRenderOpts.experimental,
|
||||
isRoutePPREnabled,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import { isPrerenderInterruptedError } from './dynamic-rendering'
|
||||
import { getProperError } from '../../lib/is-error'
|
||||
import { createDigestWithErrorCode } from '../../lib/error-telemetry-utils'
|
||||
import { isReactLargeShellError } from './react-large-shell-error'
|
||||
import { isInstantValidationError } from './instant-validation/instant-validation-error'
|
||||
|
||||
declare global {
|
||||
var __next_log_error__: undefined | ((err: unknown) => void)
|
||||
@@ -46,6 +47,8 @@ export function getDigestForWellKnownError(error: unknown): string | undefined {
|
||||
// If this is a prerender interrupted error, we don't need to log the error.
|
||||
if (isPrerenderInterruptedError(error)) return error.digest
|
||||
|
||||
if (isInstantValidationError(error)) return error.digest
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
|
||||
import { InvariantError } from '../../shared/lib/invariant-error'
|
||||
import { INSTANT_VALIDATION_BOUNDARY_NAME } from './instant-validation/boundary-constants'
|
||||
import type { ValidationBoundaryTracking } from './instant-validation/boundary-tracking'
|
||||
import type { InstantValidationSampleTracking } from './instant-validation/instant-samples'
|
||||
|
||||
const hasPostpone = typeof React.unstable_postpone === 'function'
|
||||
|
||||
@@ -618,6 +619,11 @@ export function useDynamicRouteParams(expression: string) {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'validation-client': {
|
||||
// Don't check fallbackRouteParams here. We handle params that weren't
|
||||
// provided in the samples using a proxy that throws when accessed.
|
||||
break
|
||||
}
|
||||
case 'prerender-runtime':
|
||||
throw new InvariantError(
|
||||
`\`${expression}\` was called during a runtime prerender. Next.js should be preventing ${expression} from being included in server components statically, but did not in this case.`
|
||||
@@ -627,9 +633,6 @@ export function useDynamicRouteParams(expression: string) {
|
||||
throw new InvariantError(
|
||||
`\`${expression}\` was called inside a cache scope. Next.js should be preventing ${expression} from being included in server components statically, but did not in this case.`
|
||||
)
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation): in build, this depends on samples
|
||||
break
|
||||
case 'prerender-legacy':
|
||||
case 'request':
|
||||
case 'unstable-cache':
|
||||
@@ -655,7 +658,8 @@ export function useDynamicSearchParams(expression: string) {
|
||||
|
||||
switch (workUnitStore.type) {
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation): in build, this depends on samples
|
||||
// During instant validation we try to behave as close to client as possible,
|
||||
// so this shouldn't hang during SSR.
|
||||
return
|
||||
case 'prerender-client': {
|
||||
React.use(
|
||||
@@ -722,7 +726,7 @@ const hasViewportRegex = new RegExp(
|
||||
)
|
||||
const hasOutletRegex = new RegExp(`\\n\\s+at ${OUTLET_BOUNDARY_NAME}[\\n\\s]`)
|
||||
|
||||
const hasPrefetchValidationBoundaryRegex = new RegExp(
|
||||
const hasInstantValidationBoundaryRegex = new RegExp(
|
||||
`\\n\\s+at ${INSTANT_VALIDATION_BOUNDARY_NAME}[\\n\\s]`
|
||||
)
|
||||
|
||||
@@ -853,7 +857,7 @@ export function trackDynamicHoleInNavigation(
|
||||
}
|
||||
|
||||
const boundaryLocation =
|
||||
hasPrefetchValidationBoundaryRegex.exec(componentStack)
|
||||
hasInstantValidationBoundaryRegex.exec(componentStack)
|
||||
if (!boundaryLocation) {
|
||||
// We don't see the validation boundary in the component stack,
|
||||
// so this hole must be coming from a shared parent.
|
||||
@@ -941,16 +945,42 @@ export function trackDynamicHoleInNavigation(
|
||||
}
|
||||
|
||||
export function trackThrownErrorInNavigation(
|
||||
workStore: WorkStore,
|
||||
dynamicValidation: InstantValidationState,
|
||||
error: unknown,
|
||||
thrownError: unknown,
|
||||
componentStack: string
|
||||
) {
|
||||
// If we see a validation boundary on the component stack,
|
||||
// this error couldn't have blocked a validation boundary from rendering.
|
||||
if (hasPrefetchValidationBoundaryRegex.test(componentStack)) {
|
||||
return
|
||||
const boundaryLocation =
|
||||
hasInstantValidationBoundaryRegex.exec(componentStack)
|
||||
if (!boundaryLocation) {
|
||||
// There's no validation boundary on the component stack.
|
||||
// This error may have blocked a boundary from rendering.
|
||||
dynamicValidation.thrownErrorsOutsideBoundary.push(thrownError)
|
||||
} else {
|
||||
// There's validation boundary on the component stack,
|
||||
// so we know this error didn't block a validation boundary from rendering.
|
||||
// However, this error might be hiding be hiding dynamic content that would
|
||||
// cause validation to fail.
|
||||
const suspenseLocation = hasSuspenseRegex.exec(componentStack)
|
||||
if (suspenseLocation) {
|
||||
if (suspenseLocation.index < boundaryLocation.index) {
|
||||
// There's a Suspense below the validation boundary but above this error's location.
|
||||
// This subtree can't fail instant validation because any potential
|
||||
// dynamic holes would be guarded by the Suspense anyway,
|
||||
// so we can allow this.
|
||||
return
|
||||
} else {
|
||||
// invalid - fallthrough
|
||||
}
|
||||
}
|
||||
const message = `Route "${workStore.route}": Could not validate \`unstable_instant\` because an error prevented the target segment from rendering.`
|
||||
const error = addErrorContext(
|
||||
new Error(message, { cause: thrownError }),
|
||||
componentStack,
|
||||
null // TODO(instant-validation-build): conflicting use of cause
|
||||
)
|
||||
dynamicValidation.validationPreventingErrors.push(error)
|
||||
}
|
||||
dynamicValidation.thrownErrorsOutsideBoundary.push(error)
|
||||
}
|
||||
|
||||
export function trackDynamicHoleInRuntimeShell(
|
||||
@@ -1221,8 +1251,17 @@ export function getNavigationDisallowedDynamicReasons(
|
||||
workStore: WorkStore,
|
||||
prelude: PreludeState,
|
||||
dynamicValidation: InstantValidationState,
|
||||
validationSampleTracking: InstantValidationSampleTracking | null,
|
||||
boundaryState: ValidationBoundaryTracking
|
||||
): Array<Error> {
|
||||
// If we have errors related to missing samples, those should take precedence over everything else.
|
||||
if (validationSampleTracking) {
|
||||
const { missingSampleErrors } = validationSampleTracking
|
||||
if (missingSampleErrors.length > 0) {
|
||||
return missingSampleErrors
|
||||
}
|
||||
}
|
||||
|
||||
const { validationPreventingErrors } = dynamicValidation
|
||||
if (validationPreventingErrors.length > 0) {
|
||||
return validationPreventingErrors
|
||||
|
||||
@@ -42,10 +42,16 @@ export { Postpone } from './rsc/postpone'
|
||||
export { taintObjectReference } from './rsc/taint'
|
||||
export { collectSegmentData } from './collect-segment-data'
|
||||
|
||||
export const InstantValidation =
|
||||
process.env.NODE_ENV === 'development' && process.env.NEXT_RUNTIME !== 'edge'
|
||||
? (require('./instant-validation/instant-validation') as typeof import('./instant-validation/instant-validation'))
|
||||
: undefined
|
||||
export const InstantValidation = () => {
|
||||
if (
|
||||
process.env.NEXT_RUNTIME !== 'edge' &&
|
||||
process.env.__NEXT_CACHE_COMPONENTS
|
||||
) {
|
||||
return require('./instant-validation/instant-validation') as typeof import('./instant-validation/instant-validation')
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
import type { NodeJsPartialHmrUpdate } from '../../build/swc/types'
|
||||
import { workAsyncStorage } from '../app-render/work-async-storage.external'
|
||||
|
||||
+11
-8
@@ -1,11 +1,20 @@
|
||||
/* eslint-disable @next/internal/no-ambiguous-jsx -- React Client */
|
||||
'use client'
|
||||
|
||||
// Do not put a "use client" directive here. Import this module via the shim in
|
||||
// `packages/next/src/client/components/instant-validation/boundary.tsx` instead.
|
||||
// 'use client'
|
||||
|
||||
import { createContext, type ReactNode } from 'react'
|
||||
import { INSTANT_VALIDATION_BOUNDARY_NAME } from './boundary-constants'
|
||||
import { InvariantError } from '../../../shared/lib/invariant-error'
|
||||
import { workUnitAsyncStorage } from '../work-unit-async-storage.external'
|
||||
import type { ValidationBoundaryTracking } from './boundary-tracking'
|
||||
import { workUnitAsyncStorage } from '../work-unit-async-storage.external'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new InvariantError(
|
||||
'Instant validation boundaries should never appear in browser bundles.'
|
||||
)
|
||||
}
|
||||
|
||||
function getValidationBoundaryTracking(): ValidationBoundaryTracking | null {
|
||||
const store = workUnitAsyncStorage.getStore()
|
||||
@@ -39,12 +48,6 @@ const NameSpace = {
|
||||
id: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new InvariantError(
|
||||
'InstantValidationBoundary should only be rendered in SSR'
|
||||
)
|
||||
}
|
||||
|
||||
// Track which boundaries we actually managed to render.
|
||||
const state = getValidationBoundaryTracking()
|
||||
if (state === null) {
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getLayoutOrPageModule } from '../../lib/app-dir-module'
|
||||
import type { LoaderTree } from '../../lib/app-dir-module'
|
||||
import { parseLoaderTree } from '../../../shared/lib/router/utils/parse-loader-tree'
|
||||
import type { AppSegmentConfig } from '../../../build/segment-config/app/app-segment-config'
|
||||
import type {
|
||||
AppSegmentConfig,
|
||||
InstantSample,
|
||||
} from '../../../build/segment-config/app/app-segment-config'
|
||||
import {
|
||||
workAsyncStorage,
|
||||
type WorkStore,
|
||||
@@ -78,25 +81,41 @@ type FoundSegmentWithConfig = {
|
||||
* Checks if any segments in the loader tree have `instant` configs that need validating.
|
||||
* NOTE: Client navigations call this multiple times, so we cache it.
|
||||
* */
|
||||
export const anySegmentNeedsInstantValidation = cacheScopedToWorkStore(
|
||||
async (rootTree: LoaderTree): Promise<boolean> => {
|
||||
const segments = await findSegmentsWithInstantConfig(rootTree)
|
||||
// Shared helper (not exported, not cached — called by the cached wrappers)
|
||||
async function anySegmentNeedsInstantValidation(
|
||||
rootTree: LoaderTree,
|
||||
mode: 'dev' | 'build'
|
||||
): Promise<boolean> {
|
||||
const segments = await findSegmentsWithInstantConfig(rootTree)
|
||||
|
||||
// Check if there's any configs with `prefetch: 'static'` or `mode: 'instant'`.
|
||||
// (If there's only `false`, there's no need to run validation).
|
||||
// If any segment has `unstable_disableValidation`, we skip validation for the whole tree.
|
||||
let needsValidation = false
|
||||
for (const { config } of segments) {
|
||||
if (typeof config === 'object') {
|
||||
if (config.unstable_disableValidation) {
|
||||
return false
|
||||
}
|
||||
// do not short-circuit, some other segment might still have `unstable_disableValidation`
|
||||
needsValidation = true
|
||||
// Check if there's any configs with `prefetch: 'static'` or `mode: 'instant'`.
|
||||
// (If there's only `false`, there's no need to run validation).
|
||||
// If any segment has `unstable_disableValidation`, we skip validation for the whole tree.
|
||||
let needsValidation = false
|
||||
for (const { config } of segments) {
|
||||
if (typeof config === 'object') {
|
||||
if (
|
||||
config.unstable_disableValidation === true ||
|
||||
(mode === 'dev' && config.unstable_disableDevValidation === true) ||
|
||||
(mode === 'build' && config.unstable_disableBuildValidation === true)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// do not short-circuit, some other segment might still have `unstable_disableValidation`
|
||||
needsValidation = true
|
||||
}
|
||||
return needsValidation
|
||||
}
|
||||
return needsValidation
|
||||
}
|
||||
|
||||
export const anySegmentNeedsInstantValidationInDev = cacheScopedToWorkStore(
|
||||
async (rootTree: LoaderTree): Promise<boolean> =>
|
||||
anySegmentNeedsInstantValidation(rootTree, 'dev')
|
||||
)
|
||||
|
||||
export const anySegmentNeedsInstantValidationInBuild = cacheScopedToWorkStore(
|
||||
async (rootTree: LoaderTree): Promise<boolean> =>
|
||||
anySegmentNeedsInstantValidation(rootTree, 'build')
|
||||
)
|
||||
|
||||
export const findSegmentsWithInstantConfig = cacheScopedToWorkStore(
|
||||
@@ -129,6 +148,43 @@ export const findSegmentsWithInstantConfig = cacheScopedToWorkStore(
|
||||
}
|
||||
)
|
||||
|
||||
export const resolveInstantConfigSamplesForPage = async (
|
||||
tree: LoaderTree
|
||||
): Promise<InstantSample[] | null> => {
|
||||
const { mod: layoutOrPageMod } = await getLayoutOrPageModule(tree)
|
||||
|
||||
const instantConfig = layoutOrPageMod
|
||||
? (layoutOrPageMod as AppSegmentConfig).unstable_instant
|
||||
: undefined
|
||||
|
||||
let samples: InstantSample[] | null = null
|
||||
if (
|
||||
instantConfig !== undefined &&
|
||||
typeof instantConfig === 'object' &&
|
||||
instantConfig.samples
|
||||
) {
|
||||
samples = instantConfig.samples
|
||||
}
|
||||
|
||||
// The samples from inner segments override samples from outer segments,
|
||||
// i.e. a page overrides the samples from a layout.
|
||||
// We do not perform any merging logic.
|
||||
const { parallelRoutes } = parseLoaderTree(tree)
|
||||
for (const parallelRouteKey in parallelRoutes) {
|
||||
if (parallelRouteKey !== 'children') {
|
||||
// TODO(instant-validation-build): do something with with samples from non-children slots?
|
||||
continue
|
||||
}
|
||||
const childTree = parallelRoutes[parallelRouteKey]
|
||||
const childSamples = await resolveInstantConfigSamplesForPage(childTree)
|
||||
if (childSamples !== null) {
|
||||
samples = childSamples
|
||||
}
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple cache wrapper for 1-argument functions.
|
||||
* The cache will live as long as the current WorkStore,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Params } from '../../request/params'
|
||||
import type { ReadonlyURLSearchParams } from '../../../client/components/readonly-url-search-params'
|
||||
import { workUnitAsyncStorage } from '../work-unit-async-storage.external'
|
||||
import { workAsyncStorage } from '../work-async-storage.external'
|
||||
import {
|
||||
createExhaustiveParamsProxy,
|
||||
createExhaustiveURLSearchParamsProxy,
|
||||
trackMissingSampleErrorAndThrow,
|
||||
} from './instant-samples'
|
||||
import { InstantValidationError } from './instant-validation-error'
|
||||
|
||||
export function instrumentParamsForClientValidation<TPArams extends Params>(
|
||||
underlyingParams: TPArams
|
||||
): TPArams {
|
||||
const workStore = workAsyncStorage.getStore()
|
||||
const workUnitStore = workUnitAsyncStorage.getStore()
|
||||
if (workStore && workUnitStore) {
|
||||
switch (workUnitStore.type) {
|
||||
case 'validation-client': {
|
||||
if (workUnitStore.validationSamples) {
|
||||
const declaredKeys = new Set(
|
||||
Object.keys(workUnitStore.validationSamples.params ?? {})
|
||||
)
|
||||
return createExhaustiveParamsProxy(
|
||||
underlyingParams,
|
||||
declaredKeys,
|
||||
workStore.route
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'prerender-runtime':
|
||||
case 'prerender-client':
|
||||
case 'prerender-legacy':
|
||||
case 'prerender-ppr':
|
||||
case 'prerender':
|
||||
case 'cache':
|
||||
case 'request':
|
||||
case 'private-cache':
|
||||
case 'unstable-cache':
|
||||
break
|
||||
default:
|
||||
workUnitStore satisfies never
|
||||
}
|
||||
}
|
||||
return underlyingParams
|
||||
}
|
||||
|
||||
export function expectCompleteParamsInClientValidation(
|
||||
expression: string
|
||||
): void {
|
||||
const workStore = workAsyncStorage.getStore()
|
||||
const workUnitStore = workUnitAsyncStorage.getStore()
|
||||
if (workStore && workUnitStore) {
|
||||
switch (workUnitStore.type) {
|
||||
case 'validation-client': {
|
||||
if (workUnitStore.validationSamples) {
|
||||
const fallbackParams = workUnitStore.fallbackRouteParams
|
||||
if (fallbackParams && fallbackParams.size > 0) {
|
||||
const missingParams = Array.from(fallbackParams.keys())
|
||||
trackMissingSampleErrorAndThrow(
|
||||
new InstantValidationError(
|
||||
`Route "${workStore.route}" called ${expression} but param${missingParams.length > 1 ? 's' : ''} ${missingParams.map((p) => `"${p}"`).join(', ')} ${missingParams.length > 1 ? 'are' : 'is'} not defined in the \`samples\` of \`unstable_instant\`. ` +
|
||||
`${expression} requires all route params to be provided.`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'prerender-runtime':
|
||||
case 'prerender-client':
|
||||
case 'prerender-legacy':
|
||||
case 'prerender-ppr':
|
||||
case 'prerender':
|
||||
case 'cache':
|
||||
case 'request':
|
||||
case 'private-cache':
|
||||
case 'unstable-cache':
|
||||
break
|
||||
default:
|
||||
workUnitStore satisfies never
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function instrumentSearchParamsForClientValidation(
|
||||
underlyingSearchParams: ReadonlyURLSearchParams
|
||||
): ReadonlyURLSearchParams {
|
||||
const workStore = workAsyncStorage.getStore()
|
||||
const workUnitStore = workUnitAsyncStorage.getStore()
|
||||
if (workStore && workUnitStore) {
|
||||
switch (workUnitStore.type) {
|
||||
case 'validation-client': {
|
||||
if (workUnitStore.validationSamples) {
|
||||
const declaredKeys = new Set(
|
||||
Object.keys(workUnitStore.validationSamples.searchParams ?? {})
|
||||
)
|
||||
return createExhaustiveURLSearchParamsProxy(
|
||||
underlyingSearchParams,
|
||||
declaredKeys,
|
||||
workStore.route
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'prerender-runtime':
|
||||
case 'prerender-client':
|
||||
case 'prerender-legacy':
|
||||
case 'prerender-ppr':
|
||||
case 'prerender':
|
||||
case 'cache':
|
||||
case 'request':
|
||||
case 'private-cache':
|
||||
case 'unstable-cache':
|
||||
break
|
||||
default:
|
||||
workUnitStore satisfies never
|
||||
}
|
||||
}
|
||||
return underlyingSearchParams
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import type { InstantSample } from '../../../build/segment-config/app/app-segment-config'
|
||||
import type { ReadonlyRequestCookies } from '../../web/spec-extension/adapters/request-cookies'
|
||||
import type { ReadonlyHeaders } from '../../web/spec-extension/adapters/headers'
|
||||
import type { DraftModeProvider } from '../../async-storage/draft-mode-provider'
|
||||
import type { Params } from '../../request/params'
|
||||
|
||||
import { RequestCookies } from '../../web/spec-extension/cookies'
|
||||
import { RequestCookiesAdapter } from '../../web/spec-extension/adapters/request-cookies'
|
||||
import { HeadersAdapter } from '../../web/spec-extension/adapters/headers'
|
||||
import type { SearchParams } from '../../request/search-params'
|
||||
import { getSegmentParam } from '../../../shared/lib/router/utils/get-segment-param'
|
||||
import { parseRelativeUrl } from '../../../shared/lib/router/utils/parse-relative-url'
|
||||
import { InvariantError } from '../../../shared/lib/invariant-error'
|
||||
import { InstantValidationError } from './instant-validation-error'
|
||||
import { workUnitAsyncStorage } from '../work-unit-async-storage.external'
|
||||
import { wellKnownProperties } from '../../../shared/lib/utils/reflect-utils'
|
||||
import type { WorkStore } from '../work-async-storage.external'
|
||||
|
||||
export type InstantValidationSampleTracking = {
|
||||
// TODO(instant-validation-build): track which samples config we used and attribute errors
|
||||
missingSampleErrors: InstantValidationError[]
|
||||
}
|
||||
|
||||
export function createValidationSampleTracking(): InstantValidationSampleTracking {
|
||||
return {
|
||||
missingSampleErrors: [],
|
||||
}
|
||||
}
|
||||
|
||||
function getExpectedSampleTracking(): InstantValidationSampleTracking {
|
||||
let validationSampleTracking: InstantValidationSampleTracking | null = null
|
||||
const workUnitStore = workUnitAsyncStorage.getStore()
|
||||
if (workUnitStore) {
|
||||
switch (workUnitStore.type) {
|
||||
case 'request':
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation-build): do we need any special handling for caches?
|
||||
validationSampleTracking =
|
||||
workUnitStore.validationSampleTracking ?? null
|
||||
break
|
||||
case 'cache':
|
||||
case 'private-cache':
|
||||
case 'unstable-cache':
|
||||
case 'prerender-legacy':
|
||||
case 'prerender-ppr':
|
||||
case 'prerender-client':
|
||||
case 'prerender':
|
||||
case 'prerender-runtime':
|
||||
break
|
||||
default:
|
||||
workUnitStore satisfies never
|
||||
}
|
||||
}
|
||||
if (!validationSampleTracking) {
|
||||
throw new InvariantError(
|
||||
'Expected to have a workUnitStore that provides validationSampleTracking'
|
||||
)
|
||||
}
|
||||
return validationSampleTracking
|
||||
}
|
||||
|
||||
export function trackMissingSampleError(error: InstantValidationError): void {
|
||||
const validationSampleTracking = getExpectedSampleTracking()
|
||||
validationSampleTracking.missingSampleErrors.push(error)
|
||||
}
|
||||
|
||||
export function trackMissingSampleErrorAndThrow(
|
||||
error: InstantValidationError
|
||||
): never {
|
||||
// TODO(instant-validation-build): this should abort the render
|
||||
trackMissingSampleError(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates ReadonlyRequestCookies from sample cookie data.
|
||||
* Accessing a cookie not declared in the sample will throw an error.
|
||||
* Cookies with `value: null` are declared (allowed to access) but return no value.
|
||||
*/
|
||||
export function createCookiesFromSample(
|
||||
sampleCookies: InstantSample['cookies'],
|
||||
route: string
|
||||
): ReadonlyRequestCookies {
|
||||
const declaredNames = new Set<string>()
|
||||
|
||||
const cookies = new RequestCookies(new Headers())
|
||||
if (sampleCookies) {
|
||||
for (const cookie of sampleCookies) {
|
||||
declaredNames.add(cookie.name)
|
||||
if (cookie.value !== null) {
|
||||
cookies.set(cookie.name, cookie.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sealed = RequestCookiesAdapter.seal(cookies)
|
||||
|
||||
return new Proxy(sealed, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'has') {
|
||||
const originalMethod = Reflect.get(target, prop, receiver)
|
||||
const wrappedMethod: typeof originalMethod = function (name) {
|
||||
if (!declaredNames.has(name)) {
|
||||
trackMissingSampleErrorAndThrow(
|
||||
createMissingCookieSampleError(route, name)
|
||||
)
|
||||
}
|
||||
return originalMethod.call(target, name)
|
||||
}
|
||||
return wrappedMethod
|
||||
}
|
||||
if (prop === 'get') {
|
||||
const originalMethod = Reflect.get(target, prop, receiver)
|
||||
const wrappedMethod: typeof originalMethod = function (nameOrCookie) {
|
||||
let name: string
|
||||
if (typeof nameOrCookie === 'string') {
|
||||
name = nameOrCookie
|
||||
} else if (
|
||||
nameOrCookie &&
|
||||
typeof nameOrCookie === 'object' &&
|
||||
typeof nameOrCookie.name === 'string'
|
||||
) {
|
||||
name = nameOrCookie.name
|
||||
} else {
|
||||
// This is an invalid input. Pass it through to the original method so it can error.
|
||||
return originalMethod.call(target, nameOrCookie)
|
||||
}
|
||||
|
||||
if (!declaredNames.has(name)) {
|
||||
trackMissingSampleErrorAndThrow(
|
||||
createMissingCookieSampleError(route, name)
|
||||
)
|
||||
}
|
||||
return originalMethod.call(target, name)
|
||||
}
|
||||
return wrappedMethod
|
||||
}
|
||||
|
||||
// TODO(instant-validation-build): what should getAll do?
|
||||
// Maybe we should only allow it if there's an array (possibly empty?)
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createMissingCookieSampleError(
|
||||
route: string,
|
||||
name: string
|
||||
): InstantValidationError {
|
||||
return new InstantValidationError(
|
||||
`Route "${route}" accessed cookie "${name}" which is not defined in the \`samples\` ` +
|
||||
`of \`unstable_instant\`. Add it to the sample's \`cookies\` array, ` +
|
||||
`or \`{ name: "${name}", value: null }\` if it should be absent.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates ReadonlyHeaders from sample header data.
|
||||
* Accessing a header not declared in the sample will throw an error.
|
||||
* Headers with `value: null` are declared (allowed to access) but return null.
|
||||
*/
|
||||
export function createHeadersFromSample(
|
||||
rawSampleHeaders: InstantSample['headers'],
|
||||
sampleCookies: InstantSample['cookies'],
|
||||
route: string
|
||||
): ReadonlyHeaders {
|
||||
// If we have cookie samples, add a `cookie` header to match.
|
||||
// Accessing it will be implicitly allowed by the proxy --
|
||||
// if the user defined some cookies, accessing the "cookie" header is also fine.
|
||||
const sampleHeaders = rawSampleHeaders ? [...rawSampleHeaders] : []
|
||||
if (sampleHeaders.find(([name]) => name.toLowerCase() === 'cookie')) {
|
||||
throw new InstantValidationError(
|
||||
'Invalid sample: Defining cookies via a "cookie" header is not supported. Use `cookies: [{ name: ..., value: ... }]` instead.'
|
||||
)
|
||||
}
|
||||
if (sampleCookies) {
|
||||
const cookieHeaderValue = sampleCookies.toString()
|
||||
sampleHeaders.push([
|
||||
'cookie',
|
||||
// if the `cookies` samples were empty, or they were all `null`, then we have no cookies,
|
||||
// and the header isn't present, but should remains readable, so we set it to null.
|
||||
cookieHeaderValue !== '' ? cookieHeaderValue : null,
|
||||
])
|
||||
}
|
||||
|
||||
const declaredNames = new Set<string>()
|
||||
const headersInit: Record<string, string> = {}
|
||||
|
||||
for (const [name, value] of sampleHeaders) {
|
||||
declaredNames.add(name.toLowerCase())
|
||||
if (value !== null) {
|
||||
headersInit[name.toLowerCase()] = value
|
||||
}
|
||||
}
|
||||
|
||||
const sealed = HeadersAdapter.seal(HeadersAdapter.from(headersInit))
|
||||
|
||||
return new Proxy(sealed, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'get' || prop === 'has') {
|
||||
const originalMethod = Reflect.get(target, prop, receiver)
|
||||
const patchedMethod: typeof originalMethod = function (rawName) {
|
||||
const name = rawName.toLowerCase()
|
||||
if (!declaredNames.has(name)) {
|
||||
trackMissingSampleErrorAndThrow(
|
||||
new InstantValidationError(
|
||||
`Route "${route}" accessed header "${name}" which is not defined in the \`samples\` ` +
|
||||
`of \`unstable_instant\`. Add it to the sample's \`headers\` array, ` +
|
||||
`or \`["${name}", null]\` if it should be absent.`
|
||||
)
|
||||
)
|
||||
}
|
||||
// typescript can't reconcile a union of functions with a union of return types,
|
||||
// so we have to cast the original return type away
|
||||
return (originalMethod as (...args: any[]) => any).call(target, name)
|
||||
}
|
||||
return patchedMethod
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DraftModeProvider that always returns isEnabled: false.
|
||||
*/
|
||||
export function createDraftModeForValidation(): DraftModeProvider {
|
||||
// Create a minimal DraftModeProvider-compatible object
|
||||
// that always reports draft mode as disabled.
|
||||
//
|
||||
// private properties that can't be set from outside the class.
|
||||
return {
|
||||
get isEnabled() {
|
||||
return false
|
||||
},
|
||||
enable() {
|
||||
throw new Error(
|
||||
'Draft mode cannot be enabled during build-time instant validation.'
|
||||
)
|
||||
},
|
||||
disable() {
|
||||
throw new Error(
|
||||
'Draft mode cannot be disabled during build-time instant validation.'
|
||||
)
|
||||
},
|
||||
} as Partial<DraftModeProvider> as DraftModeProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates params wrapped with an exhaustive proxy.
|
||||
* Accessing a param not declared in the sample will throw an error.
|
||||
*/
|
||||
export function createExhaustiveParamsProxy<TParams extends Params>(
|
||||
underlyingParams: TParams,
|
||||
declaredParamNames: Set<string>,
|
||||
route: string
|
||||
): TParams {
|
||||
return new Proxy(underlyingParams, {
|
||||
get(target, prop, receiver) {
|
||||
if (
|
||||
typeof prop === 'string' &&
|
||||
!wellKnownProperties.has(prop) &&
|
||||
// Only error when accessing a param that is part of the route but wasn't provided.
|
||||
// accessing properties that aren't expected to be a valid param value is fine.
|
||||
prop in underlyingParams &&
|
||||
!declaredParamNames.has(prop)
|
||||
) {
|
||||
trackMissingSampleErrorAndThrow(
|
||||
new InstantValidationError(
|
||||
`Route "${route}" accessed param "${prop}" which is not defined in the \`samples\` ` +
|
||||
`of \`unstable_instant\`. Add it to the sample's \`params\` object.`
|
||||
)
|
||||
)
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
// We don't need to override `has` or `ownKeys`.
|
||||
// the shape of the params object is determined by the routing structure
|
||||
// and independent of the samples. We only need to instrument accessing the values.
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates searchParams wrapped with an exhaustive proxy.
|
||||
* Accessing a searchParam not declared in the sample will throw an error.
|
||||
* A searchParam with `value: undefined` means "declared but absent" (allowed to access, returns undefined).
|
||||
*/
|
||||
export function createExhaustiveSearchParamsProxy(
|
||||
searchParams: SearchParams,
|
||||
declaredSearchParamNames: Set<string>,
|
||||
route: string
|
||||
): SearchParams {
|
||||
return new Proxy(searchParams, {
|
||||
get(target, prop, receiver) {
|
||||
if (
|
||||
typeof prop === 'string' &&
|
||||
!wellKnownProperties.has(prop) &&
|
||||
!declaredSearchParamNames.has(prop)
|
||||
) {
|
||||
trackMissingSampleErrorAndThrow(
|
||||
createMissingSearchParamSampleError(route, prop)
|
||||
)
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
has(target, prop) {
|
||||
if (
|
||||
typeof prop === 'string' &&
|
||||
!wellKnownProperties.has(prop) &&
|
||||
!declaredSearchParamNames.has(prop)
|
||||
) {
|
||||
trackMissingSampleErrorAndThrow(
|
||||
createMissingSearchParamSampleError(route, prop)
|
||||
)
|
||||
}
|
||||
return Reflect.has(target, prop)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a URLSearchParams (or subclass like ReadonlyURLSearchParams) with an
|
||||
* exhaustive proxy. Accessing a search param not declared in the sample via
|
||||
* get/getAll/has will throw an error.
|
||||
*/
|
||||
export function createExhaustiveURLSearchParamsProxy<T extends URLSearchParams>(
|
||||
searchParams: T,
|
||||
declaredSearchParamNames: Set<string>,
|
||||
route: string
|
||||
): T {
|
||||
return new Proxy(searchParams, {
|
||||
get(target, prop, receiver) {
|
||||
// Intercept method calls that access specific param names
|
||||
if (prop === 'get' || prop === 'getAll' || prop === 'has') {
|
||||
const originalMathod = Reflect.get(target, prop, receiver)
|
||||
return (name: string) => {
|
||||
if (typeof name === 'string' && !declaredSearchParamNames.has(name)) {
|
||||
trackMissingSampleErrorAndThrow(
|
||||
createMissingSearchParamSampleError(route, name)
|
||||
)
|
||||
}
|
||||
return (originalMathod as (...args: any[]) => any).call(target, name)
|
||||
}
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver)
|
||||
// Prevent `TypeError: Value of "this" must be of type URLSearchParams` for methods
|
||||
if (typeof value === 'function' && !Object.hasOwn(target, prop)) {
|
||||
return value.bind(target)
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createMissingSearchParamSampleError(
|
||||
route: string,
|
||||
name: string
|
||||
): InstantValidationError {
|
||||
return new InstantValidationError(
|
||||
`Route "${route}" accessed searchParam "${name}" which is not defined in the \`samples\` ` +
|
||||
`of \`unstable_instant\`. Add it to the sample's \`searchParams\` object, ` +
|
||||
`or \`{ "${name}": null }\` if it should be absent.`
|
||||
)
|
||||
}
|
||||
|
||||
export function createRelativeURLFromSamples(
|
||||
route: string,
|
||||
sampleParams: InstantSample['params'],
|
||||
sampleSearchParams: InstantSample['searchParams']
|
||||
) {
|
||||
// Build searchParams query object and URL search string from sample
|
||||
const pathname = createPathnameFromRouteAndSampleParams(
|
||||
route,
|
||||
sampleParams ?? {}
|
||||
)
|
||||
|
||||
let search = ''
|
||||
if (sampleSearchParams) {
|
||||
const qs = createURLSearchParamsFromSample(sampleSearchParams).toString()
|
||||
if (qs) {
|
||||
search = '?' + qs
|
||||
}
|
||||
}
|
||||
|
||||
return parseRelativeUrl(pathname + search, undefined, true)
|
||||
}
|
||||
|
||||
function createURLSearchParamsFromSample(
|
||||
sampleSearchParams: InstantSample['searchParams']
|
||||
) {
|
||||
const result = new URLSearchParams()
|
||||
if (sampleSearchParams) {
|
||||
for (const [key, value] of Object.entries(sampleSearchParams)) {
|
||||
if (value === null || value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
result.append(key, v)
|
||||
}
|
||||
} else {
|
||||
result.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute sample params into `workStore.route` to create a plausible pathname.
|
||||
* TODO(instant-validation-build): this logic is somewhat hacky and likely incomplete,
|
||||
* but it should be good enough for some initial testing.
|
||||
*/
|
||||
function createPathnameFromRouteAndSampleParams(route: string, params: Params) {
|
||||
let interpolatedSegments: string[] = []
|
||||
const rawSegments = route.split('/')
|
||||
for (const rawSegment of rawSegments) {
|
||||
const param = getSegmentParam(rawSegment)
|
||||
if (param) {
|
||||
switch (param.paramType) {
|
||||
case 'catchall':
|
||||
case 'optional-catchall': {
|
||||
let paramValue = params[param.paramName]
|
||||
if (paramValue === undefined) {
|
||||
// The value for the param was not provided. `usePathname` will detect this and throw
|
||||
// before this can surface to userspace. Use `[...NAME]` as a placeholder for the param value
|
||||
// in case it pops up somewhere unexpectedly.
|
||||
paramValue = [rawSegment]
|
||||
} else if (!Array.isArray(paramValue)) {
|
||||
// NOTE: this happens outside of render, so we don't need `trackMissingSampleErrorAndThrow`
|
||||
throw new InstantValidationError(
|
||||
`Expected sample param value for segment '${rawSegment}' to be an array of strings, got ${typeof paramValue}`
|
||||
)
|
||||
}
|
||||
interpolatedSegments.push(
|
||||
...paramValue.map((v) => encodeURIComponent(v))
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'dynamic': {
|
||||
let paramValue = params[param.paramName]
|
||||
if (paramValue === undefined) {
|
||||
// The value for the param was not provided. `usePathname` will detect this and throw
|
||||
// before this can surface to userspace. Use `[NAME]` as a placeholder for the param value
|
||||
// in case it pops up somewhere unexpectedly.
|
||||
paramValue = rawSegment
|
||||
} else if (typeof paramValue !== 'string') {
|
||||
// NOTE: this happens outside of render, so we don't need `trackMissingSampleErrorAndThrow`
|
||||
throw new InstantValidationError(
|
||||
`Expected sample param value for segment '${rawSegment}' to be a string, got ${typeof paramValue}`
|
||||
)
|
||||
}
|
||||
interpolatedSegments.push(encodeURIComponent(paramValue))
|
||||
break
|
||||
}
|
||||
case 'catchall-intercepted-(..)(..)':
|
||||
case 'catchall-intercepted-(.)':
|
||||
case 'catchall-intercepted-(..)':
|
||||
case 'catchall-intercepted-(...)':
|
||||
case 'dynamic-intercepted-(..)(..)':
|
||||
case 'dynamic-intercepted-(.)':
|
||||
case 'dynamic-intercepted-(..)':
|
||||
case 'dynamic-intercepted-(...)': {
|
||||
// TODO(instant-validation-build): i don't know how these are supposed to work, or if we can even get them here
|
||||
throw new InvariantError(
|
||||
'Not implemented: Validation of interception routes'
|
||||
)
|
||||
}
|
||||
default: {
|
||||
param.paramType satisfies never
|
||||
}
|
||||
}
|
||||
} else {
|
||||
interpolatedSegments.push(rawSegment)
|
||||
}
|
||||
}
|
||||
return interpolatedSegments.join('/')
|
||||
}
|
||||
|
||||
export function assertRootParamInSamples(
|
||||
workStore: WorkStore,
|
||||
sampleParams: Params | undefined,
|
||||
paramName: string
|
||||
) {
|
||||
if (sampleParams && paramName in sampleParams) {
|
||||
// The param is defined in the samples.
|
||||
} else {
|
||||
const route = workStore.route
|
||||
trackMissingSampleErrorAndThrow(
|
||||
new InstantValidationError(
|
||||
`Route "${route}" accessed root param "${paramName}" which is not defined in the \`samples\` ` +
|
||||
`of \`unstable_instant\`. Add it to the sample's \`params\` object.`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const INSTANT_VALIDATION_ERROR_DIGEST = 'INSTANT_VALIDATION_ERROR'
|
||||
|
||||
/** Check if an error is an exhaustive samples validation error (by digest). */
|
||||
export function isInstantValidationError(
|
||||
err: unknown
|
||||
): err is InstantValidationError {
|
||||
return !!(
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
err instanceof Error &&
|
||||
(err as any).digest === INSTANT_VALIDATION_ERROR_DIGEST
|
||||
)
|
||||
}
|
||||
|
||||
export class InstantValidationError extends Error {
|
||||
digest = INSTANT_VALIDATION_ERROR_DIGEST
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { getDigestForWellKnownError } from '../create-error-handler'
|
||||
import {
|
||||
// NOTE: we're in the server layer, so this is a client reference
|
||||
PlaceValidationBoundaryBelowThisLevel,
|
||||
} from './boundary'
|
||||
} from '../../../client/components/instant-validation/boundary'
|
||||
import type { ValidationBoundaryTracking } from './boundary-tracking'
|
||||
import {
|
||||
getLayoutOrPageModule,
|
||||
@@ -299,6 +299,17 @@ export async function collectStagedSegmentData(
|
||||
if (digest) {
|
||||
return digest
|
||||
}
|
||||
|
||||
// Forward existing digests
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'digest' in error &&
|
||||
typeof error.digest === 'string'
|
||||
) {
|
||||
return error.digest
|
||||
}
|
||||
|
||||
// We don't need to log the errors because we would have already done that
|
||||
// when generating the original Flight stream for the whole page.
|
||||
if (
|
||||
@@ -526,6 +537,17 @@ export async function createCombinedPayloadStream(
|
||||
if (digest) {
|
||||
return digest
|
||||
}
|
||||
|
||||
// Forward existing digests
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'digest' in error &&
|
||||
typeof error.digest === 'string'
|
||||
) {
|
||||
return error.digest
|
||||
}
|
||||
|
||||
// We don't need to log the errors because we would have already done that
|
||||
// when generating the original Flight stream for the whole page.
|
||||
if (
|
||||
|
||||
@@ -35,8 +35,9 @@ export class StagedRenderingController {
|
||||
private dynamicStagePromise = createPromiseWithResolvers<void>()
|
||||
|
||||
constructor(
|
||||
private abortSignal: AbortSignal | null = null,
|
||||
private abandonController: AbortController | null = null
|
||||
private abortSignal: AbortSignal | null,
|
||||
private abandonController: AbortController | null,
|
||||
private shouldTrackSyncIO: boolean
|
||||
) {
|
||||
if (abortSignal) {
|
||||
abortSignal.addEventListener(
|
||||
@@ -88,13 +89,15 @@ export class StagedRenderingController {
|
||||
}
|
||||
}
|
||||
|
||||
canSyncInterrupt() {
|
||||
// If we haven't started the render yet, it can't be interrupted.
|
||||
if (this.currentStage === RenderStage.Before) {
|
||||
shouldTrackSyncInterrupt(): boolean {
|
||||
if (!this.shouldTrackSyncIO) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (this.currentStage) {
|
||||
case RenderStage.Before:
|
||||
// If we haven't started the render yet, it can't be interrupted.
|
||||
return false
|
||||
case RenderStage.EarlyStatic:
|
||||
case RenderStage.Static:
|
||||
return true
|
||||
@@ -132,8 +135,18 @@ export class StagedRenderingController {
|
||||
return
|
||||
}
|
||||
|
||||
// If we're in the final render, we cannot abandon it. We need to advance to the Dynamic stage
|
||||
// and capture the interruption reason.
|
||||
if (this.abortSignal) {
|
||||
// If this is an abortable render, we capture the interruption reason and stop advancing.
|
||||
// We don't release any more promises.
|
||||
// The caller is expected to abort the signal.
|
||||
this.syncInterruptReason = reason
|
||||
this.currentStage = RenderStage.Abandoned
|
||||
return
|
||||
}
|
||||
|
||||
// If we're in a non-abandonable & non-abortable render,
|
||||
// we need to advance to the Dynamic stage and capture the interruption reason.
|
||||
// (in dev, this will be the restarted render)
|
||||
switch (this.currentStage) {
|
||||
case RenderStage.EarlyStatic:
|
||||
case RenderStage.Static:
|
||||
|
||||
@@ -208,6 +208,13 @@ export interface RenderOptsPartial {
|
||||
* Prerendering those routes would catch any invalid dynamic accesses.
|
||||
*/
|
||||
allowEmptyStaticShell?: boolean
|
||||
|
||||
/**
|
||||
* When true, attempt to run build-time instant validation for this prerender.
|
||||
* Only the first prerender per page sets this, since validation uses
|
||||
* unstable_instant.samples and is independent of actual route params.
|
||||
*/
|
||||
runInstantValidation?: boolean
|
||||
}
|
||||
|
||||
export type RenderOpts = LoadComponentsReturnType<AppPageModule> &
|
||||
|
||||
@@ -23,6 +23,7 @@ import { InvariantError } from '../../shared/lib/invariant-error'
|
||||
import type { StagedRenderingController } from './staged-rendering'
|
||||
import { RenderStage } from './staged-rendering'
|
||||
import type { ValidationBoundaryTracking } from './instant-validation/boundary-tracking'
|
||||
import type { InstantValidationSampleTracking } from './instant-validation/instant-samples'
|
||||
|
||||
export type WorkUnitPhase = 'action' | 'render' | 'after'
|
||||
|
||||
@@ -76,10 +77,23 @@ export interface RequestStore extends CommonWorkUnitStore {
|
||||
prerenderResumeDataCache?: PrerenderResumeDataCache | null
|
||||
fallbackParams?: OpaqueFallbackRouteParams | null
|
||||
|
||||
// Only in build-time instant-validation
|
||||
// We mirror the controller/renderSignal from prerender stores to allow aborting the render
|
||||
// in case we hit an error that makes it unnecessary to continue
|
||||
controller?: AbortController
|
||||
renderSignal?: AbortSignal
|
||||
validationSamples?: InstantValidationSamples
|
||||
validationSampleTracking?: InstantValidationSampleTracking | null
|
||||
|
||||
// DEV-only
|
||||
usedDynamic?: boolean
|
||||
}
|
||||
|
||||
export type InstantValidationSamples = {
|
||||
params: Params | undefined
|
||||
searchParams: Record<string, string | string[] | null> | undefined
|
||||
}
|
||||
|
||||
export type AsyncApiPromises = {
|
||||
cookies: Promise<ReadonlyRequestCookies>
|
||||
earlyCookies: Promise<ReadonlyRequestCookies>
|
||||
@@ -149,8 +163,9 @@ export interface PrerenderStoreModernClient
|
||||
export interface ValidationStoreClient extends PrerenderStoreModernCommon {
|
||||
readonly type: 'validation-client'
|
||||
readonly boundaryState: ValidationBoundaryTracking | null
|
||||
// When we implement build validation, the store will contain e.g. cookies
|
||||
// and other values derived from samples.
|
||||
validationSamples: InstantValidationSamples | null
|
||||
validationSampleTracking: InstantValidationSampleTracking | null
|
||||
fallbackRouteParams: OpaqueFallbackRouteParams | null
|
||||
}
|
||||
|
||||
export interface PrerenderStoreModernServer
|
||||
|
||||
@@ -1114,6 +1114,16 @@ export type ExportPathMap = {
|
||||
* @internal
|
||||
*/
|
||||
_allowEmptyStaticShell?: boolean
|
||||
|
||||
/**
|
||||
* When true, run build-time instant validation for this export path.
|
||||
* Only set on the first export entry per page, since validation uses
|
||||
* unstable_instant.samples (not actual params from generateStaticParams),
|
||||
* so the result is the same for all param combinations.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
_runInstantValidation?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,69 +82,79 @@ export function io(expression: string, type: ApiType) {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'request':
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const stageController = workUnitStore.stagedRendering
|
||||
if (stageController && stageController.canSyncInterrupt()) {
|
||||
let message: string
|
||||
if (
|
||||
stageController.currentStage === RenderStage.Static ||
|
||||
stageController.currentStage === RenderStage.EarlyStatic
|
||||
) {
|
||||
switch (type) {
|
||||
case 'time':
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or Request data (e.g. \`cookies()\`, \`headers()\`, \`connection()\`, and \`searchParams\`). Accessing the current time in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-current-time`
|
||||
break
|
||||
case 'random':
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or Request data (e.g. \`cookies()\`, \`headers()\`, \`connection()\`, and \`searchParams\`). Accessing random values synchronously in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-random`
|
||||
break
|
||||
case 'crypto':
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or Request data (e.g. \`cookies()\`, \`headers()\`, \`connection()\`, and \`searchParams\`). Accessing random cryptographic values synchronously in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-crypto`
|
||||
break
|
||||
default:
|
||||
throw new InvariantError(
|
||||
'Unknown expression type in abortOnSynchronousPlatformIOAccess.'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// We're in the Runtime stage.
|
||||
// We only error for Sync IO in the Runtime stage if the route has a runtime prefetch config.
|
||||
// This check is implemented in `stageController.canSyncInterrupt()` --
|
||||
// if runtime prefetching isn't enabled, then we won't get here.
|
||||
case 'request': {
|
||||
const stageController = workUnitStore.stagedRendering
|
||||
if (stageController && stageController.shouldTrackSyncInterrupt()) {
|
||||
let message: string
|
||||
if (
|
||||
stageController.currentStage === RenderStage.Static ||
|
||||
stageController.currentStage === RenderStage.EarlyStatic
|
||||
) {
|
||||
switch (type) {
|
||||
case 'time':
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or Request data (e.g. \`cookies()\`, \`headers()\`, \`connection()\`, and \`searchParams\`). Accessing the current time in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-current-time`
|
||||
break
|
||||
case 'random':
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or Request data (e.g. \`cookies()\`, \`headers()\`, \`connection()\`, and \`searchParams\`). Accessing random values synchronously in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-random`
|
||||
break
|
||||
case 'crypto':
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or Request data (e.g. \`cookies()\`, \`headers()\`, \`connection()\`, and \`searchParams\`). Accessing random cryptographic values synchronously in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: https://nextjs.org/docs/messages/next-prerender-crypto`
|
||||
break
|
||||
default:
|
||||
throw new InvariantError(
|
||||
'Unknown expression type in abortOnSynchronousPlatformIOAccess.'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// We're in the Runtime stage.
|
||||
// We only error for Sync IO in the Runtime stage if the route has a runtime prefetch config.
|
||||
// This check is implemented in `stageController.canSyncInterrupt()` --
|
||||
// if runtime prefetching isn't enabled, then we won't get here.
|
||||
|
||||
let accessStatement: string
|
||||
let additionalInfoLink: string
|
||||
let accessStatement: string
|
||||
let additionalInfoLink: string
|
||||
|
||||
switch (type) {
|
||||
case 'time':
|
||||
accessStatement = 'the current time'
|
||||
additionalInfoLink =
|
||||
'https://nextjs.org/docs/messages/next-prerender-runtime-current-time'
|
||||
break
|
||||
case 'random':
|
||||
accessStatement = 'random values synchronously'
|
||||
additionalInfoLink =
|
||||
'https://nextjs.org/docs/messages/next-prerender-runtime-random'
|
||||
break
|
||||
case 'crypto':
|
||||
accessStatement = 'random cryptographic values synchronously'
|
||||
additionalInfoLink =
|
||||
'https://nextjs.org/docs/messages/next-prerender-runtime-crypto'
|
||||
break
|
||||
default:
|
||||
throw new InvariantError(
|
||||
'Unknown expression type in abortOnSynchronousPlatformIOAccess.'
|
||||
)
|
||||
}
|
||||
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or awaiting \`connection()\`. When configured for Runtime prefetching, accessing ${accessStatement} in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: ${additionalInfoLink}`
|
||||
switch (type) {
|
||||
case 'time':
|
||||
accessStatement = 'the current time'
|
||||
additionalInfoLink =
|
||||
'https://nextjs.org/docs/messages/next-prerender-runtime-current-time'
|
||||
break
|
||||
case 'random':
|
||||
accessStatement = 'random values synchronously'
|
||||
additionalInfoLink =
|
||||
'https://nextjs.org/docs/messages/next-prerender-runtime-random'
|
||||
break
|
||||
case 'crypto':
|
||||
accessStatement = 'random cryptographic values synchronously'
|
||||
additionalInfoLink =
|
||||
'https://nextjs.org/docs/messages/next-prerender-runtime-crypto'
|
||||
break
|
||||
default:
|
||||
throw new InvariantError(
|
||||
'Unknown expression type in abortOnSynchronousPlatformIOAccess.'
|
||||
)
|
||||
}
|
||||
|
||||
const syncIOError = applyOwnerStack(new Error(message))
|
||||
stageController.syncInterruptCurrentStageWithReason(syncIOError)
|
||||
message = `Route "${workStore.route}" used ${expression} before accessing either uncached data (e.g. \`fetch()\`) or awaiting \`connection()\`. When configured for Runtime prefetching, accessing ${accessStatement} in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: ${additionalInfoLink}`
|
||||
}
|
||||
|
||||
const syncIOError = applyOwnerStack(new Error(message))
|
||||
stageController.syncInterruptCurrentStageWithReason(syncIOError)
|
||||
|
||||
// A build-time validation render uses a 'request' store type, but may be abortable.
|
||||
// If we're in the second, restarted render of the restart-on-cache miss flow,
|
||||
// Sync IO is an error, and unlike dev, there's no need to continue the render past the sync IO,
|
||||
// so we can abort it.
|
||||
if (
|
||||
workUnitStore.controller &&
|
||||
!workUnitStore.controller.signal.aborted
|
||||
) {
|
||||
workUnitStore.controller.abort(syncIOError)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'validation-client':
|
||||
case 'prerender-ppr':
|
||||
case 'prerender-legacy':
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
throwInvariantForMissingStore,
|
||||
type PrerenderStoreModernRuntime,
|
||||
type RequestStore,
|
||||
type ValidationStoreClient,
|
||||
} from '../app-render/work-unit-async-storage.external'
|
||||
import { InvariantError } from '../../shared/lib/invariant-error'
|
||||
import {
|
||||
@@ -67,8 +68,11 @@ export function createParamsFromClient(
|
||||
varyParamsAccumulator
|
||||
)
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation): in build, this depends on samples
|
||||
return createRenderParamsInProd(underlyingParams)
|
||||
return createClientParamsInInstantValidation(
|
||||
underlyingParams,
|
||||
workStore,
|
||||
workUnitStore.validationSamples
|
||||
)
|
||||
case 'cache':
|
||||
case 'private-cache':
|
||||
case 'unstable-cache':
|
||||
@@ -94,6 +98,12 @@ export function createParamsFromClient(
|
||||
workUnitStore,
|
||||
isRuntimePrefetchable
|
||||
)
|
||||
} else if (workUnitStore.validationSamples) {
|
||||
return createClientParamsInInstantValidation(
|
||||
underlyingParams,
|
||||
workStore,
|
||||
workUnitStore.validationSamples
|
||||
)
|
||||
} else {
|
||||
return createRenderParamsInProd(underlyingParams)
|
||||
}
|
||||
@@ -214,8 +224,9 @@ export function createServerParamsForServerSegment(
|
||||
varyParamsAccumulator
|
||||
)
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation): in build, this depends on samples
|
||||
return createRenderParamsInProd(underlyingParams)
|
||||
throw new InvariantError(
|
||||
'createServerParamsForServerSegment should not be called in client contexts.'
|
||||
)
|
||||
case 'cache':
|
||||
case 'private-cache':
|
||||
case 'unstable-cache':
|
||||
@@ -243,6 +254,17 @@ export function createServerParamsForServerSegment(
|
||||
workUnitStore,
|
||||
isRuntimePrefetchable
|
||||
)
|
||||
} else if (
|
||||
workUnitStore.asyncApiPromises &&
|
||||
workUnitStore.validationSamples
|
||||
) {
|
||||
return createServerParamsInInstantValidation(
|
||||
underlyingParams,
|
||||
workStore,
|
||||
workUnitStore.validationSamples,
|
||||
workUnitStore.asyncApiPromises,
|
||||
isRuntimePrefetchable
|
||||
)
|
||||
} else if (
|
||||
workUnitStore.asyncApiPromises &&
|
||||
hasFallbackRouteParams(underlyingParams, workUnitStore.fallbackParams)
|
||||
@@ -295,7 +317,9 @@ export function createPrerenderParamsForClientSegment(
|
||||
}
|
||||
break
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation): in build, this depends on samples
|
||||
throw new InvariantError(
|
||||
'createPrerenderParamsForClientSegment should not be called in validation contexts.'
|
||||
)
|
||||
break
|
||||
case 'cache':
|
||||
case 'private-cache':
|
||||
@@ -421,6 +445,44 @@ function hasFallbackRouteParams(
|
||||
return false
|
||||
}
|
||||
|
||||
function createServerParamsInInstantValidation(
|
||||
underlyingParams: Params,
|
||||
workStore: WorkStore,
|
||||
validationSamples: NonNullable<RequestStore['validationSamples']>,
|
||||
asyncApiPromises: NonNullable<RequestStore['asyncApiPromises']>,
|
||||
isRuntimePrefetchable: boolean
|
||||
): Promise<Params> {
|
||||
const { createExhaustiveParamsProxy } =
|
||||
require('../app-render/instant-validation/instant-samples') as typeof import('../app-render/instant-validation/instant-samples')
|
||||
const declaredParams = new Set(Object.keys(validationSamples.params ?? {}))
|
||||
const proxiedUnderlying = createExhaustiveParamsProxy(
|
||||
underlyingParams,
|
||||
declaredParams,
|
||||
workStore.route
|
||||
)
|
||||
return (
|
||||
isRuntimePrefetchable
|
||||
? asyncApiPromises.earlySharedParamsParent
|
||||
: asyncApiPromises.sharedParamsParent
|
||||
).then(() => proxiedUnderlying)
|
||||
}
|
||||
|
||||
function createClientParamsInInstantValidation(
|
||||
underlyingParams: Params,
|
||||
workStore: WorkStore,
|
||||
validationSamples: ValidationStoreClient['validationSamples']
|
||||
): Promise<Params> {
|
||||
const { createExhaustiveParamsProxy } =
|
||||
require('../app-render/instant-validation/instant-samples') as typeof import('../app-render/instant-validation/instant-samples')
|
||||
const declaredParams = new Set(Object.keys(validationSamples?.params ?? {}))
|
||||
const proxiedUnderlying = createExhaustiveParamsProxy(
|
||||
underlyingParams,
|
||||
declaredParams,
|
||||
workStore.route
|
||||
)
|
||||
return Promise.resolve(proxiedUnderlying)
|
||||
}
|
||||
|
||||
function createRenderParamsInProd(underlyingParams: Params): Promise<Params> {
|
||||
return makeUntrackedParams(underlyingParams)
|
||||
}
|
||||
|
||||
@@ -80,9 +80,28 @@ export function getRootParam(paramName: string): Promise<ParamValue> {
|
||||
`${apiName} must not be used within a client component. Next.js should be preventing ${apiName} from being included in client components statically, but did not in this case.`
|
||||
)
|
||||
}
|
||||
case 'private-cache':
|
||||
case 'prerender-runtime':
|
||||
case 'request': {
|
||||
if (
|
||||
process.env.__NEXT_CACHE_COMPONENTS &&
|
||||
workUnitStore.validationSamples
|
||||
) {
|
||||
const { assertRootParamInSamples } =
|
||||
require('../app-render/instant-validation/instant-samples') as typeof import('../app-render/instant-validation/instant-samples')
|
||||
// If we error, make sure we return a rejected promise instead of erroring synchronously.
|
||||
try {
|
||||
assertRootParamInSamples(
|
||||
workStore,
|
||||
workUnitStore.validationSamples.params,
|
||||
paramName
|
||||
)
|
||||
} catch (err) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'private-cache':
|
||||
case 'prerender-runtime': {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type StaticPrerenderStore,
|
||||
throwInvariantForMissingStore,
|
||||
type RequestStore,
|
||||
type ValidationStoreClient,
|
||||
} from '../app-render/work-unit-async-storage.external'
|
||||
import { InvariantError } from '../../shared/lib/invariant-error'
|
||||
import {
|
||||
@@ -59,9 +60,13 @@ export function createSearchParamsFromClient(
|
||||
case 'prerender-ppr':
|
||||
case 'prerender-legacy':
|
||||
return createStaticPrerenderSearchParams(workStore, workUnitStore)
|
||||
case 'validation-client':
|
||||
// TODO(instant-validation): in build, this depends on samples
|
||||
return makeUntrackedSearchParams(underlyingSearchParams)
|
||||
case 'validation-client': {
|
||||
return createClientSearchParamsInValidation(
|
||||
underlyingSearchParams,
|
||||
workStore,
|
||||
workUnitStore
|
||||
)
|
||||
}
|
||||
case 'prerender-runtime':
|
||||
throw new InvariantError(
|
||||
'createSearchParamsFromClient should not be called in a runtime prerender.'
|
||||
@@ -266,6 +271,19 @@ function createRenderSearchParams(
|
||||
isRuntimePrefetchable
|
||||
)
|
||||
} else if (requestStore.asyncApiPromises) {
|
||||
if (requestStore.validationSamples) {
|
||||
const { createExhaustiveSearchParamsProxy } =
|
||||
require('../app-render/instant-validation/instant-samples') as typeof import('../app-render/instant-validation/instant-samples')
|
||||
const declaredKeys = new Set(
|
||||
Object.keys(requestStore.validationSamples.searchParams ?? {})
|
||||
)
|
||||
underlyingSearchParams = createExhaustiveSearchParamsProxy(
|
||||
underlyingSearchParams,
|
||||
declaredKeys,
|
||||
workStore.route
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
isRuntimePrefetchable
|
||||
? requestStore.asyncApiPromises.earlySharedSearchParamsParent
|
||||
@@ -674,3 +692,21 @@ function createSearchAccessError(
|
||||
`Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis`
|
||||
)
|
||||
}
|
||||
|
||||
function createClientSearchParamsInValidation(
|
||||
underlyingSearchParams: SearchParams,
|
||||
workStore: WorkStore,
|
||||
workUnitStore: ValidationStoreClient
|
||||
) {
|
||||
const { createExhaustiveSearchParamsProxy } =
|
||||
require('../app-render/instant-validation/instant-samples') as typeof import('../app-render/instant-validation/instant-samples')
|
||||
const declaredKeys = new Set(
|
||||
Object.keys(workUnitStore.validationSamples?.searchParams ?? {})
|
||||
)
|
||||
underlyingSearchParams = createExhaustiveSearchParamsProxy(
|
||||
underlyingSearchParams,
|
||||
declaredKeys,
|
||||
workStore.route
|
||||
)
|
||||
return Promise.resolve(underlyingSearchParams)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { CollectedCacheResult } from '../use-cache/use-cache-wrapper'
|
||||
*/
|
||||
type CacheStore<T> = Pick<
|
||||
Map<string, T>,
|
||||
'entries' | 'keys' | 'size' | 'get' | 'set'
|
||||
'entries' | 'keys' | 'size' | 'get' | 'set' | typeof Symbol.iterator
|
||||
>
|
||||
|
||||
/**
|
||||
|
||||
@@ -147,12 +147,23 @@ export async function stringifyResumeDataCache(
|
||||
*
|
||||
* @returns A new empty PrerenderResumeDataCache instance
|
||||
*/
|
||||
export function createPrerenderResumeDataCache(): PrerenderResumeDataCache {
|
||||
return {
|
||||
cache: new Map(),
|
||||
fetch: new Map(),
|
||||
encryptedBoundArgs: new Map(),
|
||||
decryptedBoundArgs: new Map(),
|
||||
export function createPrerenderResumeDataCache(
|
||||
source?: PrerenderResumeDataCache | RenderResumeDataCache
|
||||
): PrerenderResumeDataCache {
|
||||
if (source) {
|
||||
return {
|
||||
cache: new Map(source.cache),
|
||||
fetch: new Map(source.fetch),
|
||||
encryptedBoundArgs: new Map(source.encryptedBoundArgs),
|
||||
decryptedBoundArgs: new Map(source.decryptedBoundArgs),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
cache: new Map(),
|
||||
fetch: new Map(),
|
||||
encryptedBoundArgs: new Map(),
|
||||
decryptedBoundArgs: new Map(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,7 @@ export const wellKnownProperties = new Set([
|
||||
'toJSON',
|
||||
'$$typeof',
|
||||
'__esModule',
|
||||
|
||||
// Tested by flight when checking for iterables
|
||||
'@@iterator',
|
||||
])
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
export function ThrowsInClient(): Promise<never> {
|
||||
useSearchParams()
|
||||
throw new Error('Kaboom')
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<p>This layout wraps the children in Suspense.</p>
|
||||
<hr />
|
||||
<Suspense fallback={<div>Fallback from layout...</div>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Instant } from 'next'
|
||||
import { ThrowsInClient } from './client'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [{ searchParams: {} }],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page has an error in a client component with no suspense
|
||||
boundaries. It didn't render successfully, so it should be treated as
|
||||
blocking validation -- we don't know what content would be rendered if
|
||||
the error didn't occur.
|
||||
</p>
|
||||
<ThrowsInClient />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
export function ThrowsInClient(): Promise<never> {
|
||||
useSearchParams()
|
||||
throw new Error('Kaboom')
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Instant } from 'next'
|
||||
import { ThrowsInClient } from './client'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [{ searchParams: {} }],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page has an error in a client component with no suspense
|
||||
boundaries. It didn't render successfully, so it should be treated as
|
||||
blocking validation -- we don't know what content would be rendered if
|
||||
the error didn't occur.
|
||||
</p>
|
||||
<ThrowsInClient />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { Suspense } from 'react'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ cookies: [] }],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a cookie that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error. It catches the
|
||||
error thrown by the cookie access, but validation should still fail.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestCookies />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestCookies() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
try {
|
||||
ensureThrows(
|
||||
() => cookieStore.get('undeclaredCookie'),
|
||||
`Expected get() to throw for undeclared cookies`
|
||||
)
|
||||
} catch (err) {
|
||||
// We swallow the error. It should still be reported and fail the validation.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { Suspense } from 'react'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ cookies: [] }],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a cookie that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestCookies />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestCookies() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
ensureThrows(
|
||||
() => cookieStore.get('undeclaredCookie'),
|
||||
`Expected get() to throw for undeclared cookies`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { Suspense } from 'react'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ cookies: [] }],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a cookie that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestCookies />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestCookies() {
|
||||
const cookieStore = await cookies()
|
||||
ensureThrows(
|
||||
() => cookieStore.has('undeclaredCookie'),
|
||||
`Expected has() to throw for undeclared cookies`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { Instant } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
cookies: [
|
||||
{ name: 'testCookie', value: 'testValue' },
|
||||
{ name: 'missingCookie', value: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
When validated in build, the page should receive the cookies specified
|
||||
in the sample.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestCookies />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestCookies() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
const testCookie = cookieStore.get('testCookie')
|
||||
assert.deepStrictEqual(testCookie, {
|
||||
name: 'testCookie',
|
||||
value: 'testValue',
|
||||
})
|
||||
|
||||
const missingCookie = cookieStore.get('missingCookie')
|
||||
assert.equal(missingCookie, undefined)
|
||||
assert.equal(cookieStore.has('missingCookie'), false)
|
||||
|
||||
assert.deepStrictEqual(cookieStore.getAll(), [
|
||||
{ name: 'testCookie', value: 'testValue' },
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import type { Instant } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
cookies: [{ name: 'testCookie', value: 'testValue' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<CachedChild cookieStore={await cookies()} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function CachedChild({ cookieStore }: { cookieStore: unknown }) {
|
||||
'use cache'
|
||||
// Flight serializes cookies as an iterable, i.e. a sequence of entries
|
||||
assert.deepStrictEqual(cookieStore, [
|
||||
['testCookie', { name: 'testCookie', value: 'testValue' }],
|
||||
])
|
||||
return null
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { assert } from '../../../../client-assertion-error'
|
||||
|
||||
export function ClientChild({ cookieStore }: { cookieStore: unknown }) {
|
||||
// Flight serializes cookies as an iterable, i.e. a sequence of entries
|
||||
assert(
|
||||
JSON.stringify(cookieStore) ===
|
||||
JSON.stringify([
|
||||
['testCookie', { name: 'testCookie', value: 'testValue' }],
|
||||
]),
|
||||
`Unexpected value for cookieStore: ${JSON.stringify(cookieStore)}`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { Instant } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { ClientChild } from './client'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
cookies: [{ name: 'testCookie', value: 'testValue' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
Passing the whole cookies object to the client is generally a bad idea,
|
||||
but it shouldn't crash validation, so we still do it
|
||||
</p>
|
||||
<ClientChild cookieStore={await cookies()} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { assert } from '../../../../client-assertion-error'
|
||||
|
||||
/**
|
||||
* Asserts that client hooks receive sample params during instant validation,
|
||||
* not the values from generateStaticParams. This component is inside a Suspense
|
||||
* boundary gated by `await cookies()`, so it only renders during validation.
|
||||
*/
|
||||
export function AssertParamsClient() {
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
// During validation, useParams() should return the sample param ('hello'),
|
||||
// not a value from generateStaticParams ('foo' or 'bar').
|
||||
assert(
|
||||
params.slug === 'hello',
|
||||
`Expected useParams().slug === 'hello' (from sample), got '${params.slug}'`
|
||||
)
|
||||
|
||||
// usePathname() should reflect the sample param value too.
|
||||
assert(
|
||||
pathname === '/gsp/hello',
|
||||
`Expected usePathname() === '/gsp/hello', got '${pathname}'`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
import { cookies } from 'next/headers'
|
||||
import { Suspense } from 'react'
|
||||
import { AssertParamsClient } from './client'
|
||||
|
||||
// samples use 'hello', but generateStaticParams uses 'foo'/'bar'.
|
||||
// During validation, the sample params should be used, not the GSP values.
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
slug: 'hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ slug: 'foo' }, { slug: 'bar' }]
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AssertSampleParams params={params} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function AssertSampleParams({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
// Gate behind cookies() so this subtree is only reachable during
|
||||
// instant validation (not during regular GSP prerenders, where
|
||||
// cookies() creates a dynamic hole and defers the Suspense boundary).
|
||||
await cookies()
|
||||
|
||||
const p = await params
|
||||
// During validation, the param value should come from the sample ('hello'),
|
||||
// not from generateStaticParams ('foo' or 'bar').
|
||||
assert.equal(
|
||||
p.slug,
|
||||
'hello',
|
||||
`Expected param 'slug' to be 'hello' (from sample), got '${p.slug}'`
|
||||
)
|
||||
|
||||
// Client component rendered after the cookies() gate, so it also
|
||||
// only runs during validation.
|
||||
return (
|
||||
<>
|
||||
<div id="slug">{p.slug}</div>
|
||||
<AssertParamsClient />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { Suspense } from 'react'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ headers: [] }],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a header that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error. It catches the
|
||||
error thrown by the header access, but validation should still fail.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestHeaders />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestHeaders() {
|
||||
const headerStore = await headers()
|
||||
|
||||
try {
|
||||
ensureThrows(
|
||||
() => headerStore.get('undeclaredHeader'),
|
||||
`Expected get() to throw for undeclared headers`
|
||||
)
|
||||
} catch (err) {
|
||||
// We swallow the error. It should still be reported and fail the validation.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { Suspense } from 'react'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ headers: [] }],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a header that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestHeaders />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestHeaders() {
|
||||
const headersStore = await headers()
|
||||
ensureThrows(
|
||||
() => headersStore.get('undeclaredHeader'),
|
||||
`Expected get() to throw for undeclared headers`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { Suspense } from 'react'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ headers: [] }],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a header that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestHeaders />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestHeaders() {
|
||||
const headerStore = await headers()
|
||||
ensureThrows(
|
||||
() => headerStore.has('undeclaredHeader'),
|
||||
`Expected has() to throw for undeclared headers`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { Instant } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
headers: [
|
||||
['testHeader', 'testValue'],
|
||||
['missingHeader', null],
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
When validated in build, the page should receive the headers specified
|
||||
in the sample.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestHeaders />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestHeaders() {
|
||||
const headerStore = await headers()
|
||||
|
||||
const testHeader = headerStore.get('testHeader')
|
||||
assert.equal(testHeader, 'testValue')
|
||||
|
||||
const missingHeader = headerStore.get('missingHeader')
|
||||
assert.equal(missingHeader, undefined)
|
||||
assert.equal(headerStore.has('missingHeader'), false)
|
||||
|
||||
// TODO(instant-validation-build): test iteration
|
||||
|
||||
return null
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import type { Instant } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
headers: [['x-test-header', 'testValue']],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<CachedChild headerStore={await headers()} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function CachedChild({ headerStore }: { headerStore: unknown }) {
|
||||
'use cache'
|
||||
// Flight serializes headers as an iterable, i.e. a sequence of entries
|
||||
assert.deepStrictEqual(headerStore, [['x-test-header', 'testValue']])
|
||||
return null
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { assert } from '../../../../client-assertion-error'
|
||||
|
||||
export function ClientChild({ headerStore }: { headerStore: unknown }) {
|
||||
// Flight serializes headers as an iterable, i.e. a sequence of entries
|
||||
assert(
|
||||
JSON.stringify(headerStore) ===
|
||||
JSON.stringify([['x-test-header', 'testValue']]),
|
||||
`Unexpected value for headerStore: ${JSON.stringify(headerStore)}`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { Instant } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import { ClientChild } from './client'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
headers: [['x-test-header', 'testValue']],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
Passing the whole headers object to the client is generally a bad idea,
|
||||
but it shouldn't crash validation, so we still do it
|
||||
</p>
|
||||
<ClientChild headerStore={await headers()} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { connection } from 'next/server'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ cookies: [{ name: 'auth', value: '1' }] }],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const c = await cookies()
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads cookies outside Suspense, so it should fail validation
|
||||
because it would block navigation.
|
||||
</p>
|
||||
<div id="runtime-content">cookie: {c.get('auth')?.value}</div>
|
||||
<Dynamic />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function Dynamic() {
|
||||
await connection()
|
||||
return <div id="dynamic-content">Dynamic content</div>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { connection } from 'next/server'
|
||||
import { ReactNode, Suspense } from 'react'
|
||||
|
||||
export const unstable_instant = false
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<Suspense fallback="...">
|
||||
<Now />
|
||||
</Suspense>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
async function Now() {
|
||||
await connection()
|
||||
return <span>{Date.now()}</span>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <p>hello world</p>
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ensureThrows } from '../../../../../../ensure-error'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
// two: <missing>
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a param that is not declared in the sample, so it should
|
||||
fail validation with an exhaustiveness error. It catches the error
|
||||
thrown by the param access, but validation should still fail.
|
||||
</p>
|
||||
<TestParams params={params} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestParams({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
const p = await params
|
||||
|
||||
assert.equal(p.one, '123', `Unexpected value for param 'one'`)
|
||||
|
||||
try {
|
||||
// We're not allowed to access params not in the samples.
|
||||
ensureThrows(() => p.two, `Expected accessing an undeclared param to throw`)
|
||||
} catch (err) {
|
||||
// We swallow the error. It should still be reported and fail the validation.
|
||||
}
|
||||
|
||||
// TODO(instant-validation-build): test `in` and iteration
|
||||
// assert.deepStrictEqual(
|
||||
// { ...p },
|
||||
// { one: '123', two: '456' },
|
||||
// `Unexpected value when iterating over params object`
|
||||
// )
|
||||
|
||||
return null
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ensureThrows } from '../../../../../../ensure-error'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
// two: <missing>
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a param that is not declared in the sample, so it should
|
||||
fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<TestParams params={params} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestParams({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
const p = await params
|
||||
|
||||
assert.equal(p.one, '123', `Unexpected value for param 'one'`)
|
||||
|
||||
// We're allowed to access names that don't correspond to a param.
|
||||
assert.equal('three' in p, false)
|
||||
assert.equal(p.three, undefined)
|
||||
|
||||
// We're not allowed to access params not in the samples.
|
||||
ensureThrows(() => p.two)
|
||||
|
||||
// TODO: test `in` and iteration
|
||||
// assert.deepStrictEqual(
|
||||
// { ...p },
|
||||
// { one: '123', two: '456' },
|
||||
// `Unexpected value when iterating over params object`
|
||||
// )
|
||||
|
||||
return null
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { Instant } from 'next'
|
||||
import { ParamsReader } from './params-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
// two: <missing>
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a param via useParams() that is not declared in the
|
||||
sample, so it should fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<ParamsReader />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ensureThrows } from '../../../../../../ensure-error'
|
||||
|
||||
export function ParamsReader() {
|
||||
const params = useParams()
|
||||
try {
|
||||
// We're not allowed to access params not in the samples.
|
||||
ensureThrows(
|
||||
() => params.two,
|
||||
`Expected accessing an undeclared param to throw`
|
||||
)
|
||||
} catch {
|
||||
// We swallow the error. It should still be reported and fail the validation.
|
||||
}
|
||||
return null
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { Instant } from 'next'
|
||||
import { ParamsReader } from './params-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
// two: <missing>
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a param via useParams() that is not declared in the
|
||||
sample, so it should fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<ParamsReader />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ensureThrows } from '../../../../../../ensure-error'
|
||||
|
||||
export function ParamsReader() {
|
||||
const params = useParams()
|
||||
// We're not allowed to access params not in the samples.
|
||||
ensureThrows(
|
||||
() => params.two,
|
||||
`Expected accessing an undeclared param to throw`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
two: '456',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
When validated in build, the page should receive the params specified in
|
||||
the sample.
|
||||
</p>
|
||||
<TestParams params={params} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestParams({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
const p = await params
|
||||
const { one, two } = p
|
||||
|
||||
assert.equal(one, '123', `Unexpected value for param 'one'`)
|
||||
assert.equal(two, '456', `Unexpected value for param 'two'`)
|
||||
assert.equal('three' in p, false)
|
||||
assert.equal(p.three, undefined)
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{ ...p },
|
||||
{ one: '123', two: '456' },
|
||||
`Unexpected value when iterating over params object`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ params: { slug: 'hello' } }],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<CachedChild params={await params} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function CachedChild({ params }: { params: Record<string, string> }) {
|
||||
'use cache'
|
||||
assert.equal(
|
||||
params.slug,
|
||||
'hello',
|
||||
`Expected params.slug to be 'hello', got '${params.slug}'`
|
||||
)
|
||||
assert.deepStrictEqual(Object.keys(params), ['slug'])
|
||||
|
||||
return <div id="slug">{params.slug}</div>
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { assert } from '../../../../../client-assertion-error'
|
||||
|
||||
export function ClientChild({ params }: { params: Record<string, string> }) {
|
||||
assert(
|
||||
params.slug === 'hello',
|
||||
`Expected params.slug === 'hello', got '${params.slug}'`
|
||||
)
|
||||
|
||||
assert(
|
||||
JSON.stringify(Object.keys(params)) === JSON.stringify(['slug']),
|
||||
`Expected Object.keys(params) to be ["slug"], got ${JSON.stringify(Object.keys(params))}`
|
||||
)
|
||||
|
||||
return <div id="slug">{params.slug}</div>
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { Instant } from 'next'
|
||||
import { ClientChild } from './client'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ params: { slug: 'hello' } }],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<ClientChild params={await params} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { Instant } from 'next'
|
||||
import { ParamsReader } from './params-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
two: '456',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
When validated in build, useParams() should receive the params specified
|
||||
in the sample.
|
||||
</p>
|
||||
<ParamsReader />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { assert } from '../../../../../../client-assertion-error'
|
||||
|
||||
export function ParamsReader() {
|
||||
const params = useParams()
|
||||
|
||||
assert(
|
||||
params.one === '123',
|
||||
`Expected params.one === '123', got '${params.one}'`
|
||||
)
|
||||
|
||||
assert(
|
||||
params.two === '456',
|
||||
`Expected params.two === '456', got '${params.two}'`
|
||||
)
|
||||
|
||||
// We're allowed to access names that don't correspond to a param.
|
||||
assert(
|
||||
'three' in params === false,
|
||||
`Expected \`'three' in params\` to be false`
|
||||
)
|
||||
assert(
|
||||
params.three === undefined,
|
||||
`Expected \`params.three\` to be undefined, got ${params.three}`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { Instant } from 'next'
|
||||
import { PathnameReader } from './pathname-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
// two: <missing>
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
usePathname() on a route with dynamic params where not all params are
|
||||
provided in the sample should fail validation.
|
||||
</p>
|
||||
<PathnameReader />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ensureThrows } from '../../../../../../ensure-error'
|
||||
|
||||
export function PathnameReader() {
|
||||
ensureThrows(
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
() => usePathname(),
|
||||
`Expected usePathname() to throw when not all params are provided in samples`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { Instant } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { PathnameReader } from './pathname-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
catchAll: ['aaa', 'bbb', 'ccc'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
usePathname() on a catch-all route should return the pathname with the
|
||||
sample array items joined by slashes.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<PathnameReader />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { assert } from '../../../../../client-assertion-error'
|
||||
|
||||
export function PathnameReader() {
|
||||
const pathname = usePathname()
|
||||
assert(
|
||||
pathname === '/pathname/valid-use-pathname-catch-all/aaa/bbb/ccc',
|
||||
`Expected pathname to be '/pathname/valid-use-pathname-catch-all/aaa/bbb/ccc', got '${pathname}'`
|
||||
)
|
||||
return <div id="result">pathname: {pathname}</div>
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { Instant } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { PathnameReader } from './pathname-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [{}],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>usePathname() on a route without dynamic params should work fine.</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<PathnameReader />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { assert } from '../../../../client-assertion-error'
|
||||
|
||||
export function PathnameReader() {
|
||||
const pathname = usePathname()
|
||||
assert(
|
||||
pathname === '/pathname/valid-use-pathname-no-params',
|
||||
`Expected pathname to be '/pathname/valid-use-pathname-no-params', got '${pathname}'`
|
||||
)
|
||||
return <div id="result">pathname: {pathname}</div>
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { Instant } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { PathnameReader } from './pathname-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
optionalCatchAll: ['xxx', 'yyy'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
usePathname() on an optional catch-all route should return the pathname
|
||||
with the sample array items joined by slashes.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<PathnameReader />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { assert } from '../../../../../client-assertion-error'
|
||||
|
||||
export function PathnameReader() {
|
||||
const pathname = usePathname()
|
||||
assert(
|
||||
pathname === '/pathname/valid-use-pathname-optional-catch-all/xxx/yyy',
|
||||
`Expected pathname to be '/pathname/valid-use-pathname-optional-catch-all/xxx/yyy', got '${pathname}'`
|
||||
)
|
||||
return <div id="result">pathname: {pathname}</div>
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { Instant } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { PathnameReader } from './pathname-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [{}],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
usePathname() on a route inside a route group should not contain the
|
||||
route group segment in the pathname.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<PathnameReader />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { assert } from '../../../../../client-assertion-error'
|
||||
|
||||
export function PathnameReader() {
|
||||
const pathname = usePathname()
|
||||
assert(
|
||||
pathname === '/pathname/valid-use-pathname-route-group',
|
||||
`Expected pathname to be '/pathname/valid-use-pathname-route-group', got '${pathname}'`
|
||||
)
|
||||
return <div id="result">pathname: {pathname}</div>
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import type { Instant } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { PathnameReader } from './pathname-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
one: '123',
|
||||
two: '456',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
usePathname() on a route with dynamic params should return the pathname
|
||||
with the sample param values substituted.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<PathnameReader />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { assert } from '../../../../../../client-assertion-error'
|
||||
|
||||
export function PathnameReader() {
|
||||
const pathname = usePathname()
|
||||
assert(
|
||||
pathname === '/pathname/valid-use-pathname-with-params/123/456',
|
||||
`Expected pathname to be '/pathname/valid-use-pathname-with-params/123/456', got '${pathname}'`
|
||||
)
|
||||
return <div id="result">pathname: {pathname}</div>
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { Instant } from 'next'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
slug: 'from-layout',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
// No unstable_instant here — should inherit samples from the layout.
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page does not define unstable_instant samples, so it should inherit
|
||||
the samples from the parent layout.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestParams params={params} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestParams({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
const p = await params
|
||||
assert.equal(
|
||||
p.slug,
|
||||
'from-layout',
|
||||
`Expected param 'slug' to be 'from-layout', got '${p.slug}'`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'static',
|
||||
samples: [
|
||||
{
|
||||
params: {
|
||||
slug: 'from-page',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page defines its own unstable_instant samples, which should
|
||||
override the layout samples (no merging).
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestParams params={params} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestParams({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Record<string, string>>
|
||||
}) {
|
||||
const p = await params
|
||||
assert.equal(
|
||||
p.slug,
|
||||
'from-page',
|
||||
`Expected param 'slug' to be 'from-page', got '${p.slug}'`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ searchParams: { q: 'test' } }],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; undeclared?: string }>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a searchParam that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error. It catches the
|
||||
error thrown by the searchParam access, but validation should still
|
||||
fail.
|
||||
</p>
|
||||
<SearchResult searchParams={searchParams} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function SearchResult({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; undeclared?: string }>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
|
||||
try {
|
||||
ensureThrows(
|
||||
() => sp.undeclared,
|
||||
`Expected accessing an undeclared search param to throw`
|
||||
)
|
||||
} catch (err) {
|
||||
// We swallow the error. It should still be reported and fail the validation.
|
||||
}
|
||||
|
||||
return <div id="search-result">query: {sp.q}</div>
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ searchParams: { q: 'test' } }],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; undeclared?: string }>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a searchParam that is not declared in the sample, so it
|
||||
should fail validation with an exhaustiveness error.
|
||||
</p>
|
||||
<SearchResult searchParams={searchParams} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function SearchResult({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; undeclared?: string }>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
ensureThrows(
|
||||
() => sp.undeclared,
|
||||
`Expected accessing an undeclared search param to throw`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import type { Instant } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { SearchParamsReader } from './search-params-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
searchParams: {
|
||||
q: 'test',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a searchParam via useSearchParams() that is not declared
|
||||
in the sample, so it should fail validation with an exhaustiveness
|
||||
error.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SearchParamsReader />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export function SearchParamsReader() {
|
||||
const sp = useSearchParams()
|
||||
try {
|
||||
ensureThrows(
|
||||
() => sp.get('undeclared'),
|
||||
`Expected accessing an undeclared search param to throw`
|
||||
)
|
||||
} catch {
|
||||
// We swallow the error. It should still be reported and fail the validation.
|
||||
}
|
||||
return null
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { Instant } from 'next'
|
||||
import { SearchParamsReader } from './search-params-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
searchParams: {
|
||||
q: 'test',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page reads a searchParam via useSearchParams() that is not declared
|
||||
in the sample, so it should fail validation with an exhaustiveness
|
||||
error.
|
||||
</p>
|
||||
<SearchParamsReader />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { ensureThrows } from '../../../../ensure-error'
|
||||
|
||||
export function SearchParamsReader() {
|
||||
const sp = useSearchParams()
|
||||
ensureThrows(
|
||||
() => sp.get('undeclared'),
|
||||
`Expected accessing an undeclared search param to throw`
|
||||
)
|
||||
return null
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
searchParams: {
|
||||
// TODO(instant-validation-build): specify and test escaping behavior for spaces etc
|
||||
single: 'test',
|
||||
multiple: ['a', 'b'],
|
||||
missing: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
type SearchParams = Record<string, string | string[]>
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ foo?: string | string[] }>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
When validated in build, the page should receive the search params
|
||||
specified in the sample.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<TestSearchParams searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestSearchParams({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const search = await searchParams
|
||||
|
||||
assert.equal(
|
||||
search.single,
|
||||
'test',
|
||||
`Unexpected search param value for 'single'`
|
||||
)
|
||||
|
||||
assert.deepStrictEqual(
|
||||
search.multiple,
|
||||
['a', 'b'],
|
||||
`Unexpected search param value for 'multiple'`
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
search.missing,
|
||||
undefined,
|
||||
`search param 'missing' should not be defined`
|
||||
)
|
||||
assert.equal(
|
||||
'missing' in search,
|
||||
false,
|
||||
`search param 'missing' should not be in the keys of searchParams`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
searchParams: {
|
||||
single: 'test',
|
||||
multiple: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[]>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<CachedChild searchParams={await searchParams} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function CachedChild({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[]>
|
||||
}) {
|
||||
'use cache'
|
||||
assert.equal(
|
||||
searchParams.single,
|
||||
'test',
|
||||
`Expected searchParams.single to be 'test', got '${searchParams.single}'`
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
searchParams.multiple,
|
||||
['a', 'b'],
|
||||
`Unexpected value for 'multiple'`
|
||||
)
|
||||
return <div id="single">{searchParams.single}</div>
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { assert } from '../../../../client-assertion-error'
|
||||
|
||||
export function ClientChild({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[]>
|
||||
}) {
|
||||
assert(
|
||||
searchParams.single === 'test',
|
||||
`Expected searchParams.single === 'test', got '${searchParams.single}'`
|
||||
)
|
||||
return <div id="single">{searchParams.single}</div>
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { Instant } from 'next'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ClientChild } from './client'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
searchParams: {
|
||||
single: 'test',
|
||||
multiple: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[]>>
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
<TestSearchParams searchParams={searchParams} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function TestSearchParams({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[]>>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
assert.equal(
|
||||
sp.single,
|
||||
'test',
|
||||
`Expected 'single' to be 'test', got '${sp.single}'`
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
sp.multiple,
|
||||
['a', 'b'],
|
||||
`Unexpected value for 'multiple'`
|
||||
)
|
||||
return <ClientChild searchParams={sp} />
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type { Instant } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { SearchParamsReader } from './search-params-reader'
|
||||
|
||||
export const unstable_instant: Instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [
|
||||
{
|
||||
searchParams: {
|
||||
single: 'test',
|
||||
multiple: ['a', 'b'],
|
||||
missing: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
When validated in build, useSearchParams() should receive the search
|
||||
params specified in the sample.
|
||||
</p>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SearchParamsReader />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { assert } from '../../../../client-assertion-error'
|
||||
|
||||
export function SearchParamsReader() {
|
||||
const sp = useSearchParams()
|
||||
|
||||
assert(
|
||||
sp.get('single') === 'test',
|
||||
`Expected sp.get('single') === 'test', got '${sp.get('single')}'`
|
||||
)
|
||||
|
||||
const multiple = sp.getAll('multiple')
|
||||
assert(
|
||||
multiple.length === 2 && multiple[0] === 'a' && multiple[1] === 'b',
|
||||
`Expected sp.getAll('multiple') === ['a', 'b'], got ${JSON.stringify(multiple)}`
|
||||
)
|
||||
|
||||
assert(
|
||||
sp.get('missing') === null,
|
||||
`Expected sp.get('missing') === null, got '${sp.get('missing')}'`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export function IgnoreServerContent(_props: { content: ReactNode }) {
|
||||
return <p>Does nothing</p>
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { IgnoreServerContent } from './client'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ cookies: [] }],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page passes an erroring server component to a client component that
|
||||
ignores it. The error didn't block validation, so the page should pass
|
||||
validation.
|
||||
</p>
|
||||
<IgnoreServerContent content={<Throws />} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function Throws(): Promise<never> {
|
||||
await cookies()
|
||||
throw new Error('Kaboom')
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<p>This layout wraps the children in Suspense.</p>
|
||||
<hr />
|
||||
<Suspense fallback={<div>Fallback from layout...</div>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ cookies: [] }],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page has an error in a server component with no suspense
|
||||
boundaries. It didn't render successfully, so it should be treated as
|
||||
blocking validation -- we don't know what content would be rendered if
|
||||
the error didn't occur.
|
||||
</p>
|
||||
<Throws />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function Throws(): Promise<never> {
|
||||
await cookies()
|
||||
throw new Error('Kaboom')
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{ cookies: [] }],
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page has an error in a server component with no suspense
|
||||
boundaries. It didn't render successfully, so it should be treated as
|
||||
blocking validation -- we don't know what content would be rendered if
|
||||
the error didn't occur.
|
||||
</p>
|
||||
<Throws />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function Throws(): Promise<never> {
|
||||
await cookies()
|
||||
throw new Error('Kaboom')
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export const unstable_instant = {
|
||||
prefetch: 'runtime',
|
||||
samples: [{}],
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
await cachedIO('static')
|
||||
|
||||
await cookies()
|
||||
await cachedIO('runtime')
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p>
|
||||
This page awaits a cache that is reachable in the static stage and a
|
||||
cache that is reachable in the runtime stage. This should never need a
|
||||
suspense boundary. This is a sanity check to make sure that we warm
|
||||
caches correctly when validating.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
async function cachedIO(key: any) {
|
||||
'use cache'
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5))
|
||||
return key
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user