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 : (
)
}