From cdeac6697df0cc1b06b30c220cdc7befd42ef28c Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 23 Aug 2022 15:50:51 +0200 Subject: [PATCH 01/39] Add prefetch to new router Adds the initial event for prefetching. Implementation is WIP. --- .../client/components/app-router.client.tsx | 12 ++++- packages/next/client/components/reducer.ts | 47 ++++++++++++++++++- .../next/shared/lib/app-router-context.ts | 2 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 2899be27bb24d90..b57d3af83a8298c 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -12,6 +12,7 @@ import type { import type { FlightRouterState, FlightData } from '../../server/app-render' import { ACTION_NAVIGATE, + ACTION_PREFETCH, ACTION_RELOAD, ACTION_RESTORE, ACTION_SERVER_PATCH, @@ -106,6 +107,7 @@ export default function AppRouter({ parallelRoutes: typeof window === 'undefined' ? new Map() : initialParallelRoutes, }, + prefetchCache: new Map(), pushRef: { pendingPush: false, mpaNavigation: false }, focusAndScrollRef: { apply: false }, canonicalUrl: @@ -179,7 +181,15 @@ export default function AppRouter({ const routerInstance: AppRouterInstance = { // TODO-APP: implement prefetching of flight - prefetch: (_href) => Promise.resolve(), + prefetch: (href) => { + // @ts-ignore startTransition exists + React.startTransition(() => { + dispatch({ + type: ACTION_PREFETCH, + url: new URL(href, location.origin), + }) + }) + }, replace: (href) => { // @ts-ignore startTransition exists React.startTransition(() => { diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 2e5a19daba6f485..0b13aabebda1afa 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -327,6 +327,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. @@ -405,6 +406,11 @@ interface ServerPatchAction { cache: CacheNode } +interface PrefetchAction { + type: typeof ACTION_PREFETCH + url: URL +} + interface PushRef { /** * If the app-router should push a new history entry in app-router's useEffect() @@ -432,6 +438,10 @@ type AppRouterState = { * It also holds in-progress data requests. */ cache: CacheNode + /** + * Cache that holds prefetched Flight responses keyed by url + */ + prefetchCache: Map> /** * Decides if the update should create a new history entry and if the navigation can't be handled by app-router. */ @@ -452,7 +462,11 @@ type AppRouterState = { export function reducer( state: Readonly, action: Readonly< - ReloadAction | NavigateAction | RestoreAction | ServerPatchAction + | ReloadAction + | NavigateAction + | RestoreAction + | ServerPatchAction + | PrefetchAction > ): AppRouterState { switch (action.type) { @@ -466,6 +480,7 @@ export function reducer( pushRef: state.pushRef, focusAndScrollRef: state.focusAndScrollRef, cache: state.cache, + prefetchCache: state.prefetchCache, // Restore provided tree tree: tree, } @@ -500,6 +515,7 @@ export function reducer( focusAndScrollRef: { apply: true }, // Existing cache is used for soft navigation. cache: state.cache, + prefetchCache: state.prefetchCache, // Optimistic tree is applied. tree: optimisticTree, } @@ -523,6 +539,7 @@ export function reducer( focusAndScrollRef: { apply: true }, // Apply cache. cache: cache, + prefetchCache: state.prefetchCache, // Apply patched router state. tree: mutable.patchedTree, } @@ -572,6 +589,7 @@ export function reducer( focusAndScrollRef: { apply: true }, // Apply patched cache. cache: cache, + prefetchCache: state.prefetchCache, // Apply optimistic tree. tree: optimisticTree, } @@ -597,6 +615,7 @@ export function reducer( // Don't apply scroll and focus management. focusAndScrollRef: { apply: false }, cache: state.cache, + prefetchCache: state.prefetchCache, tree: state.tree, } } @@ -638,6 +657,7 @@ export function reducer( focusAndScrollRef: { apply: true }, // Apply patched cache. cache: cache, + prefetchCache: state.prefetchCache, // Apply patched tree. tree: newTree, } @@ -669,6 +689,7 @@ export function reducer( focusAndScrollRef: { apply: false }, // Other state is kept as-is. cache: state.cache, + prefetchCache: state.prefetchCache, tree: state.tree, } } @@ -700,6 +721,7 @@ export function reducer( focusAndScrollRef: state.focusAndScrollRef, // Apply patched router state tree: newTree, + prefetchCache: state.prefetchCache, // Apply patched cache cache: cache, } @@ -724,6 +746,7 @@ export function reducer( // TODO-APP: might need to disable this for Fast Refresh. focusAndScrollRef: { apply: true }, cache: cache, + prefetchCache: state.prefetchCache, tree: mutable.patchedTree, } } @@ -746,6 +769,7 @@ export function reducer( pushRef: { pendingPush: true, mpaNavigation: true }, focusAndScrollRef: { apply: false }, cache: state.cache, + prefetchCache: state.prefetchCache, tree: state.tree, } } @@ -787,10 +811,31 @@ export function reducer( focusAndScrollRef: { apply: false }, // Apply patched cache. cache: cache, + prefetchCache: state.prefetchCache, // Apply patched router state. tree: newTree, } } + case ACTION_PREFETCH: { + const { url } = action + const href = url.pathname + url.search + url.hash + // url is already prefetched. + if (state.prefetchCache.has(href)) { + return state + } + + // state.prefetchCache.set( + // href, + // fetchServerResponse(url, [ + // state.tree[0], + // state.tree[1], + // state.tree[2], + // 'refetch', + // ]) + // ) + + return state + } // This case should never be hit as dispatch is strongly typed. default: throw new Error('Unknown action') diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 06c78f84e797f70..eef84f3c522938e 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -52,7 +52,7 @@ export type AppRouterInstance = { /** * 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( From b0b3d0ee1ef9672cc68ea004af2fe2aa9219cf96 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 24 Aug 2022 15:05:25 +0200 Subject: [PATCH 02/39] Add Promise.resolve to compat between new and old router prefetch --- packages/next/client/link.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index ceea17414c9b12f..bdbfac6f4179c5f 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -79,7 +79,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 From 46b41448cf12792f3faa77c183c409599182805c Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 24 Aug 2022 15:15:13 +0200 Subject: [PATCH 03/39] Add test app for prefetch --- .../app-prefetch/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 +++ test/e2e/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 8 files changed, 63 insertions(+) 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 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 000000000000000..84ebbb490d40615 --- /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 000000000000000..b515fb9bf3d3766 --- /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 000000000000000..e74475359606e29 --- /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, 10000)) + 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 000000000000000..c84b681925ebc89 --- /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 000000000000000..a0e67e9acf3774c --- /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 000000000000000..5cb37da2ad8a970 --- /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 000000000000000..b76b309cf1e3538 --- /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 000000000000000..e69de29bb2d1d64 From 2617766bd64a39b60f1fd50c65f699e62d86517d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 25 Aug 2022 19:53:40 +0200 Subject: [PATCH 04/39] WIP --- .../client/components/app-router.client.tsx | 11 ++++-- .../components/layout-router.client.tsx | 34 +++++++++---------- packages/next/client/components/reducer.ts | 20 +++++------ packages/next/server/app-render.tsx | 31 +++++++++++++++-- .../app-prefetch/app/dashboard/page.server.js | 2 +- 5 files changed, 63 insertions(+), 35 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index b57d3af83a8298c..ae47297940373cd 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -30,7 +30,8 @@ import { */ function fetchFlight( url: URL, - flightRouterState: FlightRouterState + flightRouterState: FlightRouterState, + prefetch?: true ): ReadableStream { const flightUrl = new URL(url) const searchParams = flightUrl.searchParams @@ -41,6 +42,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() @@ -57,10 +61,11 @@ 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)) } /** diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index bffb974c706e8d0..86310b06e81016b 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -114,7 +114,7 @@ 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 && !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, { @@ -230,22 +230,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 0b13aabebda1afa..270a43260c80378 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -469,6 +469,7 @@ export function reducer( | PrefetchAction > ): AppRouterState { + console.log(action.type, action) switch (action.type) { case ACTION_RESTORE: { const { url, tree } = action @@ -549,7 +550,8 @@ export function reducer( /** * If the tree can be optimistically rendered and suspend in layout-router instead of in the reducer. */ - const isOptimistic = canOptimisticallyRender(segments, state.tree) + const isOptimistic = + false && canOptimisticallyRender(segments, state.tree) // Optimistic tree case. if (isOptimistic) { @@ -600,7 +602,11 @@ export function reducer( // If no in-flight fetch at the top, start it. if (!cache.data) { - cache.data = fetchServerResponse(url, state.tree) + const prefetchResponse = state.prefetchCache.get(href) + console.log({ prefetchResponse }) + cache.data = prefetchResponse + ? prefetchResponse + : fetchServerResponse(url, state.tree) } // readRoot to suspend here (in the reducer) until the fetch resolves. @@ -824,15 +830,7 @@ export function reducer( return state } - // state.prefetchCache.set( - // href, - // fetchServerResponse(url, [ - // state.tree[0], - // state.tree[1], - // state.tree[2], - // 'refetch', - // ]) - // ) + state.prefetchCache.set(href, fetchServerResponse(url, state.tree, true)) return state } diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 012a0682d01aba9..7dbcc71e2917e5f 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -342,6 +342,7 @@ export type FlightData = Array | string export type ChildProp = { current: React.ReactNode segment: Segment + partial: boolean } /** @@ -429,6 +430,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) { @@ -678,6 +680,31 @@ export async function renderToHTMLOrFlight( ? [parallelRouteKey] : [actualSegment, parallelRouteKey] + const childSegment = parallelRoutes[parallelRouteKey][0] + const childSegmentParam = getDynamicParamFromSegment(childSegment) + + if (isPrefetch && Loading) { + const childProp: ChildProp = { + partial: true, + current: <>TEST, + 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) => { @@ -688,9 +715,8 @@ export async function renderToHTMLOrFlight( rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, }) - const childSegment = parallelRoutes[parallelRouteKey][0] - const childSegmentParam = getDynamicParamFromSegment(childSegment) const childProp: ChildProp = { + partial: false, current: , segment: childSegmentParam ? childSegmentParam.treeSegment @@ -915,7 +941,6 @@ export async function renderToHTMLOrFlight( loaderTree: loaderTreeToFilter, parentParams: currentParams, firstItem: true, - // parentSegmentPath: '', } ) ).Component 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 index e74475359606e29..d22dfdf51e84754 100644 --- a/test/e2e/app-dir/app-prefetch/app/dashboard/page.server.js +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/page.server.js @@ -1,5 +1,5 @@ export async function getServerSideProps() { - await new Promise((resolve) => setTimeout(resolve, 10000)) + await new Promise((resolve) => setTimeout(resolve, 3000)) return { props: { message: 'Welcome to the dashboard', From aa3938426677d2f753d3eac87acbde1bfb7263f3 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 13:15:34 +0200 Subject: [PATCH 05/39] Refactor prefetch handling --- .../client/components/app-router.client.tsx | 63 ++-- packages/next/client/components/reducer.ts | 288 +++++++++++------- 2 files changed, 210 insertions(+), 141 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index ae47297940373cd..e77828344aab2cf 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -103,24 +103,26 @@ export default function AppRouter({ children: React.ReactNode hotReloader?: React.ReactNode }) { - const [{ tree, cache, pushRef, focusAndScrollRef, canonicalUrl }, dispatch] = - React.useReducer(reducer, { - tree: initialTree, - cache: { - data: null, - subTreeData: children, - parallelRoutes: - typeof window === 'undefined' ? new Map() : initialParallelRoutes, - }, - prefetchCache: new Map(), - pushRef: { pendingPush: false, mpaNavigation: false }, - focusAndScrollRef: { apply: false }, - canonicalUrl: - initialCanonicalUrl + - // 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 : ''), - }) + const [ + { tree, cache, prefetchCache, pushRef, focusAndScrollRef, canonicalUrl }, + dispatch, + ] = React.useReducer(reducer, { + tree: initialTree, + cache: { + data: null, + subTreeData: children, + parallelRoutes: + typeof window === 'undefined' ? new Map() : initialParallelRoutes, + }, + prefetchCache: new Map(), + pushRef: { pendingPush: false, mpaNavigation: false }, + focusAndScrollRef: { apply: false }, + canonicalUrl: + initialCanonicalUrl + + // 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 : ''), + }) useEffect(() => { // Ensure initialParallelRoutes is cleaned up from memory once it's used. @@ -186,14 +188,23 @@ export default function AppRouter({ const routerInstance: AppRouterInstance = { // TODO-APP: implement prefetching of flight - prefetch: (href) => { - // @ts-ignore startTransition exists - React.startTransition(() => { - dispatch({ - type: ACTION_PREFETCH, - url: new URL(href, location.origin), + prefetch: async (href) => { + const url = new URL(href, location.origin) + const r = fetchServerResponse(url, tree, true) + try { + r.readRoot() + } catch (e) { + await e + const flightData = r.readRoot() + // @ts-ignore startTransition exists + React.startTransition(() => { + dispatch({ + type: ACTION_PREFETCH, + url, + flightData, + }) }) - }) + } }, replace: (href) => { // @ts-ignore startTransition exists @@ -266,7 +277,7 @@ export default function AppRouter({ // 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 } } /** diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 270a43260c80378..4b0ee72738f4de0 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -3,6 +3,7 @@ import type { FlightRouterState, FlightData, FlightDataPath, + FlightSegmentPath, } from '../../server/app-render' import { matchSegment } from './match-segments' import { fetchServerResponse } from './app-router.client' @@ -76,6 +77,53 @@ function fillCacheWithNewSubTreeData( ) } +/** + * 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 +202,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. @@ -377,6 +383,7 @@ interface NavigateAction { mutable: { previousTree?: FlightRouterState patchedTree?: FlightRouterState + useExistingCache?: true } } @@ -409,6 +416,7 @@ interface ServerPatchAction { interface PrefetchAction { type: typeof ACTION_PREFETCH url: URL + flightData: FlightData } interface PushRef { @@ -441,7 +449,13 @@ type AppRouterState = { /** * Cache that holds prefetched Flight responses keyed by url */ - prefetchCache: Map> + 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. */ @@ -492,6 +506,58 @@ export function reducer( const href = pathname + search + hash const pendingPush = navigateType === 'push' + // 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, + // 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 }, + // Apply cache. + cache: mutable.useExistingCache ? state.cache : cache, + prefetchCache: state.prefetchCache, + // Apply patched router state. + tree: mutable.patchedTree, + } + } + + 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 + ) + + mutable.previousTree = state.tree + mutable.patchedTree = newTree + mutable.useExistingCache = true + + 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: state.cache, + prefetchCache: state.prefetchCache, + // Apply patched tree. + tree: newTree, + } + } + const segments = pathname.split('/') // TODO-APP: figure out something better for index pages segments.push('') @@ -507,6 +573,10 @@ export function reducer( href ) + mutable.patchedTree = optimisticTree + mutable.previousTree = state.tree + mutable.useExistingCache = true + return { // Set href. canonicalUrl: href, @@ -526,87 +596,57 @@ export function reducer( // 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) - ) { + // TODO-APP: flag on the tree of which part of the tree for if there is a loading boundary + + // Optimistic tree 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 + ) + + // 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, - // 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. + // Apply patched cache. cache: cache, prefetchCache: state.prefetchCache, - // Apply patched router state. - tree: mutable.patchedTree, + // Apply optimistic tree. + tree: optimisticTree, } } - // 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 = - false && canOptimisticallyRender(segments, state.tree) - - // 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, - prefetchCache: state.prefetchCache, - // Apply optimistic tree. - tree: optimisticTree, - } - } - } - - // Below is the not-optimistic case. + // Below is the not-optimistic case where optimistic bailed. Data is fetched at the root and suspended there. // If no in-flight fetch at the top, start it. if (!cache.data) { - const prefetchResponse = state.prefetchCache.get(href) - console.log({ prefetchResponse }) - cache.data = prefetchResponse - ? prefetchResponse - : fetchServerResponse(url, state.tree) + cache.data = fetchServerResponse(url, state.tree) } // readRoot to suspend here (in the reducer) until the fetch resolves. @@ -823,14 +863,32 @@ export function reducer( } } case ACTION_PREFETCH: { - const { url } = action - const href = url.pathname + url.search + url.hash - // url is already prefetched. - if (state.prefetchCache.has(href)) { + const { url, flightData } = action + + // TODO-APP: Implement prefetch for hard navigation + if (typeof flightData === 'string') { return state } - state.prefetchCache.set(href, fetchServerResponse(url, state.tree, true)) + 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] = flightDataPath.slice(-2) + + // Path without the last segment, router state, and the subTreeData + const flightSegmentPath = flightDataPath.slice(0, -3) + + fillCacheWithPrefetchedSubTreeData(state.cache, flightDataPath) + + // Create new tree based on the flightSegmentPath and router state patch + state.prefetchCache.set(href, { + flightSegmentPath, + treePatch, + }) return state } From c7682142cab0bd9f0bd6776805e258f97f10c229 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 13:56:35 +0200 Subject: [PATCH 06/39] Add check for loading component one level down in the common layout --- packages/next/client/components/reducer.ts | 11 ++++--- packages/next/server/app-render.tsx | 37 ++++++++++++---------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 4b0ee72738f4de0..7c0c0f8b49ff257 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -877,13 +877,16 @@ export function reducer( const flightDataPath = flightData[0] // The one before last item is the router state tree patch - const [treePatch] = flightDataPath.slice(-2) + 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) + } // Path without the last segment, router state, and the subTreeData const flightSegmentPath = flightDataPath.slice(0, -3) - - fillCacheWithPrefetchedSubTreeData(state.cache, flightDataPath) - // Create new tree based on the flightSegmentPath and router state patch state.prefetchCache.set(href, { flightSegmentPath, diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 7dbcc71e2917e5f..f9e754b6e9ed26a 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -328,7 +328,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 ] /** @@ -340,7 +340,7 @@ export type FlightData = Array | string * Property holding the current subTreeData. */ export type ChildProp = { - current: React.ReactNode + current: React.ReactNode | null segment: Segment partial: boolean } @@ -686,7 +686,7 @@ export async function renderToHTMLOrFlight( if (isPrefetch && Loading) { const childProp: ChildProp = { partial: true, - current: <>TEST, + current: null, segment: childSegmentParam ? childSegmentParam.treeSegment : childSegment, @@ -931,20 +931,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, - } - ) - ).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 + ), ] } From 773ef2de797d70ddd8e99275f1a894c6df49e0f9 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 13:59:00 +0200 Subject: [PATCH 07/39] Remove loading indicator from router state --- packages/next/client/components/reducer.ts | 8 -------- packages/next/server/app-render.tsx | 8 +------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 7c0c0f8b49ff257..65d553dcf04c2ab 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -258,10 +258,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 } @@ -314,10 +310,6 @@ function applyRouterStatePatchToTree( // if (url) { // tree[2] = url // } - // Copy loading flag - if (flightRouterState[4]) { - tree[4] = flightRouterState[4] - } return tree } diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index f9e754b6e9ed26a..760b3e6dd0f98c0 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -299,8 +299,7 @@ export type FlightRouterState = [ segment: Segment, parallelRoutes: { [parallelRouterKey: string]: FlightRouterState }, url?: string, - refresh?: 'refetch', - loading?: 'loading' + refresh?: 'refetch' ] /** @@ -560,9 +559,7 @@ export async function renderToHTMLOrFlight( const createFlightRouterStateFromLoaderTree = ([ segment, parallelRoutes, - { loading }, ]: LoaderTree): FlightRouterState => { - const hasLoading = Boolean(loading) const dynamicParam = getDynamicParamFromSegment(segment) const segmentTree: FlightRouterState = [ @@ -582,9 +579,6 @@ export async function renderToHTMLOrFlight( ) } - if (hasLoading) { - segmentTree[4] = 'loading' - } return segmentTree } From 7bcec1b2ca9558fae674b9e5cfd93347582f93bf Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 15:24:59 +0200 Subject: [PATCH 08/39] Ensure links with prefetch disabled get optimistic behavior --- .../client/components/app-router.client.tsx | 20 ++-- packages/next/client/components/reducer.ts | 99 ++++++++++--------- packages/next/client/link.tsx | 23 +++-- .../next/shared/lib/app-router-context.ts | 14 ++- 4 files changed, 90 insertions(+), 66 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index e77828344aab2cf..7db6be850f5c200 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -170,11 +170,13 @@ export default function AppRouter({ 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), + forceOptimisticNavigation, cacheType, navigateType, cache: { @@ -206,28 +208,28 @@ export default function AppRouter({ }) } }, - replace: (href) => { + replace: (href, options) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'hard', 'replace') + navigate(href, 'hard', 'replace', options.forceOptimisticNavigation) }) }, - softReplace: (href) => { + softReplace: (href, options) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'soft', 'replace') + navigate(href, 'soft', 'replace', options.forceOptimisticNavigation) }) }, - softPush: (href) => { + softPush: (href, options) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'soft', 'push') + navigate(href, 'soft', 'push', options.forceOptimisticNavigation) }) }, - push: (href) => { + push: (href, options) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'hard', 'push') + navigate(href, 'hard', 'push', options.forceOptimisticNavigation) }) }, reload: () => { diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 65d553dcf04c2ab..f60ef7eb71f50e6 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -371,6 +371,7 @@ interface NavigateAction { url: URL cacheType: 'soft' | 'hard' navigateType: 'push' | 'replace' + forceOptimisticNavigation: boolean cache: CacheNode mutable: { previousTree?: FlightRouterState @@ -493,7 +494,14 @@ export function reducer( } } case ACTION_NAVIGATE: { - const { url, cacheType, navigateType, cache, mutable } = action + const { + url, + cacheType, + navigateType, + cache, + mutable, + forceOptimisticNavigation, + } = action const { pathname, search, hash } = url const href = pathname + search + hash const pendingPush = navigateType === 'push' @@ -588,53 +596,54 @@ export function reducer( // 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') { - // TODO-APP: flag on the tree of which part of the tree for if there is a loading boundary - - // Optimistic tree 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 - ) - - // 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, - prefetchCache: state.prefetchCache, - // Apply optimistic tree. - tree: optimisticTree, + // forceOptimisticNavigation is used for links that have `prefetch={false}`. + if (forceOptimisticNavigation) { + // Optimistic tree 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 + ) + + // 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, + prefetchCache: state.prefetchCache, + // Apply optimistic tree. + tree: optimisticTree, + } } } - // Below is the not-optimistic case where optimistic bailed. Data is fetched at the root and suspended there. + // Below is the not-optimistic case. Data is fetched at the root and suspended there without a suspense boundary. // If no in-flight fetch at the top, start it. if (!cache.data) { diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index bdbfac6f4179c5f..9e3f003eccda785 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -116,7 +116,8 @@ function linkClicked( shallow?: boolean, scroll?: boolean, locale?: string | false, - startTransition?: (cb: any) => void + startTransition?: (cb: any) => void, + prefetchEnabled?: boolean ): void { const { nodeName } = e.currentTarget @@ -144,7 +145,7 @@ function linkClicked( ? 'replace' : 'push' - router[method](href) + router[method](href, { forceOptimisticNavigation: !prefetchEnabled }) } else { router[replace ? 'replace' : 'push'](href, as, { shallow, @@ -459,7 +460,8 @@ const Link = React.forwardRef( shallow, scroll, locale, - appRouter ? startTransition : undefined + appRouter ? startTransition : undefined, + p ) } }, @@ -474,8 +476,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 +497,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/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index eef84f3c522938e..2f90397fdfc8c9b 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,22 +37,22 @@ export type AppRouterInstance = { * Hard navigate to the provided href. Fetches new data from the server. * Pushes a new history entry. */ - push(href: string): void + push(href: string, options: NavigateOptions): 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 + softPush(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 + replace(href: string, options: NavigateOptions): 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 + softReplace(href: string, options: NavigateOptions): void /** * Soft prefetch the provided href. Does not fetch data from the server if it was already fetched. */ From c651263d7da6182d009c490b2e34e692c3944989 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 15:49:32 +0200 Subject: [PATCH 09/39] Ensure options is defined --- .../client/components/app-router.client.tsx | 36 ++++++++++++++----- .../next/shared/lib/app-router-context.ts | 2 +- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 7db6be850f5c200..07c8306e38c35fb 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -208,28 +208,48 @@ export default function AppRouter({ }) } }, - replace: (href, options) => { + replace: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'hard', 'replace', options.forceOptimisticNavigation) + navigate( + href, + 'hard', + 'replace', + Boolean(options.forceOptimisticNavigation) + ) }) }, - softReplace: (href, options) => { + softReplace: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'soft', 'replace', options.forceOptimisticNavigation) + navigate( + href, + 'soft', + 'replace', + Boolean(options.forceOptimisticNavigation) + ) }) }, - softPush: (href, options) => { + softPush: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'soft', 'push', options.forceOptimisticNavigation) + navigate( + href, + 'soft', + 'push', + Boolean(options.forceOptimisticNavigation) + ) }) }, - push: (href, options) => { + push: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate(href, 'hard', 'push', options.forceOptimisticNavigation) + navigate( + href, + 'hard', + 'push', + Boolean(options.forceOptimisticNavigation) + ) }) }, reload: () => { diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 2f90397fdfc8c9b..3ff8a533226883d 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -25,7 +25,7 @@ export type CacheNode = { } interface NavigateOptions { - forceOptimisticNavigation: boolean + forceOptimisticNavigation?: boolean } export interface AppRouterInstance { From 515e7301b525dcf4703a55e251b518fd03617950 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 15:49:54 +0200 Subject: [PATCH 10/39] Remove debug log --- packages/next/client/components/reducer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index f60ef7eb71f50e6..aeb29714b0f37ce 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -476,7 +476,6 @@ export function reducer( | PrefetchAction > ): AppRouterState { - console.log(action.type, action) switch (action.type) { case ACTION_RESTORE: { const { url, tree } = action From 15d4b4fe874d074d28cd0ce9e29b6ee40c1a430f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 16:23:44 +0200 Subject: [PATCH 11/39] Fix eslint warnings --- .../components/layout-router.client.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 86310b06e81016b..737c2e4aae4e4cb 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -1,37 +1,40 @@ 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) +// } /** * Used to cache in createInfinitePromise From d13bb7c3c3a46a7902eead46540aaced64fc3626 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 21:14:56 +0200 Subject: [PATCH 12/39] Remove cacheType on link --- .../client/components/app-router.client.tsx | 38 +--- packages/next/client/components/reducer.ts | 212 +++++++++--------- packages/next/client/link.tsx | 25 +-- .../next/shared/lib/app-router-context.ts | 10 - 4 files changed, 111 insertions(+), 174 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 07c8306e38c35fb..f591f06d812de3b 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -169,7 +169,6 @@ export default function AppRouter({ const appRouter = React.useMemo(() => { const navigate = ( href: string, - cacheType: 'hard' | 'soft', navigateType: 'push' | 'replace', forceOptimisticNavigation: boolean ) => { @@ -177,7 +176,6 @@ export default function AppRouter({ type: ACTION_NAVIGATE, url: new URL(href, location.origin), forceOptimisticNavigation, - cacheType, navigateType, cache: { data: null, @@ -211,45 +209,13 @@ export default function AppRouter({ replace: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate( - href, - 'hard', - 'replace', - Boolean(options.forceOptimisticNavigation) - ) - }) - }, - softReplace: (href, options = {}) => { - // @ts-ignore startTransition exists - React.startTransition(() => { - navigate( - href, - 'soft', - 'replace', - Boolean(options.forceOptimisticNavigation) - ) - }) - }, - softPush: (href, options = {}) => { - // @ts-ignore startTransition exists - React.startTransition(() => { - navigate( - href, - 'soft', - 'push', - Boolean(options.forceOptimisticNavigation) - ) + navigate(href, 'replace', Boolean(options.forceOptimisticNavigation)) }) }, push: (href, options = {}) => { // @ts-ignore startTransition exists React.startTransition(() => { - navigate( - href, - 'hard', - 'push', - Boolean(options.forceOptimisticNavigation) - ) + navigate(href, 'push', Boolean(options.forceOptimisticNavigation)) }) }, reload: () => { diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index aeb29714b0f37ce..863a054f941a5fb 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -369,7 +369,6 @@ interface ReloadAction { interface NavigateAction { type: typeof ACTION_NAVIGATE url: URL - cacheType: 'soft' | 'hard' navigateType: 'push' | 'replace' forceOptimisticNavigation: boolean cache: CacheNode @@ -493,14 +492,8 @@ export function reducer( } } case ACTION_NAVIGATE: { - const { - url, - cacheType, - navigateType, - cache, - mutable, - forceOptimisticNavigation, - } = action + const { url, navigateType, cache, mutable, forceOptimisticNavigation } = + action const { pathname, search, hash } = url const href = pathname + search + hash const pendingPush = navigateType === 'push' @@ -561,8 +554,10 @@ export function reducer( // TODO-APP: figure out something better for index pages segments.push('') + const isSoftNavigation = false + // In case of soft push data fetching happens in layout-router if a segment is missing - if (cacheType === 'soft') { + if (isSoftNavigation) { // Create optimistic tree that causes missing data to be fetched in layout-router during render. const optimisticTree = createOptimisticTree( segments, @@ -594,124 +589,119 @@ export function reducer( // 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') { - // forceOptimisticNavigation is used for links that have `prefetch={false}`. - if (forceOptimisticNavigation) { - // Optimistic tree 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 - ) - - // 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, - prefetchCache: state.prefetchCache, - // Apply optimistic tree. - tree: optimisticTree, - } - } - } - // Below is the not-optimistic case. Data is fetched at the root and suspended there without a suspense boundary. + // forceOptimisticNavigation is used for links that have `prefetch={false}`. + if (forceOptimisticNavigation) { + // Optimistic tree 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, + // 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, - tree: state.tree, + // Apply optimistic tree. + tree: optimisticTree, } } + } - // Remove cache.data as it has been resolved at this point. - cache.data = null - - // 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] = flightDataPath.slice(-2) - - // Path without the last segment, router state, and the subTreeData - const flightSegmentPath = flightDataPath.slice(0, -3) + // Below is the not-optimistic case. Data is fetched at the root and suspended there without a suspense boundary. - // Create new tree based on the flightSegmentPath and router state patch - const newTree = applyRouterStatePatchToTree( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - state.tree, - treePatch - ) - - mutable.previousTree = state.tree - mutable.patchedTree = newTree + // If no in-flight fetch at the top, start it. + if (!cache.data) { + cache.data = fetchServerResponse(url, state.tree) + } - // 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) + // readRoot to suspend here (in the reducer) until the fetch resolves. + const flightData = cache.data.readRoot() + // Handle case when navigating to page in `pages` from `app` + if (typeof flightData === 'string') { 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, + 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, - // Apply patched tree. - tree: newTree, + tree: state.tree, } } - // 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') + // Remove cache.data as it has been resolved at this point. + cache.data = null + + // 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] = flightDataPath.slice(-2) + + // 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 + ) + + 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 diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 9e3f003eccda785..4d261b281e8c073 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -112,7 +112,6 @@ function linkClicked( href: string, as: string, replace?: boolean, - soft?: boolean, shallow?: boolean, scroll?: boolean, locale?: string | false, @@ -132,26 +131,19 @@ 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, { forceOptimisticNavigation: !prefetchEnabled }) - } 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 we're doing a soft navigation, use the soft variants of + // replace/push. + const method: keyof AppRouterInstance = replace ? 'replace' : 'push' + + router[method](href, { forceOptimisticNavigation: !prefetchEnabled }) } } @@ -456,7 +448,6 @@ const Link = React.forwardRef( href, as, replace, - soft, shallow, scroll, locale, diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 3ff8a533226883d..74f73d1916e8fad 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -38,21 +38,11 @@ export interface AppRouterInstance { * Pushes a new history entry. */ push(href: string, options: NavigateOptions): 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, 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, options: NavigateOptions): 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, options: NavigateOptions): void /** * Soft prefetch the provided href. Does not fetch data from the server if it was already fetched. */ From 176d88a6db1146b91ea174da0c4382b1927f7b79 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 30 Aug 2022 21:16:46 +0200 Subject: [PATCH 13/39] Remove leftover soft prop --- packages/next/client/link.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 4d261b281e8c073..26ed48d1404c55f 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 @@ -139,8 +134,7 @@ function linkClicked( scroll, }) } else { - // If we're doing a soft navigation, use the soft variants of - // replace/push. + // If `beforePopState` doesn't exist on the router it's the AppRouter. const method: keyof AppRouterInstance = replace ? 'replace' : 'push' router[method](href, { forceOptimisticNavigation: !prefetchEnabled }) @@ -205,7 +199,6 @@ const Link = React.forwardRef( const optionalPropsGuard: Record = { as: true, replace: true, - soft: true, scroll: true, shallow: true, passHref: true, @@ -252,7 +245,6 @@ const Link = React.forwardRef( } } else if ( key === 'replace' || - key === 'soft' || key === 'scroll' || key === 'shallow' || key === 'passHref' || @@ -293,7 +285,6 @@ const Link = React.forwardRef( prefetch: prefetchProp, passHref, replace, - soft, shallow, scroll, locale, From 2715c249fe0b3ea16c92b9c9653cd96059e47161 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 31 Aug 2022 14:02:30 +0200 Subject: [PATCH 14/39] Remove additional soft navigation check --- packages/next/client/components/reducer.ts | 62 ++++++---------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 863a054f941a5fb..aa603d9e27a9105 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -476,21 +476,6 @@ export function reducer( > ): 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, - prefetchCache: state.prefetchCache, - // Restore provided tree - tree: tree, - } - } case ACTION_NAVIGATE: { const { url, navigateType, cache, mutable, forceOptimisticNavigation } = action @@ -554,38 +539,6 @@ export function reducer( // TODO-APP: figure out something better for index pages segments.push('') - const isSoftNavigation = false - - // In case of soft push data fetching happens in layout-router if a segment is missing - if (isSoftNavigation) { - // Create optimistic tree that causes missing data to be fetched in layout-router during render. - const optimisticTree = createOptimisticTree( - segments, - state.tree, - true, - false, - href - ) - - mutable.patchedTree = optimisticTree - mutable.previousTree = state.tree - mutable.useExistingCache = true - - return { - // Set href. - canonicalUrl: href, - // Set pendingPush. mpaNavigation is handled during rendering in layout-router for this case. - 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, - prefetchCache: state.prefetchCache, - // Optimistic tree is applied. - tree: optimisticTree, - } - } - // 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 @@ -762,6 +715,21 @@ export function reducer( cache: cache, } } + 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, + prefetchCache: state.prefetchCache, + // Restore provided tree + tree: tree, + } + } case ACTION_RELOAD: { const { url, cache, mutable } = action const href = url.pathname + url.search + url.hash From 52635da1ed42b8830a489dbeda918bc93b6bf9f9 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 31 Aug 2022 17:32:47 +0200 Subject: [PATCH 15/39] Add prefetch check to not trigger additional prefetches --- .../next/client/components/app-router.client.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index f591f06d812de3b..576ac7e75543299 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -89,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. */ @@ -189,8 +191,16 @@ export default function AppRouter({ const routerInstance: AppRouterInstance = { // TODO-APP: implement prefetching of flight 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) - const r = fetchServerResponse(url, tree, true) + // TODO-APP: handle case where history.state is not the new router history entry + const r = fetchServerResponse(url, window.history.state.tree, true) try { r.readRoot() } catch (e) { From 498fbc0c3a87a930bfc98f0a0f8dddd25eb8fa85 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 1 Sep 2022 13:12:02 +0200 Subject: [PATCH 16/39] Add handling of soft push --- .../components/layout-router.client.tsx | 11 ++- packages/next/client/components/reducer.ts | 85 +++++++++++++++++-- packages/next/server/app-render.tsx | 7 +- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 737c2e4aae4e4cb..a81ddf13d2cda57 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -117,7 +117,13 @@ export function InnerLayoutRouter({ let childNode = childNodes.get(path) // If childProp is available this means it's the Flight / SSR case. - if (childProp && !childNode && !childProp.partial) { + 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, { @@ -191,9 +197,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(), }) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index aa603d9e27a9105..cd86c643cd8b8af 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -77,6 +77,67 @@ 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) + } + + 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) + } + + // In case of last entry don't copy further down. + if (isLastEntry) { + if (childCacheNode) { + childCacheNode.parallelRoutes.clear() + } + return + } + + 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. @@ -518,7 +579,22 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = newTree - mutable.useExistingCache = true + + const hardNavigate = true + if (hardNavigate) { + // 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 + + invalidateCacheBelowFlightSegmentPath( + cache, + state.cache, + flightSegmentPath + ) + } else { + mutable.useExistingCache = true + } return { // Set href. @@ -528,7 +604,7 @@ export function reducer( // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, // Apply patched cache. - cache: state.cache, + cache: hardNavigate ? cache : state.cache, prefetchCache: state.prefetchCache, // Apply patched tree. tree: newTree, @@ -843,11 +919,10 @@ export function reducer( fillCacheWithPrefetchedSubTreeData(state.cache, flightDataPath) } - // 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 state.prefetchCache.set(href, { - flightSegmentPath, + // Path without the last segment, router state, and the subTreeData + flightSegmentPath: flightDataPath.slice(0, -2), treePatch, }) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 760b3e6dd0f98c0..32310dafc552044 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -339,9 +339,11 @@ export type FlightData = Array | string * Property holding the current subTreeData. */ export type ChildProp = { + /** + * Null indicates that the tree is partial + */ current: React.ReactNode | null segment: Segment - partial: boolean } /** @@ -679,7 +681,7 @@ export async function renderToHTMLOrFlight( if (isPrefetch && Loading) { const childProp: ChildProp = { - partial: true, + // Null indicates the tree is not fully rendered current: null, segment: childSegmentParam ? childSegmentParam.treeSegment @@ -710,7 +712,6 @@ export async function renderToHTMLOrFlight( }) const childProp: ChildProp = { - partial: false, current: , segment: childSegmentParam ? childSegmentParam.treeSegment From 3bf114d3a03b90cd1212af46fea6b441f0068ed7 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sat, 3 Sep 2022 13:52:09 +0200 Subject: [PATCH 17/39] Move walkAddRefetch --- .../components/layout-router.client.tsx | 99 ++++++++++--------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index a81ddf13d2cda57..63e7aa083c33228 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -36,6 +36,56 @@ import { fetchServerResponse } from './app-router.client' // 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 +} + /** * Used to cache in createInfinitePromise */ @@ -139,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 */ From 698351b60c932264d34b056f86ba4826625bb40a Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sat, 3 Sep 2022 13:54:38 +0200 Subject: [PATCH 18/39] Ensure existing segments that are not affected do not get thrown out. --- packages/next/client/components/reducer.ts | 86 ++++++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index cd86c643cd8b8af..073e7623c977347 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -46,11 +46,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 } @@ -375,6 +395,35 @@ function applyRouterStatePatchToTree( return tree } +/** + * Apply the router state from the Flight response. Creates a new router state tree. + */ +function shouldHardNavigate( + flightSegmentPath: FlightData[0], + flightRouterState: FlightRouterState, + treePatch: FlightRouterState +): boolean { + const [segment, parallelRoutes] = flightRouterState + const [currentSegment, parallelRouteKey] = flightSegmentPath + + // Tree path returned from the server should always match up with the current tree in the browser + if (!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() @@ -464,6 +513,9 @@ interface ServerPatchAction { flightData: FlightData previousTree: FlightRouterState cache: CacheNode + mutable: { + patchedTree?: FlightRouterState + } } interface PrefetchAction { @@ -580,7 +632,12 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = newTree - const hardNavigate = true + const hardNavigate = shouldHardNavigate( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + state.tree, + newTree + ) if (hardNavigate) { // Fill in the cache with blank that holds the `data` field. // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. @@ -733,7 +790,7 @@ export function reducer( } } 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)) { @@ -743,6 +800,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 { @@ -773,6 +847,8 @@ export function reducer( treePatch ) + mutable.patchedTree = newTree + // Copy subTreeData for the root node of the cache. cache.subTreeData = state.cache.subTreeData fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) From 4d7f194642355c82f2480de649cc05d5c48e1cc5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sat, 3 Sep 2022 13:54:55 +0200 Subject: [PATCH 19/39] Add additional page for testing --- .../app/dashboard/[id]/page.server.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.server.js 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 000000000000000..5b4a6e8078cdbc2 --- /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 + + ) +} From 9d014a190864cecceccc1d384737a328d0694ba5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sat, 3 Sep 2022 15:43:28 +0200 Subject: [PATCH 20/39] Connect useReducer to Redux Devtools --- .../client/components/app-router.client.tsx | 112 ++++++------ .../components/use-reducer-with-devtools.ts | 163 ++++++++++++++++++ 2 files changed, 224 insertions(+), 51 deletions(-) create mode 100644 packages/next/client/components/use-reducer-with-devtools.ts diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 576ac7e75543299..45965ce9b3d834a 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, @@ -24,6 +25,7 @@ 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. @@ -71,9 +73,7 @@ export function fetchServerResponse( /** * 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 { @@ -102,29 +102,33 @@ export default function AppRouter({ }: { initialTree: FlightRouterState initialCanonicalUrl: string - children: React.ReactNode - hotReloader?: React.ReactNode + children: ReactNode + hotReloader?: ReactNode }) { + const initialState = useMemo(() => { + return { + tree: initialTree, + cache: { + data: null, + subTreeData: children, + parallelRoutes: + typeof window === 'undefined' ? new Map() : initialParallelRoutes, + }, + prefetchCache: new Map(), + pushRef: { pendingPush: false, mpaNavigation: false }, + focusAndScrollRef: { apply: false }, + canonicalUrl: + initialCanonicalUrl + + // 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, - ] = React.useReducer(reducer, { - tree: initialTree, - cache: { - data: null, - subTreeData: children, - parallelRoutes: - typeof window === 'undefined' ? new Map() : initialParallelRoutes, - }, - prefetchCache: new Map(), - pushRef: { pendingPush: false, mpaNavigation: false }, - focusAndScrollRef: { apply: false }, - canonicalUrl: - initialCanonicalUrl + - // 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 : ''), - }) + sync, + ] = useReducerWithReduxDevtools(reducer, initialState) useEffect(() => { // Ensure initialParallelRoutes is cleaned up from memory once it's used. @@ -132,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 @@ -149,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, @@ -160,15 +164,16 @@ 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, navigateType: 'push' | 'replace', @@ -248,7 +253,7 @@ export default function AppRouter({ } return routerInstance - }, []) + }, [dispatch]) useEffect(() => { // When mpaNavigation flag is set do a hard navigation to the new url. @@ -269,7 +274,9 @@ 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. @@ -283,33 +290,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/use-reducer-with-devtools.ts b/packages/next/client/components/use-reducer-with-devtools.ts new file mode 100644 index 000000000000000..3194bbd6ecd4d12 --- /dev/null +++ b/packages/next/client/components/use-reducer-with-devtools.ts @@ -0,0 +1,163 @@ +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 +} + +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) { + return + } + + if (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] +} From 7eb0f751ecb5c9de4f61a27c060a23ea04374933 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sat, 3 Sep 2022 16:17:34 +0200 Subject: [PATCH 21/39] Simplify return --- .../next/client/components/use-reducer-with-devtools.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/next/client/components/use-reducer-with-devtools.ts b/packages/next/client/components/use-reducer-with-devtools.ts index 3194bbd6ecd4d12..7245526f7019ee4 100644 --- a/packages/next/client/components/use-reducer-with-devtools.ts +++ b/packages/next/client/components/use-reducer-with-devtools.ts @@ -115,11 +115,7 @@ export function useReducerWithReduxDevtools( const enabledRef = useRef() useEffect(() => { - if (devtoolsConnectionRef.current) { - return - } - - if (enabledRef.current === false) { + if (devtoolsConnectionRef.current || enabledRef.current === false) { return } From 00add2e5fda1afe54cd51b14539bb0d7d76b5668 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 4 Sep 2022 17:10:03 +0200 Subject: [PATCH 22/39] Fix test check for __flight__ --- test/e2e/app-dir/rsc-basic.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/app-dir/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic.test.ts index 015c055a76c92ff..23707387d49662e 100644 --- a/test/e2e/app-dir/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic.test.ts @@ -114,7 +114,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 } }) From ae2fce9f14333ac569b5da2bbfcd3c887ac2dce4 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 4 Sep 2022 17:10:14 +0200 Subject: [PATCH 23/39] Disable logReducer --- .../components/use-reducer-with-devtools.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/next/client/components/use-reducer-with-devtools.ts b/packages/next/client/components/use-reducer-with-devtools.ts index 7245526f7019ee4..b9c73c99ae06a18 100644 --- a/packages/next/client/components/use-reducer-with-devtools.ts +++ b/packages/next/client/components/use-reducer-with-devtools.ts @@ -61,20 +61,21 @@ function normalizeRouterState(val: any): any { return val } -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 - } -} +// 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 { @@ -143,7 +144,7 @@ export function useReducerWithReduxDevtools( }, [initialState]) const [state, dispatch] = useReducer( - devToolReducer(logReducer(fn), devtoolsConnectionRef), + devToolReducer(/* logReducer( */ fn /*)*/, devtoolsConnectionRef), initialState ) From 67f0203fd25f7b46010bff3cd104732688d64f48 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 4 Sep 2022 17:10:51 +0200 Subject: [PATCH 24/39] Fix check for last segment --- packages/next/client/components/reducer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 073e7623c977347..d47472d9e7547ae 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -411,7 +411,7 @@ function shouldHardNavigate( return true } - const lastSegment = flightSegmentPath.length === 2 + const lastSegment = flightSegmentPath.length <= 2 if (lastSegment) { return false From 5496d3d95cea5c20f13b557aabc9acd207890566 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 5 Sep 2022 10:59:15 +0200 Subject: [PATCH 25/39] Fix segment mismatch with prefetch --- packages/next/client/components/reducer.ts | 119 +++++++++++++-------- 1 file changed, 72 insertions(+), 47 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index d47472d9e7547ae..15d535a984392be 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -343,14 +343,16 @@ function createOptimisticTree( return result } +// type a = + /** * Apply the router state from the Flight response. Creates a new router state tree. */ -function applyRouterStatePatchToTree( +function applyRouterStatePatchToTree( flightSegmentPath: FlightData[0], flightRouterState: FlightRouterState, treePatch: FlightRouterState -): FlightRouterState { +): FlightRouterState | null { const [segment, parallelRoutes /* , url */] = flightRouterState // Root refresh @@ -368,22 +370,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, }, ] @@ -629,55 +640,57 @@ export function reducer( treePatch ) - mutable.previousTree = state.tree - mutable.patchedTree = newTree + if (newTree !== null) { + mutable.previousTree = state.tree + mutable.patchedTree = newTree - const hardNavigate = shouldHardNavigate( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - state.tree, - newTree - ) - if (hardNavigate) { - // 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 - - invalidateCacheBelowFlightSegmentPath( - cache, - state.cache, - flightSegmentPath + const hardNavigate = shouldHardNavigate( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + state.tree, + newTree ) - } else { - mutable.useExistingCache = true - } + if (hardNavigate) { + // 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 + + invalidateCacheBelowFlightSegmentPath( + cache, + state.cache, + flightSegmentPath + ) + } else { + mutable.useExistingCache = true + } - 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: hardNavigate ? cache : state.cache, - prefetchCache: state.prefetchCache, - // Apply patched tree. - tree: newTree, + 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: hardNavigate ? cache : state.cache, + prefetchCache: state.prefetchCache, + // Apply patched tree. + tree: newTree, + } } } - const segments = pathname.split('/') - // TODO-APP: figure out something better for index pages - segments.push('') - // 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 // 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 the optimistic tree is deeper than the current state leave that deeper part out of the fetch const optimisticTree = createOptimisticTree( @@ -767,6 +780,10 @@ export function reducer( treePatch ) + if (newTree === null) { + throw new Error('SEGMENT MISMATCH') + } + mutable.previousTree = state.tree mutable.patchedTree = newTree @@ -847,6 +864,10 @@ export function reducer( treePatch ) + if (newTree === null) { + throw new Error('SEGMENT MISMATCH') + } + mutable.patchedTree = newTree // Copy subTreeData for the root node of the cache. @@ -952,6 +973,10 @@ export function reducer( treePatch ) + if (newTree === null) { + throw new Error('SEGMENT MISMATCH') + } + mutable.previousTree = state.tree mutable.patchedTree = newTree From a15527c535a44703498d222aa4675361253a0ac8 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 5 Sep 2022 13:51:58 +0200 Subject: [PATCH 26/39] Remove fixme --- test/e2e/app-dir/rsc-basic/app/next-api/link/page.server.js | 1 - 1 file changed, 1 deletion(-) 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 280b98b735bbc82..50228a0988c29c3 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: { From 6aa8557c1f010666a22224d08d8738c3a37efe95 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 5 Sep 2022 13:54:15 +0200 Subject: [PATCH 27/39] Ensure last segment is removed --- packages/next/client/components/reducer.ts | 33 +++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 15d535a984392be..e2ff2126cca743a 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -125,6 +125,12 @@ function invalidateCacheBelowFlightSegmentPath( 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) @@ -143,14 +149,6 @@ function invalidateCacheBelowFlightSegmentPath( childSegmentMap.set(segmentForCache, childCacheNode) } - // In case of last entry don't copy further down. - if (isLastEntry) { - if (childCacheNode) { - childCacheNode.parallelRoutes.clear() - } - return - } - invalidateCacheBelowFlightSegmentPath( childCacheNode, existingChildCacheNode, @@ -644,14 +642,17 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = newTree - const hardNavigate = shouldHardNavigate( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - state.tree, - newTree - ) + const hardNavigate = + // TODO-APP: Revisit if this is correct. + search !== location.search || + shouldHardNavigate( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + state.tree, + newTree + ) + if (hardNavigate) { - // 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 @@ -673,7 +674,7 @@ export function reducer( // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, // Apply patched cache. - cache: hardNavigate ? cache : state.cache, + cache: mutable.useExistingCache ? state.cache : cache, prefetchCache: state.prefetchCache, // Apply patched tree. tree: newTree, From 6b274378e856f6338a292288aba8499a7c5a99ae Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 5 Sep 2022 14:39:17 +0200 Subject: [PATCH 28/39] Only check if dynamic parameter changed for hard navigation check --- packages/next/client/components/reducer.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index e2ff2126cca743a..d643128a5f168ec 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -4,6 +4,7 @@ import type { FlightData, FlightDataPath, FlightSegmentPath, + Segment, } from '../../server/app-render' import { matchSegment } from './match-segments' import { fetchServerResponse } from './app-router.client' @@ -404,19 +405,20 @@ function applyRouterStatePatchToTree( return tree } -/** - * Apply the router state from the Flight response. Creates a new router state tree. - */ function shouldHardNavigate( - flightSegmentPath: FlightData[0], + flightSegmentPath: FlightDataPath, flightRouterState: FlightRouterState, treePatch: FlightRouterState ): boolean { const [segment, parallelRoutes] = flightRouterState - const [currentSegment, parallelRouteKey] = flightSegmentPath + // TODO-APP: Check if `as` can be replaced. + const [currentSegment, parallelRouteKey] = flightSegmentPath as [ + Segment, + string + ] - // Tree path returned from the server should always match up with the current tree in the browser - if (!matchSegment(currentSegment, segment)) { + // 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 } From 5633f942f8043e3f4ca56d20f93727d5fd9ad8eb Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 11:15:55 +0200 Subject: [PATCH 29/39] Ensure app dir is handled as Next.js app in tests --- test/lib/next-webdriver.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/lib/next-webdriver.ts b/test/lib/next-webdriver.ts index a2d3ab21132149c..cdadbcf0cc1d9e7 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() From f68736535c9b96be6dc26ad2b54f190cb99872ab Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 11:16:40 +0200 Subject: [PATCH 30/39] Ensure router.reload() can be called after router.push() --- packages/next/client/components/reducer.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index d643128a5f168ec..73e6afc2705052c 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -455,7 +455,6 @@ export const ACTION_PREFETCH = 'prefetch' */ interface ReloadAction { type: typeof ACTION_RELOAD - url: URL cache: CacheNode mutable: { previousTree?: FlightRouterState @@ -907,10 +906,8 @@ export function reducer( } } case ACTION_RELOAD: { - const { url, cache, mutable } = action - const href = url.pathname + url.search + url.hash - // Reload is always a replace. - const pendingPush = false + const { cache, mutable } = action + const href = state.canonicalUrl // Handle concurrent rendering / strict mode case where the cache and tree were already populated. if ( @@ -921,7 +918,7 @@ 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 }, @@ -933,7 +930,7 @@ export function reducer( 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], @@ -990,7 +987,7 @@ 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. From e9b1bc4231d6b3b6ef6d6a41f385a350969525c1 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 11:17:05 +0200 Subject: [PATCH 31/39] When history state doesn't exist yet use initialTree --- packages/next/client/components/app-router.client.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 45965ce9b3d834a..5ecd3671c857418 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -205,7 +205,11 @@ export default function AppRouter({ 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, window.history.state.tree, true) + const r = fetchServerResponse( + url, + window.history.state?.tree || initialTree, + true + ) try { r.readRoot() } catch (e) { @@ -240,7 +244,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, From 051e5f1f5474c10008db4b58b7fabd44f9b877f3 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 11:18:31 +0200 Subject: [PATCH 32/39] Add comment about initialTree --- packages/next/client/components/app-router.client.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 5ecd3671c857418..5a083a6c4f2b55b 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -207,6 +207,7 @@ export default function AppRouter({ // 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 ) From a2d84a883bf6bef0b1784df16702f4d2d0de919f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 11:19:00 +0200 Subject: [PATCH 33/39] Update cookies and headers test to use router.reload() --- .../app-dir/app/app/navigation/link.client.js | 24 +++++++++++++++++++ .../app-dir/app/app/navigation/page.server.js | 10 ++++---- 2 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 test/e2e/app-dir/app/app/navigation/link.client.js 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 000000000000000..545e4e9b8464bfc --- /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 1e53433733bbb47..2640936d496a9c8 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 ) From 96d852b3a06dce9ad531a774b696b66a98c794df Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 11:29:25 +0200 Subject: [PATCH 34/39] Remove soft prop from tests --- test/e2e/app-dir/app/app/link-soft-push/page.server.js | 2 +- test/e2e/app-dir/app/app/link-soft-replace/page.server.js | 4 ++-- .../app-dir/app/app/link-soft-replace/subpage/page.server.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 3f5d6bde9ebfba7..049e5be9c49c777 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 4558d664be32c74..c65758d99076cf0 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 971f2843ed74e21..6ee17de06508983 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 ) From 0d5d1406f5df408911c50832a4dab3e75c8c0d9e Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 12:03:30 +0200 Subject: [PATCH 35/39] Update hard push/replace tests --- .../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 +++++++ test/e2e/app-dir/index.test.ts | 40 ++++++++----------- 4 files changed, 45 insertions(+), 32 deletions(-) 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 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 000000000000000..c440f0582c337e4 --- /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 049e5be9c49c777..000000000000000 --- 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 000000000000000..c90e93f74c78d0c --- /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/index.test.ts b/test/e2e/app-dir/index.test.ts index e9984ce8fa0ff74..7ff270225dd5267 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -274,66 +274,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() } From 01eefbc668bfbef5b7e5d3eb4204591477d82be5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 13:00:45 +0200 Subject: [PATCH 36/39] Fix lint --- packages/next/client/components/app-router.client.tsx | 2 +- packages/next/client/components/reducer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 5a083a6c4f2b55b..4f23c4907395f6f 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -257,7 +257,7 @@ export default function AppRouter({ } return routerInstance - }, [dispatch]) + }, [dispatch, initialTree]) useEffect(() => { // When mpaNavigation flag is set do a hard navigation to the new url. diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 73e6afc2705052c..2cb291e5eb61f17 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -347,7 +347,7 @@ function createOptimisticTree( /** * Apply the router state from the Flight response. Creates a new router state tree. */ -function applyRouterStatePatchToTree( +function applyRouterStatePatchToTree( flightSegmentPath: FlightData[0], flightRouterState: FlightRouterState, treePatch: FlightRouterState From 7f35952853504125bd6ced932a83b9a93d0d4f6f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 13:02:43 +0200 Subject: [PATCH 37/39] Fix tests --- test/e2e/app-dir/rsc-basic.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/app-dir/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic.test.ts index 23707387d49662e..9e156a1d8534017 100644 --- a/test/e2e/app-dir/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic.test.ts @@ -230,7 +230,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 } }) From 50ff42fd11a6db89e0d8cd2d6aa8be876adf4973 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 15:34:11 +0200 Subject: [PATCH 38/39] Update packages/next/client/components/reducer.ts Co-authored-by: Jiachi Liu --- packages/next/client/components/reducer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 2cb291e5eb61f17..3f4321c49ae57a2 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -342,7 +342,6 @@ function createOptimisticTree( return result } -// type a = /** * Apply the router state from the Flight response. Creates a new router state tree. From 6a53158c8dd140e5acd3ec2592d0448092d93f6a Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 6 Sep 2022 15:57:20 +0200 Subject: [PATCH 39/39] Fix prettier --- packages/next/client/components/reducer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 3f4321c49ae57a2..90c081d2f05968c 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -342,7 +342,6 @@ function createOptimisticTree( return result } - /** * Apply the router state from the Flight response. Creates a new router state tree. */