diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 6d9f6d79000..dce12a991f5 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 a5ac17172dd..93a7b416756 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -3,26 +3,43 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we import { AppRouterContext, AppTreeContext, - FullAppTreeContext, + GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context' import type { CacheNode, AppRouterInstance, } from '../../shared/lib/app-router-context' import type { FlightRouterState, FlightData } from '../../server/app-render' -import { reducer } from './reducer' import { - QueryContext, + ACTION_NAVIGATE, + ACTION_RELOAD, + ACTION_RESTORE, + ACTION_SERVER_PATCH, + reducer, +} from './reducer' +import { + 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() @@ -33,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 { @@ -54,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, @@ -72,7 +99,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, @@ -90,40 +117,47 @@ 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 + /** + * Server response that only patches the cache and tree. + */ 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(), }, }) }, [] ) + /** + * 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, @@ -131,23 +165,21 @@ 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: {}, }) } 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 @@ -177,17 +209,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: {}, }) }) }, @@ -197,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 @@ -207,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) @@ -215,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. @@ -238,15 +278,14 @@ 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, }) }) }, []) + // Register popstate event to call onPopstate. React.useEffect(() => { window.addEventListener('popstate', onPopState) return () => { @@ -255,8 +294,8 @@ export default function AppRouter({ }, [onPopState]) return ( - - + - {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 + } - - + + ) } diff --git a/packages/next/client/components/hooks-client-context.ts b/packages/next/client/components/hooks-client-context.ts index 47da2be415b..5740d03a815 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 = 'SearchParamsContext' 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 fd5da9cab5d..461b013f99d 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/client/components/hot-reloader.client.tsx b/packages/next/client/components/hot-reloader.client.tsx index 00092175e84..c8b9f18d5b5 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 dc9101d017f..6406d6679ce 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 2767862c24b..0c0b589d117 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 @@ -35,7 +35,7 @@ const 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 || @@ -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] @@ -303,11 +300,20 @@ const 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 } @@ -319,108 +325,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, @@ -429,35 +399,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') { @@ -470,13 +535,13 @@ export function reducer( } } - cache.data = null - - // TODO-APP: ensure flightDataPath does not have "" as first item + // 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], @@ -484,150 +549,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 } diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 171b09d35f1..343a6f3ba39 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} + > + + ) } diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 38dc12f388e..c889f66858f 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 } @@ -31,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, @@ -43,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' }