diff --git a/packages/next/src/server/dev/hot-middleware.ts b/packages/next/src/server/dev/hot-middleware.ts index b3af17caa18232..596e7616dc8703 100644 --- a/packages/next/src/server/dev/hot-middleware.ts +++ b/packages/next/src/server/dev/hot-middleware.ts @@ -21,12 +21,14 @@ // 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. -import type { webpack } from 'next/dist/compiled/webpack/webpack' +import { type webpack, StringXor } from 'next/dist/compiled/webpack/webpack' import type ws from 'next/dist/compiled/ws' -import { isMiddlewareFilename } from '../../build/utils' +import { difference, isMiddlewareFilename } from '../../build/utils' import type { VersionInfo } from './parse-version-info' import type { HMR_ACTION_TYPES } from './hot-reloader-types' import { HMR_ACTIONS_SENT_TO_BROWSER } from './hot-reloader-types' +import { WEBPACK_LAYERS } from '../../lib/constants' +import { denormalizePagePath } from '../../shared/lib/page-path/denormalize-page-path' function isMiddlewareStats(stats: webpack.Stats) { for (const key of stats.compilation.entrypoints.keys()) { @@ -107,12 +109,18 @@ export class WebpackHotMiddleware { closed: boolean versionInfo: VersionInfo devtoolsFrontendUrl: string | undefined + pendingStats = [] + reloadAfterInvalidation?: boolean + resetFetch: () => void constructor( - compilers: webpack.Compiler[], + multiCompiler: webpack.MultiCompiler, versionInfo: VersionInfo, - devtoolsFrontendUrl: string | undefined + devtoolsFrontendUrl: string | undefined, + pageExtensions: string[], + resetFetch: () => void ) { + const compilers = multiCompiler.compilers this.eventStream = new EventStream() this.clientLatestStats = null this.middlewareLatestStats = null @@ -120,6 +128,8 @@ export class WebpackHotMiddleware { this.closed = false this.versionInfo = versionInfo this.devtoolsFrontendUrl = devtoolsFrontendUrl + this.pendingStats = [] + this.resetFetch = resetFetch compilers[0].hooks.invalid.tap( 'webpack-hot-middleware', @@ -136,9 +146,235 @@ export class WebpackHotMiddleware { 'webpack-hot-middleware', this.onEdgeServerInvalid ) + // Watch for changes to client/server page files so we can tell when just + // the server file changes and trigger a reload for GS(S)P pages + const changedClientPages = new Set() + const changedServerPages = new Set() + const changedEdgeServerPages = new Set() + + const changedServerComponentPages = new Set() + const changedCSSImportPages = new Set() + + const prevClientPageHashes = new Map() + const prevServerPageHashes = new Map() + const prevEdgeServerPageHashes = new Map() + const prevCSSImportModuleHashes = new Map() + + const pageExtensionRegex = new RegExp(`\\.(?:${pageExtensions.join('|')})$`) + + const trackPageChanges = + ( + pageHashMap: Map, + changedItems: Set, + serverComponentChangedItems?: Set + ) => + (stats: webpack.Compilation) => { + try { + stats.entrypoints.forEach((entry, key) => { + if ( + key.startsWith('pages/') || + key.startsWith('app/') || + isMiddlewareFilename(key) + ) { + // TODO this doesn't handle on demand loaded chunks + entry.chunks.forEach((chunk) => { + if (chunk.id === key) { + const modsIterable: any = + stats.chunkGraph.getChunkModulesIterable(chunk) + + let hasCSSModuleChanges = false + let chunksHash = new StringXor() + let chunksHashServerLayer = new StringXor() + + modsIterable.forEach((mod: any) => { + if ( + mod.resource && + mod.resource.replace(/\\/g, '/').includes(key) && + // Shouldn't match CSS modules, etc. + pageExtensionRegex.test(mod.resource) + ) { + // use original source to calculate hash since mod.hash + // includes the source map in development which changes + // every time for both server and client so we calculate + // the hash without the source map for the page module + const hash = require('crypto') + .createHash('sha1') + .update(mod.originalSource().buffer()) + .digest() + .toString('hex') + + if ( + mod.layer === WEBPACK_LAYERS.reactServerComponents && + mod?.buildInfo?.rsc?.type !== 'client' + ) { + chunksHashServerLayer.add(hash) + } + + chunksHash.add(hash) + } else { + // for non-pages we can use the module hash directly + const hash = stats.chunkGraph.getModuleHash( + mod, + chunk.runtime + ) + + if ( + mod.layer === WEBPACK_LAYERS.reactServerComponents && + mod?.buildInfo?.rsc?.type !== 'client' + ) { + chunksHashServerLayer.add(hash) + } + + chunksHash.add(hash) + + // Both CSS import changes from server and client + // components are tracked. + if ( + key.startsWith('app/') && + /\.(css|scss|sass)$/.test(mod.resource || '') + ) { + const resourceKey = mod.layer + ':' + mod.resource + const prevHash = + prevCSSImportModuleHashes.get(resourceKey) + if (prevHash && prevHash !== hash) { + hasCSSModuleChanges = true + } + prevCSSImportModuleHashes.set(resourceKey, hash) + } + } + }) + + const prevHash = pageHashMap.get(key) + const curHash = chunksHash.toString() + if (prevHash && prevHash !== curHash) { + changedItems.add(key) + } + pageHashMap.set(key, curHash) + + if (serverComponentChangedItems) { + const serverKey = + WEBPACK_LAYERS.reactServerComponents + ':' + key + const prevServerHash = pageHashMap.get(serverKey) + const curServerHash = chunksHashServerLayer.toString() + if (prevServerHash && prevServerHash !== curServerHash) { + serverComponentChangedItems.add(key) + } + pageHashMap.set(serverKey, curServerHash) + } + + if (hasCSSModuleChanges) { + changedCSSImportPages.add(key) + } + } + }) + } + }) + } catch (err) { + console.error(err) + } + } + + compilers[0].hooks.emit.tap( + 'NextjsHotReloaderForClient', + trackPageChanges(prevClientPageHashes, changedClientPages) + ) + compilers[1].hooks.emit.tap( + 'NextjsHotReloaderForServer', + trackPageChanges( + prevServerPageHashes, + changedServerPages, + changedServerComponentPages + ) + ) + compilers[2].hooks.emit.tap( + 'NextjsHotReloaderForServer', + trackPageChanges( + prevEdgeServerPageHashes, + changedEdgeServerPages, + changedServerComponentPages + ) + ) + let lastSentClientStats = 0 + let lastSentServerStats = 0 + + multiCompiler.hooks.done.tap('NextjsHotReloaderForServer', () => { + const reloadAfterInvalidation = this.reloadAfterInvalidation + this.reloadAfterInvalidation = false + + if ( + this.clientLatestStats && + this.clientLatestStats.ts > lastSentClientStats + ) { + lastSentClientStats = Date.now() + this.publishStats(this.clientLatestStats.stats) + } + if ( + this.serverLatestStats && + this.serverLatestStats.ts > lastSentServerStats + ) { + lastSentServerStats = Date.now() + this.publishStats(this.serverLatestStats.stats) + } + const serverOnlyChanges = difference( + changedServerPages, + changedClientPages + ) + + const edgeServerOnlyChanges = difference( + changedEdgeServerPages, + changedClientPages + ) + + const pageChanges = serverOnlyChanges + .concat(edgeServerOnlyChanges) + .filter((key) => key.startsWith('pages/')) + const middlewareChanges = Array.from(changedEdgeServerPages).filter( + (name) => isMiddlewareFilename(name) + ) + + if (middlewareChanges.length > 0) { + this.publish({ + event: HMR_ACTIONS_SENT_TO_BROWSER.MIDDLEWARE_CHANGES, + }) + } + + if (pageChanges.length > 0) { + this.publish({ + event: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ONLY_CHANGES, + pages: serverOnlyChanges.map((pg) => + denormalizePagePath(pg.slice('pages'.length)) + ), + }) + } + + if ( + changedServerComponentPages.size || + changedCSSImportPages.size || + reloadAfterInvalidation + ) { + this.resetFetch() + + // This event must come after the stats have been sent above + // or else the client might try to trigger the HMR RSC request + // before we send built event clearing any errors that are still + // valid from the HMR RSC request + this.publish({ + action: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES, + // TODO: granular reloading of changes + // entrypoints: serverComponentChanges, + }) + } + + changedClientPages.clear() + changedServerPages.clear() + changedEdgeServerPages.clear() + changedServerComponentPages.clear() + changedCSSImportPages.clear() + }) } onClientInvalid = () => { + this.clientLatestStats = null if (this.closed || this.serverLatestStats?.stats.hasErrors()) return this.publish({ action: HMR_ACTIONS_SENT_TO_BROWSER.BUILDING, @@ -148,7 +384,6 @@ export class WebpackHotMiddleware { onClientDone = (statsResult: webpack.Stats) => { this.clientLatestStats = { ts: Date.now(), stats: statsResult } if (this.closed || this.serverLatestStats?.stats.hasErrors()) return - this.publishStats(statsResult) } onServerInvalid = () => { @@ -163,7 +398,6 @@ export class WebpackHotMiddleware { if (this.closed) return if (statsResult.hasErrors()) { this.serverLatestStats = { ts: Date.now(), stats: statsResult } - this.publishStats(statsResult) } } diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index a7e9e3d29d67c5..878deb4e31a02b 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -6,7 +6,7 @@ import type { IncomingMessage, ServerResponse } from 'http' import type { UrlObject } from 'url' import type { RouteDefinition } from '../route-definitions/route-definition' -import { webpack, StringXor } from 'next/dist/compiled/webpack/webpack' +import { webpack } from 'next/dist/compiled/webpack/webpack' import { getOverlayMiddleware, getSourceMapMiddleware, @@ -57,7 +57,6 @@ import { difference, isInstrumentationHookFile, isMiddlewareFile, - isMiddlewareFilename, } from '../../build/utils' import { DecodeError } from '../../shared/lib/utils' import { type Span, trace } from '../../trace' @@ -263,7 +262,6 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { installed: '0.0.0', } private devtoolsFrontendUrl: string | undefined - private reloadAfterInvalidation: boolean = false public serverStats: webpack.Stats | null public edgeServerStats: webpack.Stats | null @@ -1139,157 +1137,6 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.multiCompiler.compilers[2] ) - // Watch for changes to client/server page files so we can tell when just - // the server file changes and trigger a reload for GS(S)P pages - const changedClientPages = new Set() - const changedServerPages = new Set() - const changedEdgeServerPages = new Set() - - const changedServerComponentPages = new Set() - const changedCSSImportPages = new Set() - - const prevClientPageHashes = new Map() - const prevServerPageHashes = new Map() - const prevEdgeServerPageHashes = new Map() - const prevCSSImportModuleHashes = new Map() - - const pageExtensionRegex = new RegExp( - `\\.(?:${this.config.pageExtensions.join('|')})$` - ) - - const trackPageChanges = - ( - pageHashMap: Map, - changedItems: Set, - serverComponentChangedItems?: Set - ) => - (stats: webpack.Compilation) => { - try { - stats.entrypoints.forEach((entry, key) => { - if ( - key.startsWith('pages/') || - key.startsWith('app/') || - isMiddlewareFilename(key) - ) { - // TODO this doesn't handle on demand loaded chunks - entry.chunks.forEach((chunk) => { - if (chunk.id === key) { - const modsIterable: any = - stats.chunkGraph.getChunkModulesIterable(chunk) - - let hasCSSModuleChanges = false - let chunksHash = new StringXor() - let chunksHashServerLayer = new StringXor() - - modsIterable.forEach((mod: any) => { - if ( - mod.resource && - mod.resource.replace(/\\/g, '/').includes(key) && - // Shouldn't match CSS modules, etc. - pageExtensionRegex.test(mod.resource) - ) { - // use original source to calculate hash since mod.hash - // includes the source map in development which changes - // every time for both server and client so we calculate - // the hash without the source map for the page module - const hash = require('crypto') - .createHash('sha1') - .update(mod.originalSource().buffer()) - .digest() - .toString('hex') - - if ( - mod.layer === WEBPACK_LAYERS.reactServerComponents && - mod?.buildInfo?.rsc?.type !== 'client' - ) { - chunksHashServerLayer.add(hash) - } - - chunksHash.add(hash) - } else { - // for non-pages we can use the module hash directly - const hash = stats.chunkGraph.getModuleHash( - mod, - chunk.runtime - ) - - if ( - mod.layer === WEBPACK_LAYERS.reactServerComponents && - mod?.buildInfo?.rsc?.type !== 'client' - ) { - chunksHashServerLayer.add(hash) - } - - chunksHash.add(hash) - - // Both CSS import changes from server and client - // components are tracked. - if ( - key.startsWith('app/') && - /\.(css|scss|sass)$/.test(mod.resource || '') - ) { - const resourceKey = mod.layer + ':' + mod.resource - const prevHash = - prevCSSImportModuleHashes.get(resourceKey) - if (prevHash && prevHash !== hash) { - hasCSSModuleChanges = true - } - prevCSSImportModuleHashes.set(resourceKey, hash) - } - } - }) - - const prevHash = pageHashMap.get(key) - const curHash = chunksHash.toString() - if (prevHash && prevHash !== curHash) { - changedItems.add(key) - } - pageHashMap.set(key, curHash) - - if (serverComponentChangedItems) { - const serverKey = - WEBPACK_LAYERS.reactServerComponents + ':' + key - const prevServerHash = pageHashMap.get(serverKey) - const curServerHash = chunksHashServerLayer.toString() - if (prevServerHash && prevServerHash !== curServerHash) { - serverComponentChangedItems.add(key) - } - pageHashMap.set(serverKey, curServerHash) - } - - if (hasCSSModuleChanges) { - changedCSSImportPages.add(key) - } - } - }) - } - }) - } catch (err) { - console.error(err) - } - } - - this.multiCompiler.compilers[0].hooks.emit.tap( - 'NextjsHotReloaderForClient', - trackPageChanges(prevClientPageHashes, changedClientPages) - ) - this.multiCompiler.compilers[1].hooks.emit.tap( - 'NextjsHotReloaderForServer', - trackPageChanges( - prevServerPageHashes, - changedServerPages, - changedServerComponentPages - ) - ) - this.multiCompiler.compilers[2].hooks.emit.tap( - 'NextjsHotReloaderForServer', - trackPageChanges( - prevEdgeServerPageHashes, - changedEdgeServerPages, - changedServerComponentPages - ) - ) - // This plugin watches for changes to _document.js and notifies the client side that it should reload the page this.multiCompiler.compilers[1].hooks.failed.tap( 'NextjsHotReloaderForServer', @@ -1369,58 +1216,6 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { } ) - this.multiCompiler.hooks.done.tap('NextjsHotReloaderForServer', () => { - const reloadAfterInvalidation = this.reloadAfterInvalidation - this.reloadAfterInvalidation = false - - const serverOnlyChanges = difference( - changedServerPages, - changedClientPages - ) - - const edgeServerOnlyChanges = difference( - changedEdgeServerPages, - changedClientPages - ) - - const pageChanges = serverOnlyChanges - .concat(edgeServerOnlyChanges) - .filter((key) => key.startsWith('pages/')) - const middlewareChanges = Array.from(changedEdgeServerPages).filter( - (name) => isMiddlewareFilename(name) - ) - - if (middlewareChanges.length > 0) { - this.send({ - event: HMR_ACTIONS_SENT_TO_BROWSER.MIDDLEWARE_CHANGES, - }) - } - - if (pageChanges.length > 0) { - this.send({ - event: HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ONLY_CHANGES, - pages: serverOnlyChanges.map((pg) => - denormalizePagePath(pg.slice('pages'.length)) - ), - }) - } - - if ( - changedServerComponentPages.size || - changedCSSImportPages.size || - reloadAfterInvalidation - ) { - this.resetFetch() - this.refreshServerComponents() - } - - changedClientPages.clear() - changedServerPages.clear() - changedEdgeServerPages.clear() - changedServerComponentPages.clear() - changedCSSImportPages.clear() - }) - this.multiCompiler.compilers[0].hooks.failed.tap( 'NextjsHotReloaderForClient', (err: Error) => { @@ -1473,9 +1268,11 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { ) this.webpackHotMiddleware = new WebpackHotMiddleware( - this.multiCompiler.compilers, + this.multiCompiler, this.versionInfo, - this.devtoolsFrontendUrl + this.devtoolsFrontendUrl, + this.config.pageExtensions, + this.resetFetch ) let booted = false @@ -1530,7 +1327,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { } ) { // Cache the `reloadAfterInvalidation` flag, and use it to reload the page when compilation is done - this.reloadAfterInvalidation = reloadAfterInvalidation + if (this.webpackHotMiddleware) { + this.webpackHotMiddleware.reloadAfterInvalidation = + reloadAfterInvalidation + } const outputPath = this.multiCompiler?.outputPath if (outputPath) { getInvalidator(outputPath)?.invalidate()