diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index e6011fcf77a29..fa6e25a6388f1 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -15,7 +15,7 @@ import type { import type { FlightRouterState, FlightData } from '../../server/app-render' import { ACTION_NAVIGATE, - // ACTION_PREFETCH, + ACTION_PREFETCH, ACTION_RELOAD, ACTION_RESTORE, ACTION_SERVER_PATCH, @@ -97,7 +97,7 @@ function ErrorOverlay({ children }: PropsWithChildren<{}>): ReactElement { let initialParallelRoutes: CacheNode['parallelRoutes'] = typeof window === 'undefined' ? null! : new Map() -// const prefetched = new Set() +const prefetched = new Set() /** * The global router that wraps the application components. @@ -208,32 +208,34 @@ export default function AppRouter({ const routerInstance: AppRouterInstance = { // TODO-APP: implement prefetching of flight - prefetch: async (_href) => { + prefetch: async (href) => { // If prefetch has already been triggered, don't trigger it again. - // if (prefetched.has(href)) { - // return - // } - // prefetched.add(href) - // const url = new URL(href, location.origin) - // try { - // // TODO-APP: handle case where history.state is not the new router history entry - // const serverResponse = await fetchServerResponse( - // url, - // // initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case. - // window.history.state?.tree || initialTree, - // true - // ) - // // @ts-ignore startTransition exists - // React.startTransition(() => { - // dispatch({ - // type: ACTION_PREFETCH, - // url, - // serverResponse, - // }) - // }) - // } catch (err) { - // console.error('PREFETCH ERROR', err) - // } + if (prefetched.has(href)) { + return + } + prefetched.add(href) + const url = new URL(href, location.origin) + try { + const routerTree = window.history.state?.tree || initialTree + // TODO-APP: handle case where history.state is not the new router history entry + const serverResponse = await fetchServerResponse( + url, + // initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case. + routerTree, + true + ) + // @ts-ignore startTransition exists + React.startTransition(() => { + dispatch({ + type: ACTION_PREFETCH, + url, + tree: routerTree, + serverResponse, + }) + }) + } catch (err) { + console.error('PREFETCH ERROR', err) + } }, replace: (href, options = {}) => { // @ts-ignore startTransition exists @@ -266,7 +268,7 @@ export default function AppRouter({ } return routerInstance - }, [dispatch /*, initialTree*/]) + }, [dispatch, initialTree]) useEffect(() => { // When mpaNavigation flag is set do a hard navigation to the new url. diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index ed96a6fb390f8..447a82f15d3dd 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -29,27 +29,7 @@ import { import { fetchServerResponse } from './app-router.client' import { createInfinitePromise } from './infinite-promise' -// import { matchSegment } from './match-segments' - -/** - * Check if every segment in array a and b matches - */ -// function equalSegmentPaths(a: Segment[], b: Segment[]) { -// // Comparing length is a fast path. -// return a.length === b.length && a.every((val, i) => matchSegment(val, b[i])) -// } - -/** - * Check if flightDataPath matches layoutSegmentPath - */ -// function segmentPathMatches( -// flightDataPath: FlightDataPath, -// layoutSegmentPath: FlightSegmentPath -// ): boolean { -// // The last three items are the current segment, tree, and subTreeData -// const pathToLayout = flightDataPath.slice(0, -3) -// return equalSegmentPaths(layoutSegmentPath, pathToLayout) -// } +import { matchSegment } from './match-segments' /** * Add refetch marker to router state at the point of the current layout segment. @@ -63,7 +43,7 @@ function walkAddRefetch( const [segment, parallelRouteKey] = segmentPathToWalk const isLast = segmentPathToWalk.length === 2 - if (treeToRecreate[0] === segment) { + if (matchSegment(treeToRecreate[0], segment)) { if (treeToRecreate[1].hasOwnProperty(parallelRouteKey)) { if (isLast) { const subTree = walkAddRefetch( diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index b1bb80536369c..ff7517af44a6d 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -590,6 +590,7 @@ interface ServerPatchAction { interface PrefetchAction { type: typeof ACTION_PREFETCH url: URL + tree: FlightRouterState serverResponse: Awaited> } @@ -627,7 +628,7 @@ type AppRouterState = { string, { flightSegmentPath: FlightSegmentPath - treePatch: FlightRouterState + tree: FlightRouterState canonicalUrlOverride: URL | undefined } > @@ -691,16 +692,11 @@ function clientReducer( const prefetchValues = state.prefetchCache.get(href) if (prefetchValues) { // The one before last item is the router state tree patch - const { flightSegmentPath, treePatch, canonicalUrlOverride } = - prefetchValues - - // Create new tree based on the flightSegmentPath and router state patch - const newTree = applyRouterStatePatchToTree( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - state.tree, - treePatch - ) + const { + flightSegmentPath, + tree: newTree, + canonicalUrlOverride, + } = prefetchValues if (newTree !== null) { mutable.previousTree = state.tree @@ -1130,11 +1126,26 @@ function clientReducer( fillCacheWithPrefetchedSubTreeData(state.cache, flightDataPath) } + const flightSegmentPath = flightDataPath.slice(0, -2) + + const newTree = applyRouterStatePatchToTree( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + state.tree, + treePatch + ) + + // Patch did not apply correctly + if (newTree === null) { + return state + } + // Create new tree based on the flightSegmentPath and router state patch state.prefetchCache.set(href, { // Path without the last segment, router state, and the subTreeData - flightSegmentPath: flightDataPath.slice(0, -2), - treePatch, + flightSegmentPath, + // Create new tree based on the flightSegmentPath and router state patch + tree: newTree, canonicalUrlOverride, }) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 98421fb1fed11..5697cef3dfcda 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -1126,12 +1126,21 @@ export async function renderToHTMLOrFlight( * Use router state to decide at what common layout to render the page. * This can either be the common layout between two pages or a specific place to start rendering from using the "refetch" marker in the tree. */ - const walkTreeWithFlightRouterState = async ( - loaderTreeToFilter: LoaderTree, - parentParams: { [key: string]: string | string[] }, - flightRouterState?: FlightRouterState, + const walkTreeWithFlightRouterState = async ({ + createSegmentPath, + loaderTreeToFilter, + parentParams, + isFirst, + flightRouterState, + parentRendered, + }: { + createSegmentPath: CreateSegmentPath + loaderTreeToFilter: LoaderTree + parentParams: { [key: string]: string | string[] } + isFirst: boolean + flightRouterState?: FlightRouterState parentRendered?: boolean - ): Promise => { + }): Promise => { const [segment, parallelRoutes] = loaderTreeToFilter const parallelRoutesKeys = Object.keys(parallelRoutes) @@ -1176,10 +1185,12 @@ export async function renderToHTMLOrFlight( await createComponentTree( // This ensures flightRouterPath is valid and filters down the tree { - createSegmentPath: (child) => child, + createSegmentPath: (child) => { + return createSegmentPath(child) + }, loaderTree: loaderTreeToFilter, parentParams: currentParams, - firstItem: true, + firstItem: isFirst, } ) ).Component @@ -1190,12 +1201,22 @@ export async function renderToHTMLOrFlight( // Walk through all parallel routes. for (const parallelRouteKey of parallelRoutesKeys) { const parallelRoute = parallelRoutes[parallelRouteKey] - const path = await walkTreeWithFlightRouterState( - parallelRoute, - currentParams, - flightRouterState && flightRouterState[1][parallelRouteKey], - parentRendered || renderComponentsOnThisLevel - ) + + const currentSegmentPath: FlightSegmentPath = isFirst + ? [parallelRouteKey] + : [actualSegment, parallelRouteKey] + + const path = await walkTreeWithFlightRouterState({ + createSegmentPath: (child) => { + return createSegmentPath([...currentSegmentPath, ...child]) + }, + loaderTreeToFilter: parallelRoute, + parentParams: currentParams, + flightRouterState: + flightRouterState && flightRouterState[1][parallelRouteKey], + parentRendered: parentRendered || renderComponentsOnThisLevel, + isFirst: false, + }) if (typeof path[path.length - 1] !== 'string') { return [actualSegment, parallelRouteKey, ...path] @@ -1210,11 +1231,13 @@ export async function renderToHTMLOrFlight( const flightData: FlightData = [ // TODO-APP: change walk to output without '' ( - await walkTreeWithFlightRouterState( - loaderTree, - {}, - providedFlightRouterState - ) + await walkTreeWithFlightRouterState({ + createSegmentPath: (child) => child, + loaderTreeToFilter: loaderTree, + parentParams: {}, + flightRouterState: providedFlightRouterState, + isFirst: true, + }) ).slice(1), ] diff --git a/test/e2e/app-dir/app/app/nested-navigation/CategoryNav.js b/test/e2e/app-dir/app/app/nested-navigation/CategoryNav.js new file mode 100644 index 0000000000000..718d82ec07ffd --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/CategoryNav.js @@ -0,0 +1,28 @@ +'client' + +import { TabNavItem } from './TabNavItem' +import { useSelectedLayoutSegment } from 'next/dist/client/components/hooks-client' + +const CategoryNav = ({ categories }) => { + const selectedLayoutSegment = useSelectedLayoutSegment() + + return ( +
+ + Home + + + {categories.map((item) => ( + + {item.name} + + ))} +
+ ) +} + +export default CategoryNav diff --git a/test/e2e/app-dir/app/app/nested-navigation/TabNavItem.js b/test/e2e/app-dir/app/app/nested-navigation/TabNavItem.js new file mode 100644 index 0000000000000..3e1cbf3dd95dd --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/TabNavItem.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export const TabNavItem = ({ children, href }) => { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/SubCategoryNav.js b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/SubCategoryNav.js new file mode 100644 index 0000000000000..4118c34c77fc4 --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/SubCategoryNav.js @@ -0,0 +1,31 @@ +'client' + +import { TabNavItem } from '../TabNavItem' +import { useSelectedLayoutSegment } from 'next/dist/client/components/hooks-client' + +const SubCategoryNav = ({ category }) => { + const selectedLayoutSegment = useSelectedLayoutSegment() + + return ( +
+ + All + + + {category.items.map((item) => ( + + {item.name} + + ))} +
+ ) +} + +export default SubCategoryNav diff --git a/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/[subCategorySlug]/page.js b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/[subCategorySlug]/page.js new file mode 100644 index 0000000000000..a43f15792c91c --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/[subCategorySlug]/page.js @@ -0,0 +1,11 @@ +import { experimental_use as use } from 'react' +import { fetchSubCategory } from '../../getCategories' + +export default function Page({ params }) { + const category = use( + fetchSubCategory(params.categorySlug, params.subCategorySlug) + ) + if (!category) return null + + return

{category.name}

+} diff --git a/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/layout.js b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/layout.js new file mode 100644 index 0000000000000..63bee78e8711e --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/layout.js @@ -0,0 +1,14 @@ +import { experimental_use as use } from 'react' +import { fetchCategoryBySlug } from '../getCategories' +import SubCategoryNav from './SubCategoryNav' + +export default function Layout({ children, params }) { + const category = use(fetchCategoryBySlug(params.categorySlug)) + if (!category) return null + return ( + <> + + {children} + + ) +} diff --git a/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/page.js b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/page.js new file mode 100644 index 0000000000000..b2e0a866c03fc --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/[categorySlug]/page.js @@ -0,0 +1,9 @@ +import { experimental_use as use } from 'react' +import { fetchCategoryBySlug } from '../getCategories' + +export default function Page({ params }) { + const category = use(fetchCategoryBySlug(params.categorySlug)) + if (!category) return null + + return

All {category.name}

+} diff --git a/test/e2e/app-dir/app/app/nested-navigation/getCategories.js b/test/e2e/app-dir/app/app/nested-navigation/getCategories.js new file mode 100644 index 0000000000000..c5da031c72b3f --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/getCategories.js @@ -0,0 +1,50 @@ +export const getCategories = () => [ + { + name: 'Electronics', + slug: 'electronics', + count: 11, + items: [ + { name: 'Phones', slug: 'phones', count: 4 }, + { name: 'Tablets', slug: 'tablets', count: 5 }, + { name: 'Laptops', slug: 'laptops', count: 2 }, + ], + }, + { + name: 'Clothing', + slug: 'clothing', + count: 12, + items: [ + { name: 'Tops', slug: 'tops', count: 3 }, + { name: 'Shorts', slug: 'shorts', count: 4 }, + { name: 'Shoes', slug: 'shoes', count: 5 }, + ], + }, + { + name: 'Books', + slug: 'books', + count: 10, + items: [ + { name: 'Fiction', slug: 'fiction', count: 5 }, + { name: 'Biography', slug: 'biography', count: 2 }, + { name: 'Education', slug: 'education', count: 3 }, + ], + }, +] + +export async function fetchCategoryBySlug(slug) { + // Assuming it always return expected categories + return getCategories().find((category) => category.slug === slug) +} + +export async function fetchCategories() { + return getCategories() +} + +async function findSubCategory(category, subCategorySlug) { + return category?.items.find((category) => category.slug === subCategorySlug) +} + +export async function fetchSubCategory(categorySlug, subCategorySlug) { + const category = await fetchCategoryBySlug(categorySlug) + return findSubCategory(category, subCategorySlug) +} diff --git a/test/e2e/app-dir/app/app/nested-navigation/layout.js b/test/e2e/app-dir/app/app/nested-navigation/layout.js new file mode 100644 index 0000000000000..43a2a79ec9165 --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/layout.js @@ -0,0 +1,17 @@ +import { experimental_use as use } from 'react' +import { fetchCategories } from './getCategories' +import React from 'react' +import CategoryNav from './CategoryNav' + +export default function Layout({ children }) { + const categories = use(fetchCategories()) + return ( +
+
+ +
+ +
{children}
+
+ ) +} diff --git a/test/e2e/app-dir/app/app/nested-navigation/page.js b/test/e2e/app-dir/app/app/nested-navigation/page.js new file mode 100644 index 0000000000000..9833f2b17c471 --- /dev/null +++ b/test/e2e/app-dir/app/app/nested-navigation/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Home

+} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 40ffd322f0c8b..631a255ecb8a7 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -432,7 +432,7 @@ describe('app dir', () => { }) // TODO-APP: Re-enable this test. - it.skip('should soft push', async () => { + it('should soft push', async () => { const browser = await webdriver(next.url, '/link-soft-push') try { @@ -1637,6 +1637,43 @@ describe('app dir', () => { }) }) }) + + describe('nested navigation', () => { + it('should navigate to nested pages', async () => { + const browser = await webdriver(next.url, '/nested-navigation') + expect(await browser.elementByCss('h1').text()).toBe('Home') + + const pages = [ + ['Electronics', ['Phones', 'Tablets', 'Laptops']], + ['Clothing', ['Tops', 'Shorts', 'Shoes']], + ['Books', ['Fiction', 'Biography', 'Education']], + ] as const + + for (const [category, subCategories] of pages) { + expect( + await browser + .elementByCss( + `a[href="/nested-navigation/${category.toLowerCase()}"]` + ) + .click() + .waitForElementByCss(`#all-${category.toLowerCase()}`) + .text() + ).toBe(`All ${category}`) + + for (const subcategory of subCategories) { + expect( + await browser + .elementByCss( + `a[href="/nested-navigation/${category.toLowerCase()}/${subcategory.toLowerCase()}"]` + ) + .click() + .waitForElementByCss(`#${subcategory.toLowerCase()}`) + .text() + ).toBe(`${subcategory}`) + } + } + }) + }) } runTests()