Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions crates/rex_build/src/client_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use crate::page_exports::{detect_data_strategy, detect_has_static_paths};
use anyhow::Result;
use rex_core::{ProjectConfig, RexConfig};
use rex_router::ScanResult;
use rolldown_common::Output;
use rolldown_common::{
ManualCodeSplittingOptions, MatchGroup, MatchGroupName, MatchGroupTest, Output,
};
use rolldown_utils::js_regex::HybridRegex;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -224,20 +227,40 @@ if (!window.__REX_NAVIGATING__) {{
// Append user-defined aliases from rex.config build.alias
client_aliases.extend(project_config.build.resolved_aliases(&config.project_root));

// Force react/react-dom into a stable vendor chunk so HMR doesn't
// re-initialize React and break the running component tree.
let vendor_chunk = MatchGroup {
name: MatchGroupName::Static("vendor".to_string()),
test: Some(MatchGroupTest::Regex(
HybridRegex::new("node_modules[\\\\/](react|react-dom)[\\\\/]")
.expect("valid vendor regex"),
)),
priority: Some(100),
..Default::default()
};

let options = rolldown::BundlerOptions {
input: Some(inputs),
cwd: Some(config.project_root.clone()),
format: Some(rolldown::OutputFormat::Esm),
dir: Some(output_dir.to_string_lossy().to_string()),
entry_filenames: Some(format!("[name]-{hash}.js").into()),
chunk_filenames: Some(format!("chunk-[name]-{hash}.js").into()),
// Use rolldown's content-based [hash] for shared chunks so that stable
// dependencies (e.g. the vendor/React chunk) keep the same filename
// across HMR rebuilds — the browser reuses the cached ESM module and
// React stays a singleton instead of being re-initialized.
chunk_filenames: Some("chunk-[name]-[hash].js".to_string().into()),
asset_filenames: Some(format!("[name]-{hash}.[ext]").into()),
platform: Some(rolldown::Platform::Browser),
module_types: Some(module_types),
minify,
define: Some(define.iter().cloned().collect()),
tsconfig: Some(rolldown_common::TsConfig::Auto(true)),
treeshake: crate::rsc_build_config::react_treeshake_options(),
manual_code_splitting: Some(ManualCodeSplittingOptions {
groups: Some(vec![vendor_chunk]),
..Default::default()
}),
resolve: Some(rolldown::ResolveOptions {
alias: Some(client_aliases),
extensions: Some(vec![
Expand Down
88 changes: 88 additions & 0 deletions crates/rex_build/tests/vendor_chunk_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#![allow(clippy::unwrap_used)]

mod common;

use common::setup_test_project;
use rex_build::build_bundles;
use rex_core::ProjectConfig;
use std::fs;

/// Test that React is isolated into vendor chunk(s) with content-based hashes.
///
/// Regression test for HMR crash: when React was co-bundled into shared app chunks,
/// HMR would re-initialize React, breaking the running component tree with
/// "Cannot read properties of null (reading 'useState')".
#[tokio::test]
async fn test_vendor_chunk_isolates_react() {
let (_tmp, config, scan) = setup_test_project(
&[
(
"index.tsx",
"export default function Home() { return <div>Home</div>; }",
),
(
"about.tsx",
"export default function About() { return <div>About</div>; }",
),
],
None,
);
let result = build_bundles(&config, &scan, &ProjectConfig::default())
.await
.unwrap();

let client_dir = config.client_build_dir();
let build_hash = &result.build_id[..8];

// Find vendor chunks in the output directory (rolldown may split react
// and react-dom into separate vendor chunks)
let vendor_chunks: Vec<_> = fs::read_dir(&client_dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("chunk-vendor-"))
.collect();
assert!(
!vendor_chunks.is_empty(),
"should have at least 1 vendor chunk"
);

// At least one vendor chunk should contain React's createElement
let vendor_has_react = vendor_chunks.iter().any(|c| {
fs::read_to_string(c.path())
.unwrap()
.contains("createElement")
});
assert!(vendor_has_react, "vendor chunk(s) should contain React");

// Vendor chunks should use content-based hash, NOT the build_id hash
for chunk in &vendor_chunks {
let name = chunk.file_name().to_string_lossy().to_string();
assert!(
!name.contains(build_hash),
"vendor chunk should use content hash, not build_id hash: {name}"
);
}

// Page entry chunks should NOT contain React inline
let index_js = fs::read_to_string(client_dir.join(format!("index-{build_hash}.js"))).unwrap();
assert!(
!index_js.contains("function createElement"),
"page chunk should not bundle React inline — it should import from vendor chunk"
);

// Page entry chunks should import from the vendor chunk
assert!(
index_js.contains("chunk-vendor-"),
"page chunk should import from vendor chunk"
);

// Manifest should list vendor chunk(s) as shared chunks
assert!(
result
.manifest
.shared_chunks
.iter()
.any(|c| c.starts_with("chunk-vendor-")),
"manifest should track vendor as a shared chunk"
);
}
Loading