diff --git a/modules/component/.eslintrc.json b/modules/component/.eslintrc.json index a5572b9e14..01173da991 100644 --- a/modules/component/.eslintrc.json +++ b/modules/component/.eslintrc.json @@ -13,7 +13,8 @@ }, "rules": { "@angular-eslint/directive-selector": "off", - "@angular-eslint/component-selector": "off" + "@angular-eslint/component-selector": "off", + "@angular-eslint/no-input-rename": "off" }, "plugins": ["@typescript-eslint"] }, diff --git a/modules/component/spec/fixtures/fixtures.ts b/modules/component/spec/fixtures/fixtures.ts index 67814d87cf..c9d881182d 100644 --- a/modules/component/spec/fixtures/fixtures.ts +++ b/modules/component/spec/fixtures/fixtures.ts @@ -1,5 +1,4 @@ -import createSpy = jasmine.createSpy; -import { ChangeDetectorRef, NgZone } from '@angular/core'; +import { NgZone } from '@angular/core'; import { MockNoopNgZone } from './mock-noop-ng-zone'; /** @@ -17,27 +16,13 @@ export const manualInstanceNoopNgZone = new NoopNgZone({ }); export class MockChangeDetectorRef { - markForCheck = createSpy('markForCheck'); - detectChanges = createSpy('detectChanges'); - checkNoChanges = createSpy('checkNoChanges'); - detach = createSpy('detach'); - reattach = createSpy('reattach'); + markForCheck = jest.fn(); + detectChanges = jest.fn(); + checkNoChanges = jest.fn(); + detach = jest.fn(); + reattach = jest.fn(); } -export const mockPromise = { - then: () => {}, -}; - -export function getMockOptimizedStrategyConfig() { - return { - component: {}, - cdRef: (new MockChangeDetectorRef() as any) as ChangeDetectorRef, - }; -} - -export function getMockNoopStrategyConfig() { - return { - component: {}, - cdRef: (new MockChangeDetectorRef() as any) as ChangeDetectorRef, - }; +export class MockErrorHandler { + handleError = jest.fn(); } diff --git a/modules/component/spec/let/let.directive.spec.ts b/modules/component/spec/let/let.directive.spec.ts index a2839c6ccc..4804f18ce6 100644 --- a/modules/component/spec/let/let.directive.spec.ts +++ b/modules/component/spec/let/let.directive.spec.ts @@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, Directive, + ErrorHandler, TemplateRef, ViewContainerRef, } from '@angular/core'; @@ -15,17 +16,20 @@ import { } from '@angular/core/testing'; import { BehaviorSubject, + delay, EMPTY, interval, NEVER, Observable, ObservableInput, of, + switchMap, + take, throwError, + timer, } from 'rxjs'; -import { take } from 'rxjs/operators'; import { LetDirective } from '../../src/let/let.directive'; -import { MockChangeDetectorRef } from '../fixtures/fixtures'; +import { MockChangeDetectorRef, MockErrorHandler } from '../fixtures/fixtures'; @Component({ template: ` @@ -47,7 +51,7 @@ class LetDirectiveTestComponent { `, }) class LetDirectiveTestErrorComponent { - value$: Observable = of(42); + value$ = of(42); } @Component({ @@ -58,7 +62,30 @@ class LetDirectiveTestErrorComponent { `, }) class LetDirectiveTestCompleteComponent { - value$: Observable = of(42); + value$ = of(42); +} + +@Component({ + template: ` + {{ + s ? 'suspense' : value + }} + `, +}) +class LetDirectiveTestSuspenseComponent { + value$ = of(42); +} + +@Component({ + template: ` + {{ + value === undefined ? 'undefined' : value + }} + Loading... + `, +}) +class LetDirectiveTestSuspenseTplComponent { + value$ = of(42); } @Directive({ @@ -79,6 +106,7 @@ export class RecursiveDirective { }) class LetDirectiveTestRecursionComponent { constructor(public subject: BehaviorSubject) {} + get value$() { return this.subject; } @@ -106,11 +134,13 @@ const setupLetDirectiveTestComponent = (): void => { fixtureLetDirectiveTestComponent.componentInstance; componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; }; + const setupLetDirectiveTestComponentError = (): void => { TestBed.configureTestingModule({ declarations: [LetDirectiveTestErrorComponent, LetDirective], providers: [ { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + { provide: ErrorHandler, useClass: MockErrorHandler }, TemplateRef, ViewContainerRef, ], @@ -123,6 +153,7 @@ const setupLetDirectiveTestComponentError = (): void => { fixtureLetDirectiveTestComponent.componentInstance; componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; }; + const setupLetDirectiveTestComponentComplete = (): void => { TestBed.configureTestingModule({ declarations: [LetDirectiveTestCompleteComponent, LetDirective], @@ -141,6 +172,44 @@ const setupLetDirectiveTestComponentComplete = (): void => { componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; }; +const setupLetDirectiveTestComponentSuspense = (): void => { + TestBed.configureTestingModule({ + declarations: [LetDirectiveTestSuspenseComponent, LetDirective], + providers: [ + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + { provide: ErrorHandler, useClass: MockErrorHandler }, + TemplateRef, + ViewContainerRef, + ], + }); + + fixtureLetDirectiveTestComponent = TestBed.createComponent( + LetDirectiveTestSuspenseComponent + ); + letDirectiveTestComponent = + fixtureLetDirectiveTestComponent.componentInstance; + componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; +}; + +const setupLetDirectiveTestComponentSuspenseTpl = (): void => { + TestBed.configureTestingModule({ + declarations: [LetDirectiveTestSuspenseTplComponent, LetDirective], + providers: [ + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + { provide: ErrorHandler, useClass: MockErrorHandler }, + TemplateRef, + ViewContainerRef, + ], + }); + + fixtureLetDirectiveTestComponent = TestBed.createComponent( + LetDirectiveTestSuspenseTplComponent + ); + letDirectiveTestComponent = + fixtureLetDirectiveTestComponent.componentInstance; + componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; +}; + const setupLetDirectiveTestRecursionComponent = (): void => { const subject = new BehaviorSubject(0); TestBed.configureTestingModule({ @@ -320,6 +389,14 @@ describe('LetDirective', () => { fixtureLetDirectiveTestComponent.detectChanges(); expect(componentNativeElement.textContent).toBe('true'); }); + + it('should call error handler', () => { + const errorHandler = TestBed.inject(ErrorHandler); + const error = new Error('ERROR'); + letDirectiveTestComponent.value$ = throwError(() => error); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(errorHandler.handleError).toHaveBeenCalledWith(error); + }); }); describe('when complete', () => { @@ -332,6 +409,80 @@ describe('LetDirective', () => { }); }); + describe('when suspense', () => { + beforeEach(waitForAsync(setupLetDirectiveTestComponentSuspense)); + + it('should not render when first observable is in suspense state', fakeAsync(() => { + letDirectiveTestComponent.value$ = of(true).pipe(delay(1000)); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(''); + tick(1000); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('true'); + })); + + it('should render suspense when next observable is in suspense state', fakeAsync(() => { + letDirectiveTestComponent.value$ = of(true); + fixtureLetDirectiveTestComponent.detectChanges(); + letDirectiveTestComponent.value$ = of(false).pipe(delay(1000)); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('suspense'); + tick(1000); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('false'); + })); + }); + + describe('when suspense template is passed', () => { + beforeEach(waitForAsync(setupLetDirectiveTestComponentSuspenseTpl)); + + it('should render main template when observable emits next event', () => { + letDirectiveTestComponent.value$ = new BehaviorSubject('ngrx'); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('ngrx'); + }); + + it('should render main template when observable emits error event', () => { + letDirectiveTestComponent.value$ = throwError(() => 'ERROR!'); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('undefined'); + }); + + it('should render main template when observable emits complete event', () => { + letDirectiveTestComponent.value$ = EMPTY; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('undefined'); + }); + + it('should render suspense template when observable does not emit', () => { + letDirectiveTestComponent.value$ = NEVER; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('Loading...'); + }); + + it('should render suspense template when initial observable is in suspense state', fakeAsync(() => { + letDirectiveTestComponent.value$ = of('component').pipe(delay(100)); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('Loading...'); + tick(100); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('component'); + })); + + it('should render suspense template when next observable is in suspense state', fakeAsync(() => { + letDirectiveTestComponent.value$ = new BehaviorSubject('ngrx'); + fixtureLetDirectiveTestComponent.detectChanges(); + letDirectiveTestComponent.value$ = timer(100).pipe( + switchMap(() => throwError(() => 'ERROR!')) + ); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('Loading...'); + tick(100); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('undefined'); + })); + }); + describe('when rendering recursively', () => { beforeEach(waitForAsync(setupLetDirectiveTestRecursionComponent)); diff --git a/modules/component/src/let/let.directive.ts b/modules/component/src/let/let.directive.ts index 7b9dff4ecf..cd7632c334 100644 --- a/modules/component/src/let/let.directive.ts +++ b/modules/component/src/let/let.directive.ts @@ -15,14 +15,26 @@ import { createRenderScheduler } from '../core/render-scheduler'; import { createRenderEventManager } from '../core/render-event/manager'; export interface LetViewContext { - // to enable `let` syntax we have to use $implicit (var; let v = var) + /** + * using `$implicit` to enable `let` syntax: `*ngrxLet="obs$; let o"` + */ $implicit: T; - // to enable `as` syntax we have to assign the directives selector (var as v) + /** + * using `ngrxLet` to enable `as` syntax: `*ngrxLet="obs$ as o"` + */ ngrxLet: T; - // set context var complete to true (var$; let e = $error) + /** + * `*ngrxLet="obs$; let e = $error"` or `*ngrxLet="obs$; $error as e"` + */ $error: boolean; - // set context var complete to true (var$; let c = $complete) + /** + * `*ngrxLet="obs$; let c = $complete"` or `*ngrxLet="obs$; $complete as c"` + */ $complete: boolean; + /** + * `*ngrxLet="obs$; let s = $suspense"` or `*ngrxLet="obs$; $suspense as s"` + */ + $suspense: boolean; } /** @@ -30,63 +42,45 @@ export interface LetViewContext { * * @description * - * The `*ngrxLet` directive serves a convenient way of binding observables to a view context (a dom element scope). - * It also helps with several internal processing under the hood. - * - * The current way of binding an observable to the view looks like that: - * ```html - * - * - * - * - * - * - * ``` - * - * The problem is `*ngIf` is also interfering with rendering and in case of a `0` the component would be hidden - * - * Included Features: - * - binding is always present. (`*ngIf="truthy$ | async"`) - * - it takes away the multiple usages of the `async` or `ngrxPush` pipe - * - a unified/structured way of handling null and undefined - * - triggers change-detection differently if `zone.js` is present or not (`ChangeDetectorRef.detectChanges` or `ChangeDetectorRef.markForCheck`) - * - triggers change-detection differently if ViewEngine or Ivy is present (`ChangeDetectorRef.detectChanges` or `ɵdetectChanges`) - * - distinct same values in a row (distinctUntilChanged operator) + * The `*ngrxLet` directive serves a convenient way of binding observables to a view context + * (DOM element's scope). It also helps with several internal processing under the hood. * * @usageNotes * - * The `*ngrxLet` directive take over several things and makes it more convenient and save to work with streams in the template - * `` + * ### Displaying Observable Values * * ```html - * - * - * + * + * * * - * - * - * + * + * * * ``` * - * In addition to that it provides us information from the whole observable context. - * We can track the observables: - * - next value - * - error value - * - complete state + * ### Tracking Different Observable Events * * ```html - * - * - * - * - * There is an error: {{e}} - * - * - * Observable completed: {{c}} + * + * + * + * + *

There is an error.

+ *

Observable is completed.

*
+ * ``` + * + * ### Using Suspense Template + * + * ```html + * + * * + * + * + *

Loading...

+ *
* ``` * * @publicApi @@ -95,12 +89,14 @@ export interface LetViewContext { export class LetDirective implements OnInit, OnDestroy { static ngTemplateGuard_ngrxLet: 'binding'; - private isEmbeddedViewCreated = false; + private isMainViewCreated = false; + private isSuspenseViewCreated = false; private readonly viewContext: LetViewContext = { $implicit: undefined, ngrxLet: undefined, $error: false, $complete: false, + $suspense: true, }; private readonly renderScheduler = createRenderScheduler({ ngZone: this.ngZone, @@ -108,28 +104,29 @@ export class LetDirective implements OnInit, OnDestroy { }); private readonly renderEventManager = createRenderEventManager({ reset: () => { - if (this.isEmbeddedViewCreated) { - this.viewContext.$implicit = undefined; - this.viewContext.ngrxLet = undefined; - this.viewContext.$error = false; - this.viewContext.$complete = false; + this.viewContext.$implicit = undefined; + this.viewContext.ngrxLet = undefined; + this.viewContext.$error = false; + this.viewContext.$complete = false; + this.viewContext.$suspense = true; - this.renderScheduler.schedule(); - } + this.renderSuspenseView(); }, next: (event) => { this.viewContext.$implicit = event.value; this.viewContext.ngrxLet = event.value; + this.viewContext.$suspense = false; if (event.reset) { this.viewContext.$error = false; this.viewContext.$complete = false; } - this.renderEmbeddedView(); + this.renderMainView(); }, error: (event) => { this.viewContext.$error = true; + this.viewContext.$suspense = false; if (event.reset) { this.viewContext.$implicit = undefined; @@ -137,11 +134,12 @@ export class LetDirective implements OnInit, OnDestroy { this.viewContext.$complete = false; } - this.renderEmbeddedView(); + this.renderMainView(); this.errorHandler.handleError(event.error); }, complete: (event) => { this.viewContext.$complete = true; + this.viewContext.$suspense = false; if (event.reset) { this.viewContext.$implicit = undefined; @@ -149,31 +147,35 @@ export class LetDirective implements OnInit, OnDestroy { this.viewContext.$error = false; } - this.renderEmbeddedView(); + this.renderMainView(); }, }); private readonly subscription = new Subscription(); - static ngTemplateContextGuard( - dir: LetDirective, - ctx: unknown | null | undefined - ): ctx is LetViewContext { - return true; - } - @Input() set ngrxLet(potentialObservable: PotentialObservable) { this.renderEventManager.nextPotentialObservable(potentialObservable); } + @Input('ngrxLetSuspenseTpl') suspenseTemplateRef?: TemplateRef< + LetViewContext + >; + constructor( private readonly cdRef: ChangeDetectorRef, private readonly ngZone: NgZone, - private readonly templateRef: TemplateRef>, + private readonly mainTemplateRef: TemplateRef>, private readonly viewContainerRef: ViewContainerRef, private readonly errorHandler: ErrorHandler ) {} + static ngTemplateContextGuard( + dir: LetDirective, + ctx: unknown | null | undefined + ): ctx is LetViewContext { + return true; + } + ngOnInit(): void { this.subscription.add( this.renderEventManager.handlePotentialObservableChanges().subscribe() @@ -184,15 +186,36 @@ export class LetDirective implements OnInit, OnDestroy { this.subscription.unsubscribe(); } - private renderEmbeddedView(): void { - if (!this.isEmbeddedViewCreated) { - this.isEmbeddedViewCreated = true; + private renderMainView(): void { + if (this.isSuspenseViewCreated) { + this.isSuspenseViewCreated = false; + this.viewContainerRef.clear(); + } + + if (!this.isMainViewCreated) { + this.isMainViewCreated = true; this.viewContainerRef.createEmbeddedView( - this.templateRef, + this.mainTemplateRef, this.viewContext ); } this.renderScheduler.schedule(); } + + private renderSuspenseView(): void { + if (this.suspenseTemplateRef && this.isMainViewCreated) { + this.isMainViewCreated = false; + this.viewContainerRef.clear(); + } + + if (this.suspenseTemplateRef && !this.isSuspenseViewCreated) { + this.isSuspenseViewCreated = true; + this.viewContainerRef.createEmbeddedView(this.suspenseTemplateRef); + } + + if (this.isMainViewCreated || this.isSuspenseViewCreated) { + this.renderScheduler.schedule(); + } + } } diff --git a/projects/ngrx.io/content/guide/component/let.md b/projects/ngrx.io/content/guide/component/let.md index 8b18129db4..05eff4a0ce 100644 --- a/projects/ngrx.io/content/guide/component/let.md +++ b/projects/ngrx.io/content/guide/component/let.md @@ -1,82 +1,99 @@ # ngrxLet Structural Directive -The `*ngrxLet` directive serves a convenient way of binding observables to a view context (a dom element scope). -It also helps with several internal processing under the hood. - -Same as [PushPipe](guide/component/push), it also respects ViewEngine as well as Ivy's new rendering API. +The `*ngrxLet` directive serves a convenient way of binding observables to a view context +(DOM element's scope). It also helps with several internal processing under the hood. ## Usage -The `*ngrxLet` directive is provided through the `ReactiveComponentModule`. To use it, add the `ReactiveComponentModule` to the `imports` of your NgModule. +The `*ngrxLet` directive is provided through the `ReactiveComponentModule`. +To use it, add the `ReactiveComponentModule` to the `imports` of your NgModule: -```typescript +```ts import { NgModule } from '@angular/core'; import { ReactiveComponentModule } from '@ngrx/component'; @NgModule({ imports: [ // other imports - ReactiveComponentModule - ] + ReactiveComponentModule, + ], }) export class MyFeatureModule {} ``` -## Comparison with Async Pipe +## Comparison with `*ngIf` and `async` -The current way of binding an observable to the view looks like that: +The current way of binding an observable to the view looks like this: ```html - - - - - + + + + ``` -The problem is `*ngIf` is also interfering with rendering and in case of a falsy value the component would be hidden. +The problem is that `*ngIf` is interfering with rendering. +In case of `0` (falsy value), the component would be hidden. -The `*ngrxLet` directive takes over several things while making it more convenient and safe to work with streams in the template. +The `*ngrxLet` directive takes over several things and makes it more convenient +and safe to work with streams in the template: ```html - - - + + - - - + + ``` -In addition to that it provides us information from the whole observable context. -We can track the observable notifications: +## Tracking Different Observable Events -- next value -- error value -- completion state +In addition to that it provides us information from the whole observable context. +We can track next, error, and complete events: ```html - - + + - - There is an error: {{e}} - - - Observable completed: {{c}} - + +

There is an error.

+

Observable is completed.

+
+``` + +## Using Suspense Template + +There is an option to pass the suspense template that will be displayed +when an observable is in a suspense state: + +```html + + + + +

Loading...

+
``` +
+ +An observable is in a suspense state until it emits the first event (next, error, or complete). + +
+ +In case a new observable is passed to the `*ngrxLet` directive at runtime, +the suspense template will be displayed again until the new observable emits the first event. + ## Included Features -- Binding is always present. (`*ngIf="truthy$"`) -- Takes away the multiple usages of the `async` or `ngrxPush` pipe -- Provides a unified/structured way of handling `null` and `undefined` -- Triggers change-detection differently if `zone.js` is present or not (`ChangeDetectorRef.detectChanges` or `ChangeDetectorRef.markForCheck`) -- Triggers change-detection differently if ViewEngine or Ivy is present (`ChangeDetectorRef.detectChanges` or `ɵdetectChanges`) -- Distinct same values in a row (distinctUntilChanged operator), - +- Binding is present even for falsy values. + (See ["Comparison with `*ngIf` and `async`"](#comparison-with-ngif-and-async) section) +- Takes away the multiple usages of the `async` or `ngrxPush` pipe. +- Provides a unified/structured way of handling `null` and `undefined`. +- Triggers the change detection differently if `zone.js` is present or not + using the `ChangeDetectorRef.markForCheck` or `ChangeDetectorRef.detectChanges`. +- Distinct the same values in a row using the `distinctUntilChanged` operator.