From 0bbbae19b057a85aa8e6f10e2cf29710939c6c74 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 09:39:46 +0200 Subject: [PATCH 01/29] Convert const to function --- packages/next/client/components/reducer.ts | 23 ++++++++++------------ 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 2767862c24b5..9d7d7b8657a8 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -7,11 +7,11 @@ import type { import { matchSegment } from './match-segments' import { fetchServerResponse } from './app-router.client' -const fillCacheWithNewSubTreeData = ( +function fillCacheWithNewSubTreeData( newCache: CacheNode, existingCache: CacheNode, flightDataPath: FlightDataPath -) => { +): void { const isLastEntry = flightDataPath.length <= 4 const [parallelRouteKey, segment] = flightDataPath @@ -73,12 +73,12 @@ const fillCacheWithNewSubTreeData = ( ) } -const fillCacheWithDataProperty = ( +function fillCacheWithDataProperty( newCache: CacheNode, existingCache: CacheNode, segments: string[], fetchResponse: any -): { bailOptimistic: boolean } | undefined => { +): { bailOptimistic: boolean } | undefined { const isLastEntry = segments.length === 1 const parallelRouteKey = 'children' @@ -148,10 +148,10 @@ const fillCacheWithDataProperty = ( ) } -const canOptimisticallyRender = ( +function canOptimisticallyRender( segments: string[], flightRouterState: FlightRouterState -): boolean => { +): boolean { const segment = segments[0] const isLastSegment = segments.length === 1 const [existingSegment, existingParallelRoutes, , , loadingMarker] = @@ -186,13 +186,13 @@ const canOptimisticallyRender = ( ) } -const createOptimisticTree = ( +function createOptimisticTree( segments: string[], flightRouterState: FlightRouterState | null, _isFirstSegment: boolean, parentRefetch: boolean, _href?: string -): FlightRouterState => { +): FlightRouterState { const [existingSegment, existingParallelRoutes] = flightRouterState || [ null, {}, @@ -238,7 +238,6 @@ const createOptimisticTree = ( // if (isFirstSegment) { // result[2] = href // } - // Copy the loading flag from existing tree if (flightRouterState && flightRouterState[4]) { result[4] = flightRouterState[4] @@ -247,11 +246,11 @@ const createOptimisticTree = ( return result } -const walkTreeWithFlightDataPath = ( +function walkTreeWithFlightDataPath( flightSegmentPath: FlightData[0], flightRouterState: FlightRouterState, treePatch: FlightRouterState -): FlightRouterState => { +): FlightRouterState { const [segment, parallelRoutes /* , url */] = flightRouterState // Root refresh @@ -262,7 +261,6 @@ const walkTreeWithFlightDataPath = ( // if (url) { // tree[2] = url // } - return tree } @@ -293,7 +291,6 @@ const walkTreeWithFlightDataPath = ( // if (url) { // tree[2] = url // } - // Copy loading flag if (flightRouterState[4]) { tree[4] = flightRouterState[4] From 57c7626d89be1bdc0a8901776b26add766018eba Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 09:44:59 +0200 Subject: [PATCH 02/29] Remove already handled todo --- packages/next/client/components/reducer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 9d7d7b8657a8..8e9916209035 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -35,7 +35,7 @@ function fillCacheWithNewSubTreeData( const existingChildCacheNode = existingChildSegmentMap.get(segmentForCache) let childCacheNode = childSegmentMap.get(segmentForCache) - // In case of last segment start off the fetch at this level and don't copy further down. + // In case of last segment start the fetch at this level and don't copy further down. if (isLastEntry) { if ( !childCacheNode || @@ -469,7 +469,6 @@ export function reducer( cache.data = null - // TODO-APP: ensure flightDataPath does not have "" as first item const flightDataPath = flightData[0] const [treePatch] = flightDataPath.slice(-2) From 79b4365b2fe25f0572c8d7b01ad1734e08afb1e4 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 09:54:56 +0200 Subject: [PATCH 03/29] Refactor reducer to switch, remove payload, change type to variable --- .../client/components/app-router.client.tsx | 71 ++- packages/next/client/components/reducer.ts | 451 +++++++++--------- 2 files changed, 262 insertions(+), 260 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index a5ac17172dd5..acda53cd5d1b 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -10,7 +10,13 @@ import type { AppRouterInstance, } from '../../shared/lib/app-router-context' import type { FlightRouterState, FlightData } from '../../server/app-render' -import { reducer } from './reducer' +import { + ACTION_NAVIGATE, + ACTION_RELOAD, + ACTION_RESTORE, + ACTION_SERVER_PATCH, + reducer, +} from './reducer' import { QueryContext, // ParamsContext, @@ -109,15 +115,13 @@ export default function AppRouter({ const changeByServerResponse = React.useCallback( (previousTree: FlightRouterState, flightData: FlightData) => { dispatch({ - type: 'server-patch', - payload: { - flightData, - previousTree, - cache: { - data: null, - subTreeData: null, - parallelRoutes: new Map(), - }, + type: ACTION_SERVER_PATCH, + flightData, + previousTree, + cache: { + data: null, + subTreeData: null, + parallelRoutes: new Map(), }, }) }, @@ -131,18 +135,16 @@ export default function AppRouter({ navigateType: 'push' | 'replace' ) => { return dispatch({ - type: 'navigate', - payload: { - url: new URL(href, location.origin), - cacheType, - navigateType, - cache: { - data: null, - subTreeData: null, - parallelRoutes: new Map(), - }, - mutable: {}, + type: ACTION_NAVIGATE, + url: new URL(href, location.origin), + cacheType, + navigateType, + cache: { + data: null, + subTreeData: null, + parallelRoutes: new Map(), }, + mutable: {}, }) } @@ -177,17 +179,16 @@ export default function AppRouter({ // @ts-ignore startTransition exists React.startTransition(() => { dispatch({ - type: 'reload', - payload: { - // TODO-APP: revisit if this needs to be passed. - url: new URL(window.location.href), - cache: { - data: null, - subTreeData: null, - parallelRoutes: new Map(), - }, - mutable: {}, + type: ACTION_RELOAD, + + // TODO-APP: revisit if this needs to be passed. + url: new URL(window.location.href), + cache: { + data: null, + subTreeData: null, + parallelRoutes: new Map(), }, + mutable: {}, }) }) }, @@ -238,11 +239,9 @@ export default function AppRouter({ // Without startTransition works if the cache is there for this path React.startTransition(() => { dispatch({ - type: 'restore', - payload: { - url: new URL(window.location.href), - tree: state.tree, - }, + type: ACTION_RESTORE, + url: new URL(window.location.href), + tree: state.tree, }) }) }, []) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 8e9916209035..c0725dc7eb66 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -316,108 +316,72 @@ type AppRouterState = { canonicalUrl: string } +export const ACTION_RELOAD = 'reload' +export const ACTION_NAVIGATE = 'navigate' +export const ACTION_RESTORE = 'restore' +export const ACTION_SERVER_PATCH = 'server-patch' + export function reducer( state: AppRouterState, action: | { - type: 'reload' - payload: { - url: URL - cache: CacheNode - mutable: { - previousTree?: FlightRouterState - patchedTree?: FlightRouterState - } + type: typeof ACTION_RELOAD + url: URL + cache: CacheNode + mutable: { + previousTree?: FlightRouterState + patchedTree?: FlightRouterState } } | { - type: 'navigate' - payload: { - url: URL - cacheType: 'soft' | 'hard' - navigateType: 'push' | 'replace' - cache: CacheNode - mutable: { - previousTree?: FlightRouterState - patchedTree?: FlightRouterState - } + type: typeof ACTION_NAVIGATE + + url: URL + cacheType: 'soft' | 'hard' + navigateType: 'push' | 'replace' + cache: CacheNode + mutable: { + previousTree?: FlightRouterState + patchedTree?: FlightRouterState } } - | { type: 'restore'; payload: { url: URL; tree: FlightRouterState } } | { - type: 'server-patch' - payload: { - flightData: FlightData - previousTree: FlightRouterState - cache: CacheNode - } + type: typeof ACTION_RESTORE + url: URL + tree: FlightRouterState + } + | { + type: typeof ACTION_SERVER_PATCH + flightData: FlightData + previousTree: FlightRouterState + cache: CacheNode } ): AppRouterState { - if (action.type === 'restore') { - const { url, tree } = action.payload - const href = url.pathname + url.search + url.hash - - return { - canonicalUrl: href, - pushRef: state.pushRef, - focusRef: state.focusRef, - cache: state.cache, - tree: tree, - } - } - - if (action.type === 'navigate') { - const { url, cacheType, navigateType, cache, mutable } = action.payload - const pendingPush = navigateType === 'push' ? true : false - const { pathname } = url - const href = url.pathname + url.search + url.hash - - const segments = pathname.split('/') - // TODO-APP: figure out something better for index pages - segments.push('') - - // In case of soft push data fetching happens in layout-router if a segment is missing - if (cacheType === 'soft') { - const optimisticTree = createOptimisticTree( - segments, - state.tree, - true, - false, - href - ) + switch (action.type) { + case ACTION_RESTORE: { + const { url, tree } = action + const href = url.pathname + url.search + url.hash return { canonicalUrl: href, - pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + pushRef: state.pushRef, + focusRef: state.focusRef, cache: state.cache, - tree: optimisticTree, + tree: tree, } } - - // 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') { - if ( - mutable.patchedTree && - JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) - ) { - return { - canonicalUrl: href, - pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, - cache: cache, - tree: mutable.patchedTree, - } - } - - // TODO-APP: flag on the tree of which part of the tree for if there is a loading boundary - const isOptimistic = canOptimisticallyRender(segments, state.tree) - - if (isOptimistic) { - // Build optimistic tree - // If the optimistic tree is deeper than the current state leave that deeper part out of the fetch + case ACTION_NAVIGATE: { + const { url, cacheType, navigateType, cache, mutable } = action + const pendingPush = navigateType === 'push' ? true : false + const { pathname } = url + const href = url.pathname + url.search + url.hash + + const segments = pathname.split('/') + // TODO-APP: figure out something better for index pages + segments.push('') + + // In case of soft push data fetching happens in layout-router if a segment is missing + if (cacheType === 'soft') { const optimisticTree = createOptimisticTree( segments, state.tree, @@ -426,35 +390,130 @@ export function reducer( href ) - // Fill in the cache with blank that holds the `data` field. - // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. - cache.subTreeData = state.cache.subTreeData - const res = fillCacheWithDataProperty( - cache, - state.cache, - segments.slice(1), - () => { - return fetchServerResponse(url, optimisticTree) - } - ) + return { + canonicalUrl: href, + pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, + cache: state.cache, + tree: optimisticTree, + } + } - if (!res?.bailOptimistic) { - mutable.previousTree = state.tree - mutable.patchedTree = 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 + if (cacheType === 'hard') { + if ( + mutable.patchedTree && + JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) + ) { return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, focusRef: { focus: true }, cache: cache, - tree: optimisticTree, + tree: mutable.patchedTree, + } + } + + // TODO-APP: flag on the tree of which part of the tree for if there is a loading boundary + const isOptimistic = canOptimisticallyRender(segments, state.tree) + + if (isOptimistic) { + // Build optimistic tree + // 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. + cache.subTreeData = state.cache.subTreeData + const res = fillCacheWithDataProperty( + cache, + state.cache, + segments.slice(1), + () => { + return fetchServerResponse(url, optimisticTree) + } + ) + + if (!res?.bailOptimistic) { + mutable.previousTree = state.tree + mutable.patchedTree = optimisticTree + return { + canonicalUrl: href, + pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, + cache: cache, + tree: optimisticTree, + } + } + } + + if (!cache.data) { + cache.data = fetchServerResponse(url, state.tree) + } + const flightData = cache.data.readRoot() + + // Handle case when navigating to page in `pages` from `app` + if (typeof flightData === 'string') { + return { + canonicalUrl: flightData, + pushRef: { pendingPush: true, mpaNavigation: true }, + focusRef: { focus: false }, + cache: state.cache, + tree: state.tree, } } + + cache.data = null + + const flightDataPath = flightData[0] + + const [treePatch] = flightDataPath.slice(-2) + const treePath = flightDataPath.slice(0, -3) + const newTree = walkTreeWithFlightDataPath( + // TODO-APP: remove '' + ['', ...treePath], + state.tree, + treePatch + ) + + mutable.previousTree = state.tree + mutable.patchedTree = newTree + + cache.subTreeData = state.cache.subTreeData + fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) + + return { + canonicalUrl: href, + pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, + cache: cache, + tree: newTree, + } } - if (!cache.data) { - cache.data = fetchServerResponse(url, state.tree) + return state + } + case ACTION_SERVER_PATCH: { + const { flightData, previousTree, cache } = action + if (JSON.stringify(previousTree) !== JSON.stringify(state.tree)) { + // TODO-APP: Handle tree mismatch + console.log('TREE MISMATCH') + return { + canonicalUrl: state.canonicalUrl, + pushRef: state.pushRef, + focusRef: state.focusRef, + tree: state.tree, + cache: state.cache, + } } - const flightData = cache.data.readRoot() // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { @@ -467,12 +526,13 @@ export function reducer( } } - cache.data = null - + // TODO-APP: flightData could hold multiple paths const flightDataPath = flightData[0] - const [treePatch] = flightDataPath.slice(-2) + // Slices off the last segment (which is at -3) as it doesn't exist in the tree yet const treePath = flightDataPath.slice(0, -3) + const [treePatch] = flightDataPath.slice(-2) + const newTree = walkTreeWithFlightDataPath( // TODO-APP: remove '' ['', ...treePath], @@ -480,150 +540,93 @@ export function reducer( treePatch ) - mutable.previousTree = state.tree - mutable.patchedTree = newTree - cache.subTreeData = state.cache.subTreeData fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) return { - canonicalUrl: href, - pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, - cache: cache, + canonicalUrl: state.canonicalUrl, + pushRef: state.pushRef, + focusRef: state.focusRef, tree: newTree, + cache: cache, } } + case ACTION_RELOAD: { + const { url, cache, mutable } = action + const href = url.pathname + url.search + url.hash + const pendingPush = false - return state - } + // 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 (action.type === 'server-patch') { - const { flightData, previousTree, cache } = action.payload - if (JSON.stringify(previousTree) !== JSON.stringify(state.tree)) { - // TODO-APP: Handle tree mismatch - console.log('TREE MISMATCH') - return { - canonicalUrl: state.canonicalUrl, - pushRef: state.pushRef, - focusRef: state.focusRef, - tree: state.tree, - cache: state.cache, + if ( + mutable.patchedTree && + JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) + ) { + return { + canonicalUrl: href, + pushRef: { pendingPush, mpaNavigation: false }, + focusRef: { focus: true }, + cache: cache, + tree: mutable.patchedTree, + } } - } - // Handle case when navigating to page in `pages` from `app` - if (typeof flightData === 'string') { - return { - canonicalUrl: flightData, - pushRef: { pendingPush: true, mpaNavigation: true }, - focusRef: { focus: false }, - cache: state.cache, - tree: state.tree, + if (!cache.data) { + cache.data = fetchServerResponse(url, [ + state.tree[0], + state.tree[1], + state.tree[2], + 'refetch', + ]) } - } + const flightData = cache.data.readRoot() - // TODO-APP: flightData could hold multiple paths - const flightDataPath = flightData[0] + // Handle case when navigating to page in `pages` from `app` + if (typeof flightData === 'string') { + return { + canonicalUrl: flightData, + pushRef: { pendingPush: true, mpaNavigation: true }, + focusRef: { focus: false }, + cache: state.cache, + tree: state.tree, + } + } - // Slices off the last segment (which is at -3) as it doesn't exist in the tree yet - const treePath = flightDataPath.slice(0, -3) - const [treePatch] = flightDataPath.slice(-2) + cache.data = null - const newTree = walkTreeWithFlightDataPath( - // TODO-APP: remove '' - ['', ...treePath], - state.tree, - treePatch - ) + const flightDataPath = flightData[0] - cache.subTreeData = state.cache.subTreeData - fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) + if (flightDataPath.length !== 2) { + // TODO-APP: handle this case better + console.log('RELOAD FAILED') + return state + } - return { - canonicalUrl: state.canonicalUrl, - pushRef: state.pushRef, - focusRef: state.focusRef, - tree: newTree, - cache: cache, - } - } + const [treePatch, subTreeData] = flightDataPath.slice(-2) + const newTree = walkTreeWithFlightDataPath( + // TODO-APP: remove '' + [''], + state.tree, + treePatch + ) - if (action.type === 'reload') { - const { url, cache, mutable } = action.payload - const href = url.pathname + url.search + url.hash - const pendingPush = false + mutable.previousTree = state.tree + mutable.patchedTree = newTree - // When doing a hard push there can be two cases: with optimistic tree and without - // The with optimistic tree case only happens when the layouts have a loading state (loading.js) - // The without optimistic tree case happens when there is no loading state, in that case we suspend in this reducer + cache.subTreeData = subTreeData - if ( - mutable.patchedTree && - JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) - ) { return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, - cache: cache, - tree: mutable.patchedTree, - } - } - - if (!cache.data) { - cache.data = fetchServerResponse(url, [ - state.tree[0], - state.tree[1], - state.tree[2], - 'refetch', - ]) - } - const flightData = cache.data.readRoot() - - // Handle case when navigating to page in `pages` from `app` - if (typeof flightData === 'string') { - return { - canonicalUrl: flightData, - pushRef: { pendingPush: true, mpaNavigation: true }, + // TODO-APP: Revisit if this needs to be true in certain cases focusRef: { focus: false }, - cache: state.cache, - tree: state.tree, + cache: cache, + tree: newTree, } } - - cache.data = null - - const flightDataPath = flightData[0] - - if (flightDataPath.length !== 2) { - // TODO-APP: handle this case better - console.log('RELOAD FAILED') - return state - } - - const [treePatch, subTreeData] = flightDataPath.slice(-2) - const newTree = walkTreeWithFlightDataPath( - // TODO-APP: remove '' - [''], - state.tree, - treePatch - ) - - mutable.previousTree = state.tree - mutable.patchedTree = newTree - - cache.subTreeData = subTreeData - - return { - canonicalUrl: href, - pushRef: { pendingPush, mpaNavigation: false }, - // TODO-APP: Revisit if this needs to be true in certain cases - focusRef: { focus: false }, - cache: cache, - tree: newTree, - } + default: + throw new Error('Unknown action') } - - return state } From 3d7c9ab1c8fcbe71a9a9554d251f49e0fcb5388c Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 10:26:40 +0200 Subject: [PATCH 04/29] Infer useReducer type automatically --- packages/next/client/components/app-router.client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index acda53cd5d1b..b6572b7c7457 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -78,7 +78,7 @@ export default function AppRouter({ hotReloader?: React.ReactNode }) { const [{ tree, cache, pushRef, focusRef, canonicalUrl }, dispatch] = - React.useReducer(reducer, { + React.useReducer(reducer, { tree: initialTree, cache: { data: null, From e3ffe55ef8684918897037965616951944f29c25 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 10:53:55 +0200 Subject: [PATCH 05/29] Remove obsolete providers from app-render --- .../build/webpack/loaders/next-app-loader.ts | 1 - .../client/components/app-router.client.tsx | 47 ++++++++++++++----- .../client/components/hooks-client-context.ts | 6 ++- .../next/client/components/hooks-client.ts | 6 +-- packages/next/server/app-render.tsx | 28 ++++------- 5 files changed, 51 insertions(+), 37 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 6d9f6d790009..dce12a991f5f 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -130,7 +130,6 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ ? `require('next/dist/client/components/hot-reloader.client.js').default` : 'null' } - export const hooksClientContext = require('next/dist/client/components/hooks-client-context.js') export const __next_app_webpack_require__ = __webpack_require__ ` diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index b6572b7c7457..4d51530dabb0 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -18,17 +18,28 @@ import { reducer, } from './reducer' import { - QueryContext, + SearchParamsContext, // ParamsContext, PathnameContext, // LayoutSegmentsContext, } from './hooks-client-context' -function fetchFlight(url: URL, flightRouterStateData: string): ReadableStream { +/** + * Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side. + */ +function fetchFlight( + url: URL, + flightRouterState: FlightRouterState +): ReadableStream { const flightUrl = new URL(url) const searchParams = flightUrl.searchParams + // Enable flight response searchParams.append('__flight__', '1') - searchParams.append('__flight_router_state_tree__', flightRouterStateData) + // Provide the current router state + searchParams.append( + '__flight_router_state_tree__', + JSON.stringify(flightRouterState) + ) const { readable, writable } = new TransformStream() @@ -39,14 +50,20 @@ function fetchFlight(url: URL, flightRouterStateData: string): ReadableStream { return readable } +/** + * Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side. + */ export function fetchServerResponse( url: URL, flightRouterState: FlightRouterState ): { readRoot: () => FlightData } { - const flightRouterStateData = JSON.stringify(flightRouterState) - return createFromReadableStream(fetchFlight(url, flightRouterStateData)) + // Handle the `fetch` readable stream that can be read using `readRoot`. + return createFromReadableStream(fetchFlight(url, flightRouterState)) } +/** + * Renders development error overlay when NODE_ENV is development. + */ function ErrorOverlay({ children, }: React.PropsWithChildren<{}>): React.ReactElement { @@ -60,10 +77,14 @@ function ErrorOverlay({ } } +// Ensure the initialParallelRoutes are not combined because of double-rendering in the browser with Strict Mode. // TODO-APP: move this back into AppRouter let initialParallelRoutes: CacheNode['parallelRoutes'] = typeof window === 'undefined' ? null! : new Map() +/** + * The global router that wraps the application components. + */ export default function AppRouter({ initialTree, initialCanonicalUrl, @@ -96,19 +117,23 @@ export default function AppRouter({ }) useEffect(() => { + // Ensure initialParallelRoutes is cleaned up from memory once it's used. initialParallelRoutes = null! }, []) - const { query, pathname } = React.useMemo(() => { + // Add memoized pathname/query for useSearchParams and usePathname. + const { searchParams, pathname } = React.useMemo(() => { const url = new URL( canonicalUrl, typeof window === 'undefined' ? 'http://n' : window.location.href ) - const queryObj: { [key: string]: string } = {} + + // Convert searchParams to a plain object to match server-side. + const searchParamsObj: { [key: string]: string } = {} url.searchParams.forEach((value, key) => { - queryObj[key] = value + searchParamsObj[key] = value }) - return { query: queryObj, pathname: url.pathname } + return { searchParams: searchParamsObj, pathname: url.pathname } }, [canonicalUrl]) // Server response only patches the tree @@ -254,7 +279,7 @@ export default function AppRouter({ }, [onPopState]) return ( - + - + ) } diff --git a/packages/next/client/components/hooks-client-context.ts b/packages/next/client/components/hooks-client-context.ts index 47da2be415b7..4195c31a1d4d 100644 --- a/packages/next/client/components/hooks-client-context.ts +++ b/packages/next/client/components/hooks-client-context.ts @@ -1,13 +1,15 @@ import { createContext } from 'react' import { NextParsedUrlQuery } from '../../server/request-meta' -export const QueryContext = createContext(null as any) +export const SearchParamsContext = createContext( + null as any +) export const PathnameContext = createContext(null as any) export const ParamsContext = createContext(null as any) export const LayoutSegmentsContext = createContext(null as any) if (process.env.NODE_ENV !== 'production') { - QueryContext.displayName = 'QueryContext' + SearchParamsContext.displayName = 'QueryContext' PathnameContext.displayName = 'PathnameContext' ParamsContext.displayName = 'ParamsContext' LayoutSegmentsContext.displayName = 'LayoutSegmentsContext' diff --git a/packages/next/client/components/hooks-client.ts b/packages/next/client/components/hooks-client.ts index fd5da9cab5d9..461b013f99d4 100644 --- a/packages/next/client/components/hooks-client.ts +++ b/packages/next/client/components/hooks-client.ts @@ -2,7 +2,7 @@ import { useContext } from 'react' import { - QueryContext, + SearchParamsContext, // ParamsContext, PathnameContext, // LayoutSegmentsContext, @@ -13,11 +13,11 @@ import { } from '../../shared/lib/app-router-context' export function useSearchParams() { - return useContext(QueryContext) + return useContext(SearchParamsContext) } export function useSearchParam(key: string): string | string[] { - const params = useContext(QueryContext) + const params = useContext(SearchParamsContext) return params[key] } diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 171b09d35f1b..343a6f3ba391 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -830,29 +830,17 @@ export async function renderToHTML( const AppRouter = ComponentMod.AppRouter as typeof import('../client/components/app-router.client').default - const { - QueryContext, - PathnameContext, - // ParamsContext, - // LayoutSegmentsContext, - } = ComponentMod.hooksClientContext as typeof import('../client/components/hooks-client-context') const WrappedComponentTreeWithRouter = () => { return ( - - - {/* */} - } - initialCanonicalUrl={initialCanonicalUrl} - initialTree={initialTree} - initialStylesheets={initialStylesheets} - > - - - {/* */} - - + } + initialCanonicalUrl={initialCanonicalUrl} + initialTree={initialTree} + initialStylesheets={initialStylesheets} + > + + ) } From 8fc37335bd68a1b6553c45d5ddd54281773903b0 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 11:08:52 +0200 Subject: [PATCH 06/29] Add comments for AppRouterInstance --- .../client/components/app-router.client.tsx | 9 ++++++-- .../client/components/hooks-client-context.ts | 2 +- .../next/shared/lib/app-router-context.ts | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 4d51530dabb0..beec4b11f7ed 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -136,7 +136,9 @@ export default function AppRouter({ return { searchParams: searchParamsObj, pathname: url.pathname } }, [canonicalUrl]) - // Server response only patches the tree + /** + * Server response that only patches the cache and tree. + */ const changeByServerResponse = React.useCallback( (previousTree: FlightRouterState, flightData: FlightData) => { dispatch({ @@ -153,6 +155,9 @@ export default function AppRouter({ [] ) + /** + * 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 navigate = ( href: string, @@ -174,7 +179,7 @@ export default function AppRouter({ } const routerInstance: AppRouterInstance = { - // TODO-APP: implement prefetching of loading / flight + // TODO-APP: implement prefetching of flight prefetch: (_href) => Promise.resolve(), replace: (href) => { // @ts-ignore startTransition exists diff --git a/packages/next/client/components/hooks-client-context.ts b/packages/next/client/components/hooks-client-context.ts index 4195c31a1d4d..5740d03a8157 100644 --- a/packages/next/client/components/hooks-client-context.ts +++ b/packages/next/client/components/hooks-client-context.ts @@ -9,7 +9,7 @@ export const ParamsContext = createContext(null as any) export const LayoutSegmentsContext = createContext(null as any) if (process.env.NODE_ENV !== 'production') { - SearchParamsContext.displayName = 'QueryContext' + SearchParamsContext.displayName = 'SearchParamsContext' PathnameContext.displayName = 'PathnameContext' ParamsContext.displayName = 'ParamsContext' LayoutSegmentsContext.displayName = 'LayoutSegmentsContext' diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 38dc12f388e3..5d8a60a027c4 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -14,11 +14,33 @@ export type CacheNode = { } export type AppRouterInstance = { + /** + * Reload the current page. Fetches new data from the server. + */ reload(): void + /** + * Hard navigate to the provided href. Fetches new data from the server. + * Pushes a new history entry. + */ push(href: string): void + /** + * Soft navigate to the provided href. Does not fetch data from the server if it was already fetched. + * Pushes a new history entry. + */ softPush(href: string): void + /** + * Hard navigate to the provided href. Does not fetch data from the server if it was already fetched. + * Replaces the current history entry. + */ replace(href: string): void + /** + * Soft navigate to the provided href. Does not fetch data from the server if it was already fetched. + * Replaces the current history entry. + */ softReplace(href: string): void + /** + * Soft prefetch the provided href. Does not fetch data from the server if it was already fetched. + */ prefetch(href: string): Promise } From 00fcfacf9fe4491ab2ea85b95ea289dfebdbb1bb Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 11:34:01 +0200 Subject: [PATCH 07/29] Add comments to app-router --- .../next/client/components/app-router.client.tsx | 16 +++++++++++++--- .../client/components/hot-reloader.client.tsx | 4 ++-- .../client/components/layout-router.client.tsx | 4 ++-- packages/next/client/components/reducer.ts | 9 +++++++++ packages/next/shared/lib/app-router-context.ts | 4 ++-- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index beec4b11f7ed..f22de1b81994 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -3,7 +3,7 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we import { AppRouterContext, AppTreeContext, - FullAppTreeContext, + GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import type { CacheNode, @@ -228,6 +228,7 @@ export default function AppRouter({ }, []) useEffect(() => { + // When mpaNavigation flag is set do a hard navigation to the new url. if (pushRef.mpaNavigation) { window.location.href = canonicalUrl return @@ -238,6 +239,7 @@ export default function AppRouter({ // __N is used to identify if the history entry can be handled by the old router. const historyState = { __NA: true, tree } if (pushRef.pendingPush) { + // This intentionally mutates React state, pushRef is overwritten to ensure additional push/replace calls do not trigger an additional history entry. pushRef.pendingPush = false window.history.pushState(historyState, '', canonicalUrl) @@ -246,11 +248,18 @@ export default function AppRouter({ } }, [tree, pushRef, canonicalUrl]) + // Add `window.nd` for debugging purposes. + // This is not meant for use in applications as concurrent rendering will affect the cache/tree/router. if (typeof window !== 'undefined') { // @ts-ignore this is for debugging window.nd = { router: appRouter, cache, tree } } + /** + * Handle popstate event, this is used to handle back/forward in the browser. + * 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. @@ -276,6 +285,7 @@ export default function AppRouter({ }) }, []) + // Register popstate event to call onPopstate. React.useEffect(() => { window.addEventListener('popstate', onPopState) return () => { @@ -285,7 +295,7 @@ export default function AppRouter({ return ( - - + ) diff --git a/packages/next/client/components/hot-reloader.client.tsx b/packages/next/client/components/hot-reloader.client.tsx index 00092175e842..c8b9f18d5b59 100644 --- a/packages/next/client/components/hot-reloader.client.tsx +++ b/packages/next/client/components/hot-reloader.client.tsx @@ -6,7 +6,7 @@ import { // @ts-expect-error TODO-APP: startTransition exists startTransition, } from 'react' -import { FullAppTreeContext } from '../../shared/lib/app-router-context' +import { GlobalLayoutRouterContext } from '../../shared/lib/app-router-context' import { register, unregister, @@ -397,7 +397,7 @@ function processMessage( } export default function HotReload({ assetPrefix }: { assetPrefix: string }) { - const { tree } = useContext(FullAppTreeContext) + const { tree } = useContext(GlobalLayoutRouterContext) const router = useRouter() const webSocketRef = useRef() diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index dc9101d017fc..6406d6679ce6 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -8,7 +8,7 @@ import type { } from '../../server/app-render' import { AppTreeContext, - FullAppTreeContext, + GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import { fetchServerResponse } from './app-router.client' import { matchSegment } from './match-segments' @@ -72,7 +72,7 @@ export function InnerLayoutRouter({ changeByServerResponse, tree: fullTree, focusRef, - } = useContext(FullAppTreeContext) + } = useContext(GlobalLayoutRouterContext) const focusAndScrollRef = useRef(null) useEffect(() => { diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index c0725dc7eb66..0c0b589d117a 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -300,11 +300,20 @@ function walkTreeWithFlightDataPath( } type PushRef = { + /** + * If the app-router should push a new history entry in useEffect() + */ pendingPush: boolean + /** + * Multi-page navigation through location.href. + */ mpaNavigation: boolean } export type FocusRef = { + /** + * If focus should be set in the layout-router's useEffect() + */ focus: boolean } diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 5d8a60a027c4..c889f66858f8 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -53,7 +53,7 @@ export const AppTreeContext = React.createContext<{ url: string stylesheets?: string[] }>(null as any) -export const FullAppTreeContext = React.createContext<{ +export const GlobalLayoutRouterContext = React.createContext<{ tree: FlightRouterState changeByServerResponse: ( previousTree: FlightRouterState, @@ -65,5 +65,5 @@ export const FullAppTreeContext = React.createContext<{ if (process.env.NODE_ENV !== 'production') { AppRouterContext.displayName = 'AppRouterContext' AppTreeContext.displayName = 'AppTreeContext' - FullAppTreeContext.displayName = 'FullAppTreeContext' + GlobalLayoutRouterContext.displayName = 'GlobalLayoutRouterContext' } From 3daed15f2be9343e1c5b184b62be4ee57452fc2d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 11:41:00 +0200 Subject: [PATCH 08/29] Additional comments --- .../next/client/components/app-router.client.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index f22de1b81994..93a7b4167565 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -313,8 +313,16 @@ export default function AppRouter({ stylesheets: initialStylesheets, }} > - {cache.subTreeData} - {hotReloader} + + { + // ErrorOverlay intentionally only wraps the children of app-router. + cache.subTreeData + } + + { + // HotReloader uses the router tree and router.reload() in order to apply Server Component changes. + hotReloader + } From 6406e4b6016930b9b42902ac143a1cc6c402e589 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 11:47:29 +0200 Subject: [PATCH 09/29] Add todo --- 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 93a7b4167565..c2dd1fa8d3fc 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -41,6 +41,7 @@ function fetchFlight( JSON.stringify(flightRouterState) ) + // TODO-APP: Verify that TransformStream is supported. const { readable, writable } = new TransformStream() fetch(flightUrl.toString()).then((res) => { From b4cc0002f4a7953fadb9a07b51a5bd320aac14ed Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 11:56:44 +0200 Subject: [PATCH 10/29] Remove additional type --- packages/next/shared/lib/app-router-context.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index c889f66858f8..34edab97d88d 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -3,14 +3,13 @@ import type { FocusRef } from '../../client/components/reducer' import type { FlightRouterState, FlightData } from '../../server/app-render' export type ChildSegmentMap = Map -type ParallelRoutesCacheNodes = Map export type CacheNode = { data: ReturnType< typeof import('../../client/components/app-router.client').fetchServerResponse > | null subTreeData: null | React.ReactNode - parallelRoutes: ParallelRoutesCacheNodes + parallelRoutes: Map } export type AppRouterInstance = { From 93c2121a424c3c88ac23ae5188fabc02ae355c15 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 12:31:04 +0200 Subject: [PATCH 11/29] Add comment to hooks-client --- packages/next/client/components/hooks-client.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/next/client/components/hooks-client.ts b/packages/next/client/components/hooks-client.ts index 461b013f99d4..8606c757dc1c 100644 --- a/packages/next/client/components/hooks-client.ts +++ b/packages/next/client/components/hooks-client.ts @@ -12,16 +12,25 @@ import { AppTreeContext, } from '../../shared/lib/app-router-context' +/** + * Get the current search params. For example useSearchParams() would return {"foo": "bar"} when ?foo=bar + */ export function useSearchParams() { return useContext(SearchParamsContext) } +/** + * Get an individual search param. For example useSearchParam("foo") would return "bar" when ?foo=bar + */ export function useSearchParam(key: string): string | string[] { const params = useContext(SearchParamsContext) return params[key] } // TODO-APP: Move the other router context over to this one +/** + * Get the router methods. For example router.push('/dashboard') + */ export function useRouter(): import('../../shared/lib/app-router-context').AppRouterInstance { return useContext(AppRouterContext) } @@ -31,6 +40,9 @@ export function useRouter(): import('../../shared/lib/app-router-context').AppRo // return useContext(ParamsContext) // } +/** + * Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard" + */ export function usePathname(): string { return useContext(PathnameContext) } @@ -40,6 +52,10 @@ export function usePathname(): string { // return useContext(LayoutSegmentsContext) // } +// TODO-APP: Expand description when the docs are written for it. +/** + * Get the current segment one level down from the layout. + */ export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' ): string { From 9f099e741a4e9fabc1cf78270e1c5a8e4a0f3c26 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 13:43:44 +0200 Subject: [PATCH 12/29] Simplify rendering a bit --- packages/next/server/app-render.tsx | 63 ++++++++++++++++------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 343a6f3ba391..b02d95a36d73 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -41,6 +41,9 @@ export type RenderOptsPartial = { export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial +/** + * Interop between "export default" and "module.exports". + */ function interopDefault(mod: any) { return mod.default || mod } @@ -60,6 +63,9 @@ type Record = { value: any } +/** + * Create data fetching record for Promise. + */ function createRecordFromThenable(thenable: Promise) { const record: Record = { status: RecordStatus.Pending, @@ -84,6 +90,9 @@ function createRecordFromThenable(thenable: Promise) { return record } +/** + * Read record value or throw Promise if it's not resolved yet. + */ function readRecordValue(record: Record) { if (record.status === RecordStatus.Resolved) { return record.value @@ -92,6 +101,10 @@ function readRecordValue(record: Record) { } } +/** + * Preload data fetching record before it is called during React rendering. + * If the record is already in the cache returns that record. + */ function preloadDataFetchingRecord( map: Map, key: string, @@ -108,6 +121,9 @@ function preloadDataFetchingRecord( return record } +/** + * Render Flight stream during + */ function useFlightResponse( writable: WritableStream, cachePrefix: string, @@ -831,23 +847,6 @@ export async function renderToHTML( const AppRouter = ComponentMod.AppRouter as typeof import('../client/components/app-router.client').default - const WrappedComponentTreeWithRouter = () => { - return ( - } - initialCanonicalUrl={initialCanonicalUrl} - initialTree={initialTree} - initialStylesheets={initialStylesheets} - > - - - ) - } - - const bootstrapScripts = buildManifest.rootMainFiles.map( - (src) => '/_next/' + src - ) - let serverComponentsInlinedTransformStream: TransformStream< Uint8Array, Uint8Array @@ -855,8 +854,19 @@ export async function renderToHTML( serverComponentsInlinedTransformStream = new TransformStream() - const Component = createServerComponentRenderer( - WrappedComponentTreeWithRouter, + const ServerComponentsWrapper = createServerComponentRenderer( + () => { + return ( + } + initialCanonicalUrl={initialCanonicalUrl} + initialTree={initialTree} + initialStylesheets={initialStylesheets} + > + + + ) + }, ComponentMod, { cachePrefix: pathname + (search ? `?${search}` : ''), @@ -874,10 +884,6 @@ export async function renderToHTML( return <>{styles} } - const AppContainer = ({ children }: { children: JSX.Element }) => ( - {children} - ) - /** * Rules of Static & Dynamic HTML: * @@ -894,16 +900,19 @@ export async function renderToHTML( const generateStaticHTML = supportsDynamicHTML !== true const bodyResult = async () => { const content = ( - - - + + + ) const renderStream = await renderToInitialStream({ ReactDOMServer, element: content, streamOptions: { - bootstrapScripts, + // Include hydration scripts in the HTML + bootstrapScripts: buildManifest.rootMainFiles.map( + (src) => '/_next/' + src + ), }, }) From 72225ef033ccf6df68dea58b9e4058153343789d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 14:57:18 +0200 Subject: [PATCH 13/29] Add comments / refactor names --- packages/next/server/app-render.tsx | 50 +++++++++++++++++------------ packages/next/server/next-server.ts | 4 +-- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index b02d95a36d73..2d68fe4c4894 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -122,7 +122,8 @@ function preloadDataFetchingRecord( } /** - * Render Flight stream during + * Render Flight stream. + * This is only used for renderToHTML, the Flight response does not need additional wrappers. */ function useFlightResponse( writable: WritableStream, @@ -174,10 +175,18 @@ function useFlightResponse( return entry } -// Create the wrapper component for a Flight stream. +/** + * Create a component that renders the Flight stream. + * This is only used for renderToHTML, the Flight response does not need additional wrappers. + */ function createServerComponentRenderer( ComponentToRender: React.ComponentType, - ComponentMod: any, + ComponentMod: { + __next_app_webpack_require__?: any + __next_rsc__?: { + __webpack_require__?: any + } + }, { cachePrefix, transformStream, @@ -187,7 +196,9 @@ function createServerComponentRenderer( cachePrefix: string transformStream: TransformStream serverComponentManifest: NonNullable - serverContexts: Array<[ServerContextName: string, JSONValue: any]> + serverContexts: Array< + [ServerContextName: string, JSONValue: Object | number | string] + > } ) { // We need to expose the `__webpack_require__` API globally for @@ -196,7 +207,7 @@ function createServerComponentRenderer( // @ts-ignore globalThis.__next_require__ = ComponentMod.__next_app_webpack_require__ || - ComponentMod.__next_rsc__.__webpack_require__ + ComponentMod.__next_rsc__?.__webpack_require__ // @ts-ignore globalThis.__next_chunk_load__ = () => Promise.resolve() @@ -294,6 +305,7 @@ export type FlightDataPath = parallelRoute: string, segment: Segment, parallelRoute: string, + currentSegment: Segment, tree: FlightRouterState, subTreeData: React.ReactNode ] @@ -352,7 +364,7 @@ function getCssInlinedLinkTags( ) } -export async function renderToHTML( +export async function renderToHTMLOrFlight( req: IncomingMessage, res: ServerResponse, pathname: string, @@ -825,18 +837,6 @@ export async function renderToHTML( ) } - const search = stringifyQuery(query) - - // TODO-APP: validate req.url as it gets passed to render. - const initialCanonicalUrl = req.url! - - const initialTree = createFlightRouterStateFromLoaderTree(tree) - - const initialStylesheets: string[] = getCssInlinedLinkTags( - ComponentMod, - serverComponentManifest - ) - const { Component: ComponentTree } = await createComponentTree({ createSegmentPath: (child) => child, tree, @@ -853,9 +853,17 @@ export async function renderToHTML( > | null = null serverComponentsInlinedTransformStream = new TransformStream() + // TODO-APP: validate req.url as it gets passed to render. + const initialCanonicalUrl = req.url! + const initialStylesheets: string[] = getCssInlinedLinkTags( + ComponentMod, + serverComponentManifest + ) - const ServerComponentsWrapper = createServerComponentRenderer( + const ServerComponentsRenderer = createServerComponentRenderer( () => { + const initialTree = createFlightRouterStateFromLoaderTree(tree) + return ( } @@ -869,7 +877,7 @@ export async function renderToHTML( }, ComponentMod, { - cachePrefix: pathname + (search ? `?${search}` : ''), + cachePrefix: initialCanonicalUrl, transformStream: serverComponentsInlinedTransformStream, serverComponentManifest, serverContexts, @@ -901,7 +909,7 @@ export async function renderToHTML( const bodyResult = async () => { const content = ( - + ) diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 275415eada62..a7c8d80876df 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -53,7 +53,7 @@ import { getExtension, serveStatic } from './serve-static' import { ParsedUrlQuery } from 'querystring' import { apiResolver } from './api-utils/node' import { RenderOpts, renderToHTML } from './render' -import { renderToHTML as appRenderToHTML } from './app-render' +import { renderToHTMLOrFlight as appRenderToHTMLOrFlight } from './app-render' import { ParsedUrl, parseUrl } from '../shared/lib/router/utils/parse-url' import * as Log from '../build/output/log' @@ -615,7 +615,7 @@ export default class NextNodeServer extends BaseServer { (renderOpts.isAppPath || query.__flight__) ) { const isPagesDir = !renderOpts.isAppPath - return appRenderToHTML( + return appRenderToHTMLOrFlight( req.originalRequest, res.originalResponse, pathname, From 9639c23931dee21ed134d8f2f699d3e4ac02e515 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 16:29:55 +0200 Subject: [PATCH 14/29] Refactor dynamic segment handling --- packages/next/server/app-render.tsx | 113 ++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 2d68fe4c4894..cb67d80afb9f 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -316,6 +316,9 @@ export type ChildProp = { segment: Segment } +/** + * Parse dynamic route segment to type of parameter + */ function getSegmentParam(segment: string): { param: string type: DynamicParamTypes @@ -344,6 +347,9 @@ function getSegmentParam(segment: string): { return null } +/** + * Get inline tags based on __next_rsc_css__ manifest. Only used when rendering to HTML. + */ function getCssInlinedLinkTags( ComponentMod: any, serverComponentManifest: any @@ -392,6 +398,7 @@ export async function renderToHTMLOrFlight( const isFlight = query.__flight__ !== undefined + // Handle client-side navigation to pages directory if (isFlight && isPagesDir) { stripInternalQueries(query) const search = stringifyQuery(query) @@ -408,6 +415,9 @@ export async function renderToHTMLOrFlight( // TODO-APP: verify the tree is valid // TODO-APP: verify query param is single value (not an array) // TODO-APP: verify tree can't grow out of control + /** + * Router state provided from the client-side router. Used to handle rendering from the common layout down. + */ const providedFlightRouterState: FlightRouterState = isFlight ? query.__flight_router_state_tree__ ? JSON.parse(query.__flight_router_state_tree__ as string) @@ -416,9 +426,7 @@ export async function renderToHTMLOrFlight( stripInternalQueries(query) - const hasConcurrentFeatures = !!runtime const pageIsDynamic = isDynamicRoute(pathname) - const LayoutRouter = ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default const HotReloader = ComponentMod.HotReloader as @@ -426,10 +434,14 @@ export async function renderToHTMLOrFlight( | null const headers = req.headers - // @ts-expect-error TODO-APP: fix type of req + // TODO-APP: fix type of req + // @ts-expect-error const cookies = req.cookies - const tree: LoaderTree = ComponentMod.tree + /** + * The tree created in next-app-loader that holds component segments and modules + */ + const loaderTree: LoaderTree = ComponentMod.tree // Reads of this are cached on the `req` object, so this should resolve // instantly. There's no need to pass this data down from a previous @@ -440,6 +452,11 @@ export async function renderToHTMLOrFlight( (renderOpts as any).previewProps ) const isPreview = previewData !== false + /** + * Server Context is specifically only available in Server Components. + * It has to hold values that can't change while rendering from the common layout down. + * An example of this would be that `headers` are available but `searchParams` are not because that'd mean we have to render from the root layout down on all requests. + */ const serverContexts: Array<[string, any]> = [ ['WORKAROUND', null], // TODO-APP: First value has a bug currently where the value is not set on the second request: https://github.com/facebook/react/issues/24849 ['HeadersContext', headers], @@ -447,19 +464,28 @@ export async function renderToHTMLOrFlight( ['PreviewDataContext', previewData], ] + /** + * Used to keep track of in-flight / resolved data fetching Promises. + */ const dataCache = new Map() type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath + /** + * Dynamic parameters. E.g. when you visit `/dashboard/vercel` which is rendered by `/dashboard/[slug]` the value will be {"slug": "vercel"}. + */ const pathParams = (renderOpts as any).params as ParsedUrlQuery + /** + * Parse the dynamic segment and return the associated value. + */ const getDynamicParamFromSegment = ( - // [id] or [slug] + // [slug] / [[slug]] / [...slug] segment: string ): { param: string value: string | string[] | null - treeValue: string + treeSegment: Segment type: DynamicParamTypesShort } | null => { const segmentParam = getSegmentParam(segment) @@ -471,22 +497,29 @@ export async function renderToHTMLOrFlight( const value = pathParams[key] if (!value) { + // Handle case where optional catchall does not have a value, e.g. `/dashboard/[...slug]` when requesting `/dashboard` if (segmentParam.type === 'optional-catchall') { + const type = getShortDynamicParamType(segmentParam.type) return { param: key, value: null, - type: getShortDynamicParamType(segmentParam.type), - treeValue: '', + type: type, + // This value always has to be a string. + treeSegment: [key, '', type], } } return null } + const type = getShortDynamicParamType(segmentParam.type) + return { param: key, + // The value that is passed to user code. value: value, - treeValue: Array.isArray(value) ? value.join('/') : value, - type: getShortDynamicParamType(segmentParam.type), + // The value that is rendered in the router tree. + treeSegment: [key, Array.isArray(value) ? value.join('/') : value, type], + type: type, } } @@ -499,9 +532,7 @@ export async function renderToHTMLOrFlight( const dynamicParam = getDynamicParamFromSegment(segment) const segmentTree: FlightRouterState = [ - dynamicParam - ? [dynamicParam.param, dynamicParam.treeValue, dynamicParam.type] - : segment, + dynamicParam ? dynamicParam.treeSegment : segment, {}, ] @@ -523,15 +554,18 @@ export async function renderToHTMLOrFlight( return segmentTree } + /** + * Use the provided loader tree to create the React Component tree. + */ const createComponentTree = async ({ createSegmentPath, - tree: [segment, parallelRoutes, { layout, loading, page }], + loaderTree: [segment, parallelRoutes, { layout, loading, page }], parentParams, firstItem, rootLayoutIncluded, }: { createSegmentPath: CreateSegmentPath - tree: LoaderTree + loaderTree: LoaderTree parentParams: { [key: string]: any } rootLayoutIncluded?: boolean firstItem?: boolean @@ -544,10 +578,19 @@ export async function renderToHTMLOrFlight( : isPage ? await page() : undefined + /** + * Checks if the current segment is a root layout. + */ const rootLayoutAtThisLevel = isLayout && !rootLayoutIncluded + /** + * Checks if the current segment or any level above it has a root layout. + */ const rootLayoutIncludedAtThisLevelOrAbove = rootLayoutIncluded || rootLayoutAtThisLevel + /** + * Check if the current layout/page is a client component + */ const isClientComponentModule = layoutOrPageMod && !layoutOrPageMod.hasOwnProperty('__next_rsc__') @@ -565,11 +608,18 @@ export async function renderToHTMLOrFlight( } } + /** + * The React Component to render. + */ const Component = layoutOrPageMod ? interopDefault(layoutOrPageMod) : undefined + // Handle dynamic segment params. const segmentParam = getDynamicParamFromSegment(segment) + /** + * Create object holding the parent params and current params, this is passed to getServerSideProps and getStaticProps. + */ const currentParams = // Handle null case where dynamic param is optional segmentParam && segmentParam.value !== null @@ -577,10 +627,10 @@ export async function renderToHTMLOrFlight( ...parentParams, [segmentParam.param]: segmentParam.value, } - : parentParams - const actualSegment = segmentParam - ? [segmentParam.param, segmentParam.treeValue] - : segment + : // Pass through parent params to children + parentParams + // Resolve the segment param + const actualSegment = segmentParam ? segmentParam.treeSegment : segment // This happens outside of rendering in order to eagerly kick off data fetching for layouts / the page further down const parallelRouteMap = await Promise.all( @@ -590,11 +640,12 @@ export async function renderToHTMLOrFlight( ? [parallelRouteKey] : [actualSegment, parallelRouteKey] + // Create the child component const { Component: ChildComponent } = await createComponentTree({ createSegmentPath: (child) => { return createSegmentPath([...currentSegmentPath, ...child]) }, - tree: parallelRoutes[parallelRouteKey], + loaderTree: parallelRoutes[parallelRouteKey], parentParams: currentParams, rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, }) @@ -605,11 +656,7 @@ export async function renderToHTMLOrFlight( const childProp: ChildProp = { current: , segment: childSegmentParam - ? [ - childSegmentParam.param, - childSegmentParam.treeValue, - childSegmentParam.type, - ] + ? childSegmentParam.treeSegment : parallelRoutes[parallelRouteKey][0], } @@ -775,7 +822,7 @@ export async function renderToHTMLOrFlight( } : parentParams const actualSegment: Segment = segmentParam - ? [segmentParam.param, segmentParam.treeValue, segmentParam.type] + ? segmentParam.treeSegment : segment const renderComponentsOnThisLevel = @@ -796,7 +843,7 @@ export async function renderToHTMLOrFlight( // This ensures flightRouterPath is valid and filters down the tree { createSegmentPath: (child) => child, - tree: treeToFilter, + loaderTree: treeToFilter, parentParams: currentParams, firstItem: true, } @@ -826,7 +873,11 @@ export async function renderToHTMLOrFlight( const flightData: FlightData = [ // TODO-APP: change walk to output without '' ( - await walkTreeWithFlightRouterState(tree, {}, providedFlightRouterState) + await walkTreeWithFlightRouterState( + loaderTree, + {}, + providedFlightRouterState + ) ).slice(1), ] @@ -839,7 +890,7 @@ export async function renderToHTMLOrFlight( const { Component: ComponentTree } = await createComponentTree({ createSegmentPath: (child) => child, - tree, + loaderTree: loaderTree, parentParams: {}, firstItem: true, }) @@ -862,7 +913,7 @@ export async function renderToHTMLOrFlight( const ServerComponentsRenderer = createServerComponentRenderer( () => { - const initialTree = createFlightRouterStateFromLoaderTree(tree) + const initialTree = createFlightRouterStateFromLoaderTree(loaderTree) return ( Date: Mon, 25 Jul 2022 17:00:18 +0200 Subject: [PATCH 15/29] Add comments --- packages/next/server/app-render.tsx | 44 +++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index cb67d80afb9f..57d83f6e70a0 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -636,7 +636,7 @@ export async function renderToHTMLOrFlight( const parallelRouteMap = await Promise.all( Object.keys(parallelRoutes).map( async (parallelRouteKey): Promise<[string, React.ReactNode]> => { - const currentSegmentPath = firstItem + const currentSegmentPath: FlightSegmentPath = firstItem ? [parallelRouteKey] : [actualSegment, parallelRouteKey] @@ -650,16 +650,16 @@ export async function renderToHTMLOrFlight( rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, }) - const childSegmentParam = getDynamicParamFromSegment( - parallelRoutes[parallelRouteKey][0] - ) + const childSegment = parallelRoutes[parallelRouteKey][0] + const childSegmentParam = getDynamicParamFromSegment(childSegment) const childProp: ChildProp = { current: , segment: childSegmentParam ? childSegmentParam.treeSegment - : parallelRoutes[parallelRouteKey][0], + : childSegment, } + // This is turned back into an object below. return [ parallelRouteKey, { list[parallelRouteKey] = Comp @@ -682,7 +683,7 @@ export async function renderToHTMLOrFlight( {} as { [key: string]: React.ReactNode } ) - // When the segment does not have a layout/page we still have to add the layout router to ensure the path holds the loading component + // When the segment does not have a layout or page we still have to add the layout router to ensure the path holds the loading component if (!Component) { return { Component: () => <>{parallelRouteComponents.children}, @@ -767,9 +768,9 @@ export async function renderToHTMLOrFlight( return { Component: () => { let props + // The data fetching was kicked off before rendering (see above) + // if the data was not resolved yet the layout rendering will be suspended if (fetcher) { - // The data fetching was kicked off before rendering (see above) - // if the data was not resolved yet the layout rendering will be suspended const record = preloadDataFetchingRecord( dataCache, dataCacheKey, @@ -801,17 +802,24 @@ export async function renderToHTMLOrFlight( } } + // Handle Flight render request. This is only used when client-side navigating. E.g. when you `router.push('/dashboard')` or `router.reload()`. if (isFlight) { // TODO-APP: throw on invalid flightRouterState + /** + * Use router state to decide at what common layout to render the page. + * This can either be the common layout between two pages or a specific place to start rendering from using the "refetch" marker in the tree. + */ const walkTreeWithFlightRouterState = async ( - treeToFilter: LoaderTree, + loaderTreeToFilter: LoaderTree, parentParams: { [key: string]: string | string[] }, flightRouterState?: FlightRouterState, parentRendered?: boolean ): Promise => { - const [segment, parallelRoutes] = treeToFilter + const [segment, parallelRoutes] = loaderTreeToFilter const parallelRoutesKeys = Object.keys(parallelRoutes) + // Because this function walks to a deeper point in the tree to start rendering we have to track the dynamic parameters up to the point where rendering starts + // That way even when rendering the subtree getServerSideProps/getStaticProps get the right parameters. const segmentParam = getDynamicParamFromSegment(segment) const currentParams = // Handle null case where dynamic param is optional @@ -825,8 +833,13 @@ export async function renderToHTMLOrFlight( ? segmentParam.treeSegment : segment + /** + * Decide if the current segment is where rendering has to start. + */ const renderComponentsOnThisLevel = + // No further router state available !flightRouterState || + // Segment in router state does not match current segment !matchSegment(actualSegment, flightRouterState[0]) || // Last item in the tree parallelRoutesKeys.length === 0 || @@ -836,14 +849,16 @@ export async function renderToHTMLOrFlight( if (!parentRendered && renderComponentsOnThisLevel) { return [ actualSegment, - createFlightRouterStateFromLoaderTree(treeToFilter), + // 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: treeToFilter, + loaderTree: loaderTreeToFilter, parentParams: currentParams, firstItem: true, } @@ -853,6 +868,7 @@ export async function renderToHTMLOrFlight( ] } + // Walk through all parallel routes. for (const parallelRouteKey of parallelRoutesKeys) { const parallelRoute = parallelRoutes[parallelRouteKey] const path = await walkTreeWithFlightRouterState( @@ -870,6 +886,8 @@ export async function renderToHTMLOrFlight( return [actualSegment] } + // Flight data that is going to be passed to the browser. + // Currently a single item array but in the future multiple patches might be combined in a single request. const flightData: FlightData = [ // TODO-APP: change walk to output without '' ( @@ -888,6 +906,8 @@ export async function renderToHTMLOrFlight( ) } + // Below this line is handling for rendering to HTML + const { Component: ComponentTree } = await createComponentTree({ createSegmentPath: (child) => child, loaderTree: loaderTree, From 0db5c9edc0c8f1b08d3b10d30a7aa12faca9ea18 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 22:49:49 +0200 Subject: [PATCH 16/29] Add additional comments --- packages/next/server/app-render.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 57d83f6e70a0..e62212076656 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -906,8 +906,9 @@ export async function renderToHTMLOrFlight( ) } - // Below this line is handling for rendering to HTML + // Below this line is handling for rendering to HTML. + // Create full component tree from root to leaf. const { Component: ComponentTree } = await createComponentTree({ createSegmentPath: (child) => child, loaderTree: loaderTree, @@ -915,15 +916,15 @@ export async function renderToHTMLOrFlight( firstItem: true, }) + // AppRouter is provided by next-app-loader const AppRouter = ComponentMod.AppRouter as typeof import('../client/components/app-router.client').default let serverComponentsInlinedTransformStream: TransformStream< Uint8Array, Uint8Array - > | null = null + > = new TransformStream() - serverComponentsInlinedTransformStream = new TransformStream() // TODO-APP: validate req.url as it gets passed to render. const initialCanonicalUrl = req.url! const initialStylesheets: string[] = getCssInlinedLinkTags( @@ -931,6 +932,10 @@ export async function renderToHTMLOrFlight( serverComponentManifest ) + /** + * A new React Component that renders the provided React Component + * using Flight which can then be rendered to HTML. + */ const ServerComponentsRenderer = createServerComponentRenderer( () => { const initialTree = createFlightRouterStateFromLoaderTree(loaderTree) @@ -955,9 +960,15 @@ export async function renderToHTMLOrFlight( } ) + /** + * Style registry for styled-jsx + */ const jsxStyleRegistry = createStyleRegistry() - const styledJsxFlushEffect = () => { + /** + * styled-jsx styles as React Component + */ + const styledJsxFlushEffect = (): React.ReactNode => { const styles = jsxStyleRegistry.styles() jsxStyleRegistry.flush() return <>{styles} From 4f54b93776a081debc1a3963dc54777bde5dda0c Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 26 Jul 2022 11:24:41 +0200 Subject: [PATCH 17/29] Remove segment from childProp --- .../client/components/app-router.client.tsx | 6 +- .../next/client/components/hooks-client.ts | 4 +- .../components/layout-router.client.tsx | 63 +++++++++++++------ packages/next/server/app-render.tsx | 6 -- .../next/shared/lib/app-router-context.ts | 4 +- 5 files changed, 50 insertions(+), 33 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index c2dd1fa8d3fc..c78c8e6f4854 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react' import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack' import { AppRouterContext, - AppTreeContext, + LayoutRouterContext, GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import type { @@ -304,7 +304,7 @@ export default function AppRouter({ }} > - + diff --git a/packages/next/client/components/hooks-client.ts b/packages/next/client/components/hooks-client.ts index 8606c757dc1c..dec38ef62bb9 100644 --- a/packages/next/client/components/hooks-client.ts +++ b/packages/next/client/components/hooks-client.ts @@ -9,7 +9,7 @@ import { } from './hooks-client-context' import { AppRouterContext, - AppTreeContext, + LayoutRouterContext, } from '../../shared/lib/app-router-context' /** @@ -59,7 +59,7 @@ export function usePathname(): string { export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' ): string { - const { tree } = useContext(AppTreeContext) + const { tree } = useContext(LayoutRouterContext) const segment = tree[1][parallelRouteKey][0] diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 6406d6679ce6..a82d7540b0fb 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -1,5 +1,5 @@ import React, { useContext, useEffect, useRef } from 'react' -import type { ChildProp } from '../../server/app-render' +import type { ChildProp, Segment } from '../../server/app-render' import type { ChildSegmentMap } from '../../shared/lib/app-router-context' import type { FlightRouterState, @@ -7,7 +7,7 @@ import type { FlightDataPath, } from '../../server/app-render' import { - AppTreeContext, + LayoutRouterContext, GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import { fetchServerResponse } from './app-router.client' @@ -15,23 +15,34 @@ import { matchSegment } from './match-segments' let infinitePromise: Promise | Error -function equalArray(a: any[], b: any[]) { +/** + * 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 pathMatches( +/** + * Check if flightDataPath matches layoutSegmentPath + */ +function segmentPathMatches( flightDataPath: FlightDataPath, layoutSegmentPath: FlightSegmentPath ): boolean { - // The last two items are the tree and subTreeData + // The last three items are the current segment, tree, and subTreeData const pathToLayout = flightDataPath.slice(0, -3) - return equalArray(layoutSegmentPath, pathToLayout) + return equalSegmentPaths(layoutSegmentPath, pathToLayout) } +/** + * Create a Promise that does not resolve. This is used to suspend when data is not available yet. + */ function createInfinitePromise() { if (!infinitePromise) { + // Only create the Promise once infinitePromise = new Promise((/* resolve */) => { - // Note: this is used to debug when the rendering is never updated. + // This is used to debug when the rendering is never updated. // setTimeout(() => { // infinitePromise = new Error('Infinite promise') // resolve() @@ -42,11 +53,17 @@ function createInfinitePromise() { return infinitePromise } +/** + * Check if the top of the HTMLElement is in the viewport. + */ function topOfElementInViewport(element: HTMLElement) { const rect = element.getBoundingClientRect() return rect.top >= 0 } +/** + * InnerLayoutRouter handles rendering the provided segment based on the cache. + */ export function InnerLayoutRouter({ parallelRouterKey, url, @@ -184,7 +201,7 @@ export function InnerLayoutRouter({ if (flightData.length === 1) { const flightDataPath = flightData[0] - if (pathMatches(flightDataPath, segmentPath)) { + if (segmentPathMatches(flightDataPath, segmentPath)) { childNode.data = null // Last item is the subtreeData // TODO-APP: routerTreePatch needs to be applied to the tree, handle it in render? @@ -219,7 +236,7 @@ export function InnerLayoutRouter({ } const subtree = ( - {childNode.subTreeData} - + ) // Ensure root layout is not wrapped in a div @@ -253,6 +270,10 @@ function LoadingBoundary({ return <>{children} } +/** + * OuterLayoutRouter handles the current segment as well as rendering of other segments. + * It can be rendered next to each other with a different `parallelRouterKey`, allowing for Parallel routes. + */ export default function OuterLayoutRouter({ parallelRouterKey, segmentPath, @@ -266,23 +287,25 @@ export default function OuterLayoutRouter({ loading: React.ReactNode | undefined rootLayoutIncluded: boolean }) { - const { childNodes, tree, url } = useContext(AppTreeContext) + const { childNodes, tree, url } = useContext(LayoutRouterContext) + // Get the current parallelRouter cache node let childNodesForParallelRouter = childNodes.get(parallelRouterKey) + // If the parallel router cache node does not exist yet, create it. + // 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. if (!childNodesForParallelRouter) { childNodes.set(parallelRouterKey, new Map()) childNodesForParallelRouter = childNodes.get(parallelRouterKey)! } - // This relates to the segments in the current router - // tree[1].children[0] refers to tree.children.segment in the data format + // Get the active segment in the tree + // The reason arrays are used in the data format is that these are transferred from the server to the browser so it's optimized to save bytes. const treeSegment = tree[1][parallelRouterKey][0] - const childPropSegment = Array.isArray(childProp.segment) - ? childProp.segment[1] - : childProp.segment - const currentChildSegment = - (Array.isArray(treeSegment) ? treeSegment[1] : treeSegment) ?? - childPropSegment + + // If segment is an array it's a dynamic route and we want to read the dynamic route value as the segment to get from the cache. + const currentChildSegment = Array.isArray(treeSegment) + ? treeSegment[1] + : treeSegment const preservedSegments: string[] = [currentChildSegment] return ( @@ -301,7 +324,7 @@ export default function OuterLayoutRouter({ tree={tree} childNodes={childNodesForParallelRouter!} childProp={ - childPropSegment === preservedSegment ? childProp : null + currentChildSegment === preservedSegment ? childProp : null } segmentPath={segmentPath} path={preservedSegment} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index e62212076656..81dea7b8c13d 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -313,7 +313,6 @@ export type FlightDataPath = export type FlightData = Array | string export type ChildProp = { current: React.ReactNode - segment: Segment } /** @@ -650,13 +649,8 @@ export async function renderToHTMLOrFlight( rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, }) - const childSegment = parallelRoutes[parallelRouteKey][0] - const childSegmentParam = getDynamicParamFromSegment(childSegment) const childProp: ChildProp = { current: , - segment: childSegmentParam - ? childSegmentParam.treeSegment - : childSegment, } // This is turned back into an object below. diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 34edab97d88d..c39d728d9fa5 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -46,7 +46,7 @@ export type AppRouterInstance = { export const AppRouterContext = React.createContext( null as any ) -export const AppTreeContext = React.createContext<{ +export const LayoutRouterContext = React.createContext<{ childNodes: CacheNode['parallelRoutes'] tree: FlightRouterState url: string @@ -63,6 +63,6 @@ export const GlobalLayoutRouterContext = React.createContext<{ if (process.env.NODE_ENV !== 'production') { AppRouterContext.displayName = 'AppRouterContext' - AppTreeContext.displayName = 'AppTreeContext' + LayoutRouterContext.displayName = 'LayoutRouterContext' GlobalLayoutRouterContext.displayName = 'GlobalLayoutRouterContext' } From f930b0a0c33e883ce75f26d159b9fb614f281faa Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 26 Jul 2022 11:35:47 +0200 Subject: [PATCH 18/29] Update layout-router.client.tsx --- packages/next/client/components/layout-router.client.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index a82d7540b0fb..90867fc12064 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -306,6 +306,11 @@ export default function OuterLayoutRouter({ const currentChildSegment = Array.isArray(treeSegment) ? treeSegment[1] : treeSegment + + /** + * Decides which segments to keep rendering, all segments that are not active will be wrapped in ``. + */ + // TODO-APP: Add handling of `` when it's available. const preservedSegments: string[] = [currentChildSegment] return ( @@ -317,6 +322,8 @@ export default function OuterLayoutRouter({ : null} */} {preservedSegments.map((preservedSegment) => { return ( + // Loading boundary is render for each segment to ensure they have their own loading state. + // The loading boundary is passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch. Date: Tue, 26 Jul 2022 12:03:08 +0200 Subject: [PATCH 19/29] Add additional comments to layout-router --- .../client/components/app-router.client.tsx | 6 ++-- .../components/layout-router.client.tsx | 35 +++++++++++++------ packages/next/client/components/reducer.ts | 32 ++++++++--------- .../next/shared/lib/app-router-context.ts | 4 +-- 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index c78c8e6f4854..f1b176c5134d 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -99,7 +99,7 @@ export default function AppRouter({ children: React.ReactNode hotReloader?: React.ReactNode }) { - const [{ tree, cache, pushRef, focusRef, canonicalUrl }, dispatch] = + const [{ tree, cache, pushRef, focusAndScrollRef, canonicalUrl }, dispatch] = React.useReducer(reducer, { tree: initialTree, cache: { @@ -109,7 +109,7 @@ export default function AppRouter({ typeof window === 'undefined' ? new Map() : initialParallelRoutes, }, pushRef: { pendingPush: false, mpaNavigation: false }, - focusRef: { focus: false }, + focusAndScrollRef: { apply: false }, canonicalUrl: initialCanonicalUrl + // Hash is read as the initial value for canonicalUrl in the browser @@ -300,7 +300,7 @@ export default function AppRouter({ value={{ changeByServerResponse, tree, - focusRef, + focusAndScrollRef: focusAndScrollRef, }} > diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 90867fc12064..a735453d6cf7 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -13,8 +13,6 @@ import { import { fetchServerResponse } from './app-router.client' import { matchSegment } from './match-segments' -let infinitePromise: Promise | Error - /** * Check if every segment in array a and b matches */ @@ -35,6 +33,11 @@ function segmentPathMatches( return equalSegmentPaths(layoutSegmentPath, pathToLayout) } +/** + * Used to cache in createInfinitePromise + */ +let infinitePromise: Promise | Error + /** * Create a Promise that does not resolve. This is used to suspend when data is not available yet. */ @@ -71,6 +74,7 @@ export function InnerLayoutRouter({ childProp, segmentPath, tree, + // TODO-APP: implement `` when available. // isActive, path, rootLayoutIncluded, @@ -88,34 +92,43 @@ export function InnerLayoutRouter({ const { changeByServerResponse, tree: fullTree, - focusRef, + focusAndScrollRef, } = useContext(GlobalLayoutRouterContext) - const focusAndScrollRef = useRef(null) + const focusAndScrollElementRef = useRef(null) useEffect(() => { - if (focusRef.focus && focusAndScrollRef.current) { - focusRef.focus = false - focusAndScrollRef.current.focus() + // Handle scroll and focus, it's only applied once in the first useEffect that triggers that changed. + if (focusAndScrollRef.apply && focusAndScrollElementRef.current) { + // State is mutated to ensure that the focus and scroll is applied only once. + focusAndScrollRef.apply = false + // Set focus on the element + focusAndScrollElementRef.current.focus() // Only scroll into viewport when the layout is not visible currently. - if (!topOfElementInViewport(focusAndScrollRef.current)) { - focusAndScrollRef.current.scrollIntoView() + if (!topOfElementInViewport(focusAndScrollElementRef.current)) { + focusAndScrollElementRef.current.scrollIntoView() } } - }, [focusRef]) + }, [focusAndScrollRef]) + // Read segment path from the parallel router cache node. let childNode = childNodes.get(path) + // If childProp is available this means it's the Flight / SSR case. if (childProp && !childNode) { + // 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, { data: null, subTreeData: childProp.current, parallelRoutes: new Map(), }) + // Mutates the prop in order to clean up the memory associated with the subTreeData as it is now part of the cache. childProp.current = null // In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again. childNode = childNodes.get(path) } + // When childNode is not available during rendering client-side we need to create if (!childNode) { const walkAddRefetch = ( segmentPathToWalk: FlightSegmentPath | undefined, @@ -250,7 +263,7 @@ export function InnerLayoutRouter({ // Ensure root layout is not wrapped in a div return rootLayoutIncluded ? ( -
{subtree}
+
{subtree}
) : ( subtree ) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 0c0b589d117a..8a69bf50532f 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -310,18 +310,18 @@ type PushRef = { mpaNavigation: boolean } -export type FocusRef = { +export type FocusAndScrollRef = { /** - * If focus should be set in the layout-router's useEffect() + * If focus and scroll should be set in the layout-router's useEffect() */ - focus: boolean + apply: boolean } type AppRouterState = { tree: FlightRouterState cache: CacheNode pushRef: PushRef - focusRef: FocusRef + focusAndScrollRef: FocusAndScrollRef canonicalUrl: string } @@ -374,7 +374,7 @@ export function reducer( return { canonicalUrl: href, pushRef: state.pushRef, - focusRef: state.focusRef, + focusAndScrollRef: state.focusAndScrollRef, cache: state.cache, tree: tree, } @@ -402,7 +402,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + focusAndScrollRef: { apply: true }, cache: state.cache, tree: optimisticTree, } @@ -419,7 +419,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + focusAndScrollRef: { apply: true }, cache: cache, tree: mutable.patchedTree, } @@ -457,7 +457,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + focusAndScrollRef: { apply: true }, cache: cache, tree: optimisticTree, } @@ -474,7 +474,7 @@ export function reducer( return { canonicalUrl: flightData, pushRef: { pendingPush: true, mpaNavigation: true }, - focusRef: { focus: false }, + focusAndScrollRef: { apply: false }, cache: state.cache, tree: state.tree, } @@ -502,7 +502,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + focusAndScrollRef: { apply: true }, cache: cache, tree: newTree, } @@ -518,7 +518,7 @@ export function reducer( return { canonicalUrl: state.canonicalUrl, pushRef: state.pushRef, - focusRef: state.focusRef, + focusAndScrollRef: state.focusAndScrollRef, tree: state.tree, cache: state.cache, } @@ -529,7 +529,7 @@ export function reducer( return { canonicalUrl: flightData, pushRef: { pendingPush: true, mpaNavigation: true }, - focusRef: { focus: false }, + focusAndScrollRef: { apply: false }, cache: state.cache, tree: state.tree, } @@ -555,7 +555,7 @@ export function reducer( return { canonicalUrl: state.canonicalUrl, pushRef: state.pushRef, - focusRef: state.focusRef, + focusAndScrollRef: state.focusAndScrollRef, tree: newTree, cache: cache, } @@ -576,7 +576,7 @@ export function reducer( return { canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, - focusRef: { focus: true }, + focusAndScrollRef: { apply: true }, cache: cache, tree: mutable.patchedTree, } @@ -597,7 +597,7 @@ export function reducer( return { canonicalUrl: flightData, pushRef: { pendingPush: true, mpaNavigation: true }, - focusRef: { focus: false }, + focusAndScrollRef: { apply: false }, cache: state.cache, tree: state.tree, } @@ -630,7 +630,7 @@ export function reducer( canonicalUrl: href, pushRef: { pendingPush, mpaNavigation: false }, // TODO-APP: Revisit if this needs to be true in certain cases - focusRef: { focus: false }, + focusAndScrollRef: { apply: false }, cache: cache, tree: newTree, } diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index c39d728d9fa5..7abceb4c9782 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -1,5 +1,5 @@ import React from 'react' -import type { FocusRef } from '../../client/components/reducer' +import type { FocusAndScrollRef } from '../../client/components/reducer' import type { FlightRouterState, FlightData } from '../../server/app-render' export type ChildSegmentMap = Map @@ -58,7 +58,7 @@ export const GlobalLayoutRouterContext = React.createContext<{ previousTree: FlightRouterState, flightData: FlightData ) => void - focusRef: FocusRef + focusAndScrollRef: FocusAndScrollRef }>(null as any) if (process.env.NODE_ENV !== 'production') { From f814439287c2845f1e117817894984e7818e5ad5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 26 Jul 2022 13:42:23 +0200 Subject: [PATCH 20/29] Add additional comments --- .../components/layout-router.client.tsx | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index a735453d6cf7..6c37ecc909be 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -128,8 +128,12 @@ export function InnerLayoutRouter({ childNode = childNodes.get(path) } - // When childNode is not available during rendering client-side we need to create + // 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 @@ -175,9 +179,15 @@ export function InnerLayoutRouter({ return treeToRecreate } + /** + * Router state with refetch marker added + */ // TODO-APP: remove '' const refetchTree = walkAddRefetch(['', ...segmentPath], fullTree) + /** + * 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, @@ -188,19 +198,23 @@ export function InnerLayoutRouter({ childNode = childNodes.get(path) } - // In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again. - childNode = childNodes.get(path) - + // This case should never happen so it throws an error. It indicates there's a bug in the Next.js. if (!childNode) { throw new Error('Child node should always exist') } + // This case should never happen so it throws an error. It indicates there's a bug in the Next.js. if (childNode.subTreeData && childNode.data) { throw new Error('Child node should not have both subTreeData and data') } + // If cache node has a data request we have to readRoot and update the cache. if (childNode.data) { // TODO-APP: error case + /** + * Flight response data + */ + // When the data has not resolved yet readRoot will suspend here. const flightData = childNode.data.readRoot() // Handle case when navigating to page in `pages` from `app` @@ -209,28 +223,36 @@ export function InnerLayoutRouter({ return null } + /** + * If the fast path was triggered. + * The fast path is when the returned Flight data path matches the layout segment path, then we can write the data to the cache in render instead of dispatching an action. + */ let fastPath: boolean = false - // segmentPath matches what came back from the server. This is the happy path. + + // 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 } } + // When the fast path is not used a new action is dispatched to update the tree and cache. if (!fastPath) { - // For push we can set data in the cache - // segmentPath from the server does not match the layout's segmentPath childNode.data = null + // setTimeout is used to start a new transition during render, this is an intentional hack around React. setTimeout(() => { // @ts-ignore startTransition exists React.startTransition(() => { @@ -243,12 +265,14 @@ export function InnerLayoutRouter({ } } - // TODO-APP: double check users can't return null in a component that will kick in here + // If cache node has no subTreeData and no data request we have to infinitely suspend as the data will likely flow in from another place. + // TODO-APP: double check users can't return null in a component that will kick in here. if (!childNode.subTreeData) { throw createInfinitePromise() } const subtree = ( + // The layout router context narrows down tree and childNodes at each level. ) - // Ensure root layout is not wrapped in a div + // Ensure root layout is not wrapped in a div as the root layout renders `` return rootLayoutIncluded ? (
{subtree}
) : ( @@ -269,6 +293,10 @@ export function InnerLayoutRouter({ ) } +/** + * Renders suspense boundary with the provided "loading" property as the fallback. + * If no loading property is provided it renders the children without a suspense boundary. + */ function LoadingBoundary({ children, loading, From 596992dffbdd0739d113eaebf347086e19815953 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 26 Jul 2022 14:23:05 +0200 Subject: [PATCH 21/29] Add explanation of individual actions --- packages/next/client/components/reducer.ts | 116 +++++++++++++++------ 1 file changed, 82 insertions(+), 34 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 8a69bf50532f..bce5ff5ff2d8 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -301,7 +301,7 @@ function walkTreeWithFlightDataPath( type PushRef = { /** - * If the app-router should push a new history entry in useEffect() + * If the app-router should push a new history entry in app-router's useEffect() */ pendingPush: boolean /** @@ -330,41 +330,89 @@ export const ACTION_NAVIGATE = 'navigate' export const ACTION_RESTORE = 'restore' export const ACTION_SERVER_PATCH = 'server-patch' +/** + * Reload triggers a reload of the full page data. + * - fetches the Flight data and fills subTreeData at the root of the cache. + * - The router state is updated at the root of the state tree. + */ +interface ReloadAction { + type: typeof ACTION_RELOAD + url: URL + cache: CacheNode + mutable: { + previousTree?: FlightRouterState + patchedTree?: FlightRouterState + } +} + +/** + * Navigate triggers a navigation to the provided url. It supports a combination of `cacheType` (`hard` and `soft`) and `navigateType` (`push` and `replace`). + * + * `navigateType`: + * - `push` - pushes a new history entry in the browser history + * - `replace` - replaces the current history entry in the browser history + * + * `cacheType`: + * - `hard` - Creates a new cache in one of two ways: + * - Not optimistic + * - Default if there is no loading.js. + * - Fetch data in the reducer and suspend there. + * - Copies the previous cache nodes as far as possible and applies new subTreeData. + * - Applies the new router state. + * - optimistic + * - Enabled when somewhere in the router state path to the page there is a loading.js. + * - Similar to `soft` but kicks off the data fetch in the reducer and applies `data` in the spot that should suspend. + * - This enables showing loading states while navigating. + * - Will trigger fast path or server-patch case in layout-router. + * - `soft` + * - Reuses the existing cache. + * - Creates an optimistic router state that causes the fetch to start in layout-router when there is missing data. + * - If there is no missing data the existing cache data is rendered. + */ +interface NavigateAction { + type: typeof ACTION_NAVIGATE + url: URL + cacheType: 'soft' | 'hard' + navigateType: 'push' | 'replace' + cache: CacheNode + mutable: { + previousTree?: FlightRouterState + patchedTree?: FlightRouterState + } +} + +/** + * Restore applies the provided router state. + * - Only used for `popstate` (back/forward navigation) where a known router state has to be applied. + * - Router state is applied as-is from the history state. + * - If any data is missing it will be fetched in layout-router during rendering and trigger fast path or server-patch case. + * - If no data is missing the existing cached data is rendered. + */ +interface RestoreAction { + type: typeof ACTION_RESTORE + url: URL + tree: FlightRouterState +} + +/** + * Server-patch applies the provided Flight data to the cache and router tree. + * - Only triggered in layout-router when the data can't be handled in the fast path. + * - Main case where this is triggered is when a rewrite applies and Flight data for a different path is returned from the server. + * - Creates a new cache and router state with the Flight data applied. + */ +interface ServerPatchAction { + type: typeof ACTION_SERVER_PATCH + flightData: FlightData + previousTree: FlightRouterState + cache: CacheNode +} + +/** + * Reducer that handles the app-router state updates. + */ export function reducer( state: AppRouterState, - action: - | { - type: typeof ACTION_RELOAD - url: URL - cache: CacheNode - mutable: { - previousTree?: FlightRouterState - patchedTree?: FlightRouterState - } - } - | { - type: typeof ACTION_NAVIGATE - - url: URL - cacheType: 'soft' | 'hard' - navigateType: 'push' | 'replace' - cache: CacheNode - mutable: { - previousTree?: FlightRouterState - patchedTree?: FlightRouterState - } - } - | { - type: typeof ACTION_RESTORE - url: URL - tree: FlightRouterState - } - | { - type: typeof ACTION_SERVER_PATCH - flightData: FlightData - previousTree: FlightRouterState - cache: CacheNode - } + action: ReloadAction | NavigateAction | RestoreAction | ServerPatchAction ): AppRouterState { switch (action.type) { case ACTION_RESTORE: { From 157e89db743bcacf689a3ca31dafaa380a7c1b6d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 26 Jul 2022 16:30:31 +0200 Subject: [PATCH 22/29] Add comments to navigate --- packages/next/client/components/reducer.ts | 64 +++++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index bce5ff5ff2d8..9de25ccd3fdb 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -420,18 +420,20 @@ export function reducer( const href = url.pathname + url.search + url.hash return { + // Set canonical url canonicalUrl: href, pushRef: state.pushRef, focusAndScrollRef: state.focusAndScrollRef, cache: state.cache, + // Restore provided tree tree: tree, } } case ACTION_NAVIGATE: { const { url, cacheType, navigateType, cache, mutable } = action + const { pathname, search, hash } = url + const href = pathname + search + hash const pendingPush = navigateType === 'push' ? true : false - const { pathname } = url - const href = url.pathname + url.search + url.hash const segments = pathname.split('/') // TODO-APP: figure out something better for index pages @@ -439,6 +441,7 @@ export function reducer( // In case of soft push data fetching happens in layout-router if a segment is missing if (cacheType === 'soft') { + // Create optimistic tree that causes missing data to be fetched in layout-router during render. const optimisticTree = createOptimisticTree( segments, state.tree, @@ -448,10 +451,15 @@ export function reducer( ) 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, + // Optimistic tree is applied. tree: optimisticTree, } } @@ -460,24 +468,33 @@ 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) ) { 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: cache, + // Apply patched router state tree: mutable.patchedTree, } } // TODO-APP: flag on the tree of which part of the tree for if there is a loading boundary + /** + * If the tree can be optimistically rendered and suspend in layout-router instead of in the reducer. + */ const isOptimistic = canOptimisticallyRender(segments, state.tree) + // Optimistic tree case. if (isOptimistic) { - // Build optimistic tree // If the optimistic tree is deeper than the current state leave that deeper part out of the fetch const optimisticTree = createOptimisticTree( segments, @@ -489,54 +506,76 @@ export function reducer( // 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), - () => { - return fetchServerResponse(url, optimisticTree) - } + (): { readRoot: () => FlightData } => + fetchServerResponse(url, optimisticTree) ) + // If optimistic fetch couldn't happen it falls back to the non-optimistic case. if (!res?.bailOptimistic) { mutable.previousTree = state.tree mutable.patchedTree = optimisticTree return { + // Set href canonicalUrl: href, + // Set pendingPush pushRef: { pendingPush, mpaNavigation: false }, + // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, + // Apply patched cache cache: cache, + // Apply optimistic tree tree: optimisticTree, } } } + // Below is the not-optimistic case. + + // If no in-flight fetch at the top start it. if (!cache.data) { cache.data = fetchServerResponse(url, state.tree) } + + // 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 { canonicalUrl: flightData, + // Enable mpaNavigation pushRef: { pendingPush: true, mpaNavigation: true }, + // Don't apply scroll and focus management. focusAndScrollRef: { apply: false }, cache: state.cache, tree: state.tree, } } + // 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) - const treePath = flightDataPath.slice(0, -3) + + // 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 = walkTreeWithFlightDataPath( // TODO-APP: remove '' - ['', ...treePath], + ['', ...flightSegmentPath], state.tree, treePatch ) @@ -544,18 +583,26 @@ export function reducer( 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, + // Apply patched tree tree: newTree, } } + // This case should never be hit as `cacheType` is required and both cases are implemented. return state } case ACTION_SERVER_PATCH: { @@ -597,6 +644,7 @@ export function reducer( treePatch ) + // Copy subTreeData for the root node of the cache. cache.subTreeData = state.cache.subTreeData fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) From 83971958af5331d750784b28d912a8a45c72cc9d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 26 Jul 2022 21:41:12 +0200 Subject: [PATCH 23/29] Add further comments for reducer --- packages/next/client/components/reducer.ts | 73 ++++++++++++++-------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 9de25ccd3fdb..c0cddf6790a5 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -451,13 +451,13 @@ export function reducer( ) return { - // Set href + // 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 + // Existing cache is used for soft navigation. cache: state.cache, // Optimistic tree is applied. tree: optimisticTree, @@ -474,15 +474,15 @@ export function reducer( JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) ) { return { - // Set href + // 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 + // Apply cache. cache: cache, - // Apply patched router state + // Apply patched router state. tree: mutable.patchedTree, } } @@ -523,15 +523,15 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = optimisticTree return { - // Set href + // Set href. canonicalUrl: href, - // Set pendingPush + // Set pendingPush. pushRef: { pendingPush, mpaNavigation: false }, // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, - // Apply patched cache + // Apply patched cache. cache: cache, - // Apply optimistic tree + // Apply optimistic tree. tree: optimisticTree, } } @@ -589,15 +589,15 @@ export function reducer( fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) return { - // Set href + // Set href. canonicalUrl: href, - // Set pendingPush + // Set pendingPush. pushRef: { pendingPush, mpaNavigation: false }, // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, - // Apply patched cache + // Apply patched cache. cache: cache, - // Apply patched tree + // Apply patched tree. tree: newTree, } } @@ -607,30 +607,31 @@ export function reducer( } case ACTION_SERVER_PATCH: { const { flightData, previousTree, cache } = 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)) { // TODO-APP: Handle tree mismatch console.log('TREE MISMATCH') - return { - canonicalUrl: state.canonicalUrl, - pushRef: state.pushRef, - focusAndScrollRef: state.focusAndScrollRef, - tree: state.tree, - cache: state.cache, - } + // Keep everything as-is. + return state } // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { return { + // Set href. canonicalUrl: flightData, + // Enable mpaNavigation as this is a navigation that the app-router shouldn't handle. pushRef: { pendingPush: true, mpaNavigation: true }, + // Don't apply scroll and focus management. focusAndScrollRef: { apply: false }, + // Other state is kept as-is. cache: state.cache, tree: state.tree, } } - // TODO-APP: flightData could hold multiple paths + // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. const flightDataPath = flightData[0] // Slices off the last segment (which is at -3) as it doesn't exist in the tree yet @@ -649,29 +650,36 @@ export function reducer( fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) 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: newTree, + // Apply patched cache cache: cache, } } case ACTION_RELOAD: { const { url, cache, mutable } = action const href = url.pathname + url.search + url.hash + // Reload is always a replace. const pendingPush = false - // 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 - + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. if ( mutable.patchedTree && JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) ) { return { + // Set href. canonicalUrl: href, + // set pendingPush (always false in this case). pushRef: { pendingPush, mpaNavigation: false }, + // Apply focus and scroll. + // TODO-APP: might need to disable this for Fast Refresh. focusAndScrollRef: { apply: true }, cache: cache, tree: mutable.patchedTree, @@ -679,6 +687,7 @@ export function reducer( } if (!cache.data) { + // Fetch data from the root of the tree. cache.data = fetchServerResponse(url, [ state.tree[0], state.tree[1], @@ -699,17 +708,21 @@ export function reducer( } } + // 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] + // FlightDataPath with more than two items means unexpected Flight data was returned if (flightDataPath.length !== 2) { // TODO-APP: handle this case better console.log('RELOAD FAILED') return state } - const [treePatch, subTreeData] = flightDataPath.slice(-2) + // Given the path can only have two items the items are only the router state and subTreeData for the root. + const [treePatch, subTreeData] = flightDataPath const newTree = walkTreeWithFlightDataPath( // TODO-APP: remove '' [''], @@ -720,17 +733,23 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = newTree + // Set subTreeData for the root node of the cache. cache.subTreeData = subTreeData return { + // 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 }, - // TODO-APP: Revisit if this needs to be true in certain cases + // TODO-APP: might need to disable this for Fast Refresh. focusAndScrollRef: { apply: false }, + // Apply patched cache. cache: cache, + // Apply patched router state. tree: newTree, } } + // This case should never be hit as dispatch is strongly typed. default: throw new Error('Unknown action') } From 568e8579c8160e6153fa904e6343cc1350e792ec Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Tue, 26 Jul 2022 22:39:34 +0200 Subject: [PATCH 24/29] Revert childProp.segment removal --- packages/next/client/components/layout-router.client.tsx | 6 +++++- packages/next/server/app-render.tsx | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 6c37ecc909be..1b0b93ad123f 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -343,6 +343,10 @@ export default function OuterLayoutRouter({ // The reason arrays are used in the data format is that these are transferred from the server to the browser so it's optimized to save bytes. const treeSegment = tree[1][parallelRouterKey][0] + const childPropSegment = Array.isArray(childProp.segment) + ? childProp.segment[1] + : childProp.segment + // If segment is an array it's a dynamic route and we want to read the dynamic route value as the segment to get from the cache. const currentChildSegment = Array.isArray(treeSegment) ? treeSegment[1] @@ -372,7 +376,7 @@ export default function OuterLayoutRouter({ tree={tree} childNodes={childNodesForParallelRouter!} childProp={ - currentChildSegment === preservedSegment ? childProp : null + childPropSegment === preservedSegment ? childProp : null } segmentPath={segmentPath} path={preservedSegment} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 81dea7b8c13d..e62212076656 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -313,6 +313,7 @@ export type FlightDataPath = export type FlightData = Array | string export type ChildProp = { current: React.ReactNode + segment: Segment } /** @@ -649,8 +650,13 @@ export async function renderToHTMLOrFlight( rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, }) + const childSegment = parallelRoutes[parallelRouteKey][0] + const childSegmentParam = getDynamicParamFromSegment(childSegment) const childProp: ChildProp = { current: , + segment: childSegmentParam + ? childSegmentParam.treeSegment + : childSegment, } // This is turned back into an object below. From 3797baf54adea6e15639599892cf816d02835cb3 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 27 Jul 2022 10:53:17 +0200 Subject: [PATCH 25/29] Update packages/next/client/components/app-router.client.tsx Co-authored-by: Wyatt Johnson --- packages/next/client/components/app-router.client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index f1b176c5134d..a5f2f9e58629 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -300,7 +300,7 @@ export default function AppRouter({ value={{ changeByServerResponse, tree, - focusAndScrollRef: focusAndScrollRef, + focusAndScrollRef, }} > From 4eae282bc3ed7f968740fb76177bf4bed8385297 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 27 Jul 2022 10:58:57 +0200 Subject: [PATCH 26/29] Add readonly Co-Authored-By: Wyatt Johnson --- packages/next/client/components/reducer.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index c0cddf6790a5..6d678cbb71bd 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -411,8 +411,10 @@ interface ServerPatchAction { * Reducer that handles the app-router state updates. */ export function reducer( - state: AppRouterState, - action: ReloadAction | NavigateAction | RestoreAction | ServerPatchAction + state: Readonly, + action: Readonly< + ReloadAction | NavigateAction | RestoreAction | ServerPatchAction + > ): AppRouterState { switch (action.type) { case ACTION_RESTORE: { From 270c8ec80a6a318e871ae728495489f7f28e9be9 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 27 Jul 2022 11:03:03 +0200 Subject: [PATCH 27/29] Update packages/next/client/components/reducer.ts Co-authored-by: Wyatt Johnson --- 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 c0cddf6790a5..1e9c314c0b90 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -539,7 +539,7 @@ export function reducer( // Below is the not-optimistic case. - // If no in-flight fetch at the top start it. + // If no in-flight fetch at the top, start it. if (!cache.data) { cache.data = fetchServerResponse(url, state.tree) } From 293b22751080bc2546d9f46c9432911a667bb37d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 27 Jul 2022 11:03:22 +0200 Subject: [PATCH 28/29] Update packages/next/client/components/reducer.ts Co-authored-by: Yuddomack <41747333+Yuddomack@users.noreply.github.com> --- 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 1e9c314c0b90..85f09fd35874 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -433,7 +433,7 @@ export function reducer( const { url, cacheType, navigateType, cache, mutable } = action const { pathname, search, hash } = url const href = pathname + search + hash - const pendingPush = navigateType === 'push' ? true : false + const pendingPush = navigateType === 'push' const segments = pathname.split('/') // TODO-APP: figure out something better for index pages From aab0242b33a63516563e109575503e95ebc68bc7 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Wed, 27 Jul 2022 11:05:42 +0200 Subject: [PATCH 29/29] Throw error instead --- packages/next/client/components/reducer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 6d678cbb71bd..24bf753d7df3 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -605,7 +605,8 @@ export function reducer( } // This case should never be hit as `cacheType` is required and both cases are implemented. - return state + // Short error to save bundle space. + throw new Error('Invalid navigate') } case ACTION_SERVER_PATCH: { const { flightData, previousTree, cache } = action