diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index 5fce27519569c..0562510e920c7 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -116,7 +116,7 @@ export function getFirstDynamicReason( */ export function markCurrentScopeAsDynamic( store: WorkStore, - workUnitStore: undefined | WorkUnitStore, + workUnitStore: undefined | Exclude, expression: string ): void { if (workUnitStore) { @@ -143,17 +143,7 @@ export function markCurrentScopeAsDynamic( } if (workUnitStore) { - if (workUnitStore.type === 'prerender') { - // We're prerendering the RSC stream with dynamicIO enabled and we need to abort the - // current render because something dynamic is being used. - // This won't throw so we still need to fall through to determine if/how we handle - // this specific dynamic request. - abortAndThrowOnSynchronousDynamicDataAccess( - store.route, - expression, - workUnitStore - ) - } else if (workUnitStore.type === 'prerender-ppr') { + if (workUnitStore.type === 'prerender-ppr') { postponeWithTracking( store.route, expression, diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index ac6d2a55adc8a..8e7db1e569e41 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -12,6 +12,7 @@ import { NEXT_CACHE_TAG_MAX_LENGTH, } from '../../lib/constants' import { markCurrentScopeAsDynamic } from '../app-render/dynamic-rendering' +import { makeHangingPromise } from '../dynamic-rendering-utils' import type { FetchMetric } from '../base-http' import { createDedupeFetch } from './dedupe-fetch' import type { @@ -185,6 +186,15 @@ export function createPatchedFetcher( const workStore = workAsyncStorage.getStore() const workUnitStore = workUnitAsyncStorage.getStore() + // During static generation we track cache reads so we can reason about when they fill + let cacheSignal = + workUnitStore && workUnitStore.type === 'prerender' + ? workUnitStore.cacheSignal + : null + if (cacheSignal) { + cacheSignal.beginRead() + } + const result = getTracer().trace( isInternal ? NextNodeServerSpan.internalFetch : AppRenderSpan.fetch, { @@ -373,6 +383,23 @@ export function createPatchedFetcher( revalidateStore && revalidateStore.revalidate === 0) + if ( + hasNoExplicitCacheConfig && + workUnitStore !== undefined && + workUnitStore.type === 'prerender' + ) { + // If we have no cache config, and we're in Dynamic I/O prerendering, it'll be a dynamic call. + // We don't have to issue that dynamic call. + if (cacheSignal) { + cacheSignal.endRead() + cacheSignal = null + } + return makeHangingPromise( + workUnitStore.renderSignal, + 'fetch()' + ) + } + switch (pageFetchCacheMode) { case 'force-no-store': { cacheReason = 'fetchCache = force-no-store' @@ -454,11 +481,22 @@ export function createPatchedFetcher( // If we were setting the revalidate value to 0, we should try to // postpone instead first. if (finalRevalidate === 0) { - markCurrentScopeAsDynamic( - workStore, - workUnitStore, - `revalidate: 0 fetch ${input} ${workStore.route}` - ) + if (workUnitStore && workUnitStore.type === 'prerender') { + if (cacheSignal) { + cacheSignal.endRead() + cacheSignal = null + } + return makeHangingPromise( + workUnitStore.renderSignal, + 'fetch()' + ) + } else { + markCurrentScopeAsDynamic( + workStore, + workUnitStore, + `revalidate: 0 fetch ${input} ${workStore.route}` + ) + } } if (revalidateStore) { @@ -576,7 +614,6 @@ export function createPatchedFetcher( if (workUnitStore && workUnitStore.type === 'prerender') { // We are prerendering at build time or revalidate time with dynamicIO so we need to // buffer the response so we can guarantee it can be read in a microtask - const bodyBuffer = await res.arrayBuffer() const fetchedData = { @@ -778,11 +815,22 @@ export function createPatchedFetcher( if (cache === 'no-store') { // If enabled, we should bail out of static generation. - markCurrentScopeAsDynamic( - workStore, - workUnitStore, - `no-store fetch ${input} ${workStore.route}` - ) + if (workUnitStore && workUnitStore.type === 'prerender') { + if (cacheSignal) { + cacheSignal.endRead() + cacheSignal = null + } + return makeHangingPromise( + workUnitStore.renderSignal, + 'fetch()' + ) + } else { + markCurrentScopeAsDynamic( + workStore, + workUnitStore, + `no-store fetch ${input} ${workStore.route}` + ) + } } const hasNextConfig = 'next' in init @@ -794,11 +842,18 @@ export function createPatchedFetcher( ) { if (next.revalidate === 0) { // If enabled, we should bail out of static generation. - markCurrentScopeAsDynamic( - workStore, - workUnitStore, - `revalidate: 0 fetch ${input} ${workStore.route}` - ) + if (workUnitStore && workUnitStore.type === 'prerender') { + return makeHangingPromise( + workUnitStore.renderSignal, + 'fetch()' + ) + } else { + markCurrentScopeAsDynamic( + workStore, + workUnitStore, + `revalidate: 0 fetch ${input} ${workStore.route}` + ) + } } if (!workStore.forceStatic || next.revalidate !== 0) { @@ -879,22 +934,16 @@ export function createPatchedFetcher( } ) - if ( - workUnitStore && - workUnitStore.type === 'prerender' && - workUnitStore.cacheSignal - ) { - // During static generation we track cache reads so we can reason about when they fill - const cacheSignal = workUnitStore.cacheSignal - cacheSignal.beginRead() + if (cacheSignal) { try { return await result } finally { - cacheSignal.endRead() + if (cacheSignal) { + cacheSignal.endRead() + } } - } else { - return result } + return result } // Attach the necessary properties to the patched fetch function. diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 22249d32c8b85..a8fb87222f959 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -21,6 +21,8 @@ import type { import { workUnitAsyncStorage } from '../app-render/work-unit-async-storage.external' import { runInCleanSnapshot } from '../app-render/clean-async-snapshot.external' +import { makeHangingPromise } from '../dynamic-rendering-utils' + import { cacheScopeAsyncLocalStorage } from '../async-storage/cache-scope.external' import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' @@ -33,6 +35,9 @@ import type { CacheScopeStore } from '../async-storage/cache-scope.external' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' +// If the expire time is less than . +const DYNAMIC_EXPIRE = 300 + type CacheEntry = { value: ReadableStream timestamp: number @@ -427,6 +432,20 @@ async function loadCacheEntry( const currentTime = performance.timeOrigin + performance.now() if ( + workUnitStore !== undefined && + workUnitStore.type === 'prerender' && + entry !== undefined && + (entry.revalidate === 0 || entry.expire < DYNAMIC_EXPIRE) + ) { + // In a Dynamic I/O prerender, if the cache entry has revalidate: 0 or if the + // expire time is under 5 minutes, then we consider this cache entry dynamic + // as it's not worth generating static pages for such data. It's better to leave + // a PPR hole that can be filled in dynamically with a potentially cached entry. + if (cacheSignal) { + cacheSignal.endRead() + } + return makeHangingPromise(workUnitStore.renderSignal, 'dynamic "use cache"') + } else if ( entry === undefined || currentTime > entry.timestamp + entry.expire * 1000 || (workStore.isStaticGeneration && diff --git a/packages/next/src/server/web/spec-extension/unstable-no-store.ts b/packages/next/src/server/web/spec-extension/unstable-no-store.ts index 3e93033e4e63d..d49c30c1e02a8 100644 --- a/packages/next/src/server/web/spec-extension/unstable-no-store.ts +++ b/packages/next/src/server/web/spec-extension/unstable-no-store.ts @@ -30,6 +30,10 @@ export function unstable_noStore() { return } else { store.isUnstableNoStore = true - markCurrentScopeAsDynamic(store, workUnitStore, callingExpression) + if (workUnitStore && workUnitStore.type === 'prerender') { + // unstable_noStore() is a noop in Dynamic I/O. + } else { + markCurrentScopeAsDynamic(store, workUnitStore, callingExpression) + } } } diff --git a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts index 0dbba2f79c516..e79559339ac3b 100644 --- a/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts +++ b/test/e2e/app-dir/dynamic-io/dynamic-io.test.ts @@ -288,33 +288,18 @@ describe('dynamic-io', () => { }) } - if (WITH_PPR) { - it('should partially prerender pages that use `unstable_noStore()`', async () => { - let $ = await next.render$('/cases/dynamic_api_no_store', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#inner').text()).toBe('at runtime') - } else { - expect($('#layout').text()).toBe('at buildtime') - expect($('#page').text()).toBe('at buildtime') - expect($('#inner').text()).toBe('at buildtime') - } - }) - } else { - it('should not prerender pages that use `unstable_noStore()`', async () => { - let $ = await next.render$('/cases/dynamic_api_no_store', {}) - if (isNextDev) { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#inner').text()).toBe('at runtime') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page').text()).toBe('at runtime') - expect($('#inner').text()).toBe('at runtime') - } - }) - } + it('should fully prerender pages that use `unstable_noStore()`', async () => { + let $ = await next.render$('/cases/dynamic_api_no_store', {}) + if (isNextDev) { + expect($('#layout').text()).toBe('at runtime') + expect($('#page').text()).toBe('at runtime') + expect($('#inner').text()).toBe('at runtime') + } else { + expect($('#layout').text()).toBe('at buildtime') + expect($('#page').text()).toBe('at buildtime') + expect($('#inner').text()).toBe('at buildtime') + } + }) if (WITH_PPR) { it('should partially prerender pages that use `searchParams` in Server Components', async () => { @@ -450,15 +435,9 @@ describe('dynamic-io', () => { expect($('#page-slot').text()).toBe('at runtime') expect($('#page-children').text()).toBe('at runtime') } else { - if (WITH_PPR) { - expect($('#layout').text()).toBe('at buildtime') - expect($('#page-slot').text()).toBe('at runtime') - expect($('#page-children').text()).toBe('at buildtime') - } else { - expect($('#layout').text()).toBe('at runtime') - expect($('#page-slot').text()).toBe('at runtime') - expect($('#page-children').text()).toBe('at runtime') - } + expect($('#layout').text()).toBe('at buildtime') + expect($('#page-slot').text()).toBe('at buildtime') + expect($('#page-children').text()).toBe('at buildtime') } $ = await next.render$('/cases/parallel/cookies', {})