diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 89e77094d189..b2d0ef7e82c2 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -571,14 +571,29 @@ export default async function getBaseWebpackConfig( .replace(/\\/g, '/'), ...(config.experimental.appDir ? { - [CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT]: - `./` + - path - .relative( - dir, - path.join(NEXT_PROJECT_ROOT_DIST_CLIENT, 'app-next.js') - ) - .replace(/\\/g, '/'), + [CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT]: dev + ? [ + require.resolve( + `next/dist/compiled/@next/react-refresh-utils/dist/runtime` + ), + `./` + + path + .relative( + dir, + path.join( + NEXT_PROJECT_ROOT_DIST_CLIENT, + 'app-next-dev.js' + ) + ) + .replace(/\\/g, '/'), + ] + : `./` + + path + .relative( + dir, + path.join(NEXT_PROJECT_ROOT_DIST_CLIENT, 'app-next.js') + ) + .replace(/\\/g, '/'), } : {}), } as ClientEntries) diff --git a/packages/next/build/webpack/loaders/next-app-loader.ts b/packages/next/build/webpack/loaders/next-app-loader.ts index 82333a9e5a0a..3b8833630c5a 100644 --- a/packages/next/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/build/webpack/loaders/next-app-loader.ts @@ -140,6 +140,12 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ export const AppRouter = require('next/dist/client/components/app-router.client.js').default export const LayoutRouter = require('next/dist/client/components/layout-router.client.js').default + export const HotReloader = ${ + // Disable HotReloader component in production + this.mode === 'development' + ? `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/app-index.tsx b/packages/next/client/app-index.tsx index 21df8aeb919e..13ba7f0bed17 100644 --- a/packages/next/client/app-index.tsx +++ b/packages/next/client/app-index.tsx @@ -8,6 +8,46 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we /// +// Override chunk URL mapping in the webpack runtime +// https://github.com/webpack/webpack/blob/2738eebc7880835d88c727d364ad37f3ec557593/lib/RuntimeGlobals.js#L204 + +declare global { + const __webpack_require__: any +} + +// eslint-disable-next-line no-undef +const getChunkScriptFilename = __webpack_require__.u +const chunkFilenameMap: any = {} + +// eslint-disable-next-line no-undef +__webpack_require__.u = (chunkId: any) => { + return chunkFilenameMap[chunkId] || getChunkScriptFilename(chunkId) +} + +// Ignore the module ID transform in client. +// eslint-disable-next-line no-undef +// @ts-expect-error TODO: fix type +self.__next_require__ = __webpack_require__ + +// eslint-disable-next-line no-undef +// @ts-expect-error TODO: fix type +self.__next_chunk_load__ = (chunk) => { + if (chunk.endsWith('.css')) { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = '/_next/' + chunk + document.head.appendChild(link) + return Promise.resolve() + } + + const [chunkId, chunkFileName] = chunk.split(':') + chunkFilenameMap[chunkId] = `static/chunks/${chunkFileName}.js` + + // @ts-ignore + // eslint-disable-next-line no-undef + return __webpack_chunk_load__(chunkId) +} + export const version = process.env.__NEXT_VERSION const appElement: HTMLElement | Document | null = document @@ -119,7 +159,7 @@ function useInitialServerResponse(cacheKey: string) { return newResponse } -const ServerRoot = ({ cacheKey }: { cacheKey: string }) => { +function ServerRoot({ cacheKey }: { cacheKey: string }) { React.useEffect(() => { rscCache.delete(cacheKey) }) @@ -128,6 +168,19 @@ const ServerRoot = ({ cacheKey }: { cacheKey: string }) => { return root } +function ErrorOverlay({ + children, +}: React.PropsWithChildren<{}>): React.ReactElement { + if (process.env.NODE_ENV === 'production') { + return <>{children} + } else { + const { + ReactDevOverlay, + } = require('next/dist/compiled/@next/react-dev-overlay/dist/client') + return {children} + } +} + function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement { if (process.env.__NEXT_TEST_MODE) { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -143,17 +196,19 @@ function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement { return children as React.ReactElement } -const RSCComponent = () => { +function RSCComponent() { const cacheKey = getCacheKey() return } export function hydrate() { renderReactElement(appElement!, () => ( - - - - - + + + + + + + )) } diff --git a/packages/next/client/app-next-dev.js b/packages/next/client/app-next-dev.js new file mode 100644 index 000000000000..842c553d801e --- /dev/null +++ b/packages/next/client/app-next-dev.js @@ -0,0 +1,14 @@ +import { hydrate, version } from './app-index' + +// TODO: implement FOUC guard + +// TODO: hydration warning + +window.next = { + version, + appDir: true, +} + +hydrate() + +// TODO: build indicator diff --git a/packages/next/client/app-next.js b/packages/next/client/app-next.js index d1ac05d8e566..d665023f5829 100644 --- a/packages/next/client/app-next.js +++ b/packages/next/client/app-next.js @@ -6,41 +6,7 @@ import 'next/dist/client/components/layout-router.client.js' window.next = { version, - root: true, -} - -// Override chunk URL mapping in the webpack runtime -// https://github.com/webpack/webpack/blob/2738eebc7880835d88c727d364ad37f3ec557593/lib/RuntimeGlobals.js#L204 - -// eslint-disable-next-line no-undef -const getChunkScriptFilename = __webpack_require__.u -const chunkFilenameMap = {} - -// eslint-disable-next-line no-undef -__webpack_require__.u = (chunkId) => { - return chunkFilenameMap[chunkId] || getChunkScriptFilename(chunkId) -} - -// Ignore the module ID transform in client. -// eslint-disable-next-line no-undef -self.__next_require__ = __webpack_require__ - -// eslint-disable-next-line no-undef -self.__next_chunk_load__ = (chunk) => { - if (chunk.endsWith('.css')) { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = '/_next/' + chunk - document.head.appendChild(link) - return Promise.resolve() - } - - const [chunkId, chunkFileName] = chunk.split(':') - chunkFilenameMap[chunkId] = `static/chunks/${chunkFileName}.js` - - // @ts-ignore - // eslint-disable-next-line no-undef - return __webpack_chunk_load__(chunkId) + appDir: true, } hydrate() diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index edd452e5bab3..5bca0a94c4ae 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -50,10 +50,12 @@ export default function AppRouter({ initialTree, initialCanonicalUrl, children, + hotReloader, }: { initialTree: FlightRouterState initialCanonicalUrl: string children: React.ReactNode + hotReloader?: React.ReactNode }) { const [{ tree, cache, pushRef, canonicalUrl }, dispatch] = React.useReducer< typeof reducer @@ -238,6 +240,7 @@ export default function AppRouter({ }} > {children} + {hotReloader} diff --git a/packages/next/client/components/hot-reloader.client.tsx b/packages/next/client/components/hot-reloader.client.tsx new file mode 100644 index 000000000000..090dd05b3995 --- /dev/null +++ b/packages/next/client/components/hot-reloader.client.tsx @@ -0,0 +1,454 @@ +import { useCallback, useContext, useEffect, useRef } from 'react' +import { FullAppTreeContext } from '../../shared/lib/app-router-context' +import { + register, + onBuildError, + onBuildOk, + onRefresh, +} 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' + +function getSocketProtocol(assetPrefix: string): string { + let protocol = window.location.protocol + + try { + // assetPrefix is a url + protocol = new URL(assetPrefix).protocol + } catch (_) {} + + return protocol === 'http:' ? 'ws' : 'wss' +} + +// const TIMEOUT = 5000 + +// TODO: add actual type +type PongEvent = any + +let mostRecentCompilationHash: any = null +let __nextDevClientId = Math.round(Math.random() * 100 + Date.now()) +let hadRuntimeError = false + +// let startLatency = undefined + +function onFastRefresh(hasUpdates: boolean) { + onBuildOk() + if (hasUpdates) { + onRefresh() + } + + // if (startLatency) { + // const endLatency = Date.now() + // const latency = endLatency - startLatency + // console.log(`[Fast Refresh] done in ${latency}ms`) + // sendMessage( + // JSON.stringify({ + // event: 'client-hmr-latency', + // id: __nextDevClientId, + // startTime: startLatency, + // endTime: endLatency, + // }) + // ) + // // if (self.__NEXT_HMR_LATENCY_CB) { + // // self.__NEXT_HMR_LATENCY_CB(latency) + // // } + // } +} + +// There is a newer version of the code available. +function handleAvailableHash(hash: string) { + // Update last known compilation hash. + mostRecentCompilationHash = hash +} + +// Is there a newer version of this code available? +function isUpdateAvailable() { + /* globals __webpack_hash__ */ + // __webpack_hash__ is the hash of the current compilation. + // It's a global variable injected by Webpack. + // @ts-expect-error __webpack_hash__ exists + return mostRecentCompilationHash !== __webpack_hash__ +} + +// Webpack disallows updates in other states. +function canApplyUpdates() { + // @ts-expect-error module.hot exists + return module.hot.status() === 'idle' +} +// function afterApplyUpdates(fn: any) { +// if (canApplyUpdates()) { +// fn() +// } else { +// function handler(status: any) { +// if (status === 'idle') { +// // @ts-expect-error module.hot exists +// module.hot.removeStatusHandler(handler) +// fn() +// } +// } +// // @ts-expect-error module.hot exists +// module.hot.addStatusHandler(handler) +// } +// } + +// Attempt to update code on the fly, fall back to a hard reload. +function tryApplyUpdates(onHotUpdateSuccess: any, sendMessage: any) { + // @ts-expect-error module.hot exists + if (!module.hot) { + // HotModuleReplacementPlugin is not in Webpack configuration. + console.error('HotModuleReplacementPlugin is not in Webpack configuration.') + // window.location.reload(); + return + } + + if (!isUpdateAvailable() || !canApplyUpdates()) { + onBuildOk() + return + } + + function handleApplyUpdates(err: any, updatedModules: any) { + if (err || hadRuntimeError || !updatedModules) { + if (err) { + console.warn( + '[Fast Refresh] performing full reload\n\n' + + "Fast Refresh will perform a full reload when you edit a file that's imported by modules outside of the React rendering tree.\n" + + 'You might have a file which exports a React component but also exports a value that is imported by a non-React component file.\n' + + 'Consider migrating the non-React component export to a separate file and importing it into both files.\n\n' + + 'It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.\n' + + 'Fast Refresh requires at least one parent function component in your React tree.' + ) + } else if (hadRuntimeError) { + console.warn( + '[Fast Refresh] performing full reload because your application had an unrecoverable error' + ) + } + performFullReload(err, sendMessage) + return + } + + const hasUpdates = Boolean(updatedModules.length) + if (typeof onHotUpdateSuccess === 'function') { + // Maybe we want to do something. + onHotUpdateSuccess(hasUpdates) + } + + if (isUpdateAvailable()) { + // While we were updating, there was a new update! Do it again. + tryApplyUpdates(hasUpdates ? onBuildOk : onHotUpdateSuccess, sendMessage) + } else { + onBuildOk() + // if (process.env.__NEXT_TEST_MODE) { + // afterApplyUpdates(() => { + // if (self.__NEXT_HMR_CB) { + // self.__NEXT_HMR_CB() + // self.__NEXT_HMR_CB = null + // } + // }) + // } + } + } + + // https://webpack.js.org/api/hot-module-replacement/#check + // @ts-expect-error module.hot exists + module.hot.check(/* autoApply */ true).then( + (updatedModules: any) => { + handleApplyUpdates(null, updatedModules) + }, + (err: any) => { + handleApplyUpdates(err, null) + } + ) +} + +function performFullReload(err: any, sendMessage: any) { + const stackTrace = + err && + ((err.stack && err.stack.split('\n').slice(0, 5).join('\n')) || + err.message || + err + '') + + sendMessage( + JSON.stringify({ + event: 'client-full-reload', + stackTrace, + }) + ) + + window.location.reload() +} + +function processMessage(e: any, sendMessage: any) { + const obj = JSON.parse(e.data) + + switch (obj.action) { + case 'building': { + // startLatency = Date.now() + console.log('[Fast Refresh] rebuilding') + break + } + case 'built': + case 'sync': { + if (obj.hash) { + handleAvailableHash(obj.hash) + } + + const { errors, warnings } = obj + const hasErrors = Boolean(errors && errors.length) + // Compilation with errors (e.g. syntax error or missing modules). + if (hasErrors) { + sendMessage( + JSON.stringify({ + event: 'client-error', + errorCount: errors.length, + clientId: __nextDevClientId, + }) + ) + + // "Massage" webpack messages. + var formatted = formatWebpackMessages({ + errors: errors, + warnings: [], + }) + + // Only show the first error. + onBuildError(formatted.errors[0]) + + // Also log them to the console. + for (let i = 0; i < formatted.errors.length; i++) { + console.error(stripAnsi(formatted.errors[i])) + } + + // Do not attempt to reload now. + // We will reload on next success instead. + // if (process.env.__NEXT_TEST_MODE) { + // if (self.__NEXT_HMR_CB) { + // self.__NEXT_HMR_CB(formatted.errors[0]) + // self.__NEXT_HMR_CB = null + // } + // } + return + } + + const hasWarnings = Boolean(warnings && warnings.length) + if (hasWarnings) { + sendMessage( + JSON.stringify({ + event: 'client-warning', + warningCount: warnings.length, + clientId: __nextDevClientId, + }) + ) + + // Compilation with warnings (e.g. ESLint). + const isHotUpdate = obj.action !== 'sync' + + // Print warnings to the console. + const formattedMessages = formatWebpackMessages({ + warnings: warnings, + errors: [], + }) + + for (let i = 0; i < formattedMessages.warnings.length; i++) { + if (i === 5) { + console.warn( + 'There were more warnings in other files.\n' + + 'You can find a complete log in the terminal.' + ) + break + } + console.warn(stripAnsi(formattedMessages.warnings[i])) + } + + // Attempt to apply hot updates or reload. + if (isHotUpdate) { + tryApplyUpdates(function onSuccessfulHotUpdate(hasUpdates: any) { + // Only dismiss it when we're sure it's a hot update. + // Otherwise it would flicker right before the reload. + onFastRefresh(hasUpdates) + }, sendMessage) + } + return + } + + sendMessage( + JSON.stringify({ + event: 'client-success', + clientId: __nextDevClientId, + }) + ) + + const isHotUpdate = + obj.action !== 'sync' || + ((!window.__NEXT_DATA__ || window.__NEXT_DATA__.page !== '/_error') && + isUpdateAvailable()) + + // Attempt to apply hot updates or reload. + if (isHotUpdate) { + tryApplyUpdates(function onSuccessfulHotUpdate(hasUpdates: any) { + // Only dismiss it when we're sure it's a hot update. + // Otherwise it would flicker right before the reload. + onFastRefresh(hasUpdates) + }, sendMessage) + } + return + } + case 'reloadPage': { + sendMessage( + JSON.stringify({ + event: 'client-reload-page', + clientId: __nextDevClientId, + }) + ) + return window.location.reload() + } + case 'removedPage': { + // const [page] = obj.data + // if (page === window.next.router.pathname) { + // sendMessage( + // JSON.stringify({ + // event: 'client-removed-page', + // clientId: window.__nextDevClientId, + // page, + // }) + // ) + // return window.location.reload() + // } + return + } + case 'addedPage': { + // const [page] = obj.data + // if ( + // page === window.next.router.pathname && + // typeof window.next.router.components[page] === 'undefined' + // ) { + // sendMessage( + // JSON.stringify({ + // event: 'client-added-page', + // clientId: window.__nextDevClientId, + // page, + // }) + // ) + // return window.location.reload() + // } + return + } + case 'pong': { + const { invalid } = obj + if (invalid) { + // Payload can be invalid even if the page does exist. + // So, we check if it can be created. + fetch(location.href, { + credentials: 'same-origin', + }).then((pageRes) => { + if (pageRes.status === 200) { + // Page exists now, reload + location.reload() + } else { + // TODO: fix this + // Page doesn't exist + // if ( + // self.__NEXT_DATA__.page === Router.pathname && + // Router.pathname !== '/_error' + // ) { + // // We are still on the page, + // // reload to show 404 error page + // location.reload() + // } + } + }) + } + return + } + default: { + throw new Error('Unexpected action ' + obj.action) + } + } +} + +export default function HotReload({ assetPrefix }: { assetPrefix: string }) { + const { tree } = useContext(FullAppTreeContext) + + const webSocketRef = useRef() + const sendMessage = useCallback((data) => { + const socket = webSocketRef.current + if (!socket || socket.readyState !== socket.OPEN) return + return socket.send(data) + }, []) + + useEffect(() => { + register() + }, []) + + useEffect(() => { + if (webSocketRef.current) { + return + } + + const { hostname, port } = window.location + const protocol = getSocketProtocol(assetPrefix || '') + const normalizedAssetPrefix = assetPrefix.replace(/^\/+/, '') + + let url = `${protocol}://${hostname}:${port}${ + normalizedAssetPrefix ? `/${normalizedAssetPrefix}` : '' + }` + + if (normalizedAssetPrefix.startsWith('http')) { + url = `${protocol}://${normalizedAssetPrefix.split('://')[1]}` + } + + webSocketRef.current = new window.WebSocket(`${url}/_next/webpack-hmr`) + }, [assetPrefix]) + useEffect(() => { + // Taken from on-demand-entries-client.js + // TODO: check 404 case + const interval = setInterval(() => { + sendMessage( + JSON.stringify({ + event: 'ping', + // TODO: fix case for dynamic parameters, this will be resolved wrong currently. + tree, + appDirRoute: true, + }) + ) + }, 2500) + return () => clearInterval(interval) + }, [tree, sendMessage]) + useEffect(() => { + const handler = (event: MessageEvent) => { + if ( + event.data.indexOf('action') === -1 && + // TODO: clean this up for consistency + event.data.indexOf('pong') === -1 + ) { + return + } + + try { + processMessage(event, sendMessage) + } catch (ex) { + console.warn('Invalid HMR message: ' + event.data + '\n', ex) + } + } + + if (webSocketRef.current) { + webSocketRef.current.addEventListener('message', handler) + } + + return () => + webSocketRef.current && + webSocketRef.current.removeEventListener('message', handler) + }, [sendMessage]) + // useEffect(() => { + // const interval = setInterval(function () { + // if ( + // lastActivityRef.current && + // Date.now() - lastActivityRef.current > TIMEOUT + // ) { + // handleDisconnect() + // } + // }, 2500) + + // return () => clearInterval(interval) + // }) + return null +} diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index a7843dd646d2..c9b8c571eed2 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -377,6 +377,9 @@ export async function renderToHTML( const LayoutRouter = ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default + const HotReloader = ComponentMod.HotReloader as + | typeof import('../client/components/hot-reloader.client').default + | null const headers = req.headers // @ts-expect-error TODO: fix type of req @@ -746,7 +749,7 @@ export async function renderToHTML( const search = stringifyQuery(query) // TODO: validate req.url as it gets passed to render. - const initialCanonicalUrl = req.url + const initialCanonicalUrl = req.url! // TODO: change tree to accommodate this // /blog/[...slug]/page.js -> /blog/hello-world/b/c/d -> ['children', 'blog', 'children', ['slug', 'hello-world/b/c/d']] @@ -760,7 +763,8 @@ export async function renderToHTML( firstItem: true, }) - const AppRouter = ComponentMod.AppRouter + const AppRouter = + ComponentMod.AppRouter as typeof import('../client/components/app-router.client').default const { QueryContext, PathnameContext, @@ -774,6 +778,7 @@ export async function renderToHTML( {/* */} } initialCanonicalUrl={initialCanonicalUrl} initialTree={initialTree} > diff --git a/packages/next/server/dev/on-demand-entry-handler.ts b/packages/next/server/dev/on-demand-entry-handler.ts index bbddbc9c8eba..41451552f661 100644 --- a/packages/next/server/dev/on-demand-entry-handler.ts +++ b/packages/next/server/dev/on-demand-entry-handler.ts @@ -15,6 +15,56 @@ import { serverComponentRegex } from '../../build/webpack/loaders/utils' import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' import { isMiddlewareFile, isMiddlewareFilename } from '../../build/utils' import { PageNotFoundError } from '../../shared/lib/utils' +import { FlightRouterState } from '../app-render' + +function treePathToEntrypoint( + segmentPath: string[], + parentPath?: string +): string { + const [parallelRouteKey, segment] = segmentPath + + const path = + (parentPath ? parentPath + '/' : '') + + (parallelRouteKey !== 'children' ? parallelRouteKey + '/' : '') + + (segment === '' ? 'page' : segment) + + // Last segment + if (segmentPath.length === 2) { + return path + } + + const childSegmentPath = segmentPath.slice(2) + return treePathToEntrypoint(childSegmentPath, path) +} + +function getEntrypointsFromTree( + tree: FlightRouterState, + isFirst: boolean, + parentPath: string[] = [] +) { + const [segment, parallelRoutes] = tree + + const currentSegment = Array.isArray(segment) ? segment[0] : segment + + const currentPath = [...parentPath, currentSegment] + + if (!isFirst && currentSegment === '') { + // TODO get rid of '' at the start of tree + return [treePathToEntrypoint(currentPath.slice(1))] + } + + return Object.keys(parallelRoutes).reduce( + (paths: string[], key: string): string[] => { + const childTree = parallelRoutes[key] + const childPages = getEntrypointsFromTree(childTree, false, [ + ...currentPath, + key, + ]) + return [...paths, ...childPages] + }, + [] + ) +} export const ADDED = Symbol('added') export const BUILDING = Symbol('building') @@ -78,6 +128,7 @@ export function onDemandEntryHandler({ invalidator = new Invalidator(multiCompiler) const doneCallbacks: EventEmitter | null = new EventEmitter() const lastClientAccessPages = [''] + const lastServerAccessPagesForAppDir = [''] const startBuilding = (_compilation: webpack.Compilation) => { invalidator.startBuilding() @@ -153,9 +204,48 @@ export function onDemandEntryHandler({ const pingIntervalTime = Math.max(1000, Math.min(5000, maxInactiveAge)) setInterval(function () { - disposeInactiveEntries(lastClientAccessPages, maxInactiveAge) + disposeInactiveEntries( + lastClientAccessPages, + lastServerAccessPagesForAppDir, + maxInactiveAge + ) }, pingIntervalTime + 1000).unref() + function handleAppDirPing( + tree: FlightRouterState + ): { success: true } | { invalid: true } { + const pages = getEntrypointsFromTree(tree, true) + + for (const page of pages) { + const pageKey = `server/${page}` + const entryInfo = entries[pageKey] + + // If there's no entry, it may have been invalidated and needs to be re-built. + if (!entryInfo) { + // if (page !== lastEntry) client pings, but there's no entry for page + return { invalid: true } + } + + // We don't need to maintain active state of anything other than BUILT entries + if (entryInfo.status !== BUILT) continue + + // If there's an entryInfo + if (!lastServerAccessPagesForAppDir.includes(pageKey)) { + lastServerAccessPagesForAppDir.unshift(pageKey) + + // Maintain the buffer max length + // TODO: verify that the current pageKey is not at the end of the array as multiple entrypoints can exist + if (lastServerAccessPagesForAppDir.length > pagesBufferLength) { + lastServerAccessPagesForAppDir.pop() + } + } + entryInfo.lastActiveTime = Date.now() + entryInfo.dispose = false + } + + return { success: true } + } + function handlePing(pg: string) { const page = normalizePathSep(pg) const pageKey = `client${page}` @@ -272,11 +362,13 @@ export function onDemandEntryHandler({ ) if (parsedData.event === 'ping') { - const result = handlePing(parsedData.page) + const result = parsedData.appDirRoute + ? handleAppDirPing(parsedData.tree) + : handlePing(parsedData.page) client.send( JSON.stringify({ ...result, - event: 'pong', + [parsedData.appDirRoute ? 'action' : 'event']: 'pong', }) ) } @@ -288,10 +380,17 @@ export function onDemandEntryHandler({ function disposeInactiveEntries( lastClientAccessPages: string[], + lastServerAccessPagesForAppDir: string[], maxInactiveAge: number ) { Object.keys(entries).forEach((page) => { - const { lastActiveTime, status, dispose } = entries[page] + const { lastActiveTime, status, dispose, bundlePath } = entries[page] + + const isClientComponentsEntry = + bundlePath.startsWith('app/') && page.startsWith('client/') + + // Disposing client component entry is handled when disposing server component entry + if (isClientComponentsEntry) return // Skip pages already scheduled for disposing if (dispose) return @@ -303,15 +402,26 @@ function disposeInactiveEntries( // We should not build the last accessed page even we didn't get any pings // Sometimes, it's possible our XHR ping to wait before completing other requests. // In that case, we should not dispose the current viewing page - if (lastClientAccessPages.includes(page)) return + if ( + lastClientAccessPages.includes(page) || + lastServerAccessPagesForAppDir.includes(page) + ) + return if (lastActiveTime && Date.now() - lastActiveTime > maxInactiveAge) { + const isServerComponentsEntry = + bundlePath.startsWith('app/') && page.startsWith('server/') + + // Dispose client component entrypoint when server component entrypoint is disposed. + if (isServerComponentsEntry) { + entries[page.replace('server/', 'client/')].dispose = true + } entries[page].dispose = true } }) } -// Make sure only one invalidation happens at a time +// Make sure only one invalidation happens at a timeāˆ« // Otherwise, webpack hash gets changed and it'll force the client to reload. class Invalidator { private multiCompiler: webpack.MultiCompiler diff --git a/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx b/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx index 1d2131021195..fe2fe056ca2c 100644 --- a/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx +++ b/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx @@ -47,9 +47,11 @@ type ErrorType = 'runtime' | 'build' const ReactDevOverlay: React.FunctionComponent = function ReactDevOverlay({ children, preventDisplay, + globalOverlay, }: { children?: React.ReactNode preventDisplay?: ErrorType[] + globalOverlay?: boolean }) { const [state, dispatch] = React.useReducer< React.Reducer @@ -84,7 +86,7 @@ const ReactDevOverlay: React.FunctionComponent = function ReactDevOverlay({ {children ?? null} {isMounted ? ( - + diff --git a/packages/react-dev-overlay/src/internal/components/ShadowPortal.tsx b/packages/react-dev-overlay/src/internal/components/ShadowPortal.tsx index 61b7391c58ad..1a7e7f181ed1 100644 --- a/packages/react-dev-overlay/src/internal/components/ShadowPortal.tsx +++ b/packages/react-dev-overlay/src/internal/components/ShadowPortal.tsx @@ -3,10 +3,12 @@ import { createPortal } from 'react-dom' export type ShadowPortalProps = { children: React.ReactNode + globalOverlay?: boolean } export const ShadowPortal: React.FC = function Portal({ children, + globalOverlay, }) { let mountNode = React.useRef(null) let portalNode = React.useRef(null) @@ -14,7 +16,9 @@ export const ShadowPortal: React.FC = function Portal({ let [, forceUpdate] = React.useState<{} | undefined>() React.useLayoutEffect(() => { - const ownerDocument = mountNode.current!.ownerDocument! + const ownerDocument = globalOverlay + ? document + : mountNode.current!.ownerDocument! portalNode.current = ownerDocument.createElement('nextjs-portal') shadowNode.current = portalNode.current.attachShadow({ mode: 'open' }) ownerDocument.body.appendChild(portalNode.current) @@ -24,11 +28,11 @@ export const ShadowPortal: React.FC = function Portal({ portalNode.current.ownerDocument.body.removeChild(portalNode.current) } } - }, []) + }, [globalOverlay]) return shadowNode.current ? ( createPortal(children, shadowNode.current as any) - ) : ( + ) : globalOverlay ? null : ( ) }