diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 5ea225f51d..78e1ffa1a3 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -807,8 +807,8 @@ impl AppBuilder { // If the emulator is android, we need to copy the asset to the device with `adb push asset /data/local/tmp/dx/assets/filename.ext` if self.build.bundle == BundleFormat::Android { - _ = self - .copy_file_to_android_tmp(&changed_file, &bundled_name) + let _ = self + .copy_file_to_android_tmp(&output_path, &bundled_name) .await; } bundled_names.push(bundled_name); @@ -828,7 +828,7 @@ impl AppBuilder { bundled_name: &Path, ) -> Result { let target = dioxus_cli_config::android_session_cache_dir().join(bundled_name); - tracing::debug!("Pushing asset to device: {target:?}"); + tracing::debug!("Pushing asset {changed_file:?} to device: {target:?}"); let res = Command::new(&self.build.workspace.android_tools()?.adb) .arg("push") diff --git a/packages/cli/src/serve/runner.rs b/packages/cli/src/serve/runner.rs index 4d06ae7131..c41b90d8e8 100644 --- a/packages/cli/src/serve/runner.rs +++ b/packages/cli/src/serve/runner.rs @@ -24,7 +24,7 @@ use notify::{ use std::{ collections::{HashMap, HashSet}, net::{IpAddr, TcpListener}, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, time::Duration, }; @@ -54,6 +54,7 @@ pub(crate) struct AppServer { // Tracked state related to open builds and hot reloading pub(crate) applied_client_hot_reload_message: HotReloadMsg, pub(crate) file_map: HashMap, + pub(crate) scss_indirection_map: HashMap, // Resolved args related to how we go about processing the rebuilds and logging pub(crate) use_hotpatch_engine: bool, @@ -209,6 +210,7 @@ impl AppServer { tw_watcher, server_args, client_args, + scss_indirection_map: Default::default(), }; // Only register the hot-reload stuff if we're watching the filesystem @@ -223,6 +225,7 @@ impl AppServer { // really, we should be using depinfo to get the files that are actually used, but the depinfo file might not be around yet // todo(jon): see if we can just guess the depinfo file before it generates. might be stale but at least it catches most of the files runner.load_rsx_filemap(); + runner.load_scss_filemap(); } Ok(runner) @@ -385,6 +388,37 @@ impl AppServer { continue; } + // Scss files may be referenced with `@use` at-rules by an asset we need to reload the parent + if ext == "scss" || ext == "sass" { + let Ok(new_content) = std::fs::read_to_string(path) else { + tracing::debug!( + "Failed to read SCSS/SASS file while hotreloading: {:?}", + path + ); + continue; + }; + + let Some(parent_dir) = path.parent() else { + tracing::debug!( + "Failed to get parent directory of SCSS/SASS file while hotreloading: {:?}", + path + ); + continue; + }; + + let uses_at_rules = extract_use_paths(parent_dir, &new_content); + + // Aggressively insert all at-rule targets into the map, + // we prevent cycles when resolving the at-rule targets in `get_scss_bundled_assets` + for at_rule_target in uses_at_rules { + self.scss_indirection_map + .insert(at_rule_target, path.clone()); + } + + let scss_bundled_assets = self.get_scss_bundled_assets(path).await; + assets.extend(scss_bundled_assets); + } + // If it's a rust file, we want to hotreload it using the filemap if ext == "rs" { // And grabout the contents @@ -932,6 +966,21 @@ impl AppServer { } } + /// Load the scss indirection map. + fn load_scss_filemap(&mut self) { + let dir = self.client.build.crate_dir(); + self.fill_scss_indirection(&dir); + + if let Some(server) = self.server.as_ref() { + let dir = server.build.crate_dir(); + self.fill_scss_indirection(&dir); + } + + for krate in self.all_watched_crates() { + self.fill_scss_indirection(&krate); + } + } + /// Fill the filemap with files from the filesystem, using the given filter to determine which files to include. /// /// You can use the filter with something like a gitignore to only include files that are relevant to your project. @@ -970,6 +1019,62 @@ impl AppServer { } } + /// Fill the scss indirection map with files from the filesystem, using the given filter to determine which files to include. + /// + /// Scss and Sass files will be scanned for @use statements and added to the indirection map + /// so their parent could be hot reloaded. + fn fill_scss_indirection(&mut self, dir: &PathBuf) { + for entry in walkdir::WalkDir::new(dir).into_iter().flatten() { + if self + .workspace + .ignore + .matched(entry.path(), entry.file_type().is_dir()) + .is_ignore() + { + continue; + } + + let path = entry.path(); + let ext = path.extension().and_then(|s| s.to_str()); + + if ext == Some("scss") || ext == Some("sass") { + if let Ok(contents) = std::fs::read_to_string(path) { + if let Some(parent_dir) = path.parent() { + let uses_at_rules = extract_use_paths(parent_dir, &contents); + for at_rule_target in uses_at_rules { + self.scss_indirection_map + .insert(at_rule_target, path.to_path_buf()); + } + }; + } + } + } + } + + /// Get the bundled assets referencing a given SCSS file by traversing the SCSS dependency tree upward. + /// Scss and Sass may either bundled or refenced with the `@use` scss at-rule. + async fn get_scss_bundled_assets(&self, path: &Path) -> Vec { + let mut current = path; + let mut assets = Vec::new(); + + let mut visited = HashSet::new(); + + while let Some(parent) = self.scss_indirection_map.get(current) { + if !visited.insert(parent) { + break; + } + + if let Some(bundled_names) = self.client.hotreload_bundled_assets(parent).await { + for bundled_name in bundled_names { + assets.push(PathBuf::from("/assets/").join(bundled_name)); + } + } + current = parent; + } + + assets + } + /// Commit the changes to the filemap, overwriting the contents of the files /// /// Removes any cached templates and replaces the contents of the files with the most recent @@ -1294,3 +1399,15 @@ fn is_wsl() -> bool { false } + +// Extract paths from @use statements in SCSS/SASS files +fn extract_use_paths(parent_dir: &Path, scss: &str) -> Vec { + // Match @use "something/path.scss" or @use 'something/path' or @use "..." with or without semicolon/with options + // TODO: statically compile regex + let re = regex::Regex::new(r#"(?:@use|@import)\s+['"]([^'"]+)['"]"#).unwrap(); + re.captures_iter(scss) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .map(PathBuf::from) + .filter_map(|path| parent_dir.join(path).canonicalize().ok()) + .collect() +}