diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 5bca0a94c4ae..913bbfde7bee 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -37,14 +37,8 @@ export function fetchServerResponse( } // TODO: move this back into AppRouter -let initialCache: CacheNode = - typeof window === 'undefined' - ? null! - : { - data: null, - subTreeData: null, - parallelRoutes: new Map(), - } +let initialParallelRoutes: CacheNode['parallelRoutes'] = + typeof window === 'undefined' ? null! : new Map() export default function AppRouter({ initialTree, @@ -61,20 +55,18 @@ export default function AppRouter({ typeof reducer >(reducer, { tree: initialTree, - cache: - typeof window === 'undefined' - ? { - data: null, - subTreeData: null, - parallelRoutes: new Map(), - } - : initialCache, + cache: { + data: null, + subTreeData: children, + parallelRoutes: + typeof window === 'undefined' ? new Map() : initialParallelRoutes, + }, pushRef: { pendingPush: false, mpaNavigation: false }, canonicalUrl: initialCanonicalUrl, }) useEffect(() => { - initialCache = null! + initialParallelRoutes = null! }, []) const { query, pathname } = React.useMemo(() => { @@ -157,6 +149,24 @@ export default function AppRouter({ navigate(href, 'hard', 'push') }) }, + reload: () => { + // @ts-ignore startTransition exists + React.startTransition(() => { + dispatch({ + type: 'reload', + payload: { + // TODO: revisit if this needs to be passed. + url: new URL(window.location.href), + cache: { + data: null, + subTreeData: null, + parallelRoutes: new Map(), + }, + mutable: {}, + }, + }) + }) + }, } return routerInstance @@ -219,7 +229,6 @@ export default function AppRouter({ window.removeEventListener('popstate', onPopState) } }, [onPopState]) - return ( @@ -239,7 +248,7 @@ export default function AppRouter({ url: canonicalUrl, }} > - {children} + {cache.subTreeData} {hotReloader} diff --git a/packages/next/client/components/hot-reloader.client.tsx b/packages/next/client/components/hot-reloader.client.tsx index 090dd05b3995..30cc653b12c7 100644 --- a/packages/next/client/components/hot-reloader.client.tsx +++ b/packages/next/client/components/hot-reloader.client.tsx @@ -8,6 +8,7 @@ import { } from 'next/dist/compiled/@next/react-dev-overlay/dist/client' import stripAnsi from 'next/dist/compiled/strip-ansi' import formatWebpackMessages from '../dev/error-overlay/format-webpack-messages' +import { useRouter } from './hooks-client' function getSocketProtocol(assetPrefix: string): string { let protocol = window.location.protocol @@ -177,7 +178,11 @@ function performFullReload(err: any, sendMessage: any) { window.location.reload() } -function processMessage(e: any, sendMessage: any) { +function processMessage( + e: any, + sendMessage: any, + router: ReturnType +) { const obj = JSON.parse(e.data) switch (obj.action) { @@ -292,6 +297,16 @@ function processMessage(e: any, sendMessage: any) { } return } + // TODO: make server component change more granular + case 'serverComponentChanges': { + sendMessage( + JSON.stringify({ + event: 'server-component-reload-page', + clientId: __nextDevClientId, + }) + ) + return router.reload() + } case 'reloadPage': { sendMessage( JSON.stringify({ @@ -367,6 +382,7 @@ function processMessage(e: any, sendMessage: any) { export default function HotReload({ assetPrefix }: { assetPrefix: string }) { const { tree } = useContext(FullAppTreeContext) + const router = useRouter() const webSocketRef = useRef() const sendMessage = useCallback((data) => { @@ -424,7 +440,7 @@ export default function HotReload({ assetPrefix }: { assetPrefix: string }) { } try { - processMessage(event, sendMessage) + processMessage(event, sendMessage, router) } catch (ex) { console.warn('Invalid HMR message: ' + event.data + '\n', ex) } @@ -437,7 +453,7 @@ export default function HotReload({ assetPrefix }: { assetPrefix: string }) { return () => webSocketRef.current && webSocketRef.current.removeEventListener('message', handler) - }, [sendMessage]) + }, [sendMessage, router]) // useEffect(() => { // const interval = setInterval(function () { // if ( diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 916e64ab5076..08b4cee164de 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -209,6 +209,18 @@ const walkTreeWithFlightDataPath = ( treePatch: FlightRouterState ): FlightRouterState => { const [segment, parallelRoutes, url] = flightRouterState + + // Root refresh + if (flightSegmentPath.length === 1) { + const tree: FlightRouterState = [...treePatch] + + if (url) { + tree.push(url) + } + + return tree + } + const [currentSegment, parallelRouteKey] = flightSegmentPath // Tree path returned from the server should always match up with the current tree in the browser @@ -250,6 +262,17 @@ type AppRouterState = { export function reducer( state: AppRouterState, action: + | { + type: 'reload' + payload: { + url: URL + cache: CacheNode + mutable: { + previousTree?: FlightRouterState + patchedTree?: FlightRouterState + } + } + } | { type: 'navigate' payload: { @@ -398,6 +421,7 @@ export function reducer( mutable.previousTree = state.tree mutable.patchedTree = newTree + cache.subTreeData = state.cache.subTreeData fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) return { @@ -448,6 +472,7 @@ export function reducer( treePatch ) + cache.subTreeData = state.cache.subTreeData fillCacheWithNewSubTreeData(cache, state.cache, flightDataPath) return { @@ -458,5 +483,78 @@ export function reducer( } } + if (action.type === 'reload') { + const { url, cache, mutable } = action.payload + const href = url.pathname + url.search + url.hash + const pendingPush = false + + // When doing a hard push there can be two cases: with optimistic tree and without + // The with optimistic tree case only happens when the layouts have a loading state (loading.js) + // The without optimistic tree case happens when there is no loading state, in that case we suspend in this reducer + + if ( + mutable.patchedTree && + JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) + ) { + return { + canonicalUrl: href, + pushRef: { pendingPush, mpaNavigation: false }, + 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 }, + cache: state.cache, + tree: state.tree, + } + } + + cache.data = null + + // TODO: ensure flightDataPath does not have "" as first item + const flightDataPath = flightData[0] + + if (flightDataPath.length !== 2) { + // TODO: handle this case better + console.log('RELOAD FAILED') + return state + } + + const [treePatch, subTreeData] = flightDataPath.slice(-2) + const newTree = walkTreeWithFlightDataPath( + // TODO: remove '' + [''], + state.tree, + treePatch + ) + + mutable.previousTree = state.tree + mutable.patchedTree = newTree + + cache.subTreeData = subTreeData + + return { + canonicalUrl: href, + pushRef: { pendingPush, mpaNavigation: false }, + cache: cache, + tree: newTree, + } + } + return state } diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index a8a588bf25f4..a04315a4291a 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -19,7 +19,10 @@ import * as Log from '../../build/output/log' import getBaseWebpackConfig from '../../build/webpack-config' import { API_ROUTE, APP_DIR_ALIAS } from '../../lib/constants' import { recursiveDelete } from '../../lib/recursive-delete' -import { BLOCKED_PAGES } from '../../shared/lib/constants' +import { + BLOCKED_PAGES, + NEXT_CLIENT_SSR_ENTRY_SUFFIX, +} from '../../shared/lib/constants' import { __ApiPreviewProps } from '../api-utils' import { getPathMatch } from '../../shared/lib/router/utils/path-match' import { findPageFile } from '../lib/find-page-file' @@ -694,7 +697,12 @@ export default class HotReloader { (stats: webpack5.Compilation) => { try { stats.entrypoints.forEach((entry, key) => { - if (key.startsWith('pages/') || isMiddlewareFilename(key)) { + if ( + key.startsWith('pages/') || + (key.startsWith('app/') && + !key.endsWith(NEXT_CLIENT_SSR_ENTRY_SUFFIX)) || + isMiddlewareFilename(key) + ) { // TODO this doesn't handle on demand loaded chunks entry.chunks.forEach((chunk) => { if (chunk.id === key) { @@ -812,6 +820,12 @@ export default class HotReloader { changedServerPages, changedClientPages ) + const serverComponentChanges = serverOnlyChanges.filter((key) => + key.startsWith('app/') + ) + const pageChanges = serverOnlyChanges.filter((key) => + key.startsWith('pages/') + ) const middlewareChanges = Array.from(changedEdgeServerPages).filter( (name) => isMiddlewareFilename(name) ) @@ -824,7 +838,8 @@ export default class HotReloader { event: 'middlewareChanges', }) } - if (serverOnlyChanges.length > 0) { + + if (pageChanges.length > 0) { this.send({ event: 'serverOnlyChanges', pages: serverOnlyChanges.map((pg) => @@ -832,6 +847,14 @@ export default class HotReloader { ), }) } + + if (serverComponentChanges.length > 0) { + this.send({ + action: 'serverComponentChanges', + // TODO: granular reloading of changes + // entrypoints: serverComponentChanges, + }) + } }) multiCompiler.compilers[0].hooks.failed.tap( diff --git a/packages/next/server/dev/on-demand-entry-handler.ts b/packages/next/server/dev/on-demand-entry-handler.ts index 41451552f661..b543de8752ed 100644 --- a/packages/next/server/dev/on-demand-entry-handler.ts +++ b/packages/next/server/dev/on-demand-entry-handler.ts @@ -23,6 +23,7 @@ function treePathToEntrypoint( ): string { const [parallelRouteKey, segment] = segmentPath + // TODO: modify this path to cover parallelRouteKey convention const path = (parentPath ? parentPath + '/' : '') + (parallelRouteKey !== 'children' ? parallelRouteKey + '/' : '') + diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 8e3f979d33c9..00994fda9784 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -13,6 +13,7 @@ export type CacheNode = { } export type AppRouterInstance = { + reload(): void push(href: string): void softPush(href: string): void replace(href: string): void