From d32ee25bfbe92719778a37529d1c3cb5df767e39 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 5 Apr 2023 15:40:28 +0200 Subject: [PATCH] Add dynamic parameter marker to router cache key (#47957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Took a bit to investigate this one, eventually found that the case where it broke is this one: ``` app ├── [slug] // This matches `/blog` │ └── page.js └── blog └── [name] // This matches `/blog/a-post` └── page.js ``` The router cache key is based on the "static key" / "dynamic parameter value" in the tree. This means that the cache key for `/blog` that matches `/[slug]` would be the same as the static segment `blog`. This caused the cache to become intertwined between those paths, it's accidental that the router got stuck in that case, main reason it got stuck is that the fetch for the RSC payload returned a deeper value than expected. In `walkAddRefetch` we bailed because that walked the `segmentPath` didn't match up. The underlying problem with this was that the render would override the cache nodes incorrectly. This would also cause wrong behavior, even though that wasn't reported. E.g. `app/[slug]/layout.js` would apply on `app/blog/[name]/page.js` because they'd share the `blog` cache node. ### How? This PR changes the cache key to include the dynamic parameter name and type, e.g. the dynamic segment `['slug', 'blog', 'd']` previously turned into `'blog'` as the cache key, with these changes it turns into `'slug|blog|d'`. For static segments like `blog` in `app/blog/[name]` the key is still just `'blog'`. I've also refactored the cases where we read the segment as the code was duplicated in a few places. Closes NEXT-877 Fixes #47297 fix NEXT-877 --- .../src/client/components/layout-router.tsx | 48 ++++++++++--------- .../next/src/client/components/navigation.ts | 4 +- .../create-router-cache-key.test.ts | 19 ++++++++ .../router-reducer/create-router-cache-key.ts | 7 +++ .../fill-cache-with-new-subtree-data.ts | 11 +++-- .../fill-lazy-items-till-leaf-with-head.ts | 5 +- .../router-reducer/get-segment-value.test.ts | 17 +++++++ ...validate-cache-below-flight-segmentpath.ts | 11 +++-- .../invalidate-cache-by-router-state.ts | 5 +- .../reducers/find-head-in-cache.ts | 3 +- .../reducers/get-segment-value.ts | 5 ++ .../[slug]/page.tsx | 3 ++ .../blog/[slug]/page.tsx | 3 ++ test/e2e/app-dir/app-routes/app/layout.tsx | 16 +++++++ .../app/[slug]/page.tsx | 15 ++++++ .../app/blog/[slug]/page.tsx | 12 +++++ .../app/layout.tsx | 8 ++++ .../app/page.tsx | 34 +++++++++++++ .../next.config.js | 9 ++++ ...outer-stuck-dynamic-static-segment.test.ts | 22 +++++++++ .../tsconfig.json | 24 ++++++++++ 21 files changed, 241 insertions(+), 40 deletions(-) create mode 100644 packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts create mode 100644 packages/next/src/client/components/router-reducer/create-router-cache-key.ts create mode 100644 packages/next/src/client/components/router-reducer/get-segment-value.test.ts create mode 100644 packages/next/src/client/components/router-reducer/reducers/get-segment-value.ts create mode 100644 test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/[slug]/page.tsx create mode 100644 test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/blog/[slug]/page.tsx create mode 100644 test/e2e/app-dir/app-routes/app/layout.tsx create mode 100644 test/e2e/app-dir/router-stuck-dynamic-static-segment/app/[slug]/page.tsx create mode 100644 test/e2e/app-dir/router-stuck-dynamic-static-segment/app/blog/[slug]/page.tsx create mode 100644 test/e2e/app-dir/router-stuck-dynamic-static-segment/app/layout.tsx create mode 100644 test/e2e/app-dir/router-stuck-dynamic-static-segment/app/page.tsx create mode 100644 test/e2e/app-dir/router-stuck-dynamic-static-segment/next.config.js create mode 100644 test/e2e/app-dir/router-stuck-dynamic-static-segment/router-stuck-dynamic-static-segment.test.ts create mode 100644 test/e2e/app-dir/router-stuck-dynamic-static-segment/tsconfig.json diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index cff0a52fd66d9..dd52056039495 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -5,6 +5,7 @@ import type { FlightRouterState, FlightSegmentPath, ChildProp, + Segment, } from '../../server/app-render/types' import type { ErrorComponent } from './error-boundary' import { FocusAndScrollRef } from './router-reducer/router-reducer-types' @@ -24,6 +25,8 @@ import { matchSegment } from './match-segments' import { handleSmoothScroll } from '../../shared/lib/router/utils/handle-smooth-scroll' import { RedirectBoundary } from './redirect-boundary' import { NotFoundBoundary } from './not-found-boundary' +import { getSegmentValue } from './router-reducer/reducers/get-segment-value' +import { createRouterCacheKey } from './router-reducer/create-router-cache-key' /** * Add refetch marker to router state at the point of the current layout segment. @@ -230,7 +233,7 @@ function InnerLayoutRouter({ tree, // TODO-APP: implement `` when available. // isActive, - path, + cacheKey, }: { parallelRouterKey: string url: string @@ -239,7 +242,7 @@ function InnerLayoutRouter({ segmentPath: FlightSegmentPath tree: FlightRouterState isActive: boolean - path: string + cacheKey: ReturnType }) { const context = useContext(GlobalLayoutRouterContext) if (!context) { @@ -249,7 +252,7 @@ function InnerLayoutRouter({ const { changeByServerResponse, tree: fullTree, focusAndScrollRef } = context // Read segment path from the parallel router cache node. - let childNode = childNodes.get(path) + let childNode = childNodes.get(cacheKey) // If childProp is available this means it's the Flight / SSR case. if ( @@ -269,7 +272,7 @@ function InnerLayoutRouter({ } else { // Add the segment's subTreeData to the cache. // This writes to the cache when there is no item in the cache yet. It never *overwrites* existing cache items which is why it's safe in concurrent mode. - childNodes.set(path, { + childNodes.set(cacheKey, { status: CacheStates.READY, data: null, subTreeData: childProp.current, @@ -278,7 +281,7 @@ function InnerLayoutRouter({ // Mutates the prop in order to clean up the memory associated with the subTreeData as it is now part of the cache. childProp.current = null // In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again. - childNode = childNodes.get(path) + childNode = childNodes.get(cacheKey) } } @@ -293,7 +296,7 @@ function InnerLayoutRouter({ /** * Flight data fetch kicked off during render and put into the cache. */ - childNodes.set(path, { + childNodes.set(cacheKey, { status: CacheStates.DATA_FETCH, data: fetchServerResponse(new URL(url, location.origin), refetchTree), subTreeData: null, @@ -307,7 +310,7 @@ function InnerLayoutRouter({ : new Map(), }) // In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again. - childNode = childNodes.get(path) + childNode = childNodes.get(cacheKey) } // This case should never happen so it throws an error. It indicates there's a bug in the Next.js. @@ -461,24 +464,27 @@ export default function OuterLayoutRouter({ // The reason arrays are used in the data format is that these are transferred from the server to the browser so it's optimized to save bytes. const treeSegment = tree[1][parallelRouterKey][0] - const childPropSegment = Array.isArray(childProp.segment) - ? childProp.segment[1] - : childProp.segment + const childPropSegment = childProp.segment // If segment is an array it's a dynamic route and we want to read the dynamic route value as the segment to get from the cache. - const currentChildSegment = Array.isArray(treeSegment) - ? treeSegment[1] - : treeSegment + const currentChildSegmentValue = getSegmentValue(treeSegment) /** * Decides which segments to keep rendering, all segments that are not active will be wrapped in ``. */ // TODO-APP: Add handling of `` when it's available. - const preservedSegments: string[] = [currentChildSegment] + const preservedSegments: Segment[] = [treeSegment] return ( <> {preservedSegments.map((preservedSegment) => { + const isChildPropSegment = matchSegment( + preservedSegment, + childPropSegment + ) + const preservedSegmentValue = getSegmentValue(preservedSegment) + const cacheKey = createRouterCacheKey(preservedSegment) + return ( /* - Error boundary @@ -490,7 +496,7 @@ export default function OuterLayoutRouter({ - Passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch. */ diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts index 3b53a1abd0b27..85913c150b1bc 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -14,6 +14,7 @@ import { // LayoutSegmentsContext, } from '../../shared/lib/hooks-client-context' import { clientHookInServerComponentError } from './client-hook-in-server-component-error' +import { getSegmentValue } from './router-reducer/reducers/get-segment-value' const INTERNAL_URLSEARCHPARAMS_INSTANCE = Symbol( 'internal for urlsearchparams readonly' @@ -188,7 +189,8 @@ function getSelectedLayoutSegmentPath( if (!node) return segmentPath const segment = node[0] - const segmentValue = Array.isArray(segment) ? segment[1] : segment + + const segmentValue = getSegmentValue(segment) if (!segmentValue || segmentValue.startsWith('__PAGE__')) return segmentPath segmentPath.push(segmentValue) diff --git a/packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts b/packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts new file mode 100644 index 0000000000000..ac0fd7188aadd --- /dev/null +++ b/packages/next/src/client/components/router-reducer/create-router-cache-key.test.ts @@ -0,0 +1,19 @@ +import { createRouterCacheKey } from './create-router-cache-key' + +describe('createRouterCacheKey', () => { + it('should support string segment', () => { + expect(createRouterCacheKey('foo')).toEqual('foo') + }) + + it('should support dynamic segment', () => { + expect(createRouterCacheKey(['slug', 'hello-world', 'd'])).toEqual( + 'slug|hello-world|d' + ) + }) + + it('should support catch all segment', () => { + expect(createRouterCacheKey(['slug', 'blog/hello-world', 'c'])).toEqual( + 'slug|blog/hello-world|c' + ) + }) +}) diff --git a/packages/next/src/client/components/router-reducer/create-router-cache-key.ts b/packages/next/src/client/components/router-reducer/create-router-cache-key.ts new file mode 100644 index 0000000000000..094597bf6636b --- /dev/null +++ b/packages/next/src/client/components/router-reducer/create-router-cache-key.ts @@ -0,0 +1,7 @@ +import { Segment } from '../../../server/app-render/types' + +export function createRouterCacheKey(segment: Segment) { + return Array.isArray(segment) + ? `${segment[0]}|${segment[1]}|${segment[2]}` + : segment +} diff --git a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts index 120d6c9e8a72c..f45d556546990 100644 --- a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts +++ b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts @@ -2,6 +2,7 @@ import { CacheNode, CacheStates } from '../../../shared/lib/app-router-context' import type { FlightDataPath } from '../../../server/app-render/types' import { invalidateCacheByRouterState } from './invalidate-cache-by-router-state' import { fillLazyItemsTillLeafWithHead } from './fill-lazy-items-till-leaf-with-head' +import { createRouterCacheKey } from './create-router-cache-key' /** * Fill cache with subTreeData based on flightDataPath @@ -14,7 +15,7 @@ export function fillCacheWithNewSubTreeData( const isLastEntry = flightDataPath.length <= 5 const [parallelRouteKey, segment] = flightDataPath - const segmentForCache = Array.isArray(segment) ? segment[1] : segment + const cacheKey = createRouterCacheKey(segment) const existingChildSegmentMap = existingCache.parallelRoutes.get(parallelRouteKey) @@ -31,8 +32,8 @@ export function fillCacheWithNewSubTreeData( newCache.parallelRoutes.set(parallelRouteKey, childSegmentMap) } - const existingChildCacheNode = existingChildSegmentMap.get(segmentForCache) - let childCacheNode = childSegmentMap.get(segmentForCache) + const existingChildCacheNode = existingChildSegmentMap.get(cacheKey) + let childCacheNode = childSegmentMap.get(cacheKey) if (isLastEntry) { if ( @@ -65,7 +66,7 @@ export function fillCacheWithNewSubTreeData( flightDataPath[4] ) - childSegmentMap.set(segmentForCache, childCacheNode) + childSegmentMap.set(cacheKey, childCacheNode) } return } @@ -83,7 +84,7 @@ export function fillCacheWithNewSubTreeData( subTreeData: childCacheNode.subTreeData, parallelRoutes: new Map(childCacheNode.parallelRoutes), } as CacheNode - childSegmentMap.set(segmentForCache, childCacheNode) + childSegmentMap.set(cacheKey, childCacheNode) } fillCacheWithNewSubTreeData( diff --git a/packages/next/src/client/components/router-reducer/fill-lazy-items-till-leaf-with-head.ts b/packages/next/src/client/components/router-reducer/fill-lazy-items-till-leaf-with-head.ts index d54c91022f2c8..1d87c071b6398 100644 --- a/packages/next/src/client/components/router-reducer/fill-lazy-items-till-leaf-with-head.ts +++ b/packages/next/src/client/components/router-reducer/fill-lazy-items-till-leaf-with-head.ts @@ -1,5 +1,6 @@ import { CacheNode, CacheStates } from '../../../shared/lib/app-router-context' import type { FlightRouterState } from '../../../server/app-render/types' +import { createRouterCacheKey } from './create-router-cache-key' export function fillLazyItemsTillLeafWithHead( newCache: CacheNode, @@ -17,9 +18,7 @@ export function fillLazyItemsTillLeafWithHead( for (const key in routerState[1]) { const parallelRouteState = routerState[1][key] const segmentForParallelRoute = parallelRouteState[0] - const cacheKey = Array.isArray(segmentForParallelRoute) - ? segmentForParallelRoute[1] - : segmentForParallelRoute + const cacheKey = createRouterCacheKey(segmentForParallelRoute) if (existingCache) { if (cacheKey === '__DEFAULT__') { diff --git a/packages/next/src/client/components/router-reducer/get-segment-value.test.ts b/packages/next/src/client/components/router-reducer/get-segment-value.test.ts new file mode 100644 index 0000000000000..80aa1825ba828 --- /dev/null +++ b/packages/next/src/client/components/router-reducer/get-segment-value.test.ts @@ -0,0 +1,17 @@ +import { getSegmentValue } from './reducers/get-segment-value' + +describe('getSegmentValue', () => { + it('should support string segment', () => { + expect(getSegmentValue('foo')).toEqual('foo') + }) + + it('should support dynamic segment', () => { + expect(getSegmentValue(['slug', 'hello-world', 'd'])).toEqual('hello-world') + }) + + it('should support catch all segment', () => { + expect(getSegmentValue(['slug', 'blog/hello-world', 'c'])).toEqual( + 'blog/hello-world' + ) + }) +}) diff --git a/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.ts b/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.ts index 6db20cb0ccfc7..ac343f8d79679 100644 --- a/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.ts +++ b/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.ts @@ -1,5 +1,6 @@ import type { CacheNode } from '../../../shared/lib/app-router-context' import type { FlightSegmentPath } from '../../../server/app-render/types' +import { createRouterCacheKey } from './create-router-cache-key' /** * Fill cache up to the end of the flightSegmentPath, invalidating anything below it. @@ -12,7 +13,7 @@ export function invalidateCacheBelowFlightSegmentPath( const isLastEntry = flightSegmentPath.length <= 2 const [parallelRouteKey, segment] = flightSegmentPath - const segmentForCache = Array.isArray(segment) ? segment[1] : segment + const cacheKey = createRouterCacheKey(segment) const existingChildSegmentMap = existingCache.parallelRoutes.get(parallelRouteKey) @@ -31,12 +32,12 @@ export function invalidateCacheBelowFlightSegmentPath( // In case of last entry don't copy further down. if (isLastEntry) { - childSegmentMap.delete(segmentForCache) + childSegmentMap.delete(cacheKey) return } - const existingChildCacheNode = existingChildSegmentMap.get(segmentForCache) - let childCacheNode = childSegmentMap.get(segmentForCache) + const existingChildCacheNode = existingChildSegmentMap.get(cacheKey) + let childCacheNode = childSegmentMap.get(cacheKey) if (!childCacheNode || !existingChildCacheNode) { // Bailout because the existing cache does not have the path to the leaf node @@ -51,7 +52,7 @@ export function invalidateCacheBelowFlightSegmentPath( subTreeData: childCacheNode.subTreeData, parallelRoutes: new Map(childCacheNode.parallelRoutes), } as CacheNode - childSegmentMap.set(segmentForCache, childCacheNode) + childSegmentMap.set(cacheKey, childCacheNode) } invalidateCacheBelowFlightSegmentPath( diff --git a/packages/next/src/client/components/router-reducer/invalidate-cache-by-router-state.ts b/packages/next/src/client/components/router-reducer/invalidate-cache-by-router-state.ts index 3c0f6944dcad3..820e5909bf031 100644 --- a/packages/next/src/client/components/router-reducer/invalidate-cache-by-router-state.ts +++ b/packages/next/src/client/components/router-reducer/invalidate-cache-by-router-state.ts @@ -1,5 +1,6 @@ import type { CacheNode } from '../../../shared/lib/app-router-context' import type { FlightRouterState } from '../../../server/app-render/types' +import { createRouterCacheKey } from './create-router-cache-key' /** * Invalidate cache one level down from the router state. @@ -12,9 +13,7 @@ export function invalidateCacheByRouterState( // Remove segment that we got data for so that it is filled in during rendering of subTreeData. for (const key in routerState[1]) { const segmentForParallelRoute = routerState[1][key][0] - const cacheKey = Array.isArray(segmentForParallelRoute) - ? segmentForParallelRoute[1] - : segmentForParallelRoute + const cacheKey = createRouterCacheKey(segmentForParallelRoute) const existingParallelRoutesCacheNode = existingCache.parallelRoutes.get(key) if (existingParallelRoutesCacheNode) { diff --git a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts index 8d1652287654b..f4d5e768b9808 100644 --- a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts +++ b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts @@ -1,5 +1,6 @@ import type { FlightRouterState } from '../../../../server/app-render/types' import type { CacheNode } from '../../../../shared/lib/app-router-context' +import { createRouterCacheKey } from '../create-router-cache-key' export function findHeadInCache( cache: CacheNode, @@ -16,7 +17,7 @@ export function findHeadInCache( continue } - const cacheKey = Array.isArray(segment) ? segment[1] : segment + const cacheKey = createRouterCacheKey(segment) const cacheNode = childSegmentMap.get(cacheKey) if (!cacheNode) { diff --git a/packages/next/src/client/components/router-reducer/reducers/get-segment-value.ts b/packages/next/src/client/components/router-reducer/reducers/get-segment-value.ts new file mode 100644 index 0000000000000..52000f5578bda --- /dev/null +++ b/packages/next/src/client/components/router-reducer/reducers/get-segment-value.ts @@ -0,0 +1,5 @@ +import { Segment } from '../../../../server/app-render/types' + +export function getSegmentValue(segment: Segment) { + return Array.isArray(segment) ? segment[1] : segment +} diff --git a/test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/[slug]/page.tsx b/test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/[slug]/page.tsx new file mode 100644 index 0000000000000..816bbea9cae28 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/[slug]/page.tsx @@ -0,0 +1,3 @@ +export default function Page({ params }) { + return

Page {params.slug}

+} diff --git a/test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/blog/[slug]/page.tsx b/test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/blog/[slug]/page.tsx new file mode 100644 index 0000000000000..72b7591a8ad4a --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/conflicting-dynamic-static-segments/blog/[slug]/page.tsx @@ -0,0 +1,3 @@ +export default function Page({ params }) { + return

Blog Sub Page {params.slug}

+} diff --git a/test/e2e/app-dir/app-routes/app/layout.tsx b/test/e2e/app-dir/app-routes/app/layout.tsx new file mode 100644 index 0000000000000..a14e64fcd5e33 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/[slug]/page.tsx b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/[slug]/page.tsx new file mode 100644 index 0000000000000..71aa0623bdcc8 --- /dev/null +++ b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/[slug]/page.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link' + +export default function Page({ params }) { + return ( +
+

Visiting page {params.slug}

+ + Go to a post + + + Go home + +
+ ) +} diff --git a/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/blog/[slug]/page.tsx b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/blog/[slug]/page.tsx new file mode 100644 index 0000000000000..ac82ad80adb71 --- /dev/null +++ b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/blog/[slug]/page.tsx @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Blog({ params }) { + return ( +
+

Blog post {params.slug}

+ + Go home + +
+ ) +} diff --git a/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/layout.tsx b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/layout.tsx new file mode 100644 index 0000000000000..6d7e1ed585862 --- /dev/null +++ b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/layout.tsx @@ -0,0 +1,8 @@ +export default function RootLayout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/page.tsx b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/page.tsx new file mode 100644 index 0000000000000..64b89e0195591 --- /dev/null +++ b/test/e2e/app-dir/router-stuck-dynamic-static-segment/app/page.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link' + +/** Add your relevant code here for the issue to reproduce */ +export default function Home() { + return ( +
+

Subpage linking issue reproduction

+

Reproducing:

+
    +
  1. Press the "Go to blog" link
  2. +
  3. Press the "Go to post" link
  4. +
  5. + Expected behavior: link should take you to the blog post +
  6. +
  7. + Actual behavior: the browser fetches the data for the page + but never navigates +
  8. +
+

+ Reloading and pressing the "Go to another page" link and then going to + the blog post does work however, suggesting the issue is navigating from{' '} + /blog to /blog/a-post (/[slug]{' '} + where slug is blog and /blog/[slug] where slug is a-post) +

+ + Go to blog + + + Go to another page + +
+ ) +} diff --git a/test/e2e/app-dir/router-stuck-dynamic-static-segment/next.config.js b/test/e2e/app-dir/router-stuck-dynamic-static-segment/next.config.js new file mode 100644 index 0000000000000..dafd16a82547c --- /dev/null +++ b/test/e2e/app-dir/router-stuck-dynamic-static-segment/next.config.js @@ -0,0 +1,9 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + reactStrictMode: true, + experimental: { appDir: true }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/router-stuck-dynamic-static-segment/router-stuck-dynamic-static-segment.test.ts b/test/e2e/app-dir/router-stuck-dynamic-static-segment/router-stuck-dynamic-static-segment.test.ts new file mode 100644 index 0000000000000..8dfd9e755b447 --- /dev/null +++ b/test/e2e/app-dir/router-stuck-dynamic-static-segment/router-stuck-dynamic-static-segment.test.ts @@ -0,0 +1,22 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'router-stuck-dynamic-static-segment', + { + files: __dirname, + }, + ({ next }) => { + // Checks that you can navigate from `/[slug]` where `slug` is `blog` to `/blog/[slug]` where `slug` is `a-post`. + it('should allow navigation between dynamic parameter and static parameter of the same value', async () => { + const browser = await next.browser('/') + await browser + .elementByCss('#to-blog') + .click() + .waitForElementByCss('#slug-page') + .elementByCss('#to-blog-post') + .click() + .waitForElementByCss('#blog-post-page') + expect(await browser.elementByCss('h1').text()).toBe('Blog post a-post') + }) + } +) diff --git a/test/e2e/app-dir/router-stuck-dynamic-static-segment/tsconfig.json b/test/e2e/app-dir/router-stuck-dynamic-static-segment/tsconfig.json new file mode 100644 index 0000000000000..d2bc2ac5e3cea --- /dev/null +++ b/test/e2e/app-dir/router-stuck-dynamic-static-segment/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}