From cc6e1689bdec36b64aa66a512d2db8e94cd160af Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Tue, 5 Aug 2025 14:31:23 +0200 Subject: [PATCH] fix(core): fixes empty animations when recalculating styles Any time something causes styles to recalculate, `element.getAnimations()` will be empty. This updates `animate.enter` and `animate.leave` to rely on `getComputedStyles` to determine the longest animation instead. fixes: #63006 --- .../element_removal_registry.ts} | 74 +--------- packages/core/src/animation/interfaces.ts | 70 +++++++++ .../core/src/animation/longest_animation.ts | 137 ++++++++++++++++++ packages/core/src/core.ts | 6 +- packages/core/src/core_private_export.ts | 4 +- .../render3/features/animations_feature.ts | 2 +- .../src/render3/instructions/animation.ts | 122 +++++++--------- packages/core/src/render3/state.ts | 2 +- .../core/test/acceptance/animation_spec.ts | 10 ++ .../bundling/defer/bundle.golden_symbols.json | 4 +- 10 files changed, 284 insertions(+), 147 deletions(-) rename packages/core/src/{animation.ts => animation/element_removal_registry.ts} (66%) create mode 100644 packages/core/src/animation/interfaces.ts create mode 100644 packages/core/src/animation/longest_animation.ts diff --git a/packages/core/src/animation.ts b/packages/core/src/animation/element_removal_registry.ts similarity index 66% rename from packages/core/src/animation.ts rename to packages/core/src/animation/element_removal_registry.ts index 7565a2206e6d..fa7b77081233 100644 --- a/packages/core/src/animation.ts +++ b/packages/core/src/animation/element_removal_registry.ts @@ -5,75 +5,13 @@ * 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 {InjectionToken} from './di/injection_token'; -/** - * A [DI token](api/core/InjectionToken) that enables or disables all enter and leave animations. - */ -export const ANIMATIONS_DISABLED = new InjectionToken( - typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationsDisabled' : '', - { - providedIn: 'root', - factory: () => false, - }, -); - -/** - * The event type for when `animate.enter` and `animate.leave` are used with function - * callbacks. - * - * @publicApi 20.2 - */ -export type AnimationCallbackEvent = {target: Element; animationComplete: Function}; - -/** - * A [DI token](api/core/InjectionToken) that configures the maximum animation timeout - * before element removal. The default value mirrors from Chrome's cross document - * navigation view transition timeout. It's intended to prevent people from accidentally - * forgetting to call the removal function in their callback. Also serves as a delay - * for when stylesheets are pruned. - * - * @publicApi 20.2 - */ -export const MAX_ANIMATION_TIMEOUT = new InjectionToken( - typeof ngDevMode !== 'undefined' && ngDevMode ? 'MaxAnimationTimeout' : '', - { - providedIn: 'root', - factory: () => MAX_ANIMATION_TIMEOUT_DEFAULT, - }, -); -const MAX_ANIMATION_TIMEOUT_DEFAULT = 4000; - -/** - * The function type for `animate.enter` and `animate.leave` when they are used with - * function callbacks. - * - * @publicApi 20.2 - */ -export type AnimationFunction = (event: AnimationCallbackEvent) => void; - -export type AnimationEventFunction = ( - el: Element, - value: AnimationFunction, -) => AnimationRemoveFunction; -export type AnimationClassFunction = ( - el: Element, - value: Set | null, - resolvers: Function[] | undefined, -) => AnimationRemoveFunction; -export type AnimationRemoveFunction = (removeFn: VoidFunction) => void; - -export interface LongestAnimation { - animationName: string | undefined; - propertyName: string | undefined; - duration: number; -} - -export interface AnimationDetails { - classes: Set | null; - classFns?: Function[]; - animateFn: AnimationRemoveFunction; -} +import { + AnimationClassFunction, + AnimationDetails, + AnimationEventFunction, + AnimationFunction, +} from './interfaces'; export interface AnimationRemovalRegistry { elements: ElementRegistry | undefined; diff --git a/packages/core/src/animation/interfaces.ts b/packages/core/src/animation/interfaces.ts new file mode 100644 index 000000000000..5eb3da353d5c --- /dev/null +++ b/packages/core/src/animation/interfaces.ts @@ -0,0 +1,70 @@ +/** + * @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 {InjectionToken} from '../di/injection_token'; + +/** + * A [DI token](api/core/InjectionToken) that enables or disables all enter and leave animations. + */ +export const ANIMATIONS_DISABLED = new InjectionToken( + typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationsDisabled' : '', + { + providedIn: 'root', + factory: () => false, + }, +); + +/** + * The event type for when `animate.enter` and `animate.leave` are used with function + * callbacks. + * + * @publicApi 20.2 + */ +export type AnimationCallbackEvent = {target: Element; animationComplete: Function}; + +/** + * A [DI token](api/core/InjectionToken) that configures the maximum animation timeout + * before element removal. The default value mirrors from Chrome's cross document + * navigation view transition timeout. It's intended to prevent people from accidentally + * forgetting to call the removal function in their callback. Also serves as a delay + * for when stylesheets are pruned. + * + * @publicApi 20.2 + */ +export const MAX_ANIMATION_TIMEOUT = new InjectionToken( + typeof ngDevMode !== 'undefined' && ngDevMode ? 'MaxAnimationTimeout' : '', + { + providedIn: 'root', + factory: () => MAX_ANIMATION_TIMEOUT_DEFAULT, + }, +); +const MAX_ANIMATION_TIMEOUT_DEFAULT = 4000; + +/** + * The function type for `animate.enter` and `animate.leave` when they are used with + * function callbacks. + * + * @publicApi 20.2 + */ +export type AnimationFunction = (event: AnimationCallbackEvent) => void; + +export type AnimationEventFunction = ( + el: Element, + value: AnimationFunction, +) => AnimationRemoveFunction; +export type AnimationClassFunction = ( + el: Element, + value: Set | null, + resolvers: Function[] | undefined, +) => AnimationRemoveFunction; +export type AnimationRemoveFunction = (removeFn: VoidFunction) => void; + +export interface AnimationDetails { + classes: Set | null; + classFns?: Function[]; + animateFn: AnimationRemoveFunction; +} diff --git a/packages/core/src/animation/longest_animation.ts b/packages/core/src/animation/longest_animation.ts new file mode 100644 index 000000000000..d4217ead1fa9 --- /dev/null +++ b/packages/core/src/animation/longest_animation.ts @@ -0,0 +1,137 @@ +/** + * @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 + */ + +export interface LongestAnimation { + animationName: string | undefined; + propertyName: string | undefined; + duration: number; +} + +/** Parses a CSS time value to milliseconds. */ +function parseCssTimeUnitsToMs(value: string): number { + // Some browsers will return it in seconds, whereas others will return milliseconds. + const multiplier = value.toLowerCase().indexOf('ms') > -1 ? 1 : 1000; + return parseFloat(value) * multiplier; +} + +/** Parses out multiple values from a computed style into an array. */ +function parseCssPropertyValue(computedStyle: CSSStyleDeclaration, name: string): string[] { + const value = computedStyle.getPropertyValue(name); + return value.split(',').map((part) => part.trim()); +} + +/** Gets the transform transition duration, including the delay, of an element in milliseconds. */ +function getLongestComputedTransition(computedStyle: CSSStyleDeclaration): LongestAnimation { + const transitionedProperties = parseCssPropertyValue(computedStyle, 'transition-property'); + const rawDurations = parseCssPropertyValue(computedStyle, 'transition-duration'); + const rawDelays = parseCssPropertyValue(computedStyle, 'transition-delay'); + const longest = {propertyName: '', duration: 0, animationName: undefined}; + for (let i = 0; i < transitionedProperties.length; i++) { + const duration = parseCssTimeUnitsToMs(rawDelays[i]) + parseCssTimeUnitsToMs(rawDurations[i]); + if (duration > longest.duration) { + longest.propertyName = transitionedProperties[i]; + longest.duration = duration; + } + } + return longest; +} + +function getLongestComputedAnimation(computedStyle: CSSStyleDeclaration): LongestAnimation { + const rawNames = parseCssPropertyValue(computedStyle, 'animation-name'); + const rawDelays = parseCssPropertyValue(computedStyle, 'animation-delay'); + const rawDurations = parseCssPropertyValue(computedStyle, 'animation-duration'); + const longest: LongestAnimation = {animationName: '', propertyName: undefined, duration: 0}; + for (let i = 0; i < rawNames.length; i++) { + const duration = parseCssTimeUnitsToMs(rawDelays[i]) + parseCssTimeUnitsToMs(rawDurations[i]); + if (duration > longest.duration) { + longest.animationName = rawNames[i]; + longest.duration = duration; + } + } + return longest; +} + +/** + * Determines the longest animation, but with `getComputedStyles` instead of `getAnimations`. This + * is ultimately safer than getAnimations because it can be used when recalculations are in + * progress. `getAnimations()` will be empty in that case. + */ +function determineLongestAnimationFromComputedStyles( + el: HTMLElement, + animationsMap: WeakMap, +): void { + const computedStyle = getComputedStyle(el); + + const longestAnimation = getLongestComputedAnimation(computedStyle); + const longestTransition = getLongestComputedTransition(computedStyle); + + const longest = + longestAnimation.duration > longestTransition.duration ? longestAnimation : longestTransition; + if (animationsMap.has(el) && animationsMap.get(el)!.duration > longest.duration) { + return; + } + animationsMap.set(el, longest); +} + +/** + * Multiple animations can be set on an element. This grabs an element and + * determines which of those will be the longest duration. If we didn't do + * this, elements would be removed whenever the first animation completes. + * This ensures we get the longest running animation and only remove when + * that animation completes. + */ +export function determineLongestAnimation( + event: AnimationEvent | TransitionEvent, + el: HTMLElement, + animationsMap: WeakMap, + areAnimationSupported: boolean, +): void { + if (!areAnimationSupported || !(event.target instanceof Element) || event.target !== el) return; + const animations = el.getAnimations(); + return animations.length === 0 + ? // fallback to computed styles if getAnimations is empty. This would happen if styles are + // currently recalculating due to a reflow happening elsewhere. + determineLongestAnimationFromComputedStyles(el, animationsMap) + : determineLongestAnimationFromElementAnimations(el, animationsMap, animations); +} + +function determineLongestAnimationFromElementAnimations( + el: HTMLElement, + animationsMap: WeakMap, + animations: Animation[], +): void { + let currentLongest: LongestAnimation = { + animationName: undefined, + propertyName: undefined, + duration: 0, + }; + for (const animation of animations) { + const timing = animation.effect?.getTiming(); + // duration can be a string 'auto' or a number. + const animDuration = typeof timing?.duration === 'number' ? timing.duration : 0; + let duration = (timing?.delay ?? 0) + animDuration; + + let propertyName: string | undefined; + let animationName: string | undefined; + + if ((animation as CSSAnimation).animationName) { + animationName = (animation as CSSAnimation).animationName; + } else { + // Check for CSSTransition specific property + propertyName = (animation as CSSTransition).transitionProperty; + } + + if (duration >= currentLongest.duration) { + currentLongest = {animationName, propertyName, duration}; + } + } + if (animationsMap.has(el) && animationsMap.get(el)!.duration > currentLongest.duration) { + return; + } + animationsMap.set(el, currentLongest); +} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index e80ba70a94fe..e06e4aa89541 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -121,7 +121,11 @@ export {booleanAttribute, numberAttribute} from './util/coercion'; export {REQUEST, REQUEST_CONTEXT, RESPONSE_INIT} from './application/platform_tokens'; export {DOCUMENT} from './document'; export {provideNgReflectAttributes} from './ng_reflect'; -export {AnimationCallbackEvent, AnimationFunction, MAX_ANIMATION_TIMEOUT} from './animation'; +export { + AnimationCallbackEvent, + AnimationFunction, + MAX_ANIMATION_TIMEOUT, +} from './animation/interfaces'; import {global} from './util/global'; if (typeof ngDevMode !== 'undefined' && ngDevMode) { diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 7ddce2e2f952..c0be55ffe893 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -175,5 +175,5 @@ export {ɵassertType} from './type_checking'; export { ElementRegistry as ɵElementRegistry, AnimationRemovalRegistry as ɵAnimationRemovalRegistry, - ANIMATIONS_DISABLED as ɵANIMATIONS_DISABLED, -} from './animation'; +} from './animation/element_removal_registry'; +export {ANIMATIONS_DISABLED as ɵANIMATIONS_DISABLED} from './animation/interfaces'; diff --git a/packages/core/src/render3/features/animations_feature.ts b/packages/core/src/render3/features/animations_feature.ts index a36e53b72599..677be6ae5ef5 100644 --- a/packages/core/src/render3/features/animations_feature.ts +++ b/packages/core/src/render3/features/animations_feature.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ElementRegistry} from '../../animation'; +import {ElementRegistry} from '../../animation/element_removal_registry'; import {setAnimationElementRemovalRegistry} from '../state'; /** diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index 9801b3aba069..eeaa4081c9ac 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -14,9 +14,8 @@ import { AnimationFunction, AnimationRemoveFunction, ANIMATIONS_DISABLED, - getClassListFromValue, - LongestAnimation, -} from '../../animation'; +} from '../../animation/interfaces'; +import {getClassListFromValue} from '../../animation/element_removal_registry'; import {getLView, getCurrentTNode, getTView, getAnimationElementRemovalRegistry} from '../state'; import {RENDERER, INJECTOR, CONTEXT, FLAGS, LViewFlags} from '../interfaces/view'; import {RuntimeError, RuntimeErrorCode} from '../../errors'; @@ -26,6 +25,7 @@ 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'; const DEFAULT_ANIMATIONS_DISABLED = false; const areAnimationSupported = @@ -38,7 +38,8 @@ const noOpAnimationComplete = () => {}; // Tracks the list of classes added to a DOM node from `animate.enter` calls to ensure // we remove all of the classes in the case of animation composition via host bindings. -const enterClassMap = new WeakMap(); +const enterClassMap = new WeakMap(); +const longestAnimations = new WeakMap(); /** * Instruction to handle the `animate.enter` behavior for class bindings. @@ -80,6 +81,8 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn // This also allows us to setup cancellation of animations in progress if the // gets removed early. const handleAnimationStart = (event: AnimationEvent | TransitionEvent) => { + determineLongestAnimation(event, nativeElement, longestAnimations, areAnimationSupported); + setupAnimationCancel(event, renderer); const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend'; ngZone.runOutsideAngular(() => { cleanupFns.push(renderer.listen(nativeElement, eventName, handleInAnimationEnd)); @@ -98,7 +101,7 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn cleanupFns.push(renderer.listen(nativeElement, 'transitionstart', handleAnimationStart)); }); - trackEnterClasses(nativeElement, activeClasses); + trackEnterClasses(nativeElement, activeClasses, cleanupFns); for (const klass of activeClasses) { renderer.addClass(nativeElement as HTMLElement, klass); @@ -114,14 +117,17 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn * host binding. When removing classes, we need the entire list of animation classes * added to properly remove them when the longest animation fires. */ -function trackEnterClasses(el: HTMLElement, classes: string[]) { - const classlist = enterClassMap.get(el); - if (classlist) { - for (const klass of classes) { - classlist.push(klass); +function trackEnterClasses(el: HTMLElement, classList: string[], cleanupFns: Function[]) { + const elementData = enterClassMap.get(el); + if (elementData) { + for (const klass of classList) { + elementData.classList.push(klass); + } + for (const fn of cleanupFns) { + elementData.cleanupFns.push(fn); } } else { - enterClassMap.set(el, classes); + enterClassMap.set(el, {classList, cleanupFns}); } } @@ -321,72 +327,44 @@ function getClassList(value: Set | null, resolvers: Function[] | undefin return classList; } -function cancelAnimationsIfRunning(element: HTMLElement): void { - if (areAnimationSupported) { +function cancelAnimationsIfRunning(element: HTMLElement, renderer: Renderer): void { + if (!areAnimationSupported) return; + const elementData = enterClassMap.get(element); + if (element.getAnimations().length > 0) { for (const animation of element.getAnimations()) { if (animation.playState === 'running') { animation.cancel(); } } - } -} - -/** - * Multiple animations can be set on an element. This grabs an element and - * determines which of those will be the longest duration. If we didn't do - * this, elements would be removed whenever the first animation completes. - * This ensures we get the longest running animation and only remove when - * that animation completes. - */ -function getLongestAnimation( - event: AnimationEvent | TransitionEvent, -): LongestAnimation | undefined { - if (!areAnimationSupported || !(event.target instanceof Element)) return; - const nativeElement = event.target; - const animations = nativeElement.getAnimations(); - if (animations.length === 0) return; - - let currentLongest: LongestAnimation = { - animationName: undefined, - propertyName: undefined, - duration: 0, - }; - for (const animation of animations) { - const timing = animation.effect?.getTiming(); - // duration can be a string 'auto' or a number. - const animDuration = typeof timing?.duration === 'number' ? timing.duration : 0; - let duration = (timing?.delay ?? 0) + animDuration; - - let propertyName: string | undefined; - let animationName: string | undefined; - - if ((animation as CSSAnimation).animationName) { - animationName = (animation as CSSAnimation).animationName; - } else { - // Check for CSSTransition specific property - propertyName = (animation as CSSTransition).transitionProperty; + } else { + if (elementData) { + for (const klass of elementData.classList) { + renderer.removeClass(element as unknown as RElement, klass); + } } - - if (duration >= currentLongest.duration) { - currentLongest = {animationName, propertyName, duration}; + } + // We need to prevent any enter animation listeners from firing if they exist. + if (elementData) { + for (const fn of elementData.cleanupFns) { + fn(); } } - return currentLongest; + longestAnimations.delete(element); + enterClassMap.delete(element); } -function setupAnimationCancel(event: Event, classList: string[] | null, renderer: Renderer) { +function setupAnimationCancel(event: Event, renderer: Renderer) { if (!(event.target instanceof Element)) return; const nativeElement = event.target; if (areAnimationSupported) { + const elementData = enterClassMap.get(nativeElement as HTMLElement); const animations = nativeElement.getAnimations(); if (animations.length === 0) return; for (let animation of animations) { animation.addEventListener('cancel', (event: Event) => { - if (nativeElement === event.target) { - if (classList !== null) { - for (const klass of classList) { - renderer.removeClass(nativeElement as unknown as RElement, klass); - } + if (nativeElement === event.target && elementData?.classList) { + for (const klass of elementData.classList) { + renderer.removeClass(nativeElement as unknown as RElement, klass); } } }); @@ -396,9 +374,9 @@ function setupAnimationCancel(event: Event, classList: string[] | null, renderer function isLongestAnimation( event: AnimationEvent | TransitionEvent, - nativeElement: Element, - longestAnimation: LongestAnimation | undefined, + nativeElement: HTMLElement, ): boolean { + const longestAnimation = longestAnimations.get(nativeElement); return ( nativeElement === event.target && longestAnimation !== undefined && @@ -415,20 +393,19 @@ function animationEnd( renderer: Renderer, cleanupFns: Function[], ) { - const classList = enterClassMap.get(nativeElement); - if (!classList) return; - setupAnimationCancel(event, classList, renderer); - const longestAnimation = getLongestAnimation(event); - if (isLongestAnimation(event, nativeElement, longestAnimation)) { + const elementData = enterClassMap.get(nativeElement); + if (!elementData) return; + if (isLongestAnimation(event, nativeElement)) { // Now that we've found the longest animation, there's no need // to keep bubbling up this event as it's not going to apply to // other elements further up. We don't want it to inadvertently // affect any other animations on the page. event.stopImmediatePropagation(); - for (const klass of classList) { + for (const klass of elementData.classList) { renderer.removeClass(nativeElement, klass); } enterClassMap.delete(nativeElement); + longestAnimations.delete(nativeElement); for (const fn of cleanupFns) { fn(); } @@ -457,23 +434,24 @@ function animateLeaveClassRunner( ngZone: NgZone, ) { if (animationsDisabled) { + longestAnimations.delete(el); finalRemoveFn(); } - cancelAnimationsIfRunning(el); + cancelAnimationsIfRunning(el, renderer); - let longestAnimation: LongestAnimation | undefined; const handleAnimationStart = (event: AnimationEvent | TransitionEvent) => { - longestAnimation = getLongestAnimation(event); + determineLongestAnimation(event, el, longestAnimations, areAnimationSupported); }; const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent) => { - if (isLongestAnimation(event, el, longestAnimation)) { + if (isLongestAnimation(event, el)) { // Now that we've found the longest animation, there's no need // to keep bubbling up this event as it's not going to apply to // other elements further up. We don't want it to inadvertently // affect any other animations on the page. event.stopImmediatePropagation(); + longestAnimations.delete(el); finalRemoveFn(); } }; diff --git a/packages/core/src/render3/state.ts b/packages/core/src/render3/state.ts index 97d51a2a4825..946627c6d662 100644 --- a/packages/core/src/render3/state.ts +++ b/packages/core/src/render3/state.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {AnimationRemovalRegistry, ElementRegistry} from '../animation'; +import {AnimationRemovalRegistry, ElementRegistry} from '../animation/element_removal_registry'; import {InternalInjectFlags} from '../di/interface/injector'; import { assertDefined, diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index 2fcc495ae264..4da6c0bc5c18 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -792,6 +792,16 @@ describe('Animation', () => { fixture.detectChanges(); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"'); + const paragraph = fixture.debugElement.query(By.css('p')); + paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); + paragraph.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'slide-in'}), + ); + paragraph.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'fade-in'}), + ); + expect(fixture.debugElement.nativeElement.className).not.toContain('fade-in'); + expect(fixture.debugElement.nativeElement.className).not.toContain('slide-in'); }); it('should remove right away when animations are disabled', fakeAsync(() => { diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 07b09cb1e019..f37c88bb392d 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -58,7 +58,6 @@ "shimStylesContent" ], "lazy": [ - "DeferComponent", "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS_DISABLED", "APP_BOOTSTRAP_LISTENER", @@ -754,7 +753,8 @@ "wasLastNodeCreated", "writeDirectClass", "writeDirectStyle", - "writeToDirectiveInput" + "writeToDirectiveInput", + "DeferComponent" ] } } \ No newline at end of file