HMR support + React Refresh (vercel/turbo#252)

This PR implements HMR support with React Refresh built-in.

For now, in order for React Refresh to be enabled, you'll need the `@next/react-refresh-utils` package to be resolveable: `yarn add @next/react-refresh-utils` in your app folder.

* Depends on vercel/turbo#266 
* Integrated both HMR-and-React-Refresh-specific logic directly into the ES chunks' runtime. Webpack has more complex setup here, but for now this makes the logic much more easy to follow since everything is in one place. I have yet to implement the "dependencies" signature for `hot.accept`/`hot.dispose`, since React Refresh does not depend on them. We'll have to see if they're even used in the wild or if we should deprecate them.
* Only implemented the [module API](https://webpack.js.org/api/hot-module-replacement/#module-api), not the [management API](https://webpack.js.org/api/hot-module-replacement/#management-api). We apply all updates as soon as we receive them.
* Added support for "runtime entries" to ES chunks. These are assets that will be executed *before* the main entry of an ES chunk. They'll be useful for polyfills in the future, but for now they're here to evaluate the react refresh runtime before any module is instantiated.

Next steps for HMR:
* Implement CSS HMR
* Implement (or decide to deprecate) the [dependencies form](https://webpack.js.org/api/hot-module-replacement/#accept) of `hot.accept`/`hot.dispose`
* Clean up `runtime.js` some more: switch to TypeScript, split into multiple files, etc. It'd be nice if all of this could be done at compile time, but how to achieve this is unclear at the moment. _Can we run turbopack to compile turbopack?_
This commit is contained in:
Alex Kirszenberg 2022-08-26 18:30:04 +02:00 committed by GitHub
parent 32d10a037a
commit 8f54f35e90
7 changed files with 156 additions and 18 deletions

View file

@ -1,6 +1,7 @@
#![feature(min_specialization)] #![feature(min_specialization)]
pub mod next_client; pub mod next_client;
pub mod react_refresh;
mod server_render; mod server_render;
mod server_rendered_source; mod server_rendered_source;
mod web_entry_source; mod web_entry_source;

View file

@ -0,0 +1,108 @@
use anyhow::{anyhow, Result};
use turbo_tasks::primitives::{BoolVc, StringVc};
use turbo_tasks_fs::FileSystemPathVc;
use turbopack::ecmascript::{
chunk::EcmascriptChunkPlaceableVc,
resolve::{apply_cjs_specific_options, cjs_resolve},
};
use turbopack_core::{
context::AssetContextVc,
environment::EnvironmentVc,
issue::{Issue, IssueSeverity, IssueSeverityVc, IssueVc},
resolve::{parse::RequestVc, ResolveResult},
};
#[turbo_tasks::function]
fn react_refresh_request() -> RequestVc {
RequestVc::parse_string("@next/react-refresh-utils/dist/runtime".to_string())
}
/// Checks whether we can resolve the React Refresh runtime module from the
/// given path. Emits an issue if we can't.
///
/// Differs from `resolve_react_refresh` in that we don't have access to an
/// [AssetContextVc] when we first want to check for RR.
#[turbo_tasks::function]
pub async fn assert_can_resolve_react_refresh(
path: FileSystemPathVc,
environment: EnvironmentVc,
) -> Result<BoolVc> {
let resolve_options = apply_cjs_specific_options(turbopack::resolve_options(path, environment));
let result = turbopack_core::resolve::resolve(path, react_refresh_request(), resolve_options);
Ok(match &*result.await? {
ResolveResult::Single(_, _) => BoolVc::cell(true),
_ => {
ReactRefreshResolvingIssue {
path,
description: StringVc::cell(
"could not resolve the `@next/react-refresh-utils/dist/runtime` module"
.to_string(),
),
}
.cell()
.as_issue()
.emit();
BoolVc::cell(false)
}
})
}
/// Resolves the React Refresh runtime module from the given [AssetContextVc].
#[turbo_tasks::function]
pub async fn resolve_react_refresh(context: AssetContextVc) -> Result<EcmascriptChunkPlaceableVc> {
match &*cjs_resolve(react_refresh_request(), context).await? {
ResolveResult::Single(asset, _) => {
if let Some(placeable) = EcmascriptChunkPlaceableVc::resolve_from(asset).await? {
Ok(placeable)
} else {
Err(anyhow!("React Refresh runtime asset is not placeable"))
}
}
// The react-refresh-runtime module is not installed.
ResolveResult::Unresolveable(_) => Err(anyhow!(
"could not resolve the `@next/react-refresh-utils/dist/runtime` module"
)),
_ => Err(anyhow!("invalid React Refresh runtime asset")),
}
}
/// An issue that occurred while resolving the React Refresh runtime module.
#[turbo_tasks::value(shared)]
pub struct ReactRefreshResolvingIssue {
path: FileSystemPathVc,
description: StringVc,
}
#[turbo_tasks::value_impl]
impl Issue for ReactRefreshResolvingIssue {
#[turbo_tasks::function]
fn severity(&self) -> IssueSeverityVc {
IssueSeverity::Warning.into()
}
#[turbo_tasks::function]
async fn title(&self) -> Result<StringVc> {
Ok(StringVc::cell(
"An issue occurred while resolving the React Refresh runtime. React Refresh will be \
disabled.\nTo enable React Refresh, install the `react-refresh` and \
`@next/react-refresh-utils` modules."
.to_string(),
))
}
#[turbo_tasks::function]
fn category(&self) -> StringVc {
StringVc::cell("other".to_string())
}
#[turbo_tasks::function]
fn context(&self) -> FileSystemPathVc {
self.path
}
#[turbo_tasks::function]
fn description(&self) -> StringVc {
self.description
}
}

View file

@ -168,10 +168,10 @@ async fn get_intermediate_asset(
WrapperAssetVc::new(entry_asset, "server-renderer.js", get_server_renderer()).into(), WrapperAssetVc::new(entry_asset, "server-renderer.js", get_server_renderer()).into(),
context.with_context_path(entry_asset.path()), context.with_context_path(entry_asset.path()),
Value::new(ModuleAssetType::Ecmascript), Value::new(ModuleAssetType::Ecmascript),
EcmascriptInputTransformsVc::cell(vec![EcmascriptInputTransform::JSX]), EcmascriptInputTransformsVc::cell(vec![EcmascriptInputTransform::React { refresh: false }]),
context.environment(), context.environment(),
); );
let chunk = module.as_evaluated_chunk(chunking_context.into()); let chunk = module.as_evaluated_chunk(chunking_context.into(), None);
let chunk_group = ChunkGroupVc::from_chunk(chunk); let chunk_group = ChunkGroupVc::from_chunk(chunk);
Ok(NodeJsBootstrapAsset { Ok(NodeJsBootstrapAsset {
path: intermediate_output_path.join("index.js"), path: intermediate_output_path.join("index.js"),

View file

@ -84,6 +84,7 @@ pub async fn create_server_rendered_source(
)), )),
Value::new(EnvironmentIntention::Client), Value::new(EnvironmentIntention::Client),
), ),
Default::default(),
) )
.into(); .into();

View file

@ -3,7 +3,11 @@ use std::{collections::HashMap, future::IntoFuture};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use turbo_tasks::{util::try_join_all, Value}; use turbo_tasks::{util::try_join_all, Value};
use turbo_tasks_fs::{FileSystemPathVc, FileSystemVc}; use turbo_tasks_fs::{FileSystemPathVc, FileSystemVc};
use turbopack::{ecmascript::EcmascriptModuleAssetVc, ModuleAssetContextVc}; use turbopack::{
ecmascript::{chunk::EcmascriptChunkPlaceablesVc, EcmascriptModuleAssetVc},
module_options::ModuleOptionsContext,
ModuleAssetContextVc,
};
use turbopack_core::{ use turbopack_core::{
chunk::{ chunk::{
dev::{DevChunkingContext, DevChunkingContextVc}, dev::{DevChunkingContext, DevChunkingContextVc},
@ -19,6 +23,8 @@ use turbopack_dev_server::{
source::{asset_graph::AssetGraphContentSourceVc, ContentSourceVc}, source::{asset_graph::AssetGraphContentSourceVc, ContentSourceVc},
}; };
use crate::react_refresh::{assert_can_resolve_react_refresh, resolve_react_refresh};
#[turbo_tasks::function] #[turbo_tasks::function]
pub async fn create_web_entry_source( pub async fn create_web_entry_source(
root: FileSystemPathVc, root: FileSystemPathVc,
@ -26,21 +32,32 @@ pub async fn create_web_entry_source(
dev_server_fs: FileSystemVc, dev_server_fs: FileSystemVc,
eager_compile: bool, eager_compile: bool,
) -> Result<ContentSourceVc> { ) -> Result<ContentSourceVc> {
let environment = EnvironmentVc::new(
Value::new(ExecutionEnvironment::Browser(
BrowserEnvironment {
dom: true,
web_worker: false,
service_worker: false,
browser_version: 0,
}
.into(),
)),
Value::new(EnvironmentIntention::Client),
);
let can_resolve_react_refresh = *assert_can_resolve_react_refresh(root, environment).await?;
let context: AssetContextVc = ModuleAssetContextVc::new( let context: AssetContextVc = ModuleAssetContextVc::new(
TransitionsByNameVc::cell(HashMap::new()), TransitionsByNameVc::cell(HashMap::new()),
root, root,
EnvironmentVc::new( environment,
Value::new(ExecutionEnvironment::Browser( ModuleOptionsContext {
BrowserEnvironment { // We don't need to resolve React Refresh for each module. Instead,
dom: true, // we try resolve it once at the root and pass down a context to all
web_worker: false, // the modules.
service_worker: false, enable_react_refresh: can_resolve_react_refresh,
browser_version: 0, }
} .into(),
.into(),
)),
Value::new(EnvironmentIntention::Client),
),
) )
.into(); .into();
@ -51,6 +68,14 @@ pub async fn create_web_entry_source(
} }
.into(); .into();
let runtime_entries = if can_resolve_react_refresh {
Some(EcmascriptChunkPlaceablesVc::cell(vec![
resolve_react_refresh(context),
]))
} else {
None
};
let modules = try_join_all(entry_requests.into_iter().map(|r| { let modules = try_join_all(entry_requests.into_iter().map(|r| {
context context
.resolve_asset(context.context_path(), r, context.resolve_options()) .resolve_asset(context.context_path(), r, context.resolve_options())
@ -63,7 +88,7 @@ pub async fn create_web_entry_source(
.flat_map(|assets| assets.iter().copied().collect::<Vec<_>>()); .flat_map(|assets| assets.iter().copied().collect::<Vec<_>>());
let chunks = try_join_all(modules.map(|module| async move { let chunks = try_join_all(modules.map(|module| async move {
if let Some(ecmascript) = EcmascriptModuleAssetVc::resolve_from(module).await? { if let Some(ecmascript) = EcmascriptModuleAssetVc::resolve_from(module).await? {
Ok(ecmascript.as_evaluated_chunk(chunking_context.into())) Ok(ecmascript.as_evaluated_chunk(chunking_context.into(), runtime_entries))
} else if let Some(chunkable) = ChunkableAssetVc::resolve_from(module).await? { } else if let Some(chunkable) = ChunkableAssetVc::resolve_from(module).await? {
Ok(chunkable.as_chunk(chunking_context.into())) Ok(chunkable.as_chunk(chunking_context.into()))
} else { } else {

View file

@ -50,7 +50,7 @@ impl NextDevServerBuilder {
eager_compile: false, eager_compile: false,
hostname: None, hostname: None,
port: None, port: None,
log_level: IssueSeverity::Error, log_level: IssueSeverity::Warning,
show_all: false, show_all: false,
log_detail: false, log_detail: false,
} }

View file

@ -102,7 +102,10 @@ async fn main() -> Result<()> {
.port(args.port) .port(args.port)
.log_detail(args.log_detail) .log_detail(args.log_detail)
.show_all(args.show_all) .show_all(args.show_all)
.log_level(args.log_level.map_or_else(|| IssueSeverity::Error, |l| l.0)) .log_level(
args.log_level
.map_or_else(|| IssueSeverity::Warning, |l| l.0),
)
.build() .build()
.await?; .await?;