Tom Robinson
Tom Robinson

Reputation: 8528

How can I prevent an Angular 8+ component from executing until an Input property is set and a ViewChild is ready

I've written an Angular component for rendering mermaid graphs using the mermaid npm package.

It works as-is (see below), but I would like to find a way of using it without the parent component needing to include an *ngIf (to prevent it trying to render when there's no data). I'd like the component itself to be able to decide whether to call mermaid.render() or not itself. If the *ngIf isn't used then the component's ngAfterContentInit() may run when graphDefinition is null. This causes the mermaid library to error.

I could use ngOnChanges(), or a property setter, to detect when a value is set but because the component relies on ViewChild, I think there's a danger that ngOnChanges() or the property setter would execute before the ViewChild is ready.

Question: Can you suggest a way of reliably improving this component so that the *ngIf is not required?

Usage

Example usage where the graph definition (a string) is provided as an Observable<string> from a HTTP call in the parent component:

parent.component.html

<mermaid-viewer
    *ngIf="graphDefinition$ | async as graphDefinition"
    [graphDefinition]="graphDefinition"
></mermaid-viewer>

Component

mermaid-viewer.component.html

<div #mermaid></div>

mermaid-viewer.component.ts

import {
    AfterContentInit,
    Component,
    Input,
    ViewChild
} from "@angular/core";
import * as mermaid from "mermaid";

@Component({
    selector: "mermaid-viewer",
    templateUrl: "./mermaid-viewer.component.html"
})
export class MermaidViewerComponent implements AfterContentInit {

    @ViewChild("mermaid", { static: true })
    public mermaidDiv;

    @Input()
    public graphDefinition: string;

    public ngAfterContentInit(): void {

        mermaid.initialize({
            theme: "default"
        });

        const element: any = this.mermaidDiv.nativeElement;
        mermaid.render(
            "graphDiv",
            this.graphDefinition,
            (svgCode, bindFunctions) => {
                element.innerHTML = svgCode;
            }
        );
    }
}

Upvotes: 2

Views: 1672

Answers (2)

Tom Robinson
Tom Robinson

Reputation: 8528

I've come up with this, inspired by the answer from Cristian Traìna but not using BehaviourSubject or combineLatest.

The render() function will be called twice. The first time only one of either graphDefinition or mermaidDiv will be available, and it will do nothing. The second time both values will be available, and it will then call mermaid.render. I think it's a bit simpler, though perhaps not as elegant - and wouldn't scale well if multiple properties were required.

import {
    Component,
    Input,
    ViewChild
} from "@angular/core";
import * as mermaid from "mermaid";

@Component({
    selector: "mermaid-viewer",
    templateUrl: "./mermaid-viewer.component.html"
})
export class MermaidViewerComponent {

    private hasRendered = false;

    private _mermaidDiv;
    @ViewChild("mermaid", { static: true })
    public set mermaidDiv(value) {
        this._mermaidDiv = value;
        this.render();
    }
    public get mermaidDiv() {
        return this._mermaidDiv;
    }

    private _graphDefinition: string;
    @Input()
    public set graphDefinition(value) {
        this._graphDefinition = value;
        this.render();
    }
    public get graphDefinition() {
        return this._graphDefinition;
    }

    private render() {
        if (this.graphDefinition && this.mermaidDiv) {

            this.hasRendered = true;

            mermaid.initialize({
                theme: "default"
            });

            const element = this.mermaidDiv.nativeElement as HTMLDivElement;
            mermaid.render(
                "graphDiv",
                this.graphDefinition,
                (svgCode, bindFunctions) => {
                    element.innerHTML = svgCode;
                }
            );
        }
    }
}

Upvotes: 2

Christian Vincenzo Traina
Christian Vincenzo Traina

Reputation: 10444

A complete answer for your question is overly complicated, and it would involve BehaviourSubject and operators like combineLatest. But I try to give you a hint.

You can combine your Input with a setter and a getter, in order to get notified when your input changes:

private _graphDefinition: string;
@Input()
public set graphDefinition(value) {
  this._graphDefinition = value;
  // .next() on BehaviourSubject
}
public get graphDefinition() {
  return this._graphDefinition;
}

You can do the same thing on the ViewChild, in this case you'll emit just once, when the element view is initialised:

private _mermaidDiv;
@ViewChild("mermaid", { static: true })
public set mermaidDiv(value) {
   this._mermaidDiv = value;
   // .next() on another BehaviourSubject
}
public get mermaidDiv() {
  return this._mermaidDiv;
}

Then you can use combineLatest on the ngAfterContentInit (ngAfterViewInit would be good too I guess):

ngAfterContentInit() {
  // ...
  combineLatest(
     bs1,
     bs2
  ).subscribe(result => {
     // do something with definition + view
     // result[0] => is your graph definition
     // result[1] => is your queried view
  });
  // ...
}

Now you can change your graphDefinition several times in your parent component, and your configuration will update automatically

Upvotes: 2

Related Questions