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
185 changes: 178 additions & 7 deletions crates/oxc_angular_compiler/src/component/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,39 @@ use super::dependency::R3DependencyMetadata;
use crate::directive::R3InputMetadata;
use crate::output::ast::OutputExpression;

/// Angular prerelease tag (`-next.N` / `-rc.N` in the published version).
///
/// Ordering follows Angular's release cadence: `next.N` builds come off
/// `main` weekly, `rc.N` builds are cut from the release branch later, so
/// `Next` < `Rc` < release. Unrecognized tags map to [`Prerelease::Other`],
/// which sorts below both so an unknown suffix is treated as earlier than
/// any published prerelease of the same `major.minor.patch`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Prerelease {
/// Any prerelease tag that is not `next.N` or `rc.N`.
Other,
/// A `next.N` (main-branch snapshot) prerelease.
Next(u32),
/// An `rc.N` (release candidate) prerelease.
Rc(u32),
}

impl Prerelease {
/// Parse a prerelease tag like `next.0` or `rc.1`.
///
/// Unknown tags are kept as [`Prerelease::Other`] rather than dropped so
/// they still sort below the release of the same triple.
pub fn parse(tag: &str) -> Self {
let (channel, ordinal) = tag.split_once('.').unwrap_or((tag, "0"));
let ordinal: u32 = ordinal.parse().unwrap_or(0);
match channel {
"next" => Self::Next(ordinal),
"rc" => Self::Rc(ordinal),
_ => Self::Other,
}
}
}

/// Angular version information for feature detection.
///
/// Used to determine version-conditional behavior like the default value
Expand All @@ -22,12 +55,21 @@ pub struct AngularVersion {
pub minor: u32,
/// Patch version number (e.g., 0 for Angular 19.0.0).
pub patch: u32,
/// Prerelease tag (e.g., `Next(0)` for `22.3.0-next.0`), if any.
pub prerelease: Option<Prerelease>,
}

impl AngularVersion {
/// Create a new AngularVersion.
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self { major, minor, patch }
/// Create a new AngularVersion for a stable release.
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
Self { major, minor, patch, prerelease: None }
}

/// Set the prerelease tag (e.g., `Prerelease::Next(0)` for `-next.0`).
#[must_use]
pub fn with_prerelease(mut self, prerelease: Prerelease) -> Self {
self.prerelease = Some(prerelease);
self
}

/// Check if this version supports implicit standalone default (v19.0.0+).
Expand Down Expand Up @@ -159,24 +201,89 @@ impl AngularVersion {
false
}

/// Check if this version is `major.minor.patch` or newer.
///
/// Compares the release triple only: a prerelease of `22.3.0` counts as
/// `>= 22.2.2` even though it predates the `22.3.0` stable. Callers gating
/// on a fix that landed mid-`next` cycle must compare the stored
/// prerelease against the cutoff explicitly (see
/// [`AngularVersion::at_22_2_2_cutoff_or_later`]).
pub fn is_at_least(&self, major: u32, minor: u32, patch: u32) -> bool {
(self.major, self.minor, self.patch) >= (major, minor, patch)
}

/// True when the fixes shipped in `22.2.2` / `22.3.0-next.1` apply.
///
/// Every prerelease of `22.3.0` before `next.1` — `next.0`, plus any
/// unrecognized tag which parses to [`Prerelease::Other`] and sorts below
/// `next`/`rc` — was cut before the fixes landed on `main`, so it must
/// compare strictly below the `next.1` cutoff rather than be excluded by
/// ordinal. [`AngularVersion::Rc`] sorts after `Next`, so `rc` builds
/// take the fixed path.
fn at_22_2_2_cutoff_or_later(&self) -> bool {
if self.major == 22 && self.minor == 3 && self.patch == 0 {
return match self.prerelease {
None => true,
Some(prerelease) => prerelease >= Prerelease::Next(1),
};
}
self.is_at_least(22, 2, 2)
}

/// Check if this version drops `img|src` and `video|src` from the URL
/// security context (v22.2.2+ / v22.3.0-next.1+).
///
/// `angular/angular@439ff1bdf4` ("refactor(compiler): remove img/video src
/// attribute from the URL security context") landed only on the v22 line,
/// so a bound `src` on `<img>`/`<video>` no longer receives
/// `ɵɵsanitizeUrl`. The 19.x/20.x/21.x security backports kept the keys.
/// `22.3.0-next.0` and every earlier `22.3.0` prerelease are excluded:
/// they sort after `22.2.2` by triple but were cut before the fix landed
/// on `main` (first in `v22.3.0-next.1`).
pub fn drops_img_video_src_url_context(&self) -> bool {
self.at_22_2_2_cutoff_or_later()
}

/// Check if this version strips the `:svg:`/`:math:` namespace prefix from
/// the single-root tag passed to `ɵɵconditionalCreate` /
/// `ɵɵconditionalBranchCreate` / `ɵɵrepeaterCreate` (v22.2.2+ /
/// v22.3.0-next.1+).
///
/// `angular/angular@0244a1cb3` ("fix(compiler): match namespaced root nodes
/// of control flow blocks against ng-content tag selectors") was never
/// backported to the 21.x/20.x/19.x lines. `22.3.0-next.0` and every
/// earlier `22.3.0` prerelease are excluded: they sort after `22.2.2` by
/// triple but were cut before the fix landed on `main` (first in
/// `v22.3.0-next.1`).
pub fn strips_namespaced_block_tag(&self) -> bool {
self.at_22_2_2_cutoff_or_later()
}

/// Parse a version string like "19.0.0" or "19.0.0-rc.1".
///
/// Returns `None` if the version string is invalid.
/// The `-next.N` / `-rc.N` prerelease tag is retained; any other suffix
/// parses to [`Prerelease::Other`]. Returns `None` if the version string
/// is invalid.
pub fn parse(version_str: &str) -> Option<Self> {
// Handle placeholder version (means latest/head)
if version_str == "0.0.0-PLACEHOLDER" {
return Some(Self::new(u32::MAX, 0, 0)); // Treat as latest
}

// Strip any prerelease suffix: "19.0.0-rc.1" -> "19.0.0"
let base_version = version_str.split('-').next()?;
// "19.0.0-rc.1" -> base "19.0.0", prerelease tag "rc.1"
let (base_version, prerelease) = match version_str.split_once('-') {
Some((base, tag)) => (base, Some(Prerelease::parse(tag))),
None => (version_str, None),
};

let mut parts = base_version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);

Some(Self::new(major, minor, patch))
let mut version = Self::new(major, minor, patch);
version.prerelease = prerelease;
Some(version)
}
}

Expand Down Expand Up @@ -722,3 +829,67 @@ impl<'a> ComponentMetadata<'a> {
format!("{}@{}", file_path, self.class_name)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_keeps_prerelease_tag() {
assert_eq!(
AngularVersion::parse("22.3.0-next.0"),
Some(AngularVersion::new(22, 3, 0).with_prerelease(Prerelease::Next(0)))
);
assert_eq!(
AngularVersion::parse("19.0.0-rc.1"),
Some(AngularVersion::new(19, 0, 0).with_prerelease(Prerelease::Rc(1)))
);
// Unknown tags stay a prerelease (below the release) rather than being
// dropped.
assert_eq!(
AngularVersion::parse("19.0.0-beta.2"),
Some(AngularVersion::new(19, 0, 0).with_prerelease(Prerelease::Other))
);
assert_eq!(AngularVersion::parse("22.2.2"), Some(AngularVersion::new(22, 2, 2)));
// Placeholder means latest/head: no prerelease, so every gate takes the
// newest path.
assert_eq!(
AngularVersion::parse("0.0.0-PLACEHOLDER"),
Some(AngularVersion::new(u32::MAX, 0, 0))
);
}

/// Both 22.2.2 gates share the same cutoff, so they are exercised
/// together: `>= 22.2.2` stable and `22.3.0-next.1+`, but not
/// `22.3.0-next.0` or any earlier/unknown `22.3.0` prerelease — all of
/// those were cut before the fixes landed on `main`.
#[test]
fn prerelease_gates_respect_22_3_0_next_1_cutoff() {
let parse = |s| AngularVersion::parse(s).unwrap();

for version in [
parse("22.2.2"),
parse("22.3.0-next.1"),
parse("22.3.0-rc.0"),
parse("22.3.0"),
parse("23.0.0"),
AngularVersion::new(u32::MAX, 0, 0),
] {
assert!(version.drops_img_video_src_url_context(), "{version:?}");
assert!(version.strips_namespaced_block_tag(), "{version:?}");
}

for version in [
parse("22.2.1"),
parse("22.3.0-next.0"),
// Unknown tags parse to `Other` and sort below `next`/`rc`, so
// hypothetical prereleases of 22.3.0 keep the old behavior.
parse("22.3.0-beta.2"),
parse("22.3.0-snapshot.9"),
AngularVersion::new(22, 2, 0).with_prerelease(Prerelease::Next(5)),
] {
assert!(!version.drops_img_video_src_url_context(), "{version:?}");
assert!(!version.strips_namespaced_block_tag(), "{version:?}");
}
}
}
2 changes: 1 addition & 1 deletion crates/oxc_angular_compiler/src/component/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub use dependency::{
pub use import_elision::ImportElisionAnalyzer;
pub use metadata::{
AngularVersion, ChangeDetectionStrategy, ComponentMetadata, DeclarationListEmitMode,
HostDirectiveMetadata, HostMetadata, LifecycleMetadata, TemplateDependency,
HostDirectiveMetadata, HostMetadata, LifecycleMetadata, Prerelease, TemplateDependency,
TemplateDependencyKind, ViewEncapsulation,
};
pub use namespace_registry::NamespaceRegistry;
Expand Down
8 changes: 4 additions & 4 deletions crates/oxc_angular_compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ pub use transform::{HtmlToR3Transform, html_to_r3::html_ast_to_r3_ast};
pub use component::{
AngularVersion, ChangeDetectionStrategy, CompiledComponent, ComponentMetadata,
HmrTemplateCompileOutput, HostMetadata, HostMetadataInput, ImportInfo, ImportMap,
NamespaceRegistry, ResolvedResources, TemplateCompileOutput, TransformOptions, TransformResult,
ViewEncapsulation, build_import_map, compile_component_template, compile_for_hmr,
compile_template_for_hmr, compile_template_to_js, compile_template_to_js_with_options,
extract_component_metadata, transform_angular_file,
NamespaceRegistry, Prerelease, ResolvedResources, TemplateCompileOutput, TransformOptions,
TransformResult, ViewEncapsulation, build_import_map, compile_component_template,
compile_for_hmr, compile_template_for_hmr, compile_template_to_js,
compile_template_to_js_with_options, extract_component_metadata, transform_angular_file,
};

// Re-export cross-file elision types when feature is enabled
Expand Down
30 changes: 24 additions & 6 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2452,9 +2452,9 @@ fn ingest_if_block<'a>(
let tag_name =
ingest_control_flow_insertion_point(job, view_xref, branch_view_xref, &branch.children);

// Tag name is passed directly for content projection (including namespace prefix).
// This matches TypeScript's ingestIfBlock which passes tagName directly from
// ingestControlFlowInsertionPoint without stripping the namespace.
// Tag name is passed directly for content projection;
// ingest_control_flow_insertion_point strips the namespace on 22.2.2+
// (angular/angular@0244a1cb3).
let tag = tag_name.clone();

// fn_name_suffix is hardcoded to "Conditional" without namespace prefix
Expand Down Expand Up @@ -3036,9 +3036,9 @@ fn ingest_switch_block<'a>(
let tag_name =
ingest_control_flow_insertion_point(job, view_xref, group_view_xref, &group.children);

// Tag name is passed directly for content projection (including namespace prefix).
// This matches TypeScript's ingestSwitchBlock which passes tagName directly from
// ingestControlFlowInsertionPoint without stripping the namespace.
// Tag name is passed directly for content projection;
// ingest_control_flow_insertion_point strips the namespace on 22.2.2+
// (angular/angular@0244a1cb3).
let tag = tag_name.clone();

// fn_name_suffix is hardcoded to "Case" without namespace prefix
Expand Down Expand Up @@ -4682,6 +4682,24 @@ fn ingest_control_flow_insertion_point<'a, 'b>(
// Get the tag name
let tag_name = root.tag_name();

if job.angular_version.map_or(true, |v| v.strips_namespaced_block_tag()) {
// 22.2.2+ matches the root tag against ng-content tag selectors, so the
// namespace prefix is stripped upstream (`rawTagName ?
// splitNsName(rawTagName)[1] : null`); `ng-template` is checked on the
// stripped name. See `angular/angular@0244a1cb3`.
if tag_name.as_str().is_empty() {
return None;
}
let (namespace, stripped) = split_ns_name(tag_name.as_str());
if stripped == NG_TEMPLATE_TAG_NAME {
return None;
}
if namespace.is_some() {
return Some(Ident::from(allocator.alloc_str(stripped)));
}
return Some(tag_name);
}

// Don't pass along `ng-template` tag name since it enables directive matching.
if tag_name.as_str() == NG_TEMPLATE_TAG_NAME { None } else { Some(tag_name) }
}
Expand Down
Loading
Loading