Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,17 @@ enum Commands {
/// `@angular/build:dev-server`.
#[arg(long = "ssl-cert")]
ssl_cert: Option<PathBuf>,
/// Enable Hot Module Replacement: edits to component templates and
/// styles (and global stylesheets) are applied in place without a
/// full page reload, preserving component and form state. Overrides
/// `architect.serve.options.hmr` in `angular.json`. Mirrors the `hmr`
/// option of `@angular/build:dev-server`.
#[arg(long, conflicts_with = "no_hmr")]
hmr: bool,
/// Disable Hot Module Replacement, forcing a full page reload on every
/// rebuild. Overrides `architect.serve.options.hmr` in `angular.json`.
#[arg(long = "no-hmr", conflicts_with = "hmr")]
no_hmr: bool,
},
/// Extract translatable messages from every component template in the
/// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2
Expand Down Expand Up @@ -410,6 +421,8 @@ fn main() {
ssl,
ssl_key,
ssl_cert,
hmr,
no_hmr,
} => {
let parsed_headers = match parse_header_overrides(headers.as_deref()) {
Ok(h) => h,
Expand All @@ -418,6 +431,15 @@ fn main() {
process::exit(1);
}
};
// CLI flags win over angular.json: `--hmr` → Some(true),
// `--no-hmr` → Some(false), neither → None (inherit config).
let hmr_override = if hmr {
Some(true)
} else if no_hmr {
Some(false)
} else {
None
};
if let Err(e) = serve_cmd::run(
&project,
Some(&configuration),
Expand All @@ -430,6 +452,7 @@ fn main() {
ssl,
ssl_key.as_deref(),
ssl_cert.as_deref(),
hmr_override,
) {
eprintln!("{} {e}", "Error:".red().bold());
process::exit(1);
Expand Down Expand Up @@ -1883,7 +1906,7 @@ pub(crate) fn resolve_out_dir(
}

/// Try to find angular.json by searching upward from the project file's directory.
fn find_and_resolve_angular_json(
pub(crate) fn find_and_resolve_angular_json(
project: &Path,
configuration: Option<&str>,
) -> NgcResult<Option<ResolvedAngularProject>> {
Expand Down
103 changes: 102 additions & 1 deletion crates/cli/src/serve_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub fn run(
ssl: bool,
ssl_key: Option<&Path>,
ssl_cert: Option<&Path>,
hmr_override: Option<bool>,
) -> NgcResult<()> {
run_with_stop(
project,
Expand All @@ -53,6 +54,7 @@ pub fn run(
ssl,
ssl_key,
ssl_cert,
hmr_override,
install_ctrlc,
)
}
Expand Down Expand Up @@ -121,13 +123,33 @@ pub(crate) fn run_with_stop(
ssl: bool,
ssl_key: Option<&Path>,
ssl_cert: Option<&Path>,
hmr_override: Option<bool>,
install_stop: impl FnOnce(Arc<AtomicBool>),
) -> NgcResult<()> {
let tls = resolve_tls(ssl, ssl_key, ssl_cert, host)?;

let out_dir = crate::resolve_out_dir(project, None, configuration)?;
let mut cache = BuildCache::new();

// Resolve HMR: CLI `--hmr`/`--no-hmr` wins; otherwise inherit
// `architect.serve.options.hmr` from angular.json (default `false`).
// Also collect the absolute paths of the global stylesheet entries so a
// rebuild that touches only those can be classified as a CSS-only update.
let resolved = crate::find_and_resolve_angular_json(project, configuration)?;
let hmr_enabled = hmr_override.unwrap_or_else(|| resolved.as_ref().map(|p| p.hmr).unwrap_or(false));
let global_style_paths: std::collections::HashSet<PathBuf> = resolved
.as_ref()
.map(|p| {
p.styles
.iter()
.map(|s| canonical_or_owned(&s.path))
.collect()
})
.unwrap_or_default();
if hmr_enabled {
eprintln!("{}", "ngc-rs HMR enabled".bold().green());
}

// Normalize the user-supplied servePath up-front so the dev server
// mount and the index.html `<base href>` fallback agree on the
// canonical `/foo/` form (see `ngc_dev_server::normalize_serve_path`).
Expand Down Expand Up @@ -185,6 +207,9 @@ pub(crate) fn run_with_stop(
let project_path = project.to_path_buf();
let configuration_owned = configuration.map(|s| s.to_string());
let serve_path_owned = normalized_serve_path.clone();
// Monotonic cache-buster for the swapped `styles.css` href on CSS-only
// updates; must change every rebuild so the browser re-fetches.
let mut hmr_tick: u64 = 0;

let build_fn = move |dirty: &[PathBuf]| -> NgcResult<()> {
if dirty.iter().any(|p| !is_ts_path(p)) {
Expand All @@ -209,7 +234,19 @@ pub(crate) fn run_with_stop(
result.modules_bundled,
dirty.len()
);
if event_tx.send(DevServerEvent::Reload).is_err() {
// CSS-only fast path: when HMR is on and every changed file is
// a global stylesheet entry, swap `styles.css` in place
// instead of reloading (preserving component/form state).
let css_only = hmr_enabled && is_global_css_only_change(dirty, &global_style_paths);
let event = if css_only {
hmr_tick += 1;
DevServerEvent::CssUpdate {
timestamp: hmr_tick,
}
} else {
DevServerEvent::Reload
};
if event_tx.send(event).is_err() {
tracing::debug!("dev server event channel closed");
}
Ok(())
Expand Down Expand Up @@ -249,6 +286,28 @@ pub(crate) fn build_failure_event(err: &NgcError) -> DevServerEvent {
}
}

/// Canonicalize `path`, falling back to its owned form when canonicalization
/// fails (e.g. the file was deleted between resolve and compare). Used so the
/// watcher's emitted paths and the resolved style paths compare equal even
/// across symlinks.
fn canonical_or_owned(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

/// True when a rebuild's `dirty` set is non-empty and every changed file is a
/// global stylesheet entry — the case where HMR can swap `styles.css` in place
/// instead of reloading. An empty set (or any non-stylesheet change) returns
/// `false`, falling back to a full reload.
fn is_global_css_only_change(
dirty: &[PathBuf],
global_style_paths: &std::collections::HashSet<PathBuf>,
) -> bool {
!dirty.is_empty()
&& dirty
.iter()
.all(|p| global_style_paths.contains(&canonical_or_owned(p)))
}

fn error_location(err: &NgcError) -> (Option<PathBuf>, Option<u32>, Option<u32>) {
match err {
NgcError::ParseError {
Expand Down Expand Up @@ -449,6 +508,48 @@ mod tests {
assert!(matches!(err, NgcError::Io { .. }));
}

#[test]
fn css_only_change_classification() {
use std::collections::HashSet;
let styles: HashSet<PathBuf> = [PathBuf::from("/proj/src/styles.css"), PathBuf::from("/proj/src/theme.scss")]
.into_iter()
.collect();

// All dirty files are global stylesheets → CSS-only.
assert!(is_global_css_only_change(
&[PathBuf::from("/proj/src/styles.css")],
&styles
));
assert!(is_global_css_only_change(
&[
PathBuf::from("/proj/src/styles.css"),
PathBuf::from("/proj/src/theme.scss")
],
&styles
));

// A non-stylesheet change (or a stylesheet not in the global set)
// forces a full reload.
assert!(!is_global_css_only_change(
&[PathBuf::from("/proj/src/app.component.ts")],
&styles
));
assert!(!is_global_css_only_change(
&[
PathBuf::from("/proj/src/styles.css"),
PathBuf::from("/proj/src/app.component.ts")
],
&styles
));
assert!(!is_global_css_only_change(
&[PathBuf::from("/proj/src/app.component.css")],
&styles
));

// Empty dirty set never qualifies.
assert!(!is_global_css_only_change(&[], &styles));
}

#[test]
fn build_failure_event_omits_path_for_pathless_errors() {
let err = NgcError::ServeError {
Expand Down
47 changes: 46 additions & 1 deletion crates/dev-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,20 @@ use tiny_http::{Header, Method, Response, Server, SslConfig, StatusCode};
///
/// * [`DevServerEvent::Reload`] → `event: reload`
/// * [`DevServerEvent::BuildFailed`] → `event: build-failed`
/// * [`DevServerEvent::CssUpdate`] → `event: css-update`
#[derive(Debug, Clone)]
pub enum DevServerEvent {
/// A successful rebuild — connected browsers should refresh the page.
Reload,
/// A successful rebuild that only changed global stylesheet(s). HMR
/// clients swap the `styles.css` `<link>` in place (cache-busting with
/// `timestamp`) without reloading the page, preserving component and
/// form state. Only emitted when HMR is enabled; otherwise a plain
/// [`DevServerEvent::Reload`] is sent.
CssUpdate {
/// Monotonic cache-buster appended to the swapped stylesheet href.
timestamp: u64,
},
/// A rebuild failed — connected browsers should display an error
/// overlay with the message and (when available) the offending file
/// and source coordinates.
Expand Down Expand Up @@ -695,6 +705,9 @@ fn fanout_loop(rx: Receiver<DevServerEvent>, clients: SseClients) {
pub fn sse_frame(event: &DevServerEvent) -> String {
match event {
DevServerEvent::Reload => "event: reload\ndata: rebuild\n\n".to_string(),
DevServerEvent::CssUpdate { timestamp } => {
format!("event: css-update\ndata: {{\"timestamp\":{timestamp}}}\n\n")
}
DevServerEvent::BuildFailed {
message,
file,
Expand Down Expand Up @@ -1074,7 +1087,7 @@ pub fn mime_for(path: &Path) -> &'static str {
/// Malformed `data:` payloads (non-JSON, missing keys) are tolerated and
/// fall back to a generic "build failed" message rather than crashing the
/// listener.
pub const LIVE_RELOAD_SCRIPT: &str = r#"<script>(function(){try{var ID='__ngc_rs_overlay__';function dismiss(){var n=document.getElementById(ID);if(n){n.remove();}window.__ngcRsOverlay=null;}function show(payload){dismiss();var data={};try{data=JSON.parse(payload)||{};}catch(_){}var msg=typeof data.message==='string'&&data.message?data.message:'ngc-rs rebuild failed';var loc='';if(typeof data.file==='string'&&data.file){loc=data.file;if(typeof data.line==='number'){loc+=':'+data.line;if(typeof data.column==='number'){loc+=':'+data.column;}}}var overlay=document.createElement('div');overlay.id=ID;overlay.setAttribute('role','alert');overlay.style.cssText='position:fixed;inset:0;z-index:2147483647;background:rgba(20,20,20,0.92);color:#ff6b6b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.5;padding:32px;overflow:auto;white-space:pre-wrap;word-break:break-word;';var header=document.createElement('div');header.textContent='ngc-rs build failed';header.style.cssText='font-weight:bold;font-size:16px;margin-bottom:16px;color:#ff8a8a;';overlay.appendChild(header);if(loc){var locEl=document.createElement('div');locEl.textContent=loc;locEl.style.cssText='color:#ffd166;margin-bottom:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';overlay.appendChild(locEl);}var body=document.createElement('pre');body.textContent=msg;body.style.cssText='margin:0;color:#ff6b6b;white-space:pre-wrap;word-break:break-word;';overlay.appendChild(body);var hint=document.createElement('div');hint.textContent='Press Esc to dismiss · overlay reappears on next failed rebuild';hint.style.cssText='margin-top:24px;color:#888;font-size:12px;';overlay.appendChild(hint);(document.body||document.documentElement).appendChild(overlay);window.__ngcRsOverlay=overlay;}function onKey(e){if(e.key==='Escape'){dismiss();}}document.addEventListener('keydown',onKey);var s=new EventSource('/__ngc_reload');s.addEventListener('reload',function(){dismiss();location.reload();});s.addEventListener('build-failed',function(e){show(e.data);});}catch(e){console.warn('[ngc-rs] live reload unavailable',e);}})();</script>"#;
pub const LIVE_RELOAD_SCRIPT: &str = r#"<script>(function(){try{var ID='__ngc_rs_overlay__';function dismiss(){var n=document.getElementById(ID);if(n){n.remove();}window.__ngcRsOverlay=null;}function show(payload){dismiss();var data={};try{data=JSON.parse(payload)||{};}catch(_){}var msg=typeof data.message==='string'&&data.message?data.message:'ngc-rs rebuild failed';var loc='';if(typeof data.file==='string'&&data.file){loc=data.file;if(typeof data.line==='number'){loc+=':'+data.line;if(typeof data.column==='number'){loc+=':'+data.column;}}}var overlay=document.createElement('div');overlay.id=ID;overlay.setAttribute('role','alert');overlay.style.cssText='position:fixed;inset:0;z-index:2147483647;background:rgba(20,20,20,0.92);color:#ff6b6b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.5;padding:32px;overflow:auto;white-space:pre-wrap;word-break:break-word;';var header=document.createElement('div');header.textContent='ngc-rs build failed';header.style.cssText='font-weight:bold;font-size:16px;margin-bottom:16px;color:#ff8a8a;';overlay.appendChild(header);if(loc){var locEl=document.createElement('div');locEl.textContent=loc;locEl.style.cssText='color:#ffd166;margin-bottom:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';overlay.appendChild(locEl);}var body=document.createElement('pre');body.textContent=msg;body.style.cssText='margin:0;color:#ff6b6b;white-space:pre-wrap;word-break:break-word;';overlay.appendChild(body);var hint=document.createElement('div');hint.textContent='Press Esc to dismiss · overlay reappears on next failed rebuild';hint.style.cssText='margin-top:24px;color:#888;font-size:12px;';overlay.appendChild(hint);(document.body||document.documentElement).appendChild(overlay);window.__ngcRsOverlay=overlay;}function onKey(e){if(e.key==='Escape'){dismiss();}}document.addEventListener('keydown',onKey);function swapCss(t){var links=document.querySelectorAll('link[rel="stylesheet"]');for(var i=0;i < links.length;i++){(function(link){var href=link.getAttribute('href');if(!href){return;}var base=href.split('?')[0];if(!/(^|\/)styles\.css$/.test(base)){return;}var next=link.cloneNode(false);next.setAttribute('href',base+'?ngcss='+t);next.addEventListener('load',function(){if(link.parentNode){link.parentNode.removeChild(link);}});next.addEventListener('error',function(){if(next.parentNode){next.parentNode.removeChild(next);}});link.parentNode.insertBefore(next,link.nextSibling);})(links[i]);}}var s=new EventSource('/__ngc_reload');s.addEventListener('reload',function(){dismiss();location.reload();});s.addEventListener('build-failed',function(e){show(e.data);});s.addEventListener('css-update',function(e){var t=0;try{t=(JSON.parse(e.data)||{}).timestamp||0;}catch(_){}if(!t){t=(new Date()).getTime();}dismiss();swapCss(t);});}catch(e){console.warn('[ngc-rs] live reload unavailable',e);}})();</script>"#;

/// Insert the live-reload client script into an HTML byte buffer.
///
Expand Down Expand Up @@ -1246,6 +1259,38 @@ mod tests {
);
}

#[test]
fn sse_frame_for_css_update_emits_named_event_with_timestamp() {
let frame = sse_frame(&DevServerEvent::CssUpdate { timestamp: 7 });
assert!(frame.starts_with("event: css-update\n"));
let data_line = frame.lines().nth(1).expect("data line");
let json: serde_json::Value = serde_json::from_str(
data_line.strip_prefix("data: ").expect("data: prefix"),
)
.expect("css-update payload is JSON");
assert_eq!(json["timestamp"], 7);
assert!(frame.ends_with("\n\n"));
}

#[test]
fn live_reload_script_handles_css_update_in_place() {
// The injected client must subscribe to `css-update` and swap the
// global styles.css link instead of reloading the page.
assert!(LIVE_RELOAD_SCRIPT.contains("addEventListener('css-update'"));
assert!(LIVE_RELOAD_SCRIPT.contains("function swapCss"));
assert!(LIVE_RELOAD_SCRIPT.contains("styles\\.css"));
// CSS updates must not trigger a full reload.
let after_css = LIVE_RELOAD_SCRIPT
.split("addEventListener('css-update'")
.nth(1)
.expect("css-update handler present");
let handler_body = after_css.split("});").next().unwrap_or("");
assert!(
!handler_body.contains("location.reload"),
"css-update handler must not reload the page"
);
}

#[test]
fn sse_frame_for_build_failed_emits_named_event_with_json_payload() {
let event = DevServerEvent::BuildFailed {
Expand Down
Loading