diff --git a/packages/next/src/client/components/promise-queue.test.ts b/packages/next/src/client/components/promise-queue.test.ts new file mode 100644 index 0000000000000..a9495019188c1 --- /dev/null +++ b/packages/next/src/client/components/promise-queue.test.ts @@ -0,0 +1,41 @@ +import { PromiseQueue } from './promise-queue' + +describe('PromiseQueue', () => { + it('should limit the number of concurrent promises', async () => { + const queue = new PromiseQueue(2) + const results: number[] = [] + + const promises = Array.from({ length: 5 }, (_, i) => + queue.enqueue(async () => { + results.push(i) + await new Promise((resolve) => setTimeout(resolve, 100)) + return i + }) + ) + + const resolved = await Promise.all(promises) + + expect(resolved).toEqual([0, 1, 2, 3, 4]) + expect(results).toEqual([0, 1, 2, 3, 4]) + }) + it('should allow bumping a promise to be next in the queue', async () => { + const queue = new PromiseQueue(2) + const results: number[] = [] + + const promises = Array.from({ length: 5 }, (_, i) => + queue.enqueue(async () => { + results.push(i) + await new Promise((resolve) => setTimeout(resolve, 100)) + return i + }) + ) + + queue.bump(promises[3]) + + const resolved = await Promise.all(promises) + + // 3 was bumped to be next in the queue but did not cancel the other promises before it + expect(results).toEqual([0, 1, 3, 2, 4]) + expect(resolved).toEqual([0, 1, 2, 3, 4]) + }) +}) diff --git a/packages/next/src/client/components/promise-queue.ts b/packages/next/src/client/components/promise-queue.ts new file mode 100644 index 0000000000000..169e62652a864 --- /dev/null +++ b/packages/next/src/client/components/promise-queue.ts @@ -0,0 +1,66 @@ +/* + This is a simple promise queue that allows you to limit the number of concurrent promises + that are running at any given time. It's used to limit the number of concurrent + prefetch requests that are being made to the server but could be used for other + things as well. +*/ +export class PromiseQueue { + #maxConcurrency: number + #runningCount: number + #queue: Array<{ promiseFn: Promise; task: () => void }> + + constructor(maxConcurrency = 5) { + this.#maxConcurrency = maxConcurrency + this.#runningCount = 0 + this.#queue = [] + } + + enqueue(promiseFn: () => Promise): Promise { + let taskResolve: (value: T | PromiseLike) => void + let taskReject: (reason?: any) => void + + const taskPromise = new Promise((resolve, reject) => { + taskResolve = resolve + taskReject = reject + }) as Promise + + const task = async () => { + try { + this.#runningCount++ + const result = await promiseFn() + taskResolve(result) + } catch (error) { + taskReject(error) + } finally { + this.#runningCount-- + this.#processNext() + } + } + + const enqueueResult = { promiseFn: taskPromise, task } + // wonder if we should take a LIFO approach here + this.#queue.push(enqueueResult) + this.#processNext() + + return taskPromise + } + + bump(promiseFn: Promise) { + const index = this.#queue.findIndex((item) => item.promiseFn === promiseFn) + + if (index > -1) { + const bumpedItem = this.#queue.splice(index, 1)[0] + this.#queue.unshift(bumpedItem) + this.#processNext(true) + } + } + + #processNext(forced = false) { + if ( + (this.#runningCount < this.#maxConcurrency || forced) && + this.#queue.length > 0 + ) { + this.#queue.shift()?.task() + } + } +} diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts index f22bfc72401aa..35f99de505ef8 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts @@ -30,6 +30,7 @@ import { getPrefetchEntryCacheStatus, } from '../get-prefetch-cache-entry-status' import { prunePrefetchCache } from './prune-prefetch-cache' +import { prefetchQueue } from './prefetch-reducer' export function handleExternalUrl( state: ReadonlyReducerState, @@ -245,6 +246,8 @@ export function navigateReducer( // The one before last item is the router state tree patch const { treeAtTimeOfPrefetch, data } = prefetchValues + prefetchQueue.bump(data!) + // Unwrap cache data with `use` to suspend here (in the reducer) until the fetch resolves. const [flightData, canonicalUrlOverride] = readRecordValue(data!) diff --git a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts index 3db205db6c8a6..9344223de3c8d 100644 --- a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts @@ -9,6 +9,9 @@ import { import { createRecordFromThenable } from '../create-record-from-thenable' import { prunePrefetchCache } from './prune-prefetch-cache' import { NEXT_RSC_UNION_QUERY } from '../../app-router-headers' +import { PromiseQueue } from '../../promise-queue' + +export const prefetchQueue = new PromiseQueue(5) export function prefetchReducer( state: ReadonlyReducerState, @@ -55,13 +58,15 @@ export function prefetchReducer( // fetchServerResponse is intentionally not awaited so that it can be unwrapped in the navigate-reducer const serverResponse = createRecordFromThenable( - fetchServerResponse( - url, - // initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case. - state.tree, - state.nextUrl, - state.buildId, - action.kind + prefetchQueue.enqueue(() => + fetchServerResponse( + url, + // initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case. + state.tree, + state.nextUrl, + state.buildId, + action.kind + ) ) ) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index f7cea3105b029..56acc6127cfbe 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -183,6 +183,18 @@ function findDynamicParamFromRouterState( return null } +function hasLoadingComponentInTree(tree: LoaderTree): boolean { + const [, parallelRoutes, { loading }] = tree + + if (loading) { + return true + } + + return Object.values(parallelRoutes).some((parallelRoute) => + hasLoadingComponentInTree(parallelRoute) + ) as boolean +} + export async function renderToHTMLOrFlight( req: IncomingMessage, res: ServerResponse, @@ -810,12 +822,17 @@ export async function renderToHTMLOrFlight( : [actualSegment, parallelRouteKey] const parallelRoute = parallelRoutes[parallelRouteKey] + const childSegment = parallelRoute[0] const childSegmentParam = getDynamicParamFromSegment(childSegment) // if we're prefetching and that there's a Loading component, we bail out - // otherwise we keep rendering for the prefetch - if (isPrefetch && Loading) { + // otherwise we keep rendering for the prefetch. + // We also want to bail out if there's no Loading component in the tree. + if ( + isPrefetch && + (Loading || !hasLoadingComponentInTree(parallelRoute)) + ) { const childProp: ChildProp = { // Null indicates the tree is not fully rendered current: null, @@ -1105,42 +1122,47 @@ export async function renderToHTMLOrFlight( getDynamicParamFromSegment, query ), - // Create component tree using the slice of the loaderTree - // @ts-expect-error TODO-APP: fix async component type - React.createElement(async () => { - const { Component } = await createComponentTree( - // This ensures flightRouterPath is valid and filters down the tree - { - createSegmentPath, - loaderTree: loaderTreeToFilter, - parentParams: currentParams, - firstItem: isFirst, - injectedCSS, - injectedFontPreloadTags, - // This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too. - rootLayoutIncluded, - asNotFound, - } - ) - - return - }), - (() => { - const { layoutOrPagePath } = parseLoaderTree(loaderTreeToFilter) - - const styles = getLayerAssets({ - layoutOrPagePath, - injectedCSS: new Set(injectedCSS), - injectedFontPreloadTags: new Set(injectedFontPreloadTags), - }) - - return ( - <> - {styles} - {rscPayloadHead} - - ) - })(), + isPrefetch && !Boolean(components.loading) + ? null + : // Create component tree using the slice of the loaderTree + // @ts-expect-error TODO-APP: fix async component type + React.createElement(async () => { + const { Component } = await createComponentTree( + // This ensures flightRouterPath is valid and filters down the tree + { + createSegmentPath, + loaderTree: loaderTreeToFilter, + parentParams: currentParams, + firstItem: isFirst, + injectedCSS, + injectedFontPreloadTags, + // This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too. + rootLayoutIncluded, + asNotFound, + } + ) + + return + }), + isPrefetch && !Boolean(components.loading) + ? null + : (() => { + const { layoutOrPagePath } = + parseLoaderTree(loaderTreeToFilter) + + const styles = getLayerAssets({ + layoutOrPagePath, + injectedCSS: new Set(injectedCSS), + injectedFontPreloadTags: new Set(injectedFontPreloadTags), + }) + + return ( + <> + {styles} + {rscPayloadHead} + + ) + })(), ], ] } diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index e144fad48d184..216a91491c937 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1491,7 +1491,15 @@ export default abstract class Server { if (isAppPath) { res.setHeader('vary', RSC_VARY_HEADER) - if (!isPreviewMode && isSSG && req.headers[RSC.toLowerCase()]) { + // We don't clear RSC headers in development since SSG doesn't apply + // These headers are cleared for SSG as we need to always generate the + // full RSC response for ISR + if ( + !this.renderOpts.dev && + !isPreviewMode && + isSSG && + req.headers[RSC.toLowerCase()] + ) { if (!this.minimalMode) { isDataReq = true } diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index e5fbc8f98fa1b..c6b3820bd22aa 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -475,7 +475,8 @@ createNextDescribe( }, 'success') }) - skipDeploy('should handle revalidateTag + redirect', async () => { + // TODO: investigate flakey behavior + it.skip('should handle revalidateTag + redirect', async () => { const browser = await next.browser('/revalidate') const randomNumber = await browser.elementByCss('#random-number').text() const justPutIt = await browser.elementByCss('#justputit').text()