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
156 changes: 153 additions & 3 deletions crates/cli/src/serve_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::channel;
use std::sync::Arc;

use std::collections::HashMap;
use std::sync::Mutex;

use colored::Colorize;
use ngc_dev_server::{DevServer, DevServerConfig, DevServerEvent, TlsConfig};
use ngc_dev_server::{
ComponentUpdates, DevServer, DevServerConfig, DevServerEvent, TlsConfig, HMR_RUNTIME_PRELUDE,
};
use ngc_diagnostics::{NgcError, NgcResult};
use ngc_watch::{Watcher, WatcherConfig};

Expand All @@ -40,6 +45,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 +59,7 @@ pub fn run(
ssl,
ssl_key,
ssl_cert,
hmr_override,
install_ctrlc,
)
}
Expand Down Expand Up @@ -121,13 +128,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 All @@ -152,14 +179,26 @@ pub(crate) fn run_with_stop(
initial.output_files.len(),
);

// Shared registry of per-component HMR update modules served at
// `/@ng/component`. Empty until the compiler emits update modules; we
// share one handle between the dev server and the rebuild callback.
let component_updates: ComponentUpdates = Arc::new(Mutex::new(HashMap::new()));

// When HMR is on, bind `import.meta.hot` inside the entry module so the
// per-component initializers can register update handlers.
if hmr_enabled {
inject_hmr_runtime(&out_dir);
}

let (event_tx, event_rx) = channel::<DevServerEvent>();
let cfg = DevServerConfig::new(&out_dir)
.with_host(host.to_string())
.with_port(port)
.with_serve_path(normalized_serve_path.as_deref())
.with_allowed_hosts(allowed_hosts.iter().cloned())
.with_headers(headers.iter().cloned())
.with_tls(tls);
.with_tls(tls)
.with_component_updates(Arc::clone(&component_updates));
let server = DevServer::start(cfg, event_rx)?;
let scheme = server.scheme();
let url = match server.serve_path() {
Expand All @@ -185,6 +224,10 @@ 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();
let out_dir_owned = out_dir.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 +252,23 @@ pub(crate) fn run_with_stop(
result.modules_bundled,
dirty.len()
);
if event_tx.send(DevServerEvent::Reload).is_err() {
// Re-bind `import.meta.hot` in the freshly written entry chunk.
if hmr_enabled {
inject_hmr_runtime(&out_dir_owned);
}
// 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 +308,55 @@ 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())
}

/// Prepend the HMR runtime prelude to the entry chunk (`main.js`) so the
/// per-component HMR initializers can resolve `import.meta.hot`. The build
/// rewrites `main.js` from scratch each cycle, so this runs after every
/// successful build. A guard skips the work if the prelude is already present
/// (defensive — a fresh build never has it). Failures are logged and ignored:
/// a missing entry chunk just means HMR initializers won't bind, which
/// degrades to live reload rather than breaking the served app.
fn inject_hmr_runtime(out_dir: &Path) {
let main_js = out_dir.join("main.js");
let existing = match std::fs::read_to_string(&main_js) {
Ok(s) => s,
Err(e) => {
tracing::debug!(path = %main_js.display(), error = %e, "no entry chunk to inject HMR runtime into");
return;
}
};
if existing.starts_with(HMR_RUNTIME_PRELUDE) {
return;
}
let mut patched = String::with_capacity(HMR_RUNTIME_PRELUDE.len() + existing.len());
patched.push_str(HMR_RUNTIME_PRELUDE);
patched.push_str(&existing);
if let Err(e) = std::fs::write(&main_js, patched) {
tracing::debug!(path = %main_js.display(), error = %e, "could not inject HMR runtime");
}
}

/// 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 +557,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
Loading