From 464073ddb6a52c2c04d3df25a716eff863cdc83e Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 19 Mar 2020 20:16:15 +0100 Subject: [PATCH] feat(component): add ngrxPush pipe and ngrxLet directive to @ngrx/component package (#2046) --- modules/component/package.json | 6 +- .../spec/core/cd-aware.abstract.spec.ts | 155 ++++++++++ .../projections/toObservableValue.spec.ts | 37 +++ .../get-change-detection-handling.spec.ts | 67 +++++ .../spec/core/utils/get-global-this.spec.ts | 11 + .../spec/core/utils/has-zone.spec.ts | 14 + .../component/spec/core/utils/is-ivy.spec.ts | 21 ++ .../component/spec/let/let.directive.spec.ts | 255 +++++++++++++++++ modules/component/spec/placeholder.spec.ts | 9 - modules/component/spec/push/push.pipe.spec.ts | 266 ++++++++++++++++++ .../spec/reactive-component.module.spec.ts | 18 ++ .../component/src/core/cd-aware.abstract.ts | 89 ++++++ modules/component/src/core/index.ts | 3 + .../component/src/core/projections/index.ts | 1 + .../src/core/projections/toObservableValue.ts | 32 +++ .../utils/get-change-detection-handling.ts | 35 +++ .../src/core/utils/get-global-this.ts | 7 + modules/component/src/core/utils/has-zone.ts | 5 + modules/component/src/core/utils/index.ts | 15 + modules/component/src/core/utils/is-ivy.ts | 25 ++ modules/component/src/core/utils/typing.ts | 45 +++ modules/component/src/index.ts | 4 +- modules/component/src/let/index.ts | 1 + modules/component/src/let/let.directive.ts | 232 +++++++++++++++ modules/component/src/placeholder.ts | 5 - modules/component/src/push/index.ts | 1 + modules/component/src/push/push.pipe.ts | 132 +++++++++ .../src/reactive-component.module.ts | 12 + .../ngrx.io/content/guide/component/let.md | 59 ++++ .../ngrx.io/content/guide/component/push.md | 33 +++ 30 files changed, 1577 insertions(+), 18 deletions(-) create mode 100644 modules/component/spec/core/cd-aware.abstract.spec.ts create mode 100644 modules/component/spec/core/projections/toObservableValue.spec.ts create mode 100644 modules/component/spec/core/utils/get-change-detection-handling.spec.ts create mode 100644 modules/component/spec/core/utils/get-global-this.spec.ts create mode 100644 modules/component/spec/core/utils/has-zone.spec.ts create mode 100644 modules/component/spec/core/utils/is-ivy.spec.ts create mode 100644 modules/component/spec/let/let.directive.spec.ts delete mode 100644 modules/component/spec/placeholder.spec.ts create mode 100644 modules/component/spec/push/push.pipe.spec.ts create mode 100644 modules/component/spec/reactive-component.module.spec.ts create mode 100644 modules/component/src/core/cd-aware.abstract.ts create mode 100644 modules/component/src/core/index.ts create mode 100644 modules/component/src/core/projections/index.ts create mode 100644 modules/component/src/core/projections/toObservableValue.ts create mode 100644 modules/component/src/core/utils/get-change-detection-handling.ts create mode 100644 modules/component/src/core/utils/get-global-this.ts create mode 100644 modules/component/src/core/utils/has-zone.ts create mode 100644 modules/component/src/core/utils/index.ts create mode 100644 modules/component/src/core/utils/is-ivy.ts create mode 100644 modules/component/src/core/utils/typing.ts create mode 100644 modules/component/src/let/index.ts create mode 100644 modules/component/src/let/let.directive.ts delete mode 100644 modules/component/src/placeholder.ts create mode 100644 modules/component/src/push/index.ts create mode 100644 modules/component/src/push/push.pipe.ts create mode 100644 modules/component/src/reactive-component.module.ts create mode 100644 projects/ngrx.io/content/guide/component/let.md create mode 100644 projects/ngrx.io/content/guide/component/push.md diff --git a/modules/component/package.json b/modules/component/package.json index c57916ea12..059acd628a 100644 --- a/modules/component/package.json +++ b/modules/component/package.json @@ -1,16 +1,16 @@ { "name": "@ngrx/component", "version": "0.0.0-PLACEHOLDER", - "description": "Reactive utilities for components", + "description": "Reactive Extensions for Angular Components", "repository": { "type": "git", "url": "https://github.com/ngrx/platform.git" }, "keywords": [ "Angular", - "Redux", + "RxJS", "NgRx", - "Schematics", + "Components", "Angular CLI" ], "author": "NgRx", diff --git a/modules/component/spec/core/cd-aware.abstract.spec.ts b/modules/component/spec/core/cd-aware.abstract.spec.ts new file mode 100644 index 0000000000..c2e8ea6052 --- /dev/null +++ b/modules/component/spec/core/cd-aware.abstract.spec.ts @@ -0,0 +1,155 @@ +import { + ChangeDetectorRef, + EmbeddedViewRef, + Injector, + NgZone, + OnDestroy, + Type, +} from '@angular/core'; +import { CdAware, createCdAware, getGlobalThis } from '../../src/core'; +import { + concat, + EMPTY, + NEVER, + NextObserver, + Observable, + of, + PartialObserver, + Unsubscribable, +} from 'rxjs'; +import { tap } from 'rxjs/operators'; + +class CdAwareImplementation implements OnDestroy { + public renderedValue: any = undefined; + public error: any = undefined; + public completed: boolean = false; + private readonly subscription: Unsubscribable; + public cdAware: CdAware; + resetContextObserver: NextObserver = { + next: _ => (this.renderedValue = undefined), + error: e => (this.error = e), + complete: () => (this.completed = true), + }; + updateViewContextObserver: PartialObserver = { + next: (n: U | undefined | null) => (this.renderedValue = n), + error: e => (this.error = e), + complete: () => (this.completed = true), + }; + configurableBehaviour = ( + o$: Observable> + ): Observable> => o$.pipe(tap()); + + constructor() { + this.cdAware = createCdAware({ + work: () => {}, + resetContextObserver: this.resetContextObserver, + updateViewContextObserver: this.updateViewContextObserver, + configurableBehaviour: this.configurableBehaviour, + }); + this.subscription = this.cdAware.subscribe(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} + +let cdAwareImplementation: CdAwareImplementation; +const setupCdAwareImplementation = () => { + cdAwareImplementation = new CdAwareImplementation(); + cdAwareImplementation.renderedValue = undefined; + cdAwareImplementation.error = undefined; + cdAwareImplementation.completed = false; +}; + +describe('CdAware', () => { + beforeEach(() => { + setupCdAwareImplementation(); + }); + + it('should be implementable', () => { + expect(cdAwareImplementation).toBeDefined(); + }); + + describe('next value', () => { + it('should do nothing if initialized (as no value ever was emitted)', () => { + expect(cdAwareImplementation.renderedValue).toBe(undefined); + }); + + it('should render undefined as value when initially undefined was passed (as no value ever was emitted)', () => { + cdAwareImplementation.cdAware.next(undefined); + expect(cdAwareImplementation.renderedValue).toBe(undefined); + }); + + it('should render null as value when initially null was passed (as no value ever was emitted)', () => { + cdAwareImplementation.cdAware.next(null); + expect(cdAwareImplementation.renderedValue).toBe(null); + }); + + it('should render undefined as value when initially of(undefined) was passed (as undefined was emitted)', () => { + cdAwareImplementation.cdAware.next(of(undefined)); + expect(cdAwareImplementation.renderedValue).toBe(undefined); + }); + + it('should render null as value when initially of(null) was passed (as null was emitted)', () => { + cdAwareImplementation.cdAware.next(of(null)); + expect(cdAwareImplementation.renderedValue).toBe(null); + }); + + it('should render undefined as value when initially EMPTY was passed (as no value ever was emitted)', () => { + cdAwareImplementation.cdAware.next(EMPTY); + expect(cdAwareImplementation.renderedValue).toBe(undefined); + }); + + it('should render undefined as value when initially NEVER was passed (as no value ever was emitted)', () => { + cdAwareImplementation.cdAware.next(NEVER); + expect(cdAwareImplementation.renderedValue).toBe(undefined); + }); + // Also: 'should keep last emitted value in the view until a new observable NEVER was passed (as no value ever was emitted from new observable)' + it('should render emitted value from passed observable without changing it', () => { + cdAwareImplementation.cdAware.next(of(42)); + expect(cdAwareImplementation.renderedValue).toBe(42); + }); + + it('should render undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => { + cdAwareImplementation.cdAware.next(of(42)); + expect(cdAwareImplementation.renderedValue).toBe(42); + cdAwareImplementation.cdAware.next(NEVER); + expect(cdAwareImplementation.renderedValue).toBe(undefined); + }); + }); + + describe('observable context', () => { + it('next handling running observable', () => { + cdAwareImplementation.cdAware.next(concat(of(42), NEVER)); + expect(cdAwareImplementation.renderedValue).toBe(42); + expect(cdAwareImplementation.error).toBe(undefined); + expect(cdAwareImplementation.completed).toBe(false); + }); + + it('next handling completed observable', () => { + cdAwareImplementation.cdAware.next(of(42)); + expect(cdAwareImplementation.renderedValue).toBe(42); + expect(cdAwareImplementation.error).toBe(undefined); + expect(cdAwareImplementation.completed).toBe(true); + }); + + it('error handling', () => { + expect(cdAwareImplementation.renderedValue).toBe(undefined); + cdAwareImplementation.cdAware.subscribe({ + error: (e: Error) => expect(e).toBeDefined(), + }); + expect(cdAwareImplementation.renderedValue).toBe(undefined); + // @TODO use this line + // expect(cdAwareImplementation.error).toBe(ArgumentNotObservableError); + expect(cdAwareImplementation.completed).toBe(false); + }); + + it('completion handling', () => { + cdAwareImplementation.cdAware.next(EMPTY); + expect(cdAwareImplementation.renderedValue).toBe(undefined); + expect(cdAwareImplementation.error).toBe(undefined); + expect(cdAwareImplementation.completed).toBe(true); + }); + }); +}); diff --git a/modules/component/spec/core/projections/toObservableValue.spec.ts b/modules/component/spec/core/projections/toObservableValue.spec.ts new file mode 100644 index 0000000000..5d85a19018 --- /dev/null +++ b/modules/component/spec/core/projections/toObservableValue.spec.ts @@ -0,0 +1,37 @@ +import { EMPTY, isObservable, Observable, of } from 'rxjs'; +import { toObservableValue } from '../../../src/core/projections'; + +describe('toObservableValue', () => { + describe('used as RxJS creation function', () => { + it('should take observables', () => { + const observable: Observable = toObservableValue(EMPTY); + expect(isObservable(observable)).toBe(true); + }); + + it('should take a promise', () => { + const observable: Observable = toObservableValue( + new Promise(() => {}) + ); + expect(isObservable(observable)).toBe(true); + }); + + it('should take undefined', () => { + const observable: Observable = toObservableValue(undefined); + expect(isObservable(observable)).toBe(true); + }); + + it('should take a null', () => { + const observable: Observable = toObservableValue(null); + expect(isObservable(observable)).toBe(true); + }); + + it('throw if no observable, promise, undefined or null is passed', () => { + const observable: Observable = toObservableValue(null); + observable.subscribe({ + error(e) { + expect(e).toBeDefined(); + }, + }); + }); + }); +}); diff --git a/modules/component/spec/core/utils/get-change-detection-handling.spec.ts b/modules/component/spec/core/utils/get-change-detection-handling.spec.ts new file mode 100644 index 0000000000..e556df17cf --- /dev/null +++ b/modules/component/spec/core/utils/get-change-detection-handling.spec.ts @@ -0,0 +1,67 @@ +import { + getChangeDetectionHandler, + getGlobalThis, +} from '../../../src/core/utils'; +import { Injector } from '@angular/core'; + +class NgZone {} +class NoopNgZone {} +class ChangeDetectorRef { + public markForCheck(): void {} + public detectChanges(): void {} +} + +let noopNgZone: any; +let ngZone: any; +let changeDetectorRef: any; + +beforeAll(() => { + const injector = Injector.create([ + { provide: NgZone, useClass: NgZone, deps: [] }, + { provide: NoopNgZone, useClass: NoopNgZone, deps: [] }, + { provide: ChangeDetectorRef, useClass: ChangeDetectorRef, deps: [] }, + ]); + noopNgZone = injector.get(NoopNgZone) as NgZone; + ngZone = injector.get(NgZone); + changeDetectorRef = injector.get(ChangeDetectorRef); +}); + +describe('getChangeDetectionHandler', () => { + describe('in ViewEngine', () => { + beforeAll(() => { + getGlobalThis().ng = { probe: true }; + }); + + it('should return markForCheck in zone-full mode', () => { + const markForCheckSpy = jasmine.createSpy('markForCheck'); + changeDetectorRef.markForCheck = markForCheckSpy; + getChangeDetectionHandler(ngZone, changeDetectorRef)(); + expect(markForCheckSpy).toHaveBeenCalled(); + }); + + it('should return detectChanges in zone-less mode', () => { + const detectChangesSpy = jasmine.createSpy('detectChanges'); + changeDetectorRef.detectChanges = detectChangesSpy; + getChangeDetectionHandler(noopNgZone, changeDetectorRef)(); + expect(detectChangesSpy).toHaveBeenCalled(); + }); + }); + + describe('in Ivy', () => { + beforeEach(() => { + getGlobalThis().ng = undefined; + }); + + it('should return markDirty in zone-full mode', () => { + expect(getChangeDetectionHandler(ngZone, changeDetectorRef).name).toBe( + 'markDirty' + ); + }); + + it('should return detectChanges in zone-less mode', () => { + expect( + getChangeDetectionHandler(noopNgZone, changeDetectorRef).name + ).toBe('detectChanges'); + }); + }); +}); diff --git a/modules/component/spec/core/utils/get-global-this.spec.ts b/modules/component/spec/core/utils/get-global-this.spec.ts new file mode 100644 index 0000000000..5047f5f307 --- /dev/null +++ b/modules/component/spec/core/utils/get-global-this.spec.ts @@ -0,0 +1,11 @@ +import { getGlobalThis } from '../../../src/core'; + +describe('getGlobalThis', () => { + it('should return global this', () => { + getGlobalThis().prop = 42; + const globalThis = getGlobalThis(); + + expect(globalThis).toBeDefined(); + expect(globalThis.prop).toBe(42); + }); +}); diff --git a/modules/component/spec/core/utils/has-zone.spec.ts b/modules/component/spec/core/utils/has-zone.spec.ts new file mode 100644 index 0000000000..293a4583b8 --- /dev/null +++ b/modules/component/spec/core/utils/has-zone.spec.ts @@ -0,0 +1,14 @@ +import { hasZone } from '../../../src/core/utils'; +import { NgZone } from '@angular/core'; + +class NoopNgZone {} + +describe('isZoneLess', () => { + it('should return false if something else than noop zone is passed', () => { + expect(!hasZone({} as NgZone)).toBe(false); + }); + + it('should return true if a noop zone is passed', () => { + expect(!hasZone(new NoopNgZone() as NgZone)).toBe(true); + }); +}); diff --git a/modules/component/spec/core/utils/is-ivy.spec.ts b/modules/component/spec/core/utils/is-ivy.spec.ts new file mode 100644 index 0000000000..1291d330e4 --- /dev/null +++ b/modules/component/spec/core/utils/is-ivy.spec.ts @@ -0,0 +1,21 @@ +import { getGlobalThis, isIvy } from '../../../src/core'; + +describe('isIvy', () => { + describe('in ViewEngine Angular 8 + 9', () => { + it('should return false if ng is defined with probe', () => { + getGlobalThis().ng = { probe: true }; + expect(isIvy()).toBe(false); + }); + }); + describe('in Ivy Angular 9', () => { + it('should return true if ng is undefined', () => { + getGlobalThis().ng = undefined; + expect(isIvy()).toBe(true); + }); + + it('should return true if ng.probe is set', () => { + getGlobalThis().ng = { probe: undefined }; + expect(isIvy()).toBe(true); + }); + }); +}); diff --git a/modules/component/spec/let/let.directive.spec.ts b/modules/component/spec/let/let.directive.spec.ts new file mode 100644 index 0000000000..e95d4f9975 --- /dev/null +++ b/modules/component/spec/let/let.directive.spec.ts @@ -0,0 +1,255 @@ +import { + ChangeDetectorRef, + Component, + NgZone as OriginalNgZone, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { EMPTY, interval, NEVER, Observable, of, throwError } from 'rxjs'; +import { async, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { LetDirective } from '@ngrx/component'; +import { take } from 'rxjs/operators'; + +let letDirective: any; + +class NgZone extends OriginalNgZone { + constructor() { + super({ enableLongStackTrace: false }); + } +} + +class NoopNgZone { + constructor() { + // super({enableLongStackTrace: false}); + } +} + +class MockChangeDetectorRef { + public markForCheck(): string { + return 'markForCheck'; + } + + public detectChanges(): string { + return 'detectChanges'; + } +} + +@Component({ + template: ` + {{ (value | json) || 'undefined' }} + `, +}) +class LetDirectiveTestComponent { + cfg: any = { optimized: false }; + value$: Observable = of(42); +} + +@Component({ + template: ` + {{ error }} + `, +}) +class LetDirectiveTestErrorComponent { + cfg: any = { optimized: false }; + value$: Observable = of(42); +} + +@Component({ + template: ` + {{ + complete + }} + `, +}) +class LetDirectiveTestCompleteComponent { + cfg: any = { optimized: false }; + value$: Observable = of(42); +} + +let fixtureLetDirectiveTestComponent: any; +let letDirectiveTestComponent: { + cfg: any; + value$: Observable | undefined | null; +}; +let componentNativeElement: any; + +const setupLetDirectiveTestComponent = (): void => { + TestBed.configureTestingModule({ + declarations: [LetDirectiveTestComponent, LetDirective], + providers: [ + NgZone, + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + TemplateRef, + ViewContainerRef, + ], + }); + fixtureLetDirectiveTestComponent = TestBed.createComponent( + LetDirectiveTestComponent + ); + letDirectiveTestComponent = + fixtureLetDirectiveTestComponent.componentInstance; + componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; +}; +const setupLetDirectiveTestComponentError = (): void => { + TestBed.configureTestingModule({ + declarations: [LetDirectiveTestErrorComponent, LetDirective], + providers: [ + NgZone, + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + TemplateRef, + ViewContainerRef, + ], + }); + + fixtureLetDirectiveTestComponent = TestBed.createComponent( + LetDirectiveTestErrorComponent + ); + letDirectiveTestComponent = + fixtureLetDirectiveTestComponent.componentInstance; + componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; +}; +const setupLetDirectiveTestComponentComplete = (): void => { + TestBed.configureTestingModule({ + declarations: [LetDirectiveTestCompleteComponent, LetDirective], + providers: [ + NgZone, + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + TemplateRef, + ViewContainerRef, + ], + }); + + fixtureLetDirectiveTestComponent = TestBed.createComponent( + LetDirectiveTestCompleteComponent + ); + letDirectiveTestComponent = + fixtureLetDirectiveTestComponent.componentInstance; + componentNativeElement = fixtureLetDirectiveTestComponent.nativeElement; +}; + +describe('LetDirective', () => { + describe('when nexting values', () => { + beforeEach(async(setupLetDirectiveTestComponent)); + + it('should be instantiable', () => { + expect(fixtureLetDirectiveTestComponent).toBeDefined(); + expect(letDirectiveTestComponent).toBeDefined(); + expect(componentNativeElement).toBeDefined(); + }); + + it('should render undefined as value when initially undefined was passed (as no value ever was emitted)', () => { + letDirectiveTestComponent.value$ = undefined; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('undefined'); + }); + + it('should render null as value when initially null was passed (as no value ever was emitted)', () => { + letDirectiveTestComponent.value$ = null; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('null'); + }); + + it('should render undefined as value when initially of(undefined) was passed (as undefined was emitted)', () => { + letDirectiveTestComponent.value$ = of(undefined); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('undefined'); + }); + + it('should render null as value when initially of(null) was passed (as null was emitted)', () => { + letDirectiveTestComponent.value$ = of(null); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('null'); + }); + + it('should render undefined as value when initially EMPTY was passed (as no value ever was emitted)', () => { + letDirectiveTestComponent.value$ = EMPTY; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('undefined'); + }); + + it('should render nothing as value when initially NEVER was passed (as no value ever was emitted)', () => { + letDirectiveTestComponent.value$ = NEVER; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(''); + }); + + it('should render emitted value from passed observable without changing it', () => { + letDirectiveTestComponent.value$ = of(42); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('42'); + }); + + it('should render undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => { + letDirectiveTestComponent.value$ = of(42); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('42'); + letDirectiveTestComponent.value$ = NEVER; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('undefined'); + }); + + it('should render new value as value when a new observable was passed', () => { + letDirectiveTestComponent.value$ = of(42); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('42'); + letDirectiveTestComponent.value$ = of(45); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('45'); + }); + + it('should render the last value when a new observable was passed', () => { + letDirectiveTestComponent.value$ = of(42, 45); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('45'); + }); + + it('should render values over time when a new observable was passed', fakeAsync(() => { + letDirectiveTestComponent.value$ = interval(1000).pipe(take(3)); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(''); + tick(1000); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('0'); + tick(1000); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('1'); + tick(1000); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('2'); + + tick(1000); + fixtureLetDirectiveTestComponent.detectChanges(); + // Remains at 2, since that was the last value. + expect(componentNativeElement.textContent).toBe('2'); + })); + }); + + describe('when error', () => { + beforeEach(async(setupLetDirectiveTestComponentError)); + + it('should render the error to false if next or complete', () => { + letDirectiveTestComponent.value$ = of(1); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('false'); + }); + + it('should render the error to true if one occurs', () => { + letDirectiveTestComponent.value$ = throwError(new Error('error message')); + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('true'); + }); + }); + + describe('when complete', () => { + beforeEach(async(setupLetDirectiveTestComponentComplete)); + + it('should render true if completed', () => { + letDirectiveTestComponent.value$ = EMPTY; + fixtureLetDirectiveTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe('true'); + }); + }); +}); diff --git a/modules/component/spec/placeholder.spec.ts b/modules/component/spec/placeholder.spec.ts deleted file mode 100644 index 13b296cdb1..0000000000 --- a/modules/component/spec/placeholder.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -// TODO: @ngrx/component - remove this file - -import { sum } from '@ngrx/component'; - -describe('placeholder', () => { - it('should run specs', () => { - expect(sum(2, 5)).toBe(7); - }); -}); diff --git a/modules/component/spec/push/push.pipe.spec.ts b/modules/component/spec/push/push.pipe.spec.ts new file mode 100644 index 0000000000..71322f2c65 --- /dev/null +++ b/modules/component/spec/push/push.pipe.spec.ts @@ -0,0 +1,266 @@ +import { PushPipe } from '../../src/push'; +import { async, TestBed } from '@angular/core/testing'; +import { + ChangeDetectorRef, + Component, + NgZone as OriginalNgZone, +} from '@angular/core'; +import { getGlobalThis, isIvy, hasZone } from '../../src/core/utils'; +import { EMPTY, NEVER, Observable, of } from 'rxjs'; +import { CoalescingConfig } from '../../src/core'; + +let pushPipe: any; + +function wrapWithSpace(str: string): string { + return ' ' + str + ' '; +} + +class NgZone extends OriginalNgZone { + constructor() { + super({ enableLongStackTrace: false }); + } +} + +class NoopNgZone { + constructor() { + // super({enableLongStackTrace: false}); + } +} + +class MockChangeDetectorRef { + public markForCheck(): string { + return 'markForCheck'; + } + + public detectChanges(): string { + return 'detectChanges'; + } +} + +@Component({ + template: ` + {{ (value$ | ngrxPush: cfg | json) || 'undefined' }} + `, +}) +class PushPipeTestComponent { + cfg: CoalescingConfig = { optimized: false }; + value$: Observable = of(42); +} + +let fixturePushPipeTestComponent: any; +let pushPipeTestComponent: { + cfg: CoalescingConfig; + value$: Observable | undefined | null; +}; +let componentNativeElement: any; +let noopNgZone: any; +let ngZone: NgZone; + +const setupPushPipeComponent = () => { + TestBed.configureTestingModule({ + providers: [ + PushPipe, + NgZone, + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + ], + }); + pushPipe = TestBed.get(PushPipe); +}; +const setupPushPipeComponentZoneLess = () => { + getGlobalThis().ng = undefined; + + TestBed.configureTestingModule({ + providers: [ + { provide: NgZone, useClass: NoopNgZone }, + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + { + provide: PushPipe, + useClass: PushPipe, + depths: [ChangeDetectorRef, NgZone], + }, + ], + }); + pushPipe = TestBed.get(PushPipe); + noopNgZone = TestBed.get(NgZone); +}; + +const setupPushPipeComponentZoneFull = () => { + getGlobalThis().ng = undefined; + TestBed.configureTestingModule({ + providers: [ + PushPipe, + NgZone, + { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, + ], + }); + pushPipe = TestBed.get(PushPipe); + ngZone = TestBed.get(NgZone); +}; +describe('PushPipe', () => { + describe('used as a Service', () => { + beforeEach(async(setupPushPipeComponent)); + + it('should be instantiable', () => { + expect(pushPipe).toBeDefined(); + }); + + describe('transform function', () => { + it('should return undefined as value when initially undefined was passed (as no value ever was emitted)', () => { + expect(pushPipe.transform(undefined)).toBe(undefined); + }); + + it('should return null as value when initially null was passed (as no value ever was emitted)', () => { + expect(pushPipe.transform(null)).toBe(null); + }); + + it('should return undefined as value when initially of(undefined) was passed (as undefined was emitted)', () => { + expect(pushPipe.transform(of(undefined))).toBe(undefined); + }); + + it('should return null as value when initially of(null) was passed (as null was emitted)', () => { + expect(pushPipe.transform(of(null))).toBe(null); + }); + + it('should return undefined as value when initially EMPTY was passed (as no value ever was emitted)', () => { + expect(pushPipe.transform(EMPTY)).toBe(undefined); + }); + + it('should return undefined as value when initially NEVER was passed (as no value ever was emitted)', () => { + expect(pushPipe.transform(NEVER)).toBe(undefined); + }); + + it('should return emitted value from passed observable without changing it', () => { + expect(pushPipe.transform(of(42))).toBe(42); + }); + + it('should return undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => { + expect(pushPipe.transform(of(42))).toBe(42); + expect(pushPipe.transform(NEVER)).toBe(undefined); + }); + }); + }); + + describe('used as a Pipe', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [PushPipe, PushPipeTestComponent], + }); + + fixturePushPipeTestComponent = TestBed.createComponent( + PushPipeTestComponent + ); + pushPipeTestComponent = fixturePushPipeTestComponent.componentInstance; + componentNativeElement = fixturePushPipeTestComponent.nativeElement; + }); + + it('should be instantiable', () => { + expect(fixturePushPipeTestComponent).toBeDefined(); + expect(pushPipeTestComponent).toBeDefined(); + expect(componentNativeElement).toBeDefined(); + }); + + describe('transform function', () => { + it('should return undefined as value when initially undefined was passed (as no value ever was emitted)', () => { + pushPipeTestComponent.value$ = undefined; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + }); + + it('should return null as value when initially null was passed (as no value ever was emitted)', () => { + pushPipeTestComponent.value$ = null; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('null')); + }); + + it('should return undefined as value when initially of(undefined) was passed (as undefined was emitted)', () => { + pushPipeTestComponent.value$ = of(undefined); + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + }); + + it('should return null as value when initially of(null) was passed (as null was emitted)', () => { + pushPipeTestComponent.value$ = of(null); + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('null')); + }); + + it('should return undefined as value when initially EMPTY was passed (as no value ever was emitted)', () => { + pushPipeTestComponent.value$ = EMPTY; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + }); + + it('should return undefined as value when initially NEVER was passed (as no value ever was emitted)', () => { + pushPipeTestComponent.value$ = NEVER; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + }); + + it('should return emitted value from passed observable without changing it', () => { + pushPipeTestComponent.value$ = of(42); + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); + }); + + it('should return undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => { + pushPipeTestComponent.value$ = of(42); + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); + pushPipeTestComponent.value$ = NEVER; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + }); + }); + }); + + xdescribe('when used in zone-less', () => { + beforeEach(async(setupPushPipeComponentZoneLess)); + + it('should call dcRef.detectChanges in ViewEngine', () => { + getGlobalThis().ng = { probe: true }; + const ngZone = (pushPipe as any).ngZone; + expect(!hasZone(noopNgZone)).toBe(true); + expect(noopNgZone).toBe(ngZone); + expect(!hasZone(ngZone)).toBe(true); + expect(isIvy()).toBe(false); + expect(pushPipe.handleChangeDetection.name).toBe('detectChanges'); + }); + + it('should call detectChanges in Ivy', () => { + getGlobalThis().ng = undefined; + expect(!hasZone(noopNgZone)).toBe(true); + expect(isIvy()).toBe(true); + // @TODO + expect(false).toBe('detectChanges'); + }); + }); + + xdescribe('when used in zone-full mode', () => { + beforeEach(async(setupPushPipeComponentZoneFull)); + + it('should call dcRef.markForCheck in ViewEngine', () => { + getGlobalThis().ng = { probe: true }; + expect(!hasZone(ngZone)).toBe(false); + expect(isIvy()).toBe(false); + expect(pushPipe.handleChangeDetection()).toBe('markForCheck'); + }); + + it('should call markDirty in Ivy', () => { + getGlobalThis().ng = undefined; + expect(!hasZone(ngZone)).toBe(false); + expect(isIvy()).toBe(true); + // @TODO + expect(false).toBe('markDirty'); + }); + }); +}); diff --git a/modules/component/spec/reactive-component.module.spec.ts b/modules/component/spec/reactive-component.module.spec.ts new file mode 100644 index 0000000000..efe2116f6b --- /dev/null +++ b/modules/component/spec/reactive-component.module.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveComponentModule } from '../'; + +describe('Component Module', () => { + let componentModule: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ReactiveComponentModule], + }); + + componentModule = TestBed.get(ReactiveComponentModule); + }); + + it('should add all effects when instantiated', () => { + expect(componentModule).toBeDefined(); + }); +}); diff --git a/modules/component/src/core/cd-aware.abstract.ts b/modules/component/src/core/cd-aware.abstract.ts new file mode 100644 index 0000000000..a398902ae2 --- /dev/null +++ b/modules/component/src/core/cd-aware.abstract.ts @@ -0,0 +1,89 @@ +import { ChangeDetectorRef, NgZone } from '@angular/core'; +import { getChangeDetectionHandler } from './utils'; +import { + NextObserver, + Observable, + PartialObserver, + Subject, + Subscribable, + Subscription, +} from 'rxjs'; +import { distinctUntilChanged, map, switchAll, tap } from 'rxjs/operators'; +import { toObservableValue } from './projections'; + +export interface CoalescingConfig { + optimized: boolean; +} + +export interface CdAware extends Subscribable { + next: (value: Observable | Promise | null | undefined) => void; +} + +export interface WorkConfig { + context: any; + ngZone: NgZone; + cdRef: ChangeDetectorRef; +} + +export function setUpWork(cfg: WorkConfig): () => void { + const render: (component?: any) => void = getChangeDetectionHandler( + cfg.ngZone, + cfg.cdRef + ); + return () => render(cfg.context); +} + +/** + * class CdAware + * + * @description + * This abstract class holds all the shared logic for the push pipe and the let directive + * responsible for change detection + * If you extend this class you need to implement how the update of the rendered value happens. + * Also custom behaviour is something you need to implement in the extending class + */ +export function createCdAware(cfg: { + work: () => void; + resetContextObserver: NextObserver; + configurableBehaviour: ( + o: Observable> + ) => Observable>; + updateViewContextObserver: PartialObserver; +}): CdAware { + const observablesSubject = new Subject< + Observable | Promise | null | undefined + >(); + // We have to defer the setup of observables$ until subscription as getConfigurableBehaviour is defined in the + // extending class. So getConfigurableBehaviour is not available in the abstract layer + const observables$: Observable< + U | undefined | null + > = observablesSubject.pipe( + // Ignore potential observables of the same instances + distinctUntilChanged(), + // Try to convert it to values, throw if not possible + map(v => toObservableValue(v)), + tap((v: any) => { + cfg.resetContextObserver.next(v); + cfg.work(); + }), + map(value$ => + value$.pipe(distinctUntilChanged(), tap(cfg.updateViewContextObserver)) + ), + // e.g. coalescing + cfg.configurableBehaviour, + // Unsubscribe from previous observables + // Then flatten the latest internal observables into the output + // @NOTICE applied behaviour (on the values, not the observable) will fire here + switchAll(), + tap(() => cfg.work()) + ); + + return { + next(value: any): void { + observablesSubject.next(value); + }, + subscribe(): Subscription { + return observables$.subscribe(); + }, + } as CdAware; +} diff --git a/modules/component/src/core/index.ts b/modules/component/src/core/index.ts new file mode 100644 index 0000000000..5d10e04ff6 --- /dev/null +++ b/modules/component/src/core/index.ts @@ -0,0 +1,3 @@ +export * from './utils'; +export * from './projections'; +export * from './cd-aware.abstract'; diff --git a/modules/component/src/core/projections/index.ts b/modules/component/src/core/projections/index.ts new file mode 100644 index 0000000000..23b688d4d3 --- /dev/null +++ b/modules/component/src/core/projections/index.ts @@ -0,0 +1 @@ +export * from './toObservableValue'; diff --git a/modules/component/src/core/projections/toObservableValue.ts b/modules/component/src/core/projections/toObservableValue.ts new file mode 100644 index 0000000000..4f45ca06ee --- /dev/null +++ b/modules/component/src/core/projections/toObservableValue.ts @@ -0,0 +1,32 @@ +import { from, of } from 'rxjs'; +import { + isObservableGuard, + isPromiseGuard, + PotentialObservableValue, + Output, +} from '../utils'; + +export function toObservableValue( + p: PotentialObservableValue +): Output { + // Comparing to the literal null value with the == operator covers both null and undefined values. + if (p === null) { + return of(p); + } + + if (p === undefined) { + return of(p); + } + + if (isObservableGuard(p)) { + return p; + } + + if (isPromiseGuard(p)) { + return from(p); + } + + throw new Error( + 'Argument not observable. Only null/undefined or Promise/Observable-like values are allowed.' + ); +} diff --git a/modules/component/src/core/utils/get-change-detection-handling.ts b/modules/component/src/core/utils/get-change-detection-handling.ts new file mode 100644 index 0000000000..3163fd2e0f --- /dev/null +++ b/modules/component/src/core/utils/get-change-detection-handling.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectorRef, + NgZone, + ɵdetectChanges as detectChanges, + ɵmarkDirty as markDirty, +} from '@angular/core'; + +import { isIvy } from './is-ivy'; +import { hasZone } from './has-zone'; + +export function getChangeDetectionHandler( + ngZone: NgZone, + cdRef: ChangeDetectorRef +): (component?: T) => void { + if (isIvy()) { + return hasZone(ngZone) ? markDirty : detectChanges; + } else { + return hasZone(ngZone) + ? cdRef.markForCheck.bind(cdRef) + : cdRef.detectChanges.bind(cdRef); + } +} + +export function getDetectChanges( + ngZone: NgZone, + cdRef: ChangeDetectorRef +): (component?: T) => void { + if (isIvy()) { + return !hasZone(ngZone) ? detectChanges : markDirty; + } else { + return hasZone(ngZone) + ? cdRef.markForCheck.bind(cdRef) + : cdRef.detectChanges.bind(cdRef); + } +} diff --git a/modules/component/src/core/utils/get-global-this.ts b/modules/component/src/core/utils/get-global-this.ts new file mode 100644 index 0000000000..1d0d812340 --- /dev/null +++ b/modules/component/src/core/utils/get-global-this.ts @@ -0,0 +1,7 @@ +// Returns a reference to global thin +// - Browser +// - SSR +// - Tests +export function getGlobalThis(): any { + return ((globalThis as any) || (self as any) || (window as any)) as any; +} diff --git a/modules/component/src/core/utils/has-zone.ts b/modules/component/src/core/utils/has-zone.ts new file mode 100644 index 0000000000..06140c42c8 --- /dev/null +++ b/modules/component/src/core/utils/has-zone.ts @@ -0,0 +1,5 @@ +import { NgZone } from '@angular/core'; + +export function hasZone(z: NgZone): boolean { + return z.constructor.name !== 'NoopNgZone'; +} diff --git a/modules/component/src/core/utils/index.ts b/modules/component/src/core/utils/index.ts new file mode 100644 index 0000000000..e86c97f344 --- /dev/null +++ b/modules/component/src/core/utils/index.ts @@ -0,0 +1,15 @@ +export { getChangeDetectionHandler } from './get-change-detection-handling'; +export { getGlobalThis } from './get-global-this'; +export { isIvy } from './is-ivy'; +export { hasZone } from './has-zone'; + +export { + isDefinedGuard, + isIterableGuard, + isObservableGuard, + isOperateFnArrayGuard, + isPromiseGuard, + isStringArrayGuard, + PotentialObservableValue, + Output, +} from './typing'; diff --git a/modules/component/src/core/utils/is-ivy.ts b/modules/component/src/core/utils/is-ivy.ts new file mode 100644 index 0000000000..77bfedc782 --- /dev/null +++ b/modules/component/src/core/utils/is-ivy.ts @@ -0,0 +1,25 @@ +import { getGlobalThis } from './get-global-this'; + +// Table for ng global presence in ViewEngine and Ivy for prod/dev modes: +// +// | render | ViewEngine | ViewEngine | Ivy | Ivy | +// | mode | prod | dev | prod | dev | +// | ng | present | present | undefined | present | +// | ng.probe | present | present | undefined | undefined | +// +// So for Ivy we need to make sure that ng is undefined or, +// in case of dev environment, ng.probe is undefined + +export function isIvy(): boolean { + const ng: any = getGlobalThis().ng; + + // Is the global ng object is unavailable? + // ng === undefined in Ivy production mode + // View Engine has the ng object both in development mode and production mode. + return ( + ng === undefined || + // in case we are in dev mode in ivy + // `probe` property is available on ng object we use View Engine. + ng.probe === undefined + ); +} diff --git a/modules/component/src/core/utils/typing.ts b/modules/component/src/core/utils/typing.ts new file mode 100644 index 0000000000..48fa38e950 --- /dev/null +++ b/modules/component/src/core/utils/typing.ts @@ -0,0 +1,45 @@ +import { isObservable, Observable, OperatorFunction } from 'rxjs'; +export type PotentialObservableValue = + | Observable + | Promise + | undefined + | null; +export type Output = + | Observable + | Observable + | Observable; + +export function isPromiseGuard(value: any): value is Promise { + return ( + !!value && + typeof (value as any).subscribe !== 'function' && + typeof (value as any).then === 'function' + ); +} + +export function isObservableGuard( + potentialObservable: any +): potentialObservable is Observable { + return isObservable(potentialObservable); +} + +export function isOperateFnArrayGuard( + op: any[] +): op is OperatorFunction[] { + return op.every((i: any) => typeof i !== 'string'); +} + +export function isStringArrayGuard(op: any[]): op is string[] { + return op.every((i: any) => typeof i !== 'string'); +} + +export function isDefinedGuard(opr: any): opr is T { + return !!opr; +} + +export function isIterableGuard(obj: any): obj is Array { + if (obj === undefined) { + return false; + } + return typeof (obj as any)[Symbol.iterator] === 'function'; +} diff --git a/modules/component/src/index.ts b/modules/component/src/index.ts index 44ebde2c43..6218cc09f7 100644 --- a/modules/component/src/index.ts +++ b/modules/component/src/index.ts @@ -1 +1,3 @@ -export * from './placeholder'; +export { PushPipe } from './push'; +export { LetDirective } from './let'; +export { ReactiveComponentModule } from './reactive-component.module'; diff --git a/modules/component/src/let/index.ts b/modules/component/src/let/index.ts new file mode 100644 index 0000000000..2425e55e3f --- /dev/null +++ b/modules/component/src/let/index.ts @@ -0,0 +1 @@ +export * from './let.directive'; diff --git a/modules/component/src/let/let.directive.ts b/modules/component/src/let/let.directive.ts new file mode 100644 index 0000000000..381d56ed27 --- /dev/null +++ b/modules/component/src/let/let.directive.ts @@ -0,0 +1,232 @@ +import { + ChangeDetectorRef, + Directive, + EmbeddedViewRef, + Input, + NgZone, + OnDestroy, + TemplateRef, + Type, + ViewContainerRef, +} from '@angular/core'; + +import { + EMPTY, + NextObserver, + Observable, + PartialObserver, + ReplaySubject, + Unsubscribable, +} from 'rxjs'; +import { + catchError, + distinctUntilChanged, + filter, + map, + startWith, + withLatestFrom, +} from 'rxjs/operators'; +import { + CdAware, + CoalescingConfig as NgRxLetConfig, + createCdAware, + setUpWork, +} from '../core'; + +export interface LetViewContext { + // to enable `let` syntax we have to use $implicit (var; let v = var) + $implicit?: T; + // to enable `as` syntax we have to assign the directives selector (var as v) + ngrxLet?: T; + // set context var complete to true (var$; let v = $error) + $error?: boolean; + // set context var complete to true (var$; let v = $complete) + $complete?: boolean; +} + +/** + * @Directive LetDirective + * + * @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$"`) + * - 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), + * + * @usageNotes + * + * ### Examples + * + * The `*ngrxLet` directive take over several things and makes it more convenient and save 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 observables: + * - next value + * - error value + * - complete state + * + * ```html + * + * + * + * + * There is an error: {{e}} + * + * + * Observable completed: {{c}} + * + * + * ``` + * + * @publicApi + */ +@Directive({ selector: '[ngrxLet]' }) +export class LetDirective implements OnDestroy { + private embeddedView: any; + private readonly ViewContext: LetViewContext = { + $implicit: undefined, + ngrxLet: undefined, + $error: false, + $complete: false, + }; + + private readonly configSubject = new ReplaySubject(); + private readonly config$ = this.configSubject.pipe( + filter(v => v !== undefined && v !== null), + distinctUntilChanged(), + startWith({ optimized: true }) + ); + + protected readonly subscription: Unsubscribable; + private readonly cdAware: CdAware; + private readonly resetContextObserver: NextObserver = { + next: () => { + // if not initialized no need to set undefined + if (this.embeddedView) { + this.ViewContext.$implicit = undefined; + this.ViewContext.ngrxLet = undefined; + this.ViewContext.$error = false; + this.ViewContext.$complete = false; + } + }, + }; + private readonly updateViewContextObserver: PartialObserver< + U | null | undefined + > = { + next: (value: U | null | undefined) => { + // to have init lazy + if (!this.embeddedView) { + this.createEmbeddedView(); + } + this.ViewContext.$implicit = value; + this.ViewContext.ngrxLet = value; + }, + error: (error: Error) => { + // to have init lazy + if (!this.embeddedView) { + this.createEmbeddedView(); + } + this.ViewContext.$error = true; + }, + complete: () => { + // to have init lazy + if (!this.embeddedView) { + this.createEmbeddedView(); + } + this.ViewContext.$complete = true; + }, + }; + + static ngTemplateContextGuard( + dir: LetDirective, + ctx: unknown + ): ctx is LetViewContext { + return true; + } + + private readonly configurableBehaviour = ( + o$: Observable> + ): Observable> => + o$.pipe( + withLatestFrom(this.config$), + // @NOTICE: unused config => As discussed with Brandon we keep it here because in the beta release we implement configuration behavior here + map(([value$, config]) => { + return value$.pipe(catchError(e => EMPTY)); + }) + ); + + @Input() + set ngrxLet( + potentialObservable: Observable | Promise | null | undefined + ) { + this.cdAware.next(potentialObservable); + } + + @Input() + set ngrxLetConfig(config: NgRxLetConfig) { + this.configSubject.next(config || { optimized: true }); + } + + constructor( + cdRef: ChangeDetectorRef, + ngZone: NgZone, + private readonly templateRef: TemplateRef>, + private readonly viewContainerRef: ViewContainerRef + ) { + this.cdAware = createCdAware({ + work: setUpWork({ + cdRef, + ngZone, + context: (cdRef as EmbeddedViewRef>).context, + }), + resetContextObserver: this.resetContextObserver, + updateViewContextObserver: this.updateViewContextObserver, + configurableBehaviour: this.configurableBehaviour, + }); + this.subscription = this.cdAware.subscribe(); + } + + createEmbeddedView() { + this.embeddedView = this.viewContainerRef.createEmbeddedView( + this.templateRef, + this.ViewContext + ); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + this.viewContainerRef.clear(); + } +} diff --git a/modules/component/src/placeholder.ts b/modules/component/src/placeholder.ts deleted file mode 100644 index c35602f081..0000000000 --- a/modules/component/src/placeholder.ts +++ /dev/null @@ -1,5 +0,0 @@ -// TODO: @ngrx/component - remove this file - -export function sum(a: number, b: number) { - return a + b; -} diff --git a/modules/component/src/push/index.ts b/modules/component/src/push/index.ts new file mode 100644 index 0000000000..47caf9154b --- /dev/null +++ b/modules/component/src/push/index.ts @@ -0,0 +1 @@ +export * from './push.pipe'; diff --git a/modules/component/src/push/push.pipe.ts b/modules/component/src/push/push.pipe.ts new file mode 100644 index 0000000000..d5fef096cb --- /dev/null +++ b/modules/component/src/push/push.pipe.ts @@ -0,0 +1,132 @@ +import { + ChangeDetectorRef, + EmbeddedViewRef, + NgZone, + OnDestroy, + Pipe, + PipeTransform, + Type, +} from '@angular/core'; +import { + NextObserver, + Observable, + PartialObserver, + Subject, + Unsubscribable, +} from 'rxjs'; +import { distinctUntilChanged, map, withLatestFrom } from 'rxjs/operators'; +import { + CdAware, + CoalescingConfig as PushPipeConfig, + createCdAware, + setUpWork, +} from '../core'; + +/** + * @Pipe PushPipe + * @description + * + * The `ngrxPush` pipe serves as a drop-in replacement for the `async` pipe. + * It contains intelligent handling of change detection to enable us + * running in zone-full as well as zone-less mode without any changes to the code. + * + * The current way of binding an observable to the view looks like that: + * ```html + * {{observable$ | async}} + * {{o}} + * + * ``` + * + * The problem is `async` pipe just marks the component and all its ancestors as dirty. + * It needs zone.js microtask queue to exhaust until `ApplicationRef.tick` is called to render all dirty marked components. + * + * Heavy dynamic and interactive UIs suffer from zones change detection a lot and can + * lean to bad performance or even unusable applications, but the `async` pipe does not work in zone-less mode. + * + * `ngrxPush` pipe solves that problem. + * + * Included Features: + * - Take observables or promises, 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`) + * - Distinct same values in a row to increase performance + * - Coalescing of change detection calls to boost performance + * + * @usageNotes + * + * ### Examples + * + * `ngrxPush` pipe solves that problem. It can be used like shown here: + * ```html + * {{observable$ | ngrxPush}} + * {{o}} + * + * ``` + * + * @publicApi + */ +@Pipe({ name: 'ngrxPush', pure: false }) +export class PushPipe implements PipeTransform, OnDestroy { + private renderedValue: S | null | undefined; + + private readonly configSubject = new Subject(); + private readonly config$ = this.configSubject + .asObservable() + .pipe(distinctUntilChanged()); + + private readonly subscription: Unsubscribable; + private readonly cdAware: CdAware; + private readonly updateViewContextObserver: PartialObserver< + S | null | undefined + > = { + // assign value that will get returned from the transform function on the next change detection + next: (value: S | null | undefined) => (this.renderedValue = value), + }; + private readonly resetContextObserver: NextObserver = { + next: (value: unknown) => (this.renderedValue = undefined), + }; + private readonly configurableBehaviour = ( + o$: Observable> + ): Observable> => + o$.pipe( + withLatestFrom(this.config$), + map(([value$, config]) => { + // As discussed with Brandon we keep it here + // because in the beta we implement configuration behavior here + return value$.pipe(); + }) + ); + + constructor(cdRef: ChangeDetectorRef, ngZone: NgZone) { + this.cdAware = createCdAware({ + work: setUpWork({ + ngZone, + cdRef, + context: (cdRef as EmbeddedViewRef>).context, + }), + updateViewContextObserver: this.updateViewContextObserver, + resetContextObserver: this.resetContextObserver, + configurableBehaviour: this.configurableBehaviour, + }); + this.subscription = this.cdAware.subscribe(); + } + + transform(potentialObservable: null, config?: PushPipeConfig): null; + transform(potentialObservable: undefined, config?: PushPipeConfig): undefined; + transform( + potentialObservable: Observable | Promise, + config?: PushPipeConfig + ): S; + transform( + potentialObservable: Observable | Promise | null | undefined, + config: PushPipeConfig = { optimized: true } + ): S | null | undefined { + this.configSubject.next(config); + this.cdAware.next(potentialObservable); + return this.renderedValue; + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/modules/component/src/reactive-component.module.ts b/modules/component/src/reactive-component.module.ts new file mode 100644 index 0000000000..a7b1d1ac83 --- /dev/null +++ b/modules/component/src/reactive-component.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { LetDirective } from './let'; +import { PushPipe } from './push'; + +const DECLARATIONS = [LetDirective, PushPipe]; +const EXPORTS = [DECLARATIONS]; + +@NgModule({ + declarations: [DECLARATIONS], + exports: [EXPORTS], +}) +export class ReactiveComponentModule {} diff --git a/projects/ngrx.io/content/guide/component/let.md b/projects/ngrx.io/content/guide/component/let.md new file mode 100644 index 0000000000..579599027a --- /dev/null +++ b/projects/ngrx.io/content/guide/component/let.md @@ -0,0 +1,59 @@ +# 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. + +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 + +The `*ngrxLet` directive take over several things and makes it more convenient and save 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 observables: +- next value +- error value +- complete state + +```html + + + + + There is an error: {{e}} + + + Observable completed: {{c}} + + +``` + +Included Features: +- binding is always present. (`*ngIf="truthy$"`) +- 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), + diff --git a/projects/ngrx.io/content/guide/component/push.md b/projects/ngrx.io/content/guide/component/push.md new file mode 100644 index 0000000000..0d4477ad1b --- /dev/null +++ b/projects/ngrx.io/content/guide/component/push.md @@ -0,0 +1,33 @@ +### PushPipe + +The `ngrxPush` pipe serves as a drop-in replacement for the `async` pipe. +It contains intelligent handling of change detection to enable us +running in zone-full as well as zone-less mode without any changes to the code. + +The current way of binding an observable to the view looks like that: + +```html +{{observable$ | async}} +{{o}} + +``` + +The problem is `async` pipe just marks the component and all its ancestors as dirty. +It needs zone.js microtask queue to exhaust until `ApplicationRef.tick` is called to render all dirty marked components. + +Heavy dynamic and interactive UIs suffer from zones change detection a lot and can +lean to bad performance or even unusable applications, but the `async` pipe does not work in zone-less mode. + +`ngrxPush` pipe solves that problem. It can be used like shown here: +```htmlmixed +{{observable$ | ngrxPush}} +{{o}} + +``` + +Included Features: + - Take observables or promises, 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`) + - Distinct same values in a row to increase performance + - Coalescing of change detection calls to boost performance