From 844c62e1a652b57db4731abe8d22786e22b0aa0e Mon Sep 17 00:00:00 2001 From: niketchandivade <56070211+niketchandivade@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:40:39 +0530 Subject: [PATCH 1/7] fix: add accessible label to icon-only link (#96334) ## Summary This PR improves the accessibility of the Ant Design example by adding an accessible label to the icon-only `Link`. ### Changes - Added `aria-label="Home"` to the icon-only link. ### Why The link currently renders only an icon, so it does not provide an accessible name for assistive technologies. Adding an `aria-label` makes its purpose clear to screen reader users without changing the visual appearance. ### Before Screen readers announce the element as an unlabeled link. ### After Screen readers announce the link as **"Home"**. Co-authored-by: Marcos Hernanz <96699542+marcoshernanz@users.noreply.github.com> --- examples/with-ant-design/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/with-ant-design/app/page.tsx b/examples/with-ant-design/app/page.tsx index c3ae654c0c32..3366650085d5 100644 --- a/examples/with-ant-design/app/page.tsx +++ b/examples/with-ant-design/app/page.tsx @@ -19,7 +19,7 @@ const HomePage = () => (
- +

Welcome to the world !

From 6fbd61c152d5473bf6125bab8107b7845b7a8326 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Wed, 19 Aug 2026 23:21:56 +0200 Subject: [PATCH 2/7] [PPF] Do not mark complete shell requests as partial (#97503) The router code did not respect the isPartial byte for runtime app shells for some reason, potentially leading to unnecessary speculative prefetch requests even if the shell was in fact complete. The comments say that the byte on those can't be trusted: https://github.com/vercel/next.js/blob/d00eb6b6c471c9c51ef997f643b049210d9940a5/packages/next/src/client/components/segment-cache/cache.ts#L2694-L2696 which is not true -- the byte is definitely set to partial when a shell contains holes (due to URL data or link data). Updates some tests that now no longer trigger a speculative prefetch because the shell is complete. (note that our tests use `prefetch = 'partial'` a lot, so some of them were triggering speculative prefetches even when they shouldn't have. see #97469 for more) --- .../client/components/segment-cache/cache.ts | 23 ++++------ .../prefetch-app-shell/app/(default)/page.tsx | 9 ++++ .../(default)/runtime-shell-complete/page.tsx | 20 +++++++++ .../prefetch-app-shell.test.ts | 27 ++++++++++++ .../prefetch-inlining/app/page.tsx | 4 +- .../prefetch-inlining.test.ts | 13 +++--- .../prefetch-runtime/prefetch-runtime.test.ts | 23 ++++++---- .../app/speculative-cookies/page.tsx | 44 ++++++++++++++----- .../prefetch-static-shell.test.ts | 31 +++---------- 9 files changed, 127 insertions(+), 67 deletions(-) create mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/runtime-shell-complete/page.tsx diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 2b84f03e6322..367a23a6c0e0 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -2689,11 +2689,7 @@ export async function fetchSegmentPrefetchesUsingRuntimeRequest( // Runtime prefetch responses (PPRRuntime and RuntimeShell requests) are // partial when the server marks the response as '~' (Partial). - // Full/LoadingBoundary prefetch responses are always complete. This only - // describes the FULL payload: shell-tier writes don't consume it — a - // shell is partial by construction (RuntimeShell responses omit every - // dynamic suspense boundary below the shell stage, regardless of what - // the server marker says); see writeResponsePayloadsIntoCache. + // Full/LoadingBoundary prefetch responses are always complete. const isFullResponsePartial = (fetchStrategy === FetchStrategy.PPRRuntime || fetchStrategy === FetchStrategy.RuntimeShell) && @@ -2866,12 +2862,11 @@ function writeResponsePayloadsIntoCache( } return null } - // No shell exists, and the request wasn't a static shell walk. The full - // payload fulfills the spawned entries at the request's own keying — - // including for a RuntimeShell request (shell staging not enabled, or - // the render wasn't staged), whose response is then conservatively treated - // as the shell it asked for: keyed at the shell tier and partial - // by construction. + // This request either: + // - didn't allow recovering a shell (no staged rendering), + // - or was a (runtime) shell request, so we already have a shell without recovering anything. + // In either case, we don't have anything to consider other than the request itself, + // so the payload simply fulfills the spawned entries at the request's own keying. fulfilledEntries = writeServerResponseIntoCache( now, fetchStrategy, @@ -2882,9 +2877,7 @@ function writeResponsePayloadsIntoCache( renderedSearch, buildId, staleAt, - fetchStrategy === FetchStrategy.RuntimeShell - ? true - : isFullResponsePartial, + isFullResponsePartial, metadataVaryPath, spawnedEntries, null, @@ -2909,7 +2902,7 @@ function writeResponsePayloadsIntoCache( renderedSearch, buildId, staleAt, - shellWasRequested ? true : isFullResponsePartial, + isFullResponsePartial, metadataVaryPath, spawnedEntries, // The full payload's tier: PPR for a static response, PPRRuntime for diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx index 75fe55cd082a..8341347170c3 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx @@ -127,6 +127,15 @@ export default function Page() { + +

Complete shell

+
    +
  • + + Complete runtime shell + +
  • +
) } diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/runtime-shell-complete/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/runtime-shell-complete/page.tsx new file mode 100644 index 000000000000..93c157e43319 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/runtime-shell-complete/page.tsx @@ -0,0 +1,20 @@ +import { Suspense } from 'react' +import { cookies } from 'next/headers' + +export const prefetch = 'partial' + +export default function Page() { + return ( +
+ Loading cookie...

}> + +
+
+ ) +} + +async function CookieDependent() { + const cookieStore = await cookies() + const value = cookieStore.get('testCookie')?.value ?? 'none' + return +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts index 594d7601ae33..175cb029a2a9 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts @@ -70,6 +70,33 @@ describe('App Shell prefetching', () => { ) }) + it('can navigate without extra requests if a runtime app shell is complete', async () => { + let page: Playwright.Page + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page, { includeAppShellRequests: true }) + + // Reveal the link and fetch the shell. The route uses cookies, so this will be + // a runtime shell. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/runtime-shell-complete"]') + .click() + }, [{ includes: 'Cookie: none', kind: 'runtime' }]) + + // Navigate. The shell is complete, so this shouldn't require fetching anything else. + await act(async () => { + await browser.elementByCss('a[href="/runtime-shell-complete"]').click() + }, 'no-requests') + + expect(await browser.elementById('cookie-value').text()).toEqual( + 'Cookie: none' + ) + }) + it('runtime-prefetches per-link content of a dynamic route whose static-attempt hint is unset', async () => { let page: Playwright.Page const browser = await next.browser('/', { diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx index 009c47879f41..9b16f4878de9 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/page.tsx @@ -71,12 +71,12 @@ export default function Page() {
  • - + Independent head A
  • - + Independent head B
  • 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 bd0549b6a750..354f0b366fb6 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 @@ -636,13 +636,13 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) // Reveal a default (auto) link to the route. The route is a Partial // Prefetching route (the page is partial), so every segment the // prefetch walks is held to the runtime-completeness contract — and the // route's static-attempt hint is unset because the page reads cookies, - // so the walked layout deopts directly to the batched runtime prefetch, + // so the walked layout deopts directly to the batched runtime shell, // which serves its whole subtree. The inlined layout content arrives in // that runtime response. (No static bundle request fires: the Shell // phase already runtime-cached every entry in the bundle chain, and a @@ -659,9 +659,8 @@ describe('prefetch inlining', () => { { includes: 'Static layout content', kind: 'runtime' } ) - // Reveal a prefetch={true} link to the same route. Everything is - // already runtime-cached at the per-link tier by the prefetch above, so - // opting in has nothing left to fetch. + // Reveal a prefetch={true} link to the same route. The shell is complete, + // so a runtime prefetch will not give us any more data and should be skipped. await act(async () => { await browser .elementByCss( @@ -709,7 +708,7 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) await act( async () => { @@ -810,7 +809,7 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) await act( async () => { diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts b/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts index 962a769e58e0..41172c9a0b1a 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts @@ -510,18 +510,20 @@ describe('runtime prefetching', () => { // Clear cookies after the test. This currently doesn't happen automatically. await using _ = defer(() => browser.deleteCookies()) - const act = createRouterAct(page) + const act = createRouterAct(page, { includeAppShellRequests: true }) await browser.addCookie({ name: 'testCookie', value: 'initialValue' }) - // Reveal the link to trigger a runtime prefetch for the initial cookie value + // Reveal the link. + // We won't actually perform a runtime prefetch, because the request is + // satisfied by the app shell. await act(async () => { const linkToggle = await browser.elementByCss( `input[data-link-accordion="/${prefix}/cookies-only"]` ) await linkToggle.click() }, [ - // Should allow reading cookies + // Should allow reading cookies in the app shell { includes: 'Cookie: initialValue', }, @@ -561,11 +563,14 @@ describe('runtime prefetching', () => { // Clear cookies after the test. This currently doesn't happen automatically. await using _ = defer(() => browser.deleteCookies()) - const act = createRouterAct(page) + const act = createRouterAct(page, { includeAppShellRequests: true }) await browser.addCookie({ name: 'testCookie', value: 'initialValue' }) - // Reveal the link to trigger a runtime prefetch for the initial cookie value + // Reveal the link. + // We won't actually perform a runtime prefetch, because the request is + // satisfied by the app shell. + await act(async () => { const linkToggle = await browser.elementByCss( `input[data-link-accordion="/${prefix}/cookies-only"]` @@ -1056,11 +1061,13 @@ describe('runtime prefetching', () => { page = p }, }) - const act = createRouterAct(page) + const act = createRouterAct(page, { includeAppShellRequests: true }) const STATIC_CONTENT = 'This page errors after a cookies call' - // Reveal the link to trigger a runtime prefetch + // Reveal the link. + // We won't actually perform a runtime prefetch, because the request is + // satisfied by the app shell. await act(async () => { const linkToggle = await browser.elementByCss( `input[data-link-accordion="/errors/error-after-cookies"]` @@ -1077,7 +1084,7 @@ describe('runtime prefetching', () => { expect(getCliOutput()).toContain('Error: Kaboom') } - // Navigate to the page. We already have the paged cached. + // Navigate to the page. We already have the page cached. // Even though the render errored, we shouldn't fetch it again. await act(async () => { await browser diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx index f4291dc795f1..4fc87b81bdc7 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/app/speculative-cookies/page.tsx @@ -7,20 +7,16 @@ import { cookies } from 'next/headers' // Prefetching segment, the page requires runtime-completeness during the // Speculative phase (which the consuming test enters via a `prefetch={true}` // link), and with the hint unset the scheduler skips the static attempt -// entirely and issues the runtime prefetch directly. Unlike the uses-cookies -// fixture, the Speculative runtime prefetch RESOLVES the cookies() read, so -// the cookie-derived content itself arrives in the runtime response. +// entirely and issues the runtime prefetch directly. +// It also awaits searchParams so that a speculative prefetch has non-shell +// contents to resolve. export const prefetch = 'partial' -async function CookieContent() { - const cookieStore = await cookies() - const value = cookieStore.get('testCookie')?.value ?? 'none' - return ( - - ) +type PageProps = { + searchParams: Promise } -export default function Page() { +export default function Page(props: PageProps) { return (

    Speculative-cookies page shell text

    @@ -29,8 +25,34 @@ export default function Page() { } > - +
    ) } + +async function CookieContent(props: PageProps) { + const cookieStore = await cookies() + const value = cookieStore.get('testCookie')?.value ?? 'none' + return ( + <> + + Loading search params...

    + } + > + +
    + + ) +} + +type SearchParams = Record + +async function SearchParamsContent(props: PageProps) { + const searchCount = Object.keys(await props.searchParams).length + return ( +
    {`Search params count: ${searchCount}`}
    + ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts b/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts index 2f9890c31464..b137964f87a8 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-static-shell/prefetch-static-shell.test.ts @@ -123,11 +123,8 @@ describe('static App Shell prefetch attempt', () => { .elementByCss('input[data-link-accordion="/uses-cookies"]') .click() }, [ - // The page (the new part) arrives in the runtime shell response. - // (The bare cookies() read itself isn't resolved by the runtime - // prerender — it stays a hole for the navigation-time dynamic - // request — so we only assert on the shell text.) - { includes: 'Cookies page shell text', kind: 'runtime' }, + // Cookies are included in the runtime shell. + { includes: 'cookie-content', kind: 'runtime' }, // No static attempt for the new part: the page content must not // arrive in a static per-segment response. (The route tree prefetch // is also `kind: 'static'`, but its response doesn't contain rendered @@ -382,29 +379,15 @@ describe('static App Shell prefetch attempt', () => { .elementByCss('input[data-link-accordion="/speculative-cookies"]') .click() }, [ - // Two runtime responses arrive, in order, and each resolves the - // cookies() read (a runtime prefetch renders with the request's - // cookies): - // - // 1. The Shell phase's runtime shell request — the same direct - // runtime shell behavior the hint-unset tests above exercise, - // except that here the session content resolves instead of - // remaining a hole. - { includes: 'Speculative-cookies page shell text', kind: 'runtime' }, - { includes: 'Speculative-cookies cookie: none', kind: 'runtime' }, - // 2. The Speculative phase's runtime prefetch of the page segment, - // which re-delivers the page content. (It fires on top of the - // runtime shell entry because a per-URL runtime prefetch can - // provide content a URL-independent shell response cannot.) - { includes: 'Speculative-cookies page shell text', kind: 'runtime' }, + // 1. Runtime shell (includes cookies) { includes: 'Speculative-cookies cookie: none', kind: 'runtime' }, + // 2. Runtime prefetch (includes cookies and search params) + { includes: 'Search params count: 0', kind: 'runtime' }, + // No static attempt in either phase: the page content must not // arrive in ANY static per-segment response. (The server does emit // static data for the segment — the shell text is in it — but with - // the hint unset nothing fetches it: this blanket rejection - // was verified empirically against the full request log. The route - // tree prefetch is also kind: 'static', but its response doesn't - // contain rendered page content, so it can't match this.) + // the hint unset nothing fetches it. { includes: 'Speculative-cookies page shell text', kind: 'static', From c10b89598a767df084081defabc606bf99a37bfa Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Wed, 19 Aug 2026 23:21:57 +0200 Subject: [PATCH 3/7] [PPF] Remove `unstable_eager` (#97524) The implementation of `unstable_eager` is currently incomplete, causes bugs in the main Partial Prefetching flow (see #97469), and is generally not worth the complexity it adds, so we're removing it. We may add something like this back later if we think it's useful. Importantly, removing it fixes a bug that caused apps that use incremental opt-in (i.e. no global `partialPrefetching` config and `export const prefetch = 'partial'`) to issue speculative prefetches for all links without `prefetch={true}`, because any segment without an explicit `prefetch = "partial"` was marked as needing eager prefetching, which was then propagated to the whole route. See #97469 for a full explanation. This fix causes a lot of changes in tests, which were previously incorrectly asserting that we should be seeing two requests. Now that the unnecesary speculative prefetch is gone, most of them only see one. --- docs/01-app/02-guides/instant-navigation.mdx | 2 +- .../segment-config/app/app-segment-config.ts | 17 +- .../segment-cache/navigation-testing-lock.ts | 12 +- .../components/segment-cache/scheduler.ts | 58 ++--- .../next/src/server/app-render/app-render.tsx | 10 +- .../app-render/create-component-tree.tsx | 2 +- .../create-transport-tree-from-loader-tree.ts | 31 +-- .../instant-validation/instant-config.tsx | 9 +- .../walk-tree-with-flight-router-state.tsx | 2 +- packages/next/src/server/config-schema.ts | 4 +- packages/next/src/server/config-shared.ts | 6 +- packages/next/src/server/lib/app-info-log.ts | 6 +- packages/next/src/server/lib/render-server.ts | 6 +- .../src/server/typescript/rules/config.ts | 7 +- .../next/src/shared/lib/app-router-types.ts | 28 +- .../cached-navigations.test.ts | 14 +- .../cached-navigations/default/app/page.tsx | 5 - .../default/app/prefetch-eager/page.tsx | 13 - .../app/layout.tsx | 9 - .../app/page.tsx | 17 -- .../app/posts/[id]/page.tsx | 25 -- .../components/link-accordion.tsx | 33 --- .../next.config.ts | 18 -- .../prefetch-app-shell-global-eager.test.ts | 45 ---- .../app/(default)/eager-instant/[id]/page.tsx | 31 --- .../app/(default)/eager/[id]/page.tsx | 28 -- .../prefetch-app-shell/app/(default)/page.tsx | 20 -- .../prefetch-app-shell.test.ts | 245 ++++++------------ .../app/test-independent-head/[item]/page.tsx | 2 +- .../prefetch-inlining.test.ts | 33 +-- 30 files changed, 162 insertions(+), 576 deletions(-) delete mode 100644 test/e2e/app-dir/segment-cache/cached-navigations/default/app/prefetch-eager/page.tsx delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/layout.tsx delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/page.tsx delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/posts/[id]/page.tsx delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/components/link-accordion.tsx delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/next.config.ts delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/prefetch-app-shell-global-eager.test.ts delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager-instant/[id]/page.tsx delete mode 100644 test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager/[id]/page.tsx diff --git a/docs/01-app/02-guides/instant-navigation.mdx b/docs/01-app/02-guides/instant-navigation.mdx index b81a187c98c7..add69d286f10 100644 --- a/docs/01-app/02-guides/instant-navigation.mdx +++ b/docs/01-app/02-guides/instant-navigation.mdx @@ -577,7 +577,7 @@ For opted-out segments, the navigation blocks on the server. If the content depe ## Next steps -- [Adopting Partial Prefetching](/docs/app/guides/adopting-partial-prefetching) for the recommended `` defaults and the migration path off `unstable_eager` +- [Adopting Partial Prefetching](/docs/app/guides/adopting-partial-prefetching) for the recommended `` defaults and a step-by-step adoption path - [`instant` API reference](/docs/app/api-reference/file-conventions/route-segment-config/instant) for the full configuration - [Optimizing prefetching](/docs/app/guides/optimizing-prefetching) when parts of your route depend on URL data (`searchParams` or `params`) and should resolve before navigation - [Caching](/docs/app/getting-started/caching) for background on `use cache`, Suspense, and Partial Prerendering diff --git a/packages/next/src/build/segment-config/app/app-segment-config.ts b/packages/next/src/build/segment-config/app/app-segment-config.ts index a6157228b04d..489970c15af2 100644 --- a/packages/next/src/build/segment-config/app/app-segment-config.ts +++ b/packages/next/src/build/segment-config/app/app-segment-config.ts @@ -36,16 +36,11 @@ const InstantConfigSchema = z.union([ z.literal(false), ]) -const PrefetchSchema = z.enum([ - 'auto', - 'partial', - 'unstable_eager', - 'force-disabled', -]) +const PrefetchSchema = z.enum(['auto', 'partial', 'force-disabled']) export type Instant = InstantConfig | true | false -export type Prefetch = 'auto' | 'partial' | 'unstable_eager' | 'force-disabled' +export type Prefetch = 'auto' | 'partial' | 'force-disabled' export type InstantConfigForTypeCheckInternal = __GenericInstantConfig | Instant // the __GenericInstantConfig type is used to avoid type widening issues with @@ -138,9 +133,6 @@ const AppSegmentConfigSchema = z.object({ * - 'partial' enables Partial Prefetching. Only Cache Components are * prefetched, not dynamic ones. When a static prefetch is insufficient, * the segment may be prefetched with a runtime request instead. - * - 'unstable_eager' behaves like 'partial' but, when App Shells are enabled, - * keeps eagerly prefetching the route's segments instead of relying on the - * shared app shell. Internal migration aid; not part of the public API. * - 'force-disabled' disables prefetching for the segment. */ prefetch: PrefetchSchema.optional(), @@ -197,7 +189,7 @@ export function parseAppSegmentConfig( } case 'prefetch': { return { - message: `Invalid prefetch value ${JSON.stringify(ctx.data)} on "${route}", must be "auto", "partial", "unstable_eager", or "force-disabled".`, + message: `Invalid prefetch value ${JSON.stringify(ctx.data)} on "${route}", must be "auto", "partial", or "force-disabled".`, } } case 'unstable_dynamicStaleTime': { @@ -265,9 +257,6 @@ export type AppSegmentConfig = { * - 'partial' enables Partial Prefetching. Only Cache Components are * prefetched, not dynamic ones. When a static prefetch is insufficient, * the segment may be prefetched with a runtime request instead. - * - 'unstable_eager' behaves like 'partial' but, when App Shells are enabled, - * keeps eagerly prefetching the route's segments instead of relying on the - * shared app shell. Internal migration aid; not part of the public API. * - 'force-disabled' disables prefetching for the segment. */ prefetch?: Prefetch diff --git a/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts b/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts index 2fb41191b4b3..2271fcf7461d 100644 --- a/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts +++ b/packages/next/src/client/components/segment-cache/navigation-testing-lock.ts @@ -25,10 +25,10 @@ import { } from '../../../shared/lib/app-router-types' import { NEXT_INSTANT_TEST_COOKIE } from '../app-router-headers' import { refreshOnInstantNavigationUnlock } from '../use-action-queue' -import { subtreeHasSpeculativePrefetch } from './scheduler' +import { needsSpeculativePrefetch } from './scheduler' import type { SegmentCacheEntry } from './cache' import { createCacheMap, type CacheMap } from './cache-map' -import type { FetchStrategy } from './types' +import type { PrefetchTaskFetchStrategy } from './types' type InstantNavCookieState = 'empty' | 'pending' | 'mpa' | 'spa' @@ -535,19 +535,19 @@ export function getCurrentNavigationGate(): Promise | null { * enabled for the target route, and no whole-route ("speculative") prefetch * would have been made, only the shell is prefetched — so that's all a * navigation should be allowed to match. A speculative prefetch happens for a - * `` or an eagerly-prefetched subtree, in which case the - * concrete-param entry is genuinely warm and may be matched. + * ``, in which case the concrete-param entry is genuinely + * warm and may be matched. * * Always returns false outside the testing API, via the aliased * `navigation-testing-lock.disabled` module. */ export function shouldRestrictNavigationToShell( rootPrefetchHints: number, - linkFetchStrategy: FetchStrategy + linkFetchStrategy: PrefetchTaskFetchStrategy ): boolean { return ( isNavigationLocked() && (rootPrefetchHints & PrefetchHint.SubtreeHasPartialPrefetching) !== 0 && - !subtreeHasSpeculativePrefetch(linkFetchStrategy, rootPrefetchHints) + !needsSpeculativePrefetch(linkFetchStrategy, rootPrefetchHints) ) } diff --git a/packages/next/src/client/components/segment-cache/scheduler.ts b/packages/next/src/client/components/segment-cache/scheduler.ts index 4757e4f83b6b..e9ff78d14ada 100644 --- a/packages/next/src/client/components/segment-cache/scheduler.ts +++ b/packages/next/src/client/components/segment-cache/scheduler.ts @@ -624,8 +624,7 @@ function processQueueInMicrotask() { // Finished prefetching the route tree. The two-phase (Shell then // Speculative) flow only applies to routes that have opted into // Partial Prefetching — either globally via the `partialPrefetching` - // config or per segment (`prefetch: 'partial'` or - // `'unstable_eager'`), all surfaced as the + // config or per segment (`prefetch: 'partial'`), both surfaced as the // `SubtreeHasPartialPrefetching` hint on the route tree. Every other // route skips the Shell phase and goes straight to Speculative. // @@ -907,16 +906,14 @@ function pingRootRouteTree( ? FetchStrategy.StaticShell : FetchStrategy.PPR + // In PPF, links may skip speculative prefetching if they only need a shell. if ( staticWalkStrategy === FetchStrategy.PPR && - !subtreeHasSpeculativePrefetch( + !needsSpeculativePrefetch( task.fetchStrategy, - tree.prefetchHints + route.tree.prefetchHints ) ) { - // Nothing in the target route needs to be speculatively prefetched. - // Bail out. (A PPR walk is the Speculative pass; same check as - // the per-subtree bail in pingNewPartOfCacheComponentsTree.) return PrefetchTaskExitStatus.Done } @@ -1172,17 +1169,11 @@ function pingStaticHead( * walks prefetch static data and partial entries are acceptable — the * dynamic holes are filled by the navigation-time request. * - * Note that on a Partial Prefetching route, non-eager subtrees are still - * skipped by the Speculative pass of a default (auto) link — eagerness is - * unaffected by this predicate. But every segment the pass DOES walk (eager - * segments, and everything on a `prefetch={true}` walk) is held to the - * runtime-completeness contract. The contract is affordable because most + * Note: The runtime contract is affordable because most * routes carry the ShouldAttemptStaticPrefetch hint: their segments are * prefetched statically and the responses' own sufficiency signal makes a * runtime request rare. On a hint-unset route, a walked segment deopts - * directly to the batched runtime request — which then serves the segment's - * whole subtree, so navigations into it are complete without a - * navigation-time request. + * directly to the batched runtime request. * * This is also the gate for the batched runtime request at the end of * pingRootRouteTree; requiring runtime completeness does not itself mean a @@ -1479,15 +1470,11 @@ function pingNewPartOfCacheComponentsTree( // runtime data carries it, and the dynamic holes are filled by the // navigation-time request. + // In PPF, links may skip speculative prefetching if they only need a shell. if ( - // Only the Speculative pass skips subtrees with nothing to speculatively - // prefetch. (It's also the only pass that walks at FetchStrategy.PPR; - // the Shell phase walks at StaticShell and covers the whole new tree.) fetchStrategy === FetchStrategy.PPR && - !subtreeHasSpeculativePrefetch(task.fetchStrategy, tree.prefetchHints) + !needsSpeculativePrefetch(task.fetchStrategy, route.tree.prefetchHints) ) { - // Nothing in the new part of the tree needs to be speculatively prefetched. - // Bail out. return PrefetchTaskExitStatus.Done } @@ -2678,24 +2665,21 @@ function doesCurrentSegmentMatchCachedSegment( } /** - * Decides whether to skip the speculative prefetch of a subtree. Usually we - * only perform a speculative prefetch if the Link's prefetch prop is set to - * true. However, we also will do a speculative prefetch if the prefetching - * mode of the segment is set to "unstable_eager". + * Decides whether to speculatively prefetch a subtree. Under Partial + * Prefetching we only do this if the Link's prefetch prop is set to true — + * otherwise the subtree relies on the shell that the Shell phase prefetches. */ -export function subtreeHasSpeculativePrefetch( - fetchStrategy: FetchStrategy, - prefetchHints: number +export function needsSpeculativePrefetch( + taskfetchStrategy: PrefetchTaskFetchStrategy, + rootPrefetchHints: number ): boolean { - return ( - // Check if this is a "full" prefetch (). - fetchStrategy === FetchStrategy.Full || - // Check if something in this subtree is configured to be eagerly - // prefetched at the route level. Segments that don't opt into Partial - // Prefetching are marked eager, so a route without any Partial Prefetching - // still speculatively prefetches everything. - (prefetchHints & PrefetchHint.SubtreeHasEagerPrefetch) !== 0 - ) + if ((rootPrefetchHints & PrefetchHint.SubtreeHasPartialPrefetching) !== 0) { + // PPF - only needs a speculative prefetch if this is a ``. + return taskfetchStrategy === FetchStrategy.Full + } else { + // non-PPF -- all prefetches are speculative. + return true + } } // ----------------------------------------------------------------------------- diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 7a88209d4c13..567dd6b4e0e3 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1099,7 +1099,7 @@ async function generateStagedDynamicFlightRenderResultNode( // Check if this route should runtime-cache its navigation. This happens when // Partial Prefetching is enabled for the route, either per segment (a - // `prefetch` of 'partial' or 'unstable_eager') or globally (the + // `prefetch` of 'partial') or globally (the // `partialPrefetching` config). If so, we piggyback on the dynamic render to // fill caches and then spawn a final runtime prerender whose result stream // is embedded in the RSC payload. This is gated because it adds extra server @@ -1464,7 +1464,6 @@ async function generateDynamicFlightRenderResultWithStagesInDev( prefetchStage = RenderStage.Static } else { if (prefetchMode === PrefetchingMode.Partial) { - // TODO(app-shells): model `partialPrefetching: "unstable_eager"` // TODO(app-shells): if this navigation came from , // we should show the shell for a speculative prefetch // (which can have more data than the app shell) @@ -2363,7 +2362,7 @@ async function getErrorRSCPayload( errorHints, errorPrefetchInliningEnabled, ctx.missingPrefetchHintPolicy, - ctx.renderOpts.partialPrefetching, + Boolean(ctx.renderOpts.partialPrefetching), getDynamicParamFromSegment, query ) @@ -3848,7 +3847,7 @@ async function renderToStream( // embedded in the initial RSC payload so the client can cache // runtime-prefetchable content during hydration. This is enabled when // Partial Prefetching is on for the route, either per segment (a - // `prefetch` of 'partial' or 'unstable_eager') or globally (the + // `prefetch` of 'partial') or globally (the // `partialPrefetching` config). if ( Boolean(renderOpts.partialPrefetching) || @@ -5288,7 +5287,6 @@ async function getPrefetchingModeForPage( const debug = process.env.NEXT_PRIVATE_DEBUG_VALIDATION === '1' ? console.log : undefined - // TODO(app-shells): support "unstable_eager" if (renderOpts.partialPrefetching) { debug?.('using prefetching mode Partial because of next.config.js') return PrefetchingMode.Partial @@ -5399,7 +5397,7 @@ type StreamRevealStage = | RenderStage.Runtime function navigationHasAppShell(navigationKind: DevNavigationKind): boolean { - // TODO(app-shells): when we implement `/`prefetch = "unstable_eager"` in dev, + // TODO(app-shells): when we implement `` in dev, // this might need to be adjusted, because we'll use `Runtime` for the stage return ( navigationKind.type === 'prefetched-client' && diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 4c43c302a89e..e6f0f9956f2c 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -191,7 +191,7 @@ async function createComponentTreeInternal( parseLoaderTree(tree) const prefetchInliningEnabled = Boolean(experimental.prefetchInlining) - const partialPrefetching = ctx.renderOpts.partialPrefetching + const partialPrefetching = Boolean(ctx.renderOpts.partialPrefetching) const { layout, diff --git a/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts b/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts index acee295ee36a..a78918dbbdbe 100644 --- a/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts +++ b/packages/next/src/server/app-render/create-transport-tree-from-loader-tree.ts @@ -61,7 +61,7 @@ export async function computeSegmentPrefetchHints( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, // Whether this segment is at or above the root layout (no layout was found // above it). isRootLayoutOrAbove: boolean @@ -76,11 +76,7 @@ export async function computeSegmentPrefetchHints( const instantConfig = mod ? (mod as AppSegmentConfig).instant : undefined const prefetchConfig = (mod ? (mod as AppSegmentConfig).prefetch : undefined) ?? - (partialPrefetching === 'unstable_eager' - ? 'unstable_eager' - : partialPrefetching - ? 'partial' - : undefined) + (partialPrefetching ? 'partial' : undefined) let prefetchHints = 0 // Union in the precomputed build-time hints (e.g. segment inlining @@ -132,25 +128,10 @@ export async function computeSegmentPrefetchHints( if (prefetchConfig === 'partial') { prefetchHints |= PrefetchHint.SubtreeHasPartialPrefetching - } else if (prefetchConfig === 'unstable_eager') { - // Like 'partial' (uses the PPR fetch strategy) but also marks the segment - // as eager, so App Shells keeps prefetching it instead of relying on the - // shared app shell. - prefetchHints |= - PrefetchHint.SubtreeHasPartialPrefetching | - PrefetchHint.SubtreeHasEagerPrefetch } else if (prefetchConfig === 'force-disabled') { prefetchHints |= PrefetchHint.PrefetchDisabled } - // Mark the segment as "eager" unless its effective prefetch strategy is - // 'partial'. 'unstable_eager' already set the bit above. Under App Shells, - // a subtree with no eager segment skips its Speculative prefetch and relies - // on the shared app shell instead. - if (prefetchConfig !== 'partial') { - prefetchHints |= PrefetchHint.SubtreeHasEagerPrefetch - } - // Check if this segment has a loading boundary if (loading) { prefetchHints |= PrefetchHint.SegmentHasLoadingBoundary @@ -176,7 +157,7 @@ async function createTransportTreeFromLoaderTreeImpl( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any, didFindRootLayout: boolean @@ -254,7 +235,7 @@ export async function createTransportTreeFromLoaderTree( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any, // Whether a root layout was already found above this loader tree slice, so a @@ -285,7 +266,7 @@ export async function createFullTransportTreeFromLoaderTree( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, searchParams: any ): Promise { @@ -314,7 +295,7 @@ export async function createRouteTreePrefetch( hintTree: PrefetchHints | null, prefetchInliningEnabled: boolean, missingPrefetchHintPolicy: MissingPrefetchHintPolicy, - partialPrefetching: boolean | 'unstable_eager' | undefined, + partialPrefetching: boolean, getDynamicParamFromSegment: GetDynamicParamFromSegment, // See note on createTransportTreeFromLoaderTree's didFindRootLayout. didFindRootLayout: boolean = false diff --git a/packages/next/src/server/app-render/instant-validation/instant-config.tsx b/packages/next/src/server/app-render/instant-validation/instant-config.tsx index 59ae0f816861..aec3dd4b06c5 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-config.tsx +++ b/packages/next/src/server/app-render/instant-validation/instant-config.tsx @@ -51,10 +51,9 @@ export function isFrameworkErrorRoute(route: string | undefined): boolean { } /** - * Matches any `prefetch` config that enables Partial Prefetching for the - * segment: 'partial' or 'unstable_eager'. A route with Partial Prefetching - * enabled also runtime-caches its navigations, so this gates the runtime - * prefetch spawn. + * Matches the `prefetch` config that enables Partial Prefetching for the + * segment: 'partial'. A route with Partial Prefetching enabled also + * runtime-caches its navigations, so this gates the runtime prefetch spawn. */ export async function anySegmentHasPartialPrefetchingEnabled( tree: LoaderTree @@ -65,7 +64,7 @@ export async function anySegmentHasPartialPrefetchingEnabled( const prefetchConfig = layoutOrPageMod ? (layoutOrPageMod as AppSegmentConfig).prefetch : undefined - if (prefetchConfig === 'partial' || prefetchConfig === 'unstable_eager') { + if (prefetchConfig === 'partial') { return true } diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index f1856dbb2e0f..e40f0737b709 100644 --- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx +++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx @@ -81,7 +81,7 @@ export async function walkTreeWithFlightRouterState({ parsedRequestHeaders, } = ctx const prefetchInliningEnabled = Boolean(experimental.prefetchInlining) - const partialPrefetching = ctx.renderOpts.partialPrefetching + const partialPrefetching = Boolean(ctx.renderOpts.partialPrefetching) const [segment, parallelRoutes, modules] = loaderTreeToFilter diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 3fd0c238c99d..5c211ee47c9d 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -805,9 +805,7 @@ export const configSchema: zod.ZodType = z.lazy(() => .optional(), pageExtensions: z.array(z.string()).min(1).optional(), instrumentationClientInject: z.array(z.string()).optional(), - partialPrefetching: z - .union([z.boolean(), z.literal('unstable_eager')]) - .optional(), + partialPrefetching: z.boolean().optional(), poweredByHeader: z.boolean().optional(), productionBrowserSourceMaps: z.boolean().optional(), reactCompiler: z.union([ diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 644df33842e1..0d3f6ddcb5d2 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -2015,12 +2015,8 @@ export interface NextConfig { * * When `false` or omitted, this does nothing (the legacy behavior, where * dynamic data is included in the prefetch). - * - * `'unstable_eager'` is like `true`, except the default becomes - * `'unstable_eager'` instead of `'partial'`: every Link has an implied - * prefetch={true}. Internal migration aid; not part of the public API. */ - partialPrefetching?: boolean | 'unstable_eager' + partialPrefetching?: boolean cacheLife?: { [profile: string]: { diff --git a/packages/next/src/server/lib/app-info-log.ts b/packages/next/src/server/lib/app-info-log.ts index 35bbce54eca1..5b70f3f88179 100644 --- a/packages/next/src/server/lib/app-info-log.ts +++ b/packages/next/src/server/lib/app-info-log.ts @@ -68,16 +68,14 @@ export function logExperimentalInfo({ }: { experimentalFeatures?: ConfiguredExperimentalFeature[] cacheComponents?: boolean - partialPrefetching?: boolean | 'unstable_eager' + partialPrefetching?: boolean }) { if (cacheComponents) { Log.bootstrap(`- Cache Components enabled`) } if (partialPrefetching) { - const mode = - partialPrefetching === 'unstable_eager' ? ' (unstable_eager)' : '' - Log.bootstrap(`- Partial Prefetching enabled${mode}`) + Log.bootstrap(`- Partial Prefetching enabled`) } if (experimentalFeatures?.length) { diff --git a/packages/next/src/server/lib/render-server.ts b/packages/next/src/server/lib/render-server.ts index 9e4f8fd6801d..83db7afe512c 100644 --- a/packages/next/src/server/lib/render-server.ts +++ b/packages/next/src/server/lib/render-server.ts @@ -22,8 +22,8 @@ export type ServerInitResult = { experimentalFeatures: ConfiguredExperimentalFeature[] // Whether cache components is enabled cacheComponents: boolean - // Whether partial prefetching is enabled (and its mode) - partialPrefetching?: boolean | 'unstable_eager' + // Whether partial prefetching is enabled + partialPrefetching?: boolean // Whether AGENTS.md / CLAUDE.md auto-generation is enabled (default true) agentRules?: boolean // Whether the development server memory threshold restart is enabled @@ -107,7 +107,7 @@ async function initializeImpl(opts: { distDir: string experimentalFeatures: ConfiguredExperimentalFeature[] cacheComponents: boolean - partialPrefetching?: boolean | 'unstable_eager' + partialPrefetching?: boolean devMemoryThresholdRestart: boolean }): Promise { const type = process.env.__NEXT_PRIVATE_RENDER_WORKER diff --git a/packages/next/src/server/typescript/rules/config.ts b/packages/next/src/server/typescript/rules/config.ts index 29dd6eb35519..9db590475ddd 100644 --- a/packages/next/src/server/typescript/rules/config.ts +++ b/packages/next/src/server/typescript/rules/config.ts @@ -159,16 +159,11 @@ const API_DOCS: Record< prefetch: { description: `Controls prefetching behavior for this segment. Some options are experimental and may change.`, link: '(docs coming soon)', - type: `"auto" | "partial" | "unstable_eager" | "force-disabled"`, + type: `"auto" | "partial" | "force-disabled"`, options: { auto: 'Default. Framework decides based on instant validation and segment configuration. You do not need to set this explicitly.', partial: 'Enables Partial Prefetching for this segment. When a static prefetch is insufficient, Next.js may prefetch the segment with a runtime server request so it can access session data, such as cookies.', - unstable_eager: - 'Like "partial", but adds an implied prop of prefetch={true} to ' + - 'every Link. This option only exists to aid migration of apps that ' + - 'adopted Partial Prefetching in canary before the behavior changed to ' + - 'only fetch the shell by default.', 'force-disabled': 'Never prefetch this segment.', }, insertText: `prefetch = 'partial';`, diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index 0b612246c586..3818d1ef2d6a 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -189,9 +189,9 @@ export const enum PrefetchHint { // This segment or one of its descendants opts into Partial Prefetching, i.e. // uses the two-phase (Shell then Speculative) prefetch flow. Set when - // `prefetch` is 'partial' or 'unstable_eager' (including the defaults - // implied by the global `partialPrefetching` config). Propagates upward so - // the root segment reflects the entire subtree. + // `prefetch` is 'partial' (including the default implied by the global + // `partialPrefetching` config). Propagates upward so the root segment + // reflects the entire subtree. // // Partial Prefetching segments require RUNTIME COMPLETENESS: a prefetch // isn't considered done for such a segment until an entry at least as @@ -246,12 +246,10 @@ export const enum PrefetchHint { // (see SubtreeHasPartialPrefetching), so the bit was removed. Do not reuse // it without considering caches populated by older builds. - // This segment or one of its descendants prefetches "eagerly" — i.e. its - // effective prefetch strategy is anything other than 'partial'. Used by - // the scheduler's phasing: a non-eager subtree relies on the shell the - // Shell phase prefetches and skips its Speculative prefetch. Propagates - // upward so the root reflects the entire subtree. - SubtreeHasEagerPrefetch = 0b1000000000000, + // NOTE: The 0b1000000000000 bit was previously SubtreeHasEagerPrefetch + // (prefetch: 'unstable_eager', plus every segment that did not opt into + // Partial Prefetching). After `unstable_eager` was removed, it's no longer needed. + // This segment or one of its descendants exports `instant = false`, // explicitly opting out of Partial Prefetching. Propagates upward so the root // reflects the entire subtree. Used only to suppress the dev-time @@ -311,8 +309,7 @@ export const StaticPrefetchDisabled = PrefetchHint.PrefetchDisabled export const SubtreePrefetchHints = PrefetchHint.SubtreeHasPartialPrefetching | PrefetchHint.SubtreeHasLoadingBoundary | - PrefetchHint.SubtreeHasInstantFalse | - PrefetchHint.SubtreeHasEagerPrefetch + PrefetchHint.SubtreeHasInstantFalse /** * Folds a child segment's prefetch hints into its parent's, propagating the @@ -342,13 +339,8 @@ export function propagateSubtreeBits( ) { parentHints |= PrefetchHint.SubtreeHasLoadingBoundary } - // And for eager prefetch. The bit is set directly on each eager segment, so - // there's no separate segment-local flag — propagate it as-is. - if (childHints & PrefetchHint.SubtreeHasEagerPrefetch) { - parentHints |= PrefetchHint.SubtreeHasEagerPrefetch - } - // And for `instant = false`. Like eager prefetch, the bit is set directly on - // each opted-out segment, so propagate it as-is. + // And for `instant = false`. The bit is set directly on each opted-out + // segment, so there's no separate segment-local flag — propagate it as-is. if (childHints & PrefetchHint.SubtreeHasInstantFalse) { parentHints |= PrefetchHint.SubtreeHasInstantFalse } diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts index 72d862be4b86..001a038141f0 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts +++ b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts @@ -991,11 +991,11 @@ describe('cached navigations', () => { }) }) - // A `prefetch` config that enables Partial Prefetching ('partial' or - // 'unstable_eager') also opts the route into runtime Cached Navigations, - // even though this fixture does not set the global `partialPrefetching` - // flag. Contrast with `partially-static`, which has no `prefetch` config - // and only gets static caching. + // A `prefetch` config that enables Partial Prefetching ('partial') also opts + // the route into runtime Cached Navigations, even though this fixture does + // not set the global `partialPrefetching` flag. Contrast with + // `partially-static`, which has no `prefetch` config and only gets static + // caching. async function expectRuntimeCachedOnSecondNavigation(route: string) { let page: Playwright.Page const browser = await next.browser('/', { @@ -1057,8 +1057,4 @@ describe('cached navigations', () => { it('runtime-caches a route with prefetch = "partial"', async () => { await expectRuntimeCachedOnSecondNavigation('/prefetch-partial') }) - - it('runtime-caches a route with prefetch = "unstable_eager"', async () => { - await expectRuntimeCachedOnSecondNavigation('/prefetch-eager') - }) }) diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx index b41370438010..b6f0d5ed4c62 100644 --- a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx +++ b/test/e2e/app-dir/segment-cache/cached-navigations/default/app/page.tsx @@ -38,11 +38,6 @@ export default function Home() { Go to prefetch=partial page -
  • - - Go to prefetch=unstable_eager page - -
  • ) diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/prefetch-eager/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/default/app/prefetch-eager/page.tsx deleted file mode 100644 index 7d3a3eeda30e..000000000000 --- a/test/e2e/app-dir/segment-cache/cached-navigations/default/app/prefetch-eager/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { RuntimeContent } from '../../components/runtime-content' - -// `prefetch = 'unstable_eager'` enables Partial Prefetching, which also opts -// the route into runtime Cached Navigations. -export const prefetch = 'unstable_eager' - -export default async function Page({ - searchParams, -}: { - searchParams: Promise<{ q?: string }> -}) { - return -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/layout.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/layout.tsx deleted file mode 100644 index 7758e801dc83..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { ReactNode } from 'react' - -export default function RootLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/page.tsx deleted file mode 100644 index e2cb7bb6b28b..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { LinkAccordion } from '../components/link-accordion' - -export default function Page() { - return ( -
    -

    Home

    -
      -
    • - Post 1 (default) -
    • -
    • - Post 2 (default) -
    • -
    -
    - ) -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/posts/[id]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/posts/[id]/page.tsx deleted file mode 100644 index 3b28ee5276b0..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/app/posts/[id]/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Suspense } from 'react' - -type Params = { id: string } - -// No per-segment `prefetch`. The route's prefetch config comes from -// the global `partialPrefetching: 'unstable_eager'` in next.config, which makes -// it eager — so the App Shells skip does NOT apply. -export function generateStaticParams() { - return [{ id: '1' }, { id: '2' }, { id: '3' }] -} - -export default function Page({ params }: { params: Promise }) { - return ( -
    - App shell

    }> - -
    -
    - ) -} - -async function ParamContent({ params }: { params: Promise }) { - const { id } = await params - return

    {`Eager post ${id}`}

    -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/components/link-accordion.tsx deleted file mode 100644 index c6848d479aef..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/components/link-accordion.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client' - -import Link, { type LinkProps } from 'next/link' -import { useState } from 'react' - -export function LinkAccordion({ - href, - children, - prefetch, -}: { - href: string - children: React.ReactNode - prefetch?: LinkProps['prefetch'] -}) { - const [isVisible, setIsVisible] = useState(false) - return ( - <> - setIsVisible(!isVisible)} - data-link-accordion={href} - /> - {isVisible ? ( - - {children} - - ) : ( - <>{children} (link is hidden) - )} - - ) -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/next.config.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/next.config.ts deleted file mode 100644 index a5804410a1d7..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/next.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NextConfig } from 'next' - -const nextConfig: NextConfig = { - cacheComponents: true, - // Opt the whole app into Partial Prefetching in "eager" mode. Every route's - // default prefetch config becomes 'unstable_eager', so under App Shells the - // per-link Speculative prefetch is NOT skipped — even for routes with no - // per-segment `prefetch` export. - partialPrefetching: 'unstable_eager', - experimental: { - prefetchInlining: true, - optimisticRouting: true, - cachedNavigations: true, - varyParams: true, - }, -} - -export default nextConfig diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/prefetch-app-shell-global-eager.test.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/prefetch-app-shell-global-eager.test.ts deleted file mode 100644 index ad326d3dc470..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell-global-eager/prefetch-app-shell-global-eager.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' -import type * as Playwright from 'playwright' -import { createRouterAct } from 'router-act' - -describe('App Shell prefetching - global unstable_eager', () => { - const { next, isNextDev } = nextTestSetup({ - files: __dirname, - }) - if (isNextDev) { - it('is skipped', () => {}) - return - } - - it('does NOT skip the Speculative prefetch when partialPrefetching is "unstable_eager" globally', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page) - - // /posts/[id] has no per-segment prefetch config, but the global - // `partialPrefetching: 'unstable_eager'` makes it eager. Reveal /posts/1 to - // prime the shared app shell. - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/posts/1"]') - .click() - }) - - // Reveal /posts/2 — a different param, shell already cached. Because the - // global config makes the route eager, the per-link Speculative prefetch - // still fires for param 2 (a single request carrying "Eager post 2"), - // rather than firing no requests as a non-eager route's second link would. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/posts/2"]') - .click() - }, - { includes: 'Eager post 2' } - ) - }) -}) diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager-instant/[id]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager-instant/[id]/page.tsx deleted file mode 100644 index 8979e879df30..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager-instant/[id]/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Suspense } from 'react' - -type Params = { id: string } - -// Combines both segment-level opt-ins: `instant` (which on its own -// behaves like 'partial' — not eager) AND `prefetch = 'unstable_eager'`. -// 'unstable_eager' wins: the segment is marked eager, so under App Shells the -// per-link Speculative prefetch still fires and the param-specific content -// below IS prefetched. -export const instant = true -export const prefetch = 'unstable_eager' - -export function generateStaticParams() { - return [{ id: '1' }, { id: '2' }, { id: '3' }] -} - -export default function Page({ params }: { params: Promise }) { - return ( -
    - {/* The fallback is the param-independent app shell. */} - Eager-instant app shell

    }> - -
    -
    - ) -} - -async function ParamContent({ params }: { params: Promise }) { - const { id } = await params - return

    {`Eager-instant post ${id}`}

    -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager/[id]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager/[id]/page.tsx deleted file mode 100644 index 1c782cc89deb..000000000000 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/eager/[id]/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Suspense } from 'react' - -type Params = { id: string } - -// Opts into Partial Prefetching in "eager" mode. Behaves like 'partial', but -// under App Shells it keeps prefetching the route's segments instead of relying -// on the shared app shell — so the param-specific content below IS prefetched. -export const prefetch = 'unstable_eager' - -export function generateStaticParams() { - return [{ id: '1' }, { id: '2' }, { id: '3' }] -} - -export default function Page({ params }: { params: Promise }) { - return ( -
    - {/* The fallback is the param-independent app shell. */} - Eager app shell

    }> - -
    -
    - ) -} - -async function ParamContent({ params }: { params: Promise }) { - const { id } = await params - return

    {`Eager post ${id}`}

    -} diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx index 8341347170c3..53ebb13dab14 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/app/(default)/page.tsx @@ -108,26 +108,6 @@ export default function Page() { -

    Eager posts

    -
      -
    • - Eager 1 (default) -
    • -
    • - Eager 2 (default) -
    • -
    • - - Eager-instant 1 (instant + unstable_eager) - -
    • -
    • - - Eager-instant 2 (instant + unstable_eager) - -
    • -
    -

    Complete shell

    • diff --git a/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts b/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts index 175cb029a2a9..45cd22698922 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-app-shell/prefetch-app-shell.test.ts @@ -28,19 +28,14 @@ describe('App Shell prefetching', () => { // Reveal the LinkAccordion for /posts/1. This caches the App Shell // for the route — the param-independent content of the page that's // reusable for any /posts/[id]. + // The route reads request data, so it uses a runtime shell. await act(async () => { await browser .elementByCss('input[data-link-accordion="/posts/1"]') .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { includes: 'App shell for posts', kind: 'runtime' }, { includes: 'App shell for posts', kind: 'runtime' }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -55,8 +50,7 @@ describe('App Shell prefetching', () => { expect(await browser.elementById('shell').text()).toEqual( 'App shell for posts' ) - // Sesssion data (cookies) is not dependent on URL-data, so they are - // allowed to be accessed in the shell. + // Session data (cookies) can be accessed in the shell. expect(await browser.elementById('cookie-value').text()).toEqual( 'Cookie: none' ) @@ -97,7 +91,7 @@ describe('App Shell prefetching', () => { ) }) - it('runtime-prefetches per-link content of a dynamic route whose static-attempt hint is unset', async () => { + it('reuses a runtime App Shell across params without firing a per-link prefetch', async () => { let page: Playwright.Page const browser = await next.browser('/', { beforePageLoad(p: Playwright.Page) { @@ -106,41 +100,47 @@ describe('App Shell prefetching', () => { }) const act = createRouterAct(page, { includeAppShellRequests: true }) - // Reveal /posts/1 (default/auto prefetch). The page itself is partial - // (non-eager), but the path segments above it are eager, so the - // Speculative pass still walks them. Unlike /partial (covered by the - // next test), this route is dynamic — it reads cookies — so its - // static-attempt hint is unset and the walked segments deopt to a - // per-link runtime prefetch, which serves the whole subtree, page - // included. + // Reveal /posts/1 (default/auto prefetch). Unlike /partial (covered by the next test), this route + // reads cookies, so it deopts to a runtime shell prefetch. await act(async () => { await browser .elementByCss('input[data-link-accordion="/posts/1"]') .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { includes: 'App shell for posts', kind: 'runtime' }, { includes: 'App shell for posts', kind: 'runtime' }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) // Reveal /posts/2 — a different param that shares the same App Shell. - // The shell is already cached and is not re-fetched, but the hint-unset - // deopt issues a runtime prefetch for the new param's subtree, so the - // per-link content for param 2 arrives ahead of any navigation. - // Contrast with the /partial route in the next test, whose hint is set: - // there the cached shell satisfies the second link with no requests. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/posts/2"]') - .click() - }, - { includes: 'Post 2', kind: 'runtime' } + // The shell is already cached and is not re-fetched. + await act(async () => { + await browser + .elementByCss('input[data-link-accordion="/posts/2"]') + .click() + }, 'no-requests') + + await act(async () => { + // Click the link to /posts/2. The cached App + // Shell should render immediately, before any navigation response + // arrives. + await browser.elementByCss('a[href="/posts/2"]').click() + + // While the navigation response is blocked (we're still in the + // `act` block), the cached App Shell should already be visible. + expect(await browser.elementById('shell').text()).toEqual( + 'App shell for posts' + ) + // Session data (cookies) can be accessed in the shell. + expect(await browser.elementById('cookie-value').text()).toEqual( + 'Cookie: none' + ) + }) + + // After the outer act unblocks the navigation, params resolve and the + // dynamic content streams in. + expect(await browser.elementById('param-value').text()).toEqual('Post 2') + expect(await browser.elementById('dynamic-content').text()).toEqual( + 'Post body for 2' ) }) @@ -165,7 +165,7 @@ describe('App Shell prefetching', () => { .elementByCss('input[data-link-accordion="/partial/1"]') .click() }, - { includes: 'Partial app shell' } + { includes: 'Partial app shell', kind: 'static' } ) // Reveal /partial/2 — a different param that shares the same app shell. The @@ -180,74 +180,6 @@ describe('App Shell prefetching', () => { }, 'no-requests') }) - it('does NOT skip the Speculative prefetch for a route with prefetch = "unstable_eager"', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page, { includeAppShellRequests: true }) - - // Reveal /eager/1. /eager/[id] opts into Partial Prefetching in "eager" - // mode, so this primes the shared app shell. (Because the route is eager it - // also speculatively prefetches param 1 here, but the assertion that - // demonstrates the eager behavior is on the second link below, where the - // shell is already cached and only the Speculative prefetch can fire.) - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/eager/1"]') - .click() - }) - - // Reveal /eager/2 — a different param that shares the same app shell. The - // shell is already cached, so it is NOT re-fetched. Because the route is - // eager, the per-link Speculative prefetch fires for param 2 — a single - // request carrying that param's content ("Eager post 2"). This is the - // counterpart to the partial route's second link, which fired no requests: - // an eager route keeps speculatively prefetching each new param. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/eager/2"]') - .click() - }, - { includes: 'Eager post 2' } - ) - }) - - it('treats a segment with both instant and prefetch = "unstable_eager" as eager', async () => { - let page: Playwright.Page - const browser = await next.browser('/', { - beforePageLoad(p: Playwright.Page) { - page = p - }, - }) - const act = createRouterAct(page, { includeAppShellRequests: true }) - - // /eager-instant/[id] sets BOTH instant (which alone behaves like - // 'partial' — not eager) and prefetch = 'unstable_eager'. The eager - // opt-in wins, so the segment is treated as eager. Same two-link pattern as - // the plain eager test: the first link primes the shared shell... - await act(async () => { - await browser - .elementByCss('input[data-link-accordion="/eager-instant/1"]') - .click() - }) - - // ...and the second link (different param, shell already cached) fires the - // per-link Speculative prefetch for param 2, proving the route is treated as - // eager rather than skipping the Speculative phase. - await act( - async () => { - await browser - .elementByCss('input[data-link-accordion="/eager-instant/2"]') - .click() - }, - { includes: 'Eager-instant post 2' } - ) - }) - it('does NOT skip the Speculative prefetch for a prefetch={true} link, even on a partial route', async () => { let page: Playwright.Page const browser = await next.browser('/', { @@ -257,9 +189,7 @@ describe('App Shell prefetching', () => { }) const act = createRouterAct(page, { includeAppShellRequests: true }) - // Reveal /partial/1 (default). /partial/[id] opts into Partial Prefetching, - // so the default link primes the shared shell and skips the Speculative - // prefetch (asserted by the other partial test). Here we just prime. + // Reveal /partial/1 (default). This primes the shared shell. await act(async () => { await browser .elementByCss('input[data-link-accordion="/partial/1"]') @@ -270,9 +200,7 @@ describe('App Shell prefetching', () => { // (a Full prefetch). prefetch={true} always prefetches the route's segments, // bypassing the App Shells skip. The shell is already cached, so the only // request is the Speculative prefetch for param 3, carrying its content - // ("Partial post 3"). Contrast with the default partial link, whose second - // link fires no requests: prefetch={true} opts back into per-link - // prefetching even on a partial route. + // ("Partial post 3"). await act( async () => { await browser @@ -299,16 +227,14 @@ describe('App Shell prefetching', () => { }) const act = createRouterAct(page, { includeAppShellRequests: true }) - // Reveal the LinkAccordion for /static-posts/1. The route is fully static - // and doesn't opt into Partial Prefetching, so there's no separate runtime - // shell prefetch — a single per-segment static prefetch fires, carrying the - // resolved page content plus the shell prefix above the params boundary - // (with a byte offset the client uses to extract and cache the shell). + // Reveal the LinkAccordion for /static-posts/1. The route is fully static, + // so there's no need for a runtime shell -- the static per-segment prefetch returns the + // full page content from which we can extract a static app shell. await act(async () => { await browser .elementByCss('input[data-link-accordion="/static-posts/1"]') .click() - }, [{ includes: 'App shell for static posts' }]) + }, [{ includes: 'App shell for static posts', kind: 'static' }]) // Click the link to /static-posts/124 — a different param than what // was prefetched, rendered with prefetch={false}. The cached App @@ -351,13 +277,8 @@ describe('App Shell prefetching', () => { .elementByCss('input[data-link-accordion="/short-stale/1"]') .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { includes: 'App shell for short-stale', kind: 'runtime' }, + // The route reads request data, so its static-attempt hint is unset + // and the prefetch deopts to a runtime request. { includes: 'App shell for short-stale', kind: 'runtime' }, ]) @@ -417,7 +338,7 @@ describe('App Shell prefetching', () => { await browser .elementByCss('input[data-link-accordion="/static-short-stale/1"]') .click() - }, [{ includes: 'App shell for static short-stale posts' }]) + }, [{ includes: 'App shell for static short-stale posts', kind: 'static' }]) await act(async () => { // Click the link to /static-short-stale/124 — a different param than @@ -478,20 +399,13 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { - includes: 'App shell for posts with root param: en', - kind: 'runtime', - }, + // The route reads request data, so its static-attempt hint is unset and the prefetch + // deopts to a runtime request. { includes: 'App shell for posts with root param: en', kind: 'runtime', }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -508,8 +422,7 @@ describe('App Shell prefetching', () => { expect(await browser.elementById('shell').text()).toEqual( 'App shell for posts with root param: en' ) - // Sesssion data (cookies) is not dependent on URL-data, so they are - // allowed to be accessed in the shell. + // Session data (cookies) can be accessed in the shell. expect(await browser.elementById('cookie-value').text()).toEqual( 'Cookie: none' ) @@ -552,7 +465,12 @@ describe('App Shell prefetching', () => { 'input[data-link-accordion="/with-root-param/en/static-posts/1"]' ) .click() - }, [{ includes: 'App shell for static posts with root param: en' }]) + }, [ + { + includes: 'App shell for static posts with root param: en', + kind: 'static', + }, + ]) // Click the link to /with-root-param/en/static-posts/124 — a different param than what // was prefetched, rendered with prefetch={false}. The cached App @@ -599,15 +517,9 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // per-link runtime prefetch (the page reads cookies, so the hint - // is unset and the prefetch deopts to a runtime request). Unlike - // the /posts routes, no static bundle fetch fires in between: the - // page has no non-root params, so its runtime App Shell entry is - // already as complete as any static response could be. - { includes: 'App shell for page with root param: en' }, - { includes: 'App shell for page with root param: en' }, + // The page reads cookies, so the hint is unset and the prefetch deopts to a runtime request. + { includes: 'App shell for page with root param: en', kind: 'runtime' }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -628,8 +540,7 @@ describe('App Shell prefetching', () => { expect(await browser.elementById('shell').text()).toEqual( 'App shell for page with root param: en' ) - // Sesssion data (cookies) is not dependent on URL-data, so they are - // allowed to be accessed in the shell. + // Session data (cookies) can be accessed in the shell. expect(await browser.elementById('cookie-value').text()).toEqual( 'Cookie: none' ) @@ -664,7 +575,7 @@ describe('App Shell prefetching', () => { ) .click() }, - { includes: 'App shell for page with root param: en' } + { includes: 'App shell for page with root param: en', kind: 'static' } ) await act(async () => { @@ -715,20 +626,13 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { - includes: 'App shell for posts with root param: en', - kind: 'runtime', - }, + // The route reads request data, so its static-attempt hint is unset + // and the prefetch deopts to a runtime request. { includes: 'App shell for posts with root param: en', kind: 'runtime', }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -777,20 +681,13 @@ describe('App Shell prefetching', () => { ) .click() }, [ - // Two runtime responses carry the shell text, in order: the Shell - // phase's runtime App Shell request, then the Speculative phase's - // batched per-link runtime prefetch. The route reads request data, - // so its static-attempt hint is unset and the prefetch deopts to - // runtime requests — the runtime-completeness contract of Partial - // Prefetching routes. - { - includes: 'App shell for posts with root param: fr', - kind: 'runtime', - }, + // The route reads request data, so its static-attempt hint is unset + // and the prefetch deopts to a runtime request. { includes: 'App shell for posts with root param: fr', kind: 'runtime', }, + { includes: 'param-value', block: 'reject' }, // Only a shell, no URL data. ]) await act(async () => { @@ -837,7 +734,12 @@ describe('App Shell prefetching', () => { 'input[data-link-accordion="/with-root-param/en/static-posts/1"]' ) .click() - }, [{ includes: 'App shell for static posts with root param: en' }]) + }, [ + { + includes: 'App shell for static posts with root param: en', + kind: 'static', + }, + ]) await act(async () => { const startingUrl = await browser.url() @@ -886,7 +788,6 @@ describe('App Shell prefetching', () => { [ // TODO(app-shells): why aren't there requests here? // { includes: 'App shell for static posts with root param: fr' }, - // { includes: 'App shell for static posts with root param: fr' }, ] ) diff --git a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx index a9b00e81fab5..d242925d3ad5 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-inlining/app/test-independent-head/[item]/page.tsx @@ -30,7 +30,7 @@ export default async function Page({ return (

      Independent head page

      - + Go to {sibling}
      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 354f0b366fb6..d9ff1569b21c 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 @@ -644,7 +644,7 @@ describe('prefetch inlining', () => { // route's static-attempt hint is unset because the page reads cookies, // so the walked layout deopts directly to the batched runtime shell, // which serves its whole subtree. The inlined layout content arrives in - // that runtime response. (No static bundle request fires: the Shell + // that runtime shell response. (No static bundle request fires: the Shell // phase already runtime-cached every entry in the bundle chain, and a // runtime-complete entry is never re-fetched by a static prefetch.) await act( @@ -720,7 +720,7 @@ describe('prefetch inlining', () => { }, // The layout reads cookies, so the route's static-attempt hint is // unset and the Speculative pass deopts the layout directly to the - // batched runtime prefetch, which serves the whole subtree — the + // batched runtime shell, which serves the whole subtree — the // static inner layout and page ride along in that single runtime // response. No static bundle request fires: every entry was already // runtime-cached at the shell tier by the Shell phase, and a @@ -820,7 +820,7 @@ describe('prefetch inlining', () => { .click() }, // Same as the runtime passthrough test: the hint-unset layout deopts - // to the batched runtime prefetch, which serves the whole subtree + // to the batched runtime shell, which serves the whole subtree // (both slots) in a single runtime response. { includes: 'Runtime parallel main content', kind: 'runtime' } ) @@ -842,8 +842,8 @@ describe('prefetch inlining', () => { // [item] param and searchParams, making it depend on runtime data. // // Because the layout reads cookies, the route's static-attempt hint is - // unset, so on this Partial Prefetching route every per-link prefetch - // deopts its new subtree to the batched runtime prefetch. The head is + // unset, so on this Partial Prefetching route every shell and prefetch + // deopt its new subtree to a runtime request. The head is // param-dependent, so it is NOT part of the reusable App Shell — but // whenever a runtime prefetch fires for a segment, the head rides // along in the same request. So each prefetched sibling gets its own @@ -865,22 +865,27 @@ describe('prefetch inlining', () => { page = p }, }) - const act = createRouterAct(page!) + const act = createRouterAct(page!, { includeAppShellRequests: true }) - // Prefetch and navigate to route A. This caches the layout, the static - // page, and A's head (riding along with the runtime prefetch), and - // makes A the current page. + // Runtime-prefetch (with prefetch={true}) route A. This caches the layout, the + // static page, and A's head. await act(async () => { await browser .elementByCss('input[data-link-accordion="/test-independent-head/a"]') .click() - }) + }, [ + // Shell + { includes: 'item-layout', kind: 'runtime' }, + // Speculative (search params) + { includes: 'Independent Head Title: a', kind: 'runtime' }, + ]) + // Navigate to A. It should be fully prefetched. await act(async () => { await browser.elementByCss('a[href="/test-independent-head/a"]').click() }, 'no-requests') - // Now we're on route A. Reveal the sibling link to route B. The - // layout is shared between A and B, so it's already cached and won't + // Now we're on route A. Reveal the sibling link to route B (with prefetch={true}). + // The layout is shared between A and B, so it's already cached and won't // be re-fetched. The only new segment is the [item] page. On this // hint-unset route it deopts to the batched runtime prefetch, and B's // param-specific head rides along in the same request — no standalone @@ -890,9 +895,7 @@ describe('prefetch inlining', () => { .elementByCss('input[data-link-accordion="/test-independent-head/b"]') .click() }, [ - // The page below the layout arrives via the runtime prefetch. - { includes: 'page-independent-head', kind: 'runtime' }, - // ...and B's head rides along in the same runtime response. + // The page and the head arrive in the same runtime response. { includes: 'Independent Head Title: b', kind: 'runtime' }, ]) From da2a69dbe25b538b3510b19aea8d29960771403b Mon Sep 17 00:00:00 2001 From: "next-js-bot[bot]" <279046576+next-js-bot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:22:14 +0000 Subject: [PATCH 4/7] v16.3.1-canary.25 --- 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 ac12ebd57423..0eb4a94fa3fb 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.3.1-canary.24" + "version": "16.3.1-canary.25" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index f8950dd7f932..211159a14744 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.24", + "version": "16.3.1-canary.25", "keywords": [ "react", "next", diff --git a/packages/devlow-bench/package.json b/packages/devlow-bench/package.json index 2f66ad728027..b5f69ed0ec65 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.24", + "version": "16.3.1-canary.25", "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 c1a8f834aa70..6b2a3f048be1 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.24", + "version": "16.3.1-canary.25", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.3.1-canary.24", + "@next/eslint-plugin-next": "16.3.1-canary.25", "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 88d9d9c200e3..c12f6d6943b3 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.24", + "version": "16.3.1-canary.25", "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 fb2f835e9167..89514a1db2be 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.24", + "version": "16.3.1-canary.25", "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 54d2387933f8..a44e1238775b 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.24", + "version": "16.3.1-canary.25", "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 e5d460d26ee8..cee12c930732 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.24", + "version": "16.3.1-canary.25", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index ab88dc94269f..288baf0741e2 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.24", + "version": "16.3.1-canary.25", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 2d4f6977552d..e2b0cc768186 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.24", + "version": "16.3.1-canary.25", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index f7afa0de78e3..7f65a3606c34 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.24", + "version": "16.3.1-canary.25", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-playwright/package.json b/packages/next-playwright/package.json index 4e9fdb072a48..3fc78617b328 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.24", + "version": "16.3.1-canary.25", "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 496cb796844c..ff1d693bca1f 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.24", + "version": "16.3.1-canary.25", "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 86aa388a3957..fa81f72ba73e 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.24", + "version": "16.3.1-canary.25", "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 a96e118e0124..ffb829c328a5 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.24", + "version": "16.3.1-canary.25", "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 d8f710b67c24..475d144aef75 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.24", + "version": "16.3.1-canary.25", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index cc42df58b3d7..d2416ee24ae9 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.24", + "version": "16.3.1-canary.25", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 794b151969fe..89af2c0fa961 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.24", + "version": "16.3.1-canary.25", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index 1e40f227e73e..bcefc20cfde7 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.3.1-canary.24", + "version": "16.3.1-canary.25", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -100,7 +100,7 @@ ] }, "dependencies": { - "@next/env": "16.3.1-canary.24", + "@next/env": "16.3.1-canary.25", "@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.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", + "@next/font": "16.3.1-canary.25", + "@next/polyfill-module": "16.3.1-canary.25", + "@next/polyfill-nomodule": "16.3.1-canary.25", + "@next/react-refresh-utils": "16.3.1-canary.25", + "@next/swc": "16.3.1-canary.25", "@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 bbfa30468eff..ffebf603ac57 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.24", + "version": "16.3.1-canary.25", "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 274a8bf5f91a..004af18f1a01 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.24", + "version": "16.3.1-canary.25", "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.24", + "next": "16.3.1-canary.25", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "6.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0eb8bda2eda..d9c2c695680e 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.24 + specifier: 16.3.1-canary.25 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.24 + specifier: 16.3.1-canary.25 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.24 + specifier: 16.3.1-canary.25 version: link:../font '@next/polyfill-module': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 version: link:../react-refresh-utils '@next/swc': - specifier: 16.3.1-canary.24 + specifier: 16.3.1-canary.25 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.24 + specifier: 16.3.1-canary.25 version: link:../next outdent: specifier: 0.8.0 From f603d4ab9431337929a74c0b9a8dc572a0cf0c16 Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Wed, 19 Aug 2026 17:22:46 -0700 Subject: [PATCH 5/7] Serialize frozen collections by value only (#96686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Serialize `FrozenMap` as a map and `FrozenSet` as a sequence instead of exposing their internal fields. Add JSON shape and round-trip coverage for both collection types. ## Why? Keep serialized formats tied to collection semantics rather than their current storage representation. This matches Serde's implementations for `HashMap` and `HashSet`, which serialize through `collect_map` and `collect_seq` respectively. --------- Co-authored-by: Benjamin Woodruff ‮ --- Cargo.lock | 1 + turbopack/crates/turbo-frozenmap/Cargo.toml | 1 + turbopack/crates/turbo-frozenmap/src/map.rs | 58 ++++++++++++++++++++- turbopack/crates/turbo-frozenmap/src/set.rs | 58 ++++++++++++++++++++- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e069ac13edb..4d0bb26f0812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9994,6 +9994,7 @@ dependencies = [ "bincode 2.0.1", "indexmap 2.13.0", "serde", + "serde_json", ] [[package]] diff --git a/turbopack/crates/turbo-frozenmap/Cargo.toml b/turbopack/crates/turbo-frozenmap/Cargo.toml index 9691b90a4d2d..e2842a11a527 100644 --- a/turbopack/crates/turbo-frozenmap/Cargo.toml +++ b/turbopack/crates/turbo-frozenmap/Cargo.toml @@ -10,6 +10,7 @@ indexmap = { workspace = true } serde = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } [lints] workspace = true diff --git a/turbopack/crates/turbo-frozenmap/src/map.rs b/turbopack/crates/turbo-frozenmap/src/map.rs index cf99eba3dfb9..f8f7c962f749 100644 --- a/turbopack/crates/turbo-frozenmap/src/map.rs +++ b/turbopack/crates/turbo-frozenmap/src/map.rs @@ -4,12 +4,16 @@ use std::{ fmt::{self, Debug}, hash::BuildHasher, iter::FusedIterator, + marker::PhantomData, ops::{Bound, Index, RangeBounds}, }; use bincode::{BorrowDecode, Decode, Encode}; use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Serialize, + de::{MapAccess, Visitor}, +}; /// A compact frozen (immutable) ordered map backed by a sorted boxed slice. /// @@ -38,7 +42,7 @@ use serde::{Deserialize, Serialize}; /// /// Overlapping keys encountered during construction preserve the last overlapping entry, matching /// similar behavior for other maps in the standard library. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode)] #[rustfmt::skip] // rustfmt breaks bincode's proc macro string processing #[bincode( decode_bounds = "K: Decode<__Context> + 'static, V: Decode<__Context> + 'static", @@ -49,6 +53,44 @@ pub struct FrozenMap { pub(crate) entries: Box<[(K, V)]>, } +impl Serialize for FrozenMap { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_map(self.iter()) + } +} + +impl<'de, K, V> Deserialize<'de> for FrozenMap +where + K: Deserialize<'de> + Ord, + V: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + struct MapVisitor(PhantomData<(K, V)>); + + impl<'de, K, V> Visitor<'de> for MapVisitor + where + K: Deserialize<'de> + Ord, + V: Deserialize<'de>, + { + type Value = FrozenMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a map") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some(entry) = map.next_entry()? { + entries.push(entry); + } + Ok(FrozenMap::from(entries)) + } + } + + deserializer.deserialize_map(MapVisitor(PhantomData)) + } +} + impl FrozenMap { /// Creates an empty [`FrozenMap`]. Does not perform any heap allocations. pub fn new() -> Self { @@ -812,6 +854,18 @@ impl Clone for Range<'_, K, V> { mod tests { use super::*; + #[test] + fn serde_uses_map_shape() { + let map = FrozenMap::from([("b", 2), ("a", 1)]); + let json = serde_json::to_string(&map).unwrap(); + + assert_eq!(json, r#"{"a":1,"b":2}"#); + assert_eq!( + serde_json::from_str::>(r#"{"b":2, "a":1}"#).unwrap(), + map + ); + } + #[test] fn test_empty() { let map = FrozenMap::::new(); diff --git a/turbopack/crates/turbo-frozenmap/src/set.rs b/turbopack/crates/turbo-frozenmap/src/set.rs index 30e12dea888f..4361bcea789e 100644 --- a/turbopack/crates/turbo-frozenmap/src/set.rs +++ b/turbopack/crates/turbo-frozenmap/src/set.rs @@ -4,12 +4,16 @@ use std::{ fmt::{self, Debug}, hash::BuildHasher, iter::FusedIterator, + marker::PhantomData, ops::RangeBounds, }; use bincode::{BorrowDecode, Decode, Encode}; use indexmap::IndexSet; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Serialize, + de::{SeqAccess, Visitor}, +}; use crate::map::{self, FrozenMap}; @@ -37,7 +41,7 @@ use crate::map::{self, FrozenMap}; /// [`Vec`] or boxed slice. Because of limitations of the internal representation and Rust's memory /// layout rules, the most efficient way to convert from these data structures is via an /// [`Iterator`]. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Encode, Decode)] #[bincode( decode_bounds = "T: Decode<__Context> + 'static", borrow_decode_bounds = "T: BorrowDecode<'__de, __Context> + '__de" @@ -46,6 +50,44 @@ pub struct FrozenSet { map: FrozenMap, } +impl Serialize for FrozenSet { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.iter()) + } +} + +impl<'de, T> Deserialize<'de> for FrozenSet +where + T: Deserialize<'de> + Ord, +{ + fn deserialize>(deserializer: D) -> Result { + struct SeqVisitor(PhantomData); + + impl<'de, T> Visitor<'de> for SeqVisitor + where + T: Deserialize<'de> + Ord, + { + type Value = FrozenSet; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a sequence") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(item) = seq.next_element()? { + items.push((item, ())); + } + Ok(FrozenSet { + map: FrozenMap::from(items), + }) + } + } + + deserializer.deserialize_seq(SeqVisitor(PhantomData)) + } +} + impl FrozenSet { /// Creates an empty [`FrozenSet`]. Does not perform any heap allocations. pub fn new() -> Self { @@ -355,6 +397,18 @@ impl Clone for Range<'_, T> { mod tests { use super::*; + #[test] + fn serde_uses_sequence_shape() { + let set = FrozenSet::from([2, 1]); + let json = serde_json::to_string(&set).unwrap(); + + assert_eq!(json, "[1,2]"); + assert_eq!( + serde_json::from_str::>("[2, 1]").unwrap(), + set + ); + } + #[test] fn test_empty() { let set = FrozenSet::::new(); From 1a304e371017db76b3f6b67e9dfcf068143566ab Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Wed, 19 Aug 2026 17:22:47 -0700 Subject: [PATCH 6/7] Keep HMR instructions typed until serialization (#96569) Stacked on #96686. ## What? Keep HMR update instructions as typed Rust values until they cross the client protocol serialization boundary. The JSON wire format remains unchanged. ## Why? HMR aggregation currently operates on `serde_json::Value`, so it has to inspect serialized `type` fields and clone values out of JSON objects. This loses type information before aggregation and makes unsupported instruction variants easy to overlook. Keeping instructions typed lets aggregation use concrete equality and hashing, preserves stable ordering while deduplicating updates, and falls back to a total update when it encounters an instruction type it cannot aggregate. ## How? - Add `UpdateInstructionValue`, a type-erased serializable value with typed downcasting and equality. - Represent chunk-list and merged ECMAScript updates with owned, hashable values backed by frozen collections. - Aggregate typed instructions with first-seen ordering for merged updates and last-writer-wins values for chunk updates. - Serialize instructions only when producing the HMR protocol message. - Add unit coverage for downcasting, equality, hashing, aggregation behavior, and the existing JSON protocol shape. ## Verification - Unit tests cover the new typed values and aggregation behavior. - Serialization tests confirm the client-facing HMR messages are unchanged. - CI --- Cargo.lock | 2 + crates/next-api/src/aggregate_hmr.rs | 152 ++++++++++++------ turbopack/crates/turbo-tasks-fs/src/rope.rs | 27 +++- .../src/ecmascript/list/content.rs | 1 - turbopack/crates/turbopack-core/Cargo.toml | 1 + turbopack/crates/turbopack-core/src/lib.rs | 1 + .../turbopack-core/src/update_instruction.rs | 105 ++++++++++++ .../crates/turbopack-core/src/version.rs | 5 +- .../Cargo.toml | 5 +- .../src/lib.rs | 53 +++++- .../src/chunk_list/merged_update.rs | 48 +++--- .../src/chunk_list/update.rs | 124 ++++++++++---- .../turbopack-ecmascript/src/hmr/update.rs | 73 +++++---- .../node/entry/chunk_list_content.rs | 2 +- 14 files changed, 452 insertions(+), 147 deletions(-) create mode 100644 turbopack/crates/turbopack-core/src/update_instruction.rs diff --git a/Cargo.lock b/Cargo.lock index 4d0bb26f0812..1f5903fe2cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10528,6 +10528,7 @@ dependencies = [ "const_format", "data-encoding", "either", + "erased-serde", "indexmap 2.13.0", "num-bigint", "patricia_tree", @@ -10703,6 +10704,7 @@ dependencies = [ "serde", "serde_json", "turbo-rcstr", + "turbo-tasks", "turbopack-cli-utils", "turbopack-core", ] diff --git a/crates/next-api/src/aggregate_hmr.rs b/crates/next-api/src/aggregate_hmr.rs index 895e67f6710d..c773cca54302 100644 --- a/crates/next-api/src/aggregate_hmr.rs +++ b/crates/next-api/src/aggregate_hmr.rs @@ -1,7 +1,4 @@ -use std::sync::Arc; - use anyhow::Result; -use rustc_hash::FxHashMap; use turbo_rcstr::RcStr; use turbo_tasks::{ FxIndexMap, FxIndexSet, NonLocalValue, ReadRef, ResolvedVc, TraitRef, TryJoinIterExt, Vc, @@ -10,7 +7,14 @@ use turbo_tasks::{ use turbo_tasks_fs::FileSystemPath; use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64}; use turbopack_browser::ecmascript::list::content::EcmascriptDevChunkListContent; -use turbopack_core::version::{PartialUpdate, Update, Version, VersionState, VersionedContent}; +use turbopack_core::{ + update_instruction::UpdateInstruction, + version::{PartialUpdate, Update, Version, VersionState, VersionedContent}, +}; +use turbopack_ecmascript::chunk_list::{ + merged_update::EcmascriptMergedUpdate, + update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}, +}; use turbopack_nodejs::ecmascript::node::entry::chunk_list_content::EcmascriptBuildNodeChunkListContent; use crate::versioned_content_map::VersionedContentMap; @@ -103,38 +107,30 @@ impl AggregateHmrVersion { /// Aggregates per-entry HMR instructions into a single combined `ChunkListUpdate`. #[derive(Default)] pub struct ChunkListUpdateBuilder { - chunks: FxHashMap, - merged: FxIndexSet, + chunks: FxIndexMap, + merged: FxIndexSet, } impl ChunkListUpdateBuilder { - pub fn add_instruction(&mut self, instruction: &serde_json::Value) { - let Some(obj) = instruction.as_object() else { - return; - }; - match obj.get("type").and_then(|v| v.as_str()) { - Some("ChunkListUpdate") => { - if let Some(chunks) = obj.get("chunks").and_then(|v| v.as_object()) { - for (k, v) in chunks { - self.chunks.insert(k.clone(), v.clone()); - } + pub fn add_instruction(&mut self, instruction: &UpdateInstruction) { + let instruction = instruction + .downcast_ref::() + .expect("aggregate HMR only accepts ECMAScript update instructions"); + + match instruction { + EcmascriptUpdateInstruction::ChunkList(update) => { + for (chunk_path, update) in &update.chunks { + self.chunks.insert(chunk_path.clone(), update.clone()); } - if let Some(merged) = obj.get("merged").and_then(|v| v.as_array()) { - for update in merged { - self.push_merged(update); - } + for update in &update.merged { + self.push_merged(update); } } - Some("EcmascriptMergedUpdate") => { - self.push_merged(instruction); - } - // Unknown instruction shapes are ignored; the caller already - // escalates `Total`/`Missing` updates to a full restart. - _ => {} + EcmascriptUpdateInstruction::Merged(update) => self.push_merged(update), } } - fn push_merged(&mut self, update: &serde_json::Value) { + fn push_merged(&mut self, update: &EcmascriptMergedUpdate) { self.merged.insert(update.clone()); } @@ -143,26 +139,13 @@ impl ChunkListUpdateBuilder { } pub fn build(self, to: TraitRef>) -> Update { - let mut instruction = serde_json::Map::new(); - instruction.insert( - "type".to_string(), - serde_json::Value::String("ChunkListUpdate".to_string()), - ); - if !self.chunks.is_empty() { - instruction.insert( - "chunks".to_string(), - serde_json::Value::Object(self.chunks.into_iter().collect()), - ); - } - if !self.merged.is_empty() { - instruction.insert( - "merged".to_string(), - serde_json::Value::Array(self.merged.into_iter().collect()), - ); - } Update::Partial(PartialUpdate { to, - instruction: Arc::new(serde_json::Value::Object(instruction)), + instruction: ChunkListUpdate { + chunks: self.chunks, + merged: self.merged.into_iter().collect(), + } + .into_instruction(), }) } } @@ -222,3 +205,82 @@ pub async fn diff_chunks_against( has_new_chunks, }) } + +#[cfg(test)] +mod tests { + use turbo_tasks::{FxIndexMap, FxIndexSet}; + use turbopack_core::update_instruction::UpdateInstruction; + use turbopack_ecmascript::chunk_list::{ + merged_update::{ + EcmascriptMergedChunkDeleted, EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, + }, + update::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}, + }; + + use super::ChunkListUpdateBuilder; + + fn merged(chunk_path: &str) -> EcmascriptMergedUpdate { + EcmascriptMergedUpdate { + entries: Default::default(), + chunks: [( + chunk_path.into(), + EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted { + modules: Default::default(), + }), + )] + .into_iter() + .collect(), + } + } + + #[test] + fn deduplicates_merged_updates_in_first_seen_order() { + let first = merged("first.js"); + let second = merged("second.js"); + let mut builder = ChunkListUpdateBuilder::default(); + + builder.add_instruction(&UpdateInstruction::new( + EcmascriptUpdateInstruction::Merged(first.clone()), + )); + builder.add_instruction(&UpdateInstruction::new( + EcmascriptUpdateInstruction::Merged(second.clone()), + )); + builder.add_instruction(&UpdateInstruction::new( + EcmascriptUpdateInstruction::Merged(first.clone()), + )); + + assert_eq!(builder.merged, FxIndexSet::from_iter([first, second])); + } + + #[test] + fn chunk_updates_use_last_writer_and_stable_order() { + let mut builder = ChunkListUpdateBuilder::default(); + let first = ChunkListUpdate { + chunks: FxIndexMap::from_iter([ + ("a.js".into(), ChunkUpdate::Total), + ("b.js".into(), ChunkUpdate::Added), + ]), + merged: vec![], + }; + let second = ChunkListUpdate { + chunks: FxIndexMap::from_iter([ + ("a.js".into(), ChunkUpdate::Deleted), + ("c.js".into(), ChunkUpdate::Total), + ]), + merged: vec![], + }; + + builder.add_instruction(&first.into_instruction()); + builder.add_instruction(&second.into_instruction()); + + assert_eq!( + builder + .chunks + .keys() + .map(|path| path.as_str()) + .collect::>(), + ["a.js", "b.js", "c.js"] + ); + assert_eq!(builder.chunks["a.js"], ChunkUpdate::Deleted); + } +} diff --git a/turbopack/crates/turbo-tasks-fs/src/rope.rs b/turbopack/crates/turbo-tasks-fs/src/rope.rs index 7745fe703eab..acbf0e0f9db8 100644 --- a/turbopack/crates/turbo-tasks-fs/src/rope.rs +++ b/turbopack/crates/turbo-tasks-fs/src/rope.rs @@ -2,6 +2,7 @@ use std::{ borrow::Cow, cmp::{Ordering, min}, fmt, + hash::{Hash, Hasher}, io::{BufRead, Read, Result as IoResult, Write}, mem, ops::{AddAssign, Deref}, @@ -22,7 +23,7 @@ use bytes::Bytes; use futures::Stream; use tokio::io::{AsyncRead, ReadBuf}; use triomphe::Arc; -use turbo_tasks_hash::{DeterministicHash, DeterministicHasher}; +use turbo_tasks_hash::{DeterministicHash, DeterministicHasher, hash_xxh3_hash64}; static EMPTY_BUF: &[u8] = &[]; @@ -491,6 +492,12 @@ impl PartialEq for Rope { impl Eq for Rope {} +impl Hash for Rope { + fn hash(&self, state: &mut H) { + hash_xxh3_hash64(self.content_hash()).hash(state); + } +} + impl Ord for Rope { fn cmp(&self, other: &Self) -> Ordering { if Arc::ptr_eq(&self.data, &other.data) { @@ -1124,6 +1131,24 @@ mod test { assert_eq!(hash_xxh3_hash64(rope.content_hash()), hasher.finish()); } + #[test] + fn standard_hash_uses_content() { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + let original = Rope::from("same content"); + let copied = Rope::from(original.to_bytes().into_owned()); + let mut original_hasher = DefaultHasher::new(); + let mut copied_hasher = DefaultHasher::new(); + original.hash(&mut original_hasher); + copied.hash(&mut copied_hasher); + + assert_eq!(original, copied); + assert_eq!(original_hasher.finish(), copied_hasher.finish()); + } + #[test] fn iteration() { let shared = Rope::from("def"); diff --git a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs index ff2dab584309..fe977163ee32 100644 --- a/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs +++ b/turbopack/crates/turbopack-browser/src/ecmascript/list/content.rs @@ -36,7 +36,6 @@ enum CurrentChunkMethodWithData { DocumentCurrentScript, } -/// Contents of an [`EcmascriptDevChunkList`]. #[turbo_tasks::value] pub struct EcmascriptDevChunkListContent { current_chunk_method: CurrentChunkMethodWithData, diff --git a/turbopack/crates/turbopack-core/Cargo.toml b/turbopack/crates/turbopack-core/Cargo.toml index 8ab938429845..1217222e0dc5 100644 --- a/turbopack/crates/turbopack-core/Cargo.toml +++ b/turbopack/crates/turbopack-core/Cargo.toml @@ -23,6 +23,7 @@ bytes-str = { workspace = true } const_format = { workspace = true } data-encoding = { workspace = true } either = { workspace = true } +erased-serde = { workspace = true } indexmap = { workspace = true } num-bigint = "0.4" patricia_tree = { version = "0.10.1", features = ["serde"] } diff --git a/turbopack/crates/turbopack-core/src/lib.rs b/turbopack/crates/turbopack-core/src/lib.rs index fa924a889955..43ed7036dfef 100644 --- a/turbopack/crates/turbopack-core/src/lib.rs +++ b/turbopack/crates/turbopack-core/src/lib.rs @@ -41,6 +41,7 @@ pub mod source_map; pub mod source_pos; pub mod source_transform; pub mod target; +pub mod update_instruction; mod utils; pub mod version; pub mod virtual_output; diff --git a/turbopack/crates/turbopack-core/src/update_instruction.rs b/turbopack/crates/turbopack-core/src/update_instruction.rs new file mode 100644 index 000000000000..a4ddb8407991 --- /dev/null +++ b/turbopack/crates/turbopack-core/src/update_instruction.rs @@ -0,0 +1,105 @@ +use std::{any::Any, fmt::Debug, sync::Arc}; + +use serde::Serialize; +use turbo_tasks::{ + NonLocalValue, + debug::ValueDebugFormat, + trace::{TraceRawVcs, TraceRawVcsContext}, +}; + +trait ErasedUpdateInstruction: + erased_serde::Serialize + Debug + Send + Sync + NonLocalValue + 'static +{ + fn as_any(&self) -> &dyn Any; + fn dyn_eq(&self, other: &dyn Any) -> bool; + fn trace_raw_vcs(&self, trace_context: &mut TraceRawVcsContext); +} + +impl ErasedUpdateInstruction for T +where + T: Serialize + Eq + Debug + Send + Sync + NonLocalValue + TraceRawVcs + 'static, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn dyn_eq(&self, other: &dyn Any) -> bool { + other.downcast_ref::() == Some(self) + } + + fn trace_raw_vcs(&self, trace_context: &mut TraceRawVcsContext) { + TraceRawVcs::trace_raw_vcs(self, trace_context); + } +} + +erased_serde::serialize_trait_object!(ErasedUpdateInstruction); + +#[derive(Clone, Debug, Serialize, ValueDebugFormat, NonLocalValue)] +#[serde(transparent)] +pub struct UpdateInstruction(Arc); + +impl PartialEq for UpdateInstruction { + fn eq(&self, other: &Self) -> bool { + self.0.dyn_eq(other.0.as_any()) + } +} + +impl Eq for UpdateInstruction {} + +impl UpdateInstruction { + pub fn new(instruction: T) -> Self + where + T: Serialize + Eq + Debug + Send + Sync + NonLocalValue + TraceRawVcs + 'static, + { + Self(Arc::new(instruction)) + } + + pub fn downcast_ref(&self) -> Option<&T> { + self.0.as_any().downcast_ref() + } +} + +impl TraceRawVcs for UpdateInstruction { + fn trace_raw_vcs(&self, trace_context: &mut TraceRawVcsContext) { + ErasedUpdateInstruction::trace_raw_vcs(self.0.as_ref(), trace_context); + } +} + +#[cfg(test)] +mod tests { + use serde::Serialize; + use turbo_tasks::{NonLocalValue, trace::TraceRawVcs}; + + use super::UpdateInstruction; + + #[derive(Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] + struct TestInstruction { + value: u32, + } + + #[test] + fn serializes_without_an_extra_wrapper() { + let instruction = UpdateInstruction::new(TestInstruction { value: 42 }); + + assert_eq!( + serde_json::to_value(&instruction).unwrap(), + serde_json::json!({ "value": 42 }) + ); + } + + #[test] + fn downcasts_by_concrete_type() { + let instruction = UpdateInstruction::new(TestInstruction { value: 42 }); + + assert_eq!( + instruction + .downcast_ref::() + .map(|instruction| instruction.value), + Some(42) + ); + assert_eq!( + instruction, + UpdateInstruction::new(TestInstruction { value: 42 }) + ); + } +} diff --git a/turbopack/crates/turbopack-core/src/version.rs b/turbopack/crates/turbopack-core/src/version.rs index bb1d422adc9c..89744f6f80ea 100644 --- a/turbopack/crates/turbopack-core/src/version.rs +++ b/turbopack/crates/turbopack-core/src/version.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use anyhow::{Context, Result, bail}; use turbo_rcstr::RcStr; use turbo_tasks::{ @@ -213,8 +211,7 @@ pub struct PartialUpdate { pub to: TraitRef>, /// The instructions to be passed to a remote system in order to update the /// versioned object. - #[turbo_tasks(trace_ignore)] - pub instruction: Arc, + pub instruction: crate::update_instruction::UpdateInstruction, } /// [`Version`] implementation that hashes a file at a given path and returns diff --git a/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml b/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml index 08a5893c1cee..6e85c6ae11cf 100644 --- a/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml +++ b/turbopack/crates/turbopack-ecmascript-hmr-protocol/Cargo.toml @@ -14,8 +14,11 @@ workspace = true [dependencies] serde = { workspace = true } -serde_json = { workspace = true } turbo-rcstr = { workspace = true } turbopack-cli-utils = { workspace = true } turbopack-core = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +turbo-tasks = { workspace = true } diff --git a/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs b/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs index 95fc8213b4e1..4da42b8a0a81 100644 --- a/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs +++ b/turbopack/crates/turbopack-ecmascript-hmr-protocol/src/lib.rs @@ -1,12 +1,12 @@ use std::{collections::BTreeMap, fmt::Display, path::PathBuf}; use serde::{Deserialize, Serialize}; -use serde_json::Value; use turbo_rcstr::RcStr; use turbopack_cli_utils::issue::{LogOptions, format_issue}; use turbopack_core::{ issue::{IssueSeverity, IssueStage, PlainIssue, StyledString}, source_pos::SourcePos, + update_instruction::UpdateInstruction, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -78,7 +78,7 @@ impl<'a> ClientUpdateInstruction<'a> { pub fn partial( resource: &'a ResourceIdentifier, - instruction: &'a Value, + instruction: &'a UpdateInstruction, issues: &'a [Issue<'a>], ) -> Self { Self::new( @@ -106,7 +106,7 @@ impl<'a> ClientUpdateInstruction<'a> { pub enum ClientUpdateInstructionType<'a> { Restart, NotFound, - Partial { instruction: &'a Value }, + Partial { instruction: &'a UpdateInstruction }, Issues, } @@ -187,3 +187,50 @@ impl<'a> From<&'a PlainIssue> for Issue<'a> { } } } + +#[cfg(test)] +mod tests { + use serde::Serialize; + use serde_json::json; + use turbo_rcstr::rcstr; + use turbo_tasks::{NonLocalValue, trace::TraceRawVcs}; + use turbopack_core::update_instruction::UpdateInstruction; + + use super::{ClientUpdateInstruction, ResourceIdentifier}; + + #[derive(Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] + struct TestInstruction(serde_json::Value); + + #[test] + fn partial_instruction_wire_format_is_unchanged() { + let resource = ResourceIdentifier { + path: rcstr!("server/app.js"), + headers: None, + }; + let instruction = UpdateInstruction::new(TestInstruction(json!({ + "type": "ecmascriptMerged", + "chunks": {}, + }))); + + assert_eq!( + serde_json::to_value(ClientUpdateInstruction::partial( + &resource, + &instruction, + &[], + )) + .unwrap(), + json!({ + "resource": { + "path": "server/app.js", + "headers": null, + }, + "type": "partial", + "instruction": { + "type": "ecmascriptMerged", + "chunks": {}, + }, + "issues": [], + }) + ); + } +} diff --git a/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs b/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs index 1901649fc091..3c4c88ea9762 100644 --- a/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk_list/merged_update.rs @@ -12,34 +12,36 @@ use anyhow::Result; use serde::Serialize; -use turbo_tasks::{FxIndexMap, FxIndexSet, Vc}; +use turbo_frozenmap::{FrozenMap, FrozenSet}; +use turbo_rcstr::RcStr; +use turbo_tasks::{NonLocalValue, Vc, trace::TraceRawVcs}; use turbo_tasks_fs::rope::Rope; use turbopack_core::{chunk::ModuleId, code_builder::Code, source_map::GenerateSourceMap}; /// A merged update covering one or more ecmascript chunks that share a merger. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde( tag = "type", rename = "EcmascriptMergedUpdate", rename_all = "camelCase" )] -pub struct EcmascriptMergedUpdate<'a> { +pub struct EcmascriptMergedUpdate { /// A map from module id to its latest module entry (code + source map url). - #[serde(skip_serializing_if = "FxIndexMap::is_empty")] - pub entries: FxIndexMap, + #[serde(skip_serializing_if = "FrozenMap::is_empty")] + pub entries: FrozenMap, /// A map from chunk path to the update for that chunk. - #[serde(skip_serializing_if = "FxIndexMap::is_empty")] - pub chunks: FxIndexMap<&'a str, EcmascriptMergedChunkUpdate>, + #[serde(skip_serializing_if = "FrozenMap::is_empty")] + pub chunks: FrozenMap, } -impl EcmascriptMergedUpdate<'_> { +impl EcmascriptMergedUpdate { pub fn is_empty(&self) -> bool { self.entries.is_empty() && self.chunks.is_empty() } } /// Per-chunk portion of an [`EcmascriptMergedUpdate`]. -#[derive(Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(tag = "type", rename_all = "camelCase")] pub enum EcmascriptMergedChunkUpdate { Added(EcmascriptMergedChunkAdded), @@ -48,41 +50,41 @@ pub enum EcmascriptMergedChunkUpdate { } /// A chunk that was newly added in this version. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(rename_all = "camelCase")] pub struct EcmascriptMergedChunkAdded { - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub modules: FxIndexSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub modules: FrozenSet, } /// A chunk that was removed in this version. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(rename_all = "camelCase")] pub struct EcmascriptMergedChunkDeleted { // Technically, this is redundant, since the client will already know all // modules in the chunk from the previous version. However, it's useful for // merging updates without access to an initial state. - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub modules: FxIndexSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub modules: FrozenSet, } /// A chunk that was present in both versions and whose module membership /// changed. -#[derive(Serialize, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] #[serde(rename_all = "camelCase")] pub struct EcmascriptMergedChunkPartial { - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub added: FxIndexSet, - #[serde(skip_serializing_if = "FxIndexSet::is_empty")] - pub deleted: FxIndexSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub added: FrozenSet, + #[serde(skip_serializing_if = "FrozenSet::is_empty")] + pub deleted: FrozenSet, } /// The code (and source map) for a single module in a merged update. -#[derive(Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, TraceRawVcs, NonLocalValue)] pub struct EcmascriptModuleEntry { #[serde(with = "turbo_tasks_fs::rope::ser_as_string")] pub code: Rope, - pub url: String, + pub url: RcStr, #[serde(with = "turbo_tasks_fs::rope::ser_option_as_string")] pub map: Option, } @@ -102,7 +104,7 @@ impl EcmascriptModuleEntry { Ok(EcmascriptModuleEntry { // Cloning a rope is cheap. code: code.await?.source_code().clone(), - url: format!("{}?{}", chunk_path, id), + url: format!("{}?{}", chunk_path, id).into(), map, }) } diff --git a/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs b/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs index 7233d294976b..663b795110b9 100644 --- a/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs +++ b/turbopack/crates/turbopack-ecmascript/src/chunk_list/update.rs @@ -1,51 +1,57 @@ -use std::sync::Arc; - use anyhow::Result; use serde::Serialize; -use turbo_tasks::{FxIndexMap, ResolvedVc, TraitRef, Vc}; -use turbopack_core::version::{ - MergeableVersionedContent, PartialUpdate, TotalUpdate, Update, Version, VersionedContent, - VersionedContentMerger, +use turbo_rcstr::RcStr; +use turbo_tasks::{FxIndexMap, NonLocalValue, ResolvedVc, TraitRef, Vc, trace::TraceRawVcs}; +use turbopack_core::{ + update_instruction::UpdateInstruction, + version::{ + MergeableVersionedContent, PartialUpdate, TotalUpdate, Update, Version, VersionedContent, + VersionedContentMerger, + }, }; -use super::version::ChunkListVersion; +use super::{merged_update::EcmascriptMergedUpdate, version::ChunkListVersion}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] +#[serde(untagged)] +pub enum EcmascriptUpdateInstruction { + ChunkList(ChunkListUpdate), + Merged(EcmascriptMergedUpdate), +} /// Update of a chunk list from one version to another. -#[derive(Serialize)] -#[serde(tag = "type")] -#[serde(rename_all = "camelCase")] -struct ChunkListUpdate<'a> { +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] +#[serde(tag = "type", rename = "ChunkListUpdate", rename_all = "camelCase")] +pub struct ChunkListUpdate { /// A map from chunk path to a corresponding update of that chunk. #[serde(skip_serializing_if = "FxIndexMap::is_empty")] - chunks: FxIndexMap<&'a str, ChunkUpdate>, + pub chunks: FxIndexMap, /// List of merged updates since the last version. #[serde(skip_serializing_if = "Vec::is_empty")] - merged: Vec>, + pub merged: Vec, +} + +impl ChunkListUpdate { + pub fn into_instruction(self) -> UpdateInstruction { + UpdateInstruction::new(EcmascriptUpdateInstruction::ChunkList(self)) + } } /// Update of a chunk from one version to another. -#[derive(Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TraceRawVcs, NonLocalValue)] #[serde(tag = "type")] #[serde(rename_all = "camelCase")] -enum ChunkUpdate { +pub enum ChunkUpdate { /// The chunk was updated and must be reloaded. Total, /// The chunk was updated and can be merged with the previous version. - Partial { instruction: Arc }, + Partial { instruction: EcmascriptMergedUpdate }, /// The chunk was added. Added, /// The chunk was deleted. Deleted, } -impl ChunkListUpdate<'_> { - /// Returns `true` if this update is empty. - fn is_empty(&self) -> bool { - let ChunkListUpdate { chunks, merged } = self; - chunks.is_empty() && merged.is_empty() - } -} - /// Computes the update of a chunk list from one version to another. /// /// Runtime-agnostic (takes plain paths + [`VersionedContent`]) so the browser @@ -105,25 +111,26 @@ pub async fn update_chunk_list( match &*chunk_update { Update::Total(_) => { - chunks.insert(chunk_path.as_ref(), ChunkUpdate::Total); + chunks.insert(chunk_path.clone().into(), ChunkUpdate::Total); } Update::Partial(partial) => { + let instruction = expect_merged_instruction_from_partial(partial); chunks.insert( - chunk_path.as_ref(), + chunk_path.clone().into(), ChunkUpdate::Partial { - instruction: partial.instruction.clone(), + instruction: instruction.clone(), }, ); } Update::Missing | Update::None => {} } } else { - chunks.insert(chunk_path.as_ref(), ChunkUpdate::Deleted); + chunks.insert(chunk_path.clone().into(), ChunkUpdate::Deleted); } } for chunk_path in by_path.keys() { - chunks.insert(chunk_path.as_ref(), ChunkUpdate::Added); + chunks.insert((*chunk_path).clone().into(), ChunkUpdate::Added); } let mut merged = vec![]; @@ -147,24 +154,73 @@ pub async fn update_chunk_list( .cell()); } Update::Partial(partial) => { - merged.push(partial.instruction.clone()); + let instruction = expect_merged_instruction_from_partial(partial); + merged.push(instruction.clone()); } Update::Missing | Update::None => {} } } } - let update = ChunkListUpdate { chunks, merged }; - - let update = if update.is_empty() { + let update = if chunks.is_empty() && merged.is_empty() { Update::None } else { Update::Partial(PartialUpdate { to: Vc::upcast::>(to_version) .into_trait_ref() .await?, - instruction: Arc::new(serde_json::to_value(&update)?), + instruction: ChunkListUpdate { chunks, merged }.into_instruction(), }) }; Ok(update.cell()) } + +fn expect_merged_instruction_from_partial(partial: &PartialUpdate) -> &EcmascriptMergedUpdate { + let Some(EcmascriptUpdateInstruction::Merged(instruction)) = partial + .instruction + .downcast_ref::( + ) else { + panic!("ECMAScript partial updates must contain a merged instruction"); + }; + instruction +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use turbo_frozenmap::{FrozenMap, FrozenSet}; + use turbo_tasks::FxIndexMap; + + use super::{ChunkListUpdate, ChunkUpdate, EcmascriptUpdateInstruction}; + use crate::chunk_list::merged_update::{ + EcmascriptMergedChunkAdded, EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, + }; + + #[test] + fn instruction_wire_format() { + let instruction = EcmascriptUpdateInstruction::ChunkList(ChunkListUpdate { + chunks: FxIndexMap::from_iter([("app.js".into(), ChunkUpdate::Total)]), + merged: vec![EcmascriptMergedUpdate { + entries: FrozenMap::default(), + chunks: FrozenMap::from_iter([( + "app.js".into(), + EcmascriptMergedChunkUpdate::Added(EcmascriptMergedChunkAdded { + modules: FrozenSet::default(), + }), + )]), + }], + }); + + assert_eq!( + serde_json::to_value(instruction).unwrap(), + json!({ + "type": "ChunkListUpdate", + "chunks": { "app.js": { "type": "total" } }, + "merged": [{ + "type": "EcmascriptMergedUpdate", + "chunks": { "app.js": { "type": "added" } }, + }], + }) + ); + } +} diff --git a/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs b/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs index dc0eda68e24e..5af6997c42e6 100644 --- a/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs +++ b/turbopack/crates/turbopack-ecmascript/src/hmr/update.rs @@ -1,18 +1,21 @@ -use std::sync::Arc; - use anyhow::Result; -use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TryJoinIterExt, Vc}; +use turbo_frozenmap::{FrozenMap, FrozenSet}; +use turbo_tasks::{FxIndexMap, FxIndexSet, ReadRef, ResolvedVc, TryJoinIterExt, Vc}; use turbopack_core::{ chunk::ModuleId, code_builder::Code, + update_instruction::UpdateInstruction, version::{PartialUpdate, TotalUpdate, Update, Version}, }; use crate::{ chunk::EcmascriptChunkContentEntries, - chunk_list::merged_update::{ - EcmascriptMergedChunkAdded, EcmascriptMergedChunkDeleted, EcmascriptMergedChunkPartial, - EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, EcmascriptModuleEntry, + chunk_list::{ + merged_update::{ + EcmascriptMergedChunkAdded, EcmascriptMergedChunkDeleted, EcmascriptMergedChunkPartial, + EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, EcmascriptModuleEntry, + }, + update::EcmascriptUpdateInstruction, }, hmr::{ EcmascriptHmrChunkContent, @@ -145,21 +148,24 @@ async fn partial_chunk_update( unreachable!("caller filters out EcmascriptChunkUpdate::None"); }; - let mut partial = EcmascriptMergedChunkPartial::default(); + let mut added_modules = FxIndexSet::default(); for (id, AddedModule { hash, code }) in added { - partial.added.insert(id.clone()); + added_modules.insert(id.clone()); insert_entry_unless_shipped(entries, from_versions, id, hash, *code, chunk_path).await?; } - partial.deleted.extend(deleted.into_keys()); - for (id, code) in modified { let entry = EcmascriptModuleEntry::from_code(&id, *code, chunk_path).await?; entries.insert(id, entry); } - Ok(EcmascriptMergedChunkUpdate::Partial(partial)) + Ok(EcmascriptMergedChunkUpdate::Partial( + EcmascriptMergedChunkPartial { + added: FrozenSet::from(added_modules), + deleted: deleted.into_keys().collect(), + }, + )) } /// Builds the payload for a chunk that wasn't present in the previous version. @@ -169,10 +175,10 @@ async fn added_chunk_update( from_versions: &[ReadRef], entries: &mut FxIndexMap, ) -> Result { - let mut added = EcmascriptMergedChunkAdded::default(); + let mut modules = FxIndexSet::default(); for (id, entry) in chunk_entries.iter() { - added.modules.insert(id.clone()); + modules.insert(id.clone()); insert_entry_unless_shipped( entries, from_versions, @@ -184,7 +190,11 @@ async fn added_chunk_update( .await?; } - Ok(EcmascriptMergedChunkUpdate::Added(added)) + Ok(EcmascriptMergedChunkUpdate::Added( + EcmascriptMergedChunkAdded { + modules: FrozenSet::from(modules), + }, + )) } /// Computes a single [`Update`] covering every chunk in a merged chunk content. @@ -235,7 +245,8 @@ pub async fn update_ecmascript_merged_chunk( .try_join() .await?; - let mut merged_update = EcmascriptMergedUpdate::default(); + let mut merged_entries = FxIndexMap::default(); + let mut chunks = FxIndexMap::default(); for (content, entries, to_version) in &to_contents { let chunk_path = to_version.chunk_path.as_str(); @@ -247,40 +258,34 @@ pub async fn update_ecmascript_merged_chunk( { EcmascriptChunkUpdate::None => continue, update => { - partial_chunk_update( - update, - chunk_path, - from_versions, - &mut merged_update.entries, - ) - .await? + partial_chunk_update(update, chunk_path, from_versions, &mut merged_entries) + .await? } } } None => { - added_chunk_update( - entries, - chunk_path, - from_versions, - &mut merged_update.entries, - ) - .await? + added_chunk_update(entries, chunk_path, from_versions, &mut merged_entries).await? } }; - merged_update.chunks.insert(chunk_path, chunk_update); + chunks.insert(to_version.chunk_path.clone(), chunk_update); } - for (chunk_path, chunk_version) in from_versions_by_chunk_path { + for chunk_version in from_versions_by_chunk_path.into_values() { let hashes = &chunk_version.entries_hashes; - merged_update.chunks.insert( - chunk_path, + chunks.insert( + chunk_version.chunk_path.clone(), EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted { modules: hashes.keys().cloned().collect(), }), ); } + let merged_update = EcmascriptMergedUpdate { + entries: FrozenMap::from(merged_entries), + chunks: FrozenMap::from(chunks), + }; + Ok(if merged_update.is_empty() { Update::None } else { @@ -288,7 +293,7 @@ pub async fn update_ecmascript_merged_chunk( to: Vc::upcast::>(to_merged_version) .into_trait_ref() .await?, - instruction: Arc::new(serde_json::to_value(&merged_update)?), + instruction: UpdateInstruction::new(EcmascriptUpdateInstruction::Merged(merged_update)), }) }) } diff --git a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs index 60148fe1c59c..ccdabf4177be 100644 --- a/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs +++ b/turbopack/crates/turbopack-nodejs/src/ecmascript/node/entry/chunk_list_content.rs @@ -100,7 +100,7 @@ impl EcmascriptBuildNodeChunkListContent { /// Builds a chunk list content directly from a fixed set of `chunks`, /// without expanding async-loader references. Used by - /// [`super::chunk_list::EcmascriptBuildNodeChunkList`] to track chunks + /// `super::chunk_list::EcmascriptBuildNodeChunkList` to track chunks /// (e.g. client-component SSR chunks) that are already fully enumerated by /// the caller. #[turbo_tasks::function] From e2fb664ceb129115a72071405d5e6aa14ddfc841 Mon Sep 17 00:00:00 2001 From: Will Binns-Smith Date: Wed, 19 Aug 2026 17:22:47 -0700 Subject: [PATCH 7/7] Remove HmrTarget (#97253) With #94948 we intended to move the client over to the firehose feed of HMR events with the intent of unifying the code paths for maintenance. However, now that Server HMR is moving to a pull-based model (which client HMR will not be able to implement), let's keep the split. There's no need to encode the HmrTarget into each surface, and we can just use the function name to indicate which mode of HMR it's for. --- crates/next-api/src/project.rs | 132 ++++-------------- crates/next-build-test/src/lib.rs | 10 +- .../src/next_api/project.rs | 101 ++++++-------- .../next/src/build/swc/generated-native.d.ts | 29 ++-- packages/next/src/build/swc/index.ts | 43 ++---- packages/next/src/build/swc/types.ts | 25 ++-- .../src/server/dev/hot-reloader-turbopack.ts | 6 +- test/development/basic/next-rs-api.test.ts | 12 +- 8 files changed, 106 insertions(+), 252 deletions(-) diff --git a/crates/next-api/src/project.rs b/crates/next-api/src/project.rs index 5cfc1be77210..2b8cd228caa4 100644 --- a/crates/next-api/src/project.rs +++ b/crates/next-api/src/project.rs @@ -179,39 +179,6 @@ pub struct DebugBuildPaths { pub pages: Vec, } -/// Target for HMR operations - client-side (browser) or server-side (Node.js). -#[turbo_tasks::task_input] -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, TraceRawVcs, Encode, Decode)] -pub enum HmrTarget { - #[default] - Client, - Server, -} - -impl std::fmt::Display for HmrTarget { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - HmrTarget::Client => write!(f, "client"), - HmrTarget::Server => write!(f, "server"), - } - } -} - -impl std::str::FromStr for HmrTarget { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "client" => Ok(HmrTarget::Client), - "server" => Ok(HmrTarget::Server), - _ => Err(format!( - "Invalid HMR target: '{}'. Expected 'client' or 'server'", - s - )), - } - } -} - /// Pre-converted route keys from debug build paths for O(1) lookups. struct DebugBuildPathsRouteKeys { app: FxHashSet, @@ -894,8 +861,8 @@ impl ProjectContainer { /// See [`Project::hmr_chunk_names`]. #[turbo_tasks::function] - pub fn hmr_chunk_names(self: Vc, target: HmrTarget) -> Vc> { - self.project().hmr_chunk_names(target) + pub fn hmr_chunk_names(self: Vc) -> Vc> { + self.project().hmr_chunk_names() } /// Gets a source map for a particular `file_path`. If `dev` mode is disabled, this will always @@ -2491,36 +2458,16 @@ impl Project { .await } - /// Returns the root path for HMR content based on the target. - /// Client uses client_relative_path, Server uses node_root. - #[turbo_tasks::function] - async fn hmr_root_path(self: Vc, target: HmrTarget) -> Result> { - Ok(match target { - HmrTarget::Client => self.client_relative_path(), - HmrTarget::Server => self.node_root(), - }) - } - #[turbo_tasks::function] - async fn aggregate_hmr_root_path( - self: Vc, - target: HmrTarget, - ) -> Result> { - match target { - HmrTarget::Client => bail!("aggregate HMR is not implemented for the client"), - HmrTarget::Server => Ok(self.node_root().await?.join("server/app")?.cell()), - } + async fn server_hmr_root_path(self: Vc) -> Result> { + Ok(self.node_root().await?.join("server/app")?.cell()) } - /// Get HMR content by chunk_name for the specified target. + /// Get client HMR content by chunk_name. #[turbo_tasks::function] - async fn hmr_content( - self: Vc, - chunk_name: RcStr, - target: HmrTarget, - ) -> Result> { + async fn hmr_content(self: Vc, chunk_name: RcStr) -> Result> { if let Some(map) = self.await?.versioned_content_map { - let content = map.get(self.hmr_root_path(target).await?.join(&chunk_name)?); + let content = map.get(self.client_relative_path().await?.join(&chunk_name)?); Ok(content) } else { bail!("must be in dev mode to hmr") @@ -2533,7 +2480,6 @@ impl Project { pub async fn hmr_version_state( self: ResolvedVc, chunk_name: RcStr, - target: HmrTarget, session: TransientInstance<()>, ) -> Result> { // The session argument is important to avoid caching this function between @@ -2544,23 +2490,22 @@ impl Project { level = "info", name = "get HMR version", skip_all, - fields(chunk_name = %chunk_name, target = %target), + fields(chunk_name = %chunk_name), )] #[turbo_tasks::function(operation, root)] async fn hmr_version_operation( this: ResolvedVc, chunk_name: RcStr, - target: HmrTarget, ) -> Result>> { - tracing::info!(chunk_name = %chunk_name, target = %target, "hmr subscription"); - let content = this.hmr_content(chunk_name, target).await?; + tracing::info!(chunk_name = %chunk_name, "hmr subscription"); + let content = this.hmr_content(chunk_name).await?; if let Some(content) = &*content { Ok(content.version()) } else { Ok(Vc::upcast(NotFoundVersion::new())) } } - let version_op = hmr_version_operation(self, chunk_name, target); + let version_op = hmr_version_operation(self, chunk_name); // INVALIDATION: This is intentionally untracked to avoid invalidating this // function completely. We want to initialize the VersionState with the @@ -2576,16 +2521,15 @@ impl Project { } /// Emits opaque HMR events whenever a change is detected in the chunk group - /// internally known as `chunk_name` for the specified target. + /// internally known as `chunk_name`. #[turbo_tasks::function] pub async fn hmr_update( self: Vc, chunk_name: RcStr, - target: HmrTarget, from: Vc, ) -> Result> { let from = from.get(); - let content = self.hmr_content(chunk_name, target).await?; + let content = self.hmr_content(chunk_name).await?; if let Some(content) = *content { Ok(content.update(from)) } else { @@ -2594,35 +2538,21 @@ impl Project { } /// Aggregate counterpart to [`Self::hmr_version_state`]: one [`VersionState`] - /// covering every HMR-eligible chunk under `target`'s root. See - /// [`Self::all_hmr_update`]. + /// covering every server HMR-eligible chunk. See [`Self::server_hmr_update`]. #[turbo_tasks::function(session_dependent)] - pub async fn all_hmr_version_state( - self: ResolvedVc, - target: HmrTarget, - ) -> Result> { - if target == HmrTarget::Client { - bail!("all_hmr_version_state is not yet implemented for the client target"); - } - - #[tracing::instrument( - level = "info", - name = "get aggregate HMR version", - skip_all, - fields(target = %target), - )] + pub async fn server_hmr_version_state(self: ResolvedVc) -> Result> { + #[tracing::instrument(level = "info", name = "get server HMR version", skip_all)] #[turbo_tasks::function(operation, root)] - async fn aggregate_hmr_version_operation( + async fn server_hmr_version_operation( this: ResolvedVc, - target: HmrTarget, ) -> Result>> { let Some(map) = this.await?.versioned_content_map else { bail!("must be in dev mode to hmr") }; - let root = this.aggregate_hmr_root_path(target).owned().await?; + let root = this.server_hmr_root_path().owned().await?; AggregateHmrVersion::from_map(*map, root).await } - let version_op = aggregate_hmr_version_operation(self, target); + let version_op = server_hmr_version_operation(self); // INVALIDATION: untracked initial read; the subscription drives invalidation. let state = VersionState::new( @@ -2636,8 +2566,7 @@ impl Project { } /// Aggregate counterpart to [`Self::hmr_update`]: a single `Update` whose - /// combined `ChunkListUpdate` is the union of the per-entry-chunk diffs - /// under `target`'s root. + /// combined `ChunkListUpdate` is the union of the server entry chunk diffs. /// /// Each tracked entry chunk's own update is a `ChunkListUpdate` (carrying /// the module deltas for its shared chunks via the merger) or a bare @@ -2649,19 +2578,11 @@ impl Project { /// chunks absent from `from` are skipped; the runtime require()s them on /// demand. #[turbo_tasks::function] - pub async fn all_hmr_update( - self: Vc, - target: HmrTarget, - from: Vc, - ) -> Result> { - if target == HmrTarget::Client { - bail!("all_hmr_update is not yet implemented for the client target"); - } - + pub async fn server_hmr_update(self: Vc, from: Vc) -> Result> { let Some(map) = self.await?.versioned_content_map else { bail!("must be in dev mode to hmr") }; - let root = self.aggregate_hmr_root_path(target).owned().await?; + let root = self.server_hmr_root_path().owned().await?; let chunks_versioned_content = map.hmr_chunks_in_path(root).await?; // No chunks to diff yet (e.g. before any endpoints have been written). @@ -2710,14 +2631,11 @@ impl Project { Ok(builder.build(to_ref).cell()) } - /// Gets a list of all HMR chunk names that can be subscribed to for the - /// specified target. Used by the dev server to set up server-side HMR - /// subscriptions for all Node.js App Router entries (pages and route - /// handlers). + /// Gets a list of all client HMR chunk names that can be subscribed to. #[turbo_tasks::function] - pub async fn hmr_chunk_names(self: Vc, target: HmrTarget) -> Result>> { + pub async fn hmr_chunk_names(self: Vc) -> Result>> { if let Some(map) = self.await?.versioned_content_map { - Ok(map.keys_in_path(self.hmr_root_path(target).owned().await?)) + Ok(map.keys_in_path(self.client_relative_path().owned().await?)) } else { bail!("must be in dev mode to hmr") } diff --git a/crates/next-build-test/src/lib.rs b/crates/next-build-test/src/lib.rs index f93be9adb732..e9ea3a4776cb 100644 --- a/crates/next-build-test/src/lib.rs +++ b/crates/next-build-test/src/lib.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, bail}; use futures_util::{StreamExt, TryStreamExt}; use next_api::{ entrypoints::Entrypoints, - project::{HmrTarget, ProjectContainer, ProjectOptions}, + project::{ProjectContainer, ProjectOptions}, route::{Endpoint, EndpointOutputPaths, Route, endpoint_write_to_disk}, }; use turbo_rcstr::{RcStr, rcstr}; @@ -292,7 +292,7 @@ async fn hmr( #[turbo_tasks::function(operation, root)] fn project_hmr_chunk_names_operation(project: ResolvedVc) -> Vc> { - project.hmr_chunk_names(HmrTarget::Client) + project.hmr_chunk_names() } let idents = tt @@ -316,10 +316,8 @@ async fn hmr( let ident = ident_for_task.clone(); async move { let project = project.project(); - let state = project.hmr_version_state(ident.clone(), HmrTarget::Client, session); - project - .hmr_update(ident.clone(), HmrTarget::Client, state) - .await?; + let state = project.hmr_version_state(ident.clone(), session); + project.hmr_update(ident.clone(), state).await?; Ok(Vc::<()>::cell(())) } }); diff --git a/crates/next-napi-bindings/src/next_api/project.rs b/crates/next-napi-bindings/src/next_api/project.rs index 053380dd0cc6..d23497f285a5 100644 --- a/crates/next-napi-bindings/src/next_api/project.rs +++ b/crates/next-napi-bindings/src/next_api/project.rs @@ -26,7 +26,7 @@ use next_api::{ RouteOperation, }, project::{ - DebugBuildPaths, DefineEnv, DraftModeOptions, HmrTarget, PartialProjectOptions, Project, + DebugBuildPaths, DefineEnv, DraftModeOptions, PartialProjectOptions, Project, ProjectContainer, ProjectOptions, WatchOptions, }, project_asset_hashes_manifest::immutable_hashes_manifest_asset_if_enabled, @@ -1814,27 +1814,25 @@ struct HmrUpdateWithIssues { fn project_hmr_update_operation( project: ResolvedVc, chunk_name: RcStr, - target: HmrTarget, state: ResolvedVc, ) -> Vc { - project.hmr_update(chunk_name, target, *state) + project.hmr_update(chunk_name, *state) } #[tracing::instrument( level = "info", name = "hmr subscription", skip_all, - fields(chunk_name = %chunk_name, target = %target), + fields(chunk_name = %chunk_name), )] #[turbo_tasks::function(operation, root)] async fn hmr_update_with_issues_operation( project: ResolvedVc, chunk_name: RcStr, state: ResolvedVc, - target: HmrTarget, ) -> Result> { - tracing::info!(chunk_name = %chunk_name, target = %target, "hmr subscription"); - let update_op = project_hmr_update_operation(project, chunk_name, target, state); + tracing::info!(chunk_name = %chunk_name, "hmr subscription"); + let update_op = project_hmr_update_operation(project, chunk_name, state); // NOTE: we do not use `strongly_consistent_catch_collectables` here. The JS HMR // consumers in `hot-reloader-turbopack.ts` (`subscribeToServerHmr` and // `subscribeToClientHmrEvents`) rely on this read *throwing* on build-graph @@ -1853,29 +1851,22 @@ async fn hmr_update_with_issues_operation( /// Aggregate counterpart to [`project_hmr_update_operation`]. #[turbo_tasks::function(operation, root)] -fn project_all_hmr_update_operation( +fn project_server_hmr_update_operation( project: ResolvedVc, - target: HmrTarget, state: ResolvedVc, ) -> Vc { - project.all_hmr_update(target, *state) + project.server_hmr_update(*state) } /// Aggregate counterpart to [`hmr_update_with_issues_operation`]. -#[tracing::instrument( - level = "info", - name = "aggregate hmr subscription", - skip_all, - fields(target = %target), -)] +#[tracing::instrument(level = "info", name = "server hmr subscription", skip_all)] #[turbo_tasks::function(operation, root)] -async fn all_hmr_update_with_issues_operation( +async fn server_hmr_update_with_issues_operation( project: ResolvedVc, state: ResolvedVc, - target: HmrTarget, ) -> Result> { - tracing::info!(target = %target, "aggregate hmr subscription"); - let update_op = project_all_hmr_update_operation(project, target, state); + tracing::info!("server hmr subscription"); + let update_op = project_server_hmr_update_operation(project, state); // See `hmr_update_with_issues_operation`: the JS consumer relies on this // read *throwing* on build-graph failures; don't swallow errors. let update = update_op @@ -1893,19 +1884,18 @@ async fn all_hmr_update_with_issues_operation( .cell()) } -#[tracing::instrument(level = "info", name = "get all HMR events", skip(env, project, func), fields(target = %target))] +#[tracing::instrument( + level = "info", + name = "get server HMR events", + skip(env, project, func) +)] #[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_all_hmr_events( +pub fn project_server_hmr_events( env: Env, #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, - target: String, #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] func: FunctionRef>, ()>, ) -> napi::Result> { - let hmr_target = target - .parse::() - .map_err(napi::Error::from_reason)?; - let container = project.container; // Sentinel resource id for the aggregated stream (no real chunk path). let identifier_path: RcStr = rcstr!("__next_all_hmr__"); @@ -1918,12 +1908,9 @@ pub fn project_all_hmr_events( unmark_top_level_task_may_leak_eventually_consistent_state(); let project = container.project().to_resolved().await?; - let state = project - .all_hmr_version_state(hmr_target) - .to_resolved() - .await?; + let state = project.server_hmr_version_state().to_resolved().await?; - let update_op = all_hmr_update_with_issues_operation(project, state, hmr_target); + let update_op = server_hmr_update_with_issues_operation(project, state); // HACK(bgw): Remove this mark call mark_top_level_task(); @@ -1982,20 +1969,17 @@ pub fn project_all_hmr_events( ) } -#[tracing::instrument(level = "info", name = "get HMR events", skip(env, project, func), fields(target = %target, chunk_name = %chunk_name))] +#[tracing::instrument(level = "info", name = "get client HMR events", skip(env, project, func), fields(chunk_name = %chunk_name))] #[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_hmr_events( +pub fn project_client_hmr_events( env: Env, #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, chunk_name: RcStr, - target: String, - #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] - func: FunctionRef>, ()>, + #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] func: FunctionRef< + TurbopackResult>, + (), + >, ) -> napi::Result> { - let hmr_target = target - .parse::() - .map_err(napi::Error::from_reason)?; - let container = project.container; let session = TransientInstance::new(()); subscribe( @@ -2013,16 +1997,12 @@ pub fn project_hmr_events( unmark_top_level_task_may_leak_eventually_consistent_state(); let project = container.project().to_resolved().await?; let state = project - .hmr_version_state(chunk_name.clone(), hmr_target, session) + .hmr_version_state(chunk_name.clone(), session) .to_resolved() .await?; - let update_op = hmr_update_with_issues_operation( - project, - chunk_name.clone(), - state, - hmr_target, - ); + let update_op = + hmr_update_with_issues_operation(project, chunk_name.clone(), state); // HACK(bgw): Remove this mark call mark_top_level_task(); let read = @@ -2093,19 +2073,17 @@ struct HmrChunkNamesWithIssues { } #[turbo_tasks::function(operation, root)] -fn project_hmr_chunk_names_operation( +fn project_client_hmr_chunk_names_operation( container: ResolvedVc, - target: HmrTarget, ) -> Vc> { - container.hmr_chunk_names(target) + container.hmr_chunk_names() } #[turbo_tasks::function(operation, root)] -async fn get_hmr_chunk_names_with_issues_operation( +async fn get_client_hmr_chunk_names_with_issues_operation( container: ResolvedVc, - target: HmrTarget, ) -> Result> { - let hmr_chunk_names_op = project_hmr_chunk_names_operation(container, target); + let hmr_chunk_names_op = project_client_hmr_chunk_names_operation(container); // Do NOT switch this to `strongly_consistent_catch_collectables`. The JS HMR // chunk-names consumer in `hot-reloader-turbopack.ts` relies on this read // *throwing* on build-graph failures so its outer `try` block exits the @@ -2124,19 +2102,18 @@ async fn get_hmr_chunk_names_with_issues_operation( .cell()) } -#[tracing::instrument(level = "info", name = "get HMR chunk names", skip(env, project, func), fields(target = %target))] +#[tracing::instrument( + level = "info", + name = "get client HMR chunk names", + skip(env, project, func) +)] #[napi(ts_return_type = "{ __napiType: \"RootTask\" }")] -pub fn project_hmr_chunk_names_subscribe( +pub fn project_client_hmr_chunk_names_subscribe( env: Env, #[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: &External, - target: String, #[napi(ts_arg_type = "(err: Error, value: TurbopackResult) => void")] func: FunctionRef, ()>, ) -> napi::Result> { - let hmr_target = target - .parse::() - .map_err(napi::Error::from_reason)?; - let container = project.container; subscribe( project.turbopack_ctx.clone(), @@ -2144,7 +2121,7 @@ pub fn project_hmr_chunk_names_subscribe( &func, move || async move { let hmr_chunk_names_with_issues_op = - get_hmr_chunk_names_with_issues_operation(container, hmr_target); + get_client_hmr_chunk_names_with_issues_operation(container); let read = read_strongly_consistent_and_apply_effects(hmr_chunk_names_with_issues_op, |v| { &v.effects diff --git a/packages/next/src/build/swc/generated-native.d.ts b/packages/next/src/build/swc/generated-native.d.ts index 43e9044ae3ee..69ad05307c38 100644 --- a/packages/next/src/build/swc/generated-native.d.ts +++ b/packages/next/src/build/swc/generated-native.d.ts @@ -581,10 +581,15 @@ export declare function parse( signal?: AbortSignal | undefined | null ): Promise -export declare function projectAllHmrEvents( +export declare function projectClientHmrChunkNamesSubscribe( project: { __napiType: 'Project' }, - target: string, - func: (err: Error, value: TurbopackResult) => void + func: (err: Error, value: TurbopackResult) => void +): { __napiType: 'RootTask' } + +export declare function projectClientHmrEvents( + project: { __napiType: 'Project' }, + chunkName: RcStr, + func: (err: Error, value: TurbopackResult) => void ): { __napiType: 'RootTask' } /** Subscribes to all compilation events that are not cached like timing and progress information. */ @@ -634,19 +639,6 @@ export declare function projectGetSourceMapSync( sourceMapUrl: RcStr ): string | null -export declare function projectHmrChunkNamesSubscribe( - project: { __napiType: 'Project' }, - target: string, - func: (err: Error, value: TurbopackResult) => void -): { __napiType: 'RootTask' } - -export declare function projectHmrEvents( - project: { __napiType: 'Project' }, - chunkName: RcStr, - target: string, - func: (err: Error, value: TurbopackResult) => void -): { __napiType: 'RootTask' } - /** * Invalidates the filesystem cache so that it will be deleted next time that a turbopack project * is created with filesystem cache enabled. @@ -671,6 +663,11 @@ export declare function projectOnExit(project: { __napiType: 'Project' }): Promise +export declare function projectServerHmrEvents( + project: { __napiType: 'Project' }, + func: (err: Error, value: TurbopackResult) => void +): { __napiType: 'RootTask' } + /** * Runs `project_on_exit`, and then waits for turbo_tasks to gracefully shut down. * diff --git a/packages/next/src/build/swc/index.ts b/packages/next/src/build/swc/index.ts index 25fc665b22d0..8484b3fd0d0d 100644 --- a/packages/next/src/build/swc/index.ts +++ b/packages/next/src/build/swc/index.ts @@ -43,11 +43,6 @@ import type { } from './types' import { runLoaderWorkerPool } from './loaderWorkerPool' -export enum HmrTarget { - Client = 'client', - Server = 'server', -} - type RawBindings = typeof import('./generated-native') type RawWasmBindings = typeof import('./generated-wasm') & { default?(): Promise @@ -762,47 +757,27 @@ function bindingToApi( })() } - // Note: only the Server target is implemented in the native binding; - // add a Client overload once `all_hmr_update` supports it. - allHmrEvents( - target: HmrTarget.Server - ): AsyncIterableIterator> { + serverHmrEvents(): AsyncIterableIterator> { return subscribe(true, async (callback) => - binding.projectAllHmrEvents(this._nativeProject, target, callback) + binding.projectServerHmrEvents(this._nativeProject, callback) ) } - hmrEvents( - chunkName: string, - target: HmrTarget.Client - ): AsyncIterableIterator> - hmrEvents( - chunkName: string, - target: HmrTarget.Server - ): AsyncIterableIterator> - hmrEvents(chunkName: string, target: HmrTarget.Client | HmrTarget.Server) { + clientHmrEvents( + chunkName: string + ): AsyncIterableIterator> { return subscribe(true, async (callback) => - binding.projectHmrEvents( - this._nativeProject, - chunkName, - target, - callback - ) + binding.projectClientHmrEvents(this._nativeProject, chunkName, callback) ) } - /** - * Subscribe to the list of output chunk paths that can receive HMR updates. - * Chunk paths are output file paths like "server/chunks/ssr/..._.js" for server - * or "_next/static/chunks/app/page.js" for client. - */ - hmrChunkNamesSubscribe(target: HmrTarget) { + /** Subscribe to client output chunk paths that can receive HMR updates. */ + clientHmrChunkNamesSubscribe() { return subscribe>( false, async (callback) => - binding.projectHmrChunkNamesSubscribe( + binding.projectClientHmrChunkNamesSubscribe( this._nativeProject, - target, callback ) ) diff --git a/packages/next/src/build/swc/types.ts b/packages/next/src/build/swc/types.ts index d06dcaf55019..6ffbaaeab4ab 100644 --- a/packages/next/src/build/swc/types.ts +++ b/packages/next/src/build/swc/types.ts @@ -334,24 +334,15 @@ export interface Project { TurbopackResult > - // Note: only the Server target is implemented in the native binding; - // add a Client overload once `all_hmr_update` supports it. - allHmrEvents( - target: import('./index').HmrTarget.Server - ): AsyncIterableIterator> - - hmrEvents( - identifier: string, - target: import('./index').HmrTarget.Client + serverHmrEvents(): AsyncIterableIterator> + + clientHmrEvents( + identifier: string ): AsyncIterableIterator> - hmrEvents( - identifier: string, - target: import('./index').HmrTarget.Server - ): AsyncIterableIterator> - - hmrChunkNamesSubscribe( - target: import('./index').HmrTarget - ): AsyncIterableIterator> + + clientHmrChunkNamesSubscribe(): AsyncIterableIterator< + TurbopackResult + > getSourceForAsset(filePath: string): Promise diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index d0fc70d1cc6a..62be66696bf7 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -28,7 +28,7 @@ import type { NodeJsHmrUpdate, NodeJsPartialHmrUpdate, } from '../../build/swc/types' -import { createDefineEnv, getBindingsSync, HmrTarget } from '../../build/swc' +import { createDefineEnv, getBindingsSync } from '../../build/swc' import * as Log from '../../build/output/log' import { BLOCKED_PAGES } from '../../shared/lib/constants' import { @@ -217,7 +217,7 @@ function setupServerHmr( } ) { async function runSubscription() { - const subscription = project.allHmrEvents(HmrTarget.Server) + const subscription = project.serverHmrEvents() // Subscribing immediately emits one event describing the current state. // There's no previous state to diff it against, so it never carries anything @@ -988,7 +988,7 @@ export async function createHotReloaderTurbopack( return } - const subscription = project!.hmrEvents(id, HmrTarget.Client) + const subscription = project!.clientHmrEvents(id) state.subscriptions.set(id, subscription) // The subscription will always emit once, which is the initial diff --git a/test/development/basic/next-rs-api.test.ts b/test/development/basic/next-rs-api.test.ts index da47698709e6..b07ff6226832 100644 --- a/test/development/basic/next-rs-api.test.ts +++ b/test/development/basic/next-rs-api.test.ts @@ -1,6 +1,6 @@ import { nextTestSetup } from 'e2e-utils' import { PHASE_DEVELOPMENT_SERVER } from 'next/constants' -import { createDefineEnv, loadBindings, HmrTarget } from 'next/dist/build/swc' +import { createDefineEnv, loadBindings } from 'next/dist/build/swc' import type { Issue, MemoryEvictionMode, @@ -618,15 +618,13 @@ describe('next.rs api', () => { } } - const result = await project - .hmrChunkNamesSubscribe(HmrTarget.Client) - .next() + const result = await project.clientHmrChunkNamesSubscribe().next() expect(result.done).toBe(false) const chunkNames = result.value.chunkNames expect(chunkNames).toHaveProperty('length', expect.toBePositive()) const subscriptions = chunkNames.map((chunkName) => - project.hmrEvents(chunkName, HmrTarget.Client) + project.clientHmrEvents(chunkName) ) await Promise.all( subscriptions.map(async (subscription) => { @@ -740,12 +738,12 @@ describe('next.rs api', () => { if (route.type !== 'page') throw new Error('unknown route type') await route.htmlEndpoint.writeToDisk() - const result = await project.hmrChunkNamesSubscribe(HmrTarget.Client).next() + const result = await project.clientHmrChunkNamesSubscribe().next() expect(result.done).toBe(false) const chunkNames = result.value.chunkNames const subscriptions = chunkNames.map((chunkName) => - project.hmrEvents(chunkName, HmrTarget.Client) + project.clientHmrEvents(chunkName) ) await Promise.all( subscriptions.map(async (subscription) => {