From 250d5c2dc3eae2c85bd88c42f00b826d4c74363e Mon Sep 17 00:00:00 2001 From: Gabriel Silva Date: Wed, 21 Oct 2026 03:36:04 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(defer):=20emit=20=C9=B5=C9=B5enableIncr?= =?UTF-8?q?ementalHydrationRuntime=20before=20hydrating=20defer=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since Angular 22.0.0-next.11 the incremental hydration runtime stays inert until a template calls ɵɵenableIncrementalHydrationRuntime(). OXC never emitted it, so @defer blocks with hydrate triggers were never hydrated on the client. Port Angular's insertIncrementalHydrationRuntime phase: one call per view, before its first defer block with hydrate triggers. Skipped for Angular versions without the instruction. --- .../src/component/metadata.rs | 17 ++++++ crates/oxc_angular_compiler/src/ir/enums.rs | 7 +++ .../oxc_angular_compiler/src/ir/expression.rs | 2 + crates/oxc_angular_compiler/src/ir/ops.rs | 19 +++++++ .../src/pipeline/compilation.rs | 8 +++ .../insert_incremental_hydration_runtime.rs | 40 +++++++++++++ .../src/pipeline/phases/mod.rs | 8 +++ .../src/pipeline/phases/reify/mod.rs | 3 + .../pipeline/phases/reify/statements/misc.rs | 13 +++++ .../src/r3/identifiers.rs | 4 ++ .../tests/integration_test.rs | 56 +++++++++++++++++++ .../e2e/compare/fixtures/known-differences.ts | 18 ------ 12 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 crates/oxc_angular_compiler/src/pipeline/phases/insert_incremental_hydration_runtime.rs diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index d2eb46a01..6b0bbc148 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -201,6 +201,23 @@ impl AngularVersion { false } + /// Check if this version has the `ɵɵenableIncrementalHydrationRuntime` + /// instruction (v22.0.0-next.11+). + /// + /// From that release the incremental hydration runtime stays inert until a + /// compiled template calls the instruction, so every view with a `hydrate` + /// trigger emits it. Earlier runtimes don't export it and always run + /// incremental hydration. + 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 diff --git a/crates/oxc_angular_compiler/src/ir/enums.rs b/crates/oxc_angular_compiler/src/ir/enums.rs index 84aeae0fa..d448ef9fd 100644 --- a/crates/oxc_angular_compiler/src/ir/enums.rs +++ b/crates/oxc_angular_compiler/src/ir/enums.rs @@ -102,6 +102,8 @@ pub enum OpKind { // Projection operations /// Configure content projection. ProjectionDef, + /// Activate the incremental hydration runtime. + EnableIncrementalHydrationRuntime, /// Create a content projection slot. Projection, @@ -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..2f4e0da3d 100644 --- a/crates/oxc_angular_compiler/src/ir/ops.rs +++ b/crates/oxc_angular_compiler/src/ir/ops.rs @@ -174,6 +174,8 @@ pub enum CreateOp<'a> { Namespace(NamespaceOp<'a>), /// Configure content projection. ProjectionDef(ProjectionDefOp<'a>), + /// Activate the incremental hydration runtime. + EnableIncrementalHydrationRuntime(EnableIncrementalHydrationRuntimeOp<'a>), /// Create a content projection slot. Projection(ProjectionOp<'a>), /// Create a repeater instruction. @@ -234,6 +236,9 @@ impl<'a> Op for CreateOp<'a> { CreateOp::I18nMessage(_) => OpKind::I18nMessage, CreateOp::Namespace(_) => OpKind::Namespace, CreateOp::ProjectionDef(_) => OpKind::ProjectionDef, + CreateOp::EnableIncrementalHydrationRuntime(_) => { + OpKind::EnableIncrementalHydrationRuntime + } CreateOp::Projection(_) => OpKind::Projection, CreateOp::RepeaterCreate(_) => OpKind::RepeaterCreate, CreateOp::DeclareLet(_) => OpKind::DeclareLet, @@ -279,6 +284,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::I18nMessage(op) => op.base.prev, CreateOp::Namespace(op) => op.base.prev, CreateOp::ProjectionDef(op) => op.base.prev, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.prev, CreateOp::Projection(op) => op.base.prev, CreateOp::RepeaterCreate(op) => op.base.prev, CreateOp::DeclareLet(op) => op.base.prev, @@ -324,6 +330,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::I18nMessage(op) => op.base.next, CreateOp::Namespace(op) => op.base.next, CreateOp::ProjectionDef(op) => op.base.next, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.next, CreateOp::Projection(op) => op.base.next, CreateOp::RepeaterCreate(op) => op.base.next, CreateOp::DeclareLet(op) => op.base.next, @@ -369,6 +376,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::I18nMessage(op) => op.base.prev = prev, CreateOp::Namespace(op) => op.base.prev = prev, CreateOp::ProjectionDef(op) => op.base.prev = prev, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.prev = prev, CreateOp::Projection(op) => op.base.prev = prev, CreateOp::RepeaterCreate(op) => op.base.prev = prev, CreateOp::DeclareLet(op) => op.base.prev = prev, @@ -414,6 +422,7 @@ impl<'a> Op for CreateOp<'a> { CreateOp::I18nMessage(op) => op.base.next = next, CreateOp::Namespace(op) => op.base.next = next, CreateOp::ProjectionDef(op) => op.base.next = next, + CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.next = next, CreateOp::Projection(op) => op.base.next = next, CreateOp::RepeaterCreate(op) => op.base.next = next, CreateOp::DeclareLet(op) => op.base.next = next, @@ -1159,6 +1168,16 @@ pub struct ProjectionDefOp<'a> { pub def: 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>, +} + /// Create a content projection slot. #[derive(Debug)] pub struct ProjectionOp<'a> { 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/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..b01093f4b --- /dev/null +++ b/crates/oxc_angular_compiler/src/pipeline/phases/insert_incremental_hydration_runtime.rs @@ -0,0 +1,40 @@ +//! 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 { + let is_hydrating_defer = matches!( + cursor.current(), + Some(CreateOp::Defer(defer)) if defer + .flags + .is_some_and(|flags| flags & TDeferDetailsFlags::HAS_HYDRATE_TRIGGERS.bits() != 0) + ); + if is_hydrating_defer { + cursor.insert_before(CreateOp::EnableIncrementalHydrationRuntime( + EnableIncrementalHydrationRuntimeOp { base: CreateOpBase::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..19c7a3524 100644 --- a/crates/oxc_angular_compiler/src/r3/identifiers.rs +++ b/crates/oxc_angular_compiler/src/r3/identifiers.rs @@ -621,6 +621,10 @@ impl Identifiers { /// Define projection. pub const PROJECTION_DEF: &'static str = "ɵɵprojectionDef"; + /// Activate the incremental hydration runtime. + pub const ENABLE_INCREMENTAL_HYDRATION_RUNTIME: &'static str = + "ɵɵenableIncrementalHydrationRuntime"; + // ======================================================================== // Reference Instructions // ======================================================================== diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 837ba7251..7972321e2 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -929,6 +929,62 @@ fn test_hydrate_never_mutual_exclusivity() { ); } +#[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", + ); + assert_eq!( + js.matches("ɵɵenableIncrementalHydrationRuntime()").count(), + 1, + "One activation call per view. Output:\n{js}" + ); + let enable = js.find("ɵɵenableIncrementalHydrationRuntime()").unwrap(); + let hydrating_defer = js.find("ɵɵdefer(4,").unwrap(); + assert!( + js[..enable].contains("ɵɵdefer(1,") && enable < hydrating_defer, + "The call goes right before the first defer with hydrate triggers. Output:\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!( + js.matches("ɵɵenableIncrementalHydrationRuntime()").count(), + 2, + "Each view with a hydrating defer block gets 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'], From 28749264a78a293a485620bb7c20f116209f5a15 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 15:01:41 +0800 Subject: [PATCH 2/2] refactor(defer): group the hydration runtime op with defer ops, pin its placement in tests - Place the op, OpKind and identifier next to the other defer items. - Give the op the defer block's source span, as Angular does. - Write HAS_HYDRATE_TRIGGERS in ingest through the same constant the phase reads. - Fix the doc: older runtimes turn the feature on from withIncrementalHydration(). - Tests: unit test for the 22.0.0-next.11 cutoff (rc, beta, later minors); check the exact statement after each call and which function it is in; cover hydrate never / when / timer and i18n. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/metadata.rs | 35 +++++++++-- crates/oxc_angular_compiler/src/ir/enums.rs | 4 +- crates/oxc_angular_compiler/src/ir/ops.rs | 38 ++++++------ .../src/pipeline/ingest.rs | 5 +- .../insert_incremental_hydration_runtime.rs | 23 ++++--- .../src/r3/identifiers.rs | 8 +-- .../tests/integration_test.rs | 61 ++++++++++++++----- 7 files changed, 120 insertions(+), 54 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 6b0bbc148..318e55935 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -204,10 +204,11 @@ impl AngularVersion { /// Check if this version has the `ɵɵenableIncrementalHydrationRuntime` /// instruction (v22.0.0-next.11+). /// - /// From that release the incremental hydration runtime stays inert until a - /// compiled template calls the instruction, so every view with a `hydrate` - /// trigger emits it. Earlier runtimes don't export it and always run - /// incremental hydration. + /// 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 { @@ -909,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 d448ef9fd..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. @@ -102,8 +104,6 @@ pub enum OpKind { // Projection operations /// Configure content projection. ProjectionDef, - /// Activate the incremental hydration runtime. - EnableIncrementalHydrationRuntime, /// Create a content projection slot. Projection, diff --git a/crates/oxc_angular_compiler/src/ir/ops.rs b/crates/oxc_angular_compiler/src/ir/ops.rs index 2f4e0da3d..2d448095f 100644 --- a/crates/oxc_angular_compiler/src/ir/ops.rs +++ b/crates/oxc_angular_compiler/src/ir/ops.rs @@ -168,14 +168,14 @@ 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. Namespace(NamespaceOp<'a>), /// Configure content projection. ProjectionDef(ProjectionDefOp<'a>), - /// Activate the incremental hydration runtime. - EnableIncrementalHydrationRuntime(EnableIncrementalHydrationRuntimeOp<'a>), /// Create a content projection slot. Projection(ProjectionOp<'a>), /// Create a repeater instruction. @@ -233,12 +233,12 @@ impl<'a> Op for CreateOp<'a> { CreateOp::Pipe(_) => OpKind::Pipe, CreateOp::Defer(_) => OpKind::Defer, CreateOp::DeferOn(_) => OpKind::DeferOn, - CreateOp::I18nMessage(_) => OpKind::I18nMessage, - CreateOp::Namespace(_) => OpKind::Namespace, - CreateOp::ProjectionDef(_) => OpKind::ProjectionDef, CreateOp::EnableIncrementalHydrationRuntime(_) => { OpKind::EnableIncrementalHydrationRuntime } + CreateOp::I18nMessage(_) => OpKind::I18nMessage, + CreateOp::Namespace(_) => OpKind::Namespace, + CreateOp::ProjectionDef(_) => OpKind::ProjectionDef, CreateOp::Projection(_) => OpKind::Projection, CreateOp::RepeaterCreate(_) => OpKind::RepeaterCreate, CreateOp::DeclareLet(_) => OpKind::DeclareLet, @@ -281,10 +281,10 @@ 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, - CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.prev, CreateOp::Projection(op) => op.base.prev, CreateOp::RepeaterCreate(op) => op.base.prev, CreateOp::DeclareLet(op) => op.base.prev, @@ -327,10 +327,10 @@ 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, - CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.next, CreateOp::Projection(op) => op.base.next, CreateOp::RepeaterCreate(op) => op.base.next, CreateOp::DeclareLet(op) => op.base.next, @@ -373,10 +373,10 @@ 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, - CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.prev = prev, CreateOp::Projection(op) => op.base.prev = prev, CreateOp::RepeaterCreate(op) => op.base.prev = prev, CreateOp::DeclareLet(op) => op.base.prev = prev, @@ -419,10 +419,10 @@ 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, - CreateOp::EnableIncrementalHydrationRuntime(op) => op.base.next = next, CreateOp::Projection(op) => op.base.next = next, CreateOp::RepeaterCreate(op) => op.base.next = next, CreateOp::DeclareLet(op) => op.base.next = next, @@ -1091,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 @@ -1168,16 +1178,6 @@ pub struct ProjectionDefOp<'a> { pub def: 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>, -} - /// Create a content projection slot. #[derive(Debug)] pub struct ProjectionOp<'a> { 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 index b01093f4b..b06e6a3cc 100644 --- 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 @@ -20,15 +20,22 @@ pub fn insert_incremental_hydration_runtime(job: &mut ComponentCompilationJob<'_ for view in job.all_views_mut() { let mut cursor = view.create.cursor_front(); loop { - let is_hydrating_defer = matches!( - cursor.current(), - Some(CreateOp::Defer(defer)) if defer - .flags - .is_some_and(|flags| flags & TDeferDetailsFlags::HAS_HYDRATE_TRIGGERS.bits() != 0) - ); - if is_hydrating_defer { + // 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::default() }, + EnableIncrementalHydrationRuntimeOp { + base: CreateOpBase { source_span, ..Default::default() }, + }, )); break; } diff --git a/crates/oxc_angular_compiler/src/r3/identifiers.rs b/crates/oxc_angular_compiler/src/r3/identifiers.rs index 19c7a3524..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 // ======================================================================== @@ -621,10 +625,6 @@ impl Identifiers { /// Define projection. pub const PROJECTION_DEF: &'static str = "ɵɵprojectionDef"; - /// Activate the incremental hydration runtime. - pub const ENABLE_INCREMENTAL_HYDRATION_RUNTIME: &'static str = - "ɵɵenableIncrementalHydrationRuntime"; - // ======================================================================== // Reference Instructions // ======================================================================== diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 7972321e2..4d26876b7 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -929,36 +929,69 @@ 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!( - js.matches("ɵɵenableIncrementalHydrationRuntime()").count(), - 1, - "One activation call per view. Output:\n{js}" - ); - let enable = js.find("ɵɵenableIncrementalHydrationRuntime()").unwrap(); - let hydrating_defer = js.find("ɵɵdefer(4,").unwrap(); - assert!( - js[..enable].contains("ɵɵdefer(1,") && enable < hydrating_defer, - "The call goes right before the first defer with hydrate triggers. Output:\n{js}" + 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!( - js.matches("ɵɵenableIncrementalHydrationRuntime()").count(), - 2, - "Each view with a hydrating defer block gets its own call. Output:\n{js}" - ); + 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]