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:
parent
5727d48d57
commit
db2e9b2870
43 changed files with 837 additions and 70 deletions
12
.vscode/launch.json
vendored
12
.vscode/launch.json
vendored
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -210,8 +210,8 @@ describe('refreshReducer', () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
],
|
||||
}
|
||||
|
@ -364,8 +364,8 @@ describe('refreshReducer', () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
],
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page({ params }) {
|
||||
return <p id={`photo-modal-${params.id}`}>Photo MODAL {params.id}</p>
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export default function FeedLayout({ params, children, modal }) {
|
||||
return (
|
||||
<>
|
||||
<h1>User: {params.username}</h1>
|
||||
{children}
|
||||
{modal}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page({ params }) {
|
||||
return <p id={`photo-page-${params.id}`}>Photo PAGE {params.id}</p>
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export default function PhotoLayout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<h1>Photo Layout</h1>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export default function Page({ params }) {
|
||||
return (
|
||||
<p id={`photo-intercepted-${params.id}`}>Photo INTERCEPTED {params.id}</p>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page({ params }) {
|
||||
return <p id={`photo-page-${params.id}`}>Photo PAGE {params.id}</p>
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p>hello world</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="demographics">Demographics</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="audience-home">Audience home</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="subscribers">Subscribers</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="impressions">Impressions</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="views-home">Views home</p>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="view-duration">View duration</p>
|
||||
}
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p id="home">Tab bar page (@children)</p>
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
experimental: { appDir: true },
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue