Skip to content

Commit b6d695b

Browse files
committed
feat(cli): implement hotreload for scss at-rules @use and @import
1 parent 75d9ece commit b6d695b

File tree

1 file changed

+118
-1
lines changed

1 file changed

+118
-1
lines changed

packages/cli/src/serve/runner.rs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use notify::{
2424
use std::{
2525
collections::{HashMap, HashSet},
2626
net::{IpAddr, TcpListener},
27-
path::PathBuf,
27+
path::{Path, PathBuf},
2828
sync::Arc,
2929
time::Duration,
3030
};
@@ -54,6 +54,7 @@ pub(crate) struct AppServer {
5454
// Tracked state related to open builds and hot reloading
5555
pub(crate) applied_client_hot_reload_message: HotReloadMsg,
5656
pub(crate) file_map: HashMap<PathBuf, CachedFile>,
57+
pub(crate) scss_indirection_map: HashMap<PathBuf, PathBuf>,
5758

5859
// Resolved args related to how we go about processing the rebuilds and logging
5960
pub(crate) use_hotpatch_engine: bool,
@@ -209,6 +210,7 @@ impl AppServer {
209210
tw_watcher,
210211
server_args,
211212
client_args,
213+
scss_indirection_map: Default::default(),
212214
};
213215

214216
// Only register the hot-reload stuff if we're watching the filesystem
@@ -223,6 +225,7 @@ impl AppServer {
223225
// really, we should be using depinfo to get the files that are actually used, but the depinfo file might not be around yet
224226
// 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
225227
runner.load_rsx_filemap();
228+
runner.load_scss_filemap();
226229
}
227230

228231
Ok(runner)
@@ -385,6 +388,37 @@ impl AppServer {
385388
continue;
386389
}
387390

391+
// Scss files may be referenced with `@use` at-rules by an asset we need to reload the parent
392+
if ext == "scss" || ext == "sass" {
393+
let Ok(new_content) = std::fs::read_to_string(path) else {
394+
tracing::debug!(
395+
"Failed to read SCSS/SASS file while hotreloading: {:?}",
396+
path
397+
);
398+
continue;
399+
};
400+
401+
let Some(parent_dir) = path.parent() else {
402+
tracing::debug!(
403+
"Failed to get parent directory of SCSS/SASS file while hotreloading: {:?}",
404+
path
405+
);
406+
continue;
407+
};
408+
409+
let uses_at_rules = extract_use_paths(parent_dir, &new_content);
410+
411+
// Aggressively insert all at-rule targets into the map,
412+
// we prevent cycles when resolving the at-rule targets in `get_scss_bundled_assets`
413+
for at_rule_target in uses_at_rules {
414+
self.scss_indirection_map
415+
.insert(at_rule_target, path.clone());
416+
}
417+
418+
let scss_bundled_assets = self.get_scss_bundled_assets(path).await;
419+
assets.extend(scss_bundled_assets);
420+
}
421+
388422
// If it's a rust file, we want to hotreload it using the filemap
389423
if ext == "rs" {
390424
// And grabout the contents
@@ -932,6 +966,21 @@ impl AppServer {
932966
}
933967
}
934968

969+
/// Load the scss indirection map.
970+
fn load_scss_filemap(&mut self) {
971+
let dir = self.client.build.crate_dir();
972+
self.fill_scss_indirection(&dir);
973+
974+
if let Some(server) = self.server.as_ref() {
975+
let dir = server.build.crate_dir();
976+
self.fill_scss_indirection(&dir);
977+
}
978+
979+
for krate in self.all_watched_crates() {
980+
self.fill_scss_indirection(&krate);
981+
}
982+
}
983+
935984
/// Fill the filemap with files from the filesystem, using the given filter to determine which files to include.
936985
///
937986
/// 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 {
9701019
}
9711020
}
9721021

1022+
/// Fill the scss indirection map with files from the filesystem, using the given filter to determine which files to include.
1023+
///
1024+
/// Scss and Sass files will be scanned for @use statements and added to the indirection map
1025+
/// so their parent could be hot reloaded.
1026+
fn fill_scss_indirection(&mut self, dir: &PathBuf) {
1027+
for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
1028+
if self
1029+
.workspace
1030+
.ignore
1031+
.matched(entry.path(), entry.file_type().is_dir())
1032+
.is_ignore()
1033+
{
1034+
continue;
1035+
}
1036+
1037+
let path = entry.path();
1038+
let ext = path.extension().and_then(|s| s.to_str());
1039+
1040+
if ext == Some("scss") || ext == Some("sass") {
1041+
if let Ok(contents) = std::fs::read_to_string(path) {
1042+
if let Some(parent_dir) = path.parent() {
1043+
let uses_at_rules = extract_use_paths(parent_dir, &contents);
1044+
for at_rule_target in uses_at_rules {
1045+
self.scss_indirection_map
1046+
.insert(at_rule_target, path.to_path_buf());
1047+
}
1048+
};
1049+
}
1050+
}
1051+
}
1052+
}
1053+
1054+
/// Get the bundled assets referencing a given SCSS file by traversing the SCSS dependency tree upward.
1055+
/// Scss and Sass may either bundled or refenced with the `@use` scss at-rule.
1056+
async fn get_scss_bundled_assets(&self, path: &Path) -> Vec<PathBuf> {
1057+
let mut current = path;
1058+
let mut assets = Vec::new();
1059+
1060+
let mut visited = HashSet::new();
1061+
1062+
while let Some(parent) = self.scss_indirection_map.get(current) {
1063+
if !visited.insert(parent) {
1064+
break;
1065+
}
1066+
1067+
if let Some(bundled_names) = self.client.hotreload_bundled_assets(parent).await {
1068+
for bundled_name in bundled_names {
1069+
assets.push(PathBuf::from("/assets/").join(bundled_name));
1070+
}
1071+
}
1072+
current = parent;
1073+
}
1074+
1075+
assets
1076+
}
1077+
9731078
/// Commit the changes to the filemap, overwriting the contents of the files
9741079
///
9751080
/// Removes any cached templates and replaces the contents of the files with the most recent
@@ -1294,3 +1399,15 @@ fn is_wsl() -> bool {
12941399

12951400
false
12961401
}
1402+
1403+
// Extract paths from @use statements in SCSS/SASS files
1404+
fn extract_use_paths(parent_dir: &Path, scss: &str) -> Vec<PathBuf> {
1405+
// Match @use "something/path.scss" or @use 'something/path' or @use "..." with or without semicolon/with options
1406+
// TODO: statically compile regex
1407+
let re = regex::Regex::new(r#"(?:@use|@import)\s+['"]([^'"]+)['"]"#).unwrap();
1408+
re.captures_iter(scss)
1409+
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
1410+
.map(PathBuf::from)
1411+
.filter_map(|path| parent_dir.join(path).canonicalize().ok())
1412+
.collect()
1413+
}

0 commit comments

Comments
 (0)