Repository: denoland/deno
Pull request: fix(bundle): reload html entrypoints with --watch

Fixes #30769.

Changed files:
filename: cli/tools/bundle/html.rs
patch: @@ -135,6 +135,7 @@ fn collect_scripts(doc: &str) -> Result<Vec<Script>, AnyError> {
 #[derive(Debug, Clone)]
 pub struct HtmlEntrypoint {
   pub path: PathBuf,
+  pub canonical_path: PathBuf,
   pub scripts: Vec<Script>,
   pub temp_module: String,
   pub contents: String,
@@ -163,6 +164,7 @@ fn sanitize_entry_name(cwd: &Path, path: &Path) -> String {
 fn parse_html_entrypoint(
   cwd: &Path,
   path: &Path,
+  canonical_path: PathBuf,
   contents: String,
 ) -> anyhow::Result<HtmlEntrypoint> {
   let mut scripts = collect_scripts(&contents)?;
@@ -188,6 +190,7 @@ fn parse_html_entrypoint(
 
   Ok(HtmlEntrypoint {
     path: path.to_path_buf(),
+    canonical_path,
     scripts,
     temp_module,
     contents,
@@ -201,7 +204,8 @@ pub fn load_html_entrypoint(
   path: &Path,
 ) -> anyhow::Result<HtmlEntrypoint> {
   let contents = std::fs::read_to_string(path)?;
-  parse_html_entrypoint(cwd, path, contents)
+  let canonical_path = crate::util::fs::canonicalize_path(path)?;
+  parse_html_entrypoint(cwd, path, canonical_path, contents)
 }
 
 #[derive(Debug, Clone)]

filename: cli/tools/bundle/mod.rs
patch: @@ -28,6 +28,7 @@ use deno_config::workspace::TsTypeLib;
 use deno_core::error::AnyError;
 use deno_core::futures::FutureExt as _;
 use deno_core::parking_lot::Mutex;
+use deno_core::parking_lot::RwLock;
 use deno_core::serde_json;
 use deno_core::url::Url;
 use deno_error::JsError;
@@ -131,7 +132,7 @@ pub async fn prepare_inputs(
     let mut html_pages = Vec::new();
     let mut to_cache_urls = Vec::new();
     let mut entries: Vec<(String, String)> = Vec::new();
-    let mut virtual_modules = VirtualModules::new();
+    let virtual_modules = Arc::new(VirtualModules::new());
 
     for html_path in &html_paths {
       let entry = html::load_html_entrypoint(init_cwd, html_path)?;
@@ -158,7 +159,7 @@ pub async fn prepare_inputs(
       html_pages.push(entry);
     }
 
-    plugin_handler.virtual_modules = Some(Arc::new(virtual_modules));
+    plugin_handler.virtual_modules = Some(virtual_modules);
 
     // Prepare non-HTML entries too
     let _ = plugin_handler.prepare_module_load(&script_entry_urls).await;
@@ -384,7 +385,6 @@ async fn bundle_watch(
   };
   let always_watch = Rc::new(always_watch);
   let current_roots = Rc::new(RefCell::new(initial_roots.clone()));
-  let input = bundler.input.clone();
   let bundler = Rc::new(tokio::sync::Mutex::new(bundler));
   let mut print_config =
     crate::util::file_watcher::PrintConfig::new_with_banner(
@@ -399,17 +399,14 @@ async fn bundle_watch(
       watcher_communicator.show_path_changed(changed_paths.clone());
       let bundler = Rc::clone(&bundler);
       let current_roots = current_roots.clone();
-      let input = input.clone();
       let always_watch = always_watch.clone();
       Ok(async move {
         let mut bundler = bundler.lock().await;
         let start = std::time::Instant::now();
         if let Some(changed_paths) = changed_paths {
-          bundler
-            .plugin_handler
-            .reload_specifiers(&changed_paths)
-            .await?;
+          bundler.reload_specifiers(&changed_paths).await?;
         }
+        let input = bundler.input.clone();
         let response = bundler.rebuild().await?;
         handle_esbuild_errors_and_warnings(
           &response,
@@ -584,6 +581,53 @@ impl EsbuildBundler {
       }
     }
   }
+
+  async fn reload_specifiers(
+    &mut self,
+    changed_paths: &[PathBuf],
+  ) -> Result<(), AnyError> {
+    self.reload_html_entrypoints(changed_paths)?;
+    self.plugin_handler.reload_specifiers(changed_paths).await?;
+    Ok(())
+  }
+
+  fn reload_html_entrypoints(
+    &mut self,
+    changed_paths: &[PathBuf],
+  ) -> Result<(), AnyError> {
+    let BundlerInput::EntrypointsWithHtml { html_pages, .. } = &mut self.input
+    else {
+      return Ok(());
+    };
+
+    if changed_paths.is_empty() {
+      return Ok(());
+    }
+
+    for page in html_pages.iter_mut() {
+      if !changed_paths
+        .iter()
+        .any(|changed| changed == &page.path || changed == &page.canonical_path)
+      {
+        continue;
+      }
+
+      let updated = html::load_html_entrypoint(&self.cwd, &page.path)?;
+      let virtual_module_url =
+        deno_path_util::url_from_file_path(&updated.virtual_module_path)?
+          .to_string();
+      self.plugin_handler.update_virtual_module(
+        &virtual_module_url,
+        VirtualModule::new(
+          updated.temp_module.as_bytes().to_vec(),
+          esbuild_client::BuiltinLoader::Js,
+        ),
+      );
+      *page = updated;
+    }
+
+    Ok(())
+  }
 }
 
 fn message_to_error(
@@ -739,6 +783,7 @@ fn requested_type_from_map(
   }
 }
 
+#[derive(Clone)]
 pub struct VirtualModule {
   contents: Vec<u8>,
   loader: esbuild_client::BuiltinLoader,
@@ -751,26 +796,26 @@ impl VirtualModule {
 }
 
 pub struct VirtualModules {
-  modules: IndexMap<String, VirtualModule>,
+  modules: RwLock<IndexMap<String, VirtualModule>>,
 }
 
 impl VirtualModules {
   pub fn new() -> Self {
     Self {
-      modules: IndexMap::new(),
+      modules: RwLock::new(IndexMap::new()),
     }
   }
 
-  pub fn insert(&mut self, path: String, contents: VirtualModule) {
-    self.modules.insert(path, contents);
+  pub fn insert(&self, path: String, contents: VirtualModule) {
+    self.modules.write().insert(path, contents);
   }
 
-  pub fn get(&self, path: &str) -> Option<&VirtualModule> {
-    self.modules.get(path)
+  pub fn get(&self, path: &str) -> Option<VirtualModule> {
+    self.modules.read().get(path).cloned()
   }
 
   pub fn contains(&self, path: &str) -> bool {
-    self.modules.contains_key(path)
+    self.modules.read().contains_key(path)
   }
 }
 
@@ -799,6 +844,12 @@ impl DenoPluginHandler {
   fn take_deferred_resolve_errors(&self) -> Vec<DeferredResolveError> {
     std::mem::take(&mut *self.deferred_resolve_errors.lock())
   }
+
+  fn update_virtual_module(&self, path: &str, module: VirtualModule) {
+    if let Some(virtual_modules) = &self.virtual_modules {
+      virtual_modules.insert(path.to_string(), module);
+    }
+  }
 }
 
 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -943,9 +994,11 @@ impl esbuild_client::PluginHandler for DenoPluginHandler {
     if let Some(virtual_modules) = &self.virtual_modules
       && let Some(module) = virtual_modules.get(&args.path)
     {
+      let contents = module.contents.clone();
+      let loader = module.loader;
       return Ok(Some(esbuild_client::OnLoadResult {
-        contents: Some(module.contents.clone()),
-        loader: Some(module.loader),
+        contents: Some(contents),
+        loader: Some(loader),
         ..Default::default()
       }));
     }

filename: tests/integration/watcher_tests.rs
patch: @@ -2057,6 +2057,87 @@ async fn bundle_watch() {
   check_alive_then_kill(child);
 }
 
+#[flaky_test(tokio)]
+async fn bundle_watch_html_entry() {
+  let _server = http_server();
+
+  let t = TempDir::new();
+  let index_html = t.path().join("index.html");
+  index_html.write(
+    r#"<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Initial Page</title>
+  </head>
+  <body>
+    <h1 id="message">Initial Content</h1>
+    <script type="module" src="./main.ts"></script>
+  </body>
+</html>
+"#,
+  );
+  let main_ts = t.path().join("main.ts");
+  main_ts.write(
+    r#"
+  const h1 = document.createElement("h1");
+  h1.textContent = "Hello, World!";
+  document.body.appendChild(h1);
+"#,
+  );
+
+  let out_dir = t.path().join("dist");
+
+  let mut child = util::deno_cmd()
+    .current_dir(t.path())
+    .arg("bundle")
+    .arg("--watch")
+    .arg("-L")
+    .arg("debug")
+    .arg("--outdir")
+    .arg(&out_dir)
+    .arg(&index_html)
+    .env("NO_COLOR", "1")
+    .envs(env_vars_for_npm_tests())
+    .piped_output()
+    .spawn()
+    .unwrap();
+  let (_, mut stderr_lines) = child_lines(&mut child);
+
+  wait_contains("Bundled", &mut stderr_lines).await;
+
+  let html_output = out_dir.join("index.html");
+  let contents = html_output.read_to_string();
+  assert_contains!(contents, "Initial Content");
+
+  wait_for_watcher("index.html", &mut stderr_lines).await;
+
+  index_html.write(
+    r#"<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Updated Page</title>
+  </head>
+  <body>
+    <h1 id="message">Updated Content</h1>
+    <p>Watching HTML works</p>
+    <script type="module" src="./main.ts"></script>
+  </body>
+</html>
+"#,
+  );
+
+  wait_contains("File change detected", &mut stderr_lines).await;
+  wait_contains("Bundled", &mut stderr_lines).await;
+
+  let contents = html_output.read_to_string();
+  assert_contains!(contents, "Updated Content");
+  assert_not_contains!(contents, "Initial Content");
+
+  check_alive_then_kill(child);
+}
+
 #[flaky_test(tokio)]
 async fn run_watch_env_file_basic() {
   let t = TempDir::new();