From 56dd4e0b2501f3991f9f37310649b3a5cf4e088e Mon Sep 17 00:00:00 2001 From: Julian Jandl Date: Fri, 5 Jan 2024 18:52:41 +0100 Subject: [PATCH 1/3] fix(template): use correct CdRef instance in RxPush --- libs/template/push/src/lib/push.pipe.ts | 4 +- .../push/src/lib/tests/push.pipe.spec.ts | 417 ++++++++++-------- 2 files changed, 233 insertions(+), 188 deletions(-) diff --git a/libs/template/push/src/lib/push.pipe.ts b/libs/template/push/src/lib/push.pipe.ts index fe729b97b0..505f1ee86d 100644 --- a/libs/template/push/src/lib/push.pipe.ts +++ b/libs/template/push/src/lib/push.pipe.ts @@ -91,8 +91,6 @@ export class RxPush implements PipeTransform, OnDestroy { /** @internal */ private strategyProvider = inject(RxStrategyProvider); /** @internal */ - private cdRef = inject(ChangeDetectorRef); - /** @internal */ private ngZone = inject(NgZone); /** * @internal @@ -119,6 +117,8 @@ export class RxPush implements PipeTransform, OnDestroy { /** @internal */ private _renderCallback: NextObserver; + constructor(private cdRef: ChangeDetectorRef) {} + transform( potentialObservable: null, config?: RxStrategyNames | Observable, diff --git a/libs/template/push/src/lib/tests/push.pipe.spec.ts b/libs/template/push/src/lib/tests/push.pipe.spec.ts index 49dc76f304..fe1190a81a 100644 --- a/libs/template/push/src/lib/tests/push.pipe.spec.ts +++ b/libs/template/push/src/lib/tests/push.pipe.spec.ts @@ -1,17 +1,23 @@ -import { ChangeDetectorRef, Component, computed, signal } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + computed, + Input, + signal, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { RX_RENDER_STRATEGIES_CONFIG, RxStrategyProvider, } from '@rx-angular/cdk/render-strategies'; import { Promise as unpatchedPromise } from '@rx-angular/cdk/zone-less/browser'; -import { mockConsole } from '@test-helpers/rx-angular'; import { EMPTY, NEVER, Observable, asapScheduler, of, timer } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { RxPush } from '../push.pipe'; function wrapWithSpace(str: string): string { - return ' ' + str + ' '; + return '' + str + ''; } @Component({ @@ -22,231 +28,270 @@ class PushPipeTestComponent { strategy?: string; } -let fixturePushPipeTestComponent: ComponentFixture; -let pushPipeTestComponent: { - value$: Observable | unknown | undefined | null; - strategy?: string; -}; -let componentNativeElement: HTMLElement; -let strategyProvider: RxStrategyProvider; -let pushPipe: RxPush; - -const setupPushPipeComponent = () => { - TestBed.configureTestingModule({ - declarations: [PushPipeTestComponent], - imports: [RxPush], - providers: [ - RxPush, - ChangeDetectorRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - customStrategies: { - custom: { - name: 'custom', - work: (cdRef) => { - cdRef.detectChanges(); +@Component({ + selector: 'rx-child', + template: `{{ value }}`, +}) +class ChildComponent { + @Input() value: string; +} + +describe('RxPush', () => { + let fixturePushPipeTestComponent: ComponentFixture; + let pushPipeTestComponent: { + value$: Observable | unknown | undefined | null; + strategy?: string; + }; + let componentNativeElement: HTMLElement; + let strategyProvider: RxStrategyProvider; + + const setupPushPipeComponent = ( + template = `{{ (value$ | push : strategy | json) || 'undefined' }}` + ) => { + TestBed.configureTestingModule({ + declarations: [PushPipeTestComponent, ChildComponent], + imports: [RxPush], + providers: [ + RxPush, + ChangeDetectorRef, + { + provide: RX_RENDER_STRATEGIES_CONFIG, + useValue: { + primaryStrategy: 'native', + customStrategies: { + custom: { + name: 'custom', + work: (cdRef) => { + cdRef.detectChanges(); + }, + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), }, }, }, - }, - ], - }); + ], + }); - fixturePushPipeTestComponent = TestBed.createComponent(PushPipeTestComponent); - pushPipeTestComponent = fixturePushPipeTestComponent.componentInstance; - componentNativeElement = fixturePushPipeTestComponent.nativeElement; - strategyProvider = TestBed.inject(RxStrategyProvider); - pushPipe = TestBed.inject(RxPush); -}; + fixturePushPipeTestComponent = TestBed.overrideTemplate( + PushPipeTestComponent, + template + ).createComponent(PushPipeTestComponent); + pushPipeTestComponent = fixturePushPipeTestComponent.componentInstance; + componentNativeElement = fixturePushPipeTestComponent.nativeElement; + strategyProvider = TestBed.inject(RxStrategyProvider); + }; -describe('RxPush', () => { - beforeAll(() => mockConsole()); - beforeEach(setupPushPipeComponent); + describe('used in template', () => { + beforeEach(setupPushPipeComponent); - it('should be instantiable', () => { - expect(pushPipe).toBeDefined(); - }); + it('should be instantiable', () => { + expect(TestBed.inject(RxPush)).toBeDefined(); + }); - describe('transform function', () => { - it('should not track signal reads in subscriptions', () => { - const trigger = signal(false); + it('should be instantiable', () => { + expect(fixturePushPipeTestComponent).toBeDefined(); + expect(pushPipeTestComponent).toBeDefined(); + expect(componentNativeElement).toBeDefined(); + }); - const obs = new Observable(() => { - // Whenever `obs` is subscribed, synchronously read `trigger`. - trigger(); - }); + 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') + ); + }); - let trackCount = 0; - const tracker = computed(() => { - // Subscribe to `obs` within this `computed`. If the subscription side effect runs - // within the computed, then changes to `trigger` will invalidate this computed. - pushPipe.transform(obs); + 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')); + }); - // The computed returns how many times it's run. - return ++trackCount; - }); + it('should return 42 as value when initially 42 was passed (as static value)', () => { + pushPipeTestComponent.value$ = 42; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); + }); - expect(tracker()).toBe(1); - trigger.set(true); - expect(tracker()).toBe(1); + 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') + ); }); - }); -}); -describe('RxPush used as pipe in the template', () => { - beforeAll(() => mockConsole()); + 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')); + }); - beforeEach(setupPushPipeComponent); + 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 be instantiable', () => { - expect(fixturePushPipeTestComponent).toBeDefined(); - expect(pushPipeTestComponent).toBeDefined(); - expect(componentNativeElement).toBeDefined(); - }); + 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 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 emitted value from passed observable without changing it', () => { + pushPipeTestComponent.value$ = of(42); + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); + }); - 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 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$ = of(43); + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('43')); + }); - it('should return 42 as value when initially 42 was passed (as static value)', () => { - pushPipeTestComponent.value$ = 42; - fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); - }); + describe('async values', () => { + let cdSpy: jest.SpyInstance; - 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')); - }); + beforeEach(() => { + const strategy = strategyProvider.strategies['custom']; + pushPipeTestComponent.strategy = 'custom'; + cdSpy = jest.spyOn(strategy, 'work'); + }); - 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 not detect changes with sync value', () => { + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); + expect(cdSpy).toHaveBeenCalledTimes(0); + }); - 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 detect changes with async value', async () => { + const value$ = new Observable((sub) => { + Promise.resolve().then(() => { + sub.next(44); + sub.complete(); + }); + return () => { + sub.complete(); + }; + }); + pushPipeTestComponent.value$ = value$; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + await Promise.resolve(); + expect(cdSpy).toBeCalledTimes(1); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); + }); - 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 detect changes with unpatched Promise', async () => { + const value$ = new Observable((sub) => { + unpatchedPromise.resolve().then(() => { + sub.next(44); + sub.complete(); + }); + return () => { + sub.complete(); + }; + }); + pushPipeTestComponent.value$ = value$; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + await unpatchedPromise.resolve(); + await fixturePushPipeTestComponent.whenStable(); + expect(cdSpy).toBeCalledTimes(1); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); + }); - it('should emitted value from passed observable without changing it', () => { - pushPipeTestComponent.value$ = of(42); - fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); - }); + it('should detect changes with asapScheduler', async () => { + const value$ = timer(0, asapScheduler).pipe(map(() => 44)); + pushPipeTestComponent.value$ = value$; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + await Promise.resolve(); + expect(cdSpy).toBeCalledTimes(1); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); + }); - 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$ = of(43); - fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('43')); - }); + it('should detect changes with macrotask', async () => { + const value$ = timer(0).pipe(map(() => 44)); + pushPipeTestComponent.value$ = value$; + fixturePushPipeTestComponent.detectChanges(); + expect(componentNativeElement.textContent).toBe( + wrapWithSpace('undefined') + ); + await new Promise((resolve) => { + setTimeout(resolve); + }); + expect(cdSpy).toBeCalledTimes(1); + expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); + }); + }); - describe('async values', () => { - let cdSpy: jest.SpyInstance; + describe('transform function', () => { + it('should not track signal reads in subscriptions', () => { + const trigger = signal(false); - beforeEach(() => { - const strategy = strategyProvider.strategies['custom']; - pushPipeTestComponent.strategy = 'custom'; - cdSpy = jest.spyOn(strategy, 'work'); - }); + const obs = new Observable(() => { + // Whenever `obs` is subscribed, synchronously read `trigger`. + trigger(); + }); - it('should not detect changes with sync value', () => { - fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('42')); - expect(cdSpy).toHaveBeenCalledTimes(0); - }); + let trackCount = 0; + const pushPipe = TestBed.inject(RxPush); + const tracker = computed(() => { + // Subscribe to `obs` within this `computed`. If the subscription side effect runs + // within the computed, then changes to `trigger` will invalidate this computed. + pushPipe.transform(obs); - it('should detect changes with async value', async () => { - const value$ = new Observable((sub) => { - Promise.resolve().then(() => { - sub.next(44); - sub.complete(); + // The computed returns how many times it's run. + return ++trackCount; }); - return () => { - sub.complete(); - }; + + expect(tracker()).toBe(1); + trigger.set(true); + expect(tracker()).toBe(1); }); - pushPipeTestComponent.value$ = value$; - fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe( - wrapWithSpace('undefined') - ); - await Promise.resolve(); - expect(cdSpy).toBeCalledTimes(1); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); }); + }); - it('should detect changes with unpatched Promise', async () => { - const value$ = new Observable((sub) => { - unpatchedPromise.resolve().then(() => { - sub.next(44); - sub.complete(); - }); - return () => { - sub.complete(); - }; - }); - pushPipeTestComponent.value$ = value$; - fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe( - wrapWithSpace('undefined') + describe('used as input', () => { + beforeEach(() => { + setupPushPipeComponent( + `` ); - await unpatchedPromise.resolve(); - expect(cdSpy).toBeCalledTimes(1); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); }); - it('should detect changes with asapScheduler', async () => { - const value$ = timer(0, asapScheduler).pipe(map(() => 44)); - pushPipeTestComponent.value$ = value$; - fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe( - wrapWithSpace('undefined') + it('should pass values to child component', async () => { + const child = fixturePushPipeTestComponent.debugElement.query( + By.directive(ChildComponent) ); - await Promise.resolve(); - expect(cdSpy).toBeCalledTimes(1); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); - }); - it('should detect changes with macrotask', async () => { - const value$ = timer(0).pipe(map(() => 44)); - pushPipeTestComponent.value$ = value$; + pushPipeTestComponent.value$ = timer(0).pipe(map(() => 44)); fixturePushPipeTestComponent.detectChanges(); - expect(componentNativeElement.textContent).toBe( - wrapWithSpace('undefined') - ); + await new Promise((resolve) => { setTimeout(resolve); }); - expect(cdSpy).toBeCalledTimes(1); - expect(componentNativeElement.textContent).toBe(wrapWithSpace('44')); + + expect(child.nativeElement.textContent).toBe('44'); }); }); }); From fb5963acf9bd0a29cd84aa19f941eaf63e250498 Mon Sep 17 00:00:00 2001 From: Julian Jandl Date: Fri, 5 Jan 2024 18:53:02 +0100 Subject: [PATCH 2/3] chore: adjust release branch --- libs/template/project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/template/project.json b/libs/template/project.json index 5dedb894b7..c459adb84a 100644 --- a/libs/template/project.json +++ b/libs/template/project.json @@ -74,7 +74,7 @@ "executor": "@jscutlery/semver:version", "options": { "noVerify": true, - "baseBranch": "main", + "baseBranch": "backport-push-pipe-cd-fix", "tagPrefix": "{projectName}@", "commitMessageFormat": "release({projectName}): {version}", "postTargets": ["template:github"], From 1e40c293ae9c197a3bc976ed36791e5aafe38c7b Mon Sep 17 00:00:00 2001 From: Julian Jandl Date: Fri, 5 Jan 2024 18:55:03 +0100 Subject: [PATCH 3/3] release(template): 16.1.2 --- libs/template/CHANGELOG.md | 9 +++++++++ libs/template/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/template/CHANGELOG.md b/libs/template/CHANGELOG.md index 46c5ecd6bc..fc5f146fc8 100644 --- a/libs/template/CHANGELOG.md +++ b/libs/template/CHANGELOG.md @@ -2,6 +2,15 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [16.1.2](https://github.com/rx-angular/rx-angular/compare/template@16.1.1...template@16.1.2) (2024-01-05) + + +### Bug Fixes + +* **template:** use correct CdRef instance in RxPush ([56dd4e0](https://github.com/rx-angular/rx-angular/commit/56dd4e0b2501f3991f9f37310649b3a5cf4e088e)) + + + ## [16.1.1](https://github.com/rx-angular/rx-angular/compare/template@16.1.0...template@16.1.1) (2023-10-20) diff --git a/libs/template/package.json b/libs/template/package.json index 7c27b78a14..57b3c1b484 100644 --- a/libs/template/package.json +++ b/libs/template/package.json @@ -1,6 +1,6 @@ { "name": "@rx-angular/template", - "version": "16.1.1", + "version": "16.1.2", "description": "**Fully** Reactive Component Template Rendering in Angular. @rx-angular/template aims to be a reflection of Angular's built in renderings just reactive.", "publishConfig": { "access": "public"