Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check root layout change on client #41475

Merged
merged 18 commits into from Oct 20, 2022
Merged
11 changes: 9 additions & 2 deletions packages/next/client/components/app-router.tsx
Expand Up @@ -12,7 +12,11 @@ import type {
CacheNode,
AppRouterInstance,
} from '../../shared/lib/app-router-context'
import type { FlightRouterState, FlightData } from '../../server/app-render'
import type {
FlightRouterState,
FlightData,
Segment,
} from '../../server/app-render'
import {
ACTION_NAVIGATE,
ACTION_PREFETCH,
Expand Down Expand Up @@ -99,6 +103,7 @@ type AppRouterProps = {
initialCanonicalUrl: string
children: ReactNode
assetPrefix: string
rootLayoutSegments: Segment[]
}

/**
Expand All @@ -109,6 +114,7 @@ function Router({
initialCanonicalUrl,
children,
assetPrefix,
rootLayoutSegments,
}: AppRouterProps) {
const initialState = useMemo(() => {
return {
Expand All @@ -127,8 +133,9 @@ function Router({
// Hash is read as the initial value for canonicalUrl in the browser
// This is safe to do as canonicalUrl can't be rendered, it's only used to control the history updates the useEffect further down.
(typeof window !== 'undefined' ? window.location.hash : ''),
rootLayoutSegments,
}
}, [children, initialCanonicalUrl, initialTree])
}, [children, initialCanonicalUrl, initialTree, rootLayoutSegments])
const [
{ tree, cache, prefetchCache, pushRef, focusAndScrollRef, canonicalUrl },
dispatch,
Expand Down
75 changes: 75 additions & 0 deletions packages/next/client/components/reducer.ts
Expand Up @@ -494,6 +494,54 @@ function shouldHardNavigate(
)
}

function segmentsIncludesRootLayout(
rootLayoutSegment: Segment[],
segments: Segment[]
) {
if (segments.length !== rootLayoutSegment.length) {
return false
}

for (let i = 0; i < segments.length; i++) {
const segment1 = segments[i]
const segment2 = rootLayoutSegment[i]

if (Array.isArray(segment1) && Array.isArray(segment2)) {
// Compare dynamic param name and type but ignore the value
if (segment1[0] !== segment2[0]) {
return false
}
if (segment1[2] !== segment2[2]) {
return false
}
} else if (segment1 !== segment2) {
return false
}
}

return true
}

function findRootLayoutInFlightRouterState(
[segment, parallelRoutes]: FlightRouterState,
rootLayoutSegments: Segment[],
segments: Segment[] = []
): boolean {
if (segments.length === rootLayoutSegments.length) {
return segmentsIncludesRootLayout(rootLayoutSegments, segments)
} 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,
segment,
])
}

export type FocusAndScrollRef = {
/**
* If focus and scroll should be set in the layout-router's useEffect()
Expand Down Expand Up @@ -648,6 +696,7 @@ type AppRouterState = {
* The canonical url that is pushed/replaced
*/
canonicalUrl: string
rootLayoutSegments: Segment[]
}

/**
Expand All @@ -671,6 +720,20 @@ function clientReducer(
const href = createHrefFromUrl(url)
const pendingPush = navigateType === 'push'

// Do a full page navigation when the root layout changes
if (
mutable.patchedTree &&
!findRootLayoutInFlightRouterState(
mutable.patchedTree,
state.rootLayoutSegments
)
) {
window.location.href = mutable.canonicalUrlOverride
hanneslund marked this conversation as resolved.
Show resolved Hide resolved
? mutable.canonicalUrlOverride
: href
return state
}

// Handle concurrent rendering / strict mode case where the cache and tree were already populated.
if (
mutable.patchedTree &&
Expand All @@ -690,6 +753,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Apply patched router state.
tree: mutable.patchedTree,
rootLayoutSegments: state.rootLayoutSegments,
}
}

Expand Down Expand Up @@ -752,6 +816,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Apply patched tree.
tree: newTree,
rootLayoutSegments: state.rootLayoutSegments,
}
}
}
Expand Down Expand Up @@ -805,6 +870,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Apply optimistic tree.
tree: optimisticTree,
rootLayoutSegments: state.rootLayoutSegments,
}
}
}
Expand Down Expand Up @@ -832,6 +898,7 @@ function clientReducer(
cache: state.cache,
prefetchCache: state.prefetchCache,
tree: state.tree,
rootLayoutSegments: state.rootLayoutSegments,
}
}

Expand Down Expand Up @@ -891,6 +958,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Apply patched tree.
tree: newTree,
rootLayoutSegments: state.rootLayoutSegments,
}
}
case ACTION_SERVER_PATCH: {
Expand Down Expand Up @@ -922,6 +990,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Apply patched cache
cache: cache,
rootLayoutSegments: state.rootLayoutSegments,
}
}

Expand All @@ -938,6 +1007,7 @@ function clientReducer(
cache: state.cache,
prefetchCache: state.prefetchCache,
tree: state.tree,
rootLayoutSegments: state.rootLayoutSegments,
}
}

Expand Down Expand Up @@ -992,6 +1062,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Apply patched cache
cache: cache,
rootLayoutSegments: state.rootLayoutSegments,
}
}
case ACTION_RESTORE: {
Expand All @@ -1007,6 +1078,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Restore provided tree
tree: tree,
rootLayoutSegments: state.rootLayoutSegments,
}
}
case ACTION_REFRESH: {
Expand All @@ -1031,6 +1103,7 @@ function clientReducer(
cache: cache,
prefetchCache: state.prefetchCache,
tree: mutable.patchedTree,
rootLayoutSegments: state.rootLayoutSegments,
}
}

Expand All @@ -1056,6 +1129,7 @@ function clientReducer(
cache: state.cache,
prefetchCache: state.prefetchCache,
tree: state.tree,
rootLayoutSegments: state.rootLayoutSegments,
}
}

Expand Down Expand Up @@ -1113,6 +1187,7 @@ function clientReducer(
prefetchCache: state.prefetchCache,
// Apply patched router state.
tree: newTree,
rootLayoutSegments: state.rootLayoutSegments,
}
}
case ACTION_PREFETCH: {
Expand Down
12 changes: 12 additions & 0 deletions packages/next/server/app-render.tsx
Expand Up @@ -873,6 +873,7 @@ export async function renderToHTMLOrFlight(
)

const assetPrefix = renderOpts.assetPrefix || ''
let rootLayoutSegments: Segment[] = []

/**
* Use the provided loader tree to create the React Component tree.
Expand All @@ -895,12 +896,14 @@ export async function renderToHTMLOrFlight(
parentParams,
firstItem,
rootLayoutIncluded,
currentRootLayoutSegments = [],
}: {
createSegmentPath: CreateSegmentPath
loaderTree: LoaderTree
parentParams: { [key: string]: any }
rootLayoutIncluded?: boolean
firstItem?: boolean
currentRootLayoutSegments?: Segment[]
}): Promise<{ Component: React.ComponentType }> => {
// TODO-APP: enable stylesheet per layout/page
const stylesheets: string[] = layoutOrPagePath
Expand Down Expand Up @@ -991,6 +994,10 @@ export async function renderToHTMLOrFlight(
// Resolve the segment param
const actualSegment = segmentParam ? segmentParam.treeSegment : segment

if (rootLayoutAtThisLevel) {
rootLayoutSegments = [...currentRootLayoutSegments, actualSegment]
}

// This happens outside of rendering in order to eagerly kick off data fetching for layouts / the page further down
const parallelRouteMap = await Promise.all(
Object.keys(parallelRoutes).map(
Expand Down Expand Up @@ -1040,6 +1047,10 @@ export async function renderToHTMLOrFlight(
loaderTree: parallelRoutes[parallelRouteKey],
parentParams: currentParams,
rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
currentRootLayoutSegments: [
...currentRootLayoutSegments,
actualSegment,
],
})

const childProp: ChildProp = {
Expand Down Expand Up @@ -1356,6 +1367,7 @@ export async function renderToHTMLOrFlight(
assetPrefix={assetPrefix}
initialCanonicalUrl={initialCanonicalUrl}
initialTree={initialTree}
rootLayoutSegments={rootLayoutSegments}
>
<ComponentTree />
</AppRouter>
Expand Down
58 changes: 52 additions & 6 deletions test/e2e/app-dir/root-layout.test.ts
Expand Up @@ -4,7 +4,7 @@ import { NextInstance } from 'test/lib/next-modes/base'
import webdriver from 'next-webdriver'
import { getRedboxSource, hasRedbox } from 'next-test-utils'

describe.skip('app-dir root layout', () => {
describe('app-dir root layout', () => {
const isDev = (global as any).isNextDev

if ((global as any).isNextDeploy) {
Expand Down Expand Up @@ -151,18 +151,18 @@ describe.skip('app-dir root layout', () => {
})

it('should work with dynamic routes', async () => {
const browser = await webdriver(next.url, '/dynamic/first/route')
const browser = await webdriver(next.url, '/dynamic/first')

expect(await browser.elementById('dynamic-route').text()).toBe(
'dynamic route'
expect(await browser.elementById('dynamic-first').text()).toBe(
'dynamic first'
)
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')
await browser.waitForElementByCss('#dynamic-first-second').text()
).toBe('dynamic first second')
expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue()

// Navigate to page with different root layout
Expand All @@ -172,5 +172,51 @@ describe.skip('app-dir root layout', () => {
).toBe('Inner basic route')
expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined()
})

it('should work with dynamic catchall routes', async () => {
const browser = await webdriver(next.url, '/dynamic-catchall/slug')

expect(await browser.elementById('catchall-slug').text()).toBe(
'catchall slug'
)
await browser.eval('window.__TEST_NO_RELOAD = true')

// Navigate to page with same root layout
await browser.elementById('to-next-url').click()
expect(
await browser.waitForElementByCss('#catchall-slug-slug').text()
).toBe('catchall slug slug')
expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue()

// Navigate to page with different root layout
await browser.elementById('to-dynamic-first').click()
expect(await browser.elementById('dynamic-first').text()).toBe(
'dynamic first'
)
expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined()
})

it('should work with static routes', async () => {
const browser = await webdriver(next.url, '/static-mpa-navigation/slug1')

expect(await browser.elementById('static-slug1').text()).toBe(
'static slug1'
)
await browser.eval('window.__TEST_NO_RELOAD = true')

// Navigate to page with same root layout
await browser.elementByCss('a').click()
expect(await browser.waitForElementByCss('#static-slug2').text()).toBe(
'static slug2'
)
expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue()

// Navigate to page with different root layout
await browser.elementByCss('a').click()
expect(await browser.elementById('basic-route').text()).toBe(
'Basic route'
)
expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined()
})
})
})
@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html>
<head>
<title>Hello</title>
</head>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,18 @@
import Link from 'next/link'

export default function Page({ params }) {
const nextUrl = [...params.slug, 'slug']
return (
<>
<Link href={`/dynamic-catchall/${nextUrl.join('/')}`}>
<a id="to-next-url">To next url</a>
</Link>
<Link href="/dynamic/first">
<a id="to-dynamic-first">To next url</a>
</Link>
<p id={`catchall-${params.slug.join('-')}`}>
catchall {params.slug.join(' ')}
</p>
</>
)
}
Expand Up @@ -4,7 +4,9 @@ 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>
<p id={`dynamic-${params.first}-${params.second}`}>
dynamic {params.first} {params.second}
</p>
</>
)
}