The actual AnimationRenderer

In the previous piece of this series, we explored the core ideas behind Angular's AnimationRendererFactory and the way it produces a "simple" BaseAnimationRenderer, which gives components that don't specify any animation trigger visibility to the animation engine.

Now we're turning our attention to how a more sophisticated subclass of that renderer gets created — the AnimationRenderer. This renderer variant is instantiated for components that include one or more triggers in the animations field of their @Component decorator metadata. Before the renderer can be handed to its constructor, those declared triggers must be properly registered with the animation engine.

RendererType2: the data behind rendering a component

We've previously seen that the factory's createRenderer method receives a type argument, which is defined by the RendererType2 interface. This interface holds a set of rendering-related details that are gathered when the component is created.

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

If the second and third fields look familiar, that's because the framework picks their values directly from the @Component decorator metadata during component creation — whether the component is routed or nested. Let's look at each field:

  • id: This is an identifier that the compiler generates for a specific component class. Don't be confused by the official docs, which call it A unique identifying string for the new renderer. That phrasing might imply that each component instance gets a fresh id, but that's not what happens. This property behaves like a static class field: every instance of the same @Component class shares the same id. The official wording isn't wrong in itself — it makes sense when you consider DomRendererFactory2, which returns the same cached renderer for every instance of a component (at least when ViewEncapsulation.Emulated is in play), so the id ends up being unique for all EmulatedEncapsulationDomRenderer2 instances tied to that class, and for the single DefaultDomRenderer2 object returned under certain conditions. But that reasoning collapses when we look at AnimationRenderer: two instances of the same component get different renderer objects, thanks to the namespace mechanism. (We'll get to that shortly.)
  • encapsulation: This value guides DomRendererFactory2 in choosing which type of renderer to create based on the encapsulation strategy. It's decided through a straightforward 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: The CSS style (or list of styles) that should be applied to the host element of the rendered component.
  • data: While this property is shaped like an open dictionary and might seem a bit out of place, it's actually where you can define a wide range of optional characteristics for the component being created. It's especially important in our case, because this is where AnimationRenderer looks for the declared AnimationTriggerMetadata that need to be registered.

Namespaces and tracking trigger state across component instances

To keep a close eye on the trigger state for each individual component instance, the Angular animation package introduces the AnimationTransitionNamespace. Think of it as a container that groups all elements belonging to a single animated component instance.

As we touched on earlier, the id from the Renderer2 interface is unique per component class, so it can't tell two instances of the same class apart. To solve that, the factory introduces its own counter that increments whenever an AnimationRenderer is created. That number is then appended to the Renderer2 id to form a unique identifier for the namespace, which is what gets registered 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'll notice these identifiers show up as CSS classes on every DOM element belonging to the host component, each one prefixed with ng-tns-:

ns_classes_added
Take a look at the ng-tns-c19-8 class on the <app-home> element. This indicates that HomeComponent uses c19 as its RendererType2.id, and this particular instance is linked to the ninth (since currentId starts at 0) AnimationRenderer the factory has produced. Because the factory is injected as a singleton, that number effectively reflects the creation order across the entire app. (As far as I can tell, these identifiers are purely for naming purposes, so their ordering doesn't carry any real meaning.)

The same class is applied to all of its descendants, including the <div> and <app-child> nodes. But something interesting appears in the second element: another namespace class is present alongside the parent's, specifically ng-tns-c18-9. This namespace clearly refers to a different component class than the parent (since it's c-18 rather than c-19). That makes perfect sense, because <app-child> serves as the host element for a separate component. Since ChildComponent includes triggers in its decorator's animations property, a fresh AnimationRenderer — and therefore a new namespace — is created for it. So this component instance, because it declares its own animations while also being nested inside an animated parent component, ends up belonging to two distinct namespaces.

Moving to the third child node, another <app-child>, we see the parent's namespace class is still attached, but its own namespace class differs from its sibling's: ng-tns-c18-10. It uses the same component type id, but the counter has ticked up by one. This aligns perfectly with the source we reviewed earlier — even though both elements are instances of the same component class, each one receives its own renderer and gets placed in its own namespace.

One final observation: the inner elements of the child host elements — in this example, a <div> nested inside the last expanded <app-child> — pick up the namespace of the component they're actually part of, ng-tns-c18-10, but not the grandparent's, even though their host element does. That's a logical outcome, since <app-child> is embedded in HomeComponent's template while the nodes inside its own template are not.

How triggers get registered

In the file that declares this factory, two type declarations appear — 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

At first glance these declarations look fairly convoluted, but after reading the adjacent comment and tracing through their definitions, it becomes clear they simply describe a recursive data structure.
Their purpose, as we'll see, tackles a problem that appeared during the transition from ViewEngine to Ivy. The new engine lost the ability to flatten metadata arrays, which caused triggers passed to @Component as nested arrays to be registered incorrectly and ultimately fail at runtime.

The compiler processed the metadata from our @Component decorators and saved the contents of their animations field into the animation property of the data open-dictionary field on the corresponding RendererType2 object.
When the renderer is created, it casts this value to an array of that recursive data structure we identified earlier.
Then it loops over the array, invoking a registration function for each trigger encountered.

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

That registration function must respect the recursive structure of the data. The argument it receives could be an actual trigger ready for registration, or it could be an array containing trigger definitions — and that array might itself contain further nested arrays. There's no way to predict the depth of this nesting, so the function needs recursive logic to handle it.

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

This is a standard flattening routine for recursively nested arrays: the function invokes itself repeatedly until its argument turns out not to be an array. At that point we know we've reached an actual trigger definition, which gets registered with the engine — and in turn with the relevant namespace.

Once every defined trigger has been registered, a fresh AnimationRenderer can be constructed and returned, receiving the namespaceId that was assembled in the previous step.


I hope you found this piece engaging. Since the topic still holds many nuances I'm exploring myself, I welcome any constructive thoughts in the comments.
In the next part we'll examine what the AnimationRenderer does in practice.
Until then.