changed 5 years ago
Linked with GitHub

Design Doc - Let Directive

author: [Michael Hladky]
created: 2020-02-02
status: [draft | in review | approved | implemented | obsolete]
doc url: https://hackmd.io/8_3rp0A7RweSYJiulsifbQ?both
feedback url: https://github.com/ngrx/platform/issues/2441
design doc template version: 1.0.0

Objective

The subject of this design doc is to create a structural directive, similar to *ngIf directive but without the display/show functionality.

It should just bind observable values to the view.

It takes asynchronous primitives and binds and renders their value to the template.

The main goal is to have such a directive also work zone-less.

In this document use cases and scenarios are defined to add a structural directive to the new ngrx/component package. This directive is one of many reactive primitives to make Angular more reactive.

We can look at it as a derivative of the ngrxPush pipe.

The directive solves following primitive reactive problems:

  • Retrieve asynchronous primitives
  • Subscription handling
  • Change detection and rendering

As this document uses specific terminology find detailed explanations in the gist here: Reactive Angular Terminology

Especially rendering should be done only over the ngrxPush or *ngrxLet as the render process is different from template expressions to template bindings.
Handling of change detection in the component would make it impossible to interact with either only the EnbeddedView or the ComponentView and would lead to unwanted performance drawbacks. This topic is explained in depth in another document listed in the caveats section.

Approvals

  • Tim Deschryver
  • Wes Grimes
  • Alex Okrushko
  • Brandon Roberts
  • Mike Ryan
  • Rob Wormald

Expected User/Developer Experience

With the *ngrxLet directive, the user will be able to bind the emitted notifications of an observable to an EmbeddedView.

This is done by using the structural directive in the template, in the same way, he would use the *ngIf to bind values but without the display/hide logic of the *ngIf.

The user can use any observable that can be referenced over the component class.

File: any.component.ts

import { Component, Input } from '@angular/core';
import { Subject, interval } from 'rxjs';

@Component({
  selector: 'any',
  templateUrl: 'any.component.html'
  // Implementation works independent of the ChangeDetectionStrategy 
})
export class AnyComponent {
  inputDecoratorSubject$ = new Subject<any>();
  @Input() 
  set value(value: any) {
      this.inputDecorator$.next(value);
  };
  
  classInternalObservable$ = interval(1000);

  routerParams$ = this.activatedRoute.params;
  
  constructor(public activatedRoute: ActivatedRoute, 
              public store$: Store<any>) {
      
  }
}

and bind it's valued to the part of the template the pipe is used.

File: any.component.html

<ng-container *ngrxLet="inputDecorator$ as o">
    {{o}}
</ng-container>
<div *ngrxLet="classInternalObservable$ as o">
    {{o}}
</div>
<ng-container *ngrxLet="routerParams$ as o">
    {{o}}
</ng-container>
<div *ngrxLet="store$ as o">
    {{o}}
</div>

If there is already existing code that uses the *ngIf directive used only to bind observable values it the template, and not relying on the hide/show functionality of it, the *ngrxLet pipe can be used as a drop-in replacement and also drastically reduce the complexity of that code as the if approach sometimes requires dirty hacks to work with boolean values.

<!-- before: -->
<ng-container *ngIf="observable$ | async as o">
    {{o}}
</ng-container>
<!-- after: -->
<ng-container *ngrxLet="observable$ as o">
    {{o}}
</ng-container>

Background

The current approach to bind observable values to an EmbededView looks like that:

<ng-container *ngIf="observableNumber$ | async as n">
  <app-number [number]="n">
  </app-number>
  <app-number-special [number]="n">
  </app-number-special>
</ng-container>

The problem is *ngIf is also interfering with rendering and in case of a 0 the container would be hidden.

Prior Art

At the moment it is not possible to bind observable values to an EmbeddedView without additional behavior based on the emitted values-

  • *ngIf
<ng-container *ngIf="observableNumber$ | async as n">
{{n}}
</ng-container>
  • *ngFor
<ng-container *ngFor="let n of [observableNumber$ | async]>
{{n}}
</ng-container>

Prototype

Minimal Design

ngrxLet.directive.ts

// LetContext defined ths context structure
export class LetContext {
  constructor(
    // to enable let we have to use $implicit
    public $implicit?: any,
    // to enable as we have to assign this
    public ngrxLet?: any,
    // value of error of undefined
    public $error?: Error | undefined,
    // true or undefined
    public $complete?: true | undefined
  ) {}
}

@Directive({selector: '[ngrxLet]'})
export class LetDirective implements OnInit, OnDestroy {
  // Initates the viewContext with an empty LetContext instance
  private viewContext  = new LetContext();
  // subscription to the renderer process
  private subscription = new Subscription();
  // Enables to receive input binding changes push based
  private observablesSubject = new Subject<Observable<any>>();

  // Input binding for the observable to bind to the EnbeddedView
  @Input()
  set ngrxLet(obs: Observable<any>) {
    this.observablesSubject.next(obs);
  }

  constructor(
    private cdRef: ChangeDetectorRef,
    private readonly templateRef: TemplateRef<LetContext>,
    private readonly viewContainerRef: ViewContainerRef
  ) {
    // Retreive values from passed argument
    this.subscription = this.observablesSubject.pipe(
        tap({
          // Assign value that will get returned from the transform function
          // on the next change detection
          next: renderedValue => {
            // to enable `let` syntax we have to use $implicit (var; let v = var)
            this.viewContext.$implicit = renderedValue;
            // to enable `as` syntax we have to assign the directives selector (var as v)
            this.viewContext.ngrxLet = renderedValue;
            this.cdRef.detectChanges();
          }
        })
    )
     // Start to render passed values 
    .subscribe();
    
  }

  ngOnInit() {
    // Create and embadded view with the created viewContext and bind it to the templateRef.
    this.viewContainerRef.createEmbeddedView(
      this.templateRef,
      this.viewContext
    );
  }

  ngOnDestroy() {
    // Stop to render values 
    this.subscription.unsubscribe();
    // Clear the viewContainerRef
    this.viewContainerRef.clear();
  }

}

Detailed Design

As these are a lot of equal code blocks also used in the push pipe to ensure the type of passed values or the flattening and update behavior of emitted notifications.

Therefore we pull out some parts here to have these snippets available across exposed parts of the package.

The first repetitive code is the type checking of passed values

toObservableValue.ts


export function toObservableValue<T>(
  potentialObservableValue$: potentialObservableValue<T>
): Observable<T | undefined | null> {
  if (isUndefinedOrNullGuard(potentialObservableValue$)) {
    return of(potentialObservableValue$);
  }

  if (
    isPromiseGuard(potentialObservableValue$) ||
    isObservableGuard(potentialObservableValue$)
  ) {
    return from(potentialObservableValue$);
  }

  throw new ArgumentNotObservableError();
}

processCdAwareObservables.ts

export function processCdAwareObservables<T>(
  resetContextBehaviour: (
    o$$: Observable<Observable<T>>
  ) => Observable<Observable<T>>,
  updateContextBehaviour: (
    o$$: Observable<Observable<T>>
  ) => Observable<Observable<T>>
) {
  return (o$: Observable<potentialObservableValue<T>>): Observable<T> => {
    return o$.pipe(
      toObservableValue(),
      // Ignore observables of the same instances
      distinctUntilChanged(),
      resetContextBehaviour,
      // Add apply changes to context behaviour
      updateContextBehaviour,
            // @NOTICE Configure observable here with config
      // Add cd optimization behaviour
      // ----
      // unsubscribe from previous observables
      // then flatten the latest internal observables into the output
      switchAll(),
      // reduce number of emissions to distinct values compared to teh previous one
      distinctUntilChanged()
    );
  };
}

ngrxLet.directive.ts

@Directive({selector: '[ngrxLet]'})
export class LetDirective implements OnInit, OnDestroy {
  // Initates the viewContext with an empty LetContext instance
  private viewContext  = new LetContext();
  // subscription to the renderer process
  private subscription = new Subscription();
  // Enables to receive input binding changes push based
  protected observablesSubject = new Subject<Observable<unknown> | Promise<unknown> | null | undefined>();
  protected observables$ = this.observablesSubject.pipe(
    processCdAwareObservables(
     // In case we dont have a value set yet we will receive undefined
     // In some cases people try to stop rendering by appliing null
     // Also null is a legitiment value for and value not assigned yet
     tap({
        next: (obs: Observable<unknown>) => { 
            // Apply values that should get rendered
            this.renderedValue = undefined;
            // Render new values to the template 
            this.cdRef.detectChanges();
        }
    }),
    // Update renderedValue and render it to the template
    tap({
      // Assign value that will get returned from the transform function
      // on the next change detection
      next: renderedValue => {
        // to enable `let` syntax we have to use $implicit (var; let v = var)
        this.viewContext.$implicit = renderedValue;
        // to enable `as` syntax we have to assign the directives selector (var as v)
        this.viewContext.ngrxLet = renderedValue;
        // Render new values to the template 
        this.cdRef.detectChanges();
      },
      // Get error object here and apply needed error logic
      error: (e: unknown) => {
        // Logic to deal with error object 
        // e. g. this.renderedValue = e.message;  this.cdRef.detectCahnges();
        // @Notice:
        // This is not catching the error
      }
    })
    )
  );


  // Input binding for the observable to bind to the EnbeddedView
  @Input()
  set ngrxLet(obs: Observable<any>) {
    this.observablesSubject.next(obs);
  }

  constructor(
    private cdRef: ChangeDetectorRef,
    private readonly templateRef: TemplateRef<LetContext>,
    private readonly viewContainerRef: ViewContainerRef
  ) {
    // Retreive values from passed argument
    this.subscription = this.observables$.
     // Start to render passed values 
    .subscribe();
  }

  ngOnInit() {
    // Create and embadded view with the created viewContext and bind it to the templateRef.
    this.viewContainerRef.createEmbeddedView(
      this.templateRef,
      this.viewContext
    );
  }

  ngOnDestroy() {
    // Stop to render values 
    this.subscription.unsubscribe();
    // Clear the viewContainerRef
    this.viewContainerRef.clear();
  }

}

Caveats

ViewEngine / Ivy interoperability
With the requirement to support both, we increase the complexity of the implementation.
We have to do this because in ViewEngine applications it's recommended to keep supporting View Engine in Angular versions 9 and 10

Usage of Angulars internal ɵ API:
With the current situation, we rely on Angulars internal ɵmarkDirty and ɵdetectChanges function.
This may have critical effects as these APIs can change in any release.
We need to ensure we can use ChangeDetectorRef instead.

It would make a lot of sense if they expose it Expose the isPromise utility function to the public API.

Immutability:
The second distinctUntilChanged operator for emitted values of the passed observables forces the user to work immutable. Even if this is in most cases given as people that
work more reactively use normally also ChangeDetectionStrategy.OnPush.
Still, we have to consider clear communication.

Coalescing and Scoped Coalescing:
This problem is explained in the document Design Doc - Coalescing of Change Detection

Security Considerations:
Errors happening in the observables are caught and swallowed without any thrown error.
The Observable completes instead. The error object is handled in another part of the code.

Sanitization:
Sanitization of the emitted values to get rendered in the template is done by default by Angular.

Performance Considerations

It could be possible we run into similar problems as with the Design Doc - Push Pipe

If so, details on the change detection coalescing feature can be found in Design Doc - Coalescing of Change Detection.

Documentation Plan

As the scope of this package is quite small I consider the documentation in text and some examples in the demo app and as code snippets
is everything we need.

Here a first draft of the documentation:
let.md

Style Guide Impact

The needed documentation has no impact on the current way the documentation is maintained.

Developer Impact

Public API Surface Impact

Developer Experience Impact

The new directive does not affect the existing API interface. It will get introduced under a new package @ngrx/component.
It exposes only the pipes class and its usage in the template as a pipe reference.

Following dependencies are needed:

  • @angular/core@>=8
  • @rxjs@>=6.5.4

The new directive is prefixed with ngrx and named let.
It is used the same way as the *ngIf, *ngSwitch or *ngFor structural directives are used
<ng-container *ngrxLet="interval$ as n">{{n}}</ng-container>.

The term 'let' refers to ECMAScript's let statement, which declares a block scope local variable whose initial value is optional.

This tries to give the user an intuitive understanding of its behavior out of the pipe's name itself.

Breaking Changes

The new directive will not introduce any breaking changes.

Rollout Plan

The directive and especially its features can be rolled out in the following way:

Alpha releases of the @ngrx/component package and the directive include all features the async pipe has regarding the processing of passed values, to have the directive ready for a drop-in replacement of the situations where

<ng-container *ngIf="observableNumber$ | async as n"> {{n}} </ng-container>

is used. Also, the detection depending if zone is present or not.

Shipped Features:

  • Take promises or observables, retrieve their values and render the value to the template
  • Handling null and undefined values in a clean unified/structured way
  • Triggers change-detection differently if zone.js is present or not (detectChanges or markForCheck)

Beta releases of the @ngrx/component package and the directive include a configuration argument to opt-in the coalescing of change detection calls.

Shipped Features:

  • Expose context variables $error and $completed
  • Add config options to the directive as arguments over input bindings to opt-in new features
  • Coalescing of change detection calls to boost performance

Rc and official releases of the @ngrx/component package and the directive include all features and the change detection coalescing is opt-out by default.
Alos internal operators and utils related to zone checks and event coalescing can be exposed in this releases.

Shipped Features:

  • Coalescing of change detection calls to boost performance is on by default and can get opted-out

Rollback Plan

In the late alpha versions, we introduce the coalescing of change detection calls.
If any critical problems occur in the alpha version we can just don't ship further versions.

Until the first beta, we should have to clarify this. This is the moment we introduce the configuration option for the pipe.
The coalescing can be rolled back by removing the option from the config object.

If a major issue is found in RC or even official release we can remove the push pipe from the package as it is no other part relies on the pipe.

There are no implementation details that could delay the release of other packages. Also, the bundle size of other features will not increase by introducing this feature.

Maintenance Plan

If we can get rid of the usage of Angulars internal API we have pretty low maintenance costs.
Depending on how long we want to support interrupt ViewEngine and Ivy we have a year to 1,5 to remove the ViewEngine related code.

The rest of the code relies on some pretty common Angular parts from the core package and some RxJS operators creation functions and utils naming: Observable, pipe, isObservable, from, of, filter, tap, switchAll, distinctUntilChanged, shareReplay

shareReplay will have a breaking change for our current usage as the configuration object will get removed.
A minor refactoring.
From all the other used operator we only use one where a breaking change can happen which is tap.
If Observabls make it in the standard the most probably will have callbacks as arguments instead of an observer object.
But this is also a minor refactoring.

Alternatives considered

Imperative Code style
An alternative implementation could be done without RxJS as Angulars async pipe is built. I went the Reactive way because

  • A) not a lot of operators
    -B) Easier to maintain and extend (configuration)

Drawbacks:
The use of RxJS could force the user to include operators in their bundle which they don't want.

An increased bundle size is expected through the following operators (rare used operators at the top):

  • ?.?kB switchAll
  • 275B pipe
  • 5.0kB ReplaySubject
    // Low risc as frequently used
  • ?.?kB distinctUntilChanged
  • ?.?kB filter
  • 3.3kB from
  • 2.8kB of
  • ?.?kB tap
  • 2.6kB Observable

Change Detection over ApplicationRef.tick()
An alternative way of triggering the changeDetection would be to call ApplicationRef.tick().
I did not go that way or even tried it out as It would result in a full application render for every change detection.

Change Detection in the Component
In the component, not all Observables values need to get rendered. Examples can be any background process lite polling or refresh clicks that dispatch an action or so.

Mention multi async example?
The example looks like that:

*ngIf = { a: a$ | async, b: b$ | async } as vm <ng-container *ngIf="{ o1: observable1$ | async, o2: observable2$ | async } as viewContext"> {{viewContext.o1}} {{viewContext.o2}} </ng-container>

I did not include it on purpose as composition should be placed in the Typescript section.
More flexible, less noisy template.

Providing template slots for error and complete
Here a solution that provides template slots. The document is not focusing on this feature as it trys to start with the very minimum and add features after a solid base.
In the planed release it is easily possible to implement it manually.

Let with themplate

Already existing Packages
The suggested version is the most primitive implementation.
In the RFC for this package is listed alternative implementations.
Those implementations either didn't respect the zone-less mode or had additional logic implemented to add templates for the context or other things that would go over the features of a reactive primitives.

Work Breakdown

As the pipes scope is small enough to get maintained by one person at a time we don't need to describe the breakdown of work.

Resources

Research Paper/Design Docs/StackBlitz/Video/Podcast/Blog/Tweet/Graphic:

Github Pull Request/Gists/Issue/Doc/Source Link:

Select a repo