diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 44c41cc..11e5d9c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -279,6 +279,17 @@ enum Commands { /// `@angular/build:dev-server`. #[arg(long = "ssl-cert")] ssl_cert: Option, + /// 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 @@ -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, @@ -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), @@ -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); @@ -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> { diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index f8465d7..d505762 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -40,6 +40,7 @@ pub fn run( ssl: bool, ssl_key: Option<&Path>, ssl_cert: Option<&Path>, + hmr_override: Option, ) -> NgcResult<()> { run_with_stop( project, @@ -53,6 +54,7 @@ pub fn run( ssl, ssl_key, ssl_cert, + hmr_override, install_ctrlc, ) } @@ -121,6 +123,7 @@ pub(crate) fn run_with_stop( ssl: bool, ssl_key: Option<&Path>, ssl_cert: Option<&Path>, + hmr_override: Option, install_stop: impl FnOnce(Arc), ) -> NgcResult<()> { let tls = resolve_tls(ssl, ssl_key, ssl_cert, host)?; @@ -128,6 +131,25 @@ pub(crate) fn run_with_stop( 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 = 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 `` fallback agree on the // canonical `/foo/` form (see `ngc_dev_server::normalize_serve_path`). @@ -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)) { @@ -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(()) @@ -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, +) -> bool { + !dirty.is_empty() + && dirty + .iter() + .all(|p| global_style_paths.contains(&canonical_or_owned(p))) +} + fn error_location(err: &NgcError) -> (Option, Option, Option) { match err { NgcError::ParseError { @@ -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::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 { diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index 890af2d..b1e2058 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -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` `` 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. @@ -695,6 +705,9 @@ fn fanout_loop(rx: Receiver, 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, @@ -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#""#; +pub const LIVE_RELOAD_SCRIPT: &str = r#""#; /// Insert the live-reload client script into an HTML byte buffer. /// @@ -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 { diff --git a/crates/project-resolver/src/angular_json.rs b/crates/project-resolver/src/angular_json.rs index 3f84d8b..3549abb 100644 --- a/crates/project-resolver/src/angular_json.rs +++ b/crates/project-resolver/src/angular_json.rs @@ -93,6 +93,32 @@ pub enum RawLocaleEntry { pub struct RawArchitect { /// Build target configuration. pub build: Option, + /// Serve (dev-server) target configuration. Only the options ngc-rs + /// honours are modelled — currently just `hmr`. + pub serve: Option, +} + +/// A serve target (`@angular/build:dev-server`) with default options and +/// named configurations. Only the subset ngc-rs reads is modelled. +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RawServeTarget { + /// Default serve options. + pub options: Option, + /// Named configurations (e.g. "production", "development"). + pub configurations: Option>, + /// Default configuration name used when none is specified. + pub default_configuration: Option, +} + +/// Serve options from `architect.serve.options` (or a per-configuration +/// block). Only `hmr` is honoured today. +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RawServeOptions { + /// Enable Hot Module Replacement. When absent, ngc-rs defaults to `false` + /// (full-reload live reload). + pub hmr: Option, } /// A build target with default options and named configurations. @@ -532,6 +558,11 @@ pub struct ResolvedAngularProject { /// is by exact name or `/...` prefix, mirroring how /// `@angular/build:application` (esbuild) treats package externals. pub external_dependencies: Vec, + /// Resolved `architect.serve.options.hmr` (with the active + /// configuration's override layered on top). `false` when absent — + /// matching ngc-rs's default of full-reload live reload. The CLI + /// `--hmr`/`--no-hmr` flag takes precedence over this value. + pub hmr: bool, } /// Type of a resolved size budget. @@ -840,6 +871,22 @@ pub fn resolve_angular_project( .or_else(|| options.and_then(|o| o.external_dependencies.clone())) .unwrap_or_default(); + // Resolve serve `hmr`: base serve options, with the active + // configuration's serve override layered on top when present. Absent → + // `false` (full-reload live reload). The serve target reuses the same + // configuration name as the build (matching `ng serve -c `). + let serve_target = project.architect.as_ref().and_then(|a| a.serve.as_ref()); + let serve_options = serve_target.and_then(|st| st.options.as_ref()); + let serve_config = config_name.as_deref().and_then(|cn| { + serve_target + .and_then(|st| st.configurations.as_ref()) + .and_then(|configs| configs.get(cn)) + }); + let hmr = serve_config + .and_then(|sc| sc.hmr) + .or_else(|| serve_options.and_then(|o| o.hmr)) + .unwrap_or(false); + debug!( project = %name, output_path = %output_path.display(), @@ -872,6 +919,7 @@ pub fn resolve_angular_project( define, scripts, external_dependencies, + hmr, }) } @@ -1106,6 +1154,61 @@ mod tests { assert!(result.ts_config.ends_with("tsconfig.app.json")); } + #[test] + fn test_hmr_defaults_to_false_when_no_serve_target() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { "options": { "tsConfig": "tsconfig.json" } } + } + } + } + }"#; + let f = write_temp_json(json); + let result = resolve_angular_project(f.path(), None, None).unwrap(); + assert!(!result.hmr); + } + + #[test] + fn test_hmr_read_from_serve_options() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { "options": { "tsConfig": "tsconfig.json" } }, + "serve": { "options": { "hmr": true } } + } + } + } + }"#; + let f = write_temp_json(json); + let result = resolve_angular_project(f.path(), None, None).unwrap(); + assert!(result.hmr); + } + + #[test] + fn test_hmr_serve_configuration_overrides_base() { + let json = r#"{ + "projects": { + "app": { + "architect": { + "build": { "options": { "tsConfig": "tsconfig.json" } }, + "serve": { + "options": { "hmr": false }, + "configurations": { "development": { "hmr": true } } + } + } + } + } + }"#; + let f = write_temp_json(json); + let base = resolve_angular_project(f.path(), None, None).unwrap(); + assert!(!base.hmr, "base serve options keep hmr false"); + let dev = resolve_angular_project(f.path(), None, Some("development")).unwrap(); + assert!(dev.hmr, "development configuration overrides hmr to true"); + } + #[test] fn test_parse_object_output_path() { let json = r#"{