Share Ecmascript HMR chunk versioning, diffing, and merging between browser and node (#96325)

## Summary

`turbopack-nodejs` and `turbopack-browser` each carried their own copy
of the Ecmascript chunk versioning and HMR diffing stack.
`chunk_list/merged_update.rs` already documented the intent to share
this, noting the turbo-tasks value types "cannot be generic and
therefore remain per-runtime". A `value_trait` resolves that: the shared
machinery is written against `EcmascriptHmrChunkContent`, which each
runtime implements in a few lines to expose `entries()` and
`own_version()`.

The shared code now lives in `turbopack-ecmascript/src/hmr/`
(`version.rs`, `update.rs`, `content.rs`, `merger.rs`). Both runtimes'
`ecmascript/version.rs`, `ecmascript/update.rs`, and
`ecmascript/**/merged/` directories are deleted.

No struct fields were widened and no `ChunkingContext` methods changed.

### Behavior changes

- Browser chunk version IDs now hash `minify_type`, matching node. IDs
are opaque and recomputed in-memory on both ends of an HMR connection,
so this cannot desync a client.
- The shared diff uses node's lazy `entries()` materialization, so
chunks with only deletions no longer materialize any `Vc<Code>`. This
now applies to the browser path too.
- Node error text said "chunk path ... is not in client root", a
copy-paste artifact. Now says "output root".
- `#[turbo_tasks(trace_ignore)]` is applied consistently to the merged
chunk version; previously only the browser copy had it.

Browser chunk content keeps the default `VersionedContent::update`.
Overriding it bypasses the `ChunkListUpdate` envelope the client runtime
expects.

### Not included

Unifying the chunk-content structs. That requires widening to `Box<dyn
ChunkingContext>`, and both chunking contexts define `minify_type` twice
— an inherent method and a `ChunkingContext` method whose trait default
is `NoMinify` — so it needs separate verification. The trait added here
lets the two structs coexist at no cost.

The runtime, evaluate, and entry chunk types are also out of scope:
`get_browser_runtime_code` takes eight more parameters than
`get_node_runtime_code`, so those differ behaviorally rather than by
duplication.

<!-- NEXT_JS_LLM -->
This commit is contained in:
Will Binns-Smith
2026-07-29 14:51:33 -07:00
committed by GitHub
parent be1424f3dc
commit 6fc7bf8af1
25 changed files with 606 additions and 1116 deletions
@@ -15,14 +15,15 @@ use turbopack_core::{
};
use turbopack_ecmascript::{
chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries},
hmr::{
EcmascriptHmrChunkContent, merger::EcmascriptChunkContentMerger,
version::EcmascriptChunkVersion,
},
minify::minify,
utils::StringifyJs,
};
use super::{
chunk::EcmascriptBrowserChunk, merged::merger::EcmascriptBrowserChunkContentMerger,
version::EcmascriptBrowserChunkVersion,
};
use super::chunk::EcmascriptBrowserChunk;
use crate::{
BrowserChunkingContext,
chunking_context::{CURRENT_CHUNK_METHOD_DOCUMENT_CURRENT_SCRIPT_EXPR, CurrentChunkMethod},
@@ -54,23 +55,6 @@ impl EcmascriptBrowserChunkContent {
.cell())
}
#[turbo_tasks::function]
pub fn entries(&self) -> Vc<EcmascriptChunkContentEntries> {
EcmascriptChunkContentEntries::new(*self.content)
}
}
#[turbo_tasks::value_impl]
impl EcmascriptBrowserChunkContent {
#[turbo_tasks::function]
pub(crate) async fn own_version(&self) -> Result<Vc<EcmascriptBrowserChunkVersion>> {
Ok(EcmascriptBrowserChunkVersion::new(
self.chunking_context.output_root().owned().await?,
self.chunk.path().owned().await?,
*self.content,
))
}
#[turbo_tasks::function]
pub(crate) async fn code(self: Vc<Self>) -> Result<Vc<Code>> {
let this = self.await?;
@@ -164,7 +148,25 @@ impl VersionedContent for EcmascriptBrowserChunkContent {
#[turbo_tasks::function]
fn version(self: Vc<Self>) -> Vc<Box<dyn Version>> {
Vc::upcast(self.own_version())
Vc::upcast(self.ecmascript_chunk_version())
}
}
#[turbo_tasks::value_impl]
impl EcmascriptHmrChunkContent for EcmascriptBrowserChunkContent {
#[turbo_tasks::function]
fn entries(&self) -> Vc<EcmascriptChunkContentEntries> {
EcmascriptChunkContentEntries::new(*self.content)
}
#[turbo_tasks::function]
async fn ecmascript_chunk_version(&self) -> Result<Vc<EcmascriptChunkVersion>> {
Ok(EcmascriptChunkVersion::new(
self.chunking_context.output_root().owned().await?,
self.chunk.path().owned().await?,
*self.content,
*self.chunking_context.minify_type().await?,
))
}
}
@@ -172,7 +174,7 @@ impl VersionedContent for EcmascriptBrowserChunkContent {
impl MergeableVersionedContent for EcmascriptBrowserChunkContent {
#[turbo_tasks::function]
fn get_merger(&self) -> Vc<Box<dyn VersionedContentMerger>> {
Vc::upcast(EcmascriptBrowserChunkContentMerger::new())
Vc::upcast(EcmascriptChunkContentMerger::new())
}
}
@@ -1,60 +0,0 @@
use anyhow::{Result, bail};
use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::{
asset::AssetContent,
version::{Update, Version, VersionedContent},
};
use super::{
super::content::EcmascriptBrowserChunkContent, update::update_ecmascript_merged_chunk,
version::EcmascriptBrowserMergedChunkVersion,
};
/// Composite [`EcmascriptChunkContent`] that is the result of merging multiple
/// EcmaScript chunk's contents together through the
/// [`EcmascriptChunkContentMerger`].
///
/// [`EcmascriptChunkContentMerger`]: super::merger::EcmascriptChunkContentMerger
#[turbo_tasks::value(serialization = "skip", shared)]
pub(super) struct EcmascriptBrowserMergedChunkContent {
pub contents: Vec<ResolvedVc<EcmascriptBrowserChunkContent>>,
}
#[turbo_tasks::value_impl]
impl EcmascriptBrowserMergedChunkContent {
#[turbo_tasks::function]
pub async fn version(&self) -> Result<Vc<EcmascriptBrowserMergedChunkVersion>> {
Ok(EcmascriptBrowserMergedChunkVersion {
versions: self
.contents
.iter()
.map(|content| async move { content.own_version().await })
.try_join()
.await?,
}
.cell())
}
}
#[turbo_tasks::value_impl]
impl VersionedContent for EcmascriptBrowserMergedChunkContent {
#[turbo_tasks::function]
fn content(self: Vc<Self>) -> Result<Vc<AssetContent>> {
bail!("EcmascriptDevMergedChunkContent does not have content")
}
#[turbo_tasks::function]
fn version(self: Vc<Self>) -> Vc<Box<dyn Version>> {
Vc::upcast(self.version())
}
#[turbo_tasks::function]
async fn update(
self: Vc<Self>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Vc<Update>> {
Ok(update_ecmascript_merged_chunk(self, from_version)
.await?
.cell())
}
}
@@ -1,50 +0,0 @@
use anyhow::{Result, bail};
use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::version::{VersionedContent, VersionedContentMerger, VersionedContents};
use super::{
super::content::EcmascriptBrowserChunkContent, content::EcmascriptBrowserMergedChunkContent,
};
/// Merges multiple [`EcmascriptChunkContent`] into a single
/// [`EcmascriptDevMergedChunkContent`]. This is useful for generating a single
/// update for multiple ES chunks updating all at the same time.
#[turbo_tasks::value]
pub(crate) struct EcmascriptBrowserChunkContentMerger;
#[turbo_tasks::value_impl]
impl EcmascriptBrowserChunkContentMerger {
/// Creates a new [`EcmascriptDevChunkContentMerger`].
#[turbo_tasks::function]
pub fn new() -> Vc<Self> {
Self::cell(EcmascriptBrowserChunkContentMerger)
}
}
#[turbo_tasks::value_impl]
impl VersionedContentMerger for EcmascriptBrowserChunkContentMerger {
#[turbo_tasks::function]
async fn merge(
&self,
contents: Vc<VersionedContents>,
) -> Result<Vc<Box<dyn VersionedContent>>> {
let contents = contents
.await?
.iter()
.map(|content| async move {
if let Some(content) =
ResolvedVc::try_downcast_type::<EcmascriptBrowserChunkContent>(*content)
{
Ok(content)
} else {
bail!("expected Vc<EcmascriptBrowserChunkContent>")
}
})
.try_join()
.await?;
Ok(Vc::upcast(
EcmascriptBrowserMergedChunkContent { contents }.cell(),
))
}
}
@@ -1,4 +0,0 @@
pub(crate) mod content;
pub(crate) mod merger;
pub(crate) mod update;
pub(crate) mod version;
@@ -1,191 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::{
chunk::{ChunkingContext, ModuleId},
output::OutputAsset,
version::{PartialUpdate, TotalUpdate, Update, Version},
};
use turbopack_ecmascript::chunk_list::merged_update::{
EcmascriptMergedChunkAdded, EcmascriptMergedChunkDeleted, EcmascriptMergedChunkPartial,
EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, EcmascriptModuleEntry,
};
use super::{
super::{
update::{EcmascriptChunkUpdate, update_ecmascript_chunk},
version::EcmascriptBrowserChunkVersion,
},
content::EcmascriptBrowserMergedChunkContent,
version::EcmascriptBrowserMergedChunkVersion,
};
/// Helper structure to get a module's hash from multiple different chunk
/// versions, without having to actually merge the versions into a single
/// hashmap, which would be expensive.
struct MergedModuleMap {
versions: Vec<ReadRef<EcmascriptBrowserChunkVersion>>,
}
impl MergedModuleMap {
/// Creates a new `MergedModuleMap` from the given versions.
fn new(versions: Vec<ReadRef<EcmascriptBrowserChunkVersion>>) -> Self {
Self { versions }
}
/// Returns the hash of the module with the given id, or `None` if the
/// module is not present in any of the versions.
fn get(&self, id: &ModuleId) -> Option<u128> {
for version in &self.versions {
if let Some(hash) = version.entries_hashes.get(id) {
return Some(*hash);
}
}
None
}
}
pub(super) async fn update_ecmascript_merged_chunk(
content: Vc<EcmascriptBrowserMergedChunkContent>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Update> {
let to_merged_version = content.version();
let from_merged_version = if let Some(from) =
ResolvedVc::try_downcast_type::<EcmascriptBrowserMergedChunkVersion>(from_version)
{
from
} else {
// It's likely `from_version` is `NotFoundVersion`.
return Ok(Update::Total(TotalUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_merged_version)
.into_trait_ref()
.await?,
}));
};
let to = to_merged_version.await?;
let from = from_merged_version.await?;
// When to and from point to the same value we can skip comparing them. This will happen since
// `TraitRef::<Box<dyn Version>>::cell` will not clone the value, but only make the cell point
// to the same immutable value (`Arc`).
if from.ptr_eq(&to) {
return Ok(Update::None);
}
let mut from_versions_by_chunk_path: FxIndexMap<_, _> = from
.versions
.iter()
.map(|version| (&*version.chunk_path, version))
.collect();
let merged_module_map = MergedModuleMap::new(from.versions.to_vec());
let content = content.await?;
let to_contents = content
.contents
.iter()
.map(|content| async move {
let entries = content.entries().await?;
let content_ref = content.await?;
let output_root = content_ref.chunking_context.output_root().await?;
let path = content_ref.chunk.path().await?;
Ok((*content, entries, output_root, path))
})
.try_join()
.await?;
let mut merged_update = EcmascriptMergedUpdate::default();
for (content, entries, output_root, path) in &to_contents {
let Some(chunk_path) = output_root.get_path_to(path) else {
continue;
};
let chunk_update = if let Some(from_version) =
from_versions_by_chunk_path.swap_remove(chunk_path)
{
// The chunk was present in the previous version, so we must update it.
let update = update_ecmascript_chunk(**content, from_version).await?;
match update {
EcmascriptChunkUpdate::None => {
// Nothing changed, so we can skip this chunk.
continue;
}
EcmascriptChunkUpdate::Partial(chunk_partial) => {
// The chunk was updated.
let mut partial = EcmascriptMergedChunkPartial::default();
for (module_id, (module_hash, module_code)) in chunk_partial.added {
partial.added.insert(module_id.clone());
if merged_module_map.get(&module_id) != Some(module_hash) {
let entry = EcmascriptModuleEntry::from_code(
&module_id,
*module_code,
chunk_path,
)
.await?;
merged_update.entries.insert(module_id, entry);
}
}
partial.deleted.extend(chunk_partial.deleted.into_keys());
for (module_id, module_code) in chunk_partial.modified {
let entry =
EcmascriptModuleEntry::from_code(&module_id, *module_code, chunk_path)
.await?;
merged_update.entries.insert(module_id, entry);
}
EcmascriptMergedChunkUpdate::Partial(partial)
}
}
} else {
// The chunk was added in this version.
let mut added = EcmascriptMergedChunkAdded::default();
for (id, entry) in entries {
let hash = *entry.hash.await?;
added.modules.insert(id.clone());
if merged_module_map.get(id) != Some(hash) {
let entry =
EcmascriptModuleEntry::from_code(id, *entry.code, chunk_path).await?;
merged_update.entries.insert(id.clone(), entry);
}
}
EcmascriptMergedChunkUpdate::Added(added)
};
merged_update.chunks.insert(chunk_path, chunk_update);
}
// Deleted chunks.
for (chunk_path, chunk_version) in from_versions_by_chunk_path {
let hashes = &chunk_version.entries_hashes;
merged_update.chunks.insert(
chunk_path,
EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted {
modules: hashes.keys().cloned().collect(),
}),
);
}
let update = if merged_update.is_empty() {
Update::None
} else {
Update::Partial(PartialUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_merged_version)
.into_trait_ref()
.await?,
instruction: Arc::new(serde_json::to_value(&merged_update)?),
})
};
Ok(update)
}
@@ -1,41 +0,0 @@
use anyhow::Result;
use turbo_rcstr::RcStr;
use turbo_tasks::{ReadRef, TryJoinIterExt, Vc};
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
use turbopack_core::version::Version;
use super::super::version::EcmascriptBrowserChunkVersion;
/// The version of a [`super::content::EcmascriptMergedChunkContent`]. This is
/// essentially a composite [`EcmascriptChunkVersion`].
#[turbo_tasks::value(serialization = "skip", shared)]
pub(super) struct EcmascriptBrowserMergedChunkVersion {
#[turbo_tasks(trace_ignore)]
pub(super) versions: Vec<ReadRef<EcmascriptBrowserChunkVersion>>,
}
#[turbo_tasks::value_impl]
impl Version for EcmascriptBrowserMergedChunkVersion {
#[turbo_tasks::function]
async fn id(&self) -> Result<Vc<RcStr>> {
let mut hasher = Xxh3Hash64Hasher::new();
hasher.write_value(self.versions.len());
let sorted_ids = {
let mut sorted_ids = self
.versions
.iter()
// This ReadRef::cell call is important, as it means that `.id()` is cached.
.map(|version| ReadRef::cell(version.clone()).id())
.try_join()
.await?;
sorted_ids.sort();
sorted_ids
};
for id in sorted_ids {
hasher.write_value(id);
}
let hash = hasher.finish();
let hash = encode_base64(hash);
Ok(Vc::cell(hash.into()))
}
}
@@ -2,9 +2,6 @@ pub(crate) mod chunk;
pub(crate) mod content;
pub(crate) mod evaluate;
pub mod list;
pub(crate) mod merged;
pub(crate) mod update;
pub(crate) mod version;
pub(crate) mod worker;
pub use chunk::EcmascriptBrowserChunk;
@@ -1,65 +0,0 @@
use anyhow::Result;
use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, Vc};
use turbopack_core::{chunk::ModuleId, code_builder::Code};
use super::{content::EcmascriptBrowserChunkContent, version::EcmascriptBrowserChunkVersion};
#[allow(clippy::large_enum_variant)]
pub(super) enum EcmascriptChunkUpdate {
None,
Partial(EcmascriptChunkPartialUpdate),
}
pub(super) struct EcmascriptChunkPartialUpdate {
pub added: FxIndexMap<ModuleId, (u128, ResolvedVc<Code>)>,
pub deleted: FxIndexMap<ModuleId, u128>,
pub modified: FxIndexMap<ModuleId, ResolvedVc<Code>>,
}
pub(super) async fn update_ecmascript_chunk(
content: Vc<EcmascriptBrowserChunkContent>,
from: &ReadRef<EcmascriptBrowserChunkVersion>,
) -> Result<EcmascriptChunkUpdate> {
let to = content.own_version().await?;
// When to and from point to the same value we can skip comparing them. This will happen since
// `TraitRef::<Box<dyn Version>>::cell` will not clone the value, but only make the cell point
// to the same immutable value (`Arc`).
if from.ptr_eq(&to) {
return Ok(EcmascriptChunkUpdate::None);
}
let entries = content.entries().await?;
let mut added = FxIndexMap::default();
let mut modified = FxIndexMap::default();
let mut deleted = FxIndexMap::default();
for (id, from_hash) in &from.entries_hashes {
if let Some(entry) = entries.get(id) {
if *entry.hash.await? != *from_hash {
modified.insert(id.clone(), entry.code);
}
} else {
deleted.insert(id.clone(), *from_hash);
}
}
// Remaining entries are added
for (id, entry) in entries.iter() {
if !from.entries_hashes.contains_key(id) {
added.insert(id.clone(), (*entry.hash.await?, entry.code));
}
}
let update = if added.is_empty() && modified.is_empty() && deleted.is_empty() {
EcmascriptChunkUpdate::None
} else {
EcmascriptChunkUpdate::Partial(EcmascriptChunkPartialUpdate {
added,
modified,
deleted,
})
};
Ok(update)
}
@@ -1,64 +0,0 @@
use anyhow::Result;
use turbo_rcstr::RcStr;
use turbo_tasks::{FxIndexMap, TryJoinIterExt, Vc, turbobail};
use turbo_tasks_fs::FileSystemPath;
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
use turbopack_core::{chunk::ModuleId, version::Version};
use turbopack_ecmascript::chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries};
#[turbo_tasks::value(serialization = "skip")]
pub(super) struct EcmascriptBrowserChunkVersion {
pub(super) chunk_path: String,
pub(super) entries_hashes: FxIndexMap<ModuleId, u128>,
}
#[turbo_tasks::value_impl]
impl EcmascriptBrowserChunkVersion {
#[turbo_tasks::function]
pub async fn new(
output_root: FileSystemPath,
chunk_path: FileSystemPath,
content: Vc<EcmascriptChunkContent>,
) -> Result<Vc<Self>> {
let output_root = output_root.clone();
let chunk_path = chunk_path.clone();
let chunk_path = if let Some(path) = output_root.get_path_to(&chunk_path) {
path
} else {
turbobail!("chunk path {chunk_path} is not in client root {output_root}");
};
let entries_hashes = EcmascriptChunkContentEntries::new(content)
.await?
.iter()
.map(async |(id, entry)| Ok((id.clone(), *entry.hash.await?)))
.try_join()
.await?
.into_iter()
.collect();
Ok(EcmascriptBrowserChunkVersion {
chunk_path: chunk_path.to_string(),
entries_hashes,
}
.cell())
}
}
#[turbo_tasks::value_impl]
impl Version for EcmascriptBrowserChunkVersion {
#[turbo_tasks::function]
fn id(&self) -> Vc<RcStr> {
let mut hasher = Xxh3Hash64Hasher::new();
hasher.write_ref(&self.chunk_path);
let sorted_hashes = {
let mut hashes: Vec<_> = self.entries_hashes.values().copied().collect();
hashes.sort();
hashes
};
for hash in sorted_hashes {
hasher.write_value(hash);
}
let hash = hasher.finish();
let hash = encode_base64(hash);
Vc::cell(hash.into())
}
}
@@ -0,0 +1,63 @@
use anyhow::{Result, bail};
use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::{
asset::AssetContent,
version::{Update, Version, VersionedContent},
};
use crate::hmr::{
EcmascriptHmrChunkContent, update::update_ecmascript_merged_chunk,
version::EcmascriptMergedChunkVersion,
};
/// Composite [`VersionedContent`] that is the result of merging multiple
/// Ecmascript chunk contents together through the
/// [`super::merger::EcmascriptChunkContentMerger`]. This allows a chunk
/// list to produce a single `EcmascriptMergedUpdate` for multiple chunks updating
/// at the same time.
///
/// "Merged" here refers to [`turbopack_core::version::VersionedContentMerger`]
/// combining several chunks into one update payload.
#[turbo_tasks::value(serialization = "skip", shared)]
pub struct EcmascriptMergedChunkContent {
pub contents: Vec<ResolvedVc<Box<dyn EcmascriptHmrChunkContent>>>,
}
#[turbo_tasks::value_impl]
impl EcmascriptMergedChunkContent {
#[turbo_tasks::function]
pub async fn version(&self) -> Result<Vc<EcmascriptMergedChunkVersion>> {
Ok(EcmascriptMergedChunkVersion {
versions: self
.contents
.iter()
.map(|content| async move { content.ecmascript_chunk_version().await })
.try_join()
.await?,
}
.cell())
}
}
#[turbo_tasks::value_impl]
impl VersionedContent for EcmascriptMergedChunkContent {
#[turbo_tasks::function]
fn content(self: Vc<Self>) -> Result<Vc<AssetContent>> {
bail!("EcmascriptMergedChunkContent does not have content")
}
#[turbo_tasks::function]
fn version(self: Vc<Self>) -> Vc<Box<dyn Version>> {
Vc::upcast(self.version())
}
#[turbo_tasks::function]
async fn update(
self: Vc<Self>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Vc<Update>> {
Ok(update_ecmascript_merged_chunk(self, from_version)
.await?
.cell())
}
}
@@ -0,0 +1,45 @@
use anyhow::{Result, bail};
use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::version::{VersionedContent, VersionedContentMerger, VersionedContents};
use crate::hmr::{EcmascriptHmrChunkContent, content::EcmascriptMergedChunkContent};
/// Merges multiple [`EcmascriptHmrChunkContent`] into a single
/// [`EcmascriptMergedChunkContent`]. This allows the chunk list to produce a
/// single `EcmascriptMergedUpdate` for multiple chunks updating at the same time.
#[turbo_tasks::value]
pub struct EcmascriptChunkContentMerger;
#[turbo_tasks::value_impl]
impl EcmascriptChunkContentMerger {
#[turbo_tasks::function]
pub fn new() -> Vc<Self> {
Self::cell(EcmascriptChunkContentMerger)
}
}
#[turbo_tasks::value_impl]
impl VersionedContentMerger for EcmascriptChunkContentMerger {
#[turbo_tasks::function]
async fn merge(
&self,
contents: Vc<VersionedContents>,
) -> Result<Vc<Box<dyn VersionedContent>>> {
let contents = contents
.await?
.iter()
.map(|content| async move {
if let Some(content) =
ResolvedVc::try_sidecast::<Box<dyn EcmascriptHmrChunkContent>>(*content)
{
Ok(content)
} else {
bail!("expected Vc<Box<dyn EcmascriptHmrChunkContent>>")
}
})
.try_join()
.await?;
Ok(Vc::upcast(EcmascriptMergedChunkContent { contents }.cell()))
}
}
@@ -0,0 +1,31 @@
use turbo_tasks::Vc;
use turbopack_core::version::VersionedContent;
use crate::{chunk::EcmascriptChunkContentEntries, hmr::version::EcmascriptChunkVersion};
/// Diffing and merging of Ecmascript chunk contents for hot module replacement.
///
/// Each module covers both the single-chunk and the merged (multi-chunk) case,
/// the latter being what a chunk list produces a single update from.
pub mod content;
pub mod merger;
pub mod update;
pub mod version;
/// An Ecmascript chunk content that participates in HMR.
///
/// Exists so the version/diff/merge machinery can be written once against the
/// trait rather than duplicated per runtime. The turbo-tasks value types cannot
/// be generic, so the runtimes keep their own content structs and implement this
/// to expose the two things the shared machinery needs.
#[turbo_tasks::value_trait]
pub trait EcmascriptHmrChunkContent: VersionedContent {
/// The per-module code and hashes making up this chunk.
#[turbo_tasks::function]
fn entries(self: Vc<Self>) -> Vc<EcmascriptChunkContentEntries>;
/// The same value as [`VersionedContent::version`], but with the concrete
/// type the diffing machinery needs instead of `Box<dyn Version>`.
#[turbo_tasks::function]
fn ecmascript_chunk_version(self: Vc<Self>) -> Vc<EcmascriptChunkVersion>;
}
@@ -0,0 +1,294 @@
use std::sync::Arc;
use anyhow::Result;
use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::{
chunk::ModuleId,
code_builder::Code,
version::{PartialUpdate, TotalUpdate, Update, Version},
};
use crate::{
chunk::EcmascriptChunkContentEntries,
chunk_list::merged_update::{
EcmascriptMergedChunkAdded, EcmascriptMergedChunkDeleted, EcmascriptMergedChunkPartial,
EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, EcmascriptModuleEntry,
},
hmr::{
EcmascriptHmrChunkContent,
content::EcmascriptMergedChunkContent,
version::{EcmascriptChunkVersion, EcmascriptMergedChunkVersion},
},
};
/// The module-level difference between two versions of a single chunk.
enum EcmascriptChunkUpdate {
None,
Partial {
added: FxIndexMap<ModuleId, AddedModule>,
modified: FxIndexMap<ModuleId, ResolvedVc<Code>>,
deleted: FxIndexMap<ModuleId, u128>,
},
}
struct AddedModule {
hash: u128,
code: ResolvedVc<Code>,
}
/// Diffs two versions of a single chunk's content, as one step of building a
/// merged update.
async fn update_ecmascript_hmr_chunk_content(
content: Vc<Box<dyn EcmascriptHmrChunkContent>>,
to: &ReadRef<EcmascriptChunkVersion>,
from: &ReadRef<EcmascriptChunkVersion>,
) -> Result<EcmascriptChunkUpdate> {
let mut added = FxIndexMap::default();
let mut modified = FxIndexMap::default();
let mut deleted = FxIndexMap::default();
// Lazily resolve the entries map only when we actually need to ship code
// bytes for an added or modified module. For chunks that only have deletions
// (or no changes that need code beyond hashes), this avoids materializing
// any `Vc<Code>`.
let mut entries_ref = None;
// Check for deleted and modified modules
for (id, from_hash) in &from.entries_hashes {
if let Some(to_hash) = to.entries_hashes.get(id) {
if *to_hash != *from_hash {
// Module was modified
let entries = match &entries_ref {
Some(entries) => entries,
None => entries_ref.insert(content.entries().await?),
};
if let Some(entry) = entries.get(id) {
modified.insert(id.clone(), entry.code);
}
}
} else {
// Module was deleted
deleted.insert(id.clone(), *from_hash);
}
}
// Check for added modules
for (id, hash) in &to.entries_hashes {
if !from.entries_hashes.contains_key(id) {
let entries = match &entries_ref {
Some(entries) => entries,
None => entries_ref.insert(content.entries().await?),
};
if let Some(entry) = entries.get(id) {
added.insert(
id.clone(),
AddedModule {
hash: *hash,
code: entry.code,
},
);
}
}
}
Ok(
if added.is_empty() && modified.is_empty() && deleted.is_empty() {
EcmascriptChunkUpdate::None
} else {
EcmascriptChunkUpdate::Partial {
added,
modified,
deleted,
}
},
)
}
/// Looks up a module's hash across several chunk versions, avoiding the cost of
/// merging them into a single map.
fn module_hash(versions: &[ReadRef<EcmascriptChunkVersion>], id: &ModuleId) -> Option<u128> {
versions
.iter()
.find_map(|version| version.entries_hashes.get(id).copied())
}
/// Code only has to be shipped once per update: skip any module another chunk in
/// the group already provides at the same hash.
async fn insert_entry_unless_shipped(
entries: &mut FxIndexMap<ModuleId, EcmascriptModuleEntry>,
from_versions: &[ReadRef<EcmascriptChunkVersion>],
id: ModuleId,
hash: u128,
code: Vc<Code>,
chunk_path: &str,
) -> Result<()> {
if module_hash(from_versions, &id) != Some(hash) {
let entry = EcmascriptModuleEntry::from_code(&id, code, chunk_path).await?;
entries.insert(id, entry);
}
Ok(())
}
/// Translates a single chunk's module diff into its merged-update payload.
async fn partial_chunk_update(
update: EcmascriptChunkUpdate,
chunk_path: &str,
from_versions: &[ReadRef<EcmascriptChunkVersion>],
entries: &mut FxIndexMap<ModuleId, EcmascriptModuleEntry>,
) -> Result<EcmascriptMergedChunkUpdate> {
let EcmascriptChunkUpdate::Partial {
added,
modified,
deleted,
} = update
else {
unreachable!("caller filters out EcmascriptChunkUpdate::None");
};
let mut partial = EcmascriptMergedChunkPartial::default();
for (id, AddedModule { hash, code }) in added {
partial.added.insert(id.clone());
insert_entry_unless_shipped(entries, from_versions, id, hash, *code, chunk_path).await?;
}
partial.deleted.extend(deleted.into_keys());
for (id, code) in modified {
let entry = EcmascriptModuleEntry::from_code(&id, *code, chunk_path).await?;
entries.insert(id, entry);
}
Ok(EcmascriptMergedChunkUpdate::Partial(partial))
}
/// Builds the payload for a chunk that wasn't present in the previous version.
async fn added_chunk_update(
chunk_entries: &ReadRef<EcmascriptChunkContentEntries>,
chunk_path: &str,
from_versions: &[ReadRef<EcmascriptChunkVersion>],
entries: &mut FxIndexMap<ModuleId, EcmascriptModuleEntry>,
) -> Result<EcmascriptMergedChunkUpdate> {
let mut added = EcmascriptMergedChunkAdded::default();
for (id, entry) in chunk_entries.iter() {
added.modules.insert(id.clone());
insert_entry_unless_shipped(
entries,
from_versions,
id.clone(),
*entry.hash.await?,
*entry.code,
chunk_path,
)
.await?;
}
Ok(EcmascriptMergedChunkUpdate::Added(added))
}
/// Computes a single [`Update`] covering every chunk in a merged chunk content.
///
/// Runtime-agnostic: both the browser and node chunk lists share this one
/// implementation.
pub async fn update_ecmascript_merged_chunk(
content: Vc<EcmascriptMergedChunkContent>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Update> {
let to_merged_version = content.version();
let Some(from_merged_version) =
ResolvedVc::try_downcast_type::<EcmascriptMergedChunkVersion>(from_version)
else {
// It's likely `from_version` is `NotFoundVersion`.
return Ok(Update::Total(TotalUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_merged_version)
.into_trait_ref()
.await?,
}));
};
let to = to_merged_version.await?;
let from = from_merged_version.await?;
// When to and from point to the same value we can skip comparing them
if from.ptr_eq(&to) {
return Ok(Update::None);
}
let mut from_versions_by_chunk_path: FxIndexMap<_, _> = from
.versions
.iter()
.map(|version| (&*version.chunk_path, version))
.collect();
let from_versions = &from.versions;
let content = content.await?;
let to_contents = content
.contents
.iter()
.map(|content| async move {
let entries = content.entries().await?;
let version = content.ecmascript_chunk_version().await?;
Ok((*content, entries, version))
})
.try_join()
.await?;
let mut merged_update = EcmascriptMergedUpdate::default();
for (content, entries, to_version) in &to_contents {
let chunk_path = to_version.chunk_path.as_str();
let chunk_update = match from_versions_by_chunk_path.swap_remove(chunk_path) {
Some(from_version) => {
match update_ecmascript_hmr_chunk_content(**content, to_version, from_version)
.await?
{
EcmascriptChunkUpdate::None => continue,
update => {
partial_chunk_update(
update,
chunk_path,
from_versions,
&mut merged_update.entries,
)
.await?
}
}
}
None => {
added_chunk_update(
entries,
chunk_path,
from_versions,
&mut merged_update.entries,
)
.await?
}
};
merged_update.chunks.insert(chunk_path, chunk_update);
}
for (chunk_path, chunk_version) in from_versions_by_chunk_path {
let hashes = &chunk_version.entries_hashes;
merged_update.chunks.insert(
chunk_path,
EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted {
modules: hashes.keys().cloned().collect(),
}),
);
}
Ok(if merged_update.is_empty() {
Update::None
} else {
Update::Partial(PartialUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_merged_version)
.into_trait_ref()
.await?,
instruction: Arc::new(serde_json::to_value(&merged_update)?),
})
})
}
@@ -0,0 +1,111 @@
use anyhow::Result;
use turbo_rcstr::RcStr;
use turbo_tasks::{FxIndexMap, ReadRef, TryJoinIterExt, Vc, turbobail};
use turbo_tasks_fs::FileSystemPath;
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
use turbopack_core::{
chunk::{MinifyType, ModuleId},
version::Version,
};
use crate::chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries};
/// The version of a single Ecmascript chunk's content, tracked as the set of
/// per-module content hashes.
///
/// Runtime-agnostic: the browser and node chunking contexts share this one
/// implementation rather than each carrying a copy. `minify_type` participates
/// in the hash because minification changes the emitted bytes without changing
/// any module's own hash.
#[turbo_tasks::value(serialization = "skip")]
pub struct EcmascriptChunkVersion {
pub chunk_path: RcStr,
pub minify_type: MinifyType,
pub entries_hashes: FxIndexMap<ModuleId, u128>,
}
#[turbo_tasks::value_impl]
impl EcmascriptChunkVersion {
#[turbo_tasks::function]
pub async fn new(
output_root: FileSystemPath,
chunk_path: FileSystemPath,
content: Vc<EcmascriptChunkContent>,
minify_type: MinifyType,
) -> Result<Vc<Self>> {
let Some(chunk_path) = output_root.get_path_to(&chunk_path) else {
turbobail!("chunk path {chunk_path} is not in output root {output_root}");
};
let entries_hashes = EcmascriptChunkContentEntries::new(content)
.await?
.iter()
.map(async |(id, entry)| Ok((id.clone(), *entry.hash.await?)))
.try_join()
.await?
.into_iter()
.collect();
Ok(EcmascriptChunkVersion {
chunk_path: chunk_path.into(),
minify_type,
entries_hashes,
}
.cell())
}
}
#[turbo_tasks::value_impl]
impl Version for EcmascriptChunkVersion {
#[turbo_tasks::function]
fn id(&self) -> Vc<RcStr> {
let mut hasher = Xxh3Hash64Hasher::new();
hasher.write_ref(&self.chunk_path);
hasher.write_ref(&self.minify_type);
let sorted_hashes = {
let mut hashes: Vec<_> = self.entries_hashes.values().copied().collect();
hashes.sort();
hashes
};
for hash in sorted_hashes {
hasher.write_value(hash);
}
let hash = hasher.finish();
let hash = encode_base64(hash);
Vc::cell(hash.into())
}
}
/// The version of a [`super::content::EcmascriptMergedChunkContent`]. This is
/// essentially a composite [`EcmascriptChunkVersion`].
#[turbo_tasks::value(serialization = "skip", shared)]
pub struct EcmascriptMergedChunkVersion {
#[turbo_tasks(trace_ignore)]
pub versions: Vec<ReadRef<EcmascriptChunkVersion>>,
}
#[turbo_tasks::value_impl]
impl Version for EcmascriptMergedChunkVersion {
#[turbo_tasks::function]
async fn id(&self) -> Result<Vc<RcStr>> {
let mut hasher = Xxh3Hash64Hasher::new();
hasher.write_value(self.versions.len());
let sorted_ids = {
let mut sorted_ids = self
.versions
.iter()
// This `ReadRef::cell` call is important: it ensures the id is
// computed from a cell, so it is cached.
.map(|version| ReadRef::cell(version.clone()).id())
.try_join()
.await?;
sorted_ids.sort();
sorted_ids
};
for id in sorted_ids {
hasher.write_value(id);
}
let hash = hasher.finish();
let hash = encode_base64(hash);
Ok(Vc::cell(hash.into()))
}
}
@@ -16,6 +16,7 @@ pub mod chunk_list;
pub mod code_gen;
pub mod embed_js;
mod errors;
pub mod hmr;
pub mod json_source_transform;
pub mod magic_identifier;
pub mod manifest;
@@ -12,7 +12,7 @@ use turbopack_core::{
};
use turbopack_ecmascript::chunk::EcmascriptChunk;
use super::content::EcmascriptBuildNodeChunkContent;
use super::content::EcmascriptNodeChunkContent;
use crate::NodeJsChunkingContext;
/// Production Ecmascript chunk targeting Node.js.
@@ -62,9 +62,9 @@ fn modifier() -> RcStr {
#[turbo_tasks::value_impl]
impl EcmascriptBuildNodeChunk {
#[turbo_tasks::function]
async fn own_content(self: Vc<Self>) -> Result<Vc<EcmascriptBuildNodeChunkContent>> {
async fn own_content(self: Vc<Self>) -> Result<Vc<EcmascriptNodeChunkContent>> {
let this = self.await?;
Ok(EcmascriptBuildNodeChunkContent::new(
Ok(EcmascriptNodeChunkContent::new(
*this.chunking_context,
self,
this.chunk.chunk_content(),
@@ -7,24 +7,23 @@ use turbopack_core::{
code_builder::{Code, CodeBuilder},
output::OutputAsset,
source_map::{GenerateSourceMap, SourceMapAsset},
version::{
MergeableVersionedContent, Update, Version, VersionedContent, VersionedContentMerger,
},
version::{MergeableVersionedContent, Version, VersionedContent, VersionedContentMerger},
};
use turbopack_ecmascript::{
chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries},
hmr::{
EcmascriptHmrChunkContent, merger::EcmascriptChunkContentMerger,
version::EcmascriptChunkVersion,
},
minify::minify,
utils::StringifyJs,
};
use super::{
chunk::EcmascriptBuildNodeChunk, merged::merger::EcmascriptBuildNodeChunkContentMerger,
update::update_node_chunk, version::EcmascriptBuildNodeChunkVersion,
};
use super::chunk::EcmascriptBuildNodeChunk;
use crate::NodeJsChunkingContext;
#[turbo_tasks::value]
pub(super) struct EcmascriptBuildNodeChunkContent {
pub(super) struct EcmascriptNodeChunkContent {
pub(super) content: ResolvedVc<EcmascriptChunkContent>,
pub(super) chunking_context: ResolvedVc<NodeJsChunkingContext>,
pub(super) chunk: ResolvedVc<EcmascriptBuildNodeChunk>,
@@ -32,7 +31,7 @@ pub(super) struct EcmascriptBuildNodeChunkContent {
}
#[turbo_tasks::value_impl]
impl EcmascriptBuildNodeChunkContent {
impl EcmascriptNodeChunkContent {
#[turbo_tasks::function]
pub(crate) fn new(
chunking_context: ResolvedVc<NodeJsChunkingContext>,
@@ -40,7 +39,7 @@ impl EcmascriptBuildNodeChunkContent {
content: ResolvedVc<EcmascriptChunkContent>,
source_map: ResolvedVc<SourceMapAsset>,
) -> Vc<Self> {
EcmascriptBuildNodeChunkContent {
EcmascriptNodeChunkContent {
content,
chunking_context,
chunk,
@@ -48,15 +47,10 @@ impl EcmascriptBuildNodeChunkContent {
}
.cell()
}
#[turbo_tasks::function]
pub(crate) fn entries(&self) -> Vc<EcmascriptChunkContentEntries> {
EcmascriptChunkContentEntries::new(*self.content)
}
}
#[turbo_tasks::value_impl]
impl EcmascriptBuildNodeChunkContent {
impl EcmascriptNodeChunkContent {
#[turbo_tasks::function]
async fn code(&self) -> Result<Vc<Code>> {
use std::io::Write;
@@ -94,20 +88,10 @@ impl EcmascriptBuildNodeChunkContent {
Ok(code.cell())
}
#[turbo_tasks::function]
pub(crate) async fn own_version(&self) -> Result<Vc<EcmascriptBuildNodeChunkVersion>> {
Ok(EcmascriptBuildNodeChunkVersion::new(
self.chunking_context.output_root().owned().await?,
self.chunk.path().owned().await?,
*self.content,
*self.chunking_context.minify_type().await?,
))
}
}
#[turbo_tasks::value_impl]
impl GenerateSourceMap for EcmascriptBuildNodeChunkContent {
impl GenerateSourceMap for EcmascriptNodeChunkContent {
#[turbo_tasks::function]
fn generate_source_map(self: Vc<Self>) -> Vc<FileContent> {
self.code().generate_source_map()
@@ -115,7 +99,7 @@ impl GenerateSourceMap for EcmascriptBuildNodeChunkContent {
}
#[turbo_tasks::value_impl]
impl VersionedContent for EcmascriptBuildNodeChunkContent {
impl VersionedContent for EcmascriptNodeChunkContent {
#[turbo_tasks::function]
async fn content(self: Vc<Self>) -> Result<Vc<AssetContent>> {
let this = self.await?;
@@ -131,22 +115,32 @@ impl VersionedContent for EcmascriptBuildNodeChunkContent {
#[turbo_tasks::function]
fn version(self: Vc<Self>) -> Vc<Box<dyn Version>> {
Vc::upcast(self.own_version())
}
#[turbo_tasks::function]
async fn update(
self: Vc<Self>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Vc<Update>> {
Ok(update_node_chunk(self, from_version).await?.cell())
Vc::upcast(self.ecmascript_chunk_version())
}
}
#[turbo_tasks::value_impl]
impl MergeableVersionedContent for EcmascriptBuildNodeChunkContent {
impl EcmascriptHmrChunkContent for EcmascriptNodeChunkContent {
#[turbo_tasks::function]
fn entries(&self) -> Vc<EcmascriptChunkContentEntries> {
EcmascriptChunkContentEntries::new(*self.content)
}
#[turbo_tasks::function]
async fn ecmascript_chunk_version(&self) -> Result<Vc<EcmascriptChunkVersion>> {
Ok(EcmascriptChunkVersion::new(
self.chunking_context.output_root().owned().await?,
self.chunk.path().owned().await?,
*self.content,
*self.chunking_context.minify_type().await?,
))
}
}
#[turbo_tasks::value_impl]
impl MergeableVersionedContent for EcmascriptNodeChunkContent {
#[turbo_tasks::function]
fn get_merger(&self) -> Vc<Box<dyn VersionedContentMerger>> {
Vc::upcast(EcmascriptBuildNodeChunkContentMerger::new())
Vc::upcast(EcmascriptChunkContentMerger::new())
}
}
@@ -1,55 +0,0 @@
use anyhow::{Result, bail};
use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::version::{Update, Version, VersionedContent};
use super::{
update::update_ecmascript_merged_chunk, version::EcmascriptBuildNodeMergedChunkVersion,
};
use crate::ecmascript::node::content::EcmascriptBuildNodeChunkContent;
/// Composite [`VersionedContent`] that is the result of merging multiple
/// node ecmascript chunk contents together through the
/// [`EcmascriptBuildNodeChunkContentMerger`].
#[turbo_tasks::value(serialization = "skip", shared)]
pub(crate) struct EcmascriptBuildNodeMergedChunkContent {
pub(super) contents: Vec<ResolvedVc<EcmascriptBuildNodeChunkContent>>,
}
#[turbo_tasks::value_impl]
impl EcmascriptBuildNodeMergedChunkContent {
#[turbo_tasks::function]
pub async fn version(&self) -> Result<Vc<EcmascriptBuildNodeMergedChunkVersion>> {
Ok(EcmascriptBuildNodeMergedChunkVersion {
versions: self
.contents
.iter()
.map(|content| async move { content.own_version().await })
.try_join()
.await?,
}
.cell())
}
}
#[turbo_tasks::value_impl]
impl VersionedContent for EcmascriptBuildNodeMergedChunkContent {
#[turbo_tasks::function]
fn content(self: Vc<Self>) -> Result<Vc<turbopack_core::asset::AssetContent>> {
bail!("EcmascriptBuildNodeMergedChunkContent does not have content")
}
#[turbo_tasks::function]
fn version(self: Vc<Self>) -> Vc<Box<dyn Version>> {
Vc::upcast(self.version())
}
#[turbo_tasks::function]
async fn update(
self: Vc<Self>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Vc<Update>> {
Ok(update_ecmascript_merged_chunk(self, from_version)
.await?
.cell())
}
}
@@ -1,51 +0,0 @@
use anyhow::{Result, bail};
use turbo_tasks::{ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::version::{VersionedContent, VersionedContentMerger, VersionedContents};
use crate::ecmascript::node::{
content::EcmascriptBuildNodeChunkContent,
merged::content::EcmascriptBuildNodeMergedChunkContent,
};
/// Merges multiple [`EcmascriptBuildNodeChunkContent`] into a single
/// [`EcmascriptBuildNodeMergedChunkContent`]. This allows the chunk list to
/// produce a single `EcmascriptMergedUpdate` for multiple chunks updating at
/// the same time.
#[turbo_tasks::value]
pub(crate) struct EcmascriptBuildNodeChunkContentMerger;
#[turbo_tasks::value_impl]
impl EcmascriptBuildNodeChunkContentMerger {
#[turbo_tasks::function]
pub fn new() -> Vc<Self> {
Self::cell(EcmascriptBuildNodeChunkContentMerger)
}
}
#[turbo_tasks::value_impl]
impl VersionedContentMerger for EcmascriptBuildNodeChunkContentMerger {
#[turbo_tasks::function]
async fn merge(
&self,
contents: Vc<VersionedContents>,
) -> Result<Vc<Box<dyn VersionedContent>>> {
let contents = contents
.await?
.iter()
.map(|content| async move {
if let Some(content) =
ResolvedVc::try_downcast_type::<EcmascriptBuildNodeChunkContent>(*content)
{
Ok(content)
} else {
bail!("expected Vc<EcmascriptBuildNodeChunkContent>")
}
})
.try_join()
.await?;
Ok(Vc::upcast(
EcmascriptBuildNodeMergedChunkContent { contents }.cell(),
))
}
}
@@ -1,4 +0,0 @@
pub(crate) mod content;
pub(crate) mod merger;
pub(crate) mod update;
pub(crate) mod version;
@@ -1,182 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, TryJoinIterExt, Vc};
use turbopack_core::{
chunk::{ChunkingContext, ModuleId},
output::OutputAsset,
version::{PartialUpdate, TotalUpdate, Update, Version},
};
use turbopack_ecmascript::chunk_list::merged_update::{
EcmascriptMergedChunkAdded, EcmascriptMergedChunkDeleted, EcmascriptMergedChunkPartial,
EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate, EcmascriptModuleEntry,
};
use crate::ecmascript::node::{
merged::{
content::EcmascriptBuildNodeMergedChunkContent,
version::EcmascriptBuildNodeMergedChunkVersion,
},
update::{NodeChunkUpdate, update_ecmascript_node_chunk_content},
version::EcmascriptBuildNodeChunkVersion,
};
struct MergedModuleMap {
versions: Vec<ReadRef<EcmascriptBuildNodeChunkVersion>>,
}
impl MergedModuleMap {
fn new(versions: Vec<ReadRef<EcmascriptBuildNodeChunkVersion>>) -> Self {
Self { versions }
}
fn get(&self, id: &ModuleId) -> Option<u128> {
for version in &self.versions {
if let Some(hash) = version.entries_hashes.get(id) {
return Some(*hash);
}
}
None
}
}
pub(crate) async fn update_ecmascript_merged_chunk(
content: Vc<EcmascriptBuildNodeMergedChunkContent>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Update> {
let to_merged_version = content.version();
let from_merged_version = if let Some(from) =
ResolvedVc::try_downcast_type::<EcmascriptBuildNodeMergedChunkVersion>(from_version)
{
from
} else {
return Ok(Update::Total(TotalUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_merged_version)
.into_trait_ref()
.await?,
}));
};
let to = to_merged_version.await?;
let from = from_merged_version.await?;
if from.ptr_eq(&to) {
return Ok(Update::None);
}
let mut from_versions_by_chunk_path: FxIndexMap<_, _> = from
.versions
.iter()
.map(|version| (&*version.chunk_path, version))
.collect();
let merged_module_map = MergedModuleMap::new(from.versions.to_vec());
let content = content.await?;
let to_contents = content
.contents
.iter()
.map(|content| async move {
let entries = content.entries().await?;
let content_ref = content.await?;
let output_root = content_ref.chunking_context.output_root().await?;
let path = content_ref.chunk.path().await?;
Ok((*content, entries, output_root, path))
})
.try_join()
.await?;
let mut merged_update = EcmascriptMergedUpdate::default();
for (content, entries, output_root, path) in &to_contents {
let Some(chunk_path) = output_root.get_path_to(path) else {
continue;
};
let chunk_update = if let Some(from_version) =
from_versions_by_chunk_path.swap_remove(chunk_path)
{
// Reuse the single-chunk diff so the merged path stays in sync with
// the standalone chunk update path.
let to_version = content.own_version().await?;
match update_ecmascript_node_chunk_content(**content, &to_version, from_version).await?
{
NodeChunkUpdate::None => continue,
NodeChunkUpdate::Partial {
added,
modified,
deleted,
} => {
let mut partial = EcmascriptMergedChunkPartial::default();
for (module_id, (module_hash, module_code)) in added {
partial.added.insert(module_id.clone());
// Only ship the code if no other chunk in the group
// already provides this module at the same hash.
if merged_module_map.get(&module_id) != Some(module_hash) {
let entry = EcmascriptModuleEntry::from_code(
&module_id,
module_code,
chunk_path,
)
.await?;
merged_update.entries.insert(module_id, entry);
}
}
partial.deleted.extend(deleted.into_keys());
for (module_id, module_code) in modified {
let entry =
EcmascriptModuleEntry::from_code(&module_id, module_code, chunk_path)
.await?;
merged_update.entries.insert(module_id, entry);
}
EcmascriptMergedChunkUpdate::Partial(partial)
}
}
} else {
let mut added = EcmascriptMergedChunkAdded::default();
for (id, entry) in entries {
let hash = *entry.hash.await?;
added.modules.insert(id.clone());
if merged_module_map.get(id) != Some(hash) {
let entry =
EcmascriptModuleEntry::from_code(id, *entry.code, chunk_path).await?;
merged_update.entries.insert(id.clone(), entry);
}
}
EcmascriptMergedChunkUpdate::Added(added)
};
merged_update.chunks.insert(chunk_path, chunk_update);
}
for (chunk_path, chunk_version) in from_versions_by_chunk_path {
let hashes = &chunk_version.entries_hashes;
merged_update.chunks.insert(
chunk_path,
EcmascriptMergedChunkUpdate::Deleted(EcmascriptMergedChunkDeleted {
modules: hashes.keys().cloned().collect(),
}),
);
}
let update = if merged_update.is_empty() {
Update::None
} else {
Update::Partial(PartialUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_merged_version)
.into_trait_ref()
.await?,
instruction: Arc::new(serde_json::to_value(&merged_update)?),
})
};
Ok(update)
}
@@ -1,38 +0,0 @@
use anyhow::Result;
use turbo_rcstr::RcStr;
use turbo_tasks::{ReadRef, TryJoinIterExt, Vc};
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
use turbopack_core::version::Version;
use crate::ecmascript::node::version::EcmascriptBuildNodeChunkVersion;
/// The version of a [`super::content::EcmascriptBuildNodeMergedChunkContent`].
#[turbo_tasks::value(serialization = "skip", shared)]
pub(crate) struct EcmascriptBuildNodeMergedChunkVersion {
pub(super) versions: Vec<ReadRef<EcmascriptBuildNodeChunkVersion>>,
}
#[turbo_tasks::value_impl]
impl Version for EcmascriptBuildNodeMergedChunkVersion {
#[turbo_tasks::function]
async fn id(&self) -> Result<Vc<RcStr>> {
let mut hasher = Xxh3Hash64Hasher::new();
hasher.write_value(self.versions.len());
let sorted_ids = {
let mut sorted_ids = self
.versions
.iter()
.map(|version| ReadRef::cell(version.clone()).id())
.try_join()
.await?;
sorted_ids.sort();
sorted_ids
};
for id in sorted_ids {
hasher.write_value(id);
}
let hash = hasher.finish();
let hash = encode_base64(hash);
Ok(Vc::cell(hash.into()))
}
}
@@ -1,6 +1,3 @@
pub(crate) mod chunk;
pub(crate) mod content;
pub mod entry;
pub(crate) mod merged;
pub(crate) mod update;
pub(crate) mod version;
@@ -1,168 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use turbo_tasks::{FxIndexMap, ReadRef, ResolvedVc, Vc};
use turbopack_core::{
chunk::ModuleId,
code_builder::Code,
version::{PartialUpdate, TotalUpdate, Update, Version},
};
use turbopack_ecmascript::chunk_list::merged_update::{
EcmascriptMergedChunkPartial, EcmascriptMergedChunkUpdate, EcmascriptMergedUpdate,
EcmascriptModuleEntry,
};
use super::{content::EcmascriptBuildNodeChunkContent, version::EcmascriptBuildNodeChunkVersion};
pub(super) async fn update_node_chunk(
content: Vc<EcmascriptBuildNodeChunkContent>,
from_version: ResolvedVc<Box<dyn Version>>,
) -> Result<Update> {
let to_version = content.own_version();
let from_version = if let Some(from) =
ResolvedVc::try_downcast_type::<EcmascriptBuildNodeChunkVersion>(from_version)
{
from
} else {
// It's likely `from_version` is `NotFoundVersion`.
return Ok(Update::Total(TotalUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_version)
.into_trait_ref()
.await?,
}));
};
let to = to_version.await?;
let from = from_version.await?;
// When to and from point to the same value we can skip comparing them
if from.ptr_eq(&to) {
return Ok(Update::None);
}
let chunk_path = to.chunk_path.as_str();
let chunk_update = update_ecmascript_node_chunk_content(content, &to, &from).await?;
let mut merged_update = EcmascriptMergedUpdate::default();
match chunk_update {
NodeChunkUpdate::None => {
return Ok(Update::None);
}
NodeChunkUpdate::Partial {
added,
modified,
deleted,
} => {
let mut partial = EcmascriptMergedChunkPartial::default();
for (module_id, (_hash, module_code)) in added {
partial.added.insert(module_id.clone());
let entry =
EcmascriptModuleEntry::from_code(&module_id, module_code, chunk_path).await?;
merged_update.entries.insert(module_id, entry);
}
partial.deleted.extend(deleted.into_keys());
for (module_id, module_code) in modified {
let entry =
EcmascriptModuleEntry::from_code(&module_id, module_code, chunk_path).await?;
merged_update.entries.insert(module_id, entry);
}
merged_update
.chunks
.insert(chunk_path, EcmascriptMergedChunkUpdate::Partial(partial));
}
}
let update = if merged_update.is_empty() {
Update::None
} else {
// Serialize EcmascriptMergedUpdate directly
// The hot-reloader will wrap it in ChunkListUpdate format for the runtime
let instruction_value = serde_json::to_value(&merged_update)?;
Update::Partial(PartialUpdate {
to: Vc::upcast::<Box<dyn Version>>(to_version)
.into_trait_ref()
.await?,
instruction: Arc::new(instruction_value),
})
};
Ok(update)
}
pub(crate) enum NodeChunkUpdate {
None,
Partial {
/// Added modules, keyed by id, with their content hash (used by the
/// merged-chunk path to dedup code emission across chunks) and code.
added: FxIndexMap<ModuleId, (u128, Vc<Code>)>,
modified: FxIndexMap<ModuleId, Vc<Code>>,
deleted: FxIndexMap<ModuleId, u128>,
},
}
pub(super) async fn update_ecmascript_node_chunk_content(
content: Vc<EcmascriptBuildNodeChunkContent>,
to: &ReadRef<EcmascriptBuildNodeChunkVersion>,
from: &ReadRef<EcmascriptBuildNodeChunkVersion>,
) -> Result<NodeChunkUpdate> {
let mut added = FxIndexMap::default();
let mut modified = FxIndexMap::default();
let mut deleted = FxIndexMap::default();
// Lazily resolve the entries map only when we actually need to ship code
// bytes for an added or modified module. For chunks that only have deletions
// (or no changes that need code beyond hashes), this avoids materializing
// any `Vc<Code>`.
let mut entries_ref = None;
// Check for deleted and modified modules
for (id, from_hash) in &from.entries_hashes {
if let Some(to_hash) = to.entries_hashes.get(id) {
if *to_hash != *from_hash {
// Module was modified
let entries = match &entries_ref {
Some(entries) => entries,
None => entries_ref.insert(content.entries().await?),
};
if let Some(entry) = entries.get(id) {
modified.insert(id.clone(), *entry.code);
}
}
} else {
// Module was deleted
deleted.insert(id.clone(), *from_hash);
}
}
// Check for added modules
for (id, hash) in &to.entries_hashes {
if !from.entries_hashes.contains_key(id) {
let entries = match &entries_ref {
Some(entries) => entries,
None => entries_ref.insert(content.entries().await?),
};
if let Some(entry) = entries.get(id) {
added.insert(id.clone(), (*hash, *entry.code));
}
}
}
let update = if added.is_empty() && modified.is_empty() && deleted.is_empty() {
NodeChunkUpdate::None
} else {
NodeChunkUpdate::Partial {
added,
modified,
deleted,
}
};
Ok(update)
}
@@ -1,72 +0,0 @@
use anyhow::Result;
use turbo_rcstr::RcStr;
use turbo_tasks::{FxIndexMap, TryJoinIterExt, Vc, turbobail};
use turbo_tasks_fs::FileSystemPath;
use turbo_tasks_hash::{Xxh3Hash64Hasher, encode_base64};
use turbopack_core::{
chunk::{MinifyType, ModuleId},
version::Version,
};
use turbopack_ecmascript::chunk::{EcmascriptChunkContent, EcmascriptChunkContentEntries};
#[turbo_tasks::value(serialization = "skip")]
pub(super) struct EcmascriptBuildNodeChunkVersion {
pub(super) chunk_path: RcStr,
pub(super) minify_type: MinifyType,
pub(super) entries_hashes: FxIndexMap<ModuleId, u128>,
}
#[turbo_tasks::value_impl]
impl EcmascriptBuildNodeChunkVersion {
#[turbo_tasks::function]
pub async fn new(
output_root: FileSystemPath,
chunk_path: FileSystemPath,
content: Vc<EcmascriptChunkContent>,
minify_type: MinifyType,
) -> Result<Vc<Self>> {
let output_root = output_root.clone();
let chunk_path = chunk_path.clone();
let chunk_path = if let Some(path) = output_root.get_path_to(&chunk_path) {
path
} else {
turbobail!("chunk path {chunk_path} is not in client root {output_root}");
};
let entries_hashes = EcmascriptChunkContentEntries::new(content)
.await?
.iter()
.map(async |(id, entry)| Ok((id.clone(), *entry.hash.await?)))
.try_join()
.await?
.into_iter()
.collect();
Ok(EcmascriptBuildNodeChunkVersion {
chunk_path: chunk_path.into(),
minify_type,
entries_hashes,
}
.cell())
}
}
#[turbo_tasks::value_impl]
impl Version for EcmascriptBuildNodeChunkVersion {
#[turbo_tasks::function]
fn id(&self) -> Vc<RcStr> {
let mut hasher = Xxh3Hash64Hasher::new();
hasher.write_ref(&self.chunk_path);
hasher.write_ref(&self.minify_type);
let sorted_hashes = {
let mut hashes: Vec<_> = self.entries_hashes.values().copied().collect();
hashes.sort();
hashes
};
for hash in sorted_hashes {
hasher.write_value(hash);
}
let hash = hasher.finish();
let hash = encode_base64(hash);
Vc::cell(hash.into())
}
}