diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index d715a6b5353f2..3fb3e580a2e4d 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -6,7 +6,7 @@ import type { ServerRuntime } from '../types' // @ts-ignore import React, { experimental_use as use } from 'react' -import { ParsedUrlQuery } from 'querystring' +import { ParsedUrlQuery, stringify as stringifyQuery } from 'querystring' import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack' import { NextParsedUrlQuery } from './request-meta' import RenderResult from './render-result' @@ -603,6 +603,56 @@ async function renderToString(element: React.ReactElement) { return streamToString(renderStream) } +function getRootLayoutPath( + [segment, parallelRoutes, { layout }]: LoaderTree, + rootLayoutPath = '' +): string | undefined { + rootLayoutPath += `${segment}/` + const isLayout = typeof layout !== 'undefined' + if (isLayout) return rootLayoutPath + // We can't assume it's `parallelRoutes.children` here in case the root layout is `app/@something/layout.js` + // But it's not possible to be more than one parallelRoutes before the root layout is found + const child = Object.values(parallelRoutes)[0] + if (!child) return + return getRootLayoutPath(child, rootLayoutPath) +} + +function findRootLayoutInFlightRouterState( + [segment, parallelRoutes]: FlightRouterState, + rootLayoutSegments: string, + segments = '' +): boolean { + segments += `${segment}/` + if (segments === rootLayoutSegments) { + return true + } else if (segments.length > rootLayoutSegments.length) { + return false + } + // We can't assume it's `parallelRoutes.children` here in case the root layout is `app/@something/layout.js` + // But it's not possible to be more than one parallelRoutes before the root layout is found + const child = Object.values(parallelRoutes)[0] + if (!child) return false + return findRootLayoutInFlightRouterState(child, rootLayoutSegments, segments) +} + +function isNavigatingToNewRootLayout( + loaderTree: LoaderTree, + flightRouterState: FlightRouterState +): boolean { + const newRootLayout = getRootLayoutPath(loaderTree) + // should always have a root layout + if (newRootLayout) { + const hasSameRootLayout = findRootLayoutInFlightRouterState( + flightRouterState, + newRootLayout + ) + + return !hasSameRootLayout + } + + return false +} + export async function renderToHTMLOrFlight( req: IncomingMessage, res: ServerResponse, @@ -676,6 +726,33 @@ export async function renderToHTMLOrFlight( : {} : undefined + /** + * The tree created in next-app-loader that holds component segments and modules + */ + const loaderTree: LoaderTree = ComponentMod.tree + + // If navigating to a new root layout we need to do a full page navigation. + if ( + isFlight && + Array.isArray(providedFlightRouterState) && + isNavigatingToNewRootLayout(loaderTree, providedFlightRouterState) + ) { + stripInternalQueries(query) + const search = stringifyQuery(query) + + // Empty so that the client-side router will do a full page navigation. + const flightData: FlightData = req.url! + (search ? `?${search}` : '') + return new FlightRenderResult( + ComponentMod.renderToReadableStream( + flightData, + serverComponentManifest, + { + onError: flightDataRendererErrorHandler, + } + ).pipeThrough(createBufferedTransformStream()) + ) + } + stripInternalQueries(query) const LayoutRouter = @@ -686,10 +763,6 @@ export async function renderToHTMLOrFlight( | typeof import('../client/components/hot-reloader.client').default | null - /** - * The tree created in next-app-loader that holds component segments and modules - */ - const loaderTree: LoaderTree = ComponentMod.tree /** * Server Context is specifically only available in Server Components. * It has to hold values that can't change while rendering from the common layout down. diff --git a/test/e2e/app-dir/mpa-navigation.test.ts b/test/e2e/app-dir/mpa-navigation.test.ts new file mode 100644 index 0000000000000..e2244da222e74 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation.test.ts @@ -0,0 +1,129 @@ +import path from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import webdriver from 'next-webdriver' + +describe('app-dir mpa navigation', () => { + if ((global as any).isNextDeploy) { + it('should skip next deploy for now', () => {}) + return + } + + if (process.env.NEXT_TEST_REACT_VERSION === '^17') { + it('should skip for react v17', () => {}) + return + } + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + app: new FileRef(path.join(__dirname, 'mpa-navigation/app')), + 'next.config.js': new FileRef( + path.join(__dirname, 'mpa-navigation/next.config.js') + ), + }, + dependencies: { + react: 'experimental', + 'react-dom': 'experimental', + }, + }) + }) + afterAll(() => next.destroy()) + + describe('Should do a mpa navigation when switching root layout', () => { + it('should work with basic routes', async () => { + const browser = await webdriver(next.url, '/basic-route') + + expect(await browser.elementById('basic-route').text()).toBe( + 'Basic route' + ) + await browser.eval('window.__TEST_NO_RELOAD = true') + + // Navigate to page with same root layout + await browser.elementByCss('a').click() + expect( + await browser.waitForElementByCss('#inner-basic-route').text() + ).toBe('Inner basic route') + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue() + + // Navigate to page with different root layout + await browser.elementByCss('a').click() + expect(await browser.waitForElementByCss('#route-group').text()).toBe( + 'Route group' + ) + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined() + }) + + it('should work with route groups', async () => { + const browser = await webdriver(next.url, '/route-group') + + expect(await browser.elementById('route-group').text()).toBe( + 'Route group' + ) + await browser.eval('window.__TEST_NO_RELOAD = true') + + // Navigate to page with same root layout + await browser.elementByCss('a').click() + expect( + await browser.waitForElementByCss('#nested-route-group').text() + ).toBe('Nested route group') + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue() + + // Navigate to page with different root layout + await browser.elementByCss('a').click() + expect(await browser.waitForElementByCss('#parallel-one').text()).toBe( + 'One' + ) + expect(await browser.waitForElementByCss('#parallel-two').text()).toBe( + 'Two' + ) + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined() + }) + + it('should work with parallel routes', async () => { + const browser = await webdriver(next.url, '/with-parallel-routes') + + expect(await browser.elementById('parallel-one').text()).toBe('One') + expect(await browser.elementById('parallel-two').text()).toBe('Two') + await browser.eval('window.__TEST_NO_RELOAD = true') + + // Navigate to page with same root layout + await browser.elementByCss('a').click() + expect( + await browser.waitForElementByCss('#parallel-one-inner').text() + ).toBe('One inner') + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue() + + // Navigate to page with different root layout + await browser.elementByCss('a').click() + expect(await browser.waitForElementByCss('#dynamic-hello').text()).toBe( + 'dynamic hello' + ) + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined() + }) + + it('should work with dynamic routes', async () => { + const browser = await webdriver(next.url, '/dynamic/first/route') + + expect(await browser.elementById('dynamic-route').text()).toBe( + 'dynamic route' + ) + await browser.eval('window.__TEST_NO_RELOAD = true') + + // Navigate to page with same root layout + await browser.elementByCss('a').click() + expect( + await browser.waitForElementByCss('#dynamic-second-hello').text() + ).toBe('dynamic hello') + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue() + + // Navigate to page with different root layout + await browser.elementByCss('a').click() + expect( + await browser.waitForElementByCss('#inner-basic-route').text() + ).toBe('Inner basic route') + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined() + }) + }) +}) diff --git a/test/e2e/app-dir/mpa-navigation/app/(route-group)/(nested-route-group)/nested-route-group/page.js b/test/e2e/app-dir/mpa-navigation/app/(route-group)/(nested-route-group)/nested-route-group/page.js new file mode 100644 index 0000000000000..5bfe9d5c43db8 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/(route-group)/(nested-route-group)/nested-route-group/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + To with-parallel-routes +

Nested route group

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/(route-group)/layout.js b/test/e2e/app-dir/mpa-navigation/app/(route-group)/layout.js new file mode 100644 index 0000000000000..05b841b280b3f --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/(route-group)/layout.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello + + {children} + + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/(route-group)/route-group/page.js b/test/e2e/app-dir/mpa-navigation/app/(route-group)/route-group/page.js new file mode 100644 index 0000000000000..e0456ea2ac168 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/(route-group)/route-group/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + To nested route group +

Route group

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/basic-route/inner/page.js b/test/e2e/app-dir/mpa-navigation/app/basic-route/inner/page.js new file mode 100644 index 0000000000000..5e23b1da9dcc6 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/basic-route/inner/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + To route group +

Inner basic route

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/basic-route/layout.js b/test/e2e/app-dir/mpa-navigation/app/basic-route/layout.js new file mode 100644 index 0000000000000..05b841b280b3f --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/basic-route/layout.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello + + {children} + + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/basic-route/page.js b/test/e2e/app-dir/mpa-navigation/app/basic-route/page.js new file mode 100644 index 0000000000000..24eab4ddf150a --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/basic-route/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + To inner basic route +

Basic route

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/dynamic/first/[param]/page.js b/test/e2e/app-dir/mpa-navigation/app/dynamic/first/[param]/page.js new file mode 100644 index 0000000000000..2f30013684886 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/dynamic/first/[param]/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page({ params }) { + return ( + <> + To second dynamic +

dynamic {params.param}

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/dynamic/layout.js b/test/e2e/app-dir/mpa-navigation/app/dynamic/layout.js new file mode 100644 index 0000000000000..05b841b280b3f --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/dynamic/layout.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello + + {children} + + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/dynamic/second/[param]/page.js b/test/e2e/app-dir/mpa-navigation/app/dynamic/second/[param]/page.js new file mode 100644 index 0000000000000..bfce324ce58ef --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/dynamic/second/[param]/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page({ params }) { + return ( + <> + To basic inner +

dynamic {params.param}

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/to-pages-dir/layout.js b/test/e2e/app-dir/mpa-navigation/app/to-pages-dir/layout.js new file mode 100644 index 0000000000000..05b841b280b3f --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/to-pages-dir/layout.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello + + {children} + + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/to-pages-dir/page.js b/test/e2e/app-dir/mpa-navigation/app/to-pages-dir/page.js new file mode 100644 index 0000000000000..7bfcd1802568f --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/to-pages-dir/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

In app dir

+ To pages dir + + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@one/inner/page.js b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@one/inner/page.js new file mode 100644 index 0000000000000..849c7ab32a7fb --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@one/inner/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + To dynamic route +

One inner

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@one/page.js b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@one/page.js new file mode 100644 index 0000000000000..7661f2ee7fd47 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@one/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + To parallel inner +

One

+ + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@two/page.js b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@two/page.js new file mode 100644 index 0000000000000..8eefbbcb300f6 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/@two/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Two

+} diff --git a/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/layout.js b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/layout.js new file mode 100644 index 0000000000000..267f8d02f2659 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/app/with-parallel-routes/layout.js @@ -0,0 +1,13 @@ +export default function Root({ one, two }) { + return ( + + + Hello + + + {one} + {two} + + + ) +} diff --git a/test/e2e/app-dir/mpa-navigation/next.config.js b/test/e2e/app-dir/mpa-navigation/next.config.js new file mode 100644 index 0000000000000..cfa3ac3d7aa94 --- /dev/null +++ b/test/e2e/app-dir/mpa-navigation/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + appDir: true, + }, +}