refactor(core): dispatch enter and leave animations at the right times (#63450)

This updates the enter and leave logic to use the stored LView data to dispatch the enter and leave animations at the right points in the lifecycle. This should fix issues with signals not being available yet, parallel animations, and also eliminate the need for the element registry.

fixes: #63391
fixes: #63388
fixes: #63369

PR Close #63450
This commit is contained in:
Jessica Janiuk
2025-08-28 15:01:13 +02:00
parent bf499f2fca
commit 4924108630
56 changed files with 452 additions and 638 deletions
@@ -16,7 +16,8 @@
],
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
@@ -16,7 +16,8 @@
],
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
+1 -1
View File
@@ -12,7 +12,7 @@
"importHelpers": true,
"target": "es2022",
"typeRoots": ["node_modules/@types"],
"lib": ["es2018", "dom"]
"lib": ["es2018", "dom", "ES2020.Promise"]
},
"angularCompilerOptions": {
"strictTemplates": true
@@ -7,7 +7,8 @@
"types": [
"jasmine",
"jasminewd2",
"node"
"node",
"ES2020.Promise"
]
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
"importHelpers": true,
"target": "es2022",
"typeRoots": ["node_modules/@types"],
"lib": ["es2018", "dom"]
"lib": ["es2018", "dom", "ES2020.Promise"]
},
"angularCompilerOptions": {
"strictTemplates": true
+2 -1
View File
@@ -16,7 +16,8 @@
],
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
@@ -17,7 +17,8 @@
],
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
+2 -1
View File
@@ -16,7 +16,8 @@
],
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
+2 -1
View File
@@ -20,7 +20,8 @@
"module": "es2022",
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
@@ -9,13 +9,11 @@
"moduleResolution": "NodeNext",
"outDir": "./dist/out-tsc",
"rootDir": ".",
"target": "es5",
"target": "es2020",
"lib": [
"es5",
"es2020",
"dom",
"es2015.collection",
"es2015.iterable",
"es2015.promise"
"es2020.promise"
],
"types": [],
},
@@ -20,7 +20,8 @@
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
@@ -21,7 +21,8 @@
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
@@ -7,7 +7,8 @@
"target": "es2019",
"types": [
"jasmine",
"node"
"node",
"ES2020.Promise"
]
}
}
+2 -1
View File
@@ -21,7 +21,8 @@
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
@@ -16,7 +16,8 @@
],
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
+2 -1
View File
@@ -18,7 +18,8 @@
"module": "es2022",
"lib": [
"es2018",
"dom"
"dom",
"ES2020.Promise"
]
},
"angularCompilerOptions": {
+3 -5
View File
@@ -9,13 +9,11 @@
"moduleResolution": "node",
"outDir": "./dist/out-tsc",
"rootDir": ".",
"target": "es5",
"target": "es2020",
"lib": [
"es5",
"ES2020",
"dom",
"es2015.collection",
"es2015.iterable",
"es2015.promise"
"ES2020.promise"
],
"types": [],
},
+2 -4
View File
@@ -11,11 +11,9 @@
"rootDir": ".",
"target": "ES2020",
"lib": [
"es5",
"ES2020",
"dom",
"es2015.collection",
"es2015.iterable",
"es2015.promise"
"ES2020.promise"
],
"types": [],
},
@@ -1,4 +1,4 @@
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], features: [i0.ɵɵAnimationsFeature()], decls: 3, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], decls: 3, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵdomElementStart(0, "div")(1, "p");
i0.ɵɵanimateLeave(function MyComponent_Template_animateleave_cb() { return ctx.leaveClass(); });
i0.ɵɵtext(2, "Fading Content");
@@ -1,12 +1,12 @@
ChildComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ChildComponent, selectors: [["child-component"]], hostBindings: function ChildComponent_HostBindings(rf, ctx) { if (rf & 1) {
i0.ɵɵanimateLeaveListener(function ChildComponent_animateleave_HostBindingHandler($event) { return ctx.fadeFn($event); });
} }, features: [i0.ɵɵAnimationsFeature()], decls: 2, vars: 0, template: function ChildComponent_Template(rf, ctx) { if (rf & 1) {
} }, decls: 2, vars: 0, template: function ChildComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵdomElementStart(0, "p");
i0.ɵɵtext(1, "Fading Content");
i0.ɵɵdomElementEnd();
} }, encapsulation: 2 });
…
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], features: [i0.ɵɵAnimationsFeature()], decls: 1, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], decls: 1, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵelementStart(0, "child-component");
i0.ɵɵanimateLeave("slide");
i0.ɵɵelementEnd();
@@ -1,4 +1,4 @@
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], features: [i0.ɵɵAnimationsFeature()], decls: 3, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], decls: 3, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵdomElementStart(0, "div")(1, "p");
i0.ɵɵanimateLeaveListener(function MyComponent_Template_p_animateleave_1_listener($event) { return ctx.fadeFn($event); });
i0.ɵɵtext(2, "Fading Content");
@@ -1,12 +1,12 @@
ChildComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: ChildComponent, selectors: [["child-component"]], hostBindings: function ChildComponent_HostBindings(rf, ctx) { if (rf & 1) {
i0.ɵɵanimateLeave("fade");
} }, features: [i0.ɵɵAnimationsFeature()], decls: 2, vars: 0, template: function ChildComponent_Template(rf, ctx) { if (rf & 1) {
} }, decls: 2, vars: 0, template: function ChildComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵdomElementStart(0, "p");
i0.ɵɵtext(1, "Fading Content");
i0.ɵɵdomElementEnd();
} }, encapsulation: 2 });
…
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], features: [i0.ɵɵAnimationsFeature()], decls: 1, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], decls: 1, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵelementStart(0, "child-component");
i0.ɵɵanimateLeave("slide");
i0.ɵɵelementEnd();
@@ -1,4 +1,4 @@
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], features: [i0.ɵɵAnimationsFeature()], decls: 3, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
MyComponent.ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MyComponent, selectors: [["my-component"]], decls: 3, vars: 0, template: function MyComponent_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵdomElementStart(0, "div")(1, "p");
i0.ɵɵanimateLeave("fade");
i0.ɵɵtext(2, "Fading Content");
@@ -447,11 +447,6 @@ export class Identifiers {
moduleName: CORE,
};
static AnimationsFeature: o.ExternalReference = {
name: 'ɵɵAnimationsFeature',
moduleName: CORE,
};
static listener: o.ExternalReference = {name: 'ɵɵlistener', moduleName: CORE};
static getInheritedFactory: o.ExternalReference = {
@@ -32,7 +32,6 @@ import {getTemplateSourceLocationsEnabled} from './config';
import {createContentQueriesFunction, createViewQueriesFunction} from './query_generation';
import {makeBindingParser} from './template';
import {asLiteral, conditionallyCreateDirectiveBindingLiteral, DefinitionMap} from './util';
import {analyzeTemplateForAnimations} from '../../template_parser/animation_analyzer';
const COMPONENT_VARIABLE = '%COMP%';
const HOST_ATTR = `_nghost-${COMPONENT_VARIABLE}`;
@@ -158,12 +157,6 @@ function addFeatures(
o.importExpr(R3.ExternalStylesFeature).callFn([o.literalArr(externalStyleNodes)]),
);
}
const template = (meta as R3ComponentMetadata<R3TemplateDependency>).template;
if (hasAnimationHostBinding(meta) || (template && template.nodes.length > 0)) {
if (hasAnimationHostBinding(meta) || analyzeTemplateForAnimations(template.nodes)) {
features.push(o.importExpr(R3.AnimationsFeature).callFn([]));
}
}
if (features.length) {
definitionMap.set('features', o.literalArr(features));
@@ -1,54 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {CombinedRecursiveAstVisitor} from '../combined_visitor';
import {Node, Element, visitAll} from '../render3/r3_ast';
const ANIMATE_LEAVE = `animate.leave`;
/**
* Analyzes a component's template to determine if it's using animate.enter
* or animate.leave syntax.
*/
export function analyzeTemplateForAnimations(template: Node[]): boolean {
const analyzer = new AnimationsAnalyzer();
visitAll(analyzer, template);
// The template is considered selectorless only if there
// are direct references to directives or pipes.
return analyzer.hasAnimations;
}
/**
* Visitor that traverses all the template nodes and
* expressions to look for selectorless references.
*/
class AnimationsAnalyzer extends CombinedRecursiveAstVisitor {
hasAnimations: boolean = false;
override visitElement(element: Element): void {
// check for regular strings
for (const attr of element.attributes) {
if (attr.name === ANIMATE_LEAVE) {
this.hasAnimations = true;
}
}
// check for attribute bindings
for (const input of element.inputs) {
if (input.name === ANIMATE_LEAVE) {
this.hasAnimations = true;
}
}
// check for event bindings
for (const output of element.outputs) {
if (output.name === ANIMATE_LEAVE) {
this.hasAnimations = true;
}
}
super.visitElement(element);
}
}
@@ -1,133 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
AnimationClassFunction,
AnimationDetails,
AnimationEventFunction,
AnimationFunction,
} from './interfaces';
export interface AnimationRemovalRegistry {
elements: ElementRegistry | undefined;
}
/**
* Registers elements for delayed removal action for animation in the case
* that `animate.leave` is used. This stores the target element and any
* classes, class resolvers, and callback functions that may be needed
* to apply the removal animation, and then stashes the actual element
* removal function from the dom renderer to be called after the
* animation is finished.
*/
export class ElementRegistry {
private outElements = new WeakMap<Element, AnimationDetails>();
remove(el: Element): void {
this.outElements.delete(el);
}
/** Used when animate.leave is only applying classes */
trackClasses(details: AnimationDetails, classes: string | string[]): void {
const classList = getClassListFromValue(classes);
if (!classList) return;
for (let klass of classList) {
details.classes?.add(klass);
}
}
/** Used when animate.leave is applying classes via a bound attribute
* which requires resolving the binding function at the right time
* to get the proper class list. There may be multiple resolvers due
* to composition via host bindings.
*/
trackResolver(details: AnimationDetails, resolver: Function): void {
if (!details.classFns) {
details.classFns = [resolver];
} else {
details.classFns.push(resolver);
}
}
/** Used when `animate.leave` is using the function signature and will have a
* callback function, rather than a list of classes.
*/
addCallback(
el: Element,
value: AnimationFunction,
animateWrapperFn: AnimationEventFunction,
): void {
const details = this.outElements.get(el) ?? {
classes: null,
animateFn: () => {},
isEventBinding: true,
};
details.animateFn = animateWrapperFn(el, value);
this.outElements.set(el, details);
}
/** Used when `animate.leave` is using classes. */
add(el: Element, value: string | string[] | Function, animateWrapperFn: AnimationClassFunction) {
const details = this.outElements.get(el) ?? {
classes: new Set<string>(),
animateFn: (): void => {},
isEventBinding: false,
};
if (typeof value === 'function') {
this.trackResolver(details, value);
} else {
this.trackClasses(details, value);
}
details.animateFn = animateWrapperFn(el, details.classes, details.classFns);
this.outElements.set(el, details);
}
has(el: Element): boolean {
return this.outElements.has(el);
}
/** This is called by the dom renderer to actually initiate the animation
* using the animateFn stored in the registry. The DOM renderer passes in
* the removal function to be fired off when the animation finishes.
*/
animate(el: Element, removeFn: Function, maxAnimationTimeout: number): void {
if (!this.outElements.has(el)) return removeFn();
const details = this.outElements.get(el)!;
let timeoutId: ReturnType<typeof setTimeout>;
let called = false;
const remove = () => {
// This called check is to prevent a rare race condition where the timing of removal
// might result in the removal function being called twice.
if (called) return;
called = true;
clearTimeout(timeoutId);
this.remove(el);
removeFn();
};
// this timeout is used to ensure elements actually get removed in the case
// that the user forgot to call the remove callback. The timeout is cleared
// in the DOM renderer during the remove child process. It only applies
// to the event binding use case.
if (details.isEventBinding) {
timeoutId = setTimeout(remove, maxAnimationTimeout);
}
details.animateFn(remove);
}
}
export function getClassListFromValue(value: string | Function | string[]): string[] | null {
const classes = typeof value === 'function' ? value() : value;
let classList: string[] | null = Array.isArray(classes) ? classes : null;
if (typeof classes === 'string') {
classList = classes
.trim()
.split(/\s+/)
.filter((k) => k);
}
return classList;
}
+16 -3
View File
@@ -69,7 +69,20 @@ export interface AnimationDetails {
animateFn: AnimationRemoveFunction;
isEventBinding: boolean;
}
export interface LeaveAnimation {
index: number;
animateFn: Function;
export interface LongestAnimation {
animationName: string | undefined;
propertyName: string | undefined;
duration: number;
}
export interface AnimationLViewData {
// Enter animations that apply to nodes in this view
enter?: Function[];
// Leave animations that apply to nodes in this view
leave?: (() => Promise<void>)[];
// Leave animations that apply to nodes in this view
running?: Promise<PromiseSettledResult<void>[]>;
}
@@ -6,11 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/
export interface LongestAnimation {
animationName: string | undefined;
propertyName: string | undefined;
duration: number;
}
import {LView} from '../render3/interfaces/view';
import {LongestAnimation} from './interfaces';
/** Parses a CSS time value to milliseconds. */
function parseCssTimeUnitsToMs(value: string): number {
@@ -148,3 +145,5 @@ function determineLongestAnimationFromElementAnimations(
animationsMap.set(el, longest);
}
}
export const allLeavingAnimations = new Set<LView>();
+1 -4
View File
@@ -173,8 +173,5 @@ export {getComponentDef as ɵgetComponentDef} from './render3/def_getters';
export {DEHYDRATED_BLOCK_REGISTRY as ɵDEHYDRATED_BLOCK_REGISTRY} from './defer/registry';
export {TimerScheduler as ɵTimerScheduler} from './defer/timer_scheduler';
export {ɵassertType} from './type_checking';
export {
ElementRegistry as ɵElementRegistry,
AnimationRemovalRegistry as ɵAnimationRemovalRegistry,
} from './animation/element_removal_registry';
export {ANIMATIONS_DISABLED as ɵANIMATIONS_DISABLED} from './animation/interfaces';
export {allLeavingAnimations as ɵallLeavingAnimations} from './animation/longest_animation';
@@ -152,7 +152,6 @@ export {
ɵɵproperty,
ɵɵProvidersFeature,
ɵɵHostDirectivesFeature,
ɵɵAnimationsFeature,
ɵɵpureFunction0,
ɵɵpureFunction1,
ɵɵpureFunction2,
@@ -305,4 +304,3 @@ export {depsTracker as ɵdepsTracker} from './render3/deps_tracker/deps_tracker'
export {generateStandaloneInDeclarationsError as ɵgenerateStandaloneInDeclarationsError} from './render3/jit/module';
export {getAsyncClassMetadataFn as ɵgetAsyncClassMetadataFn} from './render3/metadata';
export {DeferBlockData as ɵDeferBlockData} from './render3/util/defer';
export {getAnimationElementRemovalRegistry as ɵgetAnimationElementRemovalRegistry} from './render3/state';
@@ -1,22 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ElementRegistry} from '../../animation/element_removal_registry';
import {setAnimationElementRemovalRegistry} from '../state';
/**
* This feature adds the element registry for delayed element removal when animate.leave
* is utilized.
*
* @codeGenApi
*/
export function ɵɵAnimationsFeature() {
return () => {
setAnimationElementRemovalRegistry(new ElementRegistry());
};
}
-2
View File
@@ -12,7 +12,6 @@ import {ɵɵInheritDefinitionFeature} from './features/inherit_definition_featur
import {ɵɵNgOnChangesFeature} from './features/ng_onchanges_feature';
import {ɵɵProvidersFeature} from './features/providers_feature';
import {ɵɵExternalStylesFeature} from './features/external_styles_feature';
import {ɵɵAnimationsFeature} from './features/animations_feature';
import {
ComponentDef,
ComponentTemplate,
@@ -249,5 +248,4 @@ export {
ɵɵsetComponentScope,
ɵɵsetNgModuleScope,
ɵɵExternalStylesFeature,
ɵɵAnimationsFeature,
};
@@ -9,39 +9,30 @@
import {stringify} from '../../util/stringify'; // Adjust imports as per actual location
import {
AnimationCallbackEvent,
AnimationClassFunction,
AnimationEventFunction,
AnimationFunction,
AnimationRemoveFunction,
ANIMATIONS_DISABLED,
MAX_ANIMATION_TIMEOUT,
LongestAnimation,
} from '../../animation/interfaces';
import {
AnimationRemovalRegistry,
getClassListFromValue,
} from '../../animation/element_removal_registry';
import {getLView, getCurrentTNode, getTView, getAnimationElementRemovalRegistry} from '../state';
import {getLView, getCurrentTNode} from '../state';
import {
RENDERER,
INJECTOR,
CONTEXT,
FLAGS,
LViewFlags,
LView,
TView,
DECLARATION_LCONTAINER,
ENTER_ANIMATIONS,
LEAVE_ANIMATIONS,
ANIMATIONS,
} from '../interfaces/view';
import {RuntimeError, RuntimeErrorCode} from '../../errors';
import {getNativeByTNode, storeCleanupWithContext} from '../util/view_utils';
import {getNativeByTNode} from '../util/view_utils';
import {performanceMarkFeature} from '../../util/performance';
import {Renderer} from '../interfaces/renderer';
import {RElement} from '../interfaces/renderer_dom';
import {NgZone} from '../../zone';
import {assertDefined} from '../../util/assert';
import {determineLongestAnimation, LongestAnimation} from '../../animation/longest_animation';
import {determineLongestAnimation, allLeavingAnimations} from '../../animation/longest_animation';
import {TNode} from '../interfaces/node';
import {getBeforeNodeForView} from '../node_manipulation';
import type {PromiseConstructor} from '../../util/promise_with_resolvers';
const DEFAULT_ANIMATIONS_DISABLED = false;
const areAnimationSupported =
@@ -58,22 +49,6 @@ function areAnimationsDisabled(lView: LView): boolean {
return injector.get(ANIMATIONS_DISABLED, DEFAULT_ANIMATIONS_DISABLED);
}
/**
* Helper function to setup element registry cleanup when LView is destroyed
*/
function setupElementRegistryCleanup(
elementRegistry: AnimationRemovalRegistry,
lView: LView,
tView: TView,
nativeElement: Element,
): void {
if (lView[FLAGS] & LViewFlags.FirstLViewPass) {
storeCleanupWithContext(tView, lView, nativeElement, (elToClean: Element) => {
elementRegistry.elements!.remove(elToClean);
});
}
}
/**
* Helper function to cleanup enterClassMap data safely
*/
@@ -148,18 +123,26 @@ function trackLeavingNodes(tNode: TNode, el: HTMLElement): void {
}
function getLViewEnterAnimations(lView: LView): Function[] {
if (lView[ENTER_ANIMATIONS] === null) {
lView[ENTER_ANIMATIONS] = [];
}
return lView[ENTER_ANIMATIONS];
const animationData = (lView[ANIMATIONS] ??= {});
return (animationData.enter ??= []);
}
// function getLViewLeaveAnimations(lView: LView): Function[] {
// if (lView[LEAVE_ANIMATIONS] === null) {
// lView[LEAVE_ANIMATIONS] = [];
// }
// return lView[LEAVE_ANIMATIONS];
// }
function getLViewLeaveAnimations(lView: LView): Function[] {
const animationData = (lView[ANIMATIONS] ??= {});
return (animationData.leave ??= []);
}
function getClassListFromValue(value: string | Function | string[]): string[] | null {
const classes = typeof value === 'function' ? value() : value;
let classList: string[] | null = Array.isArray(classes) ? classes : null;
if (typeof classes === 'string') {
classList = classes
.trim()
.split(/\s+/)
.filter((k) => k);
}
return classList;
}
/**
* Instruction to handle the `animate.enter` behavior for class bindings.
@@ -295,21 +278,25 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
cancelLeavingNodes(tNode, lView);
getLViewEnterAnimations(lView).push(() => {
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
value.call(lView[CONTEXT], {target: nativeElement, animationComplete: noOpAnimationComplete});
});
getLViewEnterAnimations(lView).push(() => runEnterAnimationFunction(lView, tNode, value));
return ɵɵanimateEnterListener;
}
/**
* runs enter animations when a custom function is provided
*/
function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction) {
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
value.call(lView[CONTEXT], {target: nativeElement, animationComplete: noOpAnimationComplete});
}
/**
* Instruction to handle the `animate.leave` behavior for class animations.
* It registers an animation with the ElementRegistry to be run when the element
* is scheduled for removal from the DOM.
* It creates a leave animation function that's tracked in the LView to
* be run before DOM node removal and cleanup.
*
* @param value The value bound to `animate.leave`, which can be a string or a function.
* @returns This function returns itself so that it may be chained.
@@ -331,56 +318,53 @@ export function ɵɵanimateLeave(value: string | Function): typeof ɵɵanimateLe
return ɵɵanimateLeave;
}
const tView = getTView();
const tNode = getCurrentTNode()!;
getLViewLeaveAnimations(lView).push(() =>
runLeaveAnimations(lView, tNode, value, animationsDisabled),
);
return ɵɵanimateLeave; // For chaining
}
function runLeaveAnimations(
lView: LView,
tNode: TNode,
value: string | Function,
animationsDisabled: boolean,
): Promise<void> {
const {promise, resolve} = (Promise as unknown as PromiseConstructor).withResolvers<void>();
const nativeElement = getNativeByTNode(tNode, lView) as Element;
ngDevMode && assertElementNodes(nativeElement, 'animate.leave');
// This instruction is called in the update pass.
const renderer = lView[RENDERER];
const elementRegistry = getAnimationElementRemovalRegistry();
ngDevMode &&
assertDefined(
elementRegistry.elements,
'Expected `ElementRegistry` to be present in animations subsystem',
const ngZone = lView[INJECTOR].get(NgZone);
allLeavingAnimations.add(lView);
const activeClasses = getClassListFromValue(value);
if (activeClasses && activeClasses.length > 0) {
animateLeaveClassRunner(
nativeElement as HTMLElement,
tNode,
activeClasses,
renderer,
animationsDisabled,
ngZone,
resolve,
);
const ngZone = lView[INJECTOR]!.get(NgZone);
} else {
resolve();
}
// This function gets stashed in the registry to be used once the element removal process
// begins. We pass in the values and resolvers so as to evaluate the resolved classes
// at the latest possible time, meaning we evaluate them right before the animation
// begins.
const animate: AnimationClassFunction = (
el: Element,
value: Set<string> | null,
resolvers: Function[] | undefined,
): AnimationRemoveFunction => {
return (removalFn: VoidFunction) => {
animateLeaveClassRunner(
el as HTMLElement,
tNode,
getClassList(value, resolvers),
removalFn,
renderer,
animationsDisabled,
ngZone,
);
};
};
// Ensure cleanup if the LView is destroyed before the animation runs.
setupElementRegistryCleanup(elementRegistry, lView, tView, nativeElement);
elementRegistry.elements!.add(nativeElement, value, animate);
return ɵɵanimateLeave; // For chaining
return promise;
}
/**
* Instruction to handle the `(animate.leave)` behavior for event bindings, aka when
* a user wants to use a custom animation function rather than a class. It registers
* an animation with the ElementRegistry to be run when the element is scheduled for
* removal from the DOM.
* a leave animation function in the LView to be run at right before removal from the
* DOM.
*
* @param value The value bound to `(animate.leave)`, an AnimationFunction.
* @returns This function returns itself so that it may be chained.
@@ -402,72 +386,64 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
const lView = getLView();
const tNode = getCurrentTNode()!;
const tView = getTView();
const nativeElement = getNativeByTNode(tNode, lView) as Element;
allLeavingAnimations.add(lView);
ngDevMode && assertElementNodes(nativeElement, 'animate.leave');
const elementRegistry = getAnimationElementRemovalRegistry();
ngDevMode &&
assertDefined(
elementRegistry.elements,
'Expected `ElementRegistry` to be present in animations subsystem',
);
const renderer = lView[RENDERER];
const animationsDisabled = areAnimationsDisabled(lView);
const ngZone = lView[INJECTOR]!.get(NgZone);
const animate: AnimationEventFunction = (
_el: Element,
value: AnimationFunction,
): AnimationRemoveFunction => {
return (removeFn: VoidFunction): void => {
if (animationsDisabled) {
removeFn();
} else {
const event: AnimationCallbackEvent = {
target: nativeElement,
animationComplete: () => {
clearLeavingNodes(tNode, _el as HTMLElement);
removeFn();
},
};
trackLeavingNodes(tNode, _el as HTMLElement);
ngZone.runOutsideAngular(() => {
renderer.listen(_el, 'animationend', () => removeFn(), {once: true});
});
value.call(lView[CONTEXT], event);
}
};
};
// Ensure cleanup if the LView is destroyed before the animation runs.
setupElementRegistryCleanup(elementRegistry, lView, tView, nativeElement);
elementRegistry.elements!.addCallback(nativeElement, value, animate);
getLViewLeaveAnimations(lView).push(() => runLeaveAnimationFunction(lView, tNode, value));
return ɵɵanimateLeaveListener; // For chaining
}
/**
* Builds the list of classes to apply to an element based on either the passed in list of strings
* or the set of resolver functions that are coming from bindings. Those resolver functions should
* resolve into either a string or a string array. There may be multiple to support composition.
* runs leave animations when a custom function is provided
*/
function getClassList(value: Set<string> | null, resolvers: Function[] | undefined): Set<string> {
const classList = new Set<string>(value);
if (resolvers && resolvers.length) {
for (const resolverFn of resolvers) {
const resolvedValue = getClassListFromValue(resolverFn);
if (resolvedValue) {
for (const rv of resolvedValue) {
classList.add(rv);
}
}
}
function runLeaveAnimationFunction(
lView: LView,
tNode: TNode,
value: AnimationFunction,
): Promise<void> {
const {promise, resolve} = (Promise as unknown as PromiseConstructor).withResolvers<void>();
const nativeElement = getNativeByTNode(tNode, lView) as Element;
ngDevMode && assertElementNodes(nativeElement, 'animate.leave');
const renderer = lView[RENDERER];
const animationsDisabled = areAnimationsDisabled(lView);
const ngZone = lView[INJECTOR]!.get(NgZone);
const maxAnimationTimeout = lView[INJECTOR]!.get(MAX_ANIMATION_TIMEOUT);
if (animationsDisabled) {
resolve();
} else {
const timeoutId = setTimeout(() => {
clearLeavingNodes(tNode, nativeElement as HTMLElement);
resolve();
}, maxAnimationTimeout);
const event: AnimationCallbackEvent = {
target: nativeElement,
animationComplete: () => {
clearLeavingNodes(tNode, nativeElement as HTMLElement);
clearTimeout(timeoutId);
resolve();
},
};
trackLeavingNodes(tNode, nativeElement as HTMLElement);
ngZone.runOutsideAngular(() => {
renderer.listen(
nativeElement,
'animationend',
() => {
resolve();
},
{once: true},
);
});
value.call(lView[CONTEXT], event);
}
return classList;
// Ensure cleanup if the LView is destroyed before the animation runs.
return promise;
}
function cancelAnimationsIfRunning(element: HTMLElement, renderer: Renderer): void {
@@ -553,15 +529,15 @@ function assertElementNodes(nativeElement: Element, instruction: string) {
function animateLeaveClassRunner(
el: HTMLElement,
tNode: TNode,
classList: Set<string>,
finalRemoveFn: VoidFunction,
classList: string[],
renderer: Renderer,
animationsDisabled: boolean,
ngZone: NgZone,
resolver: VoidFunction,
) {
if (animationsDisabled) {
longestAnimations.delete(el);
finalRemoveFn();
resolver();
return;
}
@@ -576,8 +552,17 @@ function animateLeaveClassRunner(
event.stopImmediatePropagation();
longestAnimations.delete(el);
clearLeavingNodes(tNode, el);
finalRemoveFn();
if (Array.isArray(tNode.projection)) {
// in the content projection case, the element is not destroyed.
// So we need to remove the class at the end so that it isn't left
// behind for whenever the item shows up again.
for (const item of classList) {
renderer.removeClass(el, item);
}
}
}
resolver();
};
ngZone.runOutsideAngular(() => {
@@ -596,7 +581,7 @@ function animateLeaveClassRunner(
determineLongestAnimation(el, longestAnimations, areAnimationSupported);
if (!longestAnimations.has(el)) {
clearLeavingNodes(tNode, el);
finalRemoveFn();
resolver();
}
});
});
@@ -25,7 +25,7 @@ import {ComponentTemplate, HostBindingsFunction, RenderFlags} from '../interface
import {
CONTEXT,
EFFECTS_TO_SCHEDULE,
ENTER_ANIMATIONS,
ANIMATIONS,
ENVIRONMENT,
FLAGS,
InitPhaseState,
@@ -232,6 +232,7 @@ export function refreshView<T>(
if (templateFn !== null) {
executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
}
runEnterAnimations(lView);
const hooksInitPhaseCompleted =
(flags & LViewFlags.InitPhaseStateMask) === InitPhaseState.InitPhaseCompleted;
@@ -261,7 +262,6 @@ export function refreshView<T>(
// `LView` but its declaration appears after the insertion component.
markTransplantedViewsForRefresh(lView);
}
runEnterAnimations(lView);
runEffectsInView(lView);
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Global);
@@ -375,11 +375,12 @@ export function refreshView<T>(
}
function runEnterAnimations(lView: LView) {
if (lView[ENTER_ANIMATIONS]) {
for (let animateFn of lView[ENTER_ANIMATIONS]) {
const animationData = lView[ANIMATIONS];
if (animationData?.enter) {
for (const animateFn of animationData.enter) {
animateFn();
}
lView[ENTER_ANIMATIONS] = null;
animationData.enter = undefined;
}
}
+4 -8
View File
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {LeaveAnimation} from '../../animation/interfaces';
import {AnimationLViewData} from '../../animation/interfaces';
import type {ChangeDetectionScheduler} from '../../change_detection/scheduling/zoneless_scheduling';
import {TDeferBlockDetails} from '../../defer/interfaces';
import type {Injector} from '../../di/injector';
@@ -70,8 +70,7 @@ export const EFFECTS_TO_SCHEDULE = 22;
export const EFFECTS = 23;
export const REACTIVE_TEMPLATE_CONSUMER = 24;
export const AFTER_RENDER_SEQUENCES_TO_ADD = 25;
export const ENTER_ANIMATIONS = 26;
export const LEAVE_ANIMATIONS = 27;
export const ANIMATIONS = 26;
/**
* Size of LView's header. Necessary to adjust for it when setting slots.
@@ -80,7 +79,7 @@ export const LEAVE_ANIMATIONS = 27;
* instruction index into `LView` index. All other indexes should be in the `LView` index space and
* there should be no need to refer to `HEADER_OFFSET` anywhere else.
*/
export const HEADER_OFFSET = 28;
export const HEADER_OFFSET = 27;
// This interface replaces the real LView interface if it is an arg or a
// return value of a public instruction. This ensures we don't need to expose
@@ -372,10 +371,7 @@ export interface LView<T = unknown> extends Array<any> {
[AFTER_RENDER_SEQUENCES_TO_ADD]: AfterRenderSequence[] | null;
// Enter animations that apply to nodes in this view
[ENTER_ANIMATIONS]: Function[] | null;
// Leave animations that apply to nodes in this view
[LEAVE_ANIMATIONS]: Set<LeaveAnimation> | null;
[ANIMATIONS]: AnimationLViewData | null;
}
/**
@@ -45,7 +45,6 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({
'ɵɵCopyDefinitionFeature': r3.ɵɵCopyDefinitionFeature,
'ɵɵInheritDefinitionFeature': r3.ɵɵInheritDefinitionFeature,
'ɵɵExternalStylesFeature': r3.ɵɵExternalStylesFeature,
'ɵɵAnimationsFeature': r3.ɵɵAnimationsFeature,
'ɵɵnextContext': r3.ɵɵnextContext,
'ɵɵnamespaceHTML': r3.ɵɵnamespaceHTML,
'ɵɵnamespaceMathML': r3.ɵɵnamespaceMathML,
+61 -9
View File
@@ -62,6 +62,7 @@ import {
HookData,
HookFn,
HOST,
ANIMATIONS,
LView,
LViewFlags,
NEXT,
@@ -79,6 +80,7 @@ import {assertTNodeType} from './node_assert';
import {profiler} from './profiler';
import {ProfilerEvent} from './profiler_types';
import {getLViewParent, getNativeByTNode, unwrapRNode} from './util/view_utils';
import {allLeavingAnimations} from '../animation/longest_animation';
const enum WalkTNodeTreeAction {
/** node create in the native environment. Run on initial creation. */
@@ -107,6 +109,7 @@ function applyToElementOrContainer(
parent: RElement | null,
lNodeToHandle: RNode | LContainer | LView,
beforeNode?: RNode | null,
parentLView?: LView,
) {
// If this slot was allocated for a text node dynamically created by i18n, the text node itself
// won't be created until i18nApply() in the update block, so this node should be skipped.
@@ -136,9 +139,13 @@ function applyToElementOrContainer(
} else if (action === WalkTNodeTreeAction.Insert && parent !== null) {
nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true);
} else if (action === WalkTNodeTreeAction.Detach) {
nativeRemoveNode(renderer, rNode, isComponent);
runLeaveAnimationsWithCallback(parentLView, () => {
nativeRemoveNode(renderer, rNode, isComponent);
});
} else if (action === WalkTNodeTreeAction.Destroy) {
renderer.destroyNode!(rNode);
runLeaveAnimationsWithCallback(parentLView, () => {
renderer.destroyNode!(rNode);
});
}
if (lContainer != null) {
applyContainer(renderer, action, lContainer, parent, beforeNode);
@@ -278,13 +285,11 @@ export function destroyLView(tView: TView, lView: LView) {
if (isDestroyed(lView)) {
return;
}
const renderer = lView[RENDERER];
if (renderer.destroyNode) {
applyView(tView, lView, renderer, WalkTNodeTreeAction.Destroy, null, null);
}
destroyViewTree(lView);
}
@@ -345,6 +350,32 @@ function cleanUpView(tView: TView, lView: LView): void {
}
}
function runLeaveAnimationsWithCallback(lView: LView | undefined, callback: Function) {
if (lView && lView[ANIMATIONS] && lView[ANIMATIONS].leave) {
const runningAnimations = [];
for (let animateFn of lView[ANIMATIONS].leave) {
runningAnimations.push(animateFn());
}
lView[ANIMATIONS].running = Promise.allSettled(runningAnimations);
lView[ANIMATIONS].leave = undefined;
}
runAfterLeaveAnimations(lView, callback);
}
function runAfterLeaveAnimations(lView: LView | undefined, callback: Function) {
if (lView && lView[ANIMATIONS] && lView[ANIMATIONS].running) {
lView[ANIMATIONS].running.then(() => {
if (lView[ANIMATIONS] && lView[ANIMATIONS].running) {
lView[ANIMATIONS].running = undefined;
}
allLeavingAnimations.delete(lView);
callback();
});
return;
}
callback();
}
/** Removes listeners and unsubscribes from output subscriptions */
function processCleanups(tView: TView, lView: LView): void {
ngDevMode && assertNotReactive(processCleanups.name);
@@ -755,14 +786,28 @@ function applyNodes(
if (!isDetachedByI18n(tNode)) {
if (tNodeType & TNodeType.ElementContainer) {
applyNodes(renderer, action, tNode.child, lView, parentRElement, beforeNode, false);
applyToElementOrContainer(action, renderer, parentRElement, rawSlotValue, beforeNode);
applyToElementOrContainer(
action,
renderer,
parentRElement,
rawSlotValue,
beforeNode,
lView,
);
} else if (tNodeType & TNodeType.Icu) {
const nextRNode = icuContainerIterate(tNode as TIcuContainerNode, lView);
let rNode: RNode | null;
while ((rNode = nextRNode())) {
applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode);
applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode, lView);
}
applyToElementOrContainer(action, renderer, parentRElement, rawSlotValue, beforeNode);
applyToElementOrContainer(
action,
renderer,
parentRElement,
rawSlotValue,
beforeNode,
lView,
);
} else if (tNodeType & TNodeType.Projection) {
applyProjectionRecursive(
renderer,
@@ -774,7 +819,14 @@ function applyNodes(
);
} else {
ngDevMode && assertTNodeType(tNode, TNodeType.AnyRNode | TNodeType.Container);
applyToElementOrContainer(action, renderer, parentRElement, rawSlotValue, beforeNode);
applyToElementOrContainer(
action,
renderer,
parentRElement,
rawSlotValue,
beforeNode,
lView,
);
}
}
tNode = isProjection ? tNode.projectionNext : tNode.next;
@@ -891,7 +943,7 @@ function applyProjectionRecursive(
// This should be refactored and cleaned up.
for (let i = 0; i < nodeToProjectOrRNodes.length; i++) {
const rNode = nodeToProjectOrRNodes[i];
applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode);
applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode, lView);
}
} else {
let nodeToProject: TNode | null = nodeToProjectOrRNodes;
-18
View File
@@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {AnimationRemovalRegistry, ElementRegistry} from '../animation/element_removal_registry';
import {InternalInjectFlags} from '../di/interface/injector';
import {
assertDefined,
@@ -842,20 +841,3 @@ export function wasLastNodeCreated(): boolean {
export function lastNodeWasCreated(flag: boolean): void {
_wasLastNodeCreated = flag;
}
/**
* We create an object here because it's possible the DOM Renderer is created
* before the animation removal registry is defined. The object allows us to
* update the instance once the registry is created.
*/
let registry: AnimationRemovalRegistry = {elements: undefined};
export function setAnimationElementRemovalRegistry(value: ElementRegistry) {
if (registry.elements === undefined) {
registry.elements = value;
}
}
export function getAnimationElementRemovalRegistry(): AnimationRemovalRegistry {
return registry;
}
@@ -27,6 +27,7 @@ ts_project(
"//packages/core/primitives/signals",
"//packages/core/src/di/interface",
"//packages/core/src/util",
"//packages/core/test/animation_utils",
"//packages/core/test/render3:matchers",
"//packages/core/testing",
"//packages/localize",
+32 -46
View File
@@ -13,19 +13,22 @@ import {
Component,
Directive,
ElementRef,
input,
InputSignal,
NgModule,
provideZonelessChangeDetection,
signal,
ViewChild,
} from '@angular/core';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {isNode} from '@angular/private/testing';
import {tickAnimationFrames} from '../animation_utils/tick_animation_frames';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing';
/** Ticks the specified amount of `requestAnimationFrame`-s. */
export function tickAnimationFrames(amount: number) {
tick(16.6 * amount); // Angular turns rAF calls into 16.6ms timeouts in tests.
}
@NgModule({
providers: [provideZonelessChangeDetection()],
})
export class TestModule {}
describe('Animation', () => {
if (isNode) {
@@ -33,6 +36,19 @@ describe('Animation', () => {
return;
}
beforeAll(() => {
TestBed.resetTestEnvironment();
TestBed.initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting());
});
afterAll(() => {
TestBed.resetTestEnvironment();
TestBed.initTestEnvironment(
[BrowserTestingModule, NoopAnimationsModule, TestModule],
platformBrowserTesting(),
);
});
describe('animate.leave', () => {
const styles = `
.fade {
@@ -83,6 +99,7 @@ describe('Animation', () => {
paragragh.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"');
expect(logSpy).toHaveBeenCalled();
}));
@@ -191,6 +208,7 @@ describe('Animation', () => {
paragragh.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"');
}));
@@ -251,6 +269,7 @@ describe('Animation', () => {
paragragh.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"');
}));
@@ -312,6 +331,7 @@ describe('Animation', () => {
paragragh.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"');
}));
@@ -379,6 +399,7 @@ describe('Animation', () => {
fadeCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"');
}));
@@ -421,6 +442,7 @@ describe('Animation', () => {
fadeCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"');
}));
@@ -493,12 +515,12 @@ describe('Animation', () => {
@Component({
selector: 'child-cmp',
styles: multiple,
host: {'[animate.leave]': 'clazz'},
host: {'[animate.leave]': 'slide()'},
template: '<p>I should fade</p>',
encapsulation: ViewEncapsulation.None,
})
class ChildComponent {
clazz = 'slide-out';
slide = signal('slide-out');
}
@Component({
@@ -525,10 +547,8 @@ describe('Animation', () => {
fixture.detectChanges();
tickAnimationFrames(1);
expect(cmp.show()).toBeFalsy();
fixture.detectChanges();
expect(childCmp.nativeElement.className).toContain('fade');
expect(childCmp.nativeElement.className).toContain('slide-out');
fixture.detectChanges();
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-out'}),
@@ -536,6 +556,7 @@ describe('Animation', () => {
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('fade');
expect(fixture.nativeElement.outerHTML).not.toContain('slide-out');
@@ -612,7 +633,7 @@ describe('Animation', () => {
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'slide-out'}),
);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('slide-out');
expect(fixture.debugElement.query(By.css('child-cmp'))).toBeNull();
}));
@@ -1245,41 +1266,6 @@ describe('Animation', () => {
expect(childCmp.nativeElement.className).not.toContain('slide-in fade-in');
}));
xit('should support signal inputs', fakeAsync(() => {
@Component({
selector: 'child-cmp',
styles: styles,
template: '<p [animate.enter]="enterAnim()">I should fade</p>',
encapsulation: ViewEncapsulation.None,
})
class ChildComponent {
public enterAnim: InputSignal<string | string[]> = input.required<string | string[]>();
}
@Component({
selector: 'test-cmp',
styles: styles,
imports: [ChildComponent],
template: '<child-cmp enterAnim="fade-in" />',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
tickAnimationFrames(1);
const childCmp = fixture.debugElement.query(By.css('child-cmp'));
expect(childCmp.nativeElement.className).toContain('fade-in');
childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
fixture.detectChanges();
expect(childCmp.nativeElement.className).not.toContain('fade-in');
}));
it('should reset leave animation and not duplicate node when toggled quickly', fakeAsync(() => {
const animateStyles = `
.slide-in {
@@ -14,6 +14,8 @@ TEST_DEPS = [
"//packages/platform-browser:platform-browser",
"//packages/core/primitives/signals:signals",
"//:node_modules/rxjs",
"//packages/core/test/animation_utils",
"//packages/private/testing",
]
ts_project(
@@ -17,7 +17,11 @@ import {
ViewChild,
} from '@angular/core';
import {SIGNAL} from '../../../primitives/signals';
import {TestBed} from '../../../testing';
import {fakeAsync, TestBed, tick} from '../../../testing';
import {ViewEncapsulation} from '@angular/compiler';
import {By} from '@angular/platform-browser';
import {tickAnimationFrames} from '../../animation_utils/tick_animation_frames';
import {isNode} from '@angular/private/testing';
describe('signal inputs', () => {
beforeEach(() =>
@@ -314,4 +318,74 @@ describe('signal inputs', () => {
expect(host.dir.value[SIGNAL].debugName).toBe('TEST_DEBUG_NAME');
});
describe('animation API', () => {
if (isNode) {
it('should pass', () => expect(true).toBe(true));
return;
}
it('should support signal inputs', fakeAsync(() => {
const styles = `
.slide-in {
animation: slide-in 1ms;
}
.fade-in {
animation: fade-in 2ms;
}
@keyframes slide-in {
from {
transform: translateX(-10px);
}
to {
transform: translateX(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
@Component({
selector: 'child-cmp',
styles: styles,
template: '<p [animate.enter]="enterAnim()">I should fade</p>',
encapsulation: ViewEncapsulation.None,
})
class ChildComponent {
enterAnim = input.required<string | string[]>();
}
@Component({
selector: 'test-cmp',
styles: styles,
imports: [ChildComponent],
template: '<child-cmp [enterAnim]="fade" />',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
fade = 'fade-in';
}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
tickAnimationFrames(1);
const childCmp = fixture.debugElement.query(By.css('p'));
expect(childCmp.nativeElement.className).toContain('fade-in');
childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
fixture.detectChanges();
tick();
expect(childCmp.nativeElement.className).not.toContain('fade-in');
}));
});
});
@@ -400,9 +400,6 @@ function getRendererFactory2(document: Document): RendererFactory2 {
isNode ? PLATFORM_SERVER_ID : PLATFORM_BROWSER_ID,
fakeNgZone,
null,
true,
0,
null,
);
const origCreateRenderer = rendererFactory.createRenderer;
rendererFactory.createRenderer = function (element: any, type: RendererType2 | null) {
@@ -0,0 +1,14 @@
load("//tools:defaults.bzl", "ts_project")
package(default_visibility = ["//visibility:public"])
ts_project(
name = "animation_utils",
testonly = True,
srcs = [
"tick_animation_frames.ts",
],
deps = [
"//packages/core/testing",
],
)
@@ -0,0 +1,14 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {tick} from '@angular/core/testing';
/** Ticks the specified amount of `requestAnimationFrame`-s. */
export function tickAnimationFrames(amount: number) {
tick(16.6 * amount); // Angular turns rAF calls into 16.6ms timeouts in tests.
}
@@ -2,7 +2,7 @@
"chunks": {
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS_DISABLED",
"ANIMATIONS",
"ANIMATION_MODULE_TYPE",
"ANIMATION_PREFIX",
"ANY_STATE",
@@ -141,8 +141,6 @@
"MATH_ML_NAMESPACE",
"MAXIMUM_REFRESH_RERUNS",
"MAXIMUM_REFRESH_RERUNS",
"MAX_ANIMATION_TIMEOUT",
"MAX_ANIMATION_TIMEOUT_DEFAULT",
"MODIFIER_KEYS",
"MODIFIER_KEY_GETTERS",
"MONKEY_PATCH_KEY_NAME",
@@ -403,6 +401,7 @@
"addClass",
"addServerStyles",
"addToEndOfViewTree",
"allLeavingAnimations",
"allocExpando",
"allocLFrame",
"allowPreviousPlayerStylesMerge",
@@ -562,7 +561,6 @@
"forwardRef",
"freeConsumers",
"getActiveConsumer",
"getAnimationElementRemovalRegistry",
"getBaseElementHref",
"getBindingsEnabled",
"getBodyNode",
@@ -836,7 +834,6 @@
"registerLView",
"registerPostOrderHooks",
"registerPreOrderHooks",
"registry",
"relativePath",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
@@ -861,8 +858,11 @@
"resolveTimingValue",
"retrieveHydrationInfo",
"roundOffset",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
"saveNameToExportMap",
"saveResolvedLocalsInData",
@@ -68,7 +68,7 @@
],
"lazy": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS_DISABLED",
"ANIMATIONS",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_INITIALIZER",
@@ -161,8 +161,6 @@
"MATH_ML_NAMESPACE",
"MAXIMUM_REFRESH_RERUNS",
"MAXIMUM_REFRESH_RERUNS",
"MAX_ANIMATION_TIMEOUT",
"MAX_ANIMATION_TIMEOUT_DEFAULT",
"MINIMUM_SLOT",
"MONKEY_PATCH_KEY_NAME",
"MOVED_VIEWS",
@@ -348,6 +346,7 @@
"addToArray",
"addToEndOfViewTree",
"addViewToDOM",
"allLeavingAnimations",
"allocExpando",
"allocLFrame",
"angularZoneInstanceIdProperty",
@@ -480,7 +479,6 @@
"forwardRef",
"freeConsumers",
"getActiveConsumer",
"getAnimationElementRemovalRegistry",
"getBeforeNodeForView",
"getBindingsEnabled",
"getCleanupFnKeyByType",
@@ -709,7 +707,6 @@
"registerLView",
"registerPostOrderHooks",
"registerPreOrderHooks",
"registry",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
"removeFromArray",
@@ -729,8 +726,11 @@
"resolveDirectives",
"resolveForwardRef",
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
"saveNameToExportMap",
"saveResolvedLocalsInData",
@@ -2,7 +2,7 @@
"chunks": {
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS_DISABLED",
"ANIMATIONS",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -142,8 +142,6 @@
"MATH_ML_NAMESPACE",
"MAXIMUM_REFRESH_RERUNS",
"MAXIMUM_REFRESH_RERUNS",
"MAX_ANIMATION_TIMEOUT",
"MAX_ANIMATION_TIMEOUT_DEFAULT",
"MODIFIER_KEYS",
"MODIFIER_KEY_GETTERS",
"MONKEY_PATCH_KEY_NAME",
@@ -438,6 +436,7 @@
"addToEndOfViewTree",
"addValidators",
"addViewToDOM",
"allLeavingAnimations",
"allocExpando",
"allocLFrame",
"angularZoneInstanceIdProperty",
@@ -630,7 +629,6 @@
"fromPromise",
"fromReadableStreamLike",
"getActiveConsumer",
"getAnimationElementRemovalRegistry",
"getBaseElementHref",
"getBeforeNodeForView",
"getBindingsEnabled",
@@ -966,7 +964,6 @@
"registerOnValidatorChange",
"registerPostOrderHooks",
"registerPreOrderHooks",
"registry",
"relativePath",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
@@ -991,8 +988,11 @@
"resolveForwardRef",
"resolveProvider",
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
"runPostProducerCreatedFn",
"saveNameToExportMap",
@@ -2,7 +2,7 @@
"chunks": {
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS_DISABLED",
"ANIMATIONS",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -136,8 +136,6 @@
"MATH_ML_NAMESPACE",
"MAXIMUM_REFRESH_RERUNS",
"MAXIMUM_REFRESH_RERUNS",
"MAX_ANIMATION_TIMEOUT",
"MAX_ANIMATION_TIMEOUT_DEFAULT",
"MODIFIER_KEYS",
"MODIFIER_KEY_GETTERS",
"MONKEY_PATCH_KEY_NAME",
@@ -439,6 +437,7 @@
"addToEndOfViewTree",
"addValidators",
"addViewToDOM",
"allLeavingAnimations",
"allocExpando",
"allocLFrame",
"angularZoneInstanceIdProperty",
@@ -628,7 +627,6 @@
"fromPromise",
"fromReadableStreamLike",
"getActiveConsumer",
"getAnimationElementRemovalRegistry",
"getBaseElementHref",
"getBeforeNodeForView",
"getBindingsEnabled",
@@ -961,7 +959,6 @@
"registerOnValidatorChange",
"registerPostOrderHooks",
"registerPreOrderHooks",
"registry",
"relativePath",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
@@ -986,8 +983,11 @@
"resolvedPromise",
"resolvedPromise",
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
"runPostProducerCreatedFn",
"saveNameToExportMap",
@@ -4,7 +4,7 @@
"ACCEPT_HEADER_VALUE",
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ALLOWED_METHODS",
"ANIMATIONS_DISABLED",
"ANIMATIONS",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -122,8 +122,6 @@
"MATH_ML_NAMESPACE",
"MAXIMUM_REFRESH_RERUNS",
"MAXIMUM_REFRESH_RERUNS",
"MAX_ANIMATION_TIMEOUT",
"MAX_ANIMATION_TIMEOUT_DEFAULT",
"MODIFIER_KEYS",
"MODIFIER_KEY_GETTERS",
"MONKEY_PATCH_KEY_NAME",
@@ -345,6 +343,7 @@
"addAfterRenderSequencesForView",
"addServerStyles",
"addToEndOfViewTree",
"allLeavingAnimations",
"allocExpando",
"allocLFrame",
"angularZoneInstanceIdProperty",
@@ -505,7 +504,6 @@
"fromReadableStreamLike",
"generateHash",
"getActiveConsumer",
"getAnimationElementRemovalRegistry",
"getBaseElementHref",
"getClosestRElement",
"getClosureSafeProperty",
@@ -750,7 +748,6 @@
"registerLView",
"registerPostOrderHooks",
"registerPreOrderHooks",
"registry",
"relativePath",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
@@ -773,8 +770,11 @@
"retrieveHydrationInfo",
"retrieveHydrationInfoImpl",
"retrieveTransferredState",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
"saveNameToExportMap",
"scheduleArray",
@@ -3,7 +3,7 @@
"main": [
"ABSOLUTE_REDIRECT_ERROR_NAME",
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS_DISABLED",
"ANIMATIONS",
"APP_BASE_HREF",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
@@ -148,8 +148,6 @@
"MAXIMUM_REFRESH_RERUNS",
"MAXIMUM_REFRESH_RERUNS",
"MAX_ALLOWED_REDIRECTS",
"MAX_ANIMATION_TIMEOUT",
"MAX_ANIMATION_TIMEOUT_DEFAULT",
"MODIFIER_KEYS",
"MODIFIER_KEY_GETTERS",
"MONKEY_PATCH_KEY_NAME",
@@ -519,6 +517,7 @@
"addViewToDOM",
"advanceActivatedRoute",
"afterNextNavigation",
"allLeavingAnimations",
"allocExpando",
"allocLFrame",
"allowSanitizationBypassAndThrow",
@@ -749,7 +748,6 @@
"fromReadableStreamLike",
"getActiveConsumer",
"getAllRouteGuards",
"getAnimationElementRemovalRegistry",
"getBaseElementHref",
"getBeforeNodeForView",
"getBindingsEnabled",
@@ -1104,7 +1102,6 @@
"registerLView",
"registerPostOrderHooks",
"registerPreOrderHooks",
"registry",
"relativePath",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
@@ -1126,6 +1123,7 @@
"resolveNode",
"retrieveHydrationInfo",
"rootRoute",
"runAfterLeaveAnimations",
"runCanActivate",
"runCanActivateChecks",
"runCanActivateChild",
@@ -1134,7 +1132,9 @@
"runCanLoadGuards",
"runCanMatchGuards",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
"runPostProducerCreatedFn",
"runResolve",
@@ -2,7 +2,7 @@
"chunks": {
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS_DISABLED",
"ANIMATIONS",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -100,8 +100,6 @@
"MATH_ML_NAMESPACE",
"MAXIMUM_REFRESH_RERUNS",
"MAXIMUM_REFRESH_RERUNS",
"MAX_ANIMATION_TIMEOUT",
"MAX_ANIMATION_TIMEOUT_DEFAULT",
"MODIFIER_KEYS",
"MODIFIER_KEY_GETTERS",
"MONKEY_PATCH_KEY_NAME",
@@ -286,6 +284,7 @@
"addAfterRenderSequencesForView",
"addServerStyles",
"addToEndOfViewTree",
"allLeavingAnimations",
"allocExpando",
"allocLFrame",
"angularZoneInstanceIdProperty",
@@ -408,7 +407,6 @@
"forwardRef",
"freeConsumers",
"getActiveConsumer",
"getAnimationElementRemovalRegistry",
"getBaseElementHref",
"getClosestRElement",
"getClosureSafeProperty",
@@ -605,7 +603,6 @@
"registerLView",
"registerPostOrderHooks",
"registerPreOrderHooks",
"registry",
"relativePath",
"rememberChangeHistoryAndInvokeOnChangesHook",
"remove",
@@ -623,8 +620,11 @@
"resolveDirectives",
"resolveForwardRef",
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
"saveNameToExportMap",
"scheduleCallbackWithMicrotask",
@@ -63,9 +63,6 @@ export function getRendererFactory2(document: any): RendererFactory2 {
isNode ? PLATFORM_SERVER_ID : PLATFORM_BROWSER_ID,
fakeNgZone,
null,
true,
0,
null,
);
const origCreateRenderer = rendererFactory.createRenderer;
rendererFactory.createRenderer = function (element: any, type: RendererType2 | null) {
@@ -26,10 +26,7 @@ import {
ɵTracingService as TracingService,
ɵTracingSnapshot as TracingSnapshot,
Optional,
ɵAnimationRemovalRegistry as AnimationRemovalRegistry,
ɵgetAnimationElementRemovalRegistry as getAnimationElementRemovalRegistry,
ɵANIMATIONS_DISABLED as ANIMATIONS_DISABLED,
MAX_ANIMATION_TIMEOUT,
ɵallLeavingAnimations as allLeavingAnimations,
} from '@angular/core';
import {RuntimeErrorCode} from '../errors';
@@ -140,7 +137,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
>();
private readonly defaultRenderer: Renderer2;
private readonly platformIsServer: boolean;
private registry: AnimationRemovalRegistry;
constructor(
private readonly eventManager: EventManager,
@@ -151,8 +147,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
@Inject(PLATFORM_ID) readonly platformId: Object,
readonly ngZone: NgZone,
@Inject(CSP_NONCE) private readonly nonce: string | null = null,
@Inject(ANIMATIONS_DISABLED) private readonly animationDisabled: boolean,
@Inject(MAX_ANIMATION_TIMEOUT) private readonly maxAnimationTimeout: number,
@Inject(TracingService)
@Optional()
private readonly tracingService: TracingService<TracingSnapshot> | null = null,
@@ -164,8 +158,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
ngZone,
this.platformIsServer,
this.tracingService,
(this.registry = getAnimationElementRemovalRegistry()),
this.maxAnimationTimeout,
);
}
@@ -221,9 +213,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
ngZone,
platformIsServer,
tracingService,
this.registry,
this.animationDisabled,
this.maxAnimationTimeout,
);
break;
case ViewEncapsulation.ShadowDom:
@@ -236,8 +225,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
this.nonce,
platformIsServer,
tracingService,
this.registry,
this.maxAnimationTimeout,
sharedStylesHost,
);
case ViewEncapsulation.IsolatedShadowDom:
@@ -250,8 +237,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
this.nonce,
platformIsServer,
tracingService,
this.registry,
this.maxAnimationTimeout,
);
default:
@@ -264,9 +249,6 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
ngZone,
platformIsServer,
tracingService,
this.registry,
this.animationDisabled,
this.maxAnimationTimeout,
);
break;
}
@@ -305,8 +287,6 @@ class DefaultDomRenderer2 implements Renderer2 {
protected readonly ngZone: NgZone,
private readonly platformIsServer: boolean,
private readonly tracingService: TracingService<TracingSnapshot> | null,
protected readonly registry: AnimationRemovalRegistry,
protected readonly maxAnimationTimeout: number,
) {}
destroy(): void {}
@@ -351,11 +331,6 @@ class DefaultDomRenderer2 implements Renderer2 {
}
removeChild(_parent: any, oldChild: any): void {
const {elements} = this.registry;
if (elements) {
elements.animate(oldChild, () => oldChild.remove(), this.maxAnimationTimeout);
return;
}
// child was removed
oldChild.remove();
}
@@ -544,19 +519,9 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
nonce: string | null,
platformIsServer: boolean,
tracingService: TracingService<TracingSnapshot> | null,
registry: AnimationRemovalRegistry,
maxAnimationTimeout: number,
private sharedStylesHost?: SharedStylesHost,
) {
super(
eventManager,
doc,
ngZone,
platformIsServer,
tracingService,
registry,
maxAnimationTimeout,
);
super(eventManager, doc, ngZone, platformIsServer, tracingService);
this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'});
// SharedStylesHost is used to add styles to the shadow root by ShadowDom.
@@ -632,7 +597,6 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
private readonly styles: string[];
private readonly styleUrls?: string[];
private readonly _animationDisabled: boolean;
constructor(
eventManager: EventManager,
@@ -643,21 +607,9 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
ngZone: NgZone,
platformIsServer: boolean,
tracingService: TracingService<TracingSnapshot> | null,
registry: AnimationRemovalRegistry,
animationDisabled: boolean,
maxAnimationTimeout: number,
compId?: string,
) {
super(
eventManager,
doc,
ngZone,
platformIsServer,
tracingService,
registry,
maxAnimationTimeout,
);
this._animationDisabled = animationDisabled;
super(eventManager, doc, ngZone, platformIsServer, tracingService);
let styles = component.styles;
if (ngDevMode) {
// We only do this in development, as for production users should not add CSS sourcemaps to components.
@@ -677,32 +629,9 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
if (!this.removeStylesOnCompDestroy) {
return;
}
// In the case that animate.leave animations are used, depending on
// app structure, a race condition happens with the destroy call and
// the animation being added to the element. Either the DOM node is
// immediately removed or the animate instruction is called right
// as the styles are pruned, causing the animated element to sit
// until the timeout removes it. This delays the pruning of style
// sheets for a few seconds to avoid this problem.
//
// TODO(thePunderWoman): replace this with a more targeted delay on only
// cases where we know there's a leave animation, that the leave animation
// is actually running, and to instead use the longest animation value
// for the timeout duration.
if (
(typeof ngServerMode === 'undefined' || !ngServerMode) &&
!this._animationDisabled &&
this.registry.elements
) {
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
this.sharedStylesHost.removeStyles(this.styles, this.styleUrls);
}, this.maxAnimationTimeout);
});
return;
if (allLeavingAnimations.size === 0) {
this.sharedStylesHost.removeStyles(this.styles, this.styleUrls);
}
this.sharedStylesHost.removeStyles(this.styles, this.styleUrls);
}
}
@@ -720,9 +649,6 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer {
ngZone: NgZone,
platformIsServer: boolean,
tracingService: TracingService<TracingSnapshot> | null,
registry: AnimationRemovalRegistry,
animationDisabled: boolean,
maxAnimationTimeout: number,
) {
const compId = appId + '-' + component.id;
super(
@@ -734,9 +660,6 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer {
ngZone,
platformIsServer,
tracingService,
registry,
animationDisabled,
maxAnimationTimeout,
compId,
);
this.contentAttr = shimContentAttribute(compId);
@@ -2076,7 +2076,7 @@ describe('platform-server full application hydration integration', () => {
const content = clientRootNode.querySelector('app-content');
expect(content.innerHTML).toBe(
'Start Inner Start Hello <span>World</span>! <!--ICU 27:0--> Inner End Middle <span>Span</span> End',
'Start Inner Start Hello <span>World</span>! <!--ICU 28:0--> Inner End Middle <span>Span</span> End',
);
});
@@ -2130,7 +2130,7 @@ describe('platform-server full application hydration integration', () => {
const content = clientRootNode.querySelector('app-content-outer');
expect(content.innerHTML).toBe(
'<app-content-inner>Start Outer Start <span>Span</span> Hello <span>World</span>! <!--ICU 27:0--> Outer End Middle End</app-content-inner>',
'<app-content-inner>Start Outer Start <span>Span</span> Hello <span>World</span>! <!--ICU 28:0--> Outer End Middle End</app-content-inner>',
);
});
@@ -2371,7 +2371,7 @@ describe('platform-server full application hydration integration', () => {
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
const div = clientRootNode.querySelector('div');
expect(div.innerHTML).toMatch(/Some <strong>strong<\/strong><!--ICU 27:0--> content/);
expect(div.innerHTML).toMatch(/Some <strong>strong<\/strong><!--ICU 28:0--> content/);
});
it('should support translations that remove elements', async () => {