From 71ad0dd0b0f69c56215f7f0bbda798139ffb3d40 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 19:29:09 +0200 Subject: [PATCH] Add prefetch to new router (#39866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #37551 Implements prefetching for the new router. There are multiple behaviors related to prefetching so I've split them out for each case. The list below each case is what's prefetched: Reference: - Checkmark checked → it's implemented. - RSC Payload → Rendered server components. - Router state → Patch for the router history state. - Preloads for client component entry → This will be handled in a follow-up PR. - No `loading.js` static case → Will be handled in a follow-up PR. --- - `prefetch={true}` (default, same as current router, links in viewport are prefetched) - [x] Static all the way down the component tree - [x] RSC payload - [x] Router state - [ ] preloads for the client component entry - [x] Not static all the way down the component tree - [x] With `loading.js` - [x] RSC payload up until the loading below the common layout - [x] router state - [ ] preloads for the client component entry - [x] No `loading.js` (This case can be static files to make sure it’s fast) - [x] router state - [ ] preloads for the client component entry - `prefetch={false}` - [x] always do an optimistic navigation. We already have this implemented where it tries to figure out the router state based on the provided url. That result might be wrong but the router will automatically figure out that --- In the first implementation there is a distinction between `hard` and `soft` navigation. With the addition of prefetching you no longer have to add a `soft` prop to `next/link` in order to leverage the `soft` case. A heuristic has been added that automatically prefers `soft` navigation except when navigating between mismatching dynamic parameters. An example: - `app/[userOrTeam]/dashboard/page.js` and `app/[userOrTeam]/dashboard/settings/page.js` - `/tim/dashboard` → `/tim/dashboard/settings` = Soft navigation - `/tim/dashboard` → `/vercel/dashboard` = Hard navigation - `/vercel/dashboard` → `/vercel/dashboard/settings` = Soft navigation - `/vercel/dashboard/settings` -> `/tim/dashboard` = Hard navigation --- While adding these new heuristics some of the tests started failing and I found some state bugs in `router.reload()` which have been fixed. An example being when you push to `/dashboard` while on `/` in the same transition it would navigate to `/`, it also wouldn't push a new history entry. Both of these cases are now fixed: ``` React.startTransition(() => { router.push('/dashboard') router.reload() }) ``` --- While debugging the various changes I ended up debugging and manually diffing the cache and router state quite often and was looking at a way to automate this. `useReducer` is quite similar to Redux so I was wondering if Redux Devtools could be used in order to debug the various actions as it has diffing built-in. It took a bit of time to figure out the connection mechanism but in the end I figured out how to connect `useReducer`, a new hook `useReducerWithReduxDevtools` has been added, we'll probably want to put this behind a compile-time flag when the new router is marked stable but until then it's useful to have it enabled by default (only when you have Redux Devtools installed ofcourse). > ⚠️ Redux Devtools is only connected to take incoming actions / state. Time travel and other features are not supported because the state sent to the devtools is normalized to allow diffing the maps, you can't move backward based on that state so applying the state is not connected. Example of the integration: Screen Shot 2022-09-02 at 10 00 40 --- .../client/components/app-router.client.tsx | 160 +++-- .../components/layout-router.client.tsx | 173 ++--- packages/next/client/components/reducer.ts | 680 ++++++++++++------ .../components/use-reducer-with-devtools.ts | 160 +++++ packages/next/client/link.tsx | 55 +- packages/next/server/app-render.tsx | 75 +- .../next/shared/lib/app-router-context.ts | 22 +- .../app/dashboard/[id]/page.server.js | 26 + .../app/dashboard/layout.server.js | 16 + .../app-prefetch/app/dashboard/loading.js | 3 + .../app-prefetch/app/dashboard/page.server.js | 15 + .../app-dir/app-prefetch/app/layout.server.js | 10 + test/e2e/app-dir/app-prefetch/app/loading.js | 3 + .../app-dir/app-prefetch/app/page.server.js | 8 + test/e2e/app-dir/app-prefetch/next.config.js | 8 + test/e2e/app-dir/app-prefetch/pages/.gitkeep | 0 .../app/link-hard-push/[id]/page.server.js | 14 + .../app/app/link-hard-push/page.server.js | 9 - .../app/link-hard-replace/[id]/page.server.js | 14 + .../app/app/link-soft-push/page.server.js | 2 +- .../app/app/link-soft-replace/page.server.js | 4 +- .../link-soft-replace/subpage/page.server.js | 2 +- .../app-dir/app/app/navigation/link.client.js | 24 + .../app-dir/app/app/navigation/page.server.js | 10 +- test/e2e/app-dir/index.test.ts | 40 +- test/e2e/app-dir/rsc-basic.test.ts | 12 +- .../app/next-api/link/page.server.js | 1 - test/lib/next-webdriver.ts | 4 +- 28 files changed, 1068 insertions(+), 482 deletions(-) create mode 100644 packages/next/client/components/use-reducer-with-devtools.ts create mode 100644 test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.server.js create mode 100644 test/e2e/app-dir/app-prefetch/app/dashboard/layout.server.js create mode 100644 test/e2e/app-dir/app-prefetch/app/dashboard/loading.js create mode 100644 test/e2e/app-dir/app-prefetch/app/dashboard/page.server.js create mode 100644 test/e2e/app-dir/app-prefetch/app/layout.server.js create mode 100644 test/e2e/app-dir/app-prefetch/app/loading.js create mode 100644 test/e2e/app-dir/app-prefetch/app/page.server.js create mode 100644 test/e2e/app-dir/app-prefetch/next.config.js create mode 100644 test/e2e/app-dir/app-prefetch/pages/.gitkeep create mode 100644 test/e2e/app-dir/app/app/link-hard-push/[id]/page.server.js delete mode 100644 test/e2e/app-dir/app/app/link-hard-push/page.server.js create mode 100644 test/e2e/app-dir/app/app/link-hard-replace/[id]/page.server.js create mode 100644 test/e2e/app-dir/app/app/navigation/link.client.js diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 2899be27bb24..4f23c4907395 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -1,4 +1,5 @@ -import React, { useEffect } from 'react' +import type { PropsWithChildren, ReactElement, ReactNode } from 'react' +import React, { useEffect, useMemo, useCallback } from 'react' import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack' import { AppRouterContext, @@ -12,6 +13,7 @@ import type { import type { FlightRouterState, FlightData } from '../../server/app-render' import { ACTION_NAVIGATE, + ACTION_PREFETCH, ACTION_RELOAD, ACTION_RESTORE, ACTION_SERVER_PATCH, @@ -23,13 +25,15 @@ import { PathnameContext, // LayoutSegmentsContext, } from './hooks-client-context' +import { useReducerWithReduxDevtools } from './use-reducer-with-devtools' /** * Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side. */ function fetchFlight( url: URL, - flightRouterState: FlightRouterState + flightRouterState: FlightRouterState, + prefetch?: true ): ReadableStream { const flightUrl = new URL(url) const searchParams = flightUrl.searchParams @@ -40,6 +44,9 @@ function fetchFlight( '__flight_router_state_tree__', JSON.stringify(flightRouterState) ) + if (prefetch) { + searchParams.append('__flight_prefetch__', '1') + } // TODO-APP: Verify that TransformStream is supported. const { readable, writable } = new TransformStream() @@ -56,18 +63,17 @@ function fetchFlight( */ export function fetchServerResponse( url: URL, - flightRouterState: FlightRouterState + flightRouterState: FlightRouterState, + prefetch?: true ): { readRoot: () => FlightData } { // Handle the `fetch` readable stream that can be read using `readRoot`. - return createFromReadableStream(fetchFlight(url, flightRouterState)) + return createFromReadableStream(fetchFlight(url, flightRouterState, prefetch)) } /** * Renders development error overlay when NODE_ENV is development. */ -function ErrorOverlay({ - children, -}: React.PropsWithChildren<{}>): React.ReactElement { +function ErrorOverlay({ children }: PropsWithChildren<{}>): ReactElement { if (process.env.NODE_ENV === 'production') { return <>{children} } else { @@ -83,6 +89,8 @@ function ErrorOverlay({ let initialParallelRoutes: CacheNode['parallelRoutes'] = typeof window === 'undefined' ? null! : new Map() +const prefetched = new Set() + /** * The global router that wraps the application components. */ @@ -94,11 +102,11 @@ export default function AppRouter({ }: { initialTree: FlightRouterState initialCanonicalUrl: string - children: React.ReactNode - hotReloader?: React.ReactNode + children: ReactNode + hotReloader?: ReactNode }) { - const [{ tree, cache, pushRef, focusAndScrollRef, canonicalUrl }, dispatch] = - React.useReducer(reducer, { + const initialState = useMemo(() => { + return { tree: initialTree, cache: { data: null, @@ -106,6 +114,7 @@ export default function AppRouter({ parallelRoutes: typeof window === 'undefined' ? new Map() : initialParallelRoutes, }, + prefetchCache: new Map(), pushRef: { pendingPush: false, mpaNavigation: false }, focusAndScrollRef: { apply: false }, canonicalUrl: @@ -113,7 +122,13 @@ export default function AppRouter({ // Hash is read as the initial value for canonicalUrl in the browser // This is safe to do as canonicalUrl can't be rendered, it's only used to control the history updates the useEffect further down. (typeof window !== 'undefined' ? window.location.hash : ''), - }) + } + }, [children, initialCanonicalUrl, initialTree]) + const [ + { tree, cache, prefetchCache, pushRef, focusAndScrollRef, canonicalUrl }, + dispatch, + sync, + ] = useReducerWithReduxDevtools(reducer, initialState) useEffect(() => { // Ensure initialParallelRoutes is cleaned up from memory once it's used. @@ -121,7 +136,7 @@ export default function AppRouter({ }, []) // Add memoized pathname/query for useSearchParams and usePathname. - const { searchParams, pathname } = React.useMemo(() => { + const { searchParams, pathname } = useMemo(() => { const url = new URL( canonicalUrl, typeof window === 'undefined' ? 'http://n' : window.location.href @@ -138,7 +153,7 @@ export default function AppRouter({ /** * Server response that only patches the cache and tree. */ - const changeByServerResponse = React.useCallback( + const changeByServerResponse = useCallback( (previousTree: FlightRouterState, flightData: FlightData) => { dispatch({ type: ACTION_SERVER_PATCH, @@ -149,24 +164,25 @@ export default function AppRouter({ subTreeData: null, parallelRoutes: new Map(), }, + mutable: {}, }) }, - [] + [dispatch] ) /** * The app router that is exposed through `useRouter`. It's only concerned with dispatching actions to the reducer, does not hold state. */ - const appRouter = React.useMemo(() => { + const appRouter = useMemo(() => { const navigate = ( href: string, - cacheType: 'hard' | 'soft', - navigateType: 'push' | 'replace' + navigateType: 'push' | 'replace', + forceOptimisticNavigation: boolean ) => { return dispatch({ type: ACTION_NAVIGATE, url: new URL(href, location.origin), - cacheType, + forceOptimisticNavigation, navigateType, cache: { data: null, @@ -179,29 +195,47 @@ export default function AppRouter({ const routerInstance: AppRouterInstance = { // TODO-APP: implement prefetching of flight - prefetch: (_href) => Promise.resolve(), - replace: (href) => { - // @ts-ignore startTransition exists - React.startTransition(() => { - navigate(href, 'hard', 'replace') - }) - }, - softReplace: (href) => { - // @ts-ignore startTransition exists - React.startTransition(() => { - navigate(href, 'soft', 'replace') - }) + 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) + // TODO-APP: handle case where history.state is not the new router history entry + const r = 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 + ) + try { + r.readRoot() + } catch (e) { + await e + const flightData = r.readRoot() + // @ts-ignore startTransition exists + React.startTransition(() => { + dispatch({ + type: ACTION_PREFETCH, + url, + flightData, + }) + }) + } }, - softPush: (href) => { + replace: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'soft', 'push') + navigate(href, 'replace', Boolean(options.forceOptimisticNavigation)) }) }, - push: (href) => { + push: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'hard', 'push') + navigate(href, 'push', Boolean(options.forceOptimisticNavigation)) }) }, reload: () => { @@ -211,7 +245,6 @@ export default function AppRouter({ type: ACTION_RELOAD, // TODO-APP: revisit if this needs to be passed. - url: new URL(window.location.href), cache: { data: null, subTreeData: null, @@ -224,7 +257,7 @@ export default function AppRouter({ } return routerInstance - }, []) + }, [dispatch, initialTree]) useEffect(() => { // When mpaNavigation flag is set do a hard navigation to the new url. @@ -245,13 +278,15 @@ export default function AppRouter({ } else { window.history.replaceState(historyState, '', canonicalUrl) } - }, [tree, pushRef, canonicalUrl]) + + sync() + }, [tree, pushRef, canonicalUrl, sync]) // Add `window.nd` for debugging purposes. // This is not meant for use in applications as concurrent rendering will affect the cache/tree/router. if (typeof window !== 'undefined') { // @ts-ignore this is for debugging - window.nd = { router: appRouter, cache, tree } + window.nd = { router: appRouter, cache, prefetchCache, tree } } /** @@ -259,33 +294,36 @@ export default function AppRouter({ * By default dispatches ACTION_RESTORE, however if the history entry was not pushed/replaced by app-router it will reload the page. * That case can happen when the old router injected the history entry. */ - const onPopState = React.useCallback(({ state }: PopStateEvent) => { - if (!state) { - // TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case. - return - } + const onPopState = useCallback( + ({ state }: PopStateEvent) => { + if (!state) { + // TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case. + return + } - // TODO-APP: this case happens when pushState/replaceState was called outside of Next.js or when the history entry was pushed by the old router. - // It reloads the page in this case but we might have to revisit this as the old router ignores it. - if (!state.__NA) { - window.location.reload() - return - } + // TODO-APP: this case happens when pushState/replaceState was called outside of Next.js or when the history entry was pushed by the old router. + // It reloads the page in this case but we might have to revisit this as the old router ignores it. + if (!state.__NA) { + window.location.reload() + return + } - // @ts-ignore useTransition exists - // TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously - // Without startTransition works if the cache is there for this path - React.startTransition(() => { - dispatch({ - type: ACTION_RESTORE, - url: new URL(window.location.href), - tree: state.tree, + // @ts-ignore useTransition exists + // TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously + // Without startTransition works if the cache is there for this path + React.startTransition(() => { + dispatch({ + type: ACTION_RESTORE, + url: new URL(window.location.href), + tree: state.tree, + }) }) - }) - }, []) + }, + [dispatch] + ) // Register popstate event to call onPopstate. - React.useEffect(() => { + useEffect(() => { window.addEventListener('popstate', onPopState) return () => { window.removeEventListener('popstate', onPopState) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index bffb974c706e..63e7aa083c33 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -1,36 +1,89 @@ import React, { useContext, useEffect, useRef } from 'react' -import type { ChildProp, Segment } from '../../server/app-render' +import type { + ChildProp, + //Segment +} from '../../server/app-render' import type { ChildSegmentMap } from '../../shared/lib/app-router-context' import type { FlightRouterState, FlightSegmentPath, - FlightDataPath, + // FlightDataPath, } from '../../server/app-render' import { LayoutRouterContext, GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import { fetchServerResponse } from './app-router.client' -import { matchSegment } from './match-segments' +// 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])) -} +// 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) +// 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) +// } + +/** + * Add refetch marker to router state at the point of the current layout segment. + * This ensures the response returned is not further down than the current layout segment. + */ +function walkAddRefetch( + segmentPathToWalk: FlightSegmentPath | undefined, + treeToRecreate: FlightRouterState +): FlightRouterState { + if (segmentPathToWalk) { + const [segment, parallelRouteKey] = segmentPathToWalk + const isLast = segmentPathToWalk.length === 2 + + if (treeToRecreate[0] === segment) { + if (treeToRecreate[1].hasOwnProperty(parallelRouteKey)) { + if (isLast) { + const subTree = walkAddRefetch( + undefined, + treeToRecreate[1][parallelRouteKey] + ) + return [ + treeToRecreate[0], + { + ...treeToRecreate[1], + [parallelRouteKey]: [ + subTree[0], + subTree[1], + subTree[2], + 'refetch', + ], + }, + ] + } + + return [ + treeToRecreate[0], + { + ...treeToRecreate[1], + [parallelRouteKey]: walkAddRefetch( + segmentPathToWalk.slice(2), + treeToRecreate[1][parallelRouteKey] + ), + }, + ] + } + } + } + + return treeToRecreate } /** @@ -114,7 +167,13 @@ export function InnerLayoutRouter({ let childNode = childNodes.get(path) // If childProp is available this means it's the Flight / SSR case. - if (childProp && !childNode) { + if ( + childProp && + // TODO-APP: verify if this can be null based on user code + childProp.current !== null && + !childNode /*&& + !childProp.partial*/ + ) { // 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, { @@ -130,55 +189,6 @@ export function InnerLayoutRouter({ // When childNode is not available during rendering client-side we need to fetch it from the server. if (!childNode) { - /** - * Add refetch marker to router state at the point of the current layout segment. - * This ensures the response returned is not further down than the current layout segment. - */ - const walkAddRefetch = ( - segmentPathToWalk: FlightSegmentPath | undefined, - treeToRecreate: FlightRouterState - ): FlightRouterState => { - if (segmentPathToWalk) { - const [segment, parallelRouteKey] = segmentPathToWalk - const isLast = segmentPathToWalk.length === 2 - - if (treeToRecreate[0] === segment) { - if (treeToRecreate[1].hasOwnProperty(parallelRouteKey)) { - if (isLast) { - const subTree = walkAddRefetch( - undefined, - treeToRecreate[1][parallelRouteKey] - ) - if (!subTree[2]) { - subTree[2] = undefined - } - subTree[3] = 'refetch' - return [ - treeToRecreate[0], - { - ...treeToRecreate[1], - [parallelRouteKey]: [...subTree], - }, - ] - } - - return [ - treeToRecreate[0], - { - ...treeToRecreate[1], - [parallelRouteKey]: walkAddRefetch( - segmentPathToWalk.slice(2), - treeToRecreate[1][parallelRouteKey] - ), - }, - ] - } - } - } - - return treeToRecreate - } - /** * Router state with refetch marker added */ @@ -188,9 +198,8 @@ export function InnerLayoutRouter({ /** * Flight data fetch kicked off during render and put into the cache. */ - const data = fetchServerResponse(new URL(url, location.origin), refetchTree) childNodes.set(path, { - data, + data: fetchServerResponse(new URL(url, location.origin), refetchTree), subTreeData: null, parallelRoutes: new Map(), }) @@ -230,22 +239,22 @@ export function InnerLayoutRouter({ let fastPath: boolean = false // If there are multiple patches returned in the Flight data we need to dispatch to ensure a single render. - if (flightData.length === 1) { - const flightDataPath = flightData[0] - - if (segmentPathMatches(flightDataPath, segmentPath)) { - // Ensure data is set to null as subTreeData will be set in the cache now. - childNode.data = null - // Last item is the subtreeData - // TODO-APP: routerTreePatch needs to be applied to the tree, handle it in render? - const [, /* routerTreePatch */ subTreeData] = flightDataPath.slice(-2) - // Add subTreeData into the cache - childNode.subTreeData = subTreeData - // This field is required for new items - childNode.parallelRoutes = new Map() - fastPath = true - } - } + // if (flightData.length === 1) { + // const flightDataPath = flightData[0] + + // if (segmentPathMatches(flightDataPath, segmentPath)) { + // // Ensure data is set to null as subTreeData will be set in the cache now. + // childNode.data = null + // // Last item is the subtreeData + // // TODO-APP: routerTreePatch needs to be applied to the tree, handle it in render? + // const [, /* routerTreePatch */ subTreeData] = flightDataPath.slice(-2) + // // Add subTreeData into the cache + // childNode.subTreeData = subTreeData + // // This field is required for new items + // childNode.parallelRoutes = new Map() + // fastPath = true + // } + // } // When the fast path is not used a new action is dispatched to update the tree and cache. if (!fastPath) { diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 2e5a19daba6f..90c081d2f059 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -3,6 +3,8 @@ import type { FlightRouterState, FlightData, FlightDataPath, + FlightSegmentPath, + Segment, } from '../../server/app-render' import { matchSegment } from './match-segments' import { fetchServerResponse } from './app-router.client' @@ -45,11 +47,31 @@ function fillCacheWithNewSubTreeData( !childCacheNode.data || childCacheNode === existingChildCacheNode ) { - childSegmentMap.set(segmentForCache, { + childCacheNode = { data: null, subTreeData: flightDataPath[3], - parallelRoutes: new Map(), - }) + // Ensure segments other than the one we got data for are preserved. + parallelRoutes: existingChildCacheNode + ? new Map(existingChildCacheNode.parallelRoutes) + : new Map(), + } + + // Remove segment that we got data for so that it is filled in during rendering of subTreeData. + for (const key in flightDataPath[2][1]) { + const segmentForParallelRoute = flightDataPath[2][1][key][0] + const cacheKey = Array.isArray(segmentForParallelRoute) + ? segmentForParallelRoute[1] + : segmentForParallelRoute + const existingParallelRoutesCacheNode = + existingChildCacheNode?.parallelRoutes.get(key) + if (existingParallelRoutesCacheNode) { + let parallelRouteCacheNode = new Map(existingParallelRoutesCacheNode) + parallelRouteCacheNode.delete(cacheKey) + childCacheNode.parallelRoutes.set(key, parallelRouteCacheNode) + } + } + + childSegmentMap.set(segmentForCache, childCacheNode) } return } @@ -76,6 +98,112 @@ function fillCacheWithNewSubTreeData( ) } +/** + * Fill cache up to the end of the flightSegmentPath, invalidating anything below it. + */ +function invalidateCacheBelowFlightSegmentPath( + newCache: CacheNode, + existingCache: CacheNode, + flightSegmentPath: FlightSegmentPath +): void { + const isLastEntry = flightSegmentPath.length <= 2 + const [parallelRouteKey, segment] = flightSegmentPath + + const segmentForCache = Array.isArray(segment) ? segment[1] : segment + + const existingChildSegmentMap = + existingCache.parallelRoutes.get(parallelRouteKey) + + if (!existingChildSegmentMap) { + // Bailout because the existing cache does not have the path to the leaf node + // Will trigger lazy fetch in layout-router because of missing segment + return + } + + let childSegmentMap = newCache.parallelRoutes.get(parallelRouteKey) + if (!childSegmentMap || childSegmentMap === existingChildSegmentMap) { + childSegmentMap = new Map(existingChildSegmentMap) + newCache.parallelRoutes.set(parallelRouteKey, childSegmentMap) + } + + // In case of last entry don't copy further down. + if (isLastEntry) { + childSegmentMap.delete(segmentForCache) + return + } + + const existingChildCacheNode = existingChildSegmentMap.get(segmentForCache) + let childCacheNode = childSegmentMap.get(segmentForCache) + + if (!childCacheNode || !existingChildCacheNode) { + // Bailout because the existing cache does not have the path to the leaf node + // Will trigger lazy fetch in layout-router because of missing segment + return + } + + if (childCacheNode === existingChildCacheNode) { + childCacheNode = { + data: childCacheNode.data, + subTreeData: childCacheNode.subTreeData, + parallelRoutes: new Map(childCacheNode.parallelRoutes), + } + childSegmentMap.set(segmentForCache, childCacheNode) + } + + invalidateCacheBelowFlightSegmentPath( + childCacheNode, + existingChildCacheNode, + flightSegmentPath.slice(2) + ) +} + +/** + * Fill cache with subTreeData based on flightDataPath that was prefetched + * This operation is append-only to the existing cache. + */ +function fillCacheWithPrefetchedSubTreeData( + existingCache: CacheNode, + flightDataPath: FlightDataPath +): void { + const isLastEntry = flightDataPath.length <= 4 + const [parallelRouteKey, segment] = flightDataPath + + const segmentForCache = Array.isArray(segment) ? segment[1] : segment + + const existingChildSegmentMap = + existingCache.parallelRoutes.get(parallelRouteKey) + + if (!existingChildSegmentMap) { + // Bailout because the existing cache does not have the path to the leaf node + return + } + + const existingChildCacheNode = existingChildSegmentMap.get(segmentForCache) + + // In case of last segment start the fetch at this level and don't copy further down. + if (isLastEntry) { + if (!existingChildCacheNode) { + existingChildSegmentMap.set(segmentForCache, { + data: null, + subTreeData: flightDataPath[3], + parallelRoutes: new Map(), + }) + } + + return + } + + if (!existingChildCacheNode) { + // Bailout because the existing cache does not have the path to the leaf node + return + } + + fillCacheWithPrefetchedSubTreeData( + existingChildCacheNode, + flightDataPath.slice(2) + ) +} + /** * Kick off fetch based on the common layout between two routes. Fill cache with data property holding the in-progress fetch. */ @@ -154,48 +282,6 @@ function fillCacheWithDataProperty( ) } -/** - * Decide if the segments can be optimistically rendered, kicking off the fetch in layout-router. - * - When somewhere in the path to the segment there is a loading.js this becomes true - */ -function canOptimisticallyRender( - segments: string[], - flightRouterState: FlightRouterState -): boolean { - const segment = segments[0] - const isLastSegment = segments.length === 1 - const [existingSegment, existingParallelRoutes, , , loadingMarker] = - flightRouterState - // If the segments mismatch we can't resolve deeper into the tree - const segmentMatches = matchSegment(existingSegment, segment) - - // If the segment mismatches we can't assume this level has loading - if (!segmentMatches) { - return false - } - - const hasLoading = loadingMarker === 'loading' - // If the tree path holds at least one loading.js it will be optimistic - if (hasLoading) { - return true - } - // Above already catches the last segment case where `hasLoading` is true, so in this case it would always be `false`. - if (isLastSegment) { - return false - } - - // If the existingParallelRoutes does not have a `children` parallelRouteKey we can't resolve deeper into the tree - if (!existingParallelRoutes.children) { - return hasLoading - } - - // Resolve deeper in the tree as the current level did not have a loading marker - return canOptimisticallyRender( - segments.slice(1), - existingParallelRoutes.children - ) -} - /** * Create optimistic version of router state based on the existing router state and segments. * This is used to allow rendering layout-routers up till the point where data is missing. @@ -252,10 +338,6 @@ function createOptimisticTree( // if (isFirstSegment) { // result[2] = href // } - // Copy the loading flag from existing tree - if (flightRouterState && flightRouterState[4]) { - result[4] = flightRouterState[4] - } return result } @@ -267,7 +349,7 @@ function applyRouterStatePatchToTree( flightSegmentPath: FlightData[0], flightRouterState: FlightRouterState, treePatch: FlightRouterState -): FlightRouterState { +): FlightRouterState | null { const [segment, parallelRoutes /* , url */] = flightRouterState // Root refresh @@ -285,22 +367,31 @@ function applyRouterStatePatchToTree( // Tree path returned from the server should always match up with the current tree in the browser if (!matchSegment(currentSegment, segment)) { - throw new Error('SEGMENT MISMATCH') + return null } const lastSegment = flightSegmentPath.length === 2 + let parallelRoutePatch + if (lastSegment) { + parallelRoutePatch = treePatch + } else { + parallelRoutePatch = applyRouterStatePatchToTree( + flightSegmentPath.slice(2), + parallelRoutes[parallelRouteKey], + treePatch + ) + + if (parallelRoutePatch === null) { + return null + } + } + const tree: FlightRouterState = [ flightSegmentPath[0], { ...parallelRoutes, - [parallelRouteKey]: lastSegment - ? treePatch - : applyRouterStatePatchToTree( - flightSegmentPath.slice(2), - parallelRoutes[parallelRouteKey], - treePatch - ), + [parallelRouteKey]: parallelRoutePatch, }, ] @@ -308,14 +399,40 @@ function applyRouterStatePatchToTree( // if (url) { // tree[2] = url // } - // Copy loading flag - if (flightRouterState[4]) { - tree[4] = flightRouterState[4] - } return tree } +function shouldHardNavigate( + flightSegmentPath: FlightDataPath, + flightRouterState: FlightRouterState, + treePatch: FlightRouterState +): boolean { + const [segment, parallelRoutes] = flightRouterState + // TODO-APP: Check if `as` can be replaced. + const [currentSegment, parallelRouteKey] = flightSegmentPath as [ + Segment, + string + ] + + // If dynamic parameter in tree doesn't match up with segment path a hard navigation is triggered. + if (Array.isArray(currentSegment) && !matchSegment(currentSegment, segment)) { + return true + } + + const lastSegment = flightSegmentPath.length <= 2 + + if (lastSegment) { + return false + } + + return shouldHardNavigate( + flightSegmentPath.slice(2), + parallelRoutes[parallelRouteKey], + treePatch + ) +} + export type FocusAndScrollRef = { /** * If focus and scroll should be set in the layout-router's useEffect() @@ -327,6 +444,7 @@ export const ACTION_RELOAD = 'reload' export const ACTION_NAVIGATE = 'navigate' export const ACTION_RESTORE = 'restore' export const ACTION_SERVER_PATCH = 'server-patch' +export const ACTION_PREFETCH = 'prefetch' /** * Reload triggers a reload of the full page data. @@ -335,7 +453,6 @@ export const ACTION_SERVER_PATCH = 'server-patch' */ interface ReloadAction { type: typeof ACTION_RELOAD - url: URL cache: CacheNode mutable: { previousTree?: FlightRouterState @@ -370,12 +487,13 @@ interface ReloadAction { interface NavigateAction { type: typeof ACTION_NAVIGATE url: URL - cacheType: 'soft' | 'hard' navigateType: 'push' | 'replace' + forceOptimisticNavigation: boolean cache: CacheNode mutable: { previousTree?: FlightRouterState patchedTree?: FlightRouterState + useExistingCache?: true } } @@ -403,6 +521,15 @@ interface ServerPatchAction { flightData: FlightData previousTree: FlightRouterState cache: CacheNode + mutable: { + patchedTree?: FlightRouterState + } +} + +interface PrefetchAction { + type: typeof ACTION_PREFETCH + url: URL + flightData: FlightData } interface PushRef { @@ -432,6 +559,16 @@ type AppRouterState = { * It also holds in-progress data requests. */ cache: CacheNode + /** + * Cache that holds prefetched Flight responses keyed by url + */ + prefetchCache: Map< + string, + { + flightSegmentPath: FlightSegmentPath + treePatch: FlightRouterState + } + > /** * Decides if the update should create a new history entry and if the navigation can't be handled by app-router. */ @@ -452,203 +589,225 @@ type AppRouterState = { export function reducer( state: Readonly, action: Readonly< - ReloadAction | NavigateAction | RestoreAction | ServerPatchAction + | ReloadAction + | NavigateAction + | RestoreAction + | ServerPatchAction + | PrefetchAction > ): AppRouterState { switch (action.type) { - case ACTION_RESTORE: { - const { url, tree } = action - const href = url.pathname + url.search + url.hash - - return { - // Set canonical url - canonicalUrl: href, - pushRef: state.pushRef, - focusAndScrollRef: state.focusAndScrollRef, - cache: state.cache, - // Restore provided tree - tree: tree, - } - } case ACTION_NAVIGATE: { - const { url, cacheType, navigateType, cache, mutable } = action + const { url, navigateType, cache, mutable, forceOptimisticNavigation } = + action const { pathname, search, hash } = url const href = pathname + search + hash const pendingPush = navigateType === 'push' - const segments = pathname.split('/') - // TODO-APP: figure out something better for index pages - segments.push('') - - // In case of soft push data fetching happens in layout-router if a segment is missing - if (cacheType === 'soft') { - // Create optimistic tree that causes missing data to be fetched in layout-router during render. - const optimisticTree = createOptimisticTree( - segments, - state.tree, - true, - false, - href - ) - + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. + if ( + mutable.patchedTree && + JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) + ) { return { // Set href. canonicalUrl: href, - // Set pendingPush. mpaNavigation is handled during rendering in layout-router for this case. + // TODO-APP: verify mpaNavigation not being set is correct here. pushRef: { pendingPush, mpaNavigation: false }, // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, - // Existing cache is used for soft navigation. - cache: state.cache, - // Optimistic tree is applied. - tree: optimisticTree, + // Apply cache. + cache: mutable.useExistingCache ? state.cache : cache, + prefetchCache: state.prefetchCache, + // Apply patched router state. + tree: mutable.patchedTree, } } - // When doing a hard push there can be two cases: with optimistic tree and without - // The with optimistic tree case only happens when the layouts have a loading state (loading.js) - // The without optimistic tree case happens when there is no loading state, in that case we suspend in this reducer - if (cacheType === 'hard') { - // Handle concurrent rendering / strict mode case where the cache and tree were already populated. - if ( - mutable.patchedTree && - JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) - ) { + const prefetchValues = state.prefetchCache.get(href) + if (prefetchValues) { + // The one before last item is the router state tree patch + const { flightSegmentPath, treePatch } = prefetchValues + + // Create new tree based on the flightSegmentPath and router state patch + const newTree = applyRouterStatePatchToTree( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + state.tree, + treePatch + ) + + if (newTree !== null) { + mutable.previousTree = state.tree + mutable.patchedTree = newTree + + const hardNavigate = + // TODO-APP: Revisit if this is correct. + search !== location.search || + shouldHardNavigate( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + state.tree, + newTree + ) + + if (hardNavigate) { + // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. + // Copy subTreeData for the root node of the cache. + cache.subTreeData = state.cache.subTreeData + + invalidateCacheBelowFlightSegmentPath( + cache, + state.cache, + flightSegmentPath + ) + } else { + mutable.useExistingCache = true + } + return { // Set href. canonicalUrl: href, - // TODO-APP: verify mpaNavigation not being set is correct here. + // Set pendingPush. pushRef: { pendingPush, mpaNavigation: false }, // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, - // Apply cache. - cache: cache, - // Apply patched router state. - tree: mutable.patchedTree, + // Apply patched cache. + cache: mutable.useExistingCache ? state.cache : cache, + prefetchCache: state.prefetchCache, + // Apply patched tree. + tree: newTree, } } + } + + // When doing a hard push there can be two cases: with optimistic tree and without + // The with optimistic tree case only happens when the layouts have a loading state (loading.js) + // The without optimistic tree case happens when there is no loading state, in that case we suspend in this reducer - // TODO-APP: flag on the tree of which part of the tree for if there is a loading boundary - /** - * If the tree can be optimistically rendered and suspend in layout-router instead of in the reducer. - */ - const isOptimistic = canOptimisticallyRender(segments, state.tree) + // forceOptimisticNavigation is used for links that have `prefetch={false}`. + if (forceOptimisticNavigation) { + const segments = pathname.split('/') + // TODO-APP: figure out something better for index pages + segments.push('') // Optimistic tree case. - if (isOptimistic) { - // If the optimistic tree is deeper than the current state leave that deeper part out of the fetch - const optimisticTree = createOptimisticTree( - segments, - state.tree, - true, - false, - href - ) - - // Fill in the cache with blank that holds the `data` field. - // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. - // Copy subTreeData for the root node of the cache. - cache.subTreeData = state.cache.subTreeData - // Copy existing cache nodes as far as possible and fill in `data` property with the started data fetch. - // The `data` property is used to suspend in layout-router during render if it hasn't resolved yet by the time it renders. - const res = fillCacheWithDataProperty( - cache, - state.cache, - segments.slice(1), - (): { readRoot: () => FlightData } => - fetchServerResponse(url, optimisticTree) - ) - - // If optimistic fetch couldn't happen it falls back to the non-optimistic case. - if (!res?.bailOptimistic) { - mutable.previousTree = state.tree - mutable.patchedTree = optimisticTree - return { - // Set href. - canonicalUrl: href, - // Set pendingPush. - pushRef: { pendingPush, mpaNavigation: false }, - // All navigation requires scroll and focus management to trigger. - focusAndScrollRef: { apply: true }, - // Apply patched cache. - cache: cache, - // Apply optimistic tree. - tree: optimisticTree, - } - } - } - - // Below is the not-optimistic case. + // If the optimistic tree is deeper than the current state leave that deeper part out of the fetch + const optimisticTree = createOptimisticTree( + segments, + state.tree, + true, + false, + href + ) - // If no in-flight fetch at the top, start it. - if (!cache.data) { - cache.data = fetchServerResponse(url, state.tree) - } + // Fill in the cache with blank that holds the `data` field. + // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. + // Copy subTreeData for the root node of the cache. + cache.subTreeData = state.cache.subTreeData - // readRoot to suspend here (in the reducer) until the fetch resolves. - const flightData = cache.data.readRoot() + // Copy existing cache nodes as far as possible and fill in `data` property with the started data fetch. + // The `data` property is used to suspend in layout-router during render if it hasn't resolved yet by the time it renders. + const res = fillCacheWithDataProperty( + cache, + state.cache, + segments.slice(1), + (): { readRoot: () => FlightData } => + fetchServerResponse(url, optimisticTree) + ) - // Handle case when navigating to page in `pages` from `app` - if (typeof flightData === 'string') { + // If optimistic fetch couldn't happen it falls back to the non-optimistic case. + if (!res?.bailOptimistic) { + mutable.previousTree = state.tree + mutable.patchedTree = optimisticTree return { - canonicalUrl: flightData, - // Enable mpaNavigation - pushRef: { pendingPush: true, mpaNavigation: true }, - // Don't apply scroll and focus management. - focusAndScrollRef: { apply: false }, - cache: state.cache, - tree: state.tree, + // Set href. + canonicalUrl: href, + // Set pendingPush. + pushRef: { pendingPush, mpaNavigation: false }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: true }, + // Apply patched cache. + cache: cache, + prefetchCache: state.prefetchCache, + // Apply optimistic tree. + tree: optimisticTree, } } + } - // Remove cache.data as it has been resolved at this point. - cache.data = null + // Below is the not-optimistic case. Data is fetched at the root and suspended there without a suspense boundary. - // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. - const flightDataPath = flightData[0] + // If no in-flight fetch at the top, start it. + if (!cache.data) { + cache.data = fetchServerResponse(url, state.tree) + } - // The one before last item is the router state tree patch - const [treePatch] = flightDataPath.slice(-2) + // readRoot to suspend here (in the reducer) until the fetch resolves. + const flightData = cache.data.readRoot() - // Path without the last segment, router state, and the subTreeData - const flightSegmentPath = flightDataPath.slice(0, -3) + // Handle case when navigating to page in `pages` from `app` + if (typeof flightData === 'string') { + return { + canonicalUrl: flightData, + // Enable mpaNavigation + pushRef: { pendingPush: true, mpaNavigation: true }, + // Don't apply scroll and focus management. + focusAndScrollRef: { apply: false }, + cache: state.cache, + prefetchCache: state.prefetchCache, + tree: state.tree, + } + } - // Create new tree based on the flightSegmentPath and router state patch - const newTree = applyRouterStatePatchToTree( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - state.tree, - treePatch - ) + // Remove cache.data as it has been resolved at this point. + cache.data = null - mutable.previousTree = state.tree - mutable.patchedTree = newTree + // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. + const flightDataPath = flightData[0] - // Copy subTreeData for the root node of the cache. - cache.subTreeData = state.cache.subTreeData - // Create a copy of the existing cache with the subTreeData applied. - fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) + // The one before last item is the router state tree patch + const [treePatch] = flightDataPath.slice(-2) - return { - // Set href. - canonicalUrl: href, - // Set pendingPush. - pushRef: { pendingPush, mpaNavigation: false }, - // All navigation requires scroll and focus management to trigger. - focusAndScrollRef: { apply: true }, - // Apply patched cache. - cache: cache, - // Apply patched tree. - tree: newTree, - } + // Path without the last segment, router state, and the subTreeData + const flightSegmentPath = flightDataPath.slice(0, -3) + + // Create new tree based on the flightSegmentPath and router state patch + const newTree = applyRouterStatePatchToTree( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + state.tree, + treePatch + ) + + if (newTree === null) { + throw new Error('SEGMENT MISMATCH') } - // This case should never be hit as `cacheType` is required and both cases are implemented. - // Short error to save bundle space. - throw new Error('Invalid navigate') + mutable.previousTree = state.tree + mutable.patchedTree = newTree + + // Copy subTreeData for the root node of the cache. + cache.subTreeData = state.cache.subTreeData + // Create a copy of the existing cache with the subTreeData applied. + fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) + + return { + // Set href. + canonicalUrl: href, + // Set pendingPush. + pushRef: { pendingPush, mpaNavigation: false }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: true }, + // Apply patched cache. + cache: cache, + prefetchCache: state.prefetchCache, + // Apply patched tree. + tree: newTree, + } } case ACTION_SERVER_PATCH: { - const { flightData, previousTree, cache } = action + const { flightData, previousTree, cache, mutable } = action // When a fetch is slow to resolve it could be that you navigated away while the request was happening or before the reducer runs. // In that case opt-out of applying the patch given that the data could be stale. if (JSON.stringify(previousTree) !== JSON.stringify(state.tree)) { @@ -658,6 +817,23 @@ export function reducer( return state } + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. + if (mutable.patchedTree) { + return { + // Keep href as it was set during navigate / restore + canonicalUrl: state.canonicalUrl, + // Keep pushRef as server-patch only causes cache/tree update. + pushRef: state.pushRef, + // Keep focusAndScrollRef as server-patch only causes cache/tree update. + focusAndScrollRef: state.focusAndScrollRef, + // Apply patched router state + tree: mutable.patchedTree, + prefetchCache: state.prefetchCache, + // Apply patched cache + cache: cache, + } + } + // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { return { @@ -669,6 +845,7 @@ export function reducer( focusAndScrollRef: { apply: false }, // Other state is kept as-is. cache: state.cache, + prefetchCache: state.prefetchCache, tree: state.tree, } } @@ -687,6 +864,12 @@ export function reducer( treePatch ) + if (newTree === null) { + throw new Error('SEGMENT MISMATCH') + } + + mutable.patchedTree = newTree + // Copy subTreeData for the root node of the cache. cache.subTreeData = state.cache.subTreeData fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) @@ -700,15 +883,29 @@ export function reducer( focusAndScrollRef: state.focusAndScrollRef, // Apply patched router state tree: newTree, + prefetchCache: state.prefetchCache, // Apply patched cache cache: cache, } } - case ACTION_RELOAD: { - const { url, cache, mutable } = action + case ACTION_RESTORE: { + const { url, tree } = action const href = url.pathname + url.search + url.hash - // Reload is always a replace. - const pendingPush = false + + return { + // Set canonical url + canonicalUrl: href, + pushRef: state.pushRef, + focusAndScrollRef: state.focusAndScrollRef, + cache: state.cache, + prefetchCache: state.prefetchCache, + // Restore provided tree + tree: tree, + } + } + case ACTION_RELOAD: { + const { cache, mutable } = action + const href = state.canonicalUrl // Handle concurrent rendering / strict mode case where the cache and tree were already populated. if ( @@ -719,18 +916,19 @@ export function reducer( // Set href. canonicalUrl: href, // set pendingPush (always false in this case). - pushRef: { pendingPush, mpaNavigation: false }, + pushRef: state.pushRef, // Apply focus and scroll. // TODO-APP: might need to disable this for Fast Refresh. focusAndScrollRef: { apply: true }, cache: cache, + prefetchCache: state.prefetchCache, tree: mutable.patchedTree, } } if (!cache.data) { // Fetch data from the root of the tree. - cache.data = fetchServerResponse(url, [ + cache.data = fetchServerResponse(new URL(href, location.origin), [ state.tree[0], state.tree[1], state.tree[2], @@ -746,6 +944,7 @@ export function reducer( pushRef: { pendingPush: true, mpaNavigation: true }, focusAndScrollRef: { apply: false }, cache: state.cache, + prefetchCache: state.prefetchCache, tree: state.tree, } } @@ -772,6 +971,10 @@ export function reducer( treePatch ) + if (newTree === null) { + throw new Error('SEGMENT MISMATCH') + } + mutable.previousTree = state.tree mutable.patchedTree = newTree @@ -782,15 +985,48 @@ export function reducer( // Set href, this doesn't reuse the state.canonicalUrl as because of concurrent rendering the href might change between dispatching and applying. canonicalUrl: href, // set pendingPush (always false in this case). - pushRef: { pendingPush, mpaNavigation: false }, + pushRef: state.pushRef, // TODO-APP: might need to disable this for Fast Refresh. focusAndScrollRef: { apply: false }, // Apply patched cache. cache: cache, + prefetchCache: state.prefetchCache, // Apply patched router state. tree: newTree, } } + case ACTION_PREFETCH: { + const { url, flightData } = action + + // TODO-APP: Implement prefetch for hard navigation + if (typeof flightData === 'string') { + return state + } + + const { pathname, search, hash } = url + const href = pathname + search + hash + + // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. + const flightDataPath = flightData[0] + + // The one before last item is the router state tree patch + const [treePatch, subTreeData] = flightDataPath.slice(-2) + + // TODO-APP: Verify if `null` can't be returned from user code. + // If subTreeData is null the prefetch did not provide a component tree. + if (subTreeData !== null) { + fillCacheWithPrefetchedSubTreeData(state.cache, flightDataPath) + } + + // 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, + }) + + return state + } // This case should never be hit as dispatch is strongly typed. default: throw new Error('Unknown action') diff --git a/packages/next/client/components/use-reducer-with-devtools.ts b/packages/next/client/components/use-reducer-with-devtools.ts new file mode 100644 index 000000000000..b9c73c99ae06 --- /dev/null +++ b/packages/next/client/components/use-reducer-with-devtools.ts @@ -0,0 +1,160 @@ +import type { reducer } from './reducer' +import type { + ReducerState, + ReducerAction, + MutableRefObject, + Dispatch, +} from 'react' +import { useRef, useReducer, useEffect, useCallback } from 'react' + +function normalizeRouterState(val: any): any { + if (val instanceof Map) { + const obj: { [key: string]: any } = {} + for (const [key, value] of val.entries()) { + if (typeof value === 'function') { + obj[key] = 'fn()' + continue + } + if (typeof value === 'object' && value !== null) { + if (value.$$typeof) { + obj[key] = value.$$typeof.toString() + continue + } + if (value._bundlerConfig) { + obj[key] = 'FlightData' + continue + } + } + obj[key] = normalizeRouterState(value) + } + return obj + } + + if (typeof val === 'object' && val !== null) { + const obj: { [key: string]: any } = {} + for (const key in val) { + const value = val[key] + if (typeof value === 'function') { + obj[key] = 'fn()' + continue + } + if (typeof value === 'object' && value !== null) { + if (value.$$typeof) { + obj[key] = value.$$typeof.toString() + continue + } + if (value.hasOwnProperty('_bundlerConfig')) { + obj[key] = 'FlightData' + continue + } + } + + obj[key] = normalizeRouterState(value) + } + return obj + } + + if (Array.isArray(val)) { + return val.map(normalizeRouterState) + } + + return val +} + +// Log router state when actions are triggered. +// function logReducer(fn: typeof reducer) { +// return ( +// state: ReducerState, +// action: ReducerAction +// ) => { +// console.groupCollapsed(action.type) +// console.log('action', action) +// console.log('old', state) +// const res = fn(state, action) +// console.log('new', res) +// console.groupEnd() +// return res +// } +// } + +declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION__: any + } +} + +interface ReduxDevToolsInstance { + send(action: any, state: any): void + init(initialState: any): void +} + +function devToolReducer( + fn: typeof reducer, + ref: MutableRefObject +) { + return ( + state: ReducerState, + action: ReducerAction + ) => { + const res = fn(state, action) + if (ref.current) { + ref.current.send(action, normalizeRouterState(res)) + } + return res + } +} + +export function useReducerWithReduxDevtools( + fn: typeof reducer, + initialState: ReturnType +): [ + ReturnType, + Dispatch>, + () => void +] { + const devtoolsConnectionRef = useRef() + const enabledRef = useRef() + + useEffect(() => { + if (devtoolsConnectionRef.current || enabledRef.current === false) { + return + } + + if ( + enabledRef.current === undefined && + typeof window.__REDUX_DEVTOOLS_EXTENSION__ === 'undefined' + ) { + enabledRef.current = false + return + } + + devtoolsConnectionRef.current = window.__REDUX_DEVTOOLS_EXTENSION__.connect( + { + instanceId: 1, + name: 'next-router', + } + ) + if (devtoolsConnectionRef.current) { + devtoolsConnectionRef.current.init(normalizeRouterState(initialState)) + } + + return () => { + devtoolsConnectionRef.current = undefined + } + }, [initialState]) + + const [state, dispatch] = useReducer( + devToolReducer(/* logReducer( */ fn /*)*/, devtoolsConnectionRef), + initialState + ) + + const sync = useCallback(() => { + if (devtoolsConnectionRef.current) { + devtoolsConnectionRef.current.send( + { type: 'RENDER_SYNC' }, + normalizeRouterState(state) + ) + } + }, [state]) + return [state, dispatch, sync] +} diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index ceea17414c9b..26ed48d1404c 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -31,11 +31,6 @@ type InternalLinkProps = { href: Url as?: Url replace?: boolean - - /** - * TODO-APP - */ - soft?: boolean scroll?: boolean shallow?: boolean passHref?: boolean @@ -79,7 +74,7 @@ function prefetch( // We need to handle a prefetch error here since we may be // loading with priority which can reject but we don't // want to force navigation since this is only a prefetch - router.prefetch(href, as, options).catch((err) => { + Promise.resolve(router.prefetch(href, as, options)).catch((err) => { if (process.env.NODE_ENV !== 'production') { // rethrow to show invalid URL errors throw err @@ -112,11 +107,11 @@ function linkClicked( href: string, as: string, replace?: boolean, - soft?: boolean, shallow?: boolean, scroll?: boolean, locale?: string | false, - startTransition?: (cb: any) => void + startTransition?: (cb: any) => void, + prefetchEnabled?: boolean ): void { const { nodeName } = e.currentTarget @@ -131,26 +126,18 @@ function linkClicked( e.preventDefault() const navigate = () => { - // If the router is an AppRouterInstance, then it'll have `softPush` and - // `softReplace`. - if ('softPush' in router && 'softReplace' in router) { - // If we're doing a soft navigation, use the soft variants of - // replace/push. - const method: keyof AppRouterInstance = soft - ? replace - ? 'softReplace' - : 'softPush' - : replace - ? 'replace' - : 'push' - - router[method](href) - } else { + // If the router is an NextRouter instance it will have `beforePopState` + if ('beforePopState' in router) { router[replace ? 'replace' : 'push'](href, as, { shallow, locale, scroll, }) + } else { + // If `beforePopState` doesn't exist on the router it's the AppRouter. + const method: keyof AppRouterInstance = replace ? 'replace' : 'push' + + router[method](href, { forceOptimisticNavigation: !prefetchEnabled }) } } @@ -212,7 +199,6 @@ const Link = React.forwardRef( const optionalPropsGuard: Record = { as: true, replace: true, - soft: true, scroll: true, shallow: true, passHref: true, @@ -259,7 +245,6 @@ const Link = React.forwardRef( } } else if ( key === 'replace' || - key === 'soft' || key === 'scroll' || key === 'shallow' || key === 'passHref' || @@ -300,7 +285,6 @@ const Link = React.forwardRef( prefetch: prefetchProp, passHref, replace, - soft, shallow, scroll, locale, @@ -455,11 +439,11 @@ const Link = React.forwardRef( href, as, replace, - soft, shallow, scroll, locale, - appRouter ? startTransition : undefined + appRouter ? startTransition : undefined, + p ) } }, @@ -474,8 +458,12 @@ const Link = React.forwardRef( ) { child.props.onMouseEnter(e) } - if (isLocalURL(href)) { - prefetch(router, href, as, { priority: true }) + + // Check for not prefetch disabled in page using appRouter + if (!(!p && appRouter)) { + if (isLocalURL(href)) { + prefetch(router, href, as, { priority: true }) + } } }, onTouchStart: (e: React.TouchEvent) => { @@ -491,8 +479,11 @@ const Link = React.forwardRef( child.props.onTouchStart(e) } - if (isLocalURL(href)) { - prefetch(router, href, as, { priority: true }) + // Check for not prefetch disabled in page using appRouter + if (!(!p && appRouter)) { + if (isLocalURL(href)) { + prefetch(router, href, as, { priority: true }) + } } }, } diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index b8ba02cc10f9..24f651fcf737 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -298,8 +298,7 @@ export type FlightRouterState = [ segment: Segment, parallelRoutes: { [parallelRouterKey: string]: FlightRouterState }, url?: string, - refresh?: 'refetch', - loading?: 'loading' + refresh?: 'refetch' ] /** @@ -327,7 +326,7 @@ export type FlightDataPath = ...FlightSegmentPath, /* segment of the rendered slice: */ Segment, /* treePatch */ FlightRouterState, - /* subTreeData: */ React.ReactNode + /* subTreeData: */ React.ReactNode | null // Can be null during prefetch if there's no loading component ] /** @@ -339,7 +338,10 @@ export type FlightData = Array | string * Property holding the current subTreeData. */ export type ChildProp = { - current: React.ReactNode + /** + * Null indicates that the tree is partial + */ + current: React.ReactNode | null segment: Segment } @@ -431,6 +433,7 @@ export async function renderToHTMLOrFlight( } = renderOpts const isFlight = query.__flight__ !== undefined + const isPrefetch = query.__flight_prefetch__ !== undefined // Handle client-side navigation to pages directory if (isFlight && isPagesDir) { @@ -565,9 +568,7 @@ export async function renderToHTMLOrFlight( const createFlightRouterStateFromLoaderTree = ([ segment, parallelRoutes, - { loading }, ]: LoaderTree): FlightRouterState => { - const hasLoading = Boolean(loading) const dynamicParam = getDynamicParamFromSegment(segment) const segmentTree: FlightRouterState = [ @@ -587,9 +588,6 @@ export async function renderToHTMLOrFlight( ) } - if (hasLoading) { - segmentTree[4] = 'loading' - } return segmentTree } @@ -671,6 +669,31 @@ export async function renderToHTMLOrFlight( ? [parallelRouteKey] : [actualSegment, parallelRouteKey] + const childSegment = parallelRoutes[parallelRouteKey][0] + const childSegmentParam = getDynamicParamFromSegment(childSegment) + + if (isPrefetch && Loading) { + const childProp: ChildProp = { + // Null indicates the tree is not fully rendered + current: null, + segment: childSegmentParam + ? childSegmentParam.treeSegment + : childSegment, + } + + // This is turned back into an object below. + return [ + parallelRouteKey, + : undefined} + childProp={childProp} + rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove} + />, + ] + } + // Create the child component const { Component: ChildComponent } = await createComponentTree({ createSegmentPath: (child) => { @@ -681,8 +704,6 @@ export async function renderToHTMLOrFlight( rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, }) - const childSegment = parallelRoutes[parallelRouteKey][0] - const childSegmentParam = getDynamicParamFromSegment(childSegment) const childProp: ChildProp = { current: , segment: childSegmentParam @@ -898,21 +919,23 @@ export async function renderToHTMLOrFlight( actualSegment, // Create router state using the slice of the loaderTree createFlightRouterStateFromLoaderTree(loaderTreeToFilter), - // Create component tree using the slice of the loaderTree - React.createElement( - ( - await createComponentTree( - // This ensures flightRouterPath is valid and filters down the tree - { - createSegmentPath: (child) => child, - loaderTree: loaderTreeToFilter, - parentParams: currentParams, - firstItem: true, - // parentSegmentPath: '', - } - ) - ).Component - ), + // Check if one level down from the common layout has a loading component. If it doesn't only provide the router state as part of the Flight data. + isPrefetch && !Boolean(loaderTreeToFilter[2].loading) + ? null + : // Create component tree using the slice of the loaderTree + React.createElement( + ( + await createComponentTree( + // This ensures flightRouterPath is valid and filters down the tree + { + createSegmentPath: (child) => child, + loaderTree: loaderTreeToFilter, + parentParams: currentParams, + firstItem: true, + } + ) + ).Component + ), ] } diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 06c78f84e797..74f73d1916e8 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -24,7 +24,11 @@ export type CacheNode = { parallelRoutes: Map } -export type AppRouterInstance = { +interface NavigateOptions { + forceOptimisticNavigation?: boolean +} + +export interface AppRouterInstance { /** * Reload the current page. Fetches new data from the server. */ @@ -33,26 +37,16 @@ export type AppRouterInstance = { * Hard navigate to the provided href. Fetches new data from the server. * Pushes a new history entry. */ - push(href: string): void - /** - * Soft navigate to the provided href. Does not fetch data from the server if it was already fetched. - * Pushes a new history entry. - */ - softPush(href: string): void + push(href: string, options: NavigateOptions): void /** * Hard navigate to the provided href. Does not fetch data from the server if it was already fetched. * Replaces the current history entry. */ - replace(href: string): void - /** - * Soft navigate to the provided href. Does not fetch data from the server if it was already fetched. - * Replaces the current history entry. - */ - softReplace(href: string): void + replace(href: string, options: NavigateOptions): void /** * Soft prefetch the provided href. Does not fetch data from the server if it was already fetched. */ - prefetch(href: string): Promise + prefetch(href: string): void } export const AppRouterContext = React.createContext( diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.server.js b/test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.server.js new file mode 100644 index 000000000000..5b4a6e8078cd --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.server.js @@ -0,0 +1,26 @@ +import Link from 'next/link' + +export async function getServerSideProps() { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return { + props: { a: 'b' }, + } +} + +export default function IdPage({ params }) { + if (params.id === '123') { + return ( + <> + IdPage: {params.id} + To 456 + + ) + } + + return ( + <> + IdPage: {params.id} + To 123 + + ) +} diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/layout.server.js b/test/e2e/app-dir/app-prefetch/app/dashboard/layout.server.js new file mode 100644 index 000000000000..84ebbb490d40 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/layout.server.js @@ -0,0 +1,16 @@ +export async function getServerSideProps() { + await new Promise((resolve) => setTimeout(resolve, 2000)) + return { + props: { + message: 'Hello World', + }, + } +} +export default function DashboardLayout({ children, message }) { + return ( + <> +

Dashboard {message}

+ {children} + + ) +} diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/loading.js b/test/e2e/app-dir/app-prefetch/app/dashboard/loading.js new file mode 100644 index 000000000000..b515fb9bf3d3 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/loading.js @@ -0,0 +1,3 @@ +export default function DashboardLoading() { + return <>Loading dashboard... +} diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/page.server.js b/test/e2e/app-dir/app-prefetch/app/dashboard/page.server.js new file mode 100644 index 000000000000..d22dfdf51e84 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/page.server.js @@ -0,0 +1,15 @@ +export async function getServerSideProps() { + await new Promise((resolve) => setTimeout(resolve, 3000)) + return { + props: { + message: 'Welcome to the dashboard', + }, + } +} +export default function DashboardPage({ message }) { + return ( + <> +

{message}

+ + ) +} diff --git a/test/e2e/app-dir/app-prefetch/app/layout.server.js b/test/e2e/app-dir/app-prefetch/app/layout.server.js new file mode 100644 index 000000000000..c84b681925eb --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/layout.server.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello World + + {children} + + ) +} diff --git a/test/e2e/app-dir/app-prefetch/app/loading.js b/test/e2e/app-dir/app-prefetch/app/loading.js new file mode 100644 index 000000000000..a0e67e9acf37 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/loading.js @@ -0,0 +1,3 @@ +export default function GlobalLoading() { + return <>Loading... +} diff --git a/test/e2e/app-dir/app-prefetch/app/page.server.js b/test/e2e/app-dir/app-prefetch/app/page.server.js new file mode 100644 index 000000000000..5cb37da2ad8a --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/app/page.server.js @@ -0,0 +1,8 @@ +import Link from 'next/link' +export default function HomePage() { + return ( + <> + To Dashboard + + ) +} diff --git a/test/e2e/app-dir/app-prefetch/next.config.js b/test/e2e/app-dir/app-prefetch/next.config.js new file mode 100644 index 000000000000..b76b309cf1e3 --- /dev/null +++ b/test/e2e/app-dir/app-prefetch/next.config.js @@ -0,0 +1,8 @@ +module.exports = { + experimental: { + appDir: true, + serverComponents: true, + legacyBrowsers: false, + browsersListForSwc: true, + }, +} diff --git a/test/e2e/app-dir/app-prefetch/pages/.gitkeep b/test/e2e/app-dir/app-prefetch/pages/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/e2e/app-dir/app/app/link-hard-push/[id]/page.server.js b/test/e2e/app-dir/app/app/link-hard-push/[id]/page.server.js new file mode 100644 index 000000000000..c440f0582c33 --- /dev/null +++ b/test/e2e/app-dir/app/app/link-hard-push/[id]/page.server.js @@ -0,0 +1,14 @@ +import Link from 'next/link' +import { nanoid } from 'nanoid' + +export default function Page({ params }) { + const other = params.id === '123' ? '456' : '123' + return ( + <> +

{nanoid()}

{' '} + + To {other} + + + ) +} diff --git a/test/e2e/app-dir/app/app/link-hard-push/page.server.js b/test/e2e/app-dir/app/app/link-hard-push/page.server.js deleted file mode 100644 index 049e5be9c49c..000000000000 --- a/test/e2e/app-dir/app/app/link-hard-push/page.server.js +++ /dev/null @@ -1,9 +0,0 @@ -import Link from 'next/link' - -export default function Page() { - return ( - - With ID - - ) -} diff --git a/test/e2e/app-dir/app/app/link-hard-replace/[id]/page.server.js b/test/e2e/app-dir/app/app/link-hard-replace/[id]/page.server.js new file mode 100644 index 000000000000..c90e93f74c78 --- /dev/null +++ b/test/e2e/app-dir/app/app/link-hard-replace/[id]/page.server.js @@ -0,0 +1,14 @@ +import Link from 'next/link' +import { nanoid } from 'nanoid' + +export default function Page({ params }) { + const other = params.id === '123' ? '456' : '123' + return ( + <> +

{nanoid()}

{' '} + + To {other} + + + ) +} diff --git a/test/e2e/app-dir/app/app/link-soft-push/page.server.js b/test/e2e/app-dir/app/app/link-soft-push/page.server.js index 3f5d6bde9ebf..049e5be9c49c 100644 --- a/test/e2e/app-dir/app/app/link-soft-push/page.server.js +++ b/test/e2e/app-dir/app/app/link-soft-push/page.server.js @@ -2,7 +2,7 @@ import Link from 'next/link' export default function Page() { return ( - + With ID ) diff --git a/test/e2e/app-dir/app/app/link-soft-replace/page.server.js b/test/e2e/app-dir/app/app/link-soft-replace/page.server.js index 4558d664be32..c65758d99076 100644 --- a/test/e2e/app-dir/app/app/link-soft-replace/page.server.js +++ b/test/e2e/app-dir/app/app/link-soft-replace/page.server.js @@ -5,10 +5,10 @@ export default function Page() { return ( <>

{nanoid()}

- + Self Link - + Subpage diff --git a/test/e2e/app-dir/app/app/link-soft-replace/subpage/page.server.js b/test/e2e/app-dir/app/app/link-soft-replace/subpage/page.server.js index 971f2843ed74..6ee17de06508 100644 --- a/test/e2e/app-dir/app/app/link-soft-replace/subpage/page.server.js +++ b/test/e2e/app-dir/app/app/link-soft-replace/subpage/page.server.js @@ -2,7 +2,7 @@ import Link from 'next/link' export default function Page() { return ( - + Self Link ) diff --git a/test/e2e/app-dir/app/app/navigation/link.client.js b/test/e2e/app-dir/app/app/navigation/link.client.js new file mode 100644 index 000000000000..545e4e9b8464 --- /dev/null +++ b/test/e2e/app-dir/app/app/navigation/link.client.js @@ -0,0 +1,24 @@ +import { useRouter } from 'next/dist/client/components/hooks-client' +import React from 'react' +import { useEffect } from 'react' +export default function HardLink({ href, children, ...props }) { + const router = useRouter() + useEffect(() => { + router.prefetch(href) + }, [router, href]) + return ( + { + e.preventDefault() + React.startTransition(() => { + router.push(href) + router.reload() + }) + }} + > + {children} + + ) +} diff --git a/test/e2e/app-dir/app/app/navigation/page.server.js b/test/e2e/app-dir/app/app/navigation/page.server.js index 1e53433733bb..2640936d496a 100644 --- a/test/e2e/app-dir/app/app/navigation/page.server.js +++ b/test/e2e/app-dir/app/app/navigation/page.server.js @@ -1,16 +1,16 @@ import { nanoid } from 'nanoid' -import Link from 'next/link' +import Link from './link.client.js' export default function Page() { return ( <>

{nanoid()}

hello from /navigation

- - useCookies + + useCookies - - useHeaders + + useHeaders ) diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 97e10d4ace06..6ff89b275888 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -293,66 +293,60 @@ describe('app dir', () => { }) describe('', () => { - // TODO-APP: fix development test - it.skip('should hard push', async () => { - const browser = await webdriver(next.url, '/link-hard-push') + it('should hard push', async () => { + const browser = await webdriver(next.url, '/link-hard-push/123') try { // Click the link on the page, and verify that the history entry was // added. expect(await browser.eval('window.history.length')).toBe(2) await browser.elementById('link').click() - await browser.waitForElementByCss('#render-id') + await browser.waitForElementByCss('#render-id-456') expect(await browser.eval('window.history.length')).toBe(3) // Get the id on the rendered page. - const firstID = await browser.elementById('render-id').text() + const firstID = await browser.elementById('render-id-456').text() // Go back, and redo the navigation by clicking the link. await browser.back() await browser.elementById('link').click() - await browser.waitForElementByCss('#render-id') + await browser.waitForElementByCss('#render-id-456') // Get the id again, and compare, they should not be the same. - const secondID = await browser.elementById('render-id').text() + const secondID = await browser.elementById('render-id-456').text() expect(secondID).not.toBe(firstID) } finally { await browser.close() } }) - // TODO-APP: fix development test - it.skip('should hard replace', async () => { - const browser = await webdriver(next.url, '/link-hard-replace') + it('should hard replace', async () => { + const browser = await webdriver(next.url, '/link-hard-replace/123') try { - // Get the render ID so we can compare it. - const firstID = await browser.elementById('render-id').text() - // Click the link on the page, and verify that the history entry was NOT // added. expect(await browser.eval('window.history.length')).toBe(2) - await browser.elementById('self-link').click() - await browser.waitForElementByCss('#render-id') + await browser.elementById('link').click() + await browser.waitForElementByCss('#render-id-456') expect(await browser.eval('window.history.length')).toBe(2) // Get the date again, and compare, they should not be the same. - const secondID = await browser.elementById('render-id').text() - expect(secondID).not.toBe(firstID) + const firstId = await browser.elementById('render-id-456').text() // Navigate to the subpage, verify that the history entry was NOT added. - await browser.elementById('subpage-link').click() - await browser.waitForElementByCss('#back-link') + await browser.elementById('link').click() + await browser.waitForElementByCss('#render-id-123') expect(await browser.eval('window.history.length')).toBe(2) // Navigate back again, verify that the history entry was NOT added. - await browser.elementById('back-link').click() - await browser.waitForElementByCss('#render-id') + await browser.elementById('link').click() + await browser.waitForElementByCss('#render-id-456') expect(await browser.eval('window.history.length')).toBe(2) // Get the date again, and compare, they should not be the same. - const thirdID = await browser.elementById('render-id').text() - expect(thirdID).not.toBe(secondID) + const secondId = await browser.elementById('render-id-456').text() + expect(firstId).not.toBe(secondId) } finally { await browser.close() } diff --git a/test/e2e/app-dir/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic.test.ts index 4b9b3471992a..c9996fe5ec0d 100644 --- a/test/e2e/app-dir/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic.test.ts @@ -119,7 +119,11 @@ describe('app dir - react server components', () => { page.on('request', (request) => { requestsCount++ const url = request.url() - if (/\?__flight__=1/.test(url)) { + if ( + url.includes('__flight__=1') && + // Prefetches also include `__flight__` + !url.includes('__flight_prefetch__=1') + ) { hasFlightRequest = true } }) @@ -231,7 +235,11 @@ describe('app dir - react server components', () => { beforePageLoad(page) { page.on('request', (request) => { const url = request.url() - if (/\?__flight__=1/.test(url)) { + if ( + url.includes('__flight__=1') && + // Prefetches also include `__flight__` + !url.includes('__flight_prefetch__=1') + ) { hasFlightRequest = true } }) diff --git a/test/e2e/app-dir/rsc-basic/app/next-api/link/page.server.js b/test/e2e/app-dir/rsc-basic/app/next-api/link/page.server.js index 280b98b735bb..50228a0988c2 100644 --- a/test/e2e/app-dir/rsc-basic/app/next-api/link/page.server.js +++ b/test/e2e/app-dir/rsc-basic/app/next-api/link/page.server.js @@ -17,7 +17,6 @@ export default function LinkPage({ queryId }) { } export function getServerSideProps(ctx) { - // FIXME: query is missing const { searchParams } = ctx return { props: { diff --git a/test/lib/next-webdriver.ts b/test/lib/next-webdriver.ts index a2d3ab211321..cdadbcf0cc1d 100644 --- a/test/lib/next-webdriver.ts +++ b/test/lib/next-webdriver.ts @@ -116,7 +116,9 @@ export default async function webdriver( // if it's not a Next.js app return if ( - document.documentElement.innerHTML.indexOf('__NEXT_DATA__') === -1 + document.documentElement.innerHTML.indexOf('__NEXT_DATA__') === -1 && + // @ts-ignore next exists on window if it's a Next.js page. + typeof (window as any).next?.version === 'undefined' ) { console.log('Not a next.js page, resolving hydrate check') callback()