From e970e050a96b8438931644a621157abd7fc70d64 Mon Sep 17 00:00:00 2001 From: Zack Tanner Date: Mon, 2 Oct 2023 10:12:55 -0700 Subject: [PATCH] Reland static prefetches & fix prefetch bailout behavior (#56228) Reland #54403 Also modifies the implementation of #55950 to not change the prefetch behavior when there is flight router state - we should only check the entire loader tree in the static prefetch case, otherwise we're inadvertently rendering the component tree for prefetches that match the current flight router state segment. ([slack x-ref](https://vercel.slack.com/archives/C03S8ED1DKM/p1695862974745849)) This includes a few other misc fixes for static prefetch generation: - `next start` was not serving them (which also meant tests weren't catching a few small bugs) - the router cache key casing can differ between build and runtime, resulting in a bad cache lookup which results suspending indefinitely during navigation - We cannot generate static prefetches for pages that opt into `searchParams`, as the prefetch response won't have the right cache key in the RSC payload - Layouts that use headers/cookies shouldn't use a static prefetch because it can result in unexpected behavior (ie, being redirected to a login page, if the prefetch contains redirect logic for unauthed users) Closes NEXT-1665 Closes NEXT-1643 --- packages/next/src/build/index.ts | 21 +++++ .../router-reducer/create-router-cache-key.ts | 2 +- ...tatic-generation-async-storage.external.ts | 1 + .../components/static-generation-bailout.ts | 6 ++ packages/next/src/export/routes/app-page.ts | 63 ++++++++++++- packages/next/src/export/worker.ts | 6 +- .../next/src/server/app-render/app-render.tsx | 15 ++-- packages/next/src/server/app-render/types.ts | 1 + ...static-generation-async-storage-wrapper.ts | 1 + packages/next/src/server/base-server.ts | 23 +++++ packages/next/src/server/render.tsx | 1 + .../app-prefetch-static.test.ts | 39 ++++++++ .../(default)/dynamic-area/[slug]/page.js | 3 + .../app/[region]/(default)/layout.js | 13 +++ .../(default)/static-prefetch/page.js | 9 ++ .../app-dir/app-prefetch-static/app/layout.js | 17 ++++ .../app-dir/app-prefetch-static/app/page.js | 3 + .../app/prefetch-auto/[slug]/layout.js | 15 ++++ .../app/prefetch-auto/[slug]/loading.js | 3 + .../app/prefetch-auto/[slug]/page.js | 21 +++++ .../(protected)/bar/page.js | 9 ++ .../(protected)/foo/page.js | 3 + .../(protected)/layout.js | 21 +++++ .../(protected)/loading.js | 3 + .../(protected)/page.js | 3 + .../(protected)/redirect.js | 18 ++++ .../app-dir/app-prefetch/prefetching.test.ts | 88 +++++++++++++++++++ .../e2e/app-dir/app-static/app-static.test.ts | 17 ++++ test/e2e/app-dir/app/index.test.ts | 57 +++++++++++- .../custom-routes/test/index.test.js | 1 + .../dynamic-routing/test/index.test.js | 1 + 31 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts create mode 100644 test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/dynamic-area/[slug]/page.js create mode 100644 test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/layout.js create mode 100644 test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/static-prefetch/page.js create mode 100644 test/e2e/app-dir/app-prefetch-static/app/layout.js create mode 100644 test/e2e/app-dir/app-prefetch-static/app/page.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/layout.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/loading.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/page.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/bar/page.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/foo/page.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/layout.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/loading.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/page.js create mode 100644 test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/redirect.js diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 3a25959f00a25..72f9c7f5ae160 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -129,6 +129,7 @@ import { eventSwcPlugins } from '../telemetry/events/swc-plugins' import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' import { ACTION, + NEXT_ROUTER_PREFETCH, RSC, RSC_CONTENT_TYPE_HEADER, RSC_VARY_HEADER, @@ -227,6 +228,7 @@ export type RoutesManifest = { rsc: { header: typeof RSC varyHeader: typeof RSC_VARY_HEADER + prefetchHeader: typeof NEXT_ROUTER_PREFETCH } skipMiddlewareUrlNormalize?: boolean caseSensitive?: boolean @@ -795,6 +797,7 @@ export default async function build( rsc: { header: RSC, varyHeader: RSC_VARY_HEADER, + prefetchHeader: NEXT_ROUTER_PREFETCH, contentTypeHeader: RSC_CONTENT_TYPE_HEADER, }, skipMiddlewareUrlNormalize: config.skipMiddlewareUrlNormalize, @@ -1055,6 +1058,7 @@ export default async function build( const additionalSsgPaths = new Map>() const additionalSsgPathsEncoded = new Map>() const appStaticPaths = new Map>() + const appPrefetchPaths = new Map() const appStaticPathsEncoded = new Map>() const appNormalizedPaths = new Map() const appDynamicParamPaths = new Set() @@ -1554,6 +1558,14 @@ export default async function build( appDynamicParamPaths.add(originalAppPath) } appDefaultConfigs.set(originalAppPath, appConfig) + + if ( + !isStatic && + !isAppRouteRoute(originalAppPath) && + !isDynamicRoute(originalAppPath) + ) { + appPrefetchPaths.set(originalAppPath, page) + } } } else { if (isEdgeRuntime(pageRuntime)) { @@ -2001,6 +2013,15 @@ export default async function build( }) }) + for (const [originalAppPath, page] of appPrefetchPaths) { + defaultMap[page] = { + page: originalAppPath, + query: {}, + _isAppDir: true, + _isAppPrefetch: true, + } + } + if (i18n) { for (const page of [ ...staticPages, diff --git a/packages/next/src/client/components/router-reducer/create-router-cache-key.ts b/packages/next/src/client/components/router-reducer/create-router-cache-key.ts index 8f272dc5abf24..3e9d739a717d7 100644 --- a/packages/next/src/client/components/router-reducer/create-router-cache-key.ts +++ b/packages/next/src/client/components/router-reducer/create-router-cache-key.ts @@ -5,7 +5,7 @@ export function createRouterCacheKey( withoutSearchParameters: boolean = false ) { return Array.isArray(segment) - ? `${segment[0]}|${segment[1]}|${segment[2]}` + ? `${segment[0]}|${segment[1]}|${segment[2]}`.toLowerCase() : withoutSearchParameters && segment.startsWith('__PAGE__') ? '__PAGE__' : segment diff --git a/packages/next/src/client/components/static-generation-async-storage.external.ts b/packages/next/src/client/components/static-generation-async-storage.external.ts index 95c1195bd0248..5caec74a52ff2 100644 --- a/packages/next/src/client/components/static-generation-async-storage.external.ts +++ b/packages/next/src/client/components/static-generation-async-storage.external.ts @@ -30,6 +30,7 @@ export interface StaticGenerationStore { dynamicUsageDescription?: string dynamicUsageStack?: string dynamicUsageErr?: DynamicServerError + staticPrefetchBailout?: boolean nextFetchId?: number pathWasRevalidated?: boolean diff --git a/packages/next/src/client/components/static-generation-bailout.ts b/packages/next/src/client/components/static-generation-bailout.ts index 4d35150664251..3363fd7b5d3a8 100644 --- a/packages/next/src/client/components/static-generation-bailout.ts +++ b/packages/next/src/client/components/static-generation-bailout.ts @@ -38,6 +38,12 @@ export const staticGenerationBailout: StaticGenerationBailout = ( if (staticGenerationStore) { staticGenerationStore.revalidate = 0 + + if (!opts?.dynamic) { + // we can statically prefetch pages that opt into dynamic, + // but not things like headers/cookies + staticGenerationStore.staticPrefetchBailout = true + } } if (staticGenerationStore?.isStaticGeneration) { diff --git a/packages/next/src/export/routes/app-page.ts b/packages/next/src/export/routes/app-page.ts index 4624f0bfecf61..30680e8ecd65e 100644 --- a/packages/next/src/export/routes/app-page.ts +++ b/packages/next/src/export/routes/app-page.ts @@ -6,6 +6,11 @@ import type { NextParsedUrlQuery } from '../../server/request-meta' import fs from 'fs/promises' import { MockedRequest, MockedResponse } from '../../server/lib/mock-request' +import { + RSC, + NEXT_URL, + NEXT_ROUTER_PREFETCH, +} from '../../client/components/app-router-headers' import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error' import { NEXT_CACHE_TAGS_HEADER } from '../../lib/constants' import { hasNextSupport } from '../../telemetry/ci-info' @@ -19,6 +24,37 @@ const render: AppPageRender = (...args) => { ) } +export async function generatePrefetchRsc( + req: MockedRequest, + path: string, + res: MockedResponse, + pathname: string, + htmlFilepath: string, + renderOpts: RenderOpts +) { + req.headers[RSC.toLowerCase()] = '1' + req.headers[NEXT_URL.toLowerCase()] = path + req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()] = '1' + + renderOpts.supportsDynamicHTML = true + renderOpts.isPrefetch = true + delete renderOpts.isRevalidate + + const prefetchRenderResult = await render(req, res, pathname, {}, renderOpts) + + prefetchRenderResult.pipe(res) + await res.hasStreamed + + const prefetchRscData = Buffer.concat(res.buffers) + + if ((renderOpts as any).store.staticPrefetchBailout) return + + await fs.writeFile( + htmlFilepath.replace(/\.html$/, '.prefetch.rsc'), + prefetchRscData + ) +} + export async function exportAppPage( req: MockedRequest, res: MockedResponse, @@ -29,7 +65,8 @@ export async function exportAppPage( renderOpts: RenderOpts, htmlFilepath: string, debugOutput: boolean, - isDynamicError: boolean + isDynamicError: boolean, + isAppPrefetch: boolean ): Promise { // If the page is `/_not-found`, then we should update the page to be `/404`. if (page === '/_not-found') { @@ -37,6 +74,19 @@ export async function exportAppPage( } try { + if (isAppPrefetch) { + await generatePrefetchRsc( + req, + path, + res, + pathname, + htmlFilepath, + renderOpts + ) + + return { fromBuildExportRevalidate: 0 } + } + const result = await render(req, res, pathname, query, renderOpts) const html = result.toUnchunkedString() const { metadata } = result @@ -50,6 +100,17 @@ export async function exportAppPage( ) } + if (!(renderOpts as any).store.staticPrefetchBailout) { + await generatePrefetchRsc( + req, + path, + res, + pathname, + htmlFilepath, + renderOpts + ) + } + const { staticBailoutInfo = {} } = metadata if (revalidate === 0 && debugOutput && staticBailoutInfo?.description) { diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 43f8cd604114b..e8d49e88d34a0 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -108,6 +108,9 @@ async function exportPageImpl(input: ExportPageInput) { // Check if this is an `app/` page. _isAppDir: isAppDir = false, + // Check if this is an `app/` prefix request. + _isAppPrefetch: isAppPrefetch = false, + // Check if this should error when dynamic usage is detected. _isDynamicError: isDynamicError = false, @@ -306,7 +309,8 @@ async function exportPageImpl(input: ExportPageInput) { renderOpts, htmlFilepath, debugOutput, - isDynamicError + isDynamicError, + isAppPrefetch ) } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 2553c2273087a..e846b283192d2 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1106,6 +1106,13 @@ export const renderToHTMLOrFlight: AppPageRender = ( // Explicit refresh flightRouterState[3] === 'refetch' + const shouldSkipComponentTree = + isPrefetch && + !Boolean(components.loading) && + (flightRouterState || + // If there is no flightRouterState, we need to check the entire loader tree, as otherwise we'll be only checking the root + !hasLoadingComponentInTree(loaderTree)) + if (!parentRendered && renderComponentsOnThisLevel) { const overriddenSegment = flightRouterState && @@ -1122,9 +1129,7 @@ export const renderToHTMLOrFlight: AppPageRender = ( getDynamicParamFromSegment, query ), - isPrefetch && - !Boolean(components.loading) && - !hasLoadingComponentInTree(loaderTree) + shouldSkipComponentTree ? null : // Create component tree using the slice of the loaderTree // @ts-expect-error TODO-APP: fix async component type @@ -1147,9 +1152,7 @@ export const renderToHTMLOrFlight: AppPageRender = ( return }), - isPrefetch && - !Boolean(components.loading) && - !hasLoadingComponentInTree(loaderTree) + shouldSkipComponentTree ? null : (() => { const { layoutOrPagePath } = diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 9345d114b620f..1d83b5f417a77 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -131,6 +131,7 @@ export type RenderOptsPartial = { ) => Promise serverActionsBodySizeLimit?: SizeLimit params?: ParsedUrlQuery + isPrefetch?: boolean } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial diff --git a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts index e590b6301bfbf..4da466887163a 100644 --- a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts +++ b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts @@ -12,6 +12,7 @@ export type StaticGenerationContext = { isRevalidate?: boolean isOnDemandRevalidate?: boolean isBot?: boolean + isPrefetch?: boolean nextExport?: boolean fetchCache?: StaticGenerationStore['fetchCache'] isDraftMode?: boolean diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 19ca288357196..810aa60137803 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -86,6 +86,8 @@ import { FLIGHT_PARAMETERS, NEXT_RSC_UNION_QUERY, ACTION, + NEXT_ROUTER_PREFETCH, + RSC_CONTENT_TYPE_HEADER, } from '../client/components/app-router-headers' import { MatchOptions, @@ -2124,6 +2126,27 @@ export default abstract class Server { } else if ( components.routeModule?.definition.kind === RouteKind.APP_PAGE ) { + const isAppPrefetch = req.headers[NEXT_ROUTER_PREFETCH.toLowerCase()] + + if (isAppPrefetch && process.env.NODE_ENV === 'production') { + try { + const prefetchRsc = await this.getPrefetchRsc(resolvedUrlPathname) + + if (prefetchRsc) { + res.setHeader( + 'cache-control', + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) + res.setHeader('content-type', RSC_CONTENT_TYPE_HEADER) + res.body(prefetchRsc).send() + return null + } + } catch (_) { + // we fallback to invoking the function if prefetch + // data is not available + } + } + const module = components.routeModule as AppPageRouteModule // Due to the way we pass data by mutating `renderOpts`, we can't extend the diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 1adfa8157fa04..f23df5d7cf84b 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -283,6 +283,7 @@ export type RenderOptsPartial = { deploymentId?: string isServerAction?: boolean isExperimentalCompile?: boolean + isPrefetch?: boolean } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial diff --git a/test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts b/test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts new file mode 100644 index 0000000000000..ed24ce2f3b1e0 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts @@ -0,0 +1,39 @@ +import { createNextDescribe } from 'e2e-utils' +import { waitFor } from 'next-test-utils' + +createNextDescribe( + 'app-prefetch-static', + { + files: __dirname, + }, + ({ next, isNextDev }) => { + if (isNextDev) { + it('should skip next dev', () => {}) + return + } + + it('should correctly navigate between static & dynamic pages', async () => { + const browser = await next.browser('/') + // Ensure the page is prefetched + await waitFor(1000) + + await browser.elementByCss('#static-prefetch').click() + + expect(await browser.elementByCss('#static-prefetch-page').text()).toBe( + 'Hello from Static Prefetch Page' + ) + + await browser.elementByCss('#dynamic-prefetch').click() + + expect(await browser.elementByCss('#dynamic-prefetch-page').text()).toBe( + 'Hello from Dynamic Prefetch Page' + ) + + await browser.elementByCss('#static-prefetch').click() + + expect(await browser.elementByCss('#static-prefetch-page').text()).toBe( + 'Hello from Static Prefetch Page' + ) + }) + } +) diff --git a/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/dynamic-area/[slug]/page.js b/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/dynamic-area/[slug]/page.js new file mode 100644 index 0000000000000..6040b75de167b --- /dev/null +++ b/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/dynamic-area/[slug]/page.js @@ -0,0 +1,3 @@ +export default async function DynamicPage({ params, searchParams }) { + return
Hello from Dynamic Prefetch Page
+} diff --git a/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/layout.js b/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/layout.js new file mode 100644 index 0000000000000..92c69396f61f7 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/layout.js @@ -0,0 +1,13 @@ +export const regions = ['SE', 'DE'] + +export default async function Layout({ children, params }) { + return children +} + +export function generateStaticParams() { + return regions.map((region) => ({ + region, + })) +} + +export const dynamicParams = false diff --git a/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/static-prefetch/page.js b/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/static-prefetch/page.js new file mode 100644 index 0000000000000..067d0371e6aef --- /dev/null +++ b/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/static-prefetch/page.js @@ -0,0 +1,9 @@ +export const dynamic = 'force-dynamic' + +export default async function StaticPrefetchPage({ params }) { + return ( +
+

Hello from Static Prefetch Page

+
+ ) +} diff --git a/test/e2e/app-dir/app-prefetch-static/app/layout.js b/test/e2e/app-dir/app-prefetch-static/app/layout.js new file mode 100644 index 0000000000000..d169da575e505 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch-static/app/layout.js @@ -0,0 +1,17 @@ +import Link from 'next/link' +export default function RootLayout({ children }) { + return ( + + + {children} + + + Static Prefetch + + + Dynamic Prefetch + + + + ) +} diff --git a/test/e2e/app-dir/app-prefetch-static/app/page.js b/test/e2e/app-dir/app-prefetch-static/app/page.js new file mode 100644 index 0000000000000..442d7ad2a38bd --- /dev/null +++ b/test/e2e/app-dir/app-prefetch-static/app/page.js @@ -0,0 +1,3 @@ +export default function Main() { + return
Main Page
+} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/layout.js b/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/layout.js new file mode 100644 index 0000000000000..a9123d41fc185 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/layout.js @@ -0,0 +1,15 @@ +import Link from 'next/link' + +export const dynamic = 'force-dynamic' + +export default async function Layout({ children }) { + return ( +
+

Layout

+ + Prefetch Link + + {children} +
+ ) +} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/loading.js b/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/loading.js new file mode 100644 index 0000000000000..5b91c2379fa9c --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/loading.js @@ -0,0 +1,3 @@ +export default function Loading() { + return

Loading Prefetch Auto

+} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/page.js b/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/page.js new file mode 100644 index 0000000000000..b6aa1a5033af3 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-auto/[slug]/page.js @@ -0,0 +1,21 @@ +export const dynamic = 'force-dynamic' + +function getData() { + const res = new Promise((resolve) => { + setTimeout(() => { + resolve({ message: 'Hello World!' }) + }, 2000) + }) + return res +} + +export default async function Page({ params }) { + const result = await getData() + + return ( + <> +

{JSON.stringify(params)}

+

{JSON.stringify(result)}

+ + ) +} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/bar/page.js b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/bar/page.js new file mode 100644 index 0000000000000..8fe9938081170 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/bar/page.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ To Foo +
+ ) +} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/foo/page.js b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/foo/page.js new file mode 100644 index 0000000000000..39e14f6d18636 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/foo/page.js @@ -0,0 +1,3 @@ +export default function FooPage() { + return

Foo page

+} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/layout.js b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/layout.js new file mode 100644 index 0000000000000..2f392fe39e896 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/layout.js @@ -0,0 +1,21 @@ +import { cookies } from 'next/headers' +import { Redirect } from './redirect' + +async function isLoggedIn() { + // sleep for 1s + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const cookieData = cookies() + const hasSession = !!cookieData.get('logged-in') + + return hasSession +} + +export default async function Layout({ children }) { + const loggedIn = await isLoggedIn() + console.log({ loggedIn }) + + if (!loggedIn) return + + return
Protected Layout: {children}
+} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/loading.js b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/loading.js new file mode 100644 index 0000000000000..b3e23741a9c3c --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/loading.js @@ -0,0 +1,3 @@ +export default function ProtectedLoading() { + return

Protected loading...

+} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/page.js b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/page.js new file mode 100644 index 0000000000000..eafdefefd4d2e --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/page.js @@ -0,0 +1,3 @@ +export default function ProtectedPage() { + return

Protected Pgae

+} diff --git a/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/redirect.js b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/redirect.js new file mode 100644 index 0000000000000..ab476f9e99d3b --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/prefetch-dynamic-usage/(protected)/redirect.js @@ -0,0 +1,18 @@ +'use client' + +import { usePathname, useRouter } from 'next/navigation' +import { useEffect } from 'react' + +export function Redirect() { + const path = usePathname() + const router = useRouter() + + useEffect(() => { + const nextUrl = encodeURIComponent( + path ? decodeURIComponent(path) : '/search' + ) + router.push(`/login?nextUrl=${nextUrl}`) + }, [path, router]) + + return null +} diff --git a/test/e2e/app-dir/app-prefetch/prefetching.test.ts b/test/e2e/app-dir/app-prefetch/prefetching.test.ts index d1d315c7437d7..2d9b476690893 100644 --- a/test/e2e/app-dir/app-prefetch/prefetching.test.ts +++ b/test/e2e/app-dir/app-prefetch/prefetching.test.ts @@ -232,5 +232,93 @@ createNextDescribe( await browser.elementByCss('#prefetch-false-page-result').text() ).toBe('Result page') }) + + it('should not need to prefetch the layout if the prefetch is initiated at the same segment', async () => { + const stateTree = encodeURIComponent( + JSON.stringify([ + '', + { + children: [ + 'prefetch-auto', + { + children: [ + ['slug', 'justputit', 'd'], + { children: ['__PAGE__', {}] }, + ], + }, + ], + }, + null, + null, + true, + ]) + ) + const response = await next.fetch(`/prefetch-auto/justputit?_rsc=dcqtr`, { + headers: { + RSC: '1', + 'Next-Router-Prefetch': '1', + 'Next-Router-State-Tree': stateTree, + 'Next-Url': '/prefetch-auto/justputit', + }, + }) + + const prefetchResponse = await response.text() + expect(prefetchResponse).not.toContain('Hello World') + expect(prefetchResponse).not.toContain('Loading Prefetch Auto') + }) + + it('should only prefetch the loading state and not the component tree when prefetching at the same segment', async () => { + const stateTree = encodeURIComponent( + JSON.stringify([ + '', + { + children: [ + 'prefetch-auto', + { + children: [ + ['slug', 'vercel', 'd'], + { children: ['__PAGE__', {}] }, + ], + }, + ], + }, + null, + null, + true, + ]) + ) + const response = await next.fetch(`/prefetch-auto/justputit?_rsc=dcqtr`, { + headers: { + RSC: '1', + 'Next-Router-Prefetch': '1', + 'Next-Router-State-Tree': stateTree, + 'Next-Url': '/prefetch-auto/vercel', + }, + }) + + const prefetchResponse = await response.text() + expect(prefetchResponse).not.toContain('Hello World') + expect(prefetchResponse).toContain('Loading Prefetch Auto') + }) + + it('should not generate static prefetches for layouts that opt into dynamic rendering', async () => { + await next.stop() + const rootLoading = await next.readFile('./app/loading.js') + await next.deleteFile('./app/loading.js') + await next.start() + expect( + await next + .readFile('.next/server/app/prefetch-dynamic-usage/foo.prefetch.rsc') + .catch(() => false) + ).toBeFalsy() + + expect( + await next + .readFile('.next/server/app/prefetch-dynamic-usage/foo.prefetch.rsc') + .catch(() => false) + ).toBeFalsy() + + await next.patchFile('./app/loading', rootLoading) + }) } ) diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 4d97bebc25c5b..ede749c02a3d9 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -505,9 +505,11 @@ createNextDescribe( 'stale-cache-serving-edge/app-page/page.js', 'stale-cache-serving-edge/app-page/page_client-reference-manifest.js', 'stale-cache-serving-edge/route-handler/route.js', + 'stale-cache-serving/app-page.prefetch.rsc', 'stale-cache-serving/app-page/page.js', 'stale-cache-serving/app-page/page_client-reference-manifest.js', 'stale-cache-serving/route-handler/route.js', + 'custom.prefetch.rsc', 'force-cache/page.js', 'ssg-draft-mode.html', '(new)/custom/page.js', @@ -523,6 +525,7 @@ createNextDescribe( 'force-static/first.html', 'force-static/second.rsc', 'ssg-draft-mode/test.rsc', + 'ssr-forced.prefetch.rsc', 'blog/seb/second-post.rsc', 'blog/tim/first-post.html', 'force-static/second.html', @@ -530,10 +533,14 @@ createNextDescribe( 'blog/seb/second-post.html', 'ssg-draft-mode/test-2.rsc', 'blog/styfle/first-post.rsc', + 'default-cache.prefetch.rsc', 'dynamic-error/[id]/page.js', + 'response-url.prefetch.rsc', 'ssg-draft-mode/test-2.html', 'blog/styfle/first-post.html', 'blog/styfle/second-post.rsc', + 'fetch-no-cache.prefetch.rsc', + 'force-no-store.prefetch.rsc', 'force-static/[slug]/page.js', 'hooks/use-pathname/slug.rsc', 'hooks/use-search-params.rsc', @@ -559,9 +566,11 @@ createNextDescribe( 'react-fetch-deduping-node/page.js', 'variable-revalidate/encoding.html', 'variable-revalidate/cookie/page.js', + 'gen-params-dynamic/one.prefetch.rsc', 'ssg-draft-mode/[[...route]]/page.js', 'variable-revalidate/post-method.rsc', 'dynamic-no-gen-params/[slug]/page.js', + 'ssr-auto/cache-no-store.prefetch.rsc', 'static-to-dynamic-error/[id]/page.js', 'variable-revalidate/encoding/page.js', 'variable-revalidate/no-store/page.js', @@ -575,6 +584,7 @@ createNextDescribe( 'variable-revalidate/revalidate-3.html', 'force-dynamic-prerender/[slug]/page.js', 'gen-params-dynamic-revalidate/one.html', + 'react-fetch-deduping-node.prefetch.rsc', 'ssr-auto/fetch-revalidate-zero/page.js', 'variable-revalidate/authorization.html', 'force-dynamic-no-prerender/[id]/page.js', @@ -585,6 +595,7 @@ createNextDescribe( 'partial-gen-params/[lang]/[slug]/page.js', 'variable-revalidate/headers-instance.rsc', 'variable-revalidate/revalidate-3/page.js', + 'force-dynamic-catch-all/slug.prefetch.rsc', 'hooks/use-search-params/force-static.html', 'hooks/use-search-params/with-suspense.rsc', 'route-handler/revalidate-360-isr/route.js', @@ -594,8 +605,10 @@ createNextDescribe( 'variable-revalidate/headers-instance.html', 'hooks/use-search-params/with-suspense.html', 'route-handler-edge/revalidate-360/route.js', + 'variable-revalidate/no-store.prefetch.rsc', 'variable-revalidate/revalidate-360-isr.rsc', 'variable-revalidate/revalidate-360/page.js', + 'ssr-auto/fetch-revalidate-zero.prefetch.rsc', 'static-to-dynamic-error-forced/[id]/page.js', 'variable-config-revalidate/revalidate-3.rsc', 'variable-revalidate/revalidate-360-isr.html', @@ -605,6 +618,7 @@ createNextDescribe( 'variable-config-revalidate/revalidate-3.html', 'variable-revalidate-edge/post-method/page.js', 'variable-revalidate/headers-instance/page.js', + 'variable-revalidate/status-code.prefetch.rsc', 'force-cache/page_client-reference-manifest.js', 'hooks/use-search-params/with-suspense/page.js', 'variable-revalidate-edge/revalidate-3/page.js', @@ -614,8 +628,10 @@ createNextDescribe( 'variable-revalidate/revalidate-360-isr/page.js', 'blog/[author]/page_client-reference-manifest.js', 'default-cache/page_client-reference-manifest.js', + 'force-dynamic-prerender/frameworks.prefetch.rsc', 'variable-config-revalidate/revalidate-3/page.js', 'variable-revalidate/post-method-request/page.js', + 'variable-revalidate/revalidate-360.prefetch.rsc', 'fetch-no-cache/page_client-reference-manifest.js', 'force-dynamic-catch-all/[slug]/[[...id]]/page.js', 'force-no-store/page_client-reference-manifest.js', @@ -644,6 +660,7 @@ createNextDescribe( 'partial-gen-params-no-additional-lang/fr/second.html', 'partial-gen-params-no-additional-slug/en/second.html', 'partial-gen-params-no-additional-slug/fr/second.html', + 'variable-revalidate/post-method-request.prefetch.rsc', 'variable-revalidate-edge/post-method-request/page.js', 'force-static/[slug]/page_client-reference-manifest.js', 'blog/[author]/[slug]/page_client-reference-manifest.js', diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index d13a74a6adfae..4d64176661134 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -2,6 +2,8 @@ import { createNextDescribe } from 'e2e-utils' import { check, getRedboxHeader, hasRedbox, waitFor } from 'next-test-utils' import cheerio from 'cheerio' import stripAnsi from 'strip-ansi' +import { BrowserInterface } from 'test/lib/browsers/base' +import { Request } from 'playwright-core' createNextDescribe( 'app dir', @@ -12,7 +14,60 @@ createNextDescribe( : undefined, }, ({ next, isNextDev: isDev, isNextStart, isNextDeploy }) => { - if (isNextStart) { + if (isNextStart && !process.env.NEXT_EXPERIMENTAL_COMPILE) { + it('should use RSC prefetch data from build', async () => { + expect( + await next.readFile('.next/server/app/linking.prefetch.rsc') + ).toBeTruthy() + expect( + await next.readFile('.next/server/app/linking/about.prefetch.rsc') + ).toContain('About loading...') + expect( + await next.readFile( + '.next/server/app/dashboard/deployments/breakdown.prefetch.rsc' + ) + ).toBeTruthy() + expect( + await next + .readFile( + '.next/server/app/dashboard/deployments/[id].prefetch.rsc' + ) + .catch(() => false) + ).toBeFalsy() + + const outputStart = next.cliOutput.length + const browser: BrowserInterface = await next.browser('/') + const rscReqs = [] + + browser.on('request', (req: Request) => { + if (req.headers()['rsc']) { + rscReqs.push(req.url()) + } + }) + + await browser.eval('window.location.href = "/linking"') + + await check(async () => { + return rscReqs.length > 3 ? 'success' : JSON.stringify(rscReqs) + }, 'success') + + const trimmedOutput = next.cliOutput.substring(outputStart) + + expect(trimmedOutput).not.toContain( + 'rendering dashboard/(custom)/deployments/breakdown' + ) + expect(trimmedOutput).not.toContain( + 'rendering /dashboard/deployments/[id]' + ) + expect(trimmedOutput).not.toContain('rendering linking about page') + + await browser.elementByCss('#breakdown').click() + await check( + () => next.cliOutput.substring(outputStart), + /rendering .*breakdown/ + ) + }) + if (!process.env.NEXT_EXPERIMENTAL_COMPILE) { it('should have correct size in build output', async () => { expect(next.cliOutput).toMatch( diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index 66332bfec6dae..0a77ca7db82f8 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -2660,6 +2660,7 @@ const runTests = (isDev = false, isTurbo = false) => { contentTypeHeader: 'text/x-component', varyHeader: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url', + prefetchHeader: 'Next-Router-Prefetch', }, }) }) diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index 5e39562e43b98..704a7e10dd890 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -1494,6 +1494,7 @@ function runTests({ dev }) { contentTypeHeader: 'text/x-component', varyHeader: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url', + prefetchHeader: 'Next-Router-Prefetch', }, }) })