DEV Community

Cover image for Angular registering animation triggers
Gianpiero Errigo for This is Angular

Posted on

Angular registering animation triggers

This post keeps digging into AnimationRendererFactory creation routine, with a focus on triggers registering logic.


AnimationRenderer, the real one

Inside first article of this series we examined main concepts about Angular's AnimationRendererFactory and how it creates a "dumb" BaseAnimationRenderer to allow components not declaring any animation trigger to be known by the animation engine.

In this post we're going to analyze the creation process of a more interesting child class of the former renderer: AnimationRenderer.
An instance of this type is the one generated for components declaring one or more triggers inside their @Component decorator metadata animations property, which are needed to be registered with the engine before injecting the latter inside renderer constructor.

RendererType2: component's rendering metadata

We already seen factory's createRenderer expecting a type argument, declared as RendererType2.
This interface is defined to store some rendering information generated at component creation.

interface RendererType2 {
  id: string;
  encapsulation: ViewEncapsulation;
  styles: (string | any[])[];
  data: {[kind: string]: any};
}
Enter fullscreen mode Exit fullscreen mode

If second and third ones remind you of some @Component decorator's metadata, that's because the framework takes their values exactly from there when creating a component, being it routed or a child one.
Let's examine them one by one:

  • id: this is an identifier generated by compiler for the specific Component class. Don't be fooled by misleading official doc definition: A unique identifying string for the new renderer. The assertion could let the reader think everytime he creates a new instance of the component, this will get a new id, but that's not true. The property has to be considered as a static class field: every instance of the same @Component class will get the same id. Official definition is not wrong per-se, but take in account only DomRendererFactory2, that for every instance of the same component will return the same cached renderer (at least for ViewEncapsulation.Emulated), making the id effectively unique for all EmulatedEncapsulationDomRenderer2 generated for that class, and obviously for the single instance of DefaultDomRenderer2 returned in some conditions. This definition loses its accountability when talking about AnimationRenderer, where two different instances of the same component will get assigned two different instances of the renderer, due to the introduction of namespace concept. (More about this later)
  • encapsulation: with this value DomRendererFactory2 choose which type of encapsulation-specific renderer to create, through a simple switch statement:
switch (type.encapsulation) {
  case ViewEncapsulation.Emulated: {
    ...
    renderer = new EmulatedEncapsulationDomRenderer2(
      this.eventManager, this.sharedStylesHost, type, this.appId);
    ...
    return renderer;
  }
  case ViewEncapsulation.ShadowDom:
    return new ShadowDomRenderer(this.eventManager, this.sharedStylesHost, element, type);
  default: {
    ...
    return this.defaultRenderer;
  }
Enter fullscreen mode Exit fullscreen mode
  • styles: CSS style or list of styles to be assigned to host element of rendered component
  • data: this property shaped as an open dictionary could sound "alien", but it's actually the one used to define a whole set of optional characteristics of the component that's gonna be created. It's crucial for our case study because that's where AnimationRenderer will look for declared AnimationTriggerMetadata to be registered.

Namespace independently tracking state of component instances triggers

To keep track of individual component instances' triggers state, Angular animation package introduces the concept of AnimationTransitionNamespace.
You can think of it as a wrapper grouping all elements of a single animated component instance.
As already explained, id property from Renderer2 interface is unique for component's class, thus is not enough to distinguish among different instances of same component.
That's why the factory declares a new property, incremented on every AnimationRenderer creation, and concatenated with Renderer2's id to compose an identifier unique for namespace, used to register the latter with the engine:

const componentId = type.id;
const namespaceId = type.id + '-' + this._currentId;
this._currentId++;

this.engine.register(namespaceId, hostElement);
Enter fullscreen mode Exit fullscreen mode

You can notice these identifiers added as a class to every DOM element of the host component: they are all prepended by ng-tns- string:

ns_classes_added
Look at the ng-tns-c19-8 class assigned to <app-home> element: this means that HomeComponent got c19 as its RendererType2.id, and that this specific instance has been bound to the ninth (_currentId is initialized as 0) AnimationRenderer created by the factory, which being injected as singleton make that number the creation order along the whole app.
(AFAIK, these identifiers have just a naming purpose, so their order should be of no interest.)

You can see that the same class has been added to everyone of its children, both <div> and <app-child> nodes.
But in the second a difference catches the eye:
another namespace class is assigned beside the parent's one, ng-tns-c18-9.
It's clear that this namespace refers to a different component class than the parent one (c-18 vs c-19).
That's expected, being <app-child> the host element of a different component.
Since ChildComponent's declares some triggers inside its decorator animations property, a new AnimationRenderer has been created for it, thus a new namespace.
So, being this component instance both declaring some animations, both included in the template of an animated parent component, it's part of two different namespaces.

Looking at third childnode, another <app-child> element, we notice that parent namespace class is still there, but its own namespace class is different than the one of its sibling: ng-tns-c18-10, same component type id but counter incremented by one.
This concurs with source code we analyzed earlier: even being instances of the same component class, they got separate renderers and are part of distinct namespaces.

Last thing worth noting is the inner elements of child host elements, in our example a <div> inside the expanded last <app-child>: it gets the namespace of component it belongs to, ng-tns-c18-10, but not the one of its grandparent, like its host element does.
This sounds quite logical, considering that <app-child> is included into HomeComponent's template, while child nodes inside its own template are not.

Actual registration of triggers

On top of the source file declaring this factory, we got a couple of type declarations (a type and an interface, to be precise)

// Define a recursive type to allow for nested arrays of `AnimationTriggerMetadata`. Note that an
// interface declaration is used as TypeScript prior to 3.7 does not support recursive type
// references, see https://github.com/microsoft/TypeScript/pull/33050 for details.
type NestedAnimationTriggerMetadata = AnimationTriggerMetadata|RecursiveAnimationTriggerMetadata;
interface RecursiveAnimationTriggerMetadata extends Array<NestedAnimationTriggerMetadata> {}
Enter fullscreen mode Exit fullscreen mode

They look quite tricky at first sight, but reading accompanying comment and analyzing their definitions, it turns out they just form a recursive data structure.
Their use, as we're gonna see, addresses a problem arisen switching from ViewEngine to Ivy, with the new one losing the capabilities of flattening metadata arrays, resulting in wrong registering of triggers passed to @Component as nested arrays, leading to runtime errors.

Compiler parsed our @Component decorators metadata and stored their animations field content inside animation property of aforementioned data open-dictionary field of related RendererType2 object.
Renderer creation routine casts this value to an array of the recursive data structure we already seen has been defined on purpose.
Then it iterates over that, issuing a registration function for every trigger it finds.

const animationTriggers = type.data['animation'] as NestedAnimationTriggerMetadata[];
animationTriggers.forEach(registerTrigger);
Enter fullscreen mode Exit fullscreen mode

This registering function has to take in account the recursive nature of this data structure: the object it gets passed as argument could be an actual trigger to be registered, or an array itself containing triggers definitions, and even an array containing other arrays. This nesting has an unpredictable depth, so our function has to be implemented with a recursive logic.

const registerTrigger = (trigger: NestedAnimationTriggerMetadata) => {
  if (Array.isArray(trigger)) {
    trigger.forEach(registerTrigger);
  } else {
    this.engine.registerTrigger(componentId, namespaceId, hostElement, trigger.name, trigger);
  }
};
Enter fullscreen mode Exit fullscreen mode

We can see it's a common flattening algorithm often used with recursively nested arrays: a function recalling itself until its argument is proved to be an array, when this check fails we are in presence of a trigger definition to be registered with engine (and in turn with specific namespace).

When all defined triggers have been registered, a new AnimationRenderer can be built and returned, injected with the namespaceId composed in previous phase.


Hope this post has been an interesting reading, and since the topic has still many "obscure" details for me, I encourage anyone with a constructive suggestion to get in touch in comments.
Next time we'll take a look at AnimationRenderer actual work.
Cheers.

Top comments (0)