fix turbopack HMR, fix disconnect detection (#55361)

### What?

Fix reload in turbopack on every change
Fix loosing of events due to debouncing
Fix writing of CSS files for CSS HMR

### Why?

Since we removed the pong event, the websocket impl would cause reconnects every 5 seconds loosing HMR events...

### How?


Closes WEB-1555
This commit is contained in:
Tobias Koppers 2023-09-14 11:03:52 +02:00 committed by GitHub
parent 423d66b086
commit 1ffc40ac6d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 151 additions and 75 deletions

View file

@ -597,6 +597,18 @@ impl Project {
.get(self.client_relative_path().join(identifier)))
}
#[turbo_tasks::function]
async fn hmr_content_and_write(
self: Vc<Self>,
identifier: String,
) -> Result<Vc<Box<dyn VersionedContent>>> {
Ok(self.await?.versioned_content_map.get_and_write(
self.client_relative_path().join(identifier),
self.client_relative_path(),
self.node_root(),
))
}
#[turbo_tasks::function]
async fn hmr_version(self: Vc<Self>, identifier: String) -> Result<Vc<Box<dyn Version>>> {
let content = self.hmr_content(identifier);
@ -633,7 +645,7 @@ impl Project {
from: Vc<VersionState>,
) -> Result<Vc<Update>> {
let from = from.get();
Ok(self.hmr_content(identifier).update(from))
Ok(self.hmr_content_and_write(identifier).update(from))
}
/// Gets a list of all HMR identifiers that can be subscribed to. This is

View file

@ -1,6 +1,7 @@
use std::collections::HashMap;
use anyhow::{bail, Result};
use next_core::emit_client_assets;
use turbo_tasks::{State, TryFlatJoinIterExt, TryJoinIterExt, ValueDefault, ValueToString, Vc};
use turbopack_binding::{
turbo::tasks_fs::FileSystemPath,
@ -53,7 +54,7 @@ impl VersionedContentMap {
let assets = assets_operation.await?;
let entries: Vec<_> = assets
.iter()
.map(|asset| async move {
.map(|&asset| async move {
// NOTE(alexkirsz) `.versioned_content()` should not be resolved, to ensure that
// it always points to the task that computes the versioned
// content.
@ -73,21 +74,20 @@ impl VersionedContentMap {
#[turbo_tasks::function]
pub async fn get(&self, path: Vc<FileSystemPath>) -> Result<Vc<Box<dyn VersionedContent>>> {
let result = {
// NOTE(alexkirsz) This is to avoid Rust marking this method as !Send because a
// StateRef to the map is captured across an await boundary below, even though
// it does not look like it would.
// I think this is a similar issue as https://fasterthanli.me/articles/a-rust-match-made-in-hell
let map = self.map.get();
map.get(&path).copied()
};
let Some((content, assets_operation)) = result else {
let path = path.to_string().await?;
bail!("could not find versioned content for path {}", path);
};
// NOTE(alexkirsz) This is necessary to mark the task as active again.
Vc::connect(assets_operation);
Vc::connect(content);
let (content, _) = self.get_internal(path).await?;
Ok(content)
}
#[turbo_tasks::function]
pub async fn get_and_write(
&self,
path: Vc<FileSystemPath>,
client_relative_path: Vc<FileSystemPath>,
client_output_path: Vc<FileSystemPath>,
) -> Result<Vc<Box<dyn VersionedContent>>> {
let (content, assets_operation) = self.get_internal(path).await?;
// Make sure all written client assets are up-to-date
emit_client_assets(assets_operation, client_relative_path, client_output_path).await?;
Ok(content)
}
@ -106,3 +106,27 @@ impl VersionedContentMap {
Ok(Vc::cell(keys))
}
}
impl VersionedContentMap {
async fn get_internal(
&self,
path: Vc<FileSystemPath>,
) -> Result<(Vc<Box<dyn VersionedContent>>, Vc<OutputAssets>)> {
let result = {
// NOTE(alexkirsz) This is to avoid Rust marking this method as !Send because a
// StateRef to the map is captured across an await boundary below, even though
// it does not look like it would.
// I think this is a similar issue as https://fasterthanli.me/articles/a-rust-match-made-in-hell
let map = self.map.get();
map.get(&path).copied()
};
let Some((content, assets_operation)) = result else {
let path = path.to_string().await?;
bail!("could not find versioned content for path {}", path);
};
// NOTE(alexkirsz) This is necessary to mark the task as active again.
Vc::connect(assets_operation);
Vc::connect(content);
Ok((content, assets_operation))
}
}

View file

@ -39,7 +39,7 @@ async function loadPageChunk(assetPrefix: string, chunkData: ChunkData) {
}
const { assetPrefix } = await initialize({
webpackHMR: {
devClient: {
// Expected when `process.env.NODE_ENV === 'development'`
onUnrecoverableError() {},
},

View file

@ -1,7 +1,7 @@
use anyhow::Result;
use turbo_tasks::{
graph::{AdjacencyMap, GraphTraversal},
Completion, Completions, TryJoinIterExt, Vc,
Completion, Completions, TryFlatJoinIterExt, TryJoinIterExt, Vc,
};
use turbo_tasks_fs::{rebase, FileSystemPath};
use turbopack_binding::turbopack::core::{
@ -64,7 +64,7 @@ pub async fn emit_assets(
client_relative_path: Vc<FileSystemPath>,
client_output_path: Vc<FileSystemPath>,
) -> Result<Vc<Completion>> {
Ok(Completions::all(
Ok(Vc::<Completions>::cell(
assets
.await?
.iter()
@ -76,7 +76,7 @@ pub async fn emit_assets(
.await?
.is_inside_ref(&*node_root.await?)
{
return Ok(emit(asset));
return Ok(Some(emit(asset)));
} else if asset
.ident()
.path()
@ -85,14 +85,59 @@ pub async fn emit_assets(
{
// Client assets are emitted to the client output path, which is prefixed with
// _next. We need to rebase them to remove that prefix.
return Ok(emit_rebase(asset, client_relative_path, client_output_path));
return Ok(Some(emit_rebase(
asset,
client_relative_path,
client_output_path,
)));
}
Ok(Completion::immutable())
Ok(None)
})
.try_join()
.try_flat_join()
.await?,
))
)
.completed())
}
/// Emits all assets transitively reachable from the given chunks, that are
/// inside the client root.
///
/// Assets inside the given client root are rebased to the given client output
/// path.
#[turbo_tasks::function]
pub async fn emit_client_assets(
assets: Vc<OutputAssets>,
client_relative_path: Vc<FileSystemPath>,
client_output_path: Vc<FileSystemPath>,
) -> Result<Vc<Completion>> {
Ok(Vc::<Completions>::cell(
assets
.await?
.iter()
.copied()
.map(|asset| async move {
if asset
.ident()
.path()
.await?
.is_inside_ref(&*client_relative_path.await?)
{
// Client assets are emitted to the client output path, which is prefixed with
// _next. We need to rebase them to remove that prefix.
return Ok(Some(emit_rebase(
asset,
client_relative_path,
client_output_path,
)));
}
Ok(None)
})
.try_flat_join()
.await?,
)
.completed())
}
#[turbo_tasks::function]

View file

@ -56,7 +56,9 @@ pub use app_segment_config::{
parse_segment_config_from_loader_tree, parse_segment_config_from_source,
};
pub use app_source::create_app_source;
pub use emit::{all_assets_from_entries, all_server_paths, emit_all_assets, emit_assets};
pub use emit::{
all_assets_from_entries, all_server_paths, emit_all_assets, emit_assets, emit_client_assets,
};
pub use next_edge::context::{
get_edge_chunking_context, get_edge_compile_time_info, get_edge_resolve_options_context,
};

View file

@ -64,7 +64,9 @@ window.__nextDevClientId = Math.round(Math.random() * 100 + Date.now())
let hadRuntimeError = false
let customHmrEventHandler: any
export default function connect() {
let MODE: 'webpack' | 'turbopack' = 'webpack'
export default function connect(mode: 'webpack' | 'turbopack') {
MODE = mode
register()
addMessageListener((payload) => {
@ -109,15 +111,17 @@ function clearOutdatedErrors() {
function handleSuccess() {
clearOutdatedErrors()
const isHotUpdate =
!isFirstCompilation ||
(window.__NEXT_DATA__.page !== '/_error' && isUpdateAvailable())
isFirstCompilation = false
hasCompileErrors = false
if (MODE === 'webpack') {
const isHotUpdate =
!isFirstCompilation ||
(window.__NEXT_DATA__.page !== '/_error' && isUpdateAvailable())
isFirstCompilation = false
hasCompileErrors = false
// Attempt to apply hot updates or reload.
if (isHotUpdate) {
tryApplyUpdates(onBeforeFastRefresh, onFastRefresh)
// Attempt to apply hot updates or reload.
if (isHotUpdate) {
tryApplyUpdates(onBeforeFastRefresh, onFastRefresh)
}
}
}

View file

@ -5,7 +5,6 @@ let source: WebSocket
type ActionCallback = (action: HMR_ACTION_TYPES) => void
const eventCallbacks: Array<ActionCallback> = []
let lastActivity = Date.now()
function getSocketProtocol(assetPrefix: string): string {
let protocol = location.protocol
@ -27,26 +26,15 @@ export function sendMessage(data: string) {
return source.send(data)
}
export function connectHMR(options: {
path: string
assetPrefix: string
timeout?: number
}) {
if (!options.timeout) {
options.timeout = 5 * 1000
}
export function connectHMR(options: { path: string; assetPrefix: string }) {
function init() {
if (source) source.close()
function handleOnline() {
window.console.log('[HMR] connected')
lastActivity = Date.now()
}
function handleMessage(event: MessageEvent<string>) {
lastActivity = Date.now()
// Coerce into HMR_ACTION_TYPES as that is the format.
const msg: HMR_ACTION_TYPES = JSON.parse(event.data)
for (const eventCallback of eventCallbacks) {
@ -54,18 +42,12 @@ export function connectHMR(options: {
}
}
let timer: NodeJS.Timeout
function handleDisconnect() {
clearInterval(timer)
source.onerror = null
source.onclose = null
source.close()
setTimeout(init, options.timeout)
init()
}
timer = setInterval(function () {
if (Date.now() - lastActivity > (options.timeout as any)) {
handleDisconnect()
}
}, (options.timeout as any) / 2)
const { hostname, port } = location
const protocol = getSocketProtocol(options.assetPrefix || '')
@ -82,6 +64,7 @@ export function connectHMR(options: {
source = new window.WebSocket(`${url}${options.path}`)
source.onopen = handleOnline
source.onerror = handleDisconnect
source.onclose = handleDisconnect
source.onmessage = handleMessage
}

View file

@ -1,8 +1,8 @@
import connect from './error-overlay/hot-dev-client'
import { sendMessage } from './error-overlay/websocket'
export default () => {
const devClient = connect()
export default (mode: 'webpack' | 'turbopack') => {
const devClient = connect(mode)
devClient.subscribeToHmrEvent((obj: any) => {
// if we're on an error/404 page, we can't reliably tell if the newly added/removed page

View file

@ -89,7 +89,7 @@ let initialMatchesMiddleware = false
let lastAppProps: AppProps
let lastRenderReject: (() => void) | null
let webpackHMR: any
let devClient: any
let CachedApp: AppComponent, onPerfEntry: (metric: any) => void
let CachedComponent: React.ComponentType
@ -185,14 +185,14 @@ class Container extends React.Component<{
}
}
export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{
export async function initialize(opts: { devClient?: any } = {}): Promise<{
assetPrefix: string
}> {
tracer.onSpanEnd(reportToSocket)
// This makes sure this specific lines are removed in production
if (process.env.NODE_ENV === 'development') {
webpackHMR = opts.webpackHMR
devClient = opts.devClient
}
initialData = JSON.parse(
@ -357,7 +357,7 @@ function renderError(renderErrorProps: RenderErrorProps): Promise<any> {
if (process.env.NODE_ENV !== 'production') {
// A Next.js rendering runtime error is always unrecoverable
// FIXME: let's make this recoverable (error in GIP client-transition)
webpackHMR.onUnrecoverableError()
devClient.onUnrecoverableError()
// We need to render an empty <App> so that the `<ReactDevOverlay>` can
// render itself.

View file

@ -1,6 +1,6 @@
// TODO: Remove use of `any` type.
import { initialize, version, router, emitter } from './'
import initWebpackHMR from './dev/webpack-hot-middleware-client'
import initHMR from './dev/hot-middleware-client'
import './setup-hydration-warning'
import { pageBootrap } from './page-bootstrap'
@ -18,15 +18,14 @@ window.next = {
emitter,
}
;(self as any).__next_set_public_path__ = () => {}
;(self as any).__webpack_hash__ = 0
;(self as any).__webpack_hash__ = ''
// for the page loader
declare let __turbopack_load__: any
const webpackHMR = initWebpackHMR()
const devClient = initHMR('turbopack')
initialize({
// TODO the prop name is confusing as related to webpack
webpackHMR,
devClient,
})
.then(({ assetPrefix }) => {
// for the page loader

View file

@ -1,7 +1,7 @@
// TODO: Remove use of `any` type.
import './webpack'
import { initialize, version, router, emitter } from './'
import initWebpackHMR from './dev/webpack-hot-middleware-client'
import initHMR from './dev/hot-middleware-client'
import { pageBootrap } from './page-bootstrap'
import './setup-hydration-warning'
@ -15,8 +15,8 @@ window.next = {
emitter,
}
const webpackHMR = initWebpackHMR()
initialize({ webpackHMR })
const devClient = initHMR('webpack')
initialize({ devClient })
.then(({ assetPrefix }) => {
return pageBootrap(assetPrefix)
})

View file

@ -227,7 +227,7 @@ async function startWatcher(opts: SetupOpts) {
let currentEntriesHandling = new Promise(
(resolve) => (currentEntriesHandlingResolve = resolve)
)
const hmrPayloads = new Map<string, HMR_ACTION_TYPES>()
const hmrPayloads = new Map<string, HMR_ACTION_TYPES[]>()
let hmrBuilding = false
const issues = new Map<string, Map<string, Issue>>()
@ -344,7 +344,6 @@ async function startWatcher(opts: SetupOpts) {
for (const [key, issue] of issueMap) {
if (errors.has(key)) continue
console.log(issue)
const message = formatIssue(issue)
errors.set(key, {
@ -363,8 +362,10 @@ async function startWatcher(opts: SetupOpts) {
hmrBuilding = false
if (errors.size === 0) {
for (const payload of hmrPayloads.values()) {
hotReloader.send(payload)
for (const payloads of hmrPayloads.values()) {
for (const payload of payloads) {
hotReloader.send(payload)
}
}
hmrPayloads.clear()
}
@ -378,7 +379,13 @@ async function startWatcher(opts: SetupOpts) {
hotReloader.send({ action: HMR_ACTIONS_SENT_TO_BROWSER.BUILDING })
hmrBuilding = true
}
hmrPayloads.set(`${key}:${id}`, payload)
let k = `${key}:${id}`
let list = hmrPayloads.get(k)
if (!list) {
list = []
hmrPayloads.set(k, list)
}
list.push(payload)
sendHmrDebounce()
}