Skip to content

Commit 7c2496c

Browse files
committed
feat: implement hotreload for scss @use at-rule
1 parent 75d9ece commit 7c2496c

File tree

1 file changed

+120
-1
lines changed

1 file changed

+120
-1
lines changed

packages/cli/src/serve/runner.rs

Lines changed: 120 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,23 @@ 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+
self.fill_scss_indirection(&dir);
974+
975+
if let Some(server) = self.server.as_ref() {
976+
let dir = server.build.crate_dir();
977+
self.fill_scss_indirection(&dir);
978+
self.fill_scss_indirection(&dir);
979+
}
980+
981+
for krate in self.all_watched_crates() {
982+
self.fill_scss_indirection(&krate);
983+
}
984+
}
985+
935986
/// Fill the filemap with files from the filesystem, using the given filter to determine which files to include.
936987
///
937988
/// You can use the filter with something like a gitignore to only include files that are relevant to your project.
@@ -970,6 +1021,62 @@ impl AppServer {
9701021
}
9711022
}
9721023

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

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

0 commit comments

Comments
 (0)