diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index d2eb46a01..318e55935 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -201,6 +201,24 @@ impl AngularVersion { false } + /// Check if this version has the `ɵɵenableIncrementalHydrationRuntime` + /// instruction (v22.0.0-next.11+). + /// + /// From that release `withIncrementalHydration()` alone no longer turns + /// incremental hydration on: a compiled template must also call the + /// instruction, so every view with a `hydrate` trigger emits it. Earlier + /// runtimes don't export it and turn the feature on from the provider + /// alone. + pub fn supports_incremental_hydration_runtime(&self) -> bool { + if self.major == 22 && self.minor == 0 && self.patch == 0 { + return match self.prerelease { + None => true, + Some(prerelease) => prerelease >= Prerelease::Next(11), + }; + } + self.is_at_least(22, 0, 0) + } + /// Check if this version is `major.minor.patch` or newer. /// /// Compares the release triple only: a prerelease of `22.3.0` counts as @@ -892,4 +910,30 @@ mod tests { assert!(!version.strips_namespaced_block_tag(), "{version:?}"); } } + + #[test] + fn incremental_hydration_runtime_starts_at_22_0_0_next_11() { + let parse = |s| AngularVersion::parse(s).unwrap(); + + for version in [ + parse("22.0.0-next.11"), + parse("22.0.0-rc.0"), + parse("22.0.0"), + parse("22.1.0-next.0"), + parse("23.0.0"), + AngularVersion::new(u32::MAX, 0, 0), + ] { + assert!(version.supports_incremental_hydration_runtime(), "{version:?}"); + } + + for version in [ + parse("21.2.25"), + parse("22.0.0-next.0"), + parse("22.0.0-next.10"), + // Unknown tags parse to `Other`, below `next`. + parse("22.0.0-beta.1"), + ] { + assert!(!version.supports_incremental_hydration_runtime(), "{version:?}"); + } + } } diff --git a/crates/oxc_angular_compiler/src/ir/enums.rs b/crates/oxc_angular_compiler/src/ir/enums.rs index 84aeae0fa..6ea507c07 100644 --- a/crates/oxc_angular_compiler/src/ir/enums.rs +++ b/crates/oxc_angular_compiler/src/ir/enums.rs @@ -88,6 +88,8 @@ pub enum OpKind { DeferOn, /// Control @defer with a custom condition. DeferWhen, + /// Activate the incremental hydration runtime. + EnableIncrementalHydrationRuntime, // I18n operations /// An i18n message for consts array. @@ -507,6 +509,11 @@ impl TDeferDetailsFlags { pub const DEFAULT: Self = Self(0); /// Has hydrate triggers. pub const HAS_HYDRATE_TRIGGERS: Self = Self(1 << 0); + + /// The raw flag bits, as stored in `DeferOp::flags`. + pub const fn bits(self) -> u32 { + self.0 as u32 + } } #[cfg(test)] diff --git a/crates/oxc_angular_compiler/src/ir/expression.rs b/crates/oxc_angular_compiler/src/ir/expression.rs index a779a79a4..61b8b38ca 100644 --- a/crates/oxc_angular_compiler/src/ir/expression.rs +++ b/crates/oxc_angular_compiler/src/ir/expression.rs @@ -2452,6 +2452,7 @@ pub fn transform_expressions_in_create_op<'a, F>( | CreateOp::I18nMessage(_) | CreateOp::Namespace(_) | CreateOp::ProjectionDef(_) + | CreateOp::EnableIncrementalHydrationRuntime(_) | CreateOp::Projection(_) | CreateOp::DeclareLet(_) | CreateOp::I18nStart(_) @@ -2799,6 +2800,7 @@ pub fn visit_expressions_in_create_op<'a, F>( | CreateOp::I18nMessage(_) | CreateOp::Namespace(_) | CreateOp::ProjectionDef(_) + | CreateOp::EnableIncrementalHydrationRuntime(_) | CreateOp::Projection(_) | CreateOp::DeclareLet(_) | CreateOp::I18nStart(_) diff --git a/crates/oxc_angular_compiler/src/ir/ops.rs b/crates/oxc_angular_compiler/src/ir/ops.rs index 8cc9313cc..2d448095f 100644 --- a/crates/oxc_angular_compiler/src/ir/ops.rs +++ b/crates/oxc_angular_compiler/src/ir/ops.rs @@ -168,6 +168,8 @@ pub enum CreateOp<'a> { Defer(DeferOp<'a>), /// Defer trigger. DeferOn(DeferOnOp<'a>), + /// Activate the incremental hydration runtime. + EnableIncrementalHydrationRuntime(EnableIncrementalHydrationRuntimeOp<'a>), /// An i18n message. I18nMessage(I18nMessageOp<'a>), /// Change namespace. @@ -231,6 +233,9 @@ impl<'a> Op for CreateOp<'a> { CreateOp::Pipe(_) => OpKind::Pipe, CreateOp::Defer(_) => OpKind::Defer, CreateOp::DeferOn(_) => OpKind::DeferOn, + CreateOp::EnableIncrementalHydrationRuntime(_) => { + OpKind::EnableIncrementalHydrationRuntime + } CreateOp::I18nMessage(_) => OpKind::I18nMessage, CreateOp::Namespace(_) => OpKind::Namespace, CreateOp::ProjectionDef(_) => OpKind::ProjectionDef, @@ -276,6 +281,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::Pipe(op) => op.base.prev, CreateOp::Defer(op) => op.base.prev, CreateOp::DeferOn(op) => op.base.prev, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.prev, CreateOp::I18nMessage(op) => op.base.prev, CreateOp::Namespace(op) => op.base.prev, CreateOp::ProjectionDef(op) => op.base.prev, @@ -321,6 +327,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::Pipe(op) => op.base.next, CreateOp::Defer(op) => op.base.next, CreateOp::DeferOn(op) => op.base.next, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.next, CreateOp::I18nMessage(op) => op.base.next, CreateOp::Namespace(op) => op.base.next, CreateOp::ProjectionDef(op) => op.base.next, @@ -366,6 +373,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::Pipe(op) => op.base.prev = prev, CreateOp::Defer(op) => op.base.prev = prev, CreateOp::DeferOn(op) => op.base.prev = prev, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.prev = prev, CreateOp::I18nMessage(op) => op.base.prev = prev, CreateOp::Namespace(op) => op.base.prev = prev, CreateOp::ProjectionDef(op) => op.base.prev = prev, @@ -411,6 +419,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::Pipe(op) => op.base.next = next, CreateOp::Defer(op) => op.base.next = next, CreateOp::DeferOn(op) => op.base.next = next, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.next = next, CreateOp::I18nMessage(op) => op.base.next = next, CreateOp::Namespace(op) => op.base.next = next, CreateOp::ProjectionDef(op) => op.base.next = next, @@ -1082,6 +1091,16 @@ pub struct DeferOnOp<'a> { pub options: Option>>, } +/// Activate the incremental hydration runtime. +/// +/// Corresponds to the ɵɵenableIncrementalHydrationRuntime() instruction, emitted +/// once per view before its first `@defer` block with hydrate triggers. +#[derive(Debug)] +pub struct EnableIncrementalHydrationRuntimeOp<'a> { + /// Common operation metadata. + pub base: CreateOpBase<'a>, +} + /// Defer with condition (update-time operation). /// /// This operation evaluates a user-provided condition expression during diff --git a/crates/oxc_angular_compiler/src/pipeline/compilation.rs b/crates/oxc_angular_compiler/src/pipeline/compilation.rs index 8d5074c0b..89e396b98 100644 --- a/crates/oxc_angular_compiler/src/pipeline/compilation.rs +++ b/crates/oxc_angular_compiler/src/pipeline/compilation.rs @@ -318,6 +318,14 @@ impl<'a> ComponentCompilationJob<'a> { self.angular_version.map_or(true, |v: AngularVersion| v.supports_dom_property()) } + /// Check if `ɵɵenableIncrementalHydrationRuntime` is supported (Angular 22.0.0-next.11+). + /// + /// Returns `true` when version is unknown (None = latest). + pub fn supports_incremental_hydration_runtime(&self) -> bool { + self.angular_version + .map_or(true, |v: AngularVersion| v.supports_incremental_hydration_runtime()) + } + /// Resolve whether safe navigation (`?.`) should use legacy null semantics. /// /// Returns `true` when `?.` must expand to the legacy `== null ? null` ternary, diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index f7a13338d..d9589d50f 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -31,7 +31,7 @@ use crate::ast::r3::{ R3TextAttribute, SecurityContext, }; use crate::ir::enums::{ - BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TemplateKind, + BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, TDeferDetailsFlags, TemplateKind, }; use crate::ir::expression::{ BinaryExpr, ConditionalCaseExpr, EmptyExpr, IrBinaryOperator, IrExpression, LexicalReadExpr, @@ -3314,8 +3314,7 @@ fn ingest_defer_block<'a>( // Calculate flags based on whether hydrate triggers exist. // Matches Angular's calcDeferBlockFlags function in ingest.ts let flags = if defer_block.hydrate_triggers.has_any() { - // TDeferDetailsFlags.HasHydrateTriggers = 1 - Some(1u32) + Some(TDeferDetailsFlags::HAS_HYDRATE_TRIGGERS.bits()) } else { None }; diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/insert_incremental_hydration_runtime.rs b/crates/oxc_angular_compiler/src/pipeline/phases/insert_incremental_hydration_runtime.rs new file mode 100644 index 000000000..b06e6a3cc --- /dev/null +++ b/crates/oxc_angular_compiler/src/pipeline/phases/insert_incremental_hydration_runtime.rs @@ -0,0 +1,47 @@ +//! Incremental hydration runtime phase. +//! +//! Inserts an `EnableIncrementalHydrationRuntime` op before the first `@defer` +//! block with hydrate triggers in each view, so the view emits +//! `ɵɵenableIncrementalHydrationRuntime()` once. Without that call the runtime +//! never hydrates `hydrate` blocks. +//! +//! Ported from Angular's `template/pipeline/src/phases/insert_incremental_hydration_runtime.ts`. + +use crate::ir::enums::TDeferDetailsFlags; +use crate::ir::ops::{CreateOp, CreateOpBase, EnableIncrementalHydrationRuntimeOp}; +use crate::pipeline::compilation::ComponentCompilationJob; + +/// Inserts `EnableIncrementalHydrationRuntime` before the first hydrating `Defer` op of each view. +pub fn insert_incremental_hydration_runtime(job: &mut ComponentCompilationJob<'_>) { + if !job.supports_incremental_hydration_runtime() { + return; + } + + for view in job.all_views_mut() { + let mut cursor = view.create.cursor_front(); + loop { + // The new op takes the defer block's source span, as in Angular. + let hydrating_defer_span = match cursor.current() { + Some(CreateOp::Defer(defer)) + if defer.flags.is_some_and(|flags| { + flags & TDeferDetailsFlags::HAS_HYDRATE_TRIGGERS.bits() != 0 + }) => + { + Some(defer.base.source_span) + } + _ => None, + }; + if let Some(source_span) = hydrating_defer_span { + cursor.insert_before(CreateOp::EnableIncrementalHydrationRuntime( + EnableIncrementalHydrationRuntimeOp { + base: CreateOpBase { source_span, ..Default::default() }, + }, + )); + break; + } + if !cursor.move_next() { + break; + } + } + } +} diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/mod.rs b/crates/oxc_angular_compiler/src/pipeline/phases/mod.rs index 7af52ad92..d326e0c50 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/mod.rs @@ -39,6 +39,7 @@ mod host_style_property_parsing; pub mod i18n_closure; mod i18n_const_collection; mod i18n_text_extraction; +mod insert_incremental_hydration_runtime; mod local_refs; mod namespace; mod naming; @@ -254,6 +255,13 @@ pub static PHASES: &[Phase] = &[ run_host: None, name: "configureDeferInstructions", }, + // Phase 19b: insertIncrementalHydrationRuntime (Template only) + Phase { + kind: CompilationJobKind::Template, + run: insert_incremental_hydration_runtime::insert_incremental_hydration_runtime, + run_host: None, + name: "insertIncrementalHydrationRuntime", + }, // Phase 20: createVariadicPipes (Template only) Phase { kind: CompilationJobKind::Template, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs index 086a3d3f9..7994489ad 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -858,6 +858,9 @@ fn reify_create_op<'a>( proj_def.def.as_ref(), )) } + CreateOp::EnableIncrementalHydrationRuntime(_) => { + Some(create_enable_incremental_hydration_runtime_stmt(allocator, core_namespace)) + } CreateOp::DisableBindings(_) => { // Emit disableBindings instruction Some(create_disable_bindings_stmt(allocator, core_namespace)) diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs index 6011d4ff5..1342d285d 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs @@ -486,6 +486,19 @@ pub fn create_disable_bindings_stmt<'a>( ) } +/// Creates an ɵɵenableIncrementalHydrationRuntime() call statement. +pub fn create_enable_incremental_hydration_runtime_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + core_namespace: &'a str, +) -> OutputStatement<'a> { + create_instruction_call_stmt( + allocator, + core_namespace, + Identifiers::ENABLE_INCREMENTAL_HYDRATION_RUNTIME, + OxcVec::new_in(&allocator), + ) +} + /// Creates an ɵɵenableBindings() call statement. pub fn create_enable_bindings_stmt<'a>( allocator: &'a oxc_allocator::Allocator, diff --git a/crates/oxc_angular_compiler/src/r3/identifiers.rs b/crates/oxc_angular_compiler/src/r3/identifiers.rs index 8bde55d01..6cde86d9c 100644 --- a/crates/oxc_angular_compiler/src/r3/identifiers.rs +++ b/crates/oxc_angular_compiler/src/r3/identifiers.rs @@ -476,6 +476,10 @@ impl Identifiers { /// Enable timer scheduling for defer. pub const DEFER_ENABLE_TIMER_SCHEDULING: &'static str = "ɵɵdeferEnableTimerScheduling"; + /// Activate the incremental hydration runtime. + pub const ENABLE_INCREMENTAL_HYDRATION_RUNTIME: &'static str = + "ɵɵenableIncrementalHydrationRuntime"; + // ======================================================================== // Control Flow Instructions // ======================================================================== diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 837ba7251..4d26876b7 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -929,6 +929,95 @@ fn test_hydrate_never_mutual_exclusivity() { ); } +/// The statement that follows each `ɵɵenableIncrementalHydrationRuntime()` call. +fn after_hydration_runtime_calls(js: &str) -> Vec<&str> { + js.split("i0.ɵɵenableIncrementalHydrationRuntime();") + .skip(1) + .map(|rest| rest.trim_start().split(';').next().unwrap()) + .collect() +} + +/// The body of the generated template function `name`. +fn template_function<'a>(js: &'a str, name: &str) -> &'a str { + let start = js.find(&format!("function {name}(")).unwrap_or_else(|| panic!("{name}:\n{js}")); + let body = &js[start + 1..]; + &js[start..start + 1 + body.find("function ").unwrap_or(body.len())] +} + +#[test] +fn test_hydrate_trigger_enables_incremental_hydration_runtime() { + let js = compile_template_to_js( + r"@defer (on idle) {a} @defer (hydrate on hover) {b} @defer (hydrate never) {c}", + "TestComponent", + ); + // One call per view, right before the first defer block with hydrate triggers. + assert_eq!( + after_hydration_runtime_calls(&js), + ["i0.ɵɵdefer(4,3,null,null,null,null,null,null,null,1)"], + "Output:\n{js}" + ); +} + +#[test] +fn test_hydrate_trigger_kinds_enable_incremental_hydration_runtime() { + for template in [ + r"@defer (hydrate never) {a}", + r"@defer (hydrate when cond) {a}", + r"@defer (hydrate on timer(1s)) {a}", + r"
Hello @defer (hydrate on idle) {x}
", + ] { + let js = compile_template_to_js(template, "TestComponent"); + let calls = after_hydration_runtime_calls(&js); + assert!( + calls.len() == 1 && calls[0].starts_with("i0.ɵɵdefer("), + "{template}\nOutput:\n{js}" + ); + } +} + +#[test] +fn test_hydrate_trigger_in_embedded_view_enables_runtime_in_that_view() { + let js = compile_template_to_js( + r"@defer (hydrate on idle) {a} @if (x) { @defer (hydrate on hover) {b} }", + "TestComponent", + ); + assert_eq!(after_hydration_runtime_calls(&js).len(), 2, "Output:\n{js}"); + for (view, defer) in [ + ("TestComponent_Conditional_3_Template", "i0.ɵɵdefer(2,"), + ("TestComponent_Template", "i0.ɵɵdefer(1,"), + ] { + let calls = after_hydration_runtime_calls(template_function(&js, view)); + assert!( + calls.len() == 1 && calls[0].starts_with(defer), + "{view} has its own call. Output:\n{js}" + ); + } +} + +#[test] +fn test_defer_without_hydrate_trigger_does_not_enable_runtime() { + let js = compile_template_to_js(r"@defer (on idle; prefetch on hover) {a}", "TestComponent"); + assert!(!js.contains("ɵɵenableIncrementalHydrationRuntime"), "Output:\n{js}"); +} + +#[test] +fn test_incremental_hydration_runtime_is_not_emitted_before_v22_next_11() { + let template = r"@defer (hydrate on idle) {a}"; + for version in [AngularVersion::new(21, 2, 0), AngularVersion::parse("22.0.0-next.10").unwrap()] + { + let js = compile_template_to_js_with_version(template, "TestComponent", Some(version)); + assert!( + !js.contains("ɵɵenableIncrementalHydrationRuntime"), + "The instruction does not exist before 22.0.0-next.11. Output:\n{js}" + ); + } + for version in [AngularVersion::parse("22.0.0-next.11").unwrap(), AngularVersion::new(22, 0, 0)] + { + let js = compile_template_to_js_with_version(template, "TestComponent", Some(version)); + assert!(js.contains("ɵɵenableIncrementalHydrationRuntime()"), "Output:\n{js}"); + } +} + // ============================================================================ // Let Declaration Tests // ============================================================================ diff --git a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts index 23c73f3d0..73032f069 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts @@ -27,8 +27,6 @@ const CUSTOM_DECORATOR = 'the reference ngtsc compile leaves a class with a non-Angular decorator uncompiled, so only Oxc emits ɵfac/ɵcmp' const CHANGE_DETECTION_V22 = 'changeDetection is omitted for OnPush, which is the runtime default from v22, where Angular emits it explicitly' -const INCREMENTAL_HYDRATION = - 'ɵɵenableIncrementalHydrationRuntime is not emitted for a `hydrate` trigger' const SELECTOR_WHITESPACE = 'runs of whitespace inside shimmed selectors are collapsed where Angular keeps them' const INJECTABLE_FACTORY_WRAPPER = @@ -95,22 +93,6 @@ export const KNOWN_DIFFERENCES: Record = { fields: ['ChangeDetectionOnPushComponent.ɵcmp'], reasons: [CHANGE_DETECTION_V22], }, - 'defer/defer-hydrate-idle': { - fields: ['DeferHydrateIdleComponent.ɵcmp'], - reasons: [INCREMENTAL_HYDRATION], - }, - 'defer/defer-hydrate-interaction': { - fields: ['DeferHydrateInteractionComponent.ɵcmp'], - reasons: [INCREMENTAL_HYDRATION], - }, - 'defer/defer-hydrate-never': { - fields: ['DeferHydrateNeverComponent.ɵcmp'], - reasons: [INCREMENTAL_HYDRATION], - }, - 'defer/defer-hydrate-viewport': { - fields: ['DeferHydrateViewportComponent.ɵcmp'], - reasons: [INCREMENTAL_HYDRATION], - }, 'edge-cases/aliased-injectable': { fields: ['AliasedService.ɵfac'], metadataDiffs: ['AliasedService.setClassMetadata'],