rsnext/packages/next/client/components/hot-reloader.client.tsx
Shu Ding d5fa555841
Implement SWC transformer for server and client graphs (#40603)
This is an initial implementation of the Server Components SWC
transformer. For the server graph, it detects client entries via the
`"client"` directive and transpile them into module reference code; for
the client graph, it removes the directives. And for both graphs, it
checks if there is any invalid imports for the given environment and
shows proper errors.

With that added, we can switch from `next-flight-client-loader` to
directly use the SWC loader in one pass. Next step is to get rid of the
`.client.` extension in other plugins.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

## Feature

- [x] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The examples guidelines are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples)
2022-09-17 00:12:59 +02:00

498 lines
14 KiB
TypeScript

'client'
import {
useCallback,
useContext,
useEffect,
useRef,
// @ts-expect-error TODO-APP: startTransition exists
startTransition,
} from 'react'
import { GlobalLayoutRouterContext } from '../../shared/lib/app-router-context'
import {
register,
unregister,
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'
import { useRouter } from './hooks-client'
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-APP: 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)
// }
// }
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()
}
// 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 processMessage(
e: any,
sendMessage: any,
router: ReturnType<typeof useRouter>
) {
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
}
// TODO-APP: make server component change more granular
case 'serverComponentChanges': {
sendMessage(
JSON.stringify({
event: 'server-component-reload-page',
clientId: __nextDevClientId,
})
)
if (hadRuntimeError) {
return window.location.reload()
}
startTransition(() => {
router.reload()
onRefresh()
})
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-APP: 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(GlobalLayoutRouterContext)
const router = useRouter()
const webSocketRef = useRef<WebSocket>()
const sendMessage = useCallback((data) => {
const socket = webSocketRef.current
if (!socket || socket.readyState !== socket.OPEN) return
return socket.send(data)
}, [])
useEffect(() => {
register()
const onError = () => {
hadRuntimeError = true
}
window.addEventListener('error', onError)
window.addEventListener('unhandledrejection', onError)
return () => {
unregister()
window.removeEventListener('error', onError)
window.removeEventListener('unhandledrejection', onError)
}
}, [])
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-APP: check 404 case
const interval = setInterval(() => {
sendMessage(
JSON.stringify({
event: 'ping',
// TODO-APP: 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<PongEvent>) => {
if (
event.data.indexOf('action') === -1 &&
// TODO-APP: clean this up for consistency
event.data.indexOf('pong') === -1
) {
return
}
try {
processMessage(event, sendMessage, router)
} 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, router])
// useEffect(() => {
// const interval = setInterval(function () {
// if (
// lastActivityRef.current &&
// Date.now() - lastActivityRef.current > TIMEOUT
// ) {
// handleDisconnect()
// }
// }, 2500)
// return () => clearInterval(interval)
// })
return null
}