{ "community_link": "https://community.indepth.dev/t/getting-inside-angulars-elementschemaregistry-mechanism/23" }

Getting inside Angular's ElementSchemaRegistry mechanism

In this article I'll explore the mechanics of template checking in Angular. We'll become familiar with ElementSchemaRegistry, its implementations like NO_ERRORS_SCHEMA and its usages.

Getting inside Angular's ElementSchemaRegistry mechanism

I bet every Angular developer encountered such error message in ChromeDevTools console:

<some-custom-tag></some-custom-tag>

Usually, this means you forgot to add a component to the module — so Angular doesn’t know anything about it. Sometimes this error is appears for native HTML tags:

<button data-someAttr="{{someValue}}" >Save</button>
<button data-someAttr=”{{someValue}}>Save</button>

You can see these errors by running article's demo project here.

Although we can easily solve the problem by using [attr.someAttr]="someValue" notation, the question is how does Angular know that a button doesn’t have such attribute? And can we somehow omit Angular checking for our specific cases, e.g. custom elements our of Angular or some specific attributes? It's time to introduce ElementSchemaRegistry from ‘@angular/compiler’ package.

Some background

Angular uses two ways to check component templates to be good:

  1. Generating Type Checking Blocks — you can read more about it in Alexey Zuev article here.
  2. Angular's compiler instance uses TemplateParser which runs DomElementSchemaRegistry  (an implementation of ElementSchemaRegistry) methods for checking template AST (Abstract syntax tree) nodes validity.

ElementSchemaRegistry is an abstract class that defines an interface for schema class for Angular to check its component templates (if there is such an element or if such attr exists for a specific element, etc). Here it is:

import { SchemaMetadata, SecurityContext } from "../core"

export abstract class ElementSchemaRegistry {
  abstract hasProperty(tagName: string, propName: string, schemaMetas: SchemaMetadata[]): boolean;

  abstract hasElement(tagName: string, schemaMetas: SchemaMetadata[]): boolean;

  abstract securityContext(elementName: string, propName: string, isAttribute: boolean): SecurityContext;

  abstract allKnownElementNames(): string[];

  abstract getMappedPropName(propName: string): string;

  abstract getDefaultComponentElementName(): string;

  abstract validateProperty(name: string): { error: boolean, msg?: string };

  abstract validateAttribute(name: string): { error: boolean, msg?: string };

  abstract normalizeAnimationStyleProperty(propName: string): string;

  abstract normalizeAnimationStyleValue(camelCaseProp: string, userProvidedProp: string, val: string | number): { error: string, value: string };
}

As you can see it has specific methods to check if some element or some attribute exists: hasElement and hasAttribute. How does it work under-the-hood?

DomElementSchemaRegistry extends ElementSchemaRegistry

For browser elements checking we have a special class DomElementSchemaRegistry. All DOM entities and its respective attributes are defined there. Here it is:

const SCHEMA: string[] = [  '[Element]|textContent,%classList,className,id,innerHTML,*beforecopy,*beforecut,*beforepaste,*copy,*cut,*paste,*search,*selectstart,*webkitfullscreenchange,*webkitfullscreenerror,*wheel,outerHTML,#scrollLeft,#scrollTop,slot' +      /* added manually to avoid breaking  changes */      
...

And the list is created by exporting browser IDL (interface description language) as we can see from compiler-cli/src/ngtsc/typecheck/src/dom.ts file comments (one of the places where DomElementSchemaRegistry is instantiated).

`DomElementSchemaRegistry`, a schema * maintained by the Angular team via extraction from a browser IDL.

OK, so we have a list of DOM entities and set of methods for checking where an HTML template is valid. How does Angular use it?

How DomElementSchemaRegistry works

DomElementSchemaRegistry is used with both AOT and JIT compilers, let's explore the details.

AOT Compiler

  1. Typescript ts.createProgram starter needs compiler instance from compiler factory function
  2. compiler_factory (let's review AOT) creates schemaParser and feed it to new TemplateParser(…) statement and then templateParser instance are fed to compiler instance constructor (see here).
export function createAotCompiler(
compilerHost: AotCompilerHost, options: AotCompilerOptions,
errorCollector?: (error: any, type?: any) =>
void): {compiler: AotCompiler, reflector: StaticReflector} {
...
const elementSchemaRegistry = new DomElementSchemaRegistry();
const tmplParser = new TemplateParser(
config, staticReflector, expressionParser, elementSchemaRegistry, htmlParser, console, []);
...
const compiler = new AotCompiler(
config, options, compilerHost, staticReflector, resolver, tmplParser,
new StyleCompiler(urlResolver), viewCompiler, typeCheckCompiler,
new NgModuleCompiler(staticReflector),
new InjectableCompiler(staticReflector, !!options.enableIvy), new TypeScriptEmitter(),
summaryResolver, symbolResolver);
return {compiler, reflector: staticReflector};
}

Сompiler instance has two (actually more but we review these two) methods _compileComponent and _createTypeCheckBlock. These methods call _parseTemplate where templateParserInstance.parse is performed. And it starts template parsing process (btw visitor pattern is used to iterate over AST nodes) where our DomElementSchemaRegistry instance and TemplateParseVisitor instance are used to check whether AST node (which represents template element) is valid.

For example here and here you can see code that represents messages at the beginning of the article:

private _assertElementExists(matchElement: boolean, element: html.Element) {
const elName = element.name.replace(/^:xhtml:/, '');
if (!matchElement && !this._schemaRegistry.hasElement(elName, this._schemas)) {
let errorMsg = `'${elName}' is not a known element:\n`;
errorMsg +=
`1. If '${elName}' is an Angular component, then verify that it is part of this module.\n`;
if (elName.indexOf('-') > -1) {
errorMsg +=
`2. If '${elName}' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.`;
} else {
errorMsg +=
`2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.`;
}
this._reportError(errorMsg, element.sourceSpan !);
}

JIT compiler

AOT compiler creates an instance for TemplateParser explicitly (you remember that parser then uses schemaRegistry, which is an instance of DomElementSchemaRegistry):

//angular/angular/blob/8.2.x/packages/compiler/src/aot/compiler_factory.ts
...
const elementSchemaRegistry = new DomElementSchemaRegistry();
const tmplParser = new TemplateParser(config, staticReflector, expressionParser, elementSchemaRegistry, htmlParser, console, []);
...

For instantiating JIT Compiler Angular uses Injector to create respective dependencies instances to its compiler_factory:

export const COMPILER_PROVIDERS = <StaticProvider[]>[
...
{ provide: DomElementSchemaRegistry, deps: []},  
{ provide: ElementSchemaRegistry, useExisting: DomElementSchemaRegistry},
...
]

This means that for JIT build we can substitute DomElementSchemaRegistry with some custom ElementSchema class (we will talk about it later).

Now we know how it works. The question is what to do with this knowledge? Let's explore some use cases.

Use cases for DomElementSchemaRegistry

Let's start with CUSTOM_ERRORS_SCHEMA when we have custom elements in Angular application.

CUSTOM_ERRORS_SCHEMA

If you ever tried to use custom elements in Angular (you can read about it here) — you already know that to prevent template errors we need to add CUSTOM_ERRORS_SCHEMA to schemas:[] property of NgModule decorator object:

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core
...
@NgModule({
declarations: [AppComponent],
schemas: [ CUSTOM_ELEMENTS_SCHEMA ],
...
})
export class AppModule {}

Why? Because Angular doesn't know anything about custom elements tags and treats them as some error in a template. You may wonder  how does adding CUSTOM_ELEMENTS_SCHEMA prevent Angular from throwing an error? Let's see.

You remember that Template parser uses DomElementSchemaRegistry instance  through _schemaRegistry to check for the template node's validity. _schemaRegistry has two methods _schemaRegistry.hasElement and _schemaRegistry.hasProperty. Let's take a look at them in dom_element_schema_registry.ts file:

hasProperty(tagName: string, propName: string, schemaMetas: SchemaMetadata[]): boolean {
  if (schemaMetas.some((schema) => schema.name === NO_ERRORS_SCHEMA.name)) { return true; }
  if (tagName.indexOf('-') > -1) {
    if (isNgContainer(tagName) || isNgContent(tagName)) {
    return false;
    }
    if (schemaMetas.some((schema) => schema.name === CUSTOM_ELEMENTS_SCHEMA.name)) { return true;}
  }
...
}


hasElement(tagName: string, schemaMetas: SchemaMetadata[]): boolean {
  if (schemaMetas.some((schema) => schema.name === NO_ERRORS_SCHEMA.name)) { return true; }
  if (tagName.indexOf('-') > -1) {
    if (isNgContainer(tagName) || isNgContent(tagName)) { return true; }
  if (schemaMetas.some((schema) => schema.name === CUSTOM_ELEMENTS_SCHEMA.name)) { return true; }
}
...
}

Pay attention to that part:

if (tagName.indexOf('-') > -1) {
if (isNgContainer(tagName) || isNgContent(tagName)) {
return false;
}
if (schemaMetas.some((schema) => schema.name === CUSTOM_ELEMENTS_SCHEMA.name)) { return true;}
}

This means that if a template tag has dash (-) in it, this tag is not ng-content and not ng-container, and CUSTOM_ELEMENTS_SCHEMA was added to ngModule schemas (schemaMetas variable) — then treat it as valid. In other words, Angular just searches for a dash in name and if found it treats it as a custom element.

You can try the code in 2_CUSTOM_ELEMENTS_SCHEMA branch of the article demo project.

Using NO_ERRORS_SCHEMA for unit testing

Looking at Angular code above you can also understand how NO_ERRORS_SCHEMA works:

if (schemaMetas.some((schema) => schema.name === NO_ERRORS_SCHEMA.name)) { return true; }

If we added NO_ERRORS_SCHEMA to ngModule decorator schemas prop — Angular doesn't do any checks at all.

It may be useful if you test your component but doesn't want to include child component into TestBed module configuration.

// my-parent.component.ts
@Component({
  selector: "app-my-parent",
  template: `
    <div>
      <child></child> <!-- Don't want to instantiate child -->
    </div>
    <button (click)="doSomething()">Start</button>
  `,
  styleUrls: ["./my-parent.component.scss"],
})
export class MyParentComponent {
  constructor() {
  }

  doSomething() {
    // some code
  }
}

// my-parent.spec.ts
...
beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [MyParentComponent],
    schemas: [NO_ERRORS_SCHEMA],
  }).compileComponents()
}))

Without NO_ERRORS_SCHEMA Angular will throw that it doesn't know anything about <child></child>. But if you don't want to test <child>  then now you know how to skip checking for it. By the way, someone might see it as a bad practice, so it's up for you to decide. You can check the code here in the 3_NO_ERRORS_SCHEMA branch.

Redefining ElementSchemaRegistry with a custom class to add exclusion rules 

JIT Compiler version

It may be needed if you use not only Angular on your web site so some custom elements (or attributes) should be added to exclusions.
I found a nice example of this case in StackOverflow issue:

There are some custom elements and attributes in component template (in this example they are used by third-party non-Angular code):

<foo></foo>
<div data-bar="{{ bar }}"></div>

They cause a compiler error:

Template parse errors:
'foo' is not a known element:

How can foo element and data-bar attribute be added to compiler schema?

And the perfect answer from Alexey Zuev:

You can try to override DomElementSchemaRegistry like this:
//main.ts
import { DomElementSchemaRegistry, ElementSchemaRegistry } from '@angular/compiler'
import { SchemaMetadata } from '@angular/core';
const MY_DOM_ELEMENT_SCHEMA = [
  'foo'
];
const MY_CUSTOM_PROPERTIES_SCHEMA = {
  'div': {
    'bar': 'string'
  }
};
export class CustomDomElementSchemaRegistry extends DomElementSchemaRegistry {
  constructor() {
    super();
  }
hasElement(tagName: string, schemaMetas: SchemaMetadata[]): boolean {
    return MY_DOM_ELEMENT_SCHEMA.indexOf(tagName) > -1 || 
         super.hasElement(tagName, schemaMetas);
  }
hasProperty(tagName: string, propName: string, schemaMetas: SchemaMetadata[]): boolean {
    const elementProperties = MY_CUSTOM_PROPERTIES_SCHEMA[tagName.toLowerCase()];
    return (elementProperties && elementProperties[propName]) || 
        super.hasProperty(tagName, propName, schemaMetas);
  }
}
platformBrowserDynamic().bootstrapModule(AppModule, {
  providers: [{ provide: ElementSchemaRegistry, useClass: CustomDomElementSchemaRegistry, deps: [] }]
});

Now Angular doesn't show template error for <foo> element.

But this example works in JIT compilation only — check the code at 4_Customizing_element_schema branch of article demo project. Can we somehow make it work in AOT build?

AOT Compiler version

You remember that JIT Compiler uses COMPILER_PROVIDERS:

{ provide: ElementSchemaRegistry, useExisting: DomElementSchemaRegistry},

So it was easy to substitute ElementSchemaRegistry with some other custom class like we did in previous example. But it is not possible to do so for AOT Compiler since in AOT compiler factory schemaRegistry is instantiated explicitly:

const elementSchemaRegistry = new DomElementSchemaRegistry();

To make previous example work in AOT build we have to somehow re-defined DomElementSchemaRegistry.hasProperty (or hasElement) methods (thanks to Alexey Zuev for providing such approach):

Here's the full code example:

ng-new.js — Redefining DomElementSchemaRegistry.hasElement

Well, how does it work?

We copy original DomElementSchemaRegistry.hasElement implementation and then expand it with exclusion code (but after that call original method as well). To check if it works — clone this repo and run node ng serve --aot or node ng build --aot. Note that ng in that command is not Angular CLI binary ng but our ng.js file.

I implemented the code in ng-new.js file at 4_Customizing_element_schema branch of article demo project. Just clone the project, switch to the branch and run: node ng-new build --prod. No errors! But if you try ng build — prod with standard Angular CLI command  build will fail with template errors.

Imperfections

What I noticed that even if we add an exclusion for custom attributes, like this:

<div bar="{{title}}">Angular removed bar attribute from that div silently</div>

Angular will remove bar attribute ?. If we want to keep it — you have to use Angular attr notation, however in this case there's no need to create custom exclusion rule:

<div [attr.bar]="title">Angular keep bar attribute if we use [attr.bar]="" notation</div>

For custom attributes the only case that works with providing schema exclusion is non-dynamic attributes:

<div bar="NonDymanicValue">Angular removed bar attribute from that div silently</div>

Projects that use customized ElementSchema

I know two projects where customized elementSchemaRegistry is used.

Angular Terminal Platform

How to run an Angular application in a terminal window? Easy :)

And here is a link to schema-registry.ts used in a project. Nothing special — we just validate everything:

// projects/platform-terminal/src/lib/schema-registry.ts
export class TerminalElementSchemaRegistry extends ElementSchemaRegistry {
hasProperty(_tagName: string, _propName: string): boolean {
  return true;
}
hasElement(_tagName: string, _schemaMetas: SchemaMetadata[]): boolean {
return true;
}
...


// projects/platform-terminal/src/lib/platform.ts
...
{ provide: ElementSchemaRegistry, useClass: TerminalElementSchemaRegistry, deps: [] },

You should know what this means after reading the article, right? ?

Applications Node-GUI

A project that allows running Angular applications as desktop applications (like Electron app.)

The author uses NodeguiElementSchemaRegistry that also allows everything by returning true on every hasElement and hasProperty request. And like in Angular terminal project — we re-define ElementSchemaRegistry in COMPILER_OPTIONS in angular-nodegui/src/lib/platform-dynamic.ts file:

provide: COMPILER_OPTIONS,
  useValue: {
    providers: [{
     provide: ElementSchemaRegistry,
     useClass: NodeguiElementSchemaRegistry,
     deps: []
   }
...

Conclusion

So let's wrap it up:

  1. Angular uses ElementSchemaRegistry and its implementation DomElementSchemaRegistry   to check if attributes and elements in the component's template are valid.
  2. An instance of DomElementSchemaRegistry is used in TemplateParser which is used in the compiler to visit all template AST Nodes (JIT and AOT).
  3. We can create our custom rules for validation. It is easy for JIT compiler (platformBrowserDynamic) and a bit tricky but also possibly for AOT build.
  4. We can switch off validation by proving CUSTOM_ELEMENTS_SCHEMA and NO_ERRORS_SCHEMA in ngModule config object schemas property.

You can find all the code I used in the article here.