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()