From 0bbbae19b057a85aa8e6f10e2cf29710939c6c74 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 25 Jul 2022 09:39:46 +0200 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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}