From 5a5443f68e99d84863391232e000374c886fc541 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 10 Jul 2022 17:27:43 +0200 Subject: [PATCH] Handle on-demand entries and error overlay --- packages/next/build/webpack-config.ts | 32 +- .../build/webpack/loaders/next-app-loader.ts | 6 + .../app-dev/error-overlay/hot-dev-client.js | 466 ------------------ packages/next/client/app-index.tsx | 29 +- packages/next/client/app-next-dev.js | 8 - .../client/components/app-router.client.tsx | 3 + .../client/components/hot-reloader.client.tsx | 454 +++++++++++++++++ packages/next/server/app-render.tsx | 9 +- .../server/dev/on-demand-entry-handler.ts | 78 ++- .../src/internal/ReactDevOverlay.tsx | 4 +- .../src/internal/components/ShadowPortal.tsx | 8 +- 11 files changed, 591 insertions(+), 506 deletions(-) delete mode 100644 packages/next/client/app-dev/error-overlay/hot-dev-client.js create mode 100644 packages/next/client/components/hot-reloader.client.tsx diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index fcc3c896d694..b2d0ef7e82c2 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -571,17 +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, - dev ? 'app-next-dev.js' : 'app-next.js' + [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, '/'), + .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-dev/error-overlay/hot-dev-client.js b/packages/next/client/app-dev/error-overlay/hot-dev-client.js deleted file mode 100644 index ab646f298d61..000000000000 --- a/packages/next/client/app-dev/error-overlay/hot-dev-client.js +++ /dev/null @@ -1,466 +0,0 @@ -/** - * MIT License - * - * Copyright (c) 2013-present, Facebook, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -// This file is a modified version of the Create React App HMR dev client that -// can be found here: -// https://github.com/facebook/create-react-app/blob/v3.4.1/packages/react-dev-utils/webpackHotDevClient.js - -import { - register, - onBuildError, - onBuildOk, - onRefresh, -} from 'next/dist/compiled/@next/react-dev-overlay/dist/client' -import stripAnsi from 'next/dist/compiled/strip-ansi' -import { - addMessageListener, - sendMessage, -} from '../../dev/error-overlay/websocket' -import formatWebpackMessages from './format-webpack-messages' - -// This alternative WebpackDevServer combines the functionality of: -// https://github.com/webpack/webpack-dev-server/blob/webpack-1/client/index.js -// https://github.com/webpack/webpack/blob/webpack-1/hot/dev-server.js - -// It only supports their simplest configuration (hot updates on same server). -// It makes some opinionated choices on top, like adding a syntax error overlay -// that looks similar to our console output. The error overlay is inspired by: -// https://github.com/glenjamin/webpack-hot-middleware - -window.__nextDevClientId = Math.round(Math.random() * 100 + Date.now()) - -let hadRuntimeError = false -export default function connect() { - register() - - // Taken from on-demand-entries-client.js - // TODO: check 404 case - setInterval(() => { - sendMessage( - JSON.stringify({ - event: 'ping', - // TODO: fix case for dynamic parameters, this will be resolved wrong currently. - page: window.location.pathname, - appDirRoute: true, - }) - ) - }, 2500) - - addMessageListener((event) => { - console.log(event.data) - if ( - event.data.indexOf('action') === -1 && - // TODO: clean this up for consistency - event.data.indexOf('pong') === -1 - ) { - return - } - - try { - processMessage(event) - } catch (ex) { - console.warn('Invalid HMR message: ' + event.data + '\n', ex) - } - }) - - return { - onUnrecoverableError() { - hadRuntimeError = true - }, - } -} - -// Remember some state related to hot module replacement. -var isFirstCompilation = true -var mostRecentCompilationHash = null -var hasCompileErrors = false - -function clearOutdatedErrors() { - // Clean up outdated compile errors, if any. - if (typeof console !== 'undefined' && typeof console.clear === 'function') { - if (hasCompileErrors) { - console.clear() - } - } -} - -// Successful compilation. -function handleSuccess() { - clearOutdatedErrors() - - const isHotUpdate = - !isFirstCompilation || - ((!window.__NEXT_DATA__ || window.__NEXT_DATA__.page !== '/_error') && - isUpdateAvailable()) - isFirstCompilation = false - hasCompileErrors = false - - // Attempt to apply hot updates or reload. - if (isHotUpdate) { - tryApplyUpdates(function onSuccessfulHotUpdate(hasUpdates) { - // Only dismiss it when we're sure it's a hot update. - // Otherwise it would flicker right before the reload. - onFastRefresh(hasUpdates) - }) - } -} - -// Compilation with warnings (e.g. ESLint). -function handleWarnings(warnings) { - clearOutdatedErrors() - - const isHotUpdate = !isFirstCompilation - isFirstCompilation = false - hasCompileErrors = false - - function printWarnings() { - // Print warnings to the console. - const formatted = formatWebpackMessages({ - warnings: warnings, - errors: [], - }) - - if (typeof console !== 'undefined' && typeof console.warn === 'function') { - for (let i = 0; i < formatted.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(formatted.warnings[i])) - } - } - } - - printWarnings() - - // Attempt to apply hot updates or reload. - if (isHotUpdate) { - tryApplyUpdates(function onSuccessfulHotUpdate(hasUpdates) { - // Only dismiss it when we're sure it's a hot update. - // Otherwise it would flicker right before the reload. - onFastRefresh(hasUpdates) - }) - } -} - -// Compilation with errors (e.g. syntax error or missing modules). -function handleErrors(errors) { - clearOutdatedErrors() - - isFirstCompilation = false - hasCompileErrors = true - - // "Massage" webpack messages. - var formatted = formatWebpackMessages({ - errors: errors, - warnings: [], - }) - - // Only show the first error. - onBuildError(formatted.errors[0]) - - // Also log them to the console. - if (typeof console !== 'undefined' && typeof console.error === 'function') { - for (var 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 - } - } -} - -let startLatency = undefined - -function onFastRefresh(hasUpdates) { - 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: window.__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) { - // Update last known compilation hash. - mostRecentCompilationHash = hash -} - -// Handle messages from the server. -function processMessage(e) { - const obj = JSON.parse(e.data) - if (obj.event && obj.event === 'pong') obj.action = 'pong' - - 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) - if (hasErrors) { - sendMessage( - JSON.stringify({ - event: 'client-error', - errorCount: errors.length, - clientId: window.__nextDevClientId, - }) - ) - return handleErrors(errors) - } - - const hasWarnings = Boolean(warnings && warnings.length) - if (hasWarnings) { - sendMessage( - JSON.stringify({ - event: 'client-warning', - warningCount: warnings.length, - clientId: window.__nextDevClientId, - }) - ) - return handleWarnings(warnings) - } - - sendMessage( - JSON.stringify({ - event: 'client-success', - clientId: window.__nextDevClientId, - }) - ) - return handleSuccess() - } - case 'reloadPage': { - sendMessage( - JSON.stringify({ - event: 'client-reload-page', - clientId: window.__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) - } - } -} - -// 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. - return mostRecentCompilationHash !== __webpack_hash__ -} - -// Webpack disallows updates in other states. -function canApplyUpdates() { - return module.hot.status() === 'idle' -} -function afterApplyUpdates(fn) { - if (canApplyUpdates()) { - fn() - } else { - function handler(status) { - if (status === 'idle') { - module.hot.removeStatusHandler(handler) - fn() - } - } - module.hot.addStatusHandler(handler) - } -} - -// Attempt to update code on the fly, fall back to a hard reload. -function tryApplyUpdates(onHotUpdateSuccess) { - 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, updatedModules) { - 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) - 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) - } 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 - module.hot.check(/* autoApply */ true).then( - (updatedModules) => { - handleApplyUpdates(null, updatedModules) - }, - (err) => { - handleApplyUpdates(err, null) - } - ) -} - -function performFullReload(err) { - 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() -} diff --git a/packages/next/client/app-index.tsx b/packages/next/client/app-index.tsx index e545e546c138..13ba7f0bed17 100644 --- a/packages/next/client/app-index.tsx +++ b/packages/next/client/app-index.tsx @@ -159,7 +159,7 @@ function useInitialServerResponse(cacheKey: string) { return newResponse } -const ServerRoot = ({ cacheKey }: { cacheKey: string }) => { +function ServerRoot({ cacheKey }: { cacheKey: string }) { React.useEffect(() => { rscCache.delete(cacheKey) }) @@ -168,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 @@ -183,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 index 395b66030754..842c553d801e 100644 --- a/packages/next/client/app-next-dev.js +++ b/packages/next/client/app-next-dev.js @@ -1,8 +1,4 @@ import { hydrate, version } from './app-index' -import { - connectHMR /*, addMessageListener */, -} from './dev/error-overlay/websocket' -import connect from './app-dev/error-overlay/hot-dev-client' // TODO: implement FOUC guard @@ -13,10 +9,6 @@ window.next = { appDir: true, } -// TODO: implement assetPrefix support -connectHMR({ assetPrefix: '', path: '/_next/webpack-hmr', log: true }) -connect() - hydrate() // TODO: build indicator 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 35ca2ad02c95..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') @@ -161,11 +211,13 @@ export function onDemandEntryHandler({ ) }, pingIntervalTime + 1000).unref() - function handlePing(pg: string, appDirRoute?: true) { - if (appDirRoute) { - const page = normalizePathSep(pg) - // TODO: fix this - const pageKey = `server${page}/page` + 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. @@ -175,22 +227,26 @@ export function onDemandEntryHandler({ } // We don't need to maintain active state of anything other than BUILT entries - if (entryInfo.status !== BUILT) return + 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 } } + return { success: true } + } + + function handlePing(pg: string) { const page = normalizePathSep(pg) const pageKey = `client${page}` const entryInfo = entries[pageKey] @@ -306,11 +362,13 @@ export function onDemandEntryHandler({ ) if (parsedData.event === 'ping') { - const result = handlePing(parsedData.page, parsedData.appDirRoute) + const result = parsedData.appDirRoute + ? handleAppDirPing(parsedData.tree) + : handlePing(parsedData.page) client.send( JSON.stringify({ ...result, - event: 'pong', + [parsedData.appDirRoute ? 'action' : 'event']: 'pong', }) ) } @@ -363,7 +421,7 @@ function disposeInactiveEntries( }) } -// 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..e3a418a11443 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) @@ -28,7 +32,7 @@ export const ShadowPortal: React.FC = function Portal({ return shadowNode.current ? ( createPortal(children, shadowNode.current as any) - ) : ( + ) : globalOverlay ? null : ( ) }