Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix fast refresh when moving from server component to client component #41684

6 changes: 5 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["debug-react-exp", "dev", "test/e2e/app-dir/app"],
"runtimeArgs": [
"debug-react-exp",
"dev",
"test/development/acceptance-app/fixtures/default-template"
],
"skipFiles": ["<node_internals>/**"],
"env": {
"NEXT_PRIVATE_LOCAL_WEBPACK": "1"
Expand Down
234 changes: 143 additions & 91 deletions packages/next/server/dev/hot-reloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,106 +737,159 @@ export default class HotReloader {

// 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<string>()
const changedServerPages = new Set<string>()
const changedEdgeServerPages = new Set<string>()
const changedCSSImportPages = new Set<string>()

const prevClientPageHashes = new Map<string, string>()
const prevServerPageHashes = new Map<string, string>()
const prevEdgeServerPageHashes = new Map<string, string>()
const changedClientEntries = new Set<string>()
const changedServerComponentClientEntries = new Set<string>()
const changedServerPagesEntries = new Set<string>()
const changedServerComponentEntries = new Set<string>()
const changedEdgeServerEntries = new Set<string>()
const changedEdgeServerComponentsEntries = new Set<string>()
const changedCSSImportEntries = new Set<string>()

const prevClientEntryHashes = new Map<string, string>()
const prevServerComponentClientEntryHashes = new Map<string, string>()
const prevServerPagesEntryHashes = new Map<string, string>()
const prevServerComponentEntryHashes = new Map<string, string>()
const prevEdgeServerEntryHashes = new Map<string, string>()
const prevEdgeServerComponentsEntryHashes = new Map<string, string>()
const prevCSSImportModuleHashes = new Map<string, string>()

const trackPageChanges =
(pageHashMap: Map<string, string>, changedItems: Set<string>) =>
(
entryHashMap: Map<string, string>,
changedEntries: Set<string>,
entryType: 'pages' | 'app'
) =>
(stats: webpack.Compilation) => {
try {
stats.entrypoints.forEach((entry, key) => {
for (const [key, entry] of stats.entrypoints) {
if (
key.startsWith('pages/') ||
key.startsWith('app/') ||
isMiddlewareFilename(key)
entryType === 'pages' &&
!key.startsWith('pages/') &&
!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()

modsIterable.forEach((mod: any) => {
if (
mod.resource &&
mod.resource.replace(/\\/g, '/').includes(key)
) {
// 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('sha256')
.update(mod.originalSource().buffer())
.digest()
.toString('hex')

chunksHash.add(hash)
} else {
// for non-pages we can use the module hash directly
const hash = stats.chunkGraph.getModuleHash(
mod,
chunk.runtime
)
chunksHash.add(hash)

// Both CSS import changes from server and client
// components are tracked.
if (
key.startsWith('app/') &&
mod.resource?.endsWith('.css')
) {
const prevHash = prevCSSImportModuleHashes.get(
mod.resource
)
if (prevHash && prevHash !== hash) {
hasCSSModuleChanges = true
}
prevCSSImportModuleHashes.set(mod.resource, hash)
}
}
})
const prevHash = pageHashMap.get(key)
const curHash = chunksHash.toString()
continue
}

if (
entryType === 'app' &&
!key.startsWith('app/') &&
!isMiddlewareFilename(key)
) {
continue
}

// TODO this doesn't handle on demand loaded chunks
const chunk = entry.chunks.find((i) => i.id)
if (!chunk) {
continue
}

const modsIterable: any =
stats.chunkGraph.getChunkModulesIterable(chunk)

let hasCSSModuleChanges = false
let chunksHash = new StringXor()

for (const mod of modsIterable) {
if (
mod.resource &&
mod.resource.replace(/\\/g, '/').includes(key)
) {
// 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('sha256')
.update(mod.originalSource().buffer())
.digest()
.toString('hex')

chunksHash.add(hash)
continue
}

// for non-pages we can use the module hash directly
const hash = stats.chunkGraph.getModuleHash(mod, chunk.runtime)
chunksHash.add(hash)

if (prevHash && prevHash !== curHash) {
changedItems.add(key)
}
pageHashMap.set(key, curHash)
// CSS tracking is only used for app entries
if (!key.startsWith('app/')) {
continue
}

if (hasCSSModuleChanges) {
changedCSSImportPages.add(key)
}
// Both CSS import changes from server and client
// components are tracked.
if (mod.resource?.endsWith('.css')) {
const prevHash = prevCSSImportModuleHashes.get(mod.resource)
if (prevHash && prevHash !== hash) {
hasCSSModuleChanges = true
}
})
prevCSSImportModuleHashes.set(mod.resource, hash)
}
}
})

const previousHash = entryHashMap.get(key)
const currentHash = chunksHash.toString()

if (previousHash && previousHash !== currentHash) {
changedEntries.add(key)
}
entryHashMap.set(key, currentHash)

if (hasCSSModuleChanges) {
changedCSSImportEntries.add(key)
}
}
} catch (err) {
console.error(err)
}
}

this.multiCompiler.compilers[0].hooks.emit.tap(
'NextjsHotReloaderForClient',
trackPageChanges(prevClientPageHashes, changedClientPages)
trackPageChanges(prevClientEntryHashes, changedClientEntries, 'pages')
)
this.multiCompiler.compilers[0].hooks.emit.tap(
'NextjsHotReloaderForServerComponentClientEntry',
trackPageChanges(
prevServerComponentClientEntryHashes,
changedServerComponentClientEntries,
'app'
)
)
this.multiCompiler.compilers[1].hooks.emit.tap(
'NextjsHotReloaderForServer',
trackPageChanges(
prevServerPagesEntryHashes,
changedServerPagesEntries,
'pages'
)
)
this.multiCompiler.compilers[1].hooks.emit.tap(
'NextjsHotReloaderForServerComponentEntries',
trackPageChanges(
prevServerComponentEntryHashes,
changedServerComponentEntries,
'app'
)
)
this.multiCompiler.compilers[2].hooks.emit.tap(
'NextjsHotReloaderForServer',
trackPageChanges(prevServerPageHashes, changedServerPages)
trackPageChanges(
prevEdgeServerEntryHashes,
changedEdgeServerEntries,
'pages'
)
)

this.multiCompiler.compilers[2].hooks.emit.tap(
'NextjsHotReloaderForServer',
trackPageChanges(prevEdgeServerPageHashes, changedEdgeServerPages)
trackPageChanges(
prevEdgeServerComponentsEntryHashes,
changedEdgeServerComponentsEntries,
'app'
)
)

// This plugin watches for changes to _document.js and notifies the client side that it should reload the page
Expand Down Expand Up @@ -895,28 +948,27 @@ export default class HotReloader {
)
this.multiCompiler.hooks.done.tap('NextjsHotReloaderForServer', () => {
const serverOnlyChanges = difference<string>(
changedServerPages,
changedClientPages
)
const edgeServerOnlyChanges = difference<string>(
changedEdgeServerPages,
changedClientPages
changedServerPagesEntries,
changedClientEntries
)
const serverComponentChanges = serverOnlyChanges
.concat(edgeServerOnlyChanges)
.filter((key) => key.startsWith('app/'))
.concat(Array.from(changedCSSImportPages))

const serverComponentChanges = Array.from(changedServerComponentEntries)
.concat(Array.from(changedEdgeServerComponentsEntries))
.concat(Array.from(changedCSSImportEntries))

const pageChanges = serverOnlyChanges.filter((key) =>
key.startsWith('pages/')
)
const middlewareChanges = Array.from(changedEdgeServerPages).filter(
const middlewareChanges = Array.from(changedEdgeServerEntries).filter(
(name) => isMiddlewareFilename(name)
)

changedClientPages.clear()
changedServerPages.clear()
changedEdgeServerPages.clear()
changedCSSImportPages.clear()
changedClientEntries.clear()
changedServerComponentClientEntries.clear()
changedServerPagesEntries.clear()
changedServerComponentEntries.clear()
changedEdgeServerEntries.clear()
changedCSSImportEntries.clear()

if (middlewareChanges.length > 0) {
this.send({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import path from 'path'

// TODO-APP: Investigate snapshot mismatch
describe('ReactRefreshLogBox app', () => {
if (process.env.NEXT_TEST_REACT_VERSION === '^17') {
it('should skip for react v17', () => {})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,7 @@ describe('ReactRefreshRegression app', () => {
})

// https://github.com/vercel/next.js/issues/13978
// TODO-APP: fix case where server component is moved to a client component
test.skip('can fast refresh a page with dynamic rendering', async () => {
test('can fast refresh a page with dynamic rendering', async () => {
const { session, cleanup } = await sandbox(next)

await session.patch(
Expand Down Expand Up @@ -221,8 +220,7 @@ describe('ReactRefreshRegression app', () => {
})

// https://github.com/vercel/next.js/issues/13978
// TODO-APP: fix case where server component is moved to a client component
test.skip('can fast refresh a page with config', async () => {
test('can fast refresh a page with config', async () => {
const { session, cleanup } = await sandbox(next)

await session.patch(
Expand Down