From 35d91fcaae7a1601f99e78b17f026cff91cd91df Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 18 Aug 2026 12:31:47 -0700 Subject: [PATCH 01/10] Model prerenders as render candidates (#97431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This is the behavior-preserving base of a four-PR stack that introduces explicit prerender matching policy without coupling the foundational refactor to the proposed API. The build currently uses `PrerenderedRoute` values for two related but distinct concepts: - a logical request matcher that says which parameterized URL shapes the route can handle - a build-time render candidate that may or may not become a persisted prerender artifact A candidate is not guaranteed to be an output. It can be rendered only to validate a shell, discarded, and still leave behind a matcher that tells future requests to block. The new relationship is: ```text logical pathname matcher -> zero or more render candidates -> zero or more persisted artifacts ``` ## Concrete example Consider `/[top]/items/[bottom]`: ```ts export function generateStaticParams() { return [{ top: 't1', bottom: 'b1' }] } ``` Static-path generation may consider three shapes: | Shape | Purpose | | --- | --- | | `/[top]/items/[bottom]` | A generic shell candidate and logical matcher | | `/t1/items/[bottom]` | A shell after resolving `top` | | `/t1/items/b1` | A concrete build-time prerender | Suppose the generic candidate renders an allowed empty shell. The build should discard that candidate artifact and use blocking behavior for the generic matcher. It should not remove `/[top]/items/[bottom]` from the valid matcher set, and it should not discard the concrete `/t1/items/b1` artifact. This is why the build needs two sets: - route matchers, which describe valid request shapes - prerender candidates, whose render results determine whether an artifact is retained and can refine inferred fallback behavior ## Variants compatibility Variants will make pathname-only candidate maps insufficient. Several variant combinations can share `/t1/items/b1` as their logical pathname while writing distinct artifacts under variant-specific output paths. This PR keeps the route matcher keyed by logical pathname but retains every candidate associated with it. Candidate finalization can therefore evaluate each variant artifact independently without changing the route tree's matcher set. ## Behavior preservation This PR does not add a user-facing API or change `generateStaticParams` semantics: - a usable static shell remains a fallback prerender - an allowed empty shell is discarded and represented by a blocking matcher - a route that requires a non-empty shell still fails validation - unresolved matchers remain gated by route-level PPR support - the most-specific shell continues to supply first-writer-wins metadata such as prefetch hints Render results can decide whether a candidate artifact is published and can refine inferred matcher behavior, but they do not remove the logical route matcher itself. ## Stack plan 1. **#97431 — model prerenders as render candidates:** land the behavior-preserving matcher/candidate separation and post-render finalization first. 2. **#97393 — add the experimental matcher API:** add `unstable_matcher` and `unstable_generateMatcher`, policy aggregation, validation, and local diagnostics. 3. **#97426 — test complex route shapes:** add test-only coverage for catch-alls, optional catch-alls, root parameters, and parallel slots. 4. **#97427 — test foreground policy behavior:** add test-only coverage showing blocking misses generate before responding while fallback misses return the shared shell immediately. This layering lets the internal model be reviewed and landed independently of the API design. The upper test PRs validate the final behavior without increasing the implementation diff. ## Verification - `pnpm --filter=next types` - `pnpm test-start-turbo test/e2e/app-dir/sub-shell-generation/sub-shell-generation.test.ts` - `pnpm test-start-turbo test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts` --- packages/next/src/build/index.ts | 146 +++++++++++++----- packages/next/src/build/static-paths/app.ts | 26 +++- packages/next/src/build/static-paths/types.ts | 31 ++++ packages/next/src/build/utils.ts | 58 ++++--- .../[top]/[bottom]/page.tsx | 8 + .../app/test-dynamic-partial/[top]/layout.tsx | 23 +++ .../app/test-dynamic-partial/layout.tsx | 10 ++ .../prefetch-inlining.test.ts | 36 ++++- 8 files changed, 275 insertions(+), 63 deletions(-) create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/[bottom]/page.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/layout.tsx create mode 100644 test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/layout.tsx diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 9b297077e69b..b09eb6fca543 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -138,7 +138,11 @@ import { pageToRoute, } from './utils' import type { DynamicManifestRoute, PageInfo, PageInfos } from './utils' -import type { FallbackRouteParam, PrerenderedRoute } from './static-paths/types' +import type { + FallbackRouteParam, + PrerenderRouteMatcher, + PrerenderedRoute, +} from './static-paths/types' import type { AppSegmentConfig } from './segment-config/app/app-segment-config' import { writeBuildId } from './write-build-id' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' @@ -2168,6 +2172,7 @@ export default async function build( const serverPropsPages = new Set() const additionalPaths = new Map() const staticPaths = new Map() + const prerenderRouteMatchers = new Map() const appNormalizedPaths = new Map() const fallbackModes = new Map() const appDefaultConfigs = new Map() @@ -2569,6 +2574,13 @@ export default async function build( isSSG = true } + if (workerResult.prerenderRouteMatchers) { + prerenderRouteMatchers.set( + originalAppPath, + workerResult.prerenderRouteMatchers + ) + } + const appConfig = workerResult.appConfig || {} if (appConfig.revalidate !== 0) { const hasGenerateStaticParams = @@ -3226,29 +3238,30 @@ export default async function build( // If there was no result, there's nothing more to do. if (!exportResult) return - const getFallbackMode = (route: PrerenderedRoute) => { - const hasEmptyStaticShell = exportResult.byPath.get( - route.pathname - )?.hasEmptyStaticShell - + const resolveFallbackMode = ( + matcher: PrerenderRouteMatcher, + prerenderCandidate: PrerenderedRoute | undefined, + hasEmptyStaticShell: boolean | undefined + ) => { // If the route has an empty static shell and is not configured to // throw on empty static shell, then we should use the blocking // static render mode. if ( + prerenderCandidate && hasEmptyStaticShell && - !route.throwOnEmptyStaticShell && - route.fallbackMode === FallbackMode.PRERENDER + !prerenderCandidate.throwOnEmptyStaticShell && + matcher.fallbackMode === FallbackMode.PRERENDER ) { return FallbackMode.BLOCKING_STATIC_RENDER } // If the route has no fallback mode, then we should use the // `NOT_FOUND` fallback mode. - if (!route.fallbackMode) { + if (!matcher.fallbackMode) { return FallbackMode.NOT_FOUND } - return route.fallbackMode + return matcher.fallbackMode } const getCacheControl = ( @@ -3311,6 +3324,15 @@ export default async function build( if (!appConfig) throw new InvariantError('App config not found') const ssgPageRoutesSet = new Set(pageInfos.get(page)?.ssgPageRoutes) + // Preserve the specificity order that unknown prerender routes had + // before matchers were modeled separately. Some metadata, such as + // prefetch hints, is collected using first-writer-wins semantics. + const dynamicRouteMatchers = [ + ...sortPageObjects( + prerenderRouteMatchers.get(originalAppPath) ?? [], + (route) => route.pathname + ), + ] let hasRevalidateZero = appConfig.revalidate === 0 || @@ -3367,13 +3389,10 @@ export default async function build( : []), ] - // We should collect all the dynamic routes into a single array for - // this page. Including the full fallback route (the original - // route), any routes that were generated with unknown route params - // should be collected and included in the dynamic routes part - // of the manifest instead. - const staticPrerenderedRoutes: PrerenderedRoute[] = [] - const dynamicPrerenderedRoutes: PrerenderedRoute[] = [] + // Candidates without unknown params can become concrete static + // outputs. Candidates with unknown params are finalized alongside + // the logical matcher directives collected above. + const concretePrerenderCandidates: PrerenderedRoute[] = [] // Sort the outputted routes to ensure consistent output. Any route // though that has unknown route params will be pulled and sorted @@ -3439,18 +3458,17 @@ export default async function build( prerenderedRoute.fallbackRouteParams && prerenderedRoute.fallbackRouteParams.length > 0 ) { - // If the route has unknown params, then we need to add it to - // the list of dynamic routes. - dynamicPrerenderedRoutes.push(prerenderedRoute) + // Partial candidates have a corresponding matcher directive + // and are finalized below after inspecting their render. } else { // If the route doesn't have unknown params, then we need to // add it to the list of static routes. - staticPrerenderedRoutes.push(prerenderedRoute) + concretePrerenderCandidates.push(prerenderedRoute) } } // Handle all the static routes. - for (const route of staticPrerenderedRoutes) { + for (const route of concretePrerenderCandidates) { if (isDynamicRoute(page) && route.pathname === page) continue const pageInfo = pageInfos.get(page) as PageInfo @@ -3602,20 +3620,59 @@ export default async function build( // they are enabled, then it'll already be included in the // prerendered routes. if (!isRoutePPREnabled) { - dynamicPrerenderedRoutes.push({ - params: {}, + dynamicRouteMatchers.push({ pathname: page, - encodedPathname: page, fallbackRouteParams: [], fallbackMode: fallbackModes.get(originalAppPath) ?? FallbackMode.NOT_FOUND, fallbackRootParams: [], - throwOnEmptyStaticShell: true, }) } - for (const route of dynamicPrerenderedRoutes) { + // A logical matcher can have zero or more render candidates. + // Today generateStaticParams produces at most one candidate per + // pathname. Variants can multiply that into several artifacts + // without changing the logical matcher, so retain every + // candidate instead of letting pathname select whichever one was + // inserted last. + const prerenderCandidatesByPathname = new Map< + string, + PrerenderedRoute[] + >() + for (const candidate of prerenderedRoutes) { + const candidates = prerenderCandidatesByPathname.get( + candidate.pathname + ) + if (candidates) { + candidates.push(candidate) + } else { + prerenderCandidatesByPathname.set(candidate.pathname, [ + candidate, + ]) + } + } + + const dynamicRouteEntries: Array<{ + matcher: PrerenderRouteMatcher + prerenderCandidate: PrerenderedRoute | undefined + }> = [] + for (const matcher of dynamicRouteMatchers) { + const candidates = prerenderCandidatesByPathname.get( + matcher.pathname + ) ?? [undefined] + for (const prerenderCandidate of candidates) { + dynamicRouteEntries.push({ + matcher, + prerenderCandidate, + }) + } + } + + for (const { + matcher: route, + prerenderCandidate, + } of dynamicRouteEntries) { // Static metadata files are rewritten above into the known // static bucket under their `-`-placeholder pathname, so any // entry that slips through here (e.g. an unexpected fallback @@ -3626,13 +3683,24 @@ export default async function build( continue } - const normalizedRoute = normalizePagePath(route.pathname) + // This is the artifact associated with this matcher entry. It + // currently has the same pathname as the logical matcher, but + // that is not an invariant: variants can write several + // artifacts for one matcher under distinct output paths. + const prerenderOutputPathname = + prerenderCandidate?.pathname ?? route.pathname + + const normalizedRoute = normalizePagePath( + prerenderOutputPathname + ) const parentPageInfo = pageInfos.get(page) as PageInfo - const routeResult = exportResult.byPath.get(route.pathname) + const routeResult = exportResult.byPath.get( + prerenderOutputPathname + ) const metadata = routeResult?.metadata - const cacheControl = getCacheControl(route.pathname) + const cacheControl = getCacheControl(prerenderOutputPathname) let dataRoute: string | null = null if (!isAppRouteHandler) { @@ -3720,10 +3788,10 @@ export default async function build( if (route.pathname === page) { // The route pattern entry (for example `/blog/[slug]`) is - // also present in `dynamicPrerenderedRoutes`. Keep updating - // the parent entry in place so it retains its `ssgPageRoutes` - // subtree; if we rewrote it like a concrete child route we - // would lose the generated child paths from the build output. + // also present in `dynamicRouteMatchers`. Keep updating the + // parent entry in place so it retains its `ssgPageRoutes` + // subtree; rewriting it like a concrete child route would + // lose the generated child paths from the build output. pageInfos.set(page, { ...(pageInfos.get(page) as PageInfo), initialCacheControl: cacheControl, @@ -3751,7 +3819,11 @@ export default async function build( }) } - const fallbackMode = getFallbackMode(route) + const fallbackMode = resolveFallbackMode( + route, + prerenderCandidate, + routeResult?.hasEmptyStaticShell + ) // When the route is configured to serve a prerender, we should // use the cache control from the export result. If it can't be @@ -3795,7 +3867,7 @@ export default async function build( } } - prerenderManifest.dynamicRoutes[route.pathname] = { + prerenderManifest.dynamicRoutes[prerenderOutputPathname] = { experimentalPPR: isRoutePPREnabled, remainingPrerenderableParams: route.remainingPrerenderableParams, @@ -3807,7 +3879,7 @@ export default async function build( ...classification, experimentalBypassFor: bypassFor, routeRegex: normalizeRouteRegex( - getNamedRouteRegex(route.pathname, { + getNamedRouteRegex(prerenderOutputPathname, { prefixRouteKeys: false, }).re.source ), diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index 57dec791abf0..5ad4c6e12c5a 100644 --- a/packages/next/src/build/static-paths/app.ts +++ b/packages/next/src/build/static-paths/app.ts @@ -3,6 +3,7 @@ import type { AppPageModule } from '../../server/route-modules/app-page/module' import type { AppSegment } from '../segment-config/app/app-segments' import type { FallbackRouteParam, + PrerenderRouteMatcher, PrerenderedRoute, StaticPathsResult, } from './types' @@ -1175,5 +1176,28 @@ export async function buildAppStaticPaths({ assignStaticShellMetadata(prerenderedRoutes, prerenderablePathSegments) } - return { fallbackMode, prerenderedRoutes } + const prerenderRouteMatchersByPathname = new Map< + string, + PrerenderRouteMatcher + >() + if (prerenderedRoutes && isRoutePPREnabled) { + for (const prerenderCandidate of prerenderedRoutes) { + if (!prerenderCandidate.fallbackRouteParams?.length) continue + prerenderRouteMatchersByPathname.set(prerenderCandidate.pathname, { + pathname: prerenderCandidate.pathname, + fallbackRouteParams: prerenderCandidate.fallbackRouteParams, + fallbackMode: prerenderCandidate.fallbackMode, + fallbackRootParams: prerenderCandidate.fallbackRootParams, + remainingPrerenderableParams: + prerenderCandidate.remainingPrerenderableParams, + }) + } + } + + const prerenderRouteMatchers = + prerenderRouteMatchersByPathname.size > 0 + ? [...prerenderRouteMatchersByPathname.values()] + : undefined + + return { fallbackMode, prerenderedRoutes, prerenderRouteMatchers } } diff --git a/packages/next/src/build/static-paths/types.ts b/packages/next/src/build/static-paths/types.ts index 93e2d8881c81..2c324d12648b 100644 --- a/packages/next/src/build/static-paths/types.ts +++ b/packages/next/src/build/static-paths/types.ts @@ -52,9 +52,40 @@ type FallbackPrerenderedRoute = { throwOnEmptyStaticShell: boolean } +/** + * A route the build plans to prerender. Rendering decides whether the result + * becomes a published output: for example, an allowed empty fallback shell is + * discarded and its matcher becomes blocking instead. + * + * The historical name is retained because this type is used throughout static + * path generation, but values of this type are prerender candidates rather + * than guaranteed outputs. + */ export type PrerenderedRoute = StaticPrerenderedRoute | FallbackPrerenderedRoute +/** + * Describes how a dynamic pathname is matched when no concrete build-time + * output matches it. It describes the logical route independently of any + * artifacts produced for it, and is not itself something to render. + * + * Zero or more prerender candidates may share this pathname. In particular, + * variants can produce several artifacts for one logical matcher, so consumers + * must not assume pathname identifies a single candidate or render result. + */ +export type PrerenderRouteMatcher = { + readonly pathname: string + readonly fallbackRouteParams: readonly FallbackRouteParam[] + readonly fallbackMode: FallbackMode | undefined + readonly fallbackRootParams: readonly string[] + readonly remainingPrerenderableParams?: readonly FallbackRouteParam[] +} + export type StaticPathsResult = { fallbackMode: FallbackMode | undefined + + /** Planned renders, some of which may be discarded after rendering. */ prerenderedRoutes: PrerenderedRoute[] | undefined + + /** Logical request matchers, independent of the artifacts rendered for them. */ + prerenderRouteMatchers?: PrerenderRouteMatcher[] } diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index cff981644a6b..cb850f904967 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -69,7 +69,10 @@ import { createIncrementalCache } from '../export/helpers/create-incremental-cac import { collectRootParamKeys } from './segment-config/app/collect-root-param-keys' import { buildAppStaticPaths } from './static-paths/app' import { buildPagesStaticPaths } from './static-paths/pages' -import type { PrerenderedRoute } from './static-paths/types' +import type { + PrerenderRouteMatcher, + PrerenderedRoute, +} from './static-paths/types' import type { CacheControl } from '../server/lib/cache-control' import { formatExpire, formatRevalidate } from './output/format' import type { @@ -672,6 +675,7 @@ type PageIsStaticResult = { hasServerProps?: boolean hasStaticProps?: boolean prerenderedRoutes: PrerenderedRoute[] | undefined + prerenderRouteMatchers: PrerenderRouteMatcher[] | undefined prerenderFallbackMode: FallbackMode | undefined rootParamKeys: readonly string[] | undefined isNextImageImported?: boolean @@ -742,6 +746,7 @@ export async function isPageStatic({ isRoutePPREnabled: false, prerenderFallbackMode: undefined, prerenderedRoutes: undefined, + prerenderRouteMatchers: undefined, rootParamKeys: undefined, hasStaticProps: false, hasServerProps: false, @@ -768,6 +773,7 @@ export async function isPageStatic({ let componentsResult: LoadComponentsReturnType let prerenderedRoutes: PrerenderedRoute[] | undefined + let prerenderRouteMatchers: PrerenderRouteMatcher[] | undefined let prerenderFallbackMode: FallbackMode | undefined let appConfig: AppSegmentConfig = {} let rootParamKeys: readonly string[] | undefined @@ -887,29 +893,32 @@ export async function isPageStatic({ ;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } = buildStaticMetadataStaticPaths(page)) } else { - ;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } = - await buildAppStaticPaths({ - dir, - page, - route, - cacheComponents, - authInterrupts, - useCacheTimeout, - staticPageGenerationTimeout, - segments, - distDir, - requestHeaders: {}, - isrFlushToDisk, - cacheMaxMemorySize, - cacheHandler, - cacheLifeProfiles, - ComponentMod, - nextConfigOutput, - isRoutePPREnabled, - buildId, - deploymentId, - rootParamKeys, - })) + ;({ + prerenderedRoutes, + prerenderRouteMatchers, + fallbackMode: prerenderFallbackMode, + } = await buildAppStaticPaths({ + dir, + page, + route, + cacheComponents, + authInterrupts, + useCacheTimeout, + staticPageGenerationTimeout, + segments, + distDir, + requestHeaders: {}, + isrFlushToDisk, + cacheMaxMemorySize, + cacheHandler, + cacheLifeProfiles, + ComponentMod, + nextConfigOutput, + isRoutePPREnabled, + buildId, + deploymentId, + rootParamKeys, + })) } } } else { @@ -982,6 +991,7 @@ export async function isPageStatic({ isRoutePPREnabled, prerenderFallbackMode, prerenderedRoutes, + prerenderRouteMatchers, rootParamKeys, hasStaticProps, hasServerProps, diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/[bottom]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/[bottom]/page.tsx new file mode 100644 index 000000000000..d1b09a9676c2 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/[bottom]/page.tsx @@ -0,0 +1,8 @@ +export default async function Page({ + params, +}: { + params: Promise<{ top: string; bottom: string }> +}) { + const { top, bottom } = await params + return

{`Dynamic page: ${top}/${bottom}`}

+} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/layout.tsx new file mode 100644 index 000000000000..f8a898b8189e --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/[top]/layout.tsx @@ -0,0 +1,23 @@ +import { Suspense, type ReactNode } from 'react' +import { NoInline } from '../../../components/no-inline' + +export function generateStaticParams() { + return [{ top: 't1' }] +} + +export default async function Layout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ top: string }> +}) { + const { top } = await params + return ( +
+ +

{`Top: ${top}`}

+ Loading bottom...

}>{children}
+
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/layout.tsx new file mode 100644 index 000000000000..a1e138ba8b34 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-dynamic-partial/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+

Static parent

+ {children} +
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts index 64959a338c81..bd0549b6a750 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts @@ -185,7 +185,7 @@ async function getRouteTreeFromHistory( } describe('prefetch inlining', () => { - const { next, isNextDev, isTurbopack } = nextTestSetup({ + const { next, isNextDev, isNextStart, isTurbopack } = nextTestSetup({ files: __dirname, }) @@ -533,6 +533,40 @@ describe('prefetch inlining', () => { ) }) + if (isNextStart) { + it('partially generated dynamic route: build hints use the most specific shell', async () => { + const hints = await next.readJSON('.next/server/prefetch-hints.json') + + expect(hints['/test-dynamic-partial/[top]/[bottom]']) + .toMatchInlineSnapshot(` + { + "hints": 64, + "slots": { + "children": { + "hints": 96, + "slots": { + "children": { + "hints": 32, + "slots": { + "children": { + "hints": 64, + "slots": { + "children": { + "hints": 160, + "slots": null, + }, + }, + }, + }, + }, + }, + }, + }, + } + `) + }) + } + // TODO: Add a test for stale hints (InliningHintsStale). The stale hints // mechanism expires the route cache entry so the next prefetch re-fetches // the correct tree. This is hard to test reliably with act() because the From fe87d7ac7578917a237dd32ec6ead890940f6870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=98=84?= Date: Wed, 19 Aug 2026 07:08:17 +0900 Subject: [PATCH 02/10] docs: fix typos in example links (#97149) Fixes typos in the `analytics.tsx` link syntax in the two Segment example READMEs. Co-authored-by: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> --- examples/with-segment-analytics-pages-router/README.md | 2 +- examples/with-segment-analytics/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/with-segment-analytics-pages-router/README.md b/examples/with-segment-analytics-pages-router/README.md index 65cc4b3b7d56..c51a3993a8b4 100644 --- a/examples/with-segment-analytics-pages-router/README.md +++ b/examples/with-segment-analytics-pages-router/README.md @@ -1,6 +1,6 @@ # With Segment Analytics (Pages Router) -This example shows how to use Next.js along with [Segment Analytics](https://segment.com) using [segmentio/analytics-next](https://github.com/segmentio/analytics-next). The custom app [component](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics-pages-router/pages/_app.tsx) includes a component (analytics.tsx)[(https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics-pages-router/components/analytics.tsx)] which loads Segment and also exports the `analytics` object which can be imported and used to call the [Track API](https://segment.com/docs/connections/spec/track/) on user actions (Refer to [`contact.tsx`](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics-pages-router/pages/contact.tsx)). +This example shows how to use Next.js along with [Segment Analytics](https://segment.com) using [segmentio/analytics-next](https://github.com/segmentio/analytics-next). The custom app [component](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics-pages-router/pages/_app.tsx) includes a component [`analytics.tsx`](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics-pages-router/components/analytics.tsx) which loads Segment and also exports the `analytics` object which can be imported and used to call the [Track API](https://segment.com/docs/connections/spec/track/) on user actions (Refer to [`contact.tsx`](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics-pages-router/pages/contact.tsx)). ## Deploy your own diff --git a/examples/with-segment-analytics/README.md b/examples/with-segment-analytics/README.md index 3a12203fedbf..db782187cb64 100644 --- a/examples/with-segment-analytics/README.md +++ b/examples/with-segment-analytics/README.md @@ -1,6 +1,6 @@ # With Segment Analytics -This example shows how to use Next.js along with [Segment Analytics](https://segment.com) using [segmentio/analytics-next](https://github.com/segmentio/analytics-next). The main app [layout](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics/app/layout.tsx) includes a Client Component (analytics.tsx)[(https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics/components/analytics.tsx)] which loads Segment and also exports the `analytics` object which can be imported and used to call the [Track API](https://segment.com/docs/connections/spec/track/) on user actions (Refer to [`contact.tsx`](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics/app/contact/page.tsx)). +This example shows how to use Next.js along with [Segment Analytics](https://segment.com) using [segmentio/analytics-next](https://github.com/segmentio/analytics-next). The main app [layout](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics/app/layout.tsx) includes a Client Component [`analytics.tsx`](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics/components/analytics.tsx) which loads Segment and also exports the `analytics` object which can be imported and used to call the [Track API](https://segment.com/docs/connections/spec/track/) on user actions (Refer to [`contact.tsx`](https://github.com/vercel/next.js/blob/canary/examples/with-segment-analytics/app/contact/page.tsx)). ## Deploy your own From 0ff70fd55b40c790e58b8d26e4285a52ea1e7b0d Mon Sep 17 00:00:00 2001 From: niketchandivade <56070211+niketchandivade@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:01:36 +0530 Subject: [PATCH 03/10] fix(examples): correct error message typo (#97223) ## Summary While reviewing the examples in the Next.js repository for potential improvements, I noticed a typo in the error message displayed when an unexpected error occurs. ## Changes - Corrected `"An unexpected error happened occurred:"` to `"An unexpected error occurred:"`. ## Testing No functional changes. This is a text-only fix. Co-authored-by: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> --- examples/with-magic/pages/login.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/with-magic/pages/login.js b/examples/with-magic/pages/login.js index 8b93533b4356..69fcca222528 100644 --- a/examples/with-magic/pages/login.js +++ b/examples/with-magic/pages/login.js @@ -39,7 +39,7 @@ const Login = () => { throw new Error(await res.text()); } } catch (error) { - console.error("An unexpected error happened occurred:", error); + console.error("An unexpected error occurred:", error); setErrorMsg(error.message); } } From c87d742332f78433889a0f6eaa24c433da458e70 Mon Sep 17 00:00:00 2001 From: Sean Beirnes <127370575+seanbeirnes@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:33:00 -0400 Subject: [PATCH 04/10] [docs] fix: grammar typos in linking and navigating guide (#95544) ### What? Fix grammar issues in the "Linking and Navigating" guide. ### Why? This sentence had a few grammar issues: - "makes" should be "make" - "a server-rendered apps" should be "server-rendered apps" - "it enables" should be "they enable" ### How? Updated the sentence in `docs/01-app/01-getting-started/04-linking-and-navigating.mdx` to correct grammar and improve subject-verb agreement. --------- Co-authored-by: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> --- docs/01-app/01-getting-started/04-linking-and-navigating.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx index de0e4c1b73bb..08a04ec62454 100644 --- a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx +++ b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx @@ -158,7 +158,7 @@ Next.js avoids this with client-side transitions using the `` component. I - Keeping any shared layouts and UI. - Replacing the current page with the prefetched loading state or a new page if available. -Client-side transitions are what makes a server-rendered apps _feel_ like client-rendered apps. And when paired with [prefetching](#prefetching) and [streaming](#streaming), it enables fast transitions, even for dynamic routes. +Client-side transitions make server-rendered apps _feel_ like client-rendered apps. And when paired with [prefetching](#prefetching) and [streaming](#streaming), they enable fast transitions, even for dynamic routes. Next.js also handles [scrolling to the top of the page](/docs/app/api-reference/components/link#scroll) during client-side transitions. If content scrolls behind a sticky or fixed header after navigation, you can fix this with CSS [`scroll-padding-top`](/docs/app/api-reference/components/link#scroll-offset-with-sticky-headers). From 520de42f19e9f79f8cc768aad7794cc8064454d6 Mon Sep 17 00:00:00 2001 From: niketchandivade <56070211+niketchandivade@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:03:46 +0530 Subject: [PATCH 05/10] fix: improve form accessibility by associating labels with inputs (#96335) ## Summary This PR improves the accessibility of the `with-apivideo-upload` example by associating form labels with their corresponding form controls using the `htmlFor` attribute. ## Changes - Added `htmlFor="link"` to the "Play button color" label. - Added `htmlFor="linkHover"` to the "Buttons hover color" label. - Added an `id` to the "Hide controls" checkbox and associated its label using `htmlFor`. ## Why Associating labels with their corresponding form controls improves accessibility by: - Allowing screen readers to correctly announce form labels. - Enabling users to focus or toggle controls by clicking their labels. - Following HTML and WCAG best practices for accessible forms. ## Before - Labels were visually displayed but were not programmatically associated with their respective inputs. ## After - Each label is associated with its corresponding form control via `htmlFor` and `id`, improving accessibility without changing functionality. ### Testing - Verified that clicking each label focuses or toggles the associated control. - No visual changes. Co-authored-by: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> --- examples/with-apivideo/pages/videos/[videoId].tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/with-apivideo/pages/videos/[videoId].tsx b/examples/with-apivideo/pages/videos/[videoId].tsx index 43d35ec6bdee..b2d81ccc73e3 100644 --- a/examples/with-apivideo/pages/videos/[videoId].tsx +++ b/examples/with-apivideo/pages/videos/[videoId].tsx @@ -68,7 +68,7 @@ const VideoView: NextPage = ({
- + = ({ />
- + = ({
{ setHideControls(e.currentTarget.checked); }} /> - +
Date: Wed, 19 Aug 2026 00:37:43 +0200 Subject: [PATCH 06/10] docs: rename Vercel Edge Config to Global Config in redirecting guide (#97456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Vercel's **Edge Config** product is now **Global Config**. This updates the two stale-name occurrences flagged in the redirecting guide. The URL was already migrated in #96723 (`/docs/edge-config/get-started` → `/docs/global-config/get-started`), but that pass only rewrote link targets — the visible product name and the SDK import in the code samples still said `Edge Config` / `@vercel/edge-config`, so the page read *"Vercel's Edge Config"* while linking to the Global Config docs. ## Changes `docs/01-app/02-guides/redirecting.mdx`, in *Managing redirects at scale → Creating and storing a redirect map*: | | Before | After | | --- | --- | --- | | Prose | Vercel's [Edge Config] | Vercel's [Global Config] | | `proxy.ts` / `proxy.js` samples | `import { get } from '@vercel/edge-config'` | `import { get } from '@vercel/global-config'` | The link target, the neighbouring [Redis](https://vercel.com/docs/redis) link, and the surrounding sentence structure are unchanged. ## Why the package import changed too The renamed package is published and is the same library — `@vercel/global-config@1.5.1`, from `vercel/storage` at `packages/global-config`, matching `@vercel/edge-config@1.5.1` — and it exports the same `get`. Vercel's own [Global Config quickstart](https://vercel.com/docs/global-config/get-started) now uses `import { get } from '@vercel/global-config'`. Leaving the old specifier would have left the sample importing a legacy package name on a page that calls the product Global Config. `@vercel/edge-config` is not yet formally deprecated on npm, so this is a naming-consistency change rather than a fix for broken code. ## Both flagged pages Two docs paths were flagged — `/docs/app/guides/redirecting` and `/docs/pages/guides/redirecting` — but they share one source. `docs/02-pages/02-guides/redirecting.mdx` is a generated stub (`source: app/guides/redirecting`, carrying the *DO NOT EDIT* banner), and the affected paragraph is not wrapped in ``/``, so it renders on both. Editing the App Router source fixes both pages; no separate edit to the Pages file is needed or allowed. ## Verification - `grep -rn "Edge Config\|edge-config" docs/` → no matches remain. - `prettier --check` and `alex` pass on both files; the pre-commit `lint-staged` (prettier + eslint) pass ran clean. - `https://vercel.com/docs/global-config/get-started` → `200`; the old `/docs/edge-config/get-started` → `308`, confirming the link already points at the final target. Docs-only change; no code or tests affected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Rich Haines <22930449+molebox@users.noreply.github.com> --- docs/01-app/02-guides/redirecting.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/01-app/02-guides/redirecting.mdx b/docs/01-app/02-guides/redirecting.mdx index 46ab94d0ca4e..cf7bda8b9e13 100644 --- a/docs/01-app/02-guides/redirecting.mdx +++ b/docs/01-app/02-guides/redirecting.mdx @@ -376,11 +376,11 @@ Consider the following data structure: } ``` -In [Proxy](/docs/app/api-reference/file-conventions/proxy), you can read from a database such as Vercel's [Edge Config](https://vercel.com/docs/global-config/get-started) or [Redis](https://vercel.com/docs/redis), and redirect the user based on the incoming request: +In [Proxy](/docs/app/api-reference/file-conventions/proxy), you can read from a database such as Vercel's [Global Config](https://vercel.com/docs/global-config/get-started) or [Redis](https://vercel.com/docs/redis), and redirect the user based on the incoming request: ```ts filename="proxy.ts" switcher import { NextResponse, NextRequest } from 'next/server' -import { get } from '@vercel/edge-config' +import { get } from '@vercel/global-config' type RedirectEntry = { destination: string @@ -404,7 +404,7 @@ export async function proxy(request: NextRequest) { ```js filename="proxy.js" switcher import { NextResponse } from 'next/server' -import { get } from '@vercel/edge-config' +import { get } from '@vercel/global-config' export async function proxy(request) { const pathname = request.nextUrl.pathname From 0eb377541648e63402a47b38fee00a8d0ce8f8f1 Mon Sep 17 00:00:00 2001 From: niketchandivade <56070211+niketchandivade@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:11:00 +0530 Subject: [PATCH 07/10] style(examples): remove redundant justify-content declaration (#97222) ## Summary While reviewing the examples in the Next.js repository for potential improvements, I noticed a redundant `justify-content` declaration in the `.submit` styles. ## Changes - Removed the redundant `justify-content: flex-end` declaration. - Kept `justify-content: space-between`, which overrides the previous declaration. ## Testing No functional changes. This is a CSS cleanup only. Co-authored-by: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> --- examples/with-magic/components/form.js | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/with-magic/components/form.js b/examples/with-magic/components/form.js index c157de3b3ddd..234f7c2bcbfb 100644 --- a/examples/with-magic/components/form.js +++ b/examples/with-magic/components/form.js @@ -28,7 +28,6 @@ const Form = ({ errorMessage, onSubmit }) => ( } .submit { display: flex; - justify-content: flex-end; align-items: center; justify-content: space-between; } From d07b580f9915377b62bfa86deb4902320dfc4a2d Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:22:34 +0000 Subject: [PATCH 08/10] v16.3.1-canary.24 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/devlow-bench/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-internal/package.json | 2 +- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-playwright/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-routing/package.json | 2 +- packages/next-rspack/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 14 +++++++------- packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 16 ++++++++-------- 22 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lerna.json b/lerna.json index e09d41f80fa6..ac12ebd57423 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.3.1-canary.23" + "version": "16.3.1-canary.24" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 327d14118d19..f8950dd7f932 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "keywords": [ "react", "next", diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index fed4a057cc93..2f66ad728027 100644 --- a/packages/devlow-bench/package.json +++ b/packages/devlow-bench/package.json @@ -1,7 +1,7 @@ { "name": "@vercel/devlow-bench", "private": true, - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "Benchmarking tool for the developer workflow", "repository": { "type": "git", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 24ef5919c477..c1a8f834aa70 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.3.1-canary.23", + "@next/eslint-plugin-next": "16.3.1-canary.24", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index c7c67cb1fe3a..88d9d9c200e3 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 5a6837ea0c94..fb2f835e9167 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index cbb994b7d2d0..54d2387933f8 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 1dc42e416810..e5d460d26ee8 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 3656ee61be7e..ab88dc94269f 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 955448078f9d..2d4f6977552d 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 97513eb23987..f7afa0de78e3 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 2d76a3617656..4e9fdb072a48 100644 --- a/packages/next-playwright/package.json +++ b/packages/next-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@next/playwright", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-playwright" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 8db48e9bd0cf..496cb796844c 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index f31f28bc1206..86aa388a3957 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 32031cc3a355..a96e118e0124 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index b5dea8319a57..d8f710b67c24 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index a85695747375..cc42df58b3d7 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index d12f378f521b..794b151969fe 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index ec4013ff932c..1e40f227e73e 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "16.3.1-canary.23", + "@next/env": "16.3.1-canary.24", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -164,11 +164,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.42.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.3.1-canary.23", - "@next/polyfill-module": "16.3.1-canary.23", - "@next/polyfill-nomodule": "16.3.1-canary.23", - "@next/react-refresh-utils": "16.3.1-canary.23", - "@next/swc": "16.3.1-canary.23", + "@next/font": "16.3.1-canary.24", + "@next/polyfill-module": "16.3.1-canary.24", + "@next/polyfill-nomodule": "16.3.1-canary.24", + "@next/react-refresh-utils": "16.3.1-canary.24", + "@next/swc": "16.3.1-canary.24", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.61.0", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index c07c6a9e857a..bbfa30468eff 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 95b1a958da05..274a8bf5f91a 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.3.1-canary.23", + "version": "16.3.1-canary.24", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.3.1-canary.23", + "next": "16.3.1-canary.24", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acc28398c22c..c0eb8bda2eda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1024,7 +1024,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1107,7 +1107,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../next-env '@swc/helpers': specifier: 0.5.23 @@ -1228,19 +1228,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../font '@next/polyfill-module': - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../react-refresh-utils '@next/swc': - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1983,7 +1983,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.3.1-canary.23 + specifier: 16.3.1-canary.24 version: link:../next outdent: specifier: 0.8.0 From dc5fe22519e355cdf5189aa96e8e30af0a68d46e Mon Sep 17 00:00:00 2001 From: KAM Date: Wed, 19 Aug 2026 07:29:32 +0800 Subject: [PATCH 09/10] docs: document metadata pagination field (#95509) ### What? Documents the `pagination` metadata field in the `generateMetadata` API reference. ### Why? The `Metadata` type supports `pagination.previous` and `pagination.next`, but the Metadata Fields documentation did not list the field. Fixes #83264 ### How? Adds a `pagination` section with an example and the generated `` / `` output. ### Tests - `pnpm prettier --check docs/01-app/03-api-reference/04-functions/generate-metadata.mdx` - `pnpm lint-eslint docs/01-app/03-api-reference/04-functions/generate-metadata.mdx` - `git diff --check HEAD~1..HEAD` --- .../04-functions/generate-metadata.mdx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/01-app/03-api-reference/04-functions/generate-metadata.mdx b/docs/01-app/03-api-reference/04-functions/generate-metadata.mdx index 6eeebd8151a1..064e5dd77c7a 100644 --- a/docs/01-app/03-api-reference/04-functions/generate-metadata.mdx +++ b/docs/01-app/03-api-reference/04-functions/generate-metadata.mdx @@ -923,6 +923,24 @@ export const metadata = { ``` +### `pagination` + +Describes the previous and next pages in a paginated sequence. + +```jsx filename="layout.js | page.js" +export const metadata = { + pagination: { + previous: 'https://nextjs.org/blog?page=1', + next: 'https://nextjs.org/blog?page=3', + }, +} +``` + +```html filename=" output" hideLineNumbers + + +``` + ### `category` ```jsx filename="layout.js | page.js" From b677feb02fd8be9f79c470a4ee395af9affd27fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Woodruff=20=E2=80=AE?= Date: Tue, 18 Aug 2026 17:05:14 -0700 Subject: [PATCH 10/10] Turbopack: More aggressively debounce filesystem watch events if we detected changes to node_modules (#96116) Previously, we were debouncing update by sleeping 1ms at a time on macos and windows, and 10ms at a time on Linux. During a slow `pnpm install`, or a `git checkout`, this could cause us to do a bunch of extra throwaway work. Changes: - Increase the debounce interval to a consistent 10ms everywhere. This should still be small enough that it's not noticable on macos or windows. - If an event touches `node_modules`, there's a good chance that a package manager is running and many other files will be modified, so extend the batch deadline by 200ms instead of 10ms. - Because there's a chance that the batch deadline could get extended indefinitely (this was always possible, just more likely now) include a compilation event that gets logged after 5 seconds. --- crates/next-api/src/project.rs | 7 +- .../src/server/dev/hot-reloader-turbopack.ts | 1 + .../fs-settling-event/app/layout.tsx | 11 ++ .../fs-settling-event/app/page.tsx | 9 + .../fs-settling-event.test.ts | 46 +++++ .../fs-settling-fixture-pkg/index.js | 1 + .../fs-settling-fixture-pkg/package.json | 5 + turbopack/crates/turbo-tasks-fs/src/lib.rs | 2 +- .../src/watcher/batch_schedule.rs | 171 ++++++++++++++++++ .../crates/turbo-tasks-fs/src/watcher/mod.rs | 127 +++++++++---- turbopack/crates/turbopack-nodejs/src/fs.rs | 21 +++ turbopack/crates/turbopack-nodejs/src/lib.rs | 1 + 12 files changed, 362 insertions(+), 40 deletions(-) create mode 100644 test/development/fs-settling-event/app/layout.tsx create mode 100644 test/development/fs-settling-event/app/page.tsx create mode 100644 test/development/fs-settling-event/fs-settling-event.test.ts create mode 100644 test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/index.js create mode 100644 test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/package.json create mode 100644 turbopack/crates/turbo-tasks-fs/src/watcher/batch_schedule.rs create mode 100644 turbopack/crates/turbopack-nodejs/src/fs.rs diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index ce5db197ca4f..5cfc1be77210 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -93,7 +93,7 @@ use turbopack_node::child_process_backend; use turbopack_node::execution_context::ExecutionContext; #[cfg(feature = "worker_pool")] use turbopack_node::worker_threads_backend; -use turbopack_nodejs::NodeJsChunkingContext; +use turbopack_nodejs::{NodeJsChunkingContext, fs::NodeModulesPathMatcher}; use crate::{ aggregate_hmr::{AggregateHmrVersion, ChunkListUpdateBuilder, DiffResult, diff_chunks_against}, @@ -1094,10 +1094,13 @@ impl Project { *self.root_path, vec![denied_path, denied_profiles_path], DiskWatcherConfig { - recursive_mode: None, poll_interval: self.watch.poll_interval, // the dev server reports these to the user report_invalidation_reason: true, + extended_batch_delay_matcher: Some(ResolvedVc::upcast( + NodeModulesPathMatcher.resolved_cell(), + )), + ..Default::default() }, )) } diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index ae393a3fe587..d0fc70d1cc6a 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -496,6 +496,7 @@ export async function createHotReloaderTurbopack( 'StartupCacheInvalidationEvent', 'TimingEvent', 'SlowFilesystemEvent', + 'FilesystemSettlingEvent', 'TraceEvent', ], parentSpan: hotReloaderSpan, diff --git a/test/development/fs-settling-event/app/layout.tsx b/test/development/fs-settling-event/app/layout.tsx new file mode 100644 index 000000000000..08eaa94fdc88 --- /dev/null +++ b/test/development/fs-settling-event/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/development/fs-settling-event/app/page.tsx b/test/development/fs-settling-event/app/page.tsx new file mode 100644 index 000000000000..3301daa998be --- /dev/null +++ b/test/development/fs-settling-event/app/page.tsx @@ -0,0 +1,9 @@ +// Importing this package makes Turbopack read (and therefore watch) the file +// inside `node_modules`, so the writes the test performs generate watcher +// events. This matters on Linux, where the watcher is non-recursive and only +// watches directories it has been asked to read. +import counter from 'fs-settling-fixture-pkg' + +export default function Page() { + return

counter: {counter}

+} diff --git a/test/development/fs-settling-event/fs-settling-event.test.ts b/test/development/fs-settling-event/fs-settling-event.test.ts new file mode 100644 index 000000000000..1b9ade87ad46 --- /dev/null +++ b/test/development/fs-settling-event/fs-settling-event.test.ts @@ -0,0 +1,46 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import stripAnsi from 'strip-ansi' +import fs from 'fs' +import path from 'path' + +// The `FilesystemSettlingEvent` compilation event is Turbopack-only. +;(process.env.IS_TURBOPACK_TEST ? describe : describe.skip)( + 'fs-settling-event', + () => { + const { next } = nextTestSetup({ files: __dirname }) + + it('logs a settling event during sustained node_modules churn', async () => { + // Compile the page first so the imported `node_modules` file is watched. + await next.render('/') + + const pkgFile = path.join( + next.testDir, + 'node_modules/fs-settling-fixture-pkg/index.js' + ) + const outputIndex = next.cliOutput.length + + // Rewrite the imported module every 20ms. Since 20ms is well below the + // extended `node_modules` batch delay (200ms), the watcher keeps a single + // batch of events open, which triggers the settling event after ~5s. + let i = 0 + const interval = setInterval(() => { + fs.writeFileSync(pkgFile, `export default ${i++}\n`) + }, 20) + + try { + await retry( + () => { + const output = stripAnsi(next.cliOutput.slice(outputIndex)) + expect(output).toContain('waiting for the filesystem to settle') + }, + // The event fires after ~5s; allow a generous window to avoid flakes. + 15000, + 500 + ) + } finally { + clearInterval(interval) + } + }) + } +) diff --git a/test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/index.js b/test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/index.js new file mode 100644 index 000000000000..029f788d6d4c --- /dev/null +++ b/test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/index.js @@ -0,0 +1 @@ +export default 0 diff --git a/test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/package.json b/test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/package.json new file mode 100644 index 000000000000..37ba57905675 --- /dev/null +++ b/test/development/fs-settling-event/node_modules/fs-settling-fixture-pkg/package.json @@ -0,0 +1,5 @@ +{ + "name": "fs-settling-fixture-pkg", + "version": "1.0.0", + "main": "index.js" +} diff --git a/turbopack/crates/turbo-tasks-fs/src/lib.rs b/turbopack/crates/turbo-tasks-fs/src/lib.rs index d0773b250a9a..93e748ee4502 100644 --- a/turbopack/crates/turbo-tasks-fs/src/lib.rs +++ b/turbopack/crates/turbo-tasks-fs/src/lib.rs @@ -60,7 +60,7 @@ pub use crate::{ path::{FileSystemPath, FileSystemPathOption, RealPathResult, RealPathResultError, rebase}, read_glob::ReadGlobResult, virtual_fs::VirtualFileSystem, - watcher::{DiskWatcherConfig, DiskWatcherRecursiveMode}, + watcher::{DiskWatcherConfig, DiskWatcherPathMatcher, DiskWatcherRecursiveMode}, windows::to_verbatim_with_case_folded_disk, }; diff --git a/turbopack/crates/turbo-tasks-fs/src/watcher/batch_schedule.rs b/turbopack/crates/turbo-tasks-fs/src/watcher/batch_schedule.rs new file mode 100644 index 000000000000..e9edab618384 --- /dev/null +++ b/turbopack/crates/turbo-tasks-fs/src/watcher/batch_schedule.rs @@ -0,0 +1,171 @@ +use std::{ + sync::{ + Arc, + mpsc::{Receiver, RecvTimeoutError}, + }, + time::{Duration, Instant}, +}; + +use serde::Serialize; +use turbo_tasks::message_queue::{CompilationEvent, Severity}; + +use crate::{DiskWatcherConfig, watcher::fs_api::DiskFileSystemWatcherApi}; + +/// Decides how long a batch of watcher events stays open, and emits a repeated +/// [`FilesystemSettlingEvent`] for as long as it does. +pub struct BatchSchedule { + settling_event_initial_delay: Duration, + settling_event_max_delay: Duration, + pending: Option, +} + +/// A batch that has at least one event in it and hasn't been flushed yet. +struct PendingBatch { + started: Instant, + /// The batch is flushed once this passes without any further events. + deadline: Instant, + /// When to emit the next [`FilesystemSettlingEvent`]. + settling_event_next_at: Instant, + /// Grows exponentially (up to [`BatchSchedule::settling_event_max_delay`]) so that a writer + /// holding a batch open for minutes doesn't flood the compilation event queue. + event_interval: Duration, +} + +impl BatchSchedule { + pub fn new(config: &DiskWatcherConfig) -> Self { + Self { + settling_event_initial_delay: config.settling_event_initial_delay, + settling_event_max_delay: config.settling_event_max_delay, + pending: None, + } + } + + /// Keeps the batch open for at least `delay` from now, opening a new batch if there isn't one. + pub fn extend(&mut self, delay: Duration) { + let now = Instant::now(); + let deadline = now.checked_add(delay).unwrap_or_else(far_future); + match &mut self.pending { + Some(pending) => pending.deadline = pending.deadline.max(deadline), + None => { + self.pending = Some(PendingBatch { + started: now, + deadline, + settling_event_next_at: now + .checked_add(self.settling_event_initial_delay) + .unwrap_or_else(far_future), + event_interval: self.settling_event_initial_delay, + }) + } + } + } + + /// Waits for the next watcher event, emitting [`FilesystemSettlingEvent`]s while the pending + /// batch keeps growing. If no batch is pending, this blocks until an event arrives. + /// + /// [`RecvTimeoutError::Timeout`] means the pending batch's deadline has passed *and* nothing + /// more is queued, so the batch is complete and should be flushed. + pub fn recv_event( + &mut self, + rx: &Receiver>, + fs: &FsApi, + ) -> Result, RecvTimeoutError> { + let max_event_delay = self.settling_event_max_delay; + loop { + let Some(pending) = &mut self.pending else { + // no pending batch: wait indefinitely + return rx.recv().map_err(|_| RecvTimeoutError::Disconnected); + }; + + let now = Instant::now(); + if now >= pending.settling_event_next_at { + pending.emit_settling_event(fs, now, max_event_delay); + } + + let timeout = pending + .deadline + .min(pending.settling_event_next_at) + .saturating_duration_since(now); + + match rx.recv_timeout(timeout) { + Ok(event) => { + return Ok(event); + } + Err(RecvTimeoutError::Timeout) => { + if Instant::now() >= pending.deadline { + self.pending = None; + return Err(RecvTimeoutError::Timeout); + } + continue; + } + Err(err) => return Err(err), + } + } + } + + /// Closes the pending batch, used when a rescan happens. + pub fn reset(&mut self) { + self.pending = None; + } +} + +impl PendingBatch { + fn emit_settling_event( + &mut self, + fs: &FsApi, + now: Instant, + max_event_delay: Duration, + ) { + let _guard = fs.tokio_handle().enter(); + if let Some(turbo_tasks) = fs.turbo_tasks() { + turbo_tasks.send_compilation_event(Arc::new(FilesystemSettlingEvent { + elapsed_secs: (now - self.started).as_secs_f64(), + })); + } + self.event_interval = self.event_interval.saturating_mul(2).min(max_event_delay); + // Schedule from "now" instead of accumulating intervals, so that emitting late (e.g. under + // heavy load) doesn't produce a catch-up burst of events. + self.settling_event_next_at = now + .checked_add(self.event_interval) + .unwrap_or_else(far_future); + } +} + +/// Emitted when frequent filesystem updates cause us to keep a batch open for an extended period of +/// time. Informing the user when this happens may help them understand what's happening, and that +/// Turbopack is not stalled. +#[derive(Debug, Clone, Serialize)] +pub struct FilesystemSettlingEvent { + /// How long the current batch has been held open, in seconds. + pub elapsed_secs: f64, +} + +impl CompilationEvent for FilesystemSettlingEvent { + fn type_name(&self) -> &'static str { + "FilesystemSettlingEvent" + } + + fn severity(&self) -> Severity { + Severity::Info + } + + fn message(&self) -> String { + format!( + "Turbopack has seen frequent file updates and is waiting for the filesystem to settle \ + ({:.1}s elapsed so far).", + self.elapsed_secs + ) + } + + fn to_json(&self) -> String { + serde_json::to_string(self).unwrap() + } +} + +// from https://github.com/tokio-rs/tokio/blob/29cd6ec1ec6f90a7ee1ad641c03e0e00badbcb0e/tokio/src/time/instant.rs#L57-L63 +fn far_future() -> Instant { + // Roughly 30 years from now. + // API does not provide a way to obtain max `Instant` + // or convert specific date in the future to instant. + // 1000 years overflows on macOS, 100 years overflows on FreeBSD. + Instant::now() + Duration::from_secs(86400 * 365 * 30) +} diff --git a/turbopack/crates/turbo-tasks-fs/src/watcher/mod.rs b/turbopack/crates/turbo-tasks-fs/src/watcher/mod.rs index 40f994033331..192284d21fde 100644 --- a/turbopack/crates/turbo-tasks-fs/src/watcher/mod.rs +++ b/turbopack/crates/turbo-tasks-fs/src/watcher/mod.rs @@ -1,3 +1,4 @@ +mod batch_schedule; mod fs_api; #[cfg(test)] mod mock_fs_api; @@ -11,7 +12,7 @@ use std::{ Arc, LazyLock, mpsc::{Receiver, RecvTimeoutError, channel}, }, - time::{Duration, Instant}, + time::Duration, }; use anyhow::{Context, Result}; @@ -31,7 +32,7 @@ use tokio::sync::{RwLock, RwLockWriteGuard}; use tracing::instrument; use turbo_rcstr::RcStr; use turbo_tasks::{ - FxIndexSet, InvalidationReason, InvalidationReasonKind, Invalidator, NonLocalValue, TaskInput, + FxIndexSet, InvalidationReason, InvalidationReasonKind, Invalidator, ResolvedVc, TraitRef, TurboTasksApi, spawn_thread, trace::TraceRawVcs, util::StaticOrArc, }; @@ -40,7 +41,7 @@ use crate::{ invalidation::{WatchChange, WatchStart}, invalidator_map::InvalidatorMap, path_map::OrderedPathMapExt, - watcher::fs_api::DiskFileSystemWatcherApi, + watcher::{batch_schedule::BatchSchedule, fs_api::DiskFileSystemWatcherApi}, }; /// Overrides [`DiskWatcherConfig::recursive_mode`]. Users shouldn't need to set this, this is @@ -59,9 +60,8 @@ static FORCED_WATCH_RECURSIVE_MODE: LazyLock> = }, ); -#[derive( - Clone, Copy, Debug, Default, Eq, PartialEq, Hash, TraceRawVcs, NonLocalValue, Encode, Decode, -)] +#[turbo_tasks::task_input] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, TraceRawVcs, Encode, Decode)] pub struct DiskWatcherConfig { /// Whether to let the [`notify::Watcher`] recurse into subdirectories itself, or to track and /// watch each directory we care about ourselves. @@ -85,14 +85,54 @@ pub struct DiskWatcherConfig { /// This costs an extra allocation per invalidated path, so it's only worth enabling when /// something actually consumes the reasons. pub report_invalidation_reason: bool, + + /// How long to keep a batch of filesystem events open, waiting for more events, before + /// flushing invalidations. Batching coalesces bursts (e.g. a `git checkout`) into a single + /// invalidation pass and avoids reading half-written files. + /// + /// If set too low (<10ms), this is known to cause partial file reads on Linux where `inotify` + /// has very low latency. + pub batch_delay: Duration, + /// When [`DiskWatcherPathMatcher::match_path`] returns `true`, we will extend the batch by + /// [`Self::extended_batch_delay_duration`]. + pub extended_batch_delay_matcher: Option>>, + /// The idle period required to close a batch once [`Self::extended_batch_delay_matcher`] has + /// matched. Unused when there is no matcher. + pub extended_batch_delay_duration: Duration, + + /// If a single batch stays open at least this long, emit a `FilesystemSettlingEvent` + /// compilation event so the user knows why work has stalled. Repeated events within the same + /// batch back off exponentially, up to [`Self::settling_event_max_delay`]. + pub settling_event_initial_delay: Duration, + /// Upper bound for the exponentially increasing interval between repeated + /// `FilesystemSettlingEvent`s within a single batch. + pub settling_event_max_delay: Duration, } -impl TaskInput for DiskWatcherConfig { - fn is_transient(&self) -> bool { - false +impl Default for DiskWatcherConfig { + fn default() -> Self { + Self { + recursive_mode: None, + poll_interval: None, + report_invalidation_reason: false, + batch_delay: Duration::from_millis(10), + extended_batch_delay_matcher: None, + extended_batch_delay_duration: Duration::from_millis(200), + settling_event_initial_delay: Duration::from_millis(500), + settling_event_max_delay: Duration::from_secs(60), + } } } +/// Matches absolute paths reported by the filesystem watcher. See +/// [`DiskWatcherConfig::extended_batch_delay_matcher`]. +#[turbo_tasks::value_trait] +pub trait DiskWatcherPathMatcher { + /// Called on the watcher thread once per path of every incoming event, so this should be + /// cheap and must not block. + fn match_path(&self, path: &Path) -> bool; +} + /// Equivalent to [`notify::RecursiveMode`], but implements traits needed by [`turbo_tasks`]. /// /// When using [`Self::Recursive`], [`notify::Watcher`] will recursively track all contents @@ -101,7 +141,8 @@ impl TaskInput for DiskWatcherConfig { /// /// When using [`Self::NonRecursive`], we only track previously read files and their parent /// directories. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, TraceRawVcs, NonLocalValue, Encode, Decode)] +#[turbo_tasks::task_input] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, TraceRawVcs, Encode, Decode)] pub enum DiskWatcherRecursiveMode { Recursive, NonRecursive, @@ -147,15 +188,6 @@ impl DiskWatcherConfig { } } -/// How long to extend an invalidation batch by when receiving new events, before flushing. This -/// reduces invalidations if the same file or directory is modified many times. -/// -/// Linux watching is too fast, so we need a longer delay there to avoid reading wip files. -#[cfg(target_os = "linux")] -const BATCH_DELAY: Duration = Duration::from_millis(10); -#[cfg(not(target_os = "linux"))] -const BATCH_DELAY: Duration = Duration::from_millis(1); - pub(crate) struct DiskWatcher { state: State, config: DiskWatcherConfig, @@ -451,6 +483,10 @@ mod non_recursive_helpers { impl DiskWatcher { pub fn new(config: DiskWatcherConfig) -> Self { + assert!( + config.extended_batch_delay_duration >= config.batch_delay, + "extended_batch_delay_duration must be at least batch_delay" + ); Self { state: State::new_stopped(config.resolve_recursive_mode()), config, @@ -459,6 +495,13 @@ impl DiskWatcher { pub async fn start_watching(fs: Arc) -> Result<()> { let watcher: &Self = fs.watcher(); + + // read in the turbo-task context and before acquiring the lock + let extended_batch_delay_matcher = match watcher.config.extended_batch_delay_matcher { + Some(matcher) => Some(matcher.into_trait_ref().await?), + None => None, + }; + let state_guard = watcher.state.write().await; // bail out if we're already watching @@ -513,7 +556,7 @@ impl DiskWatcher { spawn_thread({ let fs = fs.clone(); - move || Self::watch_thread(fs, rx) + move || Self::watch_thread(fs, rx, extended_batch_delay_matcher) }); // Updating `self.state` is done last. If we panic while setting up the watcher, it'll @@ -551,24 +594,20 @@ impl DiskWatcher { fn watch_thread( fs: Arc, rx: Receiver>, + extended_batch_delay_matcher: Option>>, ) { let watcher: &Self = fs.watcher(); - let report_invalidation_reason = watcher.config.report_invalidation_reason; + let config = &watcher.config; + let report_invalidation_reason = config.report_invalidation_reason; let mut batch = BatchedInvalidations::new( watcher.state.recursive_mode(), - watcher.config.poll_interval.is_some(), + config.poll_interval.is_some(), ); + let mut schedule = BatchSchedule::new(config); 'outer: loop { - let mut deadline: Option = None; loop { - let event_result = match deadline { - None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected), - Some(deadline) => { - rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) - } - }; - match event_result { + match schedule.recv_event(&rx, &*fs) { Ok(Ok(event)) => { // TODO: We might benefit from some user-facing diagnostics if it rescans // occur frequently (i.e. more than X times in Y minutes) @@ -613,13 +652,23 @@ impl DiskWatcher { // no need to process the rest of the batch as we just // invalidated everything batch.clear(); + schedule.reset(); break; } - // Only an event that contributes to the batch keeps it open for another - // `BATCH_DELAY`. + // Any event that contributes to the batch keeps it open for another + // `batch_delay`. A path matching `extended_batch_delay_matcher` (e.g. a + // package-manager install target) keeps it open for + // `extended_batch_delay_duration` instead. + let mut delay = config.batch_delay; + if let Some(matcher) = &extended_batch_delay_matcher + && event.paths.iter().any(|path| matcher.match_path(path)) + { + delay = delay.max(config.extended_batch_delay_duration); + } + if batch.add_event(event) { - deadline = Some(Instant::now() + BATCH_DELAY); + schedule.extend(delay); } } // Error raised by notify watcher itself @@ -629,16 +678,16 @@ impl DiskWatcher { let flags = InvalidationFlags::PATH_AND_CHILDREN | InvalidationFlags::PATH_AND_CHILDREN_DIR; if paths.is_empty() { - batch.mark(fs.root_path().into(), flags); + batch.mark(Box::from(fs.root_path()), flags); } else { for path in paths { batch.mark(path.into_boxed_path(), flags); } } - deadline = Some(Instant::now() + BATCH_DELAY); + schedule.extend(config.batch_delay); } Err(RecvTimeoutError::Timeout) => { - // The batch is complete: break out to invalidate the collected paths. + // the batch is complete: break out to invalidate the collected paths. break; } Err(RecvTimeoutError::Disconnected) => { @@ -1016,7 +1065,10 @@ impl InvalidationReasonKind for InvalidateRescanKind { #[cfg(test)] mod tests { - use std::{fs, time::SystemTime}; + use std::{ + fs, + time::{Instant, SystemTime}, + }; use rstest::rstest; use turbo_tasks::TurboTasks; @@ -1078,6 +1130,7 @@ mod tests { recursive_mode: Some(recursive_mode), poll_interval, report_invalidation_reason: true, + ..Default::default() }); let sub_dir = fs.root_path.join("sub"); let file_path = sub_dir.join("file.txt"); diff --git a/turbopack/crates/turbopack-nodejs/src/fs.rs b/turbopack/crates/turbopack-nodejs/src/fs.rs new file mode 100644 index 000000000000..dd9c6cee85d4 --- /dev/null +++ b/turbopack/crates/turbopack-nodejs/src/fs.rs @@ -0,0 +1,21 @@ +use std::{ + ffi::OsStr, + path::{Component, Path}, +}; + +use turbo_tasks_fs::DiskWatcherPathMatcher; + +/// Matches anything inside of a `node_modules` directory. +/// +/// Package managers churn `node_modules` heavily while the dev server is running. More aggressively +/// batching these may reduce system load during an installation. +#[turbo_tasks::value(shared)] +pub struct NodeModulesPathMatcher; + +#[turbo_tasks::value_impl] +impl DiskWatcherPathMatcher for NodeModulesPathMatcher { + fn match_path(&self, path: &Path) -> bool { + path.components() + .any(|component| component == Component::Normal(OsStr::new("node_modules"))) + } +} diff --git a/turbopack/crates/turbopack-nodejs/src/lib.rs b/turbopack/crates/turbopack-nodejs/src/lib.rs index 33660a5d5dd5..aee5487f7895 100644 --- a/turbopack/crates/turbopack-nodejs/src/lib.rs +++ b/turbopack/crates/turbopack-nodejs/src/lib.rs @@ -3,5 +3,6 @@ pub(crate) mod chunking_context; pub mod ecmascript; +pub mod fs; pub use chunking_context::{NodeJsChunkingContext, NodeJsChunkingContextBuilder};