From 3ed6e8e88b1c4de90fee9578521d37df9509780f Mon Sep 17 00:00:00 2001 From: claude-liminal <264858718+claude-liminal@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:29:31 -0700 Subject: [PATCH 1/2] fix(build): isolate React into stable vendor chunk to prevent HMR crashes React was being co-bundled into shared app chunks by rolldown's automatic code splitting. During HMR, re-importing the shared chunk created a fresh React instance while the mounted component tree still referenced the old one, causing "Cannot read properties of null (reading 'useState')" crashes. Two changes fix this: 1. Add manual_code_splitting with a vendor group that forces react and react-dom into their own chunk 2. Switch shared chunk filenames from build-ID hashing to rolldown's content-based [hash], so the vendor chunk keeps a stable filename across HMR rebuilds and the browser reuses the cached ESM module Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rex_build/src/client_bundle.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/rex_build/src/client_bundle.rs b/crates/rex_build/src/client_bundle.rs index 76977cb9..46266c07 100644 --- a/crates/rex_build/src/client_bundle.rs +++ b/crates/rex_build/src/client_bundle.rs @@ -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}; @@ -224,13 +227,29 @@ 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), @@ -238,6 +257,10 @@ if (!window.__REX_NAVIGATING__) {{ 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![ From e5456b632c2b546891c6cea8df6f404cd3403b64 Mon Sep 17 00:00:00 2001 From: claude-liminal <264858718+claude-liminal@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:53:46 -0700 Subject: [PATCH 2/2] test(build): add regression test for vendor chunk React isolation Verifies that manual_code_splitting isolates React into dedicated chunk-vendor-*.js files with content-based hashes, and that page entry chunks import React from the vendor chunk rather than bundling it inline. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rex_build/tests/vendor_chunk_tests.rs | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 crates/rex_build/tests/vendor_chunk_tests.rs diff --git a/crates/rex_build/tests/vendor_chunk_tests.rs b/crates/rex_build/tests/vendor_chunk_tests.rs new file mode 100644 index 00000000..a214781e --- /dev/null +++ b/crates/rex_build/tests/vendor_chunk_tests.rs @@ -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
Home
; }", + ), + ( + "about.tsx", + "export default function About() { return
About
; }", + ), + ], + 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" + ); +}