Add tests for parallel routes / interception and handle router state patch merging client-side (#45615)

Added tests:
- Add tests for interception+parallel and interception
- Add test for  parallel route tab bar
- Add test for back/forward navigation on parallel routes

Core changes:
- Updated handling of parallel route matcher `@` to produce the correct
router tree
- Fixed global-error resolving, it was resolving from the `page.js` on
each level. It should only live next to the root layout only, so now it
resolves when it finds the root layout.
- `applyRouterStatePatchToTree` now merges the levels of the original
tree and the patch. This ensures parallel routes that are not affected
by the response from the server are not removed from the tree.
- Ensure cache nodes are not removed when they're not affected by tree
patch, this ensures parallel route cache nodes will not be removed when
navigating.

Other changes:
- Added launch app-dir build to launch.json for vscode debugger


<!--
Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:
-->

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see
[`contributing.md`](https://github.com/vercel/next.js/blob/canary/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`
- [ ]
[e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
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`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && 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: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Tim Neutkens 2023-02-13 16:12:44 +01:00 committed by GitHub
parent 5727d48d57
commit db2e9b2870
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 837 additions and 70 deletions

12
.vscode/launch.json vendored
View file

@ -16,6 +16,18 @@
"NEXT_PRIVATE_LOCAL_WEBPACK": "1"
}
},
{
"name": "Launch app-dir build",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["debug", "build", "test/e2e/app-dir/app"],
"skipFiles": ["<node_internals>/**"],
"env": {
"NEXT_PRIVATE_LOCAL_WEBPACK": "1"
}
},
{
"name": "Launch app development",
"type": "node",

View file

@ -49,7 +49,7 @@ async function createTreeCodeFromPath(
resolvePath: (pathname: string) => Promise<string>
resolveParallelSegments: (
pathname: string
) => [key: string, segment: string][]
) => [key: string, segment: string | string[]][]
loaderContext: webpack.LoaderContext<AppLoaderOptions>
loaderOptions: AppLoaderOptions
}
@ -73,7 +73,7 @@ async function createTreeCodeFromPath(
const isRootLayer = segments.length === 0
// We need to resolve all parallel routes in this level.
const parallelSegments: [key: string, segment: string][] = []
const parallelSegments: [key: string, segment: string | string[]][] = []
if (isRootLayer) {
parallelSegments.push(['children', ''])
} else {
@ -101,7 +101,10 @@ async function createTreeCodeFromPath(
for (const [parallelKey, parallelSegment] of parallelSegments) {
if (parallelSegment === PAGE_SEGMENT) {
const matchedPagePath = `${appDirPrefix}${segmentPath}/page`
const matchedPagePath = `${appDirPrefix}${segmentPath}${
parallelKey === 'children' ? '' : `/@${parallelKey}`
}/page`
const resolvedPagePath = await resolver(matchedPagePath)
if (resolvedPagePath) pages.push(resolvedPagePath)
@ -117,7 +120,11 @@ async function createTreeCodeFromPath(
const parallelSegmentPath = segmentPath + '/' + parallelSegment
const { treeCode: subtreeCode } = await createSubtreePropsFromSegmentPath(
[...segments, parallelSegment]
[
...segments,
...(parallelKey === 'children' ? [] : [`@${parallelKey}`]),
Array.isArray(parallelSegment) ? parallelSegment[0] : parallelSegment,
]
)
// `page` is not included here as it's added above.
@ -125,33 +132,38 @@ async function createTreeCodeFromPath(
Object.values(FILE_TYPES).map(async (file) => {
return [
file,
await resolver(`${appDirPrefix}${parallelSegmentPath}/${file}`),
await resolver(
`${appDirPrefix}${
// TODO-APP: parallelSegmentPath sometimes ends in `/` but sometimes it doesn't. This should be consistent.
parallelSegmentPath.endsWith('/')
? parallelSegmentPath
: parallelSegmentPath + '/'
}${file}`
),
] as const
})
)
const layoutPath = filePaths.find(
([type, filePath]) => type === 'layout' && !!filePath
)?.[1]
if (!rootLayout) {
const layoutPath = filePaths.find(
([type, filePath]) => type === 'layout' && !!filePath
)?.[1]
rootLayout = layoutPath
}
if (!rootLayout) {
rootLayout = layoutPath
}
if (!globalError) {
globalError = await resolver(
`${appDirPrefix}${parallelSegmentPath}${GLOBAL_ERROR_FILE_TYPE}`
)
if (layoutPath) {
globalError = await resolver(
`${path.dirname(layoutPath)}/${GLOBAL_ERROR_FILE_TYPE}`
)
}
}
const definedFilePaths = filePaths.filter(
([, filePath]) => filePath !== undefined
)
props[parallelKey] = `[
'${parallelSegment}',
'${
Array.isArray(parallelSegment) ? parallelSegment[0] : parallelSegment
}',
${subtreeCode},
{
${definedFilePaths
@ -236,25 +248,35 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
const normalizedAppPaths =
typeof appPaths === 'string' ? [appPaths] : appPaths || []
const resolveParallelSegments = (pathname: string) => {
const matched: Record<string, string> = {}
const resolveParallelSegments = (
pathname: string
): [string, string | string[]][] => {
const matched: Record<string, string | string[]> = {}
for (const appPath of normalizedAppPaths) {
if (appPath.startsWith(pathname + '/')) {
const rest = appPath.slice(pathname.length + 1).split('/')
let matchedSegment = rest[0]
// It is the actual page, mark it specially.
if (rest.length === 1 && matchedSegment === 'page') {
matchedSegment = PAGE_SEGMENT
if (rest.length === 1 && rest[0] === 'page') {
matched.children = PAGE_SEGMENT
continue
}
const matchedKey = matchedSegment.startsWith('@')
? matchedSegment.slice(1)
: 'children'
const isParallelRoute = rest[0].startsWith('@')
if (isParallelRoute && rest.length === 2 && rest[1] === 'page') {
matched[rest[0].slice(1)] = PAGE_SEGMENT
continue
}
matched[matchedKey] = matchedSegment
if (isParallelRoute) {
matched[rest[0].slice(1)] = rest.slice(1)
continue
}
matched.children = rest[0]
}
}
return Object.entries(matched)
}

View file

@ -4,6 +4,57 @@ import {
} from '../../../server/app-render'
import { matchSegment } from '../match-segments'
/**
* Deep merge of the two router states. Parallel route keys are preserved if the patch doesn't have them.
*/
function applyPatch(
initialTree: FlightRouterState,
patchTree: FlightRouterState
): FlightRouterState {
const [segment, parallelRoutes] = initialTree
if (matchSegment(segment, patchTree[0])) {
const newParallelRoutes: FlightRouterState[1] = {}
for (const key in parallelRoutes) {
const isInPatchTreeParallelRoutes =
typeof patchTree[1][key] !== 'undefined'
if (isInPatchTreeParallelRoutes) {
newParallelRoutes[key] = applyPatch(
parallelRoutes[key],
patchTree[1][key]
)
} else {
newParallelRoutes[key] = parallelRoutes[key]
}
}
for (const key in patchTree[1]) {
if (newParallelRoutes[key]) {
continue
}
newParallelRoutes[key] = patchTree[1][key]
}
const tree: FlightRouterState = [segment, newParallelRoutes]
if (initialTree[2]) {
tree[2] = initialTree[2]
}
if (initialTree[3]) {
tree[3] = initialTree[3]
}
if (initialTree[4]) {
tree[4] = initialTree[4]
}
return tree
}
return patchTree
}
/**
* Apply the router state from the Flight response. Creates a new router state tree.
*/
@ -16,7 +67,7 @@ export function applyRouterStatePatchToTree(
// Root refresh
if (flightSegmentPath.length === 1) {
const tree: FlightRouterState = [...treePatch]
const tree: FlightRouterState = applyPatch(flightRouterState, treePatch)
return tree
}

View file

@ -137,16 +137,15 @@ describe('fillLazyItemsTillLeafWithHead', () => {
status: CacheStates.LAZY_INITIALIZED,
},
],
// TODO-APP: this segment should be preserved when creating the new cache
// [
// '',
// {
// data: null,
// status: CacheStates.READY,
// subTreeData: <>Page</>,
// parallelRoutes: new Map(),
// },
// ],
[
'',
{
data: null,
status: CacheStates.READY,
subTreeData: <>Page</>,
parallelRoutes: new Map(),
},
],
]),
],
]),

View file

@ -19,17 +19,19 @@ export function fillLazyItemsTillLeafWithHead(
const cacheKey = Array.isArray(segmentForParallelRoute)
? segmentForParallelRoute[1]
: segmentForParallelRoute
if (existingCache) {
const existingParallelRoutesCacheNode =
existingCache.parallelRoutes.get(key)
if (existingParallelRoutesCacheNode) {
let parallelRouteCacheNode = new Map(existingParallelRoutesCacheNode)
const existingCacheNode = parallelRouteCacheNode.get(cacheKey)
parallelRouteCacheNode.delete(cacheKey)
const newCacheNode: CacheNode = {
status: CacheStates.LAZY_INITIALIZED,
data: null,
subTreeData: null,
parallelRoutes: new Map(),
parallelRoutes: new Map(existingCacheNode?.parallelRoutes),
}
parallelRouteCacheNode.set(cacheKey, newCacheNode)
fillLazyItemsTillLeafWithHead(
@ -50,7 +52,14 @@ export function fillLazyItemsTillLeafWithHead(
subTreeData: null,
parallelRoutes: new Map(),
}
newCache.parallelRoutes.set(key, new Map([[cacheKey, newCacheNode]]))
const existingParallelRoutes = newCache.parallelRoutes.get(key)
if (existingParallelRoutes) {
existingParallelRoutes.set(cacheKey, newCacheNode)
} else {
newCache.parallelRoutes.set(key, new Map([[cacheKey, newCacheNode]]))
}
fillLazyItemsTillLeafWithHead(
newCacheNode,
undefined,

View file

@ -20,6 +20,37 @@ const flightData: FlightData = [
],
]
const demographicsFlightData: FlightData = [
[
[
'',
{
children: [
'parallel-tab-bar',
{
audience: [
'demographics',
{
children: ['', {}],
},
],
},
],
},
null,
null,
true,
],
<html>
<head></head>
<body>Root layout from response</body>
</html>,
<>
<title>Demographics Head</title>
</>,
],
]
jest.mock('../fetch-server-response', () => {
return {
fetchServerResponse: (
@ -29,6 +60,10 @@ jest.mock('../fetch-server-response', () => {
return Promise.resolve([flightData, undefined])
}
if (url.pathname === '/parallel-tab-bar/demographics') {
return Promise.resolve([demographicsFlightData, undefined])
}
throw new Error('unknown url in mock')
},
}
@ -1132,4 +1167,258 @@ describe('navigateReducer', () => {
expect(newState).toMatchObject(expectedState)
})
it('should apply parallel routes navigation (concurrent)', async () => {
const initialTree: FlightRouterState = [
'',
{
children: [
'parallel-tab-bar',
{
audience: ['', {}],
views: ['', {}],
children: ['', {}],
},
],
},
null,
null,
true,
]
const initialCanonicalUrl = '/parallel-tab-bar'
const children = (
<html>
<head></head>
<body>Root layout</body>
</html>
)
const initialParallelRoutes: CacheNode['parallelRoutes'] = new Map([
[
'children',
new Map([
[
'parallel-tab-bar',
{
status: CacheStates.READY,
parallelRoutes: new Map([
[
'audience',
new Map([
[
'',
{
status: CacheStates.READY,
data: null,
subTreeData: <>Audience Page</>,
parallelRoutes: new Map(),
},
],
]),
],
[
'views',
new Map([
[
'',
{
status: CacheStates.READY,
data: null,
subTreeData: <>Views Page</>,
parallelRoutes: new Map(),
},
],
]),
],
[
'children',
new Map([
[
'',
{
status: CacheStates.READY,
data: null,
subTreeData: <>Children Page</>,
parallelRoutes: new Map(),
},
],
]),
],
]),
data: null,
subTreeData: <>Layout level</>,
},
],
]),
],
])
const state = createInitialRouterState({
initialTree,
initialCanonicalUrl,
children,
initialParallelRoutes,
isServer: false,
location: new URL('/parallel-tab-bar', 'https://localhost') as any,
})
const state2 = createInitialRouterState({
initialTree,
initialCanonicalUrl,
children,
initialParallelRoutes,
isServer: false,
location: new URL('/parallel-tab-bar', 'https://localhost') as any,
})
const action: NavigateAction = {
type: ACTION_NAVIGATE,
url: new URL('/parallel-tab-bar/demographics', 'https://localhost'),
isExternalUrl: false,
locationSearch: '',
navigateType: 'push',
forceOptimisticNavigation: false,
cache: {
status: CacheStates.LAZY_INITIALIZED,
data: null,
subTreeData: null,
parallelRoutes: new Map(),
},
mutable: {},
}
await runPromiseThrowChain(() => navigateReducer(state, action))
const newState = await runPromiseThrowChain(() =>
navigateReducer(state2, action)
)
const expectedState: ReturnType<typeof navigateReducer> = {
prefetchCache: new Map(),
pushRef: {
mpaNavigation: false,
pendingPush: true,
},
focusAndScrollRef: {
apply: true,
},
canonicalUrl: '/parallel-tab-bar/demographics',
cache: {
status: CacheStates.READY,
data: null,
subTreeData: (
<html>
<head></head>
<body>Root layout from response</body>
</html>
),
parallelRoutes: new Map([
[
'children',
new Map([
[
'parallel-tab-bar',
{
status: CacheStates.LAZY_INITIALIZED,
parallelRoutes: new Map([
[
'audience',
new Map([
[
'',
{
status: CacheStates.READY,
data: null,
subTreeData: <>Audience Page</>,
parallelRoutes: new Map(),
},
],
[
'demographics',
{
status: CacheStates.LAZY_INITIALIZED,
data: null,
subTreeData: null,
parallelRoutes: new Map([
[
'children',
new Map([
[
'',
{
status: CacheStates.LAZY_INITIALIZED,
data: null,
subTreeData: null,
parallelRoutes: new Map(),
head: (
<>
<title>Demographics Head</title>
</>
),
},
],
]),
],
]),
},
],
]),
],
[
'views',
new Map([
[
'',
{
status: CacheStates.READY,
data: null,
subTreeData: <>Views Page</>,
parallelRoutes: new Map(),
},
],
]),
],
[
'children',
new Map([
[
'',
{
status: CacheStates.READY,
data: null,
subTreeData: <>Children Page</>,
parallelRoutes: new Map(),
},
],
]),
],
]),
data: null,
subTreeData: null,
},
],
]),
],
]),
},
tree: [
'',
{
children: [
'parallel-tab-bar',
{
audience: ['demographics', { children: ['', {}] }],
views: ['', {}],
children: ['', {}],
},
],
},
undefined,
undefined,
true,
],
}
expect(newState).toMatchObject(expectedState)
})
})

View file

@ -210,8 +210,8 @@ describe('refreshReducer', () => {
},
],
},
null,
null,
undefined,
undefined,
true,
],
}
@ -364,8 +364,8 @@ describe('refreshReducer', () => {
},
],
},
null,
null,
undefined,
undefined,
true,
],
}

View file

@ -30,7 +30,6 @@ import {
} from '../build/webpack/plugins/flight-manifest-plugin'
import { ServerInsertedHTMLContext } from '../shared/lib/server-inserted-html'
import { stripInternalQueries } from './internal-utils'
// import type { ComponentsType } from '../build/webpack/loaders/next-app-loader'
import { REDIRECT_ERROR_CODE } from '../client/components/redirect'
import { RequestCookies } from './web/spec-extension/cookies'
import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context'

View file

@ -377,29 +377,6 @@ createNextDescribe(
}
})
describe('parallel routes', () => {
if (!isNextDeploy) {
it('should match parallel routes', async () => {
const html = await next.render('/parallel/nested')
expect(html).toContain('parallel/layout')
expect(html).toContain('parallel/@foo/nested/layout')
expect(html).toContain('parallel/@foo/nested/@a/page')
expect(html).toContain('parallel/@foo/nested/@b/page')
expect(html).toContain('parallel/@bar/nested/layout')
expect(html).toContain('parallel/@bar/nested/@a/page')
expect(html).toContain('parallel/@bar/nested/@b/page')
expect(html).toContain('parallel/nested/page')
})
}
it('should match parallel routes in route groups', async () => {
const html = await next.render('/parallel/nested-2')
expect(html).toContain('parallel/layout')
expect(html).toContain('parallel/(new)/layout')
expect(html).toContain('parallel/(new)/@baz/nested/page')
})
})
describe('<Link />', () => {
it('should hard push', async () => {
const browser = await next.browser('/link-hard-push/123')

View file

@ -0,0 +1,18 @@
import Link from 'next/link'
export default function Page({ params }) {
return (
<>
<h2 id="user-page">Feed for {params.username}</h2>
<ul>
{Array.from({ length: 10 }).map((_, i) => (
<li>
<Link href={`/intercepting-parallel-modal/photos/${i}`}>
Link {i}
</Link>
</li>
))}
</ul>
</>
)
}

View file

@ -0,0 +1,3 @@
export default function Page({ params }) {
return <p id={`photo-modal-${params.id}`}>Photo MODAL {params.id}</p>
}

View file

@ -0,0 +1,9 @@
export default function FeedLayout({ params, children, modal }) {
return (
<>
<h1>User: {params.username}</h1>
{children}
{modal}
</>
)
}

View file

@ -0,0 +1,3 @@
export default function Page({ params }) {
return <p id={`photo-page-${params.id}`}>Photo PAGE {params.id}</p>
}

View file

@ -0,0 +1,8 @@
export default function PhotoLayout({ children }) {
return (
<>
<h1>Photo Layout</h1>
{children}
</>
)
}

View file

@ -0,0 +1,5 @@
export default function Page({ params }) {
return (
<p id={`photo-intercepted-${params.id}`}>Photo INTERCEPTED {params.id}</p>
)
}

View file

@ -0,0 +1,16 @@
import Link from 'next/link'
export default function Page() {
return (
<>
<h1 id="feed-page">Feed</h1>
<ul>
{Array.from({ length: 10 }).map((_, i) => (
<li>
<Link href={`/intercepting-routes/photos/${i}`}>Link {i}</Link>
</li>
))}
</ul>
</>
)
}

View file

@ -0,0 +1,3 @@
export default function Page({ params }) {
return <p id={`photo-page-${params.id}`}>Photo PAGE {params.id}</p>
}

View file

@ -0,0 +1,7 @@
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="demographics">Demographics</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="audience-home">Audience home</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="subscribers">Subscribers</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="impressions">Impressions</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="views-home">Views home</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="view-duration">View duration</p>
}

View file

@ -0,0 +1,61 @@
import Link from 'next/link'
function AudienceNav() {
return (
<ul>
<li>
<Link id="home-link-audience" href="/parallel-tab-bar">
Home
</Link>
</li>
<li>
<Link id="demographics-link" href="/parallel-tab-bar/demographics">
Demographics
</Link>
</li>
<li>
<Link id="subscribers-link" href="/parallel-tab-bar/subscribers">
Subscribers
</Link>
</li>
</ul>
)
}
function ViewsNav() {
return (
<ul>
<li>
<Link id="home-link-views" href="/parallel-tab-bar">
Home
</Link>
</li>
<li>
<Link id="impressions-link" href="/parallel-tab-bar/impressions">
Impressions
</Link>
</li>
<li>
<Link id="view-duration-link" href="/parallel-tab-bar/view-duration">
View Duration
</Link>
</li>
</ul>
)
}
export default function Layout({ children, audience, views }) {
return (
<>
<h1>Tab Bar Layout</h1>
{children}
<h2>Audience</h2>
<AudienceNav />
{audience}
<h2>Views</h2>
<ViewsNav />
{views}
</>
)
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="home">Tab bar page (@children)</p>
}

View file

@ -0,0 +1,8 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: { appDir: true },
}
module.exports = nextConfig

View file

@ -0,0 +1,243 @@
import { createNextDescribe } from 'e2e-utils'
// TODO-APP: remove when parallel routes and interception are implemented
const skipped = true
if (skipped) {
it.skip('skips parallel routes and interception as it is not implemented yet', () => {})
} else {
createNextDescribe(
'parallel-routes-and-interception',
{
files: __dirname,
},
({ next, isNextDeploy }) => {
describe('parallel routes', () => {
if (!isNextDeploy) {
it('should match parallel routes', async () => {
const html = await next.render('/parallel/nested')
expect(html).toContain('parallel/layout')
expect(html).toContain('parallel/@foo/nested/layout')
expect(html).toContain('parallel/@foo/nested/@a/page')
expect(html).toContain('parallel/@foo/nested/@b/page')
expect(html).toContain('parallel/@bar/nested/layout')
expect(html).toContain('parallel/@bar/nested/@a/page')
expect(html).toContain('parallel/@bar/nested/@b/page')
expect(html).toContain('parallel/nested/page')
})
}
it('should match parallel routes in route groups', async () => {
const html = await next.render('/parallel/nested-2')
expect(html).toContain('parallel/layout')
expect(html).toContain('parallel/(new)/layout')
expect(html).toContain('parallel/(new)/@baz/nested/page')
})
it('should support parallel route tab bars', async () => {
const browser = await next.browser('/parallel-tab-bar')
const hasHome = async () => {
const text = await browser.waitForElementByCss('#home').text()
expect(text).toBe('Tab bar page (@children)')
}
const hasViewsHome = async () => {
const text = await browser.waitForElementByCss('#views-home').text()
expect(text).toBe('Views home')
}
const hasViewDuration = async () => {
const text = await browser
.waitForElementByCss('#view-duration')
.text()
expect(text).toBe('View duration')
}
const hasImpressions = async () => {
const text = await browser
.waitForElementByCss('#impressions')
.text()
expect(text).toBe('Impressions')
}
const hasAudienceHome = async () => {
const text = await browser
.waitForElementByCss('#audience-home')
.text()
expect(text).toBe('Audience home')
}
const hasDemographics = async () => {
const text = await browser
.waitForElementByCss('#demographics')
.text()
expect(text).toBe('Demographics')
}
const hasSubscribers = async () => {
const text = await browser
.waitForElementByCss('#subscribers')
.text()
expect(text).toBe('Subscribers')
}
const checkUrlPath = async (path: string) => {
expect(await browser.url()).toBe(
`${next.url}/parallel-tab-bar${path}`
)
}
// Initial page
const step1 = async () => {
await hasHome()
await hasViewsHome()
await hasAudienceHome()
await checkUrlPath('')
}
await step1()
// Navigate to /views/duration
await browser.elementByCss('#view-duration-link').click()
const step2 = async () => {
await hasHome()
await hasViewDuration()
await hasAudienceHome()
await checkUrlPath('/view-duration')
}
await step2()
// Navigate to /views/impressions
await browser.elementByCss('#impressions-link').click()
const step3 = async () => {
await hasHome()
await hasImpressions()
await hasAudienceHome()
await checkUrlPath('/impressions')
}
await step3()
// Navigate to /audience/demographics
await browser.elementByCss('#demographics-link').click()
const step4 = async () => {
await hasHome()
await hasImpressions()
await hasDemographics()
await checkUrlPath('/demographics')
}
await step4()
// Navigate to /audience/subscribers
await browser.elementByCss('#subscribers-link').click()
const step5 = async () => {
await hasHome()
await hasImpressions()
await hasSubscribers()
await checkUrlPath('/subscribers')
}
await step5()
// Navigate to /
await browser.elementByCss('#home-link-audience').click()
// TODO: home link behavior
await step1()
// Test that back navigation works as intended
await browser.back()
await step5()
await browser.back()
await step4()
await browser.back()
await step3()
await browser.back()
await step2()
await browser.back()
await step1()
// Test that forward navigation works as intended
await browser.forward()
await step2()
await browser.forward()
await step3()
await browser.forward()
await step4()
await browser.forward()
await step5()
})
})
describe('route intercepting', () => {
it('should render intercepted route', async () => {
const browser = await next.browser('/intercepting-routes/feed')
// Check if navigation to modal route works.
expect(
await browser
.elementByCss('[href="/intercepting-routes/photos/1"]')
.click()
.waitForElementByCss('#photo-intercepted-1')
.text()
).toBe('Photo INTERCEPTED 1')
// Check if intercepted route was rendered while existing page content was removed.
// Content would only be preserved when combined with parallel routes.
expect(await browser.elementByCss('#feed-page').text()).not.toBe(
'Feed'
)
// Check if url matches even though it was intercepted.
expect(await browser.url()).toBe(
next.url + '/intercepting-routes/photos/1'
)
// Trigger a refresh, this should load the normal page, not the modal.
expect(
await browser.refresh().waitForElementByCss('#photo-page-1').text()
).toBe('Photo PAGE 1')
// Check if the url matches still.
expect(await browser.url()).toBe(
next.url + '/intercepting-routes/photos/1'
)
})
it('should render modal when paired with parallel routes', async () => {
const browser = await next.browser(
'/intercepting-parallel-modal/vercel'
)
// Check if navigation to modal route works.
expect(
await browser
.elementByCss('[href="/intercepting-parallel-modal/photos/1"]')
.click()
.waitForElementByCss('#photo-modal-1')
.text()
).toBe('Photo MODAL 1')
// Check if modal was rendered while existing page content is preserved.
expect(await browser.elementByCss('#user-page').text()).toBe(
'Feed for vercel'
)
// Check if url matches even though it was intercepted.
expect(await browser.url()).toBe(
next.url + '/intercepting-parallel-modal/photos/1'
)
// Trigger a refresh, this should load the normal page, not the modal.
expect(
await browser.refresh().waitForElementByCss('#photo-page-1').text()
).toBe('Photo PAGE 1')
// Check if the url matches still.
expect(await browser.url()).toBe(
next.url + '/intercepting-parallel-modal/photos/1'
)
})
})
}
)
}

View file

@ -7,9 +7,10 @@ import { FileRef } from '../e2e-utils'
import { ChildProcess } from 'child_process'
import { createNextInstall } from '../create-next-install'
import { Span } from 'next/src/trace'
import webdriver from 'next-webdriver'
import webdriver from '../next-webdriver'
import { renderViaHTTP, fetchViaHTTP } from 'next-test-utils'
import cheerio from 'cheerio'
import { BrowserInterface } from '../browsers/base'
type Event = 'stdout' | 'stderr' | 'error' | 'destroy'
export type InstallCommand =
@ -404,7 +405,7 @@ export class NextInstance {
*/
public async browser(
...args: Parameters<OmitFirstArgument<typeof webdriver>>
) {
): Promise<BrowserInterface> {
return webdriver(this.url, ...args)
}