Skip to content

Commit

Permalink
Full page reload when navigating to new root layout (vercel#40751)
Browse files Browse the repository at this point in the history
Finds the root layout segments for flight requests. If those segments doesn't match the FlightRouterState it's a new root layout and a full page reload is required.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)


Co-authored-by: Tim Neutkens <6324199+timneutkens@users.noreply.github.com>
  • Loading branch information
2 people authored and Kikobeats committed Oct 24, 2022
1 parent e16f9cc commit a82da53
Show file tree
Hide file tree
Showing 18 changed files with 358 additions and 5 deletions.
83 changes: 78 additions & 5 deletions packages/next/server/app-render.tsx
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand All @@ -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.
Expand Down
129 changes: 129 additions & 0 deletions 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()
})
})
})
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<Link href="/with-parallel-routes">To with-parallel-routes</Link>
<p id="nested-route-group">Nested route group</p>
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/mpa-navigation/app/(route-group)/layout.js
@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html>
<head>
<title>Hello</title>
</head>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<Link href="/nested-route-group">To nested route group</Link>
<p id="route-group">Route group</p>
</>
)
}
10 changes: 10 additions & 0 deletions 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 (
<>
<Link href="/route-group">To route group</Link>
<p id="inner-basic-route">Inner basic route</p>
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/mpa-navigation/app/basic-route/layout.js
@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html>
<head>
<title>Hello</title>
</head>
<body>{children}</body>
</html>
)
}
10 changes: 10 additions & 0 deletions 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 (
<>
<Link href="/basic-route/inner">To inner basic route</Link>
<p id="basic-route">Basic route</p>
</>
)
}
10 changes: 10 additions & 0 deletions 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 (
<>
<Link href="/dynamic/second/hello">To second dynamic</Link>
<p id={`dynamic-${params.param}`}>dynamic {params.param}</p>
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/mpa-navigation/app/dynamic/layout.js
@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html>
<head>
<title>Hello</title>
</head>
<body>{children}</body>
</html>
)
}
10 changes: 10 additions & 0 deletions 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 (
<>
<Link href="/basic-route/inner">To basic inner</Link>
<p id={`dynamic-second-${params.param}`}>dynamic {params.param}</p>
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/mpa-navigation/app/to-pages-dir/layout.js
@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html>
<head>
<title>Hello</title>
</head>
<body>{children}</body>
</html>
)
}
10 changes: 10 additions & 0 deletions 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 (
<>
<p id="in-app-dir">In app dir</p>
<Link href="/pages-dir">To pages dir</Link>
</>
)
}
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<Link href="/dynamic/first/hello">To dynamic route</Link>
<p id="parallel-one-inner">One inner</p>
</>
)
}
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<Link href="/with-parallel-routes/inner">To parallel inner</Link>
<p id="parallel-one">One</p>
</>
)
}
@@ -0,0 +1,3 @@
export default function Page() {
return <p id="parallel-two">Two</p>
}

0 comments on commit a82da53

Please sign in to comment.