diff --git a/apps/demos/project.json b/apps/demos/project.json index b7ac136948..1a529ee04b 100644 --- a/apps/demos/project.json +++ b/apps/demos/project.json @@ -11,10 +11,10 @@ "prefix": "rxa", "targets": { "build": { - "executor": "@angular-devkit/build-angular:browser", + "executor": "@angular-devkit/build-angular:browser-esbuild", + "defaultConfiguration": "production", "options": { - "outputPath": "dist/apps/docs/demos", - "baseHref": "/rx-angular/demos/", + "outputPath": "dist/apps/demos", "index": "apps/demos/src/index.html", "main": "apps/demos/src/main.ts", "polyfills": "apps/demos/src/polyfills.ts", @@ -83,12 +83,21 @@ } ], "polyfills": "" + }, + "development": { + "sourceMap": true, + "optimization": false, + "namedChunks": true, + "vendorChunk": true, + "extractLicenses": false, + "buildOptimizer": false } }, "outputs": ["{options.outputPath}"] }, "serve": { "executor": "@angular-devkit/build-angular:dev-server", + "defaultConfiguration": "development", "options": { "port": 4300, "buildTarget": "demos:build" @@ -102,6 +111,10 @@ }, "zoneless": { "buildTarget": "demos:build:zoneless" + }, + "development": { + "buildTarget": "demos:build:development", + "open": true } } }, diff --git a/apps/demos/src/app/app-component/app-control-panel/app-control-panel.component.ts b/apps/demos/src/app/app-component/app-control-panel/app-control-panel.component.ts deleted file mode 100644 index 3621612586..0000000000 --- a/apps/demos/src/app/app-component/app-control-panel/app-control-panel.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, ElementRef -} from '@angular/core'; -import { AppConfigService } from '../../app-config.service'; -import { BehaviorSubject, EMPTY } from 'rxjs'; -import { RippleRenderer } from '../../shared/ripple/rxa-responsive-meter'; -import { Platform } from '@angular/cdk/platform'; -import { filter, switchMap } from 'rxjs/operators'; - import { interval } from '../../rx-angular-pocs'; - -@Component({ - selector: 'rxa-config-panel', - template: ` -
- - - snooze - {{ appConfig.zoneEnv }} - - build_circle - {{ appConfig.devMode ? 'Development' : 'Production' }} - - CD - - non-blocking - - -
- `, - styles: [ - ` - rxa-strategy-select { - font-size: 14px; - margin-top: 18px; - } - `, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AppControlPanelComponent implements AfterViewInit { - toggleCdRipple$ = new BehaviorSubject(false); - toggleResponsiveRipple$ = new BehaviorSubject(false); - rippleOn$ = this.appConfig.select('rippleOn'); - rp: RippleRenderer; - - constructor( - public appConfig: AppConfigService, - private readonly elementRef: ElementRef, - private readonly platform: Platform - ) { - this.appConfig.connect('rippleOn', this.toggleCdRipple$); - this.appConfig.connect('rippleResponsiveOn', this.toggleResponsiveRipple$); - this.appConfig.hold( - this.appConfig.select('rippleResponsiveOn').pipe( - switchMap((isOn) => (isOn ? interval(300) : EMPTY)), - filter(() => !!this.rp) - ), - (v) => { - console.log('v', v); - this.rp.fadeInRipple(0, 0) - } - ); - } - - ngAfterViewInit(): void { - this.setupRipple(); - } - - tick() { - this.appConfig.appRef_tick(); - } - - setupRipple() { - this.rp = new RippleRenderer(this.elementRef, this.platform); - } -} diff --git a/apps/demos/src/app/app-component/app-control-panel/app-control-panel.module.ts b/apps/demos/src/app/app-component/app-control-panel/app-control-panel.module.ts deleted file mode 100644 index a27ea93050..0000000000 --- a/apps/demos/src/app/app-component/app-control-panel/app-control-panel.module.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { AppControlPanelComponent } from './app-control-panel.component'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatIconModule } from '@angular/material/icon'; -import { ReactiveFormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatButtonModule } from '@angular/material/button'; -import { MatListModule } from '@angular/material/list'; -import { RxLet } from '@rx-angular/template/let'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { StrategySelectModule } from '../../shared/debug-helper/strategy-select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; - -@NgModule({ - declarations: [AppControlPanelComponent], - imports: [ - CommonModule, - MatExpansionModule, - MatChipsModule, - MatIconModule, - ReactiveFormsModule, - MatFormFieldModule, - MatSelectModule, - MatButtonModule, - RxLet, - MatListModule, - MatCheckboxModule, - StrategySelectModule, - MatSlideToggleModule, - ], - exports: [AppControlPanelComponent], -}) -export class AppControlPanelModule {} diff --git a/apps/demos/src/app/app-component/app-control-panel/index.ts b/apps/demos/src/app/app-component/app-control-panel/index.ts deleted file mode 100644 index 37ef521d65..0000000000 --- a/apps/demos/src/app/app-component/app-control-panel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {AppControlPanelComponent} from './app-control-panel.component' -export {AppControlPanelModule} from './app-control-panel.module' diff --git a/apps/demos/src/app/features/concepts/coalescing/strategies/index.ts b/apps/demos/src/app/features/concepts/coalescing/strategies/index.ts deleted file mode 100644 index dd36e1a655..0000000000 --- a/apps/demos/src/app/features/concepts/coalescing/strategies/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {getTestStrategyCredentialsMap} from './strategy-map'; diff --git a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-let-poc.component.ts b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-let-poc.component.ts index 09aee41217..4c3c86142b 100644 --- a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-let-poc.component.ts +++ b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-let-poc.component.ts @@ -76,6 +76,7 @@ import { RxStrategyProvider } from '@rx-angular/cdk/render-strategies'; `, + standalone: false, changeDetection: ChangeDetectionStrategy.Default, host: { class: 'm-1 p-1', diff --git a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-children.component.ts b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-children.component.ts index 323da5bb2c..72d0937d06 100644 --- a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-children.component.ts +++ b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-children.component.ts @@ -1,4 +1,9 @@ -import { ChangeDetectionStrategy, Component, ElementRef, ViewChild } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + ViewChild, +} from '@angular/core'; import { Subject } from 'rxjs'; import { distinctUntilChanged, map, shareReplay, tap } from 'rxjs/operators'; import { RxStrategyProvider } from '@rx-angular/cdk/render-strategies'; @@ -8,21 +13,28 @@ import { RxStrategyProvider } from '@rx-angular/cdk/render-strategies'; template: `
- + - +
-
{{ v }}
+
+ {{ v }} +
-
@@ -32,27 +44,24 @@ import { RxStrategyProvider } from '@rx-angular/cdk/render-strategies'; .view-child { height: 250px; } - ` + `, ], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class RxQueryChildrenComponent { - @ViewChild('viewChild') viewChild: ElementRef; - updateValue = new Subject(); + updateValue = new Subject(); viewChildState$ = this.updateValue.pipe( map(() => this.i++), distinctUntilChanged(), // the query child is undefined here because the parent never detects changes tap(() => setTimeout(() => console.log(this.viewChild), 200)), - shareReplay({ bufferSize: 1, refCount: true }) + shareReplay({ bufferSize: 1, refCount: true }), ); private i = 0; - constructor( - public strategyProvider: RxStrategyProvider, - ) { } - + constructor(public strategyProvider: RxStrategyProvider) {} } diff --git a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-content.component.ts b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-content.component.ts index 840658eb94..d4bbdadaaf 100644 --- a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-content.component.ts +++ b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query-content.component.ts @@ -1,39 +1,43 @@ -import { ChangeDetectionStrategy, Component, ContentChild, Directive, Input, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + Input, + OnInit, +} from '@angular/core'; import { Observable } from 'rxjs'; import { RxStrategyProvider } from '@rx-angular/cdk/render-strategies'; import { RxEffects } from '@rx-angular/state/effects'; @Directive({ - selector: '[rxaContentTest]' + selector: '[rxaContentTest]', + standalone: false, }) -export class RxQueryContentTestDirective { -} +export class RxQueryContentTestDirective {} @Component({ selector: 'rxa-rx-query-content', - template: ` - - `, + template: ` `, styles: [], changeDetection: ChangeDetectionStrategy.OnPush, - providers: [RxEffects] + providers: [RxEffects], + standalone: false, }) export class RxQueryContentComponent implements OnInit { - - @ContentChild(RxQueryContentTestDirective) contentChild: RxQueryContentTestDirective; + @ContentChild(RxQueryContentTestDirective) + contentChild: RxQueryContentTestDirective; @Input() value: Observable; constructor( public strategyProvider: RxStrategyProvider, - private effects: RxEffects - ) { - } + private effects: RxEffects, + ) {} ngOnInit() { this.effects.register(this.value, () => { setTimeout(() => console.log(this.contentChild), 250); }); } - } diff --git a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query.component.ts b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query.component.ts index ea1da95e40..5a99f545f2 100644 --- a/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query.component.ts +++ b/apps/demos/src/app/features/experiments/structural-directives/rx-let-poc/rx-query.component.ts @@ -103,6 +103,7 @@ import { delay, filter, map, mapTo, share } from 'rxjs/operators'; style: 'display: block;', }, providers: [], + standalone: false, }) export class RxQueryComponent { search$ = new Subject(); @@ -110,7 +111,7 @@ export class RxQueryComponent { 'character', this.search$, (search: string) => - this.service.getCharacter({ name: search }).pipe(delay(200)) + this.service.getCharacter({ name: search }).pipe(delay(200)), ); loadingMap = { @@ -119,23 +120,23 @@ export class RxQueryComponent { }; status$ = this.charactersQueryResult$.pipe( map((v) => v.status), - share() + share(), ); suspenseTrg$ = this.status$.pipe( map((s) => this.loadingMap[s]), - filter((v) => !!v) + filter((v) => !!v), ); characters$ = this.charactersQueryResult$.pipe( - map((res) => res?.data?.results) + map((res) => res?.data?.results), ); errorTrg$ = this.charactersQueryResult$.pipe( filter((res) => res?.status === 'error'), - mapTo(true) + mapTo(true), ); constructor( public strategyProvider: RxStrategyProvider, - public service: RickAndMortyService + public service: RickAndMortyService, ) {} } diff --git a/apps/demos/src/app/features/template/rx-for/list-actions/list-action-item.component.ts b/apps/demos/src/app/features/template/rx-for/list-actions/list-action-item.component.ts new file mode 100644 index 0000000000..ffa9532f9e --- /dev/null +++ b/apps/demos/src/app/features/template/rx-for/list-actions/list-action-item.component.ts @@ -0,0 +1,18 @@ +import { Component, OnDestroy } from '@angular/core'; + +@Component({ + selector: 'list-action-item', + template: ` `, + styles: [ + ` + :host { + display: contents; + } + `, + ], +}) +export class ListActionItemComponent implements OnDestroy { + ngOnDestroy() { + // console.log('onDestroy'); + } +} diff --git a/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.component.ts b/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.component.ts index 8735576cdc..2594e00186 100644 --- a/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.component.ts +++ b/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.component.ts @@ -1,24 +1,36 @@ import { AfterViewInit, + ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, NgZone, QueryList, + signal, ViewChild, ViewChildren, ViewEncapsulation, } from '@angular/core'; -import { asyncScheduler } from 'rxjs-zone-less'; +import { coalesceWith } from '@rx-angular/cdk/coalescing'; +import { RxState } from '@rx-angular/state'; import { + animationFrameScheduler, BehaviorSubject, + combineLatest, defer, merge, - Observable, scheduled, Subject, } from 'rxjs'; -import { environment } from '../../../../../environments/environment'; +import { asyncScheduler } from 'rxjs-zone-less'; +import { + delay, + map, + shareReplay, + switchMap, + switchMapTo, +} from 'rxjs/operators'; +import { Hooks } from '../../../../shared/debug-helper/hooks'; import { ArrayProviderService, removeItemsImmutable, @@ -26,9 +38,6 @@ import { TestItem, } from '../../../../shared/debug-helper/value-provider'; import { ArrayProviderComponent } from '../../../../shared/debug-helper/value-provider/array-provider/array-provider.component'; -import { RxState } from '@rx-angular/state'; -import { Hooks } from '../../../../shared/debug-helper/hooks'; -import { map, switchMap, switchMapTo } from 'rxjs/operators'; let itemIdx = 0; @@ -149,96 +158,175 @@ const moveChangeSet1 = [items5k]; selector: 'rxa-rx-for-list-actions', template: ` -
-
-

Reactive Iterable Differ

- - - - Tile - - List - - - - - -

- Rendered {{ rendered }} -

-

- - VIEW BROKEN {{ viewBroken }} - -

+ +
+
+

Reactive Iterable Differ

+ + + + Tile + + List + + + + experimental + + legacy + + + + + +
-
+
+
+
+ +
+

+ Rendered {{ rendered }} +

+ +
+
+
-
-
- -
- - - id: {{ a.id }} - value: {{ a.value }} - index: {{ index }} - count: {{ count }} - even: {{ even }} - odd: {{ odd }} - first: {{ first }} - last: {{ last }} -
-
+ @if (reconciler() === 'experimental') { + +
+ +
+ +
+ + + id: {{ a.id }} + value: {{ a.value }} + index: {{ index }} + count: {{ count }} + even: {{ even }} + odd: {{ odd }} + first: {{ first }} + last: {{ last }} +
+
+
+
+ } @else { + +
+ +
+ +
+ + + id: {{ a.id }} + value: {{ a.value }} + index: {{ index }} + count: {{ count }} + even: {{ even }} + odd: {{ odd }} + first: {{ first }} + last: {{ last }} +
+
+
+
+ }
`, - changeDetection: environment.changeDetection, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, providers: [ArrayProviderService], styles: [ @@ -291,18 +379,24 @@ const moveChangeSet1 = [items5k]; .work-child .child-bg.even { background-color: red; } + .work-child.broken { + outline: 3px solid red; + } `, ], standalone: false, }) export class ListActionsComponent extends Hooks implements AfterViewInit { - @ViewChild('arrayP', { read: ArrayProviderComponent, static: true }) arrayP; + @ViewChild('arrayP', { read: ArrayProviderComponent, static: true }) + arrayP: ArrayProviderComponent; @ViewChildren('workChild') workChildren: QueryList>; private numRendered = 0; readonly view = new BehaviorSubject<'list' | 'tile'>('list'); + readonly reconciler = signal<'experimental' | 'legacy'>('experimental'); + readonly filter$ = new BehaviorSubject(''); readonly triggerChangeSet = new Subject(); readonly activeChangeSet$ = this.triggerChangeSet.pipe( switchMapTo(scheduled(customChangeSet, asyncScheduler)), @@ -317,11 +411,21 @@ export class ListActionsComponent extends Hooks implements AfterViewInit { ); readonly data$ = defer(() => - merge(this.arrayP.array$, this.activeChangeSet$, this.activeMoveSet$), - ); + combineLatest([ + merge(this.arrayP.array$, this.activeChangeSet$, this.activeMoveSet$), + this.filter$, + ]).pipe( + map(([items, search]) => { + return items.filter((item) => + (item.value * 100).toString().startsWith(search), + ); + }), + ), + ).pipe(shareReplay(1)); readonly renderCallback = new Subject(); readonly rendered$ = this.renderCallback.pipe(map(() => ++this.numRendered)); readonly viewBroken$ = this.renderCallback.pipe( + coalesceWith(scheduled([], asyncScheduler), (this.cdRef as any).context), map(() => { const children = Array.from( document.getElementsByClassName('work-child'), @@ -335,6 +439,7 @@ export class ListActionsComponent extends Hooks implements AfterViewInit { (!even && child.classList.contains('even')) ) { broken = true; + child.classList.add('broken'); break; } i++; diff --git a/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.module.ts b/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.module.ts index 0a7772e53f..c6ac49b4bc 100644 --- a/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.module.ts +++ b/apps/demos/src/app/features/template/rx-for/list-actions/list-actions.module.ts @@ -15,6 +15,11 @@ import { DirtyChecksModule } from '../../../../shared/debug-helper/dirty-checks' import { ValueProvidersModule } from '../../../../shared/debug-helper/value-provider/value-providers.module'; import { VisualizerModule } from '../../../../shared/debug-helper/visualizer/visualizer.module'; import { RecursiveModule } from '../../../../shared/template-structures/recursive/recursive.module'; +import { + LegacyReconciliationProvider, + NewReconciliationProvider, +} from '../reconciliation-provider-directives'; +import { ListActionItemComponent } from './list-action-item.component'; import { ListActionsComponent } from './list-actions.component'; import { ROUTES } from './list-actions.routes'; import { RxIf } from '@rx-angular/template/if'; @@ -43,6 +48,9 @@ const DECLARATIONS = [ListActionsComponent]; RxLet, RxFor, RxIf, + NewReconciliationProvider, + LegacyReconciliationProvider, + ListActionItemComponent, ], exports: [DECLARATIONS], }) diff --git a/apps/demos/src/app/features/template/rx-for/reconciliation-provider-directives.ts b/apps/demos/src/app/features/template/rx-for/reconciliation-provider-directives.ts new file mode 100644 index 0000000000..5971968020 --- /dev/null +++ b/apps/demos/src/app/features/template/rx-for/reconciliation-provider-directives.ts @@ -0,0 +1,17 @@ +import { Directive } from '@angular/core'; +import { + provideExperimentalRxForReconciliation, + provideLegacyRxForReconciliation, +} from '@rx-angular/template/for'; + +@Directive({ + selector: '[provideLegacyReconciliation]', + providers: [provideLegacyRxForReconciliation()], +}) +export class LegacyReconciliationProvider {} + +@Directive({ + selector: '[provideExperimentalReconciliation]', + providers: [provideExperimentalRxForReconciliation()], +}) +export class NewReconciliationProvider {} diff --git a/apps/demos/src/app/features/template/rx-let/ng-if-hack/ng-if-hack-rx-let.component.ts b/apps/demos/src/app/features/template/rx-let/ng-if-hack/ng-if-hack-rx-let.component.ts index 28d3940e39..d2bd683d39 100644 --- a/apps/demos/src/app/features/template/rx-let/ng-if-hack/ng-if-hack-rx-let.component.ts +++ b/apps/demos/src/app/features/template/rx-let/ng-if-hack/ng-if-hack-rx-let.component.ts @@ -19,7 +19,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; let value; suspense: suspenseView; error: errorView; - rxComplete: completeView + complete: completeView " > value: {{ value | json }}
diff --git a/apps/demos/src/app/features/template/rx-virtual-for/virtual-rendering/virtual-for-reverse-infinite-scroll.component.ts b/apps/demos/src/app/features/template/rx-virtual-for/virtual-rendering/virtual-for-reverse-infinite-scroll.component.ts index 2de8ac0f7e..64baef54a6 100644 --- a/apps/demos/src/app/features/template/rx-virtual-for/virtual-rendering/virtual-for-reverse-infinite-scroll.component.ts +++ b/apps/demos/src/app/features/template/rx-virtual-for/virtual-rendering/virtual-for-reverse-infinite-scroll.component.ts @@ -200,7 +200,6 @@ import { Message, MessageService } from './messages/messages.service'; DatePipe, MatButtonToggleModule, MatInputModule, - RxIf, RxLet, StrategySelectModule, FixedSizeVirtualScrollStrategy, diff --git a/apps/demos/src/app/features/tutorials/basics/5-side-effects/side-effects.solution.component.ts b/apps/demos/src/app/features/tutorials/basics/5-side-effects/side-effects.solution.component.ts index 10270d787a..cb8c91218a 100644 --- a/apps/demos/src/app/features/tutorials/basics/5-side-effects/side-effects.solution.component.ts +++ b/apps/demos/src/app/features/tutorials/basics/5-side-effects/side-effects.solution.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, Input, - OnDestroy, OnInit, Output, } from '@angular/core'; @@ -74,7 +73,7 @@ const initComponentState = { }) export class SideEffectsSolution extends RxState - implements OnInit, OnDestroy + implements OnInit { model$ = this.select(); diff --git a/apps/demos/src/app/rx-angular-pocs/cdk/utils/rxjs/operators/queueWith.ts b/apps/demos/src/app/rx-angular-pocs/cdk/utils/rxjs/operators/queueWith.ts deleted file mode 100644 index c88cbfbe68..0000000000 --- a/apps/demos/src/app/rx-angular-pocs/cdk/utils/rxjs/operators/queueWith.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { mergeMap } from 'rxjs/operators'; -import { Observable } from 'rxjs'; -import { TaskQueue } from '../scheduler/priority/task-queue'; -import { PrioritySchedulingOptions } from '../scheduler/priority'; - -export function queueWith(h: TaskQueue, p: PrioritySchedulingOptions

) { - return (o$: Observable<(...args: any[]) => void>) => o$.pipe( - mergeMap(w => new Observable(s => { - const id = h.queueTask(() => () => s.next(w()), p); - return () => { - h.dequeueTask(id); - } - })) - ); -} diff --git a/apps/demos/src/app/shared/debug-helper/cd-env/cd-env/cd-env.component.ts b/apps/demos/src/app/shared/debug-helper/cd-env/cd-env/cd-env.component.ts index 27ab8aae17..d36d806afb 100644 --- a/apps/demos/src/app/shared/debug-helper/cd-env/cd-env/cd-env.component.ts +++ b/apps/demos/src/app/shared/debug-helper/cd-env/cd-env/cd-env.component.ts @@ -4,24 +4,22 @@ import { CdHelper } from '../../../utils/cd-helper'; @Component({ selector: 'rxa-cd-env', - template: ` - - -

{{changeDetection}}

- - - - - - `, + template: ` + +

{{ changeDetection }}

+ + +
+ +
`, + standalone: false, host: { - class: 'd-block w-100' + class: 'd-block w-100', }, changeDetection: environment.changeDetection, - providers: [CdHelper] + providers: [CdHelper], }) export class CdEnvComponent { changeDetection = environment.changeDetection; - constructor(public cdHelper: CdHelper) { - } + constructor(public cdHelper: CdHelper) {} } diff --git a/apps/demos/src/app/shared/debug-helper/i-frame/i-frame.module.ts b/apps/demos/src/app/shared/debug-helper/i-frame/i-frame.module.ts deleted file mode 100644 index e1dc623117..0000000000 --- a/apps/demos/src/app/shared/debug-helper/i-frame/i-frame.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { IFrameComponent } from './i-frame/i-frame.component'; -import { RxLetModule } from '../../../features/experiments/structural-directives/rx-let/rx-let.module'; - - -@NgModule({ - declarations: [IFrameComponent], - imports: [ - CommonModule, - RxLetModule - ], - exports: [IFrameComponent] -}) -export class IFrameModule { -} diff --git a/apps/demos/src/app/shared/debug-helper/i-frame/i-frame/i-frame.component.ts b/apps/demos/src/app/shared/debug-helper/i-frame/i-frame/i-frame.component.ts deleted file mode 100644 index 16c510ae31..0000000000 --- a/apps/demos/src/app/shared/debug-helper/i-frame/i-frame/i-frame.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; -import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; - -@Component({ - selector: 'rxa-i-frame', - template: ` - `, - host: { - class: 'd-block w-100' - }, - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class IFrameComponent implements OnInit { - - @Input() - url; - - urlSafe: SafeResourceUrl; - - constructor(public sanitizer: DomSanitizer) { - } - - ngOnInit() { - this.urlSafe = this.sanitizer.bypassSecurityTrustResourceUrl(this.url); - } -} diff --git a/apps/demos/src/app/shared/debug-helper/ripple/ripple.component.ts b/apps/demos/src/app/shared/debug-helper/ripple/ripple.component.ts index 9e1866c203..60e640f94b 100644 --- a/apps/demos/src/app/shared/debug-helper/ripple/ripple.component.ts +++ b/apps/demos/src/app/shared/debug-helper/ripple/ripple.component.ts @@ -1,4 +1,11 @@ -import { ChangeDetectionStrategy, Component, ElementRef, Input, Renderer2, ViewChild } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + Renderer2, + ViewChild, +} from '@angular/core'; import { MatRipple } from '@angular/material/core'; import { Hooks } from '../hooks'; import { RxState } from '@rx-angular/state'; @@ -10,13 +17,18 @@ import { isObservable, Observable, of } from 'rxjs'; selector: 'rxa-ripple', changeDetection: ChangeDetectionStrategy.Default, template: ` - {{dirty()}} -
- + {{ dirty() }} +
+
`, - providers: [RxState] + providers: [RxState], + standalone: false, }) export class RippleComponent extends Hooks { @ViewChild(MatRipple) ripple: MatRipple; @@ -46,28 +58,32 @@ export class RippleComponent extends Hooks { private elementRef: ElementRef, private renderer: Renderer2, private configService: AppConfigService, - private state: RxState<{ value: any }> + private state: RxState<{ value: any }>, ) { super(); this.state.hold(this.afterViewInit$, (v) => { - console.log('hold: ', this.elementRef.nativeElement.children[0].children[0]); - this.displayElem = this.elementRef.nativeElement.children[0].children[0] + console.log( + 'hold: ', + this.elementRef.nativeElement.children[0].children[0], + ); + this.displayElem = this.elementRef.nativeElement.children[0].children[0]; }); this.state.hold( this.afterViewInit$.pipe(switchMap(() => this.state.select('value'))), - (v) => this.render(v) + (v) => this.render(v), ); } dirty() { - if(this.always) { - this.render('') + if (this.always) { + this.render(''); } } render(value: any) { this.rippleOn && this.ripple && this.ripple.launch(this.rippleEffect); - this.displayElem && this.renderer.setProperty(this.displayElem, 'innerHTML', value + ''); + this.displayElem && + this.renderer.setProperty(this.displayElem, 'innerHTML', value + ''); console.log(this.name, ' called'); } } diff --git a/apps/demos/src/app/shared/debug-helper/value-provider/array-provider.service.ts b/apps/demos/src/app/shared/debug-helper/value-provider/array-provider.service.ts index 2f3b5bef4f..59a071bf7a 100644 --- a/apps/demos/src/app/shared/debug-helper/value-provider/array-provider.service.ts +++ b/apps/demos/src/app/shared/debug-helper/value-provider/array-provider.service.ts @@ -43,6 +43,7 @@ export class ArrayProviderService extends RxState { protected moveItemsMutableSubject = new Subject(); protected updateItemsMutableSubject = new Subject(); protected removeItemsMutableSubject = new Subject(); + protected swapItemsSubject = new Subject(); private resetAll = () => { this.resetObservables(); @@ -106,6 +107,18 @@ export class ArrayProviderService extends RxState { addItemImmutable([], itemsToAdd ?? 0), ); + this.connect('array', this.swapItemsSubject, (state) => { + const data = state?.array || []; + if (data.length > 2) { + const first = data[0]; + const last = data[data.length - 1]; + data[0] = last; + data[data.length - 1] = first; + return [...data]; + } + return data; + }); + this.resetAll(); } @@ -127,6 +140,10 @@ export class ArrayProviderService extends RxState { }); } + swap() { + this.swapItemsSubject.next(); + } + shuffleItemsImmutable(): void { this.shuffleItemsImmutableSubject.next(); } diff --git a/apps/demos/src/app/shared/debug-helper/value-provider/array-provider/array-provider.component.ts b/apps/demos/src/app/shared/debug-helper/value-provider/array-provider/array-provider.component.ts index ac27db3c8f..ab49817f53 100644 --- a/apps/demos/src/app/shared/debug-helper/value-provider/array-provider/array-provider.component.ts +++ b/apps/demos/src/app/shared/debug-helper/value-provider/array-provider/array-provider.component.ts @@ -91,6 +91,9 @@ import { ArrayProviderService } from '../array-provider.service'; > Shuffle Attack + - `, + template: ` `, }) export class RenderCallbackComponent { - constructor(private strategyProvider: RxStrategyProvider) {} + private strategyProvider = inject(RxStrategyProvider); showTooltip() { this.strategyProvider.schedule( () => { // create tooltip }, - { strategy: 'immediate' } + { strategy: 'immediate' }, ); } @@ -60,7 +58,7 @@ export class RenderCallbackComponent { () => { // destroy tooltip }, - { strategy: 'immediate' } + { strategy: 'immediate' }, ); } } @@ -68,17 +66,12 @@ export class RenderCallbackComponent { ### Custom strategies +In case you want to create your own strategy, these are the types that you need to implement: + ```typescript -export type RxRenderWork = ( - cdRef: ChangeDetectorRef, - scope?: coalescingObj, - notification?: RxNotification -) => void; +export type RxRenderWork = (cdRef: ChangeDetectorRef, scope?: coalescingObj, notification?: RxNotification) => void; -export type RxRenderBehavior = ( - work: any, - scope?: coalescingObj -) => (o: Observable) => Observable; +export type RxRenderBehavior = (work: any, scope?: coalescingObj) => (o: Observable) => Observable; export interface RxStrategyCredentials { name: S; @@ -86,27 +79,15 @@ export interface RxStrategyCredentials { behavior: RxRenderBehavior; } -export type RxCustomStrategyCredentials = Record< - T, - RxStrategyCredentials ->; +export type RxCustomStrategyCredentials = Record; export type RxNativeStrategyNames = 'native' | 'local' | 'global' | 'noop'; -export type RxConcurrentStrategyNames = - | 'immediate' - | 'userBlocking' - | 'normal' - | 'low' - | 'idle'; +export type RxConcurrentStrategyNames = 'immediate' | 'userBlocking' | 'normal' | 'low' | 'idle'; -export type RxDefaultStrategyNames = - | RxNativeStrategyNames - | RxConcurrentStrategyNames; +export type RxDefaultStrategyNames = RxNativeStrategyNames | RxConcurrentStrategyNames; export type RxStrategyNames = RxDefaultStrategyNames | T; -export type RxStrategies = RxCustomStrategyCredentials< - RxStrategyNames ->; +export type RxStrategies = RxCustomStrategyCredentials>; export interface RxRenderStrategiesConfig { primaryStrategy?: RxStrategyNames; diff --git a/apps/docs/docs/eslint-plugin/_category_.json b/apps/docs/docs/eslint-plugin/_category_.json index 098a426f26..2456687aea 100644 --- a/apps/docs/docs/eslint-plugin/_category_.json +++ b/apps/docs/docs/eslint-plugin/_category_.json @@ -1,3 +1,3 @@ { - "label": "@rx-angular/eslint-plugin" + "label": "ESLint plugin" } diff --git a/apps/docs/docs/isr/_category_.json b/apps/docs/docs/isr/_category_.json index 74aaeb628b..e0f119a2f4 100644 --- a/apps/docs/docs/isr/_category_.json +++ b/apps/docs/docs/isr/_category_.json @@ -1,3 +1,3 @@ { - "label": "@rx-angular/isr" + "label": "ISR" } diff --git a/apps/docs/docs/state/_category_.json b/apps/docs/docs/state/_category_.json index 979a643d40..1a15284ea5 100644 --- a/apps/docs/docs/state/_category_.json +++ b/apps/docs/docs/state/_category_.json @@ -1,3 +1,3 @@ { - "label": "@rx-angular/state" + "label": "State" } diff --git a/apps/docs/docs/template/_category_.json b/apps/docs/docs/template/_category_.json index 7fc19f8936..1680661c3d 100644 --- a/apps/docs/docs/template/_category_.json +++ b/apps/docs/docs/template/_category_.json @@ -1,3 +1,3 @@ { - "label": "@rx-angular/template" + "label": "Template" } diff --git a/apps/docs/docs/template/api/_category_.json b/apps/docs/docs/template/api/_category_.json deleted file mode 100644 index 0c5a157a24..0000000000 --- a/apps/docs/docs/template/api/_category_.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "label": "API", - "position": 100, - "link": { - "type": "generated-index", - "title": "API reference", - "slug": "/template/api" - } -} diff --git a/apps/docs/docs/template/api/viewport-prio-directive.md b/apps/docs/docs/template/api/viewport-prio-directive.md deleted file mode 100644 index 3f3bfc061c..0000000000 --- a/apps/docs/docs/template/api/viewport-prio-directive.md +++ /dev/null @@ -1,5 +0,0 @@ -# 🧪 ViewportPrioDirective - -This directive limits renderings to only visible components. Should be used together with Noop strategy. - -_more info coming soon_ diff --git a/apps/docs/docs/template/performance-issues/handling-view-and-content-queries.md b/apps/docs/docs/template/performance-issues/handling-view-and-content-queries.md index 4f39f82711..c404a88f13 100644 --- a/apps/docs/docs/template/performance-issues/handling-view-and-content-queries.md +++ b/apps/docs/docs/template/performance-issues/handling-view-and-content-queries.md @@ -15,20 +15,14 @@ You can do so by providing a custom `RxRenderStrategiesConfig`, see the followin ```typescript // import -import { RxRenderStrategiesConfig, RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; - -// create configuration with parent flag to be false -const rxaConfig: RxRenderStrategiesConfig = { - parent: false, -}; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; // provide it, in best case on root level { providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: rxaConfig, - }, + provideRxRenderStrategies({ + parent: false, + }), ]; } ``` @@ -129,18 +123,17 @@ Take a look at the following example: export class AppListComponent {} ``` -## RX_RENDER_STRATEGIES_CONFIG +## provideRxRenderStrategies -You can also set the `parent` config globally by providing a `RX_RENDER_STRATEGIES_CONFIG`. +You can also set the `parent` config globally by using `provideRxRenderStrategies` function. See more about configuration under [render strategies](../../cdk/render-strategies) especially the section [usage-in-the-template](../../cdk/render-strategies#usage-in-the-template) ```ts @NgModule({ - providers: [{ - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { + providers: [ + provideRxRenderStrategies({ parent: false // this applies to all RxLets - } - }] + }), + ] }) ``` diff --git a/apps/docs/docs/template/performance-issues/ngzone-optimizations.md b/apps/docs/docs/template/performance-issues/ngzone-optimizations.md index 061faea2bb..42b3c3a9e0 100644 --- a/apps/docs/docs/template/performance-issues/ngzone-optimizations.md +++ b/apps/docs/docs/template/performance-issues/ngzone-optimizations.md @@ -38,18 +38,28 @@ export class AppComponent { } ``` -## RX_RENDER_STRATEGIES_CONFIG +## provideRxRenderStrategies -You can also set the `patchZone` config globally by providing a `RX_RENDER_STRATEGIES_CONFIG`. +You can also set the `patchZone` config globally by using `provideRxRenderStrategies` function. See more about configuration under [render strategies](../../cdk/render-strategies/render-strategies.mdx) especially the section [usage-in-the-template](../../cdk/render-strategies/render-strategies.mdx#global) ```ts +const appConfig: ApplicationConfig = { + providers: [ + // ... other providers + provideRxRenderStrategies({ + patchZone: false, // this applies to all RxLets + }), + ], +}; + +// OR in NgModule-based apps @NgModule({ - providers: [{ - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - patchZone: false // this applies to all RxLets - } - }] + providers: [ + provideRxRenderStrategies({ + patchZone: false, // this applies to all RxLets + }), + ], }) +export class AppModule {} ``` diff --git a/apps/docs/docs/template/api/push-pipe.md b/apps/docs/docs/template/push-pipe.md similarity index 71% rename from apps/docs/docs/template/api/push-pipe.md rename to apps/docs/docs/template/push-pipe.md index ebb6333641..a0924478b8 100644 --- a/apps/docs/docs/template/api/push-pipe.md +++ b/apps/docs/docs/template/push-pipe.md @@ -12,8 +12,10 @@ The current way of binding an observable to the view looks like that: ```html {{ observable$ | async }} + {{ o }} - + + ``` ## Problems with `async` pipe @@ -24,12 +26,12 @@ components and does not work in zone-less mode. ## Solution -`push` pipe solves that problem. It contains intelligent handling of change detection by leveraging a [RenderStrategy](../../cdk/render-strategies/render-strategies.mdx) under the hood, which in turn, takes care of optimizing the `ChangeDetection` of your component. The `push` pipe can be used in zone-full as well as zone-less mode without any changes to the code. +`push` pipe solves that problem. It contains intelligent handling of change detection by leveraging a [RenderStrategy](../cdk/render-strategies/render-strategies.mdx) under the hood, which in turn, takes care of optimizing the `ChangeDetection` of your component. The `push` pipe can be used in zone-full as well as zone-less mode without any changes to the code. _Example_ ```html - + ``` The rendering behavior can be configured per RxPush instance using the strategy parameter. @@ -37,7 +39,7 @@ The rendering behavior can be configured per RxPush instance using the strategy _Example_ ```html - + ``` ## Included features @@ -46,7 +48,7 @@ _Example_ - Handling null and undefined values in a clean unified/structured way - Distinct same values in a row to increase performance - Coalescing of change detection calls to boost performance -- Lazy rendering (see [RxLet](./rx-let-directive.mdx)) +- Lazy rendering (see [RxLet](rx-let-directive.mdx)) - Chunked rendering ## Signature diff --git a/apps/docs/docs/template/api/rx-for-directive.mdx b/apps/docs/docs/template/rx-for-directive.mdx similarity index 75% rename from apps/docs/docs/template/api/rx-for-directive.mdx rename to apps/docs/docs/template/rx-for-directive.mdx index 7246988c92..0485a1f402 100644 --- a/apps/docs/docs/template/api/rx-for-directive.mdx +++ b/apps/docs/docs/template/rx-for-directive.mdx @@ -6,6 +6,7 @@ title: 'RxFor' import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; +import ReactPlayer from 'react-player'; ## Motivation @@ -46,13 +47,17 @@ Each instance of `RxFor` can be configured to render with different settings. +:::info +You don't need to unwrap the signal, just pass its reference to `rxFor`, it'll do the rest for you. +::: + ```html title="src/list.component.html"
``` -```typescript title="src/list.component.html" +```typescript title="src/list.component.ts" import { RxFor } from '@rx-angular/template/for'; import { Component } from '@angular/core'; @@ -76,7 +81,7 @@ export class ListComponent {
``` -```typescript title="src/list.component.html" +```typescript title="src/list.component.ts" import { RxFor } from '@rx-angular/template/for'; import { Component } from '@angular/core'; @@ -105,7 +110,7 @@ export class ListComponent {
``` -```typescript title="src/list.component.html" +```typescript title="src/list.component.ts" import { RxFor } from '@rx-angular/template/for'; import { Component } from '@angular/core'; @@ -140,8 +145,8 @@ By default `*rxFor` is optimized for performance out of the box. This includes: -- The default render strategy is [`normal`](../../cdk/render-strategies/strategies/concurrent-strategies.md). -- This ensures non-blocking rendering but can cause other side-effects. See [strategy configuration](../../cdk/render-strategies#Default-configuration) if you want to change it. +- The default render strategy is [`normal`](../cdk/render-strategies/strategies/concurrent-strategies.md). +- This ensures non-blocking rendering but can cause other side-effects. See [strategy configuration](../cdk/render-strategies#Default-configuration) if you want to change it. - Creates templates lazy and manages multiple template instances As a list can take larger to render items can appear in batches if concurrent strategies are used. @@ -167,7 +172,7 @@ You can pass any valid property from the given input type as a shortcut instead ``` -```typescript title="src/list.component.html" +```typescript title="src/list.component.ts" import { RxFor } from '@rx-angular/template/for'; import { Component } from '@angular/core'; @@ -191,7 +196,7 @@ export class ListComponent { ``` -```typescript title="src/list.component.html" +```typescript title="src/list.component.ts" import { RxFor } from '@rx-angular/template/for'; import { Component } from '@angular/core'; @@ -262,10 +267,10 @@ export class ListComponent { ## Concepts -- [Local variables](../concepts/local-variables.md) -- [Handling view and content queries](../performance-issues/handling-view-and-content-queries.md) -- [NgZone optimizations](../performance-issues/ngzone-optimizations.md) -- [Render strategies](../../cdk/render-strategies/render-strategies.mdx) especially the section [usage-in-the-template](../../cdk/render-strategies#usage-in-the-template) +- [Local variables](./concepts/local-variables.md) +- [Handling view and content queries](./performance-issues/handling-view-and-content-queries.md) +- [NgZone optimizations](./performance-issues/ngzone-optimizations.md) +- [Render strategies](../cdk/render-strategies/render-strategies.mdx) especially the section [usage-in-the-template](../cdk/render-strategies#usage-in-the-template) ## Features @@ -293,13 +298,13 @@ export class ListComponent { **Rendering** -| Input | Type | description | -| --------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `trackBy` | `keyof T` or `(index: number, item: T) => any` | Identifier function for items. `rxFor` provides a shorthand where you can name the property directly. | -| `patchZone` | `boolean` | _default: `true`_ if set to `false`, the `RxFor` will operate out of `NgZone`. See [NgZone optimizations](../performance-issues/ngzone-optimizations.md) | -| `parent` (deprecated) | `boolean` | _default: `true`_ if set to `false`, the `RxFor` won't inform its host component about changes being made to the template. More performant, `@ViewChild` and `@ContentChild` queries won't work. [Handling view and content queries](../performance-issues/handling-view-and-content-queries.md) | -| `strategy` | `Observable \ RxStrategyNames \ string>` | _default: `normal`_ configure the `RxStrategyRenderStrategy` used to detect changes. | -| `renderCallback` | `Subject` | giving the developer the exact timing when the `RxFor` created, updated, removed its template. Useful for situations where you need to know when rendering is done. | +| Input | Type | description | +| --------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `trackBy` | `keyof T` or `(index: number, item: T) => any` | Identifier function for items. `rxFor` provides a shorthand where you can name the property directly. | +| `patchZone` | `boolean` | _default: `true`_ if set to `false`, the `RxFor` will operate out of `NgZone`. See [NgZone optimizations](./performance-issues/ngzone-optimizations.md) | +| `parent` (deprecated) | `boolean` | _default: `true`_ if set to `false`, the `RxFor` won't inform its host component about changes being made to the template. More performant, `@ViewChild` and `@ContentChild` queries won't work. [Handling view and content queries](./performance-issues/handling-view-and-content-queries.md) | +| `strategy` | `Observable \ RxStrategyNames \ string>` | _default: `normal`_ configure the `RxStrategyRenderStrategy` used to detect changes. | +| `renderCallback` | `Subject` | giving the developer the exact timing when the `RxFor` created, updated, removed its template. Useful for situations where you need to know when rendering is done. | ## Context Variables @@ -330,6 +335,126 @@ The following context variables are available for each template: | `odd$` | `Observable` | odd as `Observable` | | `select` | `(keys: (keyof T)[], distinctByMap) => Observable>` | returns a selection function which accepts an array of properties to pluck out of every list item. The function returns the selected properties of the current list item as distinct `Observable` key-value-pair. | +## Use the new reconciliation algorithm + +You can opt in to use the new reconciliation algorithm, which was shipped by the +angular team as part of the new `@for` control flow. + +The original implementations can be found [here](https://github.com/angular/angular/blob/main/packages/core/src/render3/list_reconciliation.ts) & [here](https://github.com/angular/angular/blob/f8d22a9ba4e426f14f9c7fd608e1ad752cd44eb5/packages/core/src/render3/instructions/control_flow.ts#L281) + +By default, `rxFor` uses the `IterableDiffer` to calculate the operations it needs to apply +when an update to the bound iterable happened. + + + + + +```typescript +import { provideExperimentalRxForReconciliation } from '@rx-angular/template/for'; + +const appConfig: AppConfig = { + providers: [provideExperimentalRxForReconciliation()], +}; +``` + + + + + +In case you want to opt-out at some level of the injector tree, you can use the `provideLegacyRxForReconciliation` provider function. + +```typescript +import { provideLegacyRxForReconciliation } from '@rx-angular/template/for'; + +@Component({ + providers: [provideLegacyRxForReconciliation()], +}) +export class MyComponent {} +``` + + + + + +### Impact + +In general, the new reconciliation algorithm diffs two lists with fewer operations to achieve the same goal as the legacy `IterableDiffer` approach. However, this only applies for move / swap operations. + +It's also more memory efficient than the iterable differ. + +For `rxFor` specifically, there are also **behavioral impacts**. +Instead of actually moving around DOM, the new reconciliation works by `detaching` & `attaching` views. +As rxFor by default uses the concurrent mode, it splits each individual task (attach, detach, update, remove) and works them off in a queue. +As we are operating on the DOM, we have to run tasks in the given order. +The biggest impact is that you'll visually see views disappearing from the screen when the whole data set is being shuffled around. + +This leads to visual instability on the one hand, but also makes sure no view is ever in the wrong position as in the legacy approach. + +#### Swap + +Swapping the first item with the last item. This shows off the advantages of the new reconciliation in the most impressive way. + + + + +The new reconciliation algorithm only needs 4 operations (detach x2, attach x2) to achieve the end result. + + + + + + +The `IterableDiffer` approach needs to move around each item in the whole list to achieve the final result. + + + + + + +#### Random Shuffle + +Randomly shuffle elements in the array. This example shows the behavioral changes. + + + + +As stated before, the new reconciliation algorithm doesn't move dom, it detaches & attaches nodes. +As rxFor schedules & runs all operations in order, it's possible that you will end up with a temporary state where nodes are detached +but not attached yet. + + + + + + +The legacy approach just moves around views until the final result is stable. This has the downside that a couple of views +might be temporarily out of order. + + + + + + +#### Filter + +Filter items and remove the filter again. Both approaches work the same in this scenario. + + + + + + + + + +The legacy approach just moves around views until the final result is stable. This has the downside that a couple of views +might be temporarily out of order. + + + + + + ## Advanced Usage ### Use render strategies (`strategy`) @@ -337,7 +462,7 @@ The following context variables are available for each template: You can change the used `RenderStrategy` by using the `strategy` input of the `*rxFor`. It accepts an `Observable` or [`RxStrategyNames`](https://github.com/rx-angular/rx-angular/blob/b0630f69017cc1871d093e976006066d5f2005b9/libs/cdk/render-strategies/src/lib/model.ts#L52). -The default value for strategy is [`normal`](../../cdk/render-strategies/strategies/concurrent-strategies.md). +The default value for strategy is [`normal`](../cdk/render-strategies/strategies/concurrent-strategies.md). ```html {{ item }} @@ -355,7 +480,7 @@ export class AppComponent { } ``` -Learn more about the general concept of [`RenderStrategies`](../../cdk/render-strategies) especially the section [usage-in-the-template](../../cdk/render-strategies#usage-in-the-template) if you need more clarity. +Learn more about the general concept of [`RenderStrategies`](../cdk/render-strategies) especially the section [usage-in-the-template](../cdk/render-strategies#usage-in-the-template) if you need more clarity. #### Local strategies and view/content queries (`parent`) @@ -372,21 +497,11 @@ You can do so by providing a custom `RxRenderStrategiesConfig`, see the followin ```typescript // import -import { RxRenderStrategiesConfig, RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; - -// create configuration with parent flag to be false -const rxaConfig: RxRenderStrategiesConfig = { - parent: false, -}; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; // provide it, in best case on root level { - providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: rxaConfig, - }, - ]; + providers: [provideRxRenderStrategies({ parent: false })]; } ``` @@ -432,11 +547,11 @@ The usage of `AppListComponent` looks like this: ``` -Read more about this at [handling view and content queries](../performance-issues/handling-view-and-content-queries.md) +Read more about this at [handling view and content queries](./performance-issues/handling-view-and-content-queries.md) #### RxFor with concurrent strategies -The `*rxFor` directive is configured to use the `normal` [concurrent strategy](../../cdk/render-strategies/strategies/concurrent-strategies.md) +The `*rxFor` directive is configured to use the `normal` [concurrent strategy](../cdk/render-strategies/strategies/concurrent-strategies.md) by default. Rendering large sets of data is and has always been a performance bottleneck, especially for business @@ -535,7 +650,7 @@ The default value is `true, `*rxFor`will create it's`EmbeddedViews`inside`NgZone Event listeners normally trigger zone. Especially high frequently events cause performance issues. -For more details read about [NgZone optimizations](../performance-issues/ngzone-optimizations.md) +For more details read about [NgZone optimizations](./performance-issues/ngzone-optimizations.md) ```ts @Component({ @@ -564,12 +679,9 @@ This can be configured as a `StaticProvider`. **Setting the default strategy** ```ts -export const RX_ANGULAR_TEST_PROVIDER: StaticProvider = { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, -}; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; + +export const RX_ANGULAR_TEST_PROVIDER = provideRxRenderStrategies({ primaryStrategy: 'native' }); ``` **Overriding a strategy** @@ -581,18 +693,15 @@ In order to still use the `native` strategy in your test environment, you can si with the native one. ```ts -export const RX_ANGULAR_TEST_PROVIDER: StaticProvider = { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - customStrategies: { - userBlocking: { - ...RX_NATIVE_STRATEGIES.native, - name: 'userBlocking', - }, +export const RX_ANGULAR_TEST_PROVIDER = provideRxRenderStrategies({ + primaryStrategy: 'native', + customStrategies: { + userBlocking: { + ...RX_NATIVE_STRATEGIES.native, + name: 'userBlocking', }, }, -}; +}); ``` If you have done your desired configuration, declare it in the providers entry of the `TestModule`. diff --git a/apps/docs/docs/template/api/rx-if-directive.mdx b/apps/docs/docs/template/rx-if-directive.mdx similarity index 91% rename from apps/docs/docs/template/api/rx-if-directive.mdx rename to apps/docs/docs/template/rx-if-directive.mdx index 45b731224e..eea796c595 100644 --- a/apps/docs/docs/template/api/rx-if-directive.mdx +++ b/apps/docs/docs/template/rx-if-directive.mdx @@ -18,7 +18,7 @@ issues, to name a few: - it leads to too many subscriptions in the template - it is cumbersome to work with values in the template -Read more about [rendering issues with native angular change detection](../performance-issues/rendering-issues-in-angular.md). +Read more about [rendering issues with native angular change detection](./performance-issues/rendering-issues-in-angular.md). The `RxIf` directive serves as a drop-in replacement for the `NgIf` directive, but with additional features. `RxIf` allows you to bind observables directly without having the need of using the `async` @@ -110,10 +110,10 @@ export class SomeComponent { ## Concepts -- [Local variables](../concepts/local-variables.md) -- [Local template](../concepts/local-templates.md) -- [Reactive context](../concepts/reactive-context.md) -- [Render strategies](../../cdk/render-strategies) +- [Local variables](./concepts/local-variables.md) +- [Local template](./concepts/local-templates.md) +- [Reactive context](./concepts/reactive-context.md) +- [Render strategies](../cdk/render-strategies) ## Features @@ -155,14 +155,14 @@ export class SomeComponent { **Rendering** -| Input | Type | description | -| --------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `then` | `TemplateRef` | defines the template for when the bound condition is true | -| `else` | `TemplateRef` | defines the template for when the bound condition is false | -| `patchZone` | `boolean` | _default: `true`_ if set to `false`, the `RxIf` will operate out of `NgZone`. See [NgZone optimizations](../performance-issues/ngzone-optimizations.md) | -| `parent` (deprecated) | `boolean` | _default: `true`_ if set to `false`, the `RxIf` won't inform its host component about changes being made to the template. More performant, `@ViewChild` and `@ContentChild` queries won't work. [Handling view and content queries](../performance-issues/handling-view-and-content-queries.md) | -| `strategy` | `Observable` or `RxStrategyNames` | _default: `normal`_ configure the `RxStrategyRenderStrategy` used to detect changes. | -| `renderCallback` | `Subject` | giving the developer the exact timing when the `RxIf` created, or removed its template. Useful for situations where you need to know when rendering is done. | +| Input | Type | description | +| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `then` | `TemplateRef` | defines the template for when the bound condition is true | +| `else` | `TemplateRef` | defines the template for when the bound condition is false | +| `patchZone` | `boolean` | _default: `true`_ if set to `false`, the `RxIf` will operate out of `NgZone`. See [NgZone optimizations](./performance-issues/ngzone-optimizations.md) | +| `parent` (deprecated) | `boolean` | _default: `true`_ if set to `false`, the `RxIf` won't inform its host component about changes being made to the template. More performant, `@ViewChild` and `@ContentChild` queries won't work. [Handling view and content queries](./performance-issues/handling-view-and-content-queries.md) | +| `strategy` | `Observable` or `RxStrategyNames` | _default: `normal`_ configure the `RxStrategyRenderStrategy` used to detect changes. | +| `renderCallback` | `Subject` | giving the developer the exact timing when the `RxIf` created, or removed its template. Useful for situations where you need to know when rendering is done. | ## Setup @@ -186,8 +186,8 @@ export class AnyComponent {} > > This includes: > -> - The default render strategy is [`normal`](../../cdk/render-strategies/strategies/concurrent-strategies.md). -> This ensures non-blocking rendering but can cause other side-effects. See [strategy configuration](../../cdk/render-strategies#Default-configuration) if you want to change it. +> - The default render strategy is [`normal`](../cdk/render-strategies/strategies/concurrent-strategies.md). +> This ensures non-blocking rendering but can cause other side-effects. See [strategy configuration](../cdk/render-strategies#Default-configuration) if you want to change it. > - Creates templates lazy and manages multiple template instances ### Bind Values @@ -279,7 +279,7 @@ export class SomeComponent { ![Contextual-State--template-vs-variable](https://user-images.githubusercontent.com/10064416/192660150-643c4d37-5326-4ba2-ad84-e079890b3f2f.png) -A nice feature of the `*rxIf` directive is, it provides 2 ways to access the [reactive context state](../concepts/reactive-context.md) in the template: +A nice feature of the `*rxIf` directive is, it provides 2 ways to access the [reactive context state](./concepts/reactive-context.md) in the template: - context variables - context templates @@ -357,7 +357,7 @@ You can use them like this: ### Context Templates -You can also use template anchors to display the [reactive context](../concepts/reactive-context.md) in the template: +You can also use template anchors to display the [reactive context](./concepts/reactive-context.md) in the template: ```html ` or [`RxStrategyNames`](https://github.com/rx-angular/rx-angular/blob/b0630f69017cc1871d093e976006066d5f2005b9/libs/cdk/render-strategies/src/lib/model.ts#L52). -The default value for strategy is [`normal`](../../cdk/render-strategies/strategies/concurrent-strategies.md). +The default value for strategy is [`normal`](../cdk/render-strategies/strategies/concurrent-strategies.md). ```html @@ -550,7 +550,7 @@ export class AppComponent { } ``` -Learn more about the general concept of [`RenderStrategies`](../../cdk/render-strategies) especially the section [usage-in-the-template](../../cdk/render-strategies#usage-in-the-template) if you need more clarity. +Learn more about the general concept of [`RenderStrategies`](../cdk/render-strategies) especially the section [usage-in-the-template](../cdk/render-strategies#usage-in-the-template) if you need more clarity. #### Local strategies and view/content queries (`parent`) @@ -569,20 +569,14 @@ You can do so by providing a custom `RxRenderStrategiesConfig`, see the followin ```typescript // import -import { RxRenderStrategiesConfig, RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; - -// create configuration with parent flag to be false -const rxaConfig: RxRenderStrategiesConfig = { - parent: false, -}; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; // provide it, in best case on root level { providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: rxaConfig, - }, + provideRxRenderStrategies({ + parent: false, + }), ]; } ``` @@ -655,7 +649,7 @@ export class AppComponent { Event listeners normally trigger zone. Especially high frequency events can cause performance issues. -For more details read about [NgZone optimizations](../performance-issues/ngzone-optimizations.md) +For more details read about [NgZone optimizations](./performance-issues/ngzone-optimizations.md) ```ts @Component({ @@ -680,7 +674,7 @@ This helps to exclude all side effects from special render strategies. ```typescript import { ChangeDetectorRef, Component, TemplateRef, ViewContainerRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { RxIf } from '@rx-angular/template/if'; @Component({ @@ -695,13 +689,8 @@ const setupTestComponent = (): void => { declarations: [TestComponent], imports: [RxIf], providers: [ - { - // don't forget to configure the primary strategy to 'native' - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + // don't forget to configure the primary strategy to 'native' + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], }); @@ -715,22 +704,17 @@ const setupTestComponent = (): void => { > do not forget to set the primary strategy to `native` in test environments -In test environments it is recommended to configure rx-angular to use the [`native` strategy](../../cdk/render-strategies/strategies/basic-strategies.md#native), +In test environments it is recommended to configure rx-angular to use the [`native` strategy](../cdk/render-strategies/strategies/basic-strategies.md#native), as it will run change detection synchronously. -Using the [`concurrent strategies`](../../cdk/render-strategies/strategies/concurrent-strategies.md) is possible, but +Using the [`concurrent strategies`](../cdk/render-strategies/strategies/concurrent-strategies.md) is possible, but requires more effort when writing the tests, as updates will be processed asynchronously. ```ts TestBed.configureTestingModule({ declarations: [], providers: [ - { - // don't forget to configure the primary strategy to 'native' - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + // don't forget to configure the primary strategy to 'native' + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], }); ``` diff --git a/apps/docs/docs/template/api/rx-let-directive.mdx b/apps/docs/docs/template/rx-let-directive.mdx similarity index 89% rename from apps/docs/docs/template/api/rx-let-directive.mdx rename to apps/docs/docs/template/rx-let-directive.mdx index b61791dc11..4f7c3070ba 100644 --- a/apps/docs/docs/template/api/rx-let-directive.mdx +++ b/apps/docs/docs/template/rx-let-directive.mdx @@ -118,8 +118,8 @@ It mostly is used in combination with state management libs to handle user inter By default `*rxLet` is optimized for performance out of the box. This includes: -- The default render strategy is [`normal`](../../cdk/render-strategies/strategies/concurrent-strategies.md). - - This ensures non-blocking rendering but can cause other side-effects. See [strategy configuration](../../cdk/render-strategies/strategies/basic-strategies.md) if you want to change it. +- The default render strategy is [`normal`](../cdk/render-strategies/strategies/concurrent-strategies.md). + - This ensures non-blocking rendering but can cause other side-effects. See [strategy configuration](../cdk/render-strategies/strategies/basic-strategies.md) if you want to change it. - Creates templates lazy and manages multiple template instances ::: @@ -188,7 +188,7 @@ export class CounterComponent { ![Contextual-State--template-vs-variable](https://user-images.githubusercontent.com/10064416/192660150-643c4d37-5326-4ba2-ad84-e079890b3f2f.png) -A nice feature of the `*rxLet` directive is, it provides 2 ways to access the [reactive context state](../concepts/reactive-context.md) in the template: +A nice feature of the `*rxLet` directive is, it provides 2 ways to access the [reactive context state](./concepts/reactive-context.md) in the template: - context variables - context templates @@ -224,7 +224,7 @@ You can use the as like this: ### Context Templates -You can also use template anchors to display the [contextual state](../concepts/reactive-context.md) in the template: +You can also use template anchors to display the [contextual state](./concepts/reactive-context.md) in the template: ```html \ RxStrategyNames \ string>` | _default: `normal`_ configure the `RxStrategyRenderStrategy` used to detect changes. | -| `renderCallback` | `Subject` | giving the developer the exact timing when the `RxLet` created, updated, removed its template. Useful for situations where you need to know when rendering is done. | +| Input | Type | description | +| --------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `patchZone` | `boolean` | _default: `true`_ if set to `false`, the `RxLet` will operate out of `NgZone`. See [NgZone optimizations](./performance-issues/ngzone-optimizations.md) | +| `parent` (deprecated) | `boolean` | _default: `true`_ if set to `false`, the `RxLet` won't inform its host component about changes being made to the template. More performant, `@ViewChild` and `@ContentChild` queries won't work. [Handling view and content queries](./performance-issues/handling-view-and-content-queries.md) | +| `strategy` | `Observable \ RxStrategyNames \ string>` | _default: `normal`_ configure the `RxStrategyRenderStrategy` used to detect changes. | +| `renderCallback` | `Subject` | giving the developer the exact timing when the `RxLet` created, updated, removed its template. Useful for situations where you need to know when rendering is done. | ## Advanced Usage @@ -445,7 +445,7 @@ export class AppComponent { You can change the used `RenderStrategy` by using the `strategy` input of the `*rxFor`. It accepts an `Observable` or [`RxStrategyNames`](https://github.com/rx-angular/rx-angular/blob/b0630f69017cc1871d093e976006066d5f2005b9/libs/cdk/render-strategies/src/lib/model.ts#L52). -The default value for strategy is [`normal`](../../cdk/render-strategies/strategies/concurrent-strategies.md#normal). +The default value for strategy is [`normal`](../cdk/render-strategies/strategies/concurrent-strategies.md#normal). ```html {{ item }} @@ -463,7 +463,7 @@ export class AppComponent { } ``` -Learn more about the general concept of [`RenderStrategies`](../../cdk/render-strategies) especially the section [usage-in-the-template](../../cdk/render-strategies#usage-in-the-template) if you need more clarity. +Learn more about the general concept of [`RenderStrategies`](../cdk/render-strategies) especially the section [usage-in-the-template](../cdk/render-strategies#usage-in-the-template) if you need more clarity. #### Local strategies and view/content queries (`parent`) @@ -482,21 +482,11 @@ You can do so by providing a custom `RxRenderStrategiesConfig`, see the followin ```typescript // import -import { RxRenderStrategiesConfig, RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; - -// create configuration with parent flag to be false -const rxaConfig: RxRenderStrategiesConfig = { - parent: false, -}; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; // provide it, in best case on root level { - providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: rxaConfig, - }, - ]; + providers: [provideRxRenderStrategies({ parent: false })]; } ``` @@ -572,7 +562,7 @@ The result of the `renderCallback` will contain the currently rendered value of Event listeners normally trigger zone. Especially high frequently events cause performance issues. By using we can run all event listener inside `rxLet` outside zone. -For more details read about [NgZone optimizations](../performance-issues/ngzone-optimizations.md) +For more details read about [NgZone optimizations](./performance-issues/ngzone-optimizations.md) ```ts @Component({ @@ -598,7 +588,7 @@ This helps to exclude all side effects from special render strategies. ```typescript import { ChangeDetectorRef, Component, TemplateRef, ViewContainerRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { RxLet } from '@rx-angular/template/let'; @Component({ @@ -616,13 +606,8 @@ const setupTestComponent = (): void => { TestBed.configureTestingModule({ declarations: [RxLet, TestComponent], providers: [ - { - // don't forget to configure the primary strategy to 'native' - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + // don't forget to configure the primary strategy to 'native' + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], }); @@ -636,22 +621,17 @@ const setupTestComponent = (): void => { > do not forget to set the primary strategy to `native` in test environments -In test environments it is recommended to configure rx-angular to use the [`native` strategy](../../cdk/render-strategies/strategies/basic-strategies.md#native), +In test environments it is recommended to configure rx-angular to use the [`native` strategy](../cdk/render-strategies/strategies/basic-strategies.md#native), as it will run change detection synchronously. -Using the [`concurrent strategies`](../../cdk/render-strategies/strategies/concurrent-strategies.md) is possible, but +Using the [`concurrent strategies`](../cdk/render-strategies/strategies/concurrent-strategies.md) is possible, but requires more effort when writing the tests, as updates will be processed asynchronously. ```ts TestBed.configureTestingModule({ declarations: [RxLet, TestComponent], providers: [ - { - // don't forget to configure the primary strategy to 'native' - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + // don't forget to configure the primary strategy to 'native' + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], }); ``` diff --git a/apps/docs/docs/template/api/unpatch-directive.md b/apps/docs/docs/template/unpatch-directive.md similarity index 96% rename from apps/docs/docs/template/api/unpatch-directive.md rename to apps/docs/docs/template/unpatch-directive.md index 0d54d9f979..d20c728c9e 100644 --- a/apps/docs/docs/template/api/unpatch-directive.md +++ b/apps/docs/docs/template/unpatch-directive.md @@ -18,7 +18,7 @@ The current way of binding events to DOM: ``` The problem is that every event registered via `()`, e.g. `(mousemove)` (or custom `@Output()`) -marks the component and all its ancestors as dirty and re-renders the whole component tree. [Read more about this here](../performance-issues/rendering-issues-in-angular.md) +marks the component and all its ancestors as dirty and re-renders the whole component tree. [Read more about this here](performance-issues/rendering-issues-in-angular.md) So even if your eventListener is not related to any change at all, your app will re-render the whole component tree. This can lead to very bad user experiences, especially if you work with frequently fired events such as `mousemove`. diff --git a/apps/docs/docs/template/api/virtual-scrolling.mdx b/apps/docs/docs/template/virtual-scrolling.mdx similarity index 92% rename from apps/docs/docs/template/api/virtual-scrolling.mdx rename to apps/docs/docs/template/virtual-scrolling.mdx index 44e2aca779..2533bbb25a 100644 --- a/apps/docs/docs/template/api/virtual-scrolling.mdx +++ b/apps/docs/docs/template/virtual-scrolling.mdx @@ -180,10 +180,10 @@ all pre-packaged ScrollStrategies as well as control the majority of inputs. ## Concepts -- [Local variables](../concepts/local-variables.md) -- [Handling view and content queries](../performance-issues/handling-view-and-content-queries.md) -- [NgZone optimizations](../performance-issues/ngzone-optimizations.md) -- [Render strategies](../../cdk/render-strategies/render-strategies.mdx) especially the section [usage-in-the-template](../../cdk/render-strategies#usage-in-the-template) +- [Local variables](./concepts/local-variables.md) +- [Handling view and content queries](./performance-issues/handling-view-and-content-queries.md) +- [NgZone optimizations](./performance-issues/ngzone-optimizations.md) +- [Render strategies](../cdk/render-strategies/render-strategies.mdx) especially the section [usage-in-the-template](../cdk/render-strategies#usage-in-the-template) ## Features @@ -196,8 +196,8 @@ all pre-packaged ScrollStrategies as well as control the majority of inputs. **Performance Features** -- lazy template creation (done by [Render Strategies](../../cdk/render-strategies/)) -- non-blocking rendering of lists [Concurrent Strategies](../../cdk/render-strategies/strategies/concurrent-strategies.md) +- lazy template creation (done by [Render Strategies](../cdk/render-strategies/)) +- non-blocking rendering of lists [Concurrent Strategies](../cdk/render-strategies/strategies/concurrent-strategies.md) - configurable frame budget (defaults to 60 FPS) - Super efficient layouting with css transformations - Scoped layouting with css containment @@ -562,7 +562,7 @@ given in our demos application You can change the used `RenderStrategy` by using the `strategy` input of the `*rxVirtualFor`. It accepts an `Observable` or [`RxStrategyNames`](https://github.com/rx-angular/rx-angular/blob/main/libs/cdk/render-strategies/src/lib/model.ts#L43). -The default value for strategy is [`normal`](../../cdk/render-strategies/strategies/concurrent-strategies.md). +The default value for strategy is [`normal`](../cdk/render-strategies/strategies/concurrent-strategies.md). ```html @@ -595,12 +595,12 @@ export class AppComponent { } ``` -Learn more about the general concept of [`RenderStrategies`](../../cdk/render-strategies) especially the section [usage-in-the-template](../../cdk/render-strategies#usage-in-the-template) if you need more clarity. +Learn more about the general concept of [`RenderStrategies`](../cdk/render-strategies) especially the section [usage-in-the-template](../cdk/render-strategies#usage-in-the-template) if you need more clarity. #### Local strategies and view/content queries (`parent`) By default, `*rxVirtualFor` has turned the `parent` flag off. This means you are unable to rely on any content or view queries. -Read more about this at [handling view and content queries](../performance-issues/handling-view-and-content-queries.md) +Read more about this at [handling view and content queries](./performance-issues/handling-view-and-content-queries.md) ### Use the `renderCallback` @@ -651,7 +651,7 @@ The default value is **true** (configurable via `RxRenderStrategiesConfig` or as Event listeners normally trigger zone. Especially high frequently events cause performance issues. -For more details read about [NgZone optimizations](../performance-issues/ngzone-optimizations.md) +For more details read about [NgZone optimizations](./performance-issues/ngzone-optimizations.md) **Example with `patchZone: false`** @@ -964,7 +964,7 @@ this section covers a brief feature comparison between both implementations and | NgZone agnostic | ✅ | ❌ | | layout containment | ✅ | ✅ | | layout technique | absolutely position each view | transform a container within the viewport | -| scheduling technique | [`RenderStrategies`](../../cdk/render-strategies/strategies/concurrent-strategies.md) | `requestAnimationFrame` | +| scheduling technique | [`RenderStrategies`](../cdk/render-strategies/strategies/concurrent-strategies.md) | `requestAnimationFrame` | | renderCallback | ✅ | ❌ | | SSR | ⚠ - to be tested | ✅ | | Define visible view buffer | configurable amount of views displayed in scroll direction,
and opposite scroll direction | configurable buffer in px | @@ -987,7 +987,7 @@ The biggest difference between the two implementations lies within the applied l Two main tasks have to be considered when layouting a virtual viewport. The sizing of the scrollable area (runway) and keeping the viewport (visible part to the user) in sync with the user defined scroll position. -![viewport and runway](../../../static/img/template/virtual-scrolling/viewport-and-runway.png) +![viewport and runway](../../static/img/template/virtual-scrolling/viewport-and-runway.png) _screenshot taken from https://developer.chrome.com/blog/infinite-scroller/_ @@ -998,13 +998,13 @@ The Angular CDK implementation sizes its viewport by adjusting the `height` styl This results in one extremely large layer that puts pressure on the devices memory by storing a texture on the graphics card that potentially has a height of a couple of hundred thousand pixels. -![cdk-container-size](../../../static/img/template/virtual-scrolling/cdk-container-size.png) +![cdk-container-size](../../static/img/template/virtual-scrolling/cdk-container-size.png) In this example, the layers tool estimates a memory footprint of ~5GB for a runway with 30.000 items. This number is only an estimate, and we couldn't see such high memory consumption on the actual device, but it stresses the point. -![layer-memory-estimate](../../../static/img/template/virtual-scrolling/layer-memory-estimate.png) +![layer-memory-estimate](../../static/img/template/virtual-scrolling/layer-memory-estimate.png) > 💡 You can counter this issue by making sure this layer is completely empty. It will be empty if it has no own paint area (e.g. background-color) > and all items are forced into their own layers (e.g. using `will-change: transform`) @@ -1012,7 +1012,7 @@ stresses the point. Another minor, but notable point is that changing an elements `height` property always forces the browser to perform a layout operation. In certain situations this can lead to more work than actually needed. -![css-triggers height](../../../static/img/template/virtual-scrolling/css-triggers-height.png) +![css-triggers height](../../static/img/template/virtual-scrolling/css-triggers-height.png) _screenshot taken from https://www.lmame-geek.com/_ @@ -1021,12 +1021,12 @@ DOM element won't grow beyond its boundaries. While this alone is already an improvement, in best case still all items within the runway are enforced on their own layer (e.g. using `will-change: transform`) to make sure the runway layer is completely empty. -![rxa-scroll-sentinel](../../../static/img/template/virtual-scrolling/rxa-scroll-sentinel.png) +![rxa-scroll-sentinel](../../static/img/template/virtual-scrolling/rxa-scroll-sentinel.png) As the runway is sized using the `transform` css property, we also don't run into the situation where resizing the runway would cause any layout work for the browser. -![css-triggers transform](../../../static/img/template/virtual-scrolling/css-triggers-transform.png) +![css-triggers transform](../../static/img/template/virtual-scrolling/css-triggers-transform.png) _screenshot taken from https://www.lmame-geek.com/_ @@ -1036,7 +1036,7 @@ The Angular CDK implementation positions its list-items relative, letting the br a separate container element which is only as large as the items it contains. It is absolutely positioned to the viewport. To keep the visible items with the viewport in sync, the whole container is moved by the css `transform` on scroll events. -![cdk-container-transform](../../../static/img/template/virtual-scrolling/cdk-container-transform.png) +![cdk-container-transform](../../static/img/template/virtual-scrolling/cdk-container-transform.png) As a user scrolls the viewport, the cdk virtual scroller calculates the range of items to be displayed. The transform value for the container is derived from the range and the actual view sizes. @@ -1050,7 +1050,7 @@ this._viewport.setRenderedContentOffset(this._itemSize * newRange.start); The RxAngular implementation calculates the position for each list item within the runway and absolutely positions each item individually with transforms. -![rxa-item-transform](../../../static/img/template/virtual-scrolling/rxa-item-transform.png) +![rxa-item-transform](../../static/img/template/virtual-scrolling/rxa-item-transform.png) As the layout is done entirely manually, it essentially removes the need for the browser to layout any item within the viewport. This is especially true for updates, moves and insertions from cache. @@ -1073,17 +1073,17 @@ into a single task. Especially when using a weak device or rendering heavy compo long tasks and can result in scroll stuttering. See the [Performance Comparison section](#performance-comparison) for more information about the actual runtime performance. -![cdk-fixed-size--throttled](../../../static/img/template/virtual-scrolling/cdk-fixed-size--throttled.png) +![cdk-fixed-size--throttled](../../static/img/template/virtual-scrolling/cdk-fixed-size--throttled.png) RxAngular's virtual scrolling implementation also uses the `requestAnimationFrame` scheduler, but not for change detection. It is used for coalescing scroll events and calculation of changes to the view range. -The scheduling being used for running change detection is configurable, by default it uses the [`normal Concurrent Strategy`](../../cdk/render-strategies/strategies/concurrent-strategies.md). +The scheduling being used for running change detection is configurable, by default it uses the [`normal Concurrent Strategy`](../cdk/render-strategies/strategies/concurrent-strategies.md). In short, the concurrent strategies batch work into pieces to match a certain frame budget (60fps by default). Changes to the view range get translated into individual work packages to insert, move, update, delete and position views. The work packages are then processed individually by keeping the frame budget in mind. -![rxa-fixed-size--throttled](../../../static/img/template/virtual-scrolling/rxa-fixed-size--throttled.png) +![rxa-fixed-size--throttled](../../static/img/template/virtual-scrolling/rxa-fixed-size--throttled.png) This technique excels in keeping long tasks at a minimum and is especially helpful to render hefty components and/or supporting weak devices. It helps keeping the scrolling and bootstrap behavior buttery smooth. @@ -1134,9 +1134,9 @@ Comparison between [RxAngular FixedSizeVirtualScrollStrategy](#fixedsizevirtuals Both solutions do fine without throttling. But, the `CdkFixedSizeVirtualScroll` already struggles with the frame rate. We can already spot `partially presented frames`. Also, the javascript tasks are taking longer compared to the `RxAngular FixedSizeVirtualScrollStrategy`. -| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | -| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| ![rxa-fixed-size--unthrottled](../../../static/img/template/virtual-scrolling/rxa-fixed-size--unthrottled.png) | ![cdk-fixed-size--unthrottled](../../../static/img/template/virtual-scrolling/cdk-fixed-size--unthrottled.png) | +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| ![rxa-fixed-size--unthrottled](../../static/img/template/virtual-scrolling/rxa-fixed-size--unthrottled.png) | ![cdk-fixed-size--unthrottled](../../static/img/template/virtual-scrolling/cdk-fixed-size--unthrottled.png) | **4x CPU throttling** @@ -1144,9 +1144,9 @@ With throttling enabled, the `CdkFixedSizeVirtualScroll` already struggles a lot the amount of `partially presented frames` increases. The `RxAngular FixedSizeVirtualScrollStrategy` has no issues whatsoever keeping the frame rate above 30fps on 4x times throttling. -| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | -| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| ![rxa-fixed-size--throttled](../../../static/img/template/virtual-scrolling/rxa-fixed-size--throttled.png) | ![cdk-fixed-size--throttled](../../../static/img/template/virtual-scrolling/cdk-fixed-size--throttled.png) | +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| ![rxa-fixed-size--throttled](../../static/img/template/virtual-scrolling/rxa-fixed-size--throttled.png) | ![cdk-fixed-size--throttled](../../static/img/template/virtual-scrolling/cdk-fixed-size--throttled.png) | ### Dynamic Size Strategy @@ -1174,9 +1174,9 @@ correct position based on an index. Both solutions do fine without throttling. But, the `CDK AutoSizeVirtualScrollStrategy` struggles with the frame rate. We can already spot lots of `partially presented frames`. The `RxAngular DynamicSizeVirtualScrollStrategy` implementation easily maintains a stable framerate around 45fps. -| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | -| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| ![rxa-dynamic-size--unthrottled.png](../../../static/img/template/virtual-scrolling/rxa-dynamic-size--unthrottled.png) | ![cdk-autosize--unthrottled.png](../../../static/img/template/virtual-scrolling/cdk-autosize--unthrottled.png) | +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| ![rxa-dynamic-size--unthrottled.png](../../static/img/template/virtual-scrolling/rxa-dynamic-size--unthrottled.png) | ![cdk-autosize--unthrottled.png](../../static/img/template/virtual-scrolling/cdk-autosize--unthrottled.png) | **4x CPU throttling** @@ -1185,9 +1185,9 @@ the amount of `partially presented frames` increases. The `RxAngular DynamicSizeVirtualScrollStrategy` has no issues whatsoever keeping the frame rate above 30fps on 4x times throttling. The javascript execution time is still very low, the style recalculations and layouting phases are increasing, though. This will also depend very much on the actual use case. -| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | -| ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| ![rxa-dynamic-size--throttled.png](../../../static/img/template/virtual-scrolling/rxa-dynamic-size--throttled.png) | ![cdk-autosize--throttled.png](../../../static/img/template/virtual-scrolling/cdk-autosize--throttled.png) | +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| ![rxa-dynamic-size--throttled.png](../../static/img/template/virtual-scrolling/rxa-dynamic-size--throttled.png) | ![cdk-autosize--throttled.png](../../static/img/template/virtual-scrolling/cdk-autosize--throttled.png) | ### Autosize Strategy @@ -1218,9 +1218,9 @@ the scrolling performance benefits from this approach. Anyway, that's why we suc Nodes that were visited once are not queried again, scrolling the same path twice will differ in runtime performance. All consequent attempts should be as fast as the fixed or dynamic size implementations. -| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | -| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| ![rxa-autosize--unthrottled.png](../../../static/img/template/virtual-scrolling/rxa-autosize--unthrottled.png) | ![cdk-autosize--unthrottled.png](../../../static/img/template/virtual-scrolling/cdk-autosize--unthrottled.png) | +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| ![rxa-autosize--unthrottled.png](../../static/img/template/virtual-scrolling/rxa-autosize--unthrottled.png) | ![cdk-autosize--unthrottled.png](../../static/img/template/virtual-scrolling/cdk-autosize--unthrottled.png) | **4x CPU throttling** @@ -1229,9 +1229,9 @@ For the `CDK AutoSizeVirtualScrollStrategy`, the same is true as for the compari Even with 4x CPU throttling enabled, the `RxAngular AutoSizeVirtualScrollStrategy` keeps a reasonable frame rate and only sometimes produces partially presented frames. Thanks to the concurrent strategies, users will never encounter long tasks while scrolling. -| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | -| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| ![rxa-autosize--throttled.png](../../../static/img/template/virtual-scrolling/rxa-autosize--throttled.png) | ![cdk-autosize--throttled.png](../../../static/img/template/virtual-scrolling/cdk-autosize--throttled.png) | +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| ![rxa-autosize--throttled.png](../../static/img/template/virtual-scrolling/rxa-autosize--throttled.png) | ![cdk-autosize--throttled.png](../../static/img/template/virtual-scrolling/cdk-autosize--throttled.png) | ## Further Improvements diff --git a/apps/docs/docs/template/api/virtual-view-directive.mdx b/apps/docs/docs/template/virtual-view-directive.mdx similarity index 86% rename from apps/docs/docs/template/api/virtual-view-directive.mdx rename to apps/docs/docs/template/virtual-view-directive.mdx index 90ac1a374b..b224532f45 100644 --- a/apps/docs/docs/template/api/virtual-view-directive.mdx +++ b/apps/docs/docs/template/virtual-view-directive.mdx @@ -1,7 +1,7 @@ --- -sidebar_label: 'RxVirtualView' +sidebar_label: '🧪 RxVirtualView' sidebar_position: 7 -title: 'RxVirtualView' +title: '🧪 RxVirtualView' --- import Tabs from '@theme/Tabs'; @@ -33,7 +33,7 @@ This is true for: This is where the RxVirtualView directive comes in. It provides a simple way to only display the elements that are currently visible to the user. -![rx-virtual-view](../../../static/img/template/rx-virtual-view/rx-virtual-view.jpg) +![rx-virtual-view](../../static/img/template/rx-virtual-view/rx-virtual-view.jpg) ## Basic Usage @@ -46,6 +46,22 @@ RxVirtualView is designed to work in combination with related directives: ### Show a widget when it's visible, otherwise show a placeholder +```typescript +import { RxVirtualView, RxVirtualViewContent, RxVirtualViewObserver, RxVirtualViewPlaceholder } from '@rx-angular/template/virtual-view'; +// Other imports... + +@Component({ + selector: 'my-list', + imports: [RxVirtualView, RxVirtualViewContent, RxVirtualViewObserver, RxVirtualViewPlaceholder], + templateUrl: './my-list.component.html', + styleUrls: ['./my-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyListComponent { + // Component code +} +``` + ```html
@@ -82,6 +98,22 @@ This will make sure you don't run into stuttery scrolling behavior and layout sh This example demonstrates how to use RxVirtualView to optimize lists by only rendering the visible list items. We are only rendering the `item` component when it's visible to the user. Otherwise, it gets replaced by an empty div. +```typescript +import { RxVirtualView, RxVirtualViewContent, RxVirtualViewObserver, RxVirtualViewPlaceholder } from '@rx-angular/template/virtual-view'; +// Other imports... + +@Component({ + selector: 'my-list', + imports: [RxVirtualView, RxVirtualViewContent, RxVirtualViewObserver, RxVirtualViewPlaceholder], + templateUrl: './my-list.component.html', + styleUrls: ['./my-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyListComponent { + // Component code +} +``` + ```html
@for (item of items; track item.id) { @@ -112,8 +144,8 @@ We are only rendering the `item` component when it's visible to the user. Otherw | `keepLastKnownSize` | `boolean` | This will keep the last known size of the host element while the content is visible. It sets 'minHeight' to the host node | | `useContentVisibility` | `boolean` | It will add the `content-visibility` CSS class to the host element, together with `contain-intrinsic-width` and `contain-intrinsic-height` CSS properties. | | `useContainment` | `boolean` | It will add `contain` css property with:
- `size layout paint`: if `useContentVisibility` is `true` && placeholder is visible
- `content`: if `useContentVisibility` is `false` or content is visible | -| `placeholderStrategy` | `boolean` | The strategy to use for rendering the placeholder.
Defaults to: `low`
[Read more about strategies](../../cdk/render-strategies/strategies/concurrent-strategies) | -| `contentStrategy` | `boolean` | The strategy to use for rendering the content.
Defaults to: `normal`
[Read more about strategies](../../cdk/render-strategies/strategies/concurrent-strategies) | +| `placeholderStrategy` | `boolean` | The strategy to use for rendering the placeholder.
Defaults to: `low`
[Read more about strategies](../cdk/render-strategies/strategies/concurrent-strategies) | +| `contentStrategy` | `boolean` | The strategy to use for rendering the content.
Defaults to: `normal`
[Read more about strategies](../cdk/render-strategies/strategies/concurrent-strategies) | ### RxVirtualViewConfig diff --git a/apps/docs/static/img/template/rx-for/filter-experimental.mp4 b/apps/docs/static/img/template/rx-for/filter-experimental.mp4 new file mode 100644 index 0000000000..8a7f92a94e Binary files /dev/null and b/apps/docs/static/img/template/rx-for/filter-experimental.mp4 differ diff --git a/apps/docs/static/img/template/rx-for/filter-legacy.mp4 b/apps/docs/static/img/template/rx-for/filter-legacy.mp4 new file mode 100644 index 0000000000..d192333ab6 Binary files /dev/null and b/apps/docs/static/img/template/rx-for/filter-legacy.mp4 differ diff --git a/apps/docs/static/img/template/rx-for/shuffle-experimental.mp4 b/apps/docs/static/img/template/rx-for/shuffle-experimental.mp4 new file mode 100644 index 0000000000..5185de84f2 Binary files /dev/null and b/apps/docs/static/img/template/rx-for/shuffle-experimental.mp4 differ diff --git a/apps/docs/static/img/template/rx-for/shuffle-legacy.mp4 b/apps/docs/static/img/template/rx-for/shuffle-legacy.mp4 new file mode 100644 index 0000000000..b31a019927 Binary files /dev/null and b/apps/docs/static/img/template/rx-for/shuffle-legacy.mp4 differ diff --git a/apps/docs/static/img/template/rx-for/swap-experimental.mp4 b/apps/docs/static/img/template/rx-for/swap-experimental.mp4 new file mode 100644 index 0000000000..6d5bf3462f Binary files /dev/null and b/apps/docs/static/img/template/rx-for/swap-experimental.mp4 differ diff --git a/apps/docs/static/img/template/rx-for/swap-legacy.mp4 b/apps/docs/static/img/template/rx-for/swap-legacy.mp4 new file mode 100644 index 0000000000..1de8305735 Binary files /dev/null and b/apps/docs/static/img/template/rx-for/swap-legacy.mp4 differ diff --git a/apps/ssr-isr/tsconfig.json b/apps/ssr-isr/tsconfig.json index cd3727d6fb..4016e40f96 100644 --- a/apps/ssr-isr/tsconfig.json +++ b/apps/ssr-isr/tsconfig.json @@ -1,7 +1,5 @@ { "compilerOptions": { - "target": "es2022", - "useDefineForClassFields": false, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, diff --git a/apps/ssr/tsconfig.json b/apps/ssr/tsconfig.json index 3ba33047f0..d95ae3109a 100644 --- a/apps/ssr/tsconfig.json +++ b/apps/ssr/tsconfig.json @@ -15,8 +15,5 @@ { "path": "./cypress/tsconfig.json" } - ], - "compilerOptions": { - "target": "ES2020" - } + ] } diff --git a/libs/cdk/CHANGELOG.md b/libs/cdk/CHANGELOG.md index bbd03c3f50..f8f6c217b4 100644 --- a/libs/cdk/CHANGELOG.md +++ b/libs/cdk/CHANGELOG.md @@ -2,6 +2,21 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +# [19.1.0](https://github.com/rx-angular/rx-angular/compare/cdk@19.0.1...cdk@19.1.0) (2025-01-09) + + +### Bug Fixes + +* properly include files in tsconfig ([7d26e82](https://github.com/rx-angular/rx-angular/commit/7d26e8200b0e11449e2f1273893c2644eee506da)) + + +### Features + +* **cdk:** added provideRxRenderStrategies provider function ([5c78bbf](https://github.com/rx-angular/rx-angular/commit/5c78bbfea23237e079e3334fd6393d25794d1b1b)) +* **cdk:** introduce new reconciliation algorithm & RxLiveCollection ([568d8b1](https://github.com/rx-angular/rx-angular/commit/568d8b1e2024662305c8d9783264de6cd54c267b)) + + + ## [19.0.1](https://github.com/rx-angular/rx-angular/compare/cdk@19.0.0...cdk@19.0.1) (2024-12-23) diff --git a/libs/cdk/internals/scheduler/src/lib/scheduler-post-task.ts b/libs/cdk/internals/scheduler/src/lib/scheduler-post-task.ts new file mode 100644 index 0000000000..a33136fa9e --- /dev/null +++ b/libs/cdk/internals/scheduler/src/lib/scheduler-post-task.ts @@ -0,0 +1,220 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import { ɵglobal } from '@angular/core'; +import { PriorityLevel } from './schedulerPriorities'; + +declare class TaskController { + // @ts-ignore + constructor(options?: { priority?: string }): TaskController; + signal: unknown; + abort(): void; +} + +type PostTaskPriorityLevel = 'user-blocking' | 'user-visible' | 'background'; + +type CallbackNode = { + _controller: TaskController; +}; + +// Capture local references to native APIs, in case a polyfill overrides them. +const perf = window.performance; +const setTimeout = window.setTimeout; + +// Use experimental Chrome Scheduler postTask API. +const scheduler = ɵglobal.scheduler; + +const getCurrentTime: () => DOMHighResTimeStamp = perf.now.bind(perf); + +export const now = getCurrentTime; + +// Scheduler periodically yields in case there is other work on the main +// thread, like user events. By default, it yields multiple times per frame. +// It does not attempt to align with frame boundaries, since most tasks don't +// need to be frame aligned; for those that do, use requestAnimationFrame. +const yieldInterval = 5; +let deadline = 0; + +let currentPriorityLevel_DEPRECATED = PriorityLevel.NormalPriority; + +// Always yield at the end of the frame. +export function shouldYield(): boolean { + return getCurrentTime() >= deadline; +} + +export function requestPaint() { + // Since we yield every frame regardless, `requestPaint` has no effect. +} + +type SchedulerCallback = (didTimeout_DEPRECATED: boolean) => + | T + // May return a continuation + | SchedulerCallback; + +export function scheduleCallback( + priorityLevel: PriorityLevel, + callback: SchedulerCallback, + options?: { delay?: number }, +): CallbackNode { + let postTaskPriority; + switch (priorityLevel) { + case PriorityLevel.ImmediatePriority: + case PriorityLevel.UserBlockingPriority: + postTaskPriority = 'user-blocking'; + break; + case PriorityLevel.LowPriority: + case PriorityLevel.NormalPriority: + postTaskPriority = 'user-visible'; + break; + case PriorityLevel.IdlePriority: + postTaskPriority = 'background'; + break; + default: + postTaskPriority = 'user-visible'; + break; + } + + const controller = new TaskController({ priority: postTaskPriority }); + const postTaskOptions = { + delay: typeof options === 'object' && options !== null ? options.delay : 0, + signal: controller.signal, + }; + + const node = { + _controller: controller, + }; + + scheduler + .postTask( + runTask.bind(null, priorityLevel, postTaskPriority, node, callback), + postTaskOptions, + ) + .catch(handleAbortError); + + return node; +} + +function runTask( + priorityLevel: PriorityLevel, + postTaskPriority: PostTaskPriorityLevel, + node: CallbackNode, + callback: SchedulerCallback, +) { + deadline = getCurrentTime() + yieldInterval; + try { + currentPriorityLevel_DEPRECATED = priorityLevel; + const didTimeout_DEPRECATED = false; + const result = callback(didTimeout_DEPRECATED); + if (typeof result === 'function') { + // Assume this is a continuation + const continuation: SchedulerCallback = (result: any) => result; + const continuationOptions = { + signal: node._controller.signal, + }; + + const nextTask = runTask.bind( + null, + priorityLevel, + postTaskPriority, + node, + continuation, + ); + + if (scheduler.yield !== undefined) { + scheduler + .yield(continuationOptions) + .then(nextTask) + .catch(handleAbortError); + } else { + scheduler + .postTask(nextTask, continuationOptions) + .catch(handleAbortError); + } + } + } catch (error) { + // We're inside a `postTask` promise. If we don't handle this error, then it + // will trigger an "Unhandled promise rejection" error. We don't want that, + // but we do want the default error reporting behavior that normal + // (non-Promise) tasks get for unhandled errors. + // + // So we'll re-throw the error inside a regular browser task. + setTimeout(() => { + throw error; + }); + } finally { + currentPriorityLevel_DEPRECATED = PriorityLevel.NormalPriority; + } +} + +function handleAbortError(error: any) { + // Abort errors are an implementation detail. We don't expose the + // TaskController to the user, nor do we expose the promise that is returned + // from `postTask`. So we should suppress them, since there's no way for the + // user to handle them. +} + +export function cancelCallback(node: CallbackNode) { + const controller = node._controller; + controller.abort(); +} + +export function runWithPriority( + priorityLevel: PriorityLevel, + callback: () => T, +): T { + const previousPriorityLevel = currentPriorityLevel_DEPRECATED; + currentPriorityLevel_DEPRECATED = priorityLevel; + try { + return callback(); + } finally { + currentPriorityLevel_DEPRECATED = previousPriorityLevel; + } +} + +export function getCurrentPriorityLevel(): PriorityLevel { + return currentPriorityLevel_DEPRECATED; +} + +export function next(callback: () => T): T { + let priorityLevel; + switch (currentPriorityLevel_DEPRECATED) { + case PriorityLevel.ImmediatePriority: + case PriorityLevel.UserBlockingPriority: + case PriorityLevel.NormalPriority: + // Shift down to normal priority + priorityLevel = PriorityLevel.NormalPriority; + break; + default: + // Anything lower than normal priority should remain at the current level. + priorityLevel = currentPriorityLevel_DEPRECATED; + break; + } + + const previousPriorityLevel = currentPriorityLevel_DEPRECATED; + currentPriorityLevel_DEPRECATED = priorityLevel; + try { + return callback(); + } finally { + currentPriorityLevel_DEPRECATED = previousPriorityLevel; + } +} + +export function wrapCallback(callback: () => T): () => T { + const parentPriorityLevel = currentPriorityLevel_DEPRECATED; + return () => { + const previousPriorityLevel = currentPriorityLevel_DEPRECATED; + currentPriorityLevel_DEPRECATED = parentPriorityLevel; + try { + return callback(); + } finally { + currentPriorityLevel_DEPRECATED = previousPriorityLevel; + } + }; +} + +export function forceFrameRate() {} diff --git a/libs/cdk/package.json b/libs/cdk/package.json index 11171abbb7..d459aa2f43 100644 --- a/libs/cdk/package.json +++ b/libs/cdk/package.json @@ -1,6 +1,6 @@ { "name": "@rx-angular/cdk", - "version": "19.0.1", + "version": "19.1.0", "description": "@rx-angular/cdk is a Component Development Kit for ergonomic and highly performant angular applications. It helps to to build Large scale applications, UI libs, state management, rendering systems and much more. Furthermore the unique way of mixing reactive as well as imperative code leads to best DX and speed.", "publishConfig": { "access": "public" diff --git a/libs/cdk/render-strategies/src/index.ts b/libs/cdk/render-strategies/src/index.ts index bbd24cc264..d8f1d498ef 100644 --- a/libs/cdk/render-strategies/src/index.ts +++ b/libs/cdk/render-strategies/src/index.ts @@ -3,6 +3,7 @@ export { RxConcurrentStrategies, } from './lib/concurrent-strategies'; export { + provideRxRenderStrategies, RX_RENDER_STRATEGIES_CONFIG, RxRenderStrategiesConfig, } from './lib/config'; diff --git a/libs/cdk/render-strategies/src/lib/config.ts b/libs/cdk/render-strategies/src/lib/config.ts index 0b60fdd295..e898ba4dd5 100644 --- a/libs/cdk/render-strategies/src/lib/config.ts +++ b/libs/cdk/render-strategies/src/lib/config.ts @@ -1,4 +1,4 @@ -import { InjectionToken } from '@angular/core'; +import { InjectionToken, Provider } from '@angular/core'; import { RX_CONCURRENT_STRATEGIES } from './concurrent-strategies'; import { RxCustomStrategyCredentials, @@ -38,9 +38,7 @@ export function mergeDefaultConfig( ): Required> { const custom: RxRenderStrategiesConfig = cfg ? cfg - : ({ - customStrategies: {}, - } as any); + : ({ customStrategies: {} } as any); return { ...RX_RENDER_STRATEGIES_DEFAULTS, ...custom, @@ -50,3 +48,49 @@ export function mergeDefaultConfig( }, }; } + +/** + * @description + * Can be used to set the default render strategy or create custom render strategies. + * + * With this function you can customize the behavior of: + * - `rxLet` directive + * - `rxFor` directive + * - `rxIf` directive + * - `rxVirtualFor` directive + * - `rxVirtualView` directive + * - `RxStrategyProvider` service. + * + * @example + * import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; + * + * const appConfig: ApplicationConfig = { + * providers: [ + * provideRxRenderStrategies({ + * primaryStrategy: 'sync', + * customStrategies: { + * sync: { + * name: 'sync', + * work: (cdRef) => { cdRef.detectChanges(); }, + * behavior: ({ work }) => (o$) => o$.pipe(tap(() => work())) + * }, + * asap: { + * name: 'asap', + * work: (cdRef) => { cdRef.detectChanges(); }, + * behavior: ({ work }) => (o$) => o$.pipe(delay(0, asapScheduler), tap(() => work())) + * }, + * }), + * ], + * }; + * + * @param config - The configuration object. + * @returns A provider that can be used to set the default render strategy or create custom render strategies. + */ +export function provideRxRenderStrategies( + config: RxRenderStrategiesConfig, +): Provider { + return { + provide: RX_RENDER_STRATEGIES_CONFIG, + useValue: mergeDefaultConfig(config), + } satisfies Provider; +} diff --git a/libs/cdk/template/spec/list-manager.spec.ts b/libs/cdk/template/spec/list-manager.spec.ts index 4d3398383a..0d90d7a7a2 100644 --- a/libs/cdk/template/spec/list-manager.spec.ts +++ b/libs/cdk/template/spec/list-manager.spec.ts @@ -13,7 +13,7 @@ import { ViewContainerRef, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { RxStrategyProvider } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { ReplaySubject } from 'rxjs'; @@ -141,12 +141,7 @@ const setupListManagerComponent = (): void => { providers: [ { provide: ErrorHandler, useValue: customErrorHandler }, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/cdk/template/spec/template-manager.spec.ts b/libs/cdk/template/spec/template-manager.spec.ts index d70538f1b5..3cbc9a4784 100644 --- a/libs/cdk/template/spec/template-manager.spec.ts +++ b/libs/cdk/template/spec/template-manager.spec.ts @@ -1,10 +1,10 @@ import { ErrorHandler, TemplateRef, ViewContainerRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RxNotificationKind } from '@rx-angular/cdk/notifications'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { of, ReplaySubject, throwError } from 'rxjs'; import { tap } from 'rxjs/operators'; +import { provideRxRenderStrategies } from '../../render-strategies/src'; import { RxTemplateManager } from '../src/lib/template-manager'; import { createTestComponent, @@ -32,22 +32,19 @@ const setupTemplateManagerComponent = (template = DEFAULT_TEMPLATE): void => { providers: [ { provide: ErrorHandler, useValue: customErrorHandler }, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'test', - customStrategies: { - test: { - name: 'test', - work: (cdRef) => cdRef.detectChanges(), - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), - }, + provideRxRenderStrategies({ + primaryStrategy: 'test', + customStrategies: { + test: { + name: 'test', + work: (cdRef) => cdRef.detectChanges(), + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/cdk/template/src/index.ts b/libs/cdk/template/src/index.ts index 3034867d2e..b039145624 100644 --- a/libs/cdk/template/src/index.ts +++ b/libs/cdk/template/src/index.ts @@ -1,3 +1,4 @@ +export { LiveCollection, reconcile } from './lib/list-reconciliation'; export { createListTemplateManager, RxListManager, @@ -13,6 +14,7 @@ export { RxRenderAware, RxViewContext, } from './lib/model'; +export { RxLiveCollection } from './lib/rx-live-collection'; export { createTemplateManager, RxNotificationTemplateNameMap, diff --git a/libs/cdk/template/src/lib/list-reconciliation.ts b/libs/cdk/template/src/lib/list-reconciliation.ts new file mode 100644 index 0000000000..92a23685fc --- /dev/null +++ b/libs/cdk/template/src/lib/list-reconciliation.ts @@ -0,0 +1,506 @@ +// copied from https://github.com/angular/angular/blob/main/packages/core/src/render3/list_reconciliation.ts + +import { TrackByFunction } from '@angular/core'; + +/** + * @description Will be provided through Terser global definitions by Angular CLI + * during the production build. + */ +declare const ngDevMode: boolean; + +/** + * A type representing the live collection to be reconciled with any new (incoming) collection. This + * is an adapter class that makes it possible to work with different internal data structures, + * regardless of the actual values of the incoming collection. + */ +export abstract class LiveCollection { + abstract get length(): number; + abstract at(index: number): V; + abstract attach(index: number, item: T): void; + abstract detach(index: number): T; + abstract create(index: number, value: V): T; + destroy(item: T): void { + // noop by default + } + updateValue(index: number, value: V): void { + // noop by default + } + + // operations below could be implemented on top of the operations defined so far, but having + // them explicitly allow clear expression of intent and potentially more performant + // implementations + swap(index1: number, index2: number): void { + const startIdx = Math.min(index1, index2); + const endIdx = Math.max(index1, index2); + const endItem = this.detach(endIdx); + if (endIdx - startIdx > 1) { + const startItem = this.detach(startIdx); + this.attach(startIdx, endItem); + this.attach(endIdx, startItem); + } else { + this.attach(startIdx, endItem); + } + } + move(prevIndex: number, newIdx: number): void { + this.attach(newIdx, this.detach(prevIndex)); + } +} + +function valuesMatching( + liveIdx: number, + liveValue: V, + newIdx: number, + newValue: V, + trackBy: TrackByFunction, +): number { + if (liveIdx === newIdx && Object.is(liveValue, newValue)) { + // matching and no value identity to update + return 1; + } else if ( + Object.is(trackBy(liveIdx, liveValue), trackBy(newIdx, newValue)) + ) { + // matching but requires value identity update + return -1; + } + + return 0; +} + +function recordDuplicateKeys( + keyToIdx: Map>, + key: unknown, + idx: number, +): void { + const idxSoFar = keyToIdx.get(key); + + if (idxSoFar !== undefined) { + idxSoFar.add(idx); + } else { + keyToIdx.set(key, new Set([idx])); + } +} + +/** + * The live collection reconciliation algorithm that perform various in-place operations, so it + * reflects the content of the new (incoming) collection. + * + * The reconciliation algorithm has 2 code paths: + * - "fast" path that don't require any memory allocation; + * - "slow" path that requires additional memory allocation for intermediate data structures used to + * collect additional information about the live collection. + * It might happen that the algorithm switches between the two modes in question in a single + * reconciliation path - generally it tries to stay on the "fast" path as much as possible. + * + * The overall complexity of the algorithm is O(n + m) for speed and O(n) for memory (where n is the + * length of the live collection and m is the length of the incoming collection). Given the problem + * at hand the complexity / performance constraints makes it impossible to perform the absolute + * minimum of operation to reconcile the 2 collections. The algorithm makes different tradeoffs to + * stay within reasonable performance bounds and may apply sub-optimal number of operations in + * certain situations. + * + * @param liveCollection the current, live collection; + * @param newCollection the new, incoming collection; + * @param trackByFn key generation function that determines equality between items in the life and + * incoming collection; + */ +export function reconcile( + liveCollection: LiveCollection, + newCollection: Iterable | undefined | null, + trackByFn: TrackByFunction, +): void { + let detachedItems: UniqueValueMultiKeyMap | undefined = undefined; + let liveKeysInTheFuture: Set | undefined = undefined; + + let liveStartIdx = 0; + let liveEndIdx = liveCollection.length - 1; + + const duplicateKeys = ngDevMode ? new Map>() : undefined; + + if (Array.isArray(newCollection)) { + let newEndIdx = newCollection.length - 1; + + while (liveStartIdx <= liveEndIdx && liveStartIdx <= newEndIdx) { + // compare from the beginning + const liveStartValue = liveCollection.at(liveStartIdx); + const newStartValue = newCollection[liveStartIdx]; + + if (ngDevMode) { + recordDuplicateKeys( + duplicateKeys!, + trackByFn(liveStartIdx, newStartValue), + liveStartIdx, + ); + } + + const isStartMatching = valuesMatching( + liveStartIdx, + liveStartValue, + liveStartIdx, + newStartValue, + trackByFn, + ); + if (isStartMatching !== 0) { + if (isStartMatching < 0) { + liveCollection.updateValue(liveStartIdx, newStartValue); + } + liveStartIdx++; + continue; + } + + // compare from the end + // TODO(perf): do _all_ the matching from the end + const liveEndValue = liveCollection.at(liveEndIdx); + const newEndValue = newCollection[newEndIdx]; + + if (ngDevMode) { + recordDuplicateKeys( + duplicateKeys!, + trackByFn(newEndIdx, newEndValue), + newEndIdx, + ); + } + + const isEndMatching = valuesMatching( + liveEndIdx, + liveEndValue, + newEndIdx, + newEndValue, + trackByFn, + ); + if (isEndMatching !== 0) { + if (isEndMatching < 0) { + liveCollection.updateValue(liveEndIdx, newEndValue); + } + liveEndIdx--; + newEndIdx--; + continue; + } + + // Detect swap and moves: + const liveStartKey = trackByFn(liveStartIdx, liveStartValue); + const liveEndKey = trackByFn(liveEndIdx, liveEndValue); + const newStartKey = trackByFn(liveStartIdx, newStartValue); + if (Object.is(newStartKey, liveEndKey)) { + const newEndKey = trackByFn(newEndIdx, newEndValue); + // detect swap on both ends; + if (Object.is(newEndKey, liveStartKey)) { + liveCollection.swap(liveStartIdx, liveEndIdx); + liveCollection.updateValue(liveEndIdx, newEndValue); + newEndIdx--; + liveEndIdx--; + } else { + // the new item is the same as the live item with the end pointer - this is a move forward + // to an earlier index; + liveCollection.move(liveEndIdx, liveStartIdx); + } + liveCollection.updateValue(liveStartIdx, newStartValue); + liveStartIdx++; + continue; + } + + // Fallback to the slow path: we need to learn more about the content of the live and new + // collections. + detachedItems ??= new UniqueValueMultiKeyMap(); + liveKeysInTheFuture ??= initLiveItemsInTheFuture( + liveCollection, + liveStartIdx, + liveEndIdx, + trackByFn, + ); + + // Check if I'm inserting a previously detached item: if so, attach it here + if ( + attachPreviouslyDetached( + liveCollection, + detachedItems, + liveStartIdx, + newStartKey, + ) + ) { + liveCollection.updateValue(liveStartIdx, newStartValue); + liveStartIdx++; + liveEndIdx++; + } else if (!liveKeysInTheFuture.has(newStartKey)) { + // Check if we seen a new item that doesn't exist in the old collection and must be INSERTED + const newItem = liveCollection.create( + liveStartIdx, + newCollection[liveStartIdx], + ); + liveCollection.attach(liveStartIdx, newItem); + liveStartIdx++; + liveEndIdx++; + } else { + // We know that the new item exists later on in old collection but we don't know its index + // and as the consequence can't move it (don't know where to find it). Detach the old item, + // hoping that it unlocks the fast path again. + detachedItems.set(liveStartKey, liveCollection.detach(liveStartIdx)); + liveEndIdx--; + } + } + + // Final cleanup steps: + // - more items in the new collection => insert + while (liveStartIdx <= newEndIdx) { + createOrAttach( + liveCollection, + detachedItems, + trackByFn, + liveStartIdx, + newCollection[liveStartIdx], + ); + liveStartIdx++; + } + } else if (newCollection != null) { + // iterable - immediately fallback to the slow path + const newCollectionIterator = newCollection[Symbol.iterator](); + let newIterationResult = newCollectionIterator.next(); + while (!newIterationResult.done && liveStartIdx <= liveEndIdx) { + const liveValue = liveCollection.at(liveStartIdx); + const newValue = newIterationResult.value; + + if (ngDevMode) { + recordDuplicateKeys( + duplicateKeys!, + trackByFn(liveStartIdx, newValue), + liveStartIdx, + ); + } + + const isStartMatching = valuesMatching( + liveStartIdx, + liveValue, + liveStartIdx, + newValue, + trackByFn, + ); + if (isStartMatching !== 0) { + // found a match - move on, but update value + if (isStartMatching < 0) { + liveCollection.updateValue(liveStartIdx, newValue); + } + liveStartIdx++; + newIterationResult = newCollectionIterator.next(); + } else { + detachedItems ??= new UniqueValueMultiKeyMap(); + liveKeysInTheFuture ??= initLiveItemsInTheFuture( + liveCollection, + liveStartIdx, + liveEndIdx, + trackByFn, + ); + + // Check if I'm inserting a previously detached item: if so, attach it here + const newKey = trackByFn(liveStartIdx, newValue); + if ( + attachPreviouslyDetached( + liveCollection, + detachedItems, + liveStartIdx, + newKey, + ) + ) { + liveCollection.updateValue(liveStartIdx, newValue); + liveStartIdx++; + liveEndIdx++; + newIterationResult = newCollectionIterator.next(); + } else if (!liveKeysInTheFuture.has(newKey)) { + liveCollection.attach( + liveStartIdx, + liveCollection.create(liveStartIdx, newValue), + ); + liveStartIdx++; + liveEndIdx++; + newIterationResult = newCollectionIterator.next(); + } else { + // it is a move forward - detach the current item without advancing in collections + const liveKey = trackByFn(liveStartIdx, liveValue); + detachedItems.set(liveKey, liveCollection.detach(liveStartIdx)); + liveEndIdx--; + } + } + } + + // this is a new item as we run out of the items in the old collection - create or attach a + // previously detached one + while (!newIterationResult.done) { + createOrAttach( + liveCollection, + detachedItems, + trackByFn, + liveCollection.length, + newIterationResult.value, + ); + newIterationResult = newCollectionIterator.next(); + } + } + + // Cleanups common to the array and iterable: + // - more items in the live collection => delete starting from the end; + while (liveStartIdx <= liveEndIdx) { + liveCollection.destroy(liveCollection.detach(liveEndIdx--)); + } + + // - destroy items that were detached but never attached again. + detachedItems?.forEach((item) => { + liveCollection.destroy(item); + }); + + // report duplicate keys (dev mode only) + if (ngDevMode) { + const duplicatedKeysMsg = []; + for (const [key, idxSet] of duplicateKeys!) { + if (idxSet.size > 1) { + const idx = [...idxSet].sort((a, b) => a - b); + for (let i = 1; i < idx.length; i++) { + duplicatedKeysMsg.push( + `key "${key}" at index "${idx[i - 1]}" and "${idx[i]}"`, + ); + } + } + } + + if (duplicatedKeysMsg.length > 0) { + const message = + 'The provided track expression resulted in duplicated keys for a given collection. ' + + 'Adjust the tracking expression such that it uniquely identifies all the items in the collection. ' + + 'Duplicated keys were: \n' + + duplicatedKeysMsg.join(', \n') + + '.'; + + // tslint:disable-next-line:no-console + console.warn(message); + } + } +} + +function attachPreviouslyDetached( + prevCollection: LiveCollection, + detachedItems: UniqueValueMultiKeyMap | undefined, + index: number, + key: unknown, +): boolean { + if (detachedItems !== undefined && detachedItems.has(key)) { + prevCollection.attach(index, detachedItems.get(key)!); + detachedItems.delete(key); + return true; + } + return false; +} + +function createOrAttach( + liveCollection: LiveCollection, + detachedItems: UniqueValueMultiKeyMap | undefined, + trackByFn: TrackByFunction, + index: number, + value: V, +) { + if ( + !attachPreviouslyDetached( + liveCollection, + detachedItems, + index, + trackByFn(index, value), + ) + ) { + const newItem = liveCollection.create(index, value); + liveCollection.attach(index, newItem); + } else { + liveCollection.updateValue(index, value); + } +} + +function initLiveItemsInTheFuture( + liveCollection: LiveCollection, + start: number, + end: number, + trackByFn: TrackByFunction, +): Set { + const keys = new Set(); + for (let i = start; i <= end; i++) { + keys.add(trackByFn(i, liveCollection.at(i))); + } + return keys; +} + +/** + * A specific, partial implementation of the Map interface with the following characteristics: + * - allows multiple values for a given key; + * - maintain FIFO order for multiple values corresponding to a given key; + * - assumes that all values are unique. + * + * The implementation aims at having the minimal overhead for cases where keys are _not_ duplicated + * (the most common case in the list reconciliation algorithm). To achieve this, the first value for + * a given key is stored in a regular map. Then, when more values are set for a given key, we + * maintain a form of linked list in a separate map. To maintain this linked list we assume that all + * values (in the entire collection) are unique. + */ +export class UniqueValueMultiKeyMap { + // A map from a key to the first value corresponding to this key. + private kvMap = new Map(); + // A map that acts as a linked list of values - each value maps to the next value in this "linked + // list" (this only works if values are unique). Allocated lazily to avoid memory consumption when + // there are no duplicated values. + private _vMap: Map | undefined = undefined; + + has(key: K): boolean { + return this.kvMap.has(key); + } + + delete(key: K): boolean { + if (!this.has(key)) return false; + + const value = this.kvMap.get(key)!; + if (this._vMap !== undefined && this._vMap.has(value)) { + this.kvMap.set(key, this._vMap.get(value)!); + this._vMap.delete(value); + } else { + this.kvMap.delete(key); + } + + return true; + } + + get(key: K): V | undefined { + return this.kvMap.get(key); + } + + set(key: K, value: V): void { + if (this.kvMap.has(key)) { + let prevValue = this.kvMap.get(key)!; + + // Note: we don't use `assertNotSame`, because the value needs to be stringified even if + // there is no error which can freeze the browser for large values (see #58509). + /*if (ngDevMode && prevValue === value) { + throw new Error( + `Detected a duplicated value ${value} for the key ${key}`, + ); + }*/ + + if (this._vMap === undefined) { + this._vMap = new Map(); + } + + const vMap = this._vMap; + while (vMap.has(prevValue)) { + prevValue = vMap.get(prevValue)!; + } + vMap.set(prevValue, value); + } else { + this.kvMap.set(key, value); + } + } + + forEach(cb: (v: V, k: K) => void) { + // eslint-disable-next-line prefer-const + for (let [key, value] of this.kvMap) { + cb(value, key); + if (this._vMap !== undefined) { + const vMap = this._vMap; + while (vMap.has(value)) { + value = vMap.get(value)!; + cb(value, key); + } + } + } + } +} diff --git a/libs/cdk/template/src/lib/list-template-manager.ts b/libs/cdk/template/src/lib/list-template-manager.ts index 5f55f460ab..7d9bb2b799 100644 --- a/libs/cdk/template/src/lib/list-template-manager.ts +++ b/libs/cdk/template/src/lib/list-template-manager.ts @@ -43,7 +43,7 @@ export interface RxListManager { export function createListTemplateManager< T, - C extends RxListViewContext + C extends RxListViewContext, >(config: { renderSettings: RxRenderSettings; templateSettings: RxListTemplateSettings & { @@ -92,7 +92,7 @@ export function createListTemplateManager< strategyHandling$.next(nextConfig); }, render( - values$: Observable> + values$: Observable>, ): Observable | null> { return values$.pipe(render()); }, @@ -105,7 +105,7 @@ export function createListTemplateManager< partiallyFinished = false; errorHandler.handleError(err); return of(null); - }) + }), ); } @@ -116,24 +116,30 @@ export function createListTemplateManager< strategyHandling$.strategy$.pipe(distinctUntilChanged()), ]).pipe( map(([iterable, strategy]) => { - const differ = getDiffer(iterable); - let changes: IterableChanges; - if (differ) { - if (partiallyFinished) { - const currentIterable = []; - for (let i = 0, ilen = viewContainerRef.length; i < ilen; i++) { - const viewRef = >viewContainerRef.get(i); - currentIterable[i] = viewRef.context.$implicit; + try { + const differ = getDiffer(iterable); + let changes: IterableChanges; + if (differ) { + if (partiallyFinished) { + const currentIterable = []; + for (let i = 0, ilen = viewContainerRef.length; i < ilen; i++) { + const viewRef = >viewContainerRef.get(i); + currentIterable[i] = viewRef.context.$implicit; + } + differ.diff(currentIterable); } - differ.diff(currentIterable); + changes = differ.diff(iterable); } - changes = differ.diff(iterable); + return { + changes, + iterable, + strategy, + }; + } catch { + throw new Error( + `Error trying to diff '${iterable}'. Only arrays and iterables are allowed`, + ); } - return { - changes, - iterable, - strategy, - }; }), // Cancel old renders switchMap(({ changes, iterable, strategy }) => { @@ -149,25 +155,25 @@ export function createListTemplateManager< const applyChanges$ = getObservablesFromChangesArray( changesArr, strategy, - items.length + items.length, ); partiallyFinished = true; notifyParent = insertedOrRemoved && parent; return combineLatest( - applyChanges$.length > 0 ? applyChanges$ : [of(null)] + applyChanges$.length > 0 ? applyChanges$ : [of(null)], ).pipe( tap(() => (partiallyFinished = false)), notifyAllParentsIfNeeded( injectingViewCdRef, strategy, () => notifyParent, - ngZone + ngZone, ), handleError(), - map(() => iterable) + map(() => iterable), ); }), - handleError() + handleError(), ); } @@ -185,7 +191,7 @@ export function createListTemplateManager< function getObservablesFromChangesArray( changes: RxListTemplateChange[], strategy: RxStrategyCredentials, - count: number + count: number, ): Observable[] { return changes.length > 0 ? changes.map((change) => { @@ -203,7 +209,7 @@ export function createListTemplateManager< payload[2], payload[0], payload[1], - count + count, ); break; case RxListTemplateChangeType.remove: @@ -216,12 +222,12 @@ export function createListTemplateManager< listViewHandler.updateUnchangedContext( payload[0], payload[1], - count + count, ); break; } }, - { ngZone } + { ngZone }, ); }) : [of(null)]; diff --git a/libs/cdk/template/src/lib/rx-live-collection.ts b/libs/cdk/template/src/lib/rx-live-collection.ts new file mode 100644 index 0000000000..dea385bd19 --- /dev/null +++ b/libs/cdk/template/src/lib/rx-live-collection.ts @@ -0,0 +1,332 @@ +import { + EmbeddedViewRef, + NgZone, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { + onStrategy, + RxStrategyNames, + RxStrategyProvider, +} from '@rx-angular/cdk/render-strategies'; +import { combineLatest } from 'rxjs'; +import { LiveCollection } from './list-reconciliation'; +import { + RxDefaultListViewContext, + RxListViewComputedContext, +} from './list-view-context'; + +type View = EmbeddedViewRef> & { + _tempView?: boolean; +}; + +class WorkQueue { + private queue = new Map< + T, + { + work: () => View; + type: 'attach' | 'detach' | 'remove' | 'update'; + order: number; + }[] + >(); + + private length = 0; + + constructor(private strategyProvider: RxStrategyProvider) {} + + patch( + view: T, + data: { + work: () => View | undefined; + type: 'attach' | 'detach' | 'remove' | 'update'; + }, + ) { + if (this.queue.has(view)) { + const entries = this.queue.get(view); + const lastEntry = entries[entries.length - 1]; + /*console.log( + 'patch I has a work in queue', + data.type, + this.queue.get(view).map((w) => w.type), + );*/ + const work = lastEntry.work; + lastEntry.work = () => { + const view = work(); + const view2 = data.work(); + return view ?? view2; + }; + } else { + this.set(view, data); + } + } + + override( + view: T, + data: { + work: () => View | undefined; + type: 'attach' | 'detach' | 'remove' | 'update'; + }, + ) { + if (this.queue.has(view)) { + const entries = this.queue.get(view); + const lastEntry = entries[entries.length - 1]; + this.queue.set(view, [ + { + work: data.work, + type: 'remove', + order: lastEntry.order, + }, + ]); + } else { + this.set(view, data); + } + } + + set( + view: T, + data: { + work: () => View | undefined; + type: 'attach' | 'detach' | 'remove' | 'update'; + }, + ) { + if (this.queue.has(view)) { + /* console.log( + 'I has a work in queue', + data.type, + this.queue.get(view).map((w) => w.type), + );*/ + this.queue + .get(view) + .push({ work: data.work, type: data.type, order: this.length++ }); + } else { + this.queue.set(view, [ + { work: data.work, type: data.type, order: this.length++ }, + ]); + } + } + + flush(strategy: RxStrategyNames, ngZone?: NgZone) { + // console.log('operations', this.length); + return combineLatest( + Array.from(this.queue.values()) + .flatMap((entry) => entry) + .sort((a, b) => a.order - b.order) + .map(({ work }) => { + // console.log('operation', type); + return onStrategy( + null, + this.strategyProvider.strategies[strategy], + () => { + // console.log('exec order', order, type); + const view = work(); + view?.detectChanges(); + }, + { ngZone }, + ); + }), + ); + } + + clear() { + this.queue.clear(); + this.length = 0; + } +} + +export class RxLiveCollection extends LiveCollection, T> { + /** + Property indicating if indexes in the repeater context need to be updated following the live + collection changes. Index updates are necessary if and only if views are inserted / removed in + the middle of LContainer. Adds and removals at the end don't require index updates. + */ + private needsIndexUpdate = false; + private _needHostUpdate = false; + private set needHostUpdate(needHostUpdate: boolean) { + this._needHostUpdate = needHostUpdate; + } + get needHostUpdate() { + return this._needHostUpdate; + } + private lastCount: number | undefined = undefined; + private workQueue = new WorkQueue(this.strategyProvider); + private _virtualViews: View[]; + + constructor( + private viewContainer: ViewContainerRef, + private templateRef: TemplateRef<{ $implicit: unknown; index: number }>, + private strategyProvider: RxStrategyProvider, + private createViewContext: ( + item: T, + context: RxListViewComputedContext, + ) => RxDefaultListViewContext, + private updateViewContext: ( + item: T, + view: View, + context: RxListViewComputedContext, + ) => void, + ) { + super(); + } + + flushQueue(strategy: RxStrategyNames, ngZone?: NgZone) { + return this.workQueue.flush(strategy, ngZone); + } + + override get length(): number { + return this._virtualViews.length; + } + override at(index: number): T { + // console.log('live-coll: at', { index }); + return this.getView(index).context.$implicit; + } + override attach(index: number, view: View): void { + this.needsIndexUpdate ||= index !== this.length; + this.needHostUpdate = true; + + addToArray(this._virtualViews, index, view); + // console.log('live-coll: attach', { index, existingWork }); + this.workQueue.set(view.context.$implicit, { + work: () => { + return this.attachView(view, index); + }, + type: 'attach', + }); + } + private attachView(view: View, index: number): View { + if (view._tempView) { + // fake view + return (this._virtualViews[index] = >( + this.viewContainer.createEmbeddedView( + this.templateRef, + this.createViewContext(view.context.$implicit, { + index, + count: this.length, + }), + { index }, + ) + )); + } + // TODO: this is only here because at the time of `create` we don't have information about the count yet + this.updateViewContext(view.context.$implicit, view, { + index, + count: this.length, + }); + return >this.viewContainer.insert(view, index); + } + override detach(index: number) { + this.needsIndexUpdate ||= index !== this.length - 1; + const detachedView = removeFromArray(this._virtualViews, index); + // console.log('live-coll: detach', { index, existingWork }); + this.workQueue.set(detachedView.context.$implicit, { + work: () => { + // return undefined, to prevent `.detectChanges` being called + return this.detachView(index); + }, + type: 'detach', + }); + + return detachedView; + } + private detachView(index: number) { + this.viewContainer.detach(index); + return undefined; + } + + override create(index: number, value: T) { + // console.log('live-coll: create', { index, value }); + // only create a fake EmbeddedView + return >{ + context: { $implicit: value, index }, + _tempView: true, + }; + } + + override destroy(view: View): void { + // console.log('live-coll: destroy', { existingWork }); + this.needHostUpdate = true; + this.workQueue.override(view.context.$implicit, { + work: () => { + this.destroyView(view); + // return undefined, to prevent `.detectChanges` being called + return undefined; + }, + type: 'remove', + }); + } + private destroyView(view: View): View { + view.destroy(); + return view; + } + override updateValue(index: number, value: T): void { + const view = this.getView(index); + // console.log('live-coll: updateValue', { index, value, existingWork }); + this.workQueue.patch(view.context.$implicit, { + work: () => { + return this.updateView(value, index, view); + }, + type: 'update', + }); + } + + private updateView(value: T, index: number, view: View): View { + this.updateViewContext(value, view, { index, count: this.length }); + return view; + } + + reset() { + this._virtualViews = []; + this.workQueue.clear(); + for (let i = 0; i < this.viewContainer.length; i++) { + this._virtualViews[i] = this.viewContainer.get(i) as View; + } + this.needsIndexUpdate = false; + this.needHostUpdate = false; + } + + updateIndexes() { + const count = this.length; + if ( + this.needsIndexUpdate || + (this.lastCount !== undefined && this.lastCount !== count) + ) { + // console.log('live-coll: updateIndexes'); + for (let i = 0; i < count; i++) { + const view = this.getView(i); + this.workQueue.patch(view.context.$implicit, { + work: () => { + const v = this.getView(i); + if (v.context.index !== i || v.context.count !== count) { + return this.updateView(v.context.$implicit, i, v); + } + }, + type: 'update', + }); + } + } + this.lastCount = count; + } + + private getView(index: number) { + return ( + this._virtualViews[index] ?? (this.viewContainer.get(index) as View) + ); + } +} + +function addToArray(arr: any[], index: number, value: any): void { + // perf: array.push is faster than array.splice! + if (index >= arr.length) { + arr.push(value); + } else { + arr.splice(index, 0, value); + } +} + +function removeFromArray(arr: T[], index: number): T { + // perf: array.pop is faster than array.splice! + if (index >= arr.length - 1) { + return arr.pop(); + } else { + return arr.splice(index, 1)[0]; + } +} diff --git a/libs/cdk/tsconfig.json b/libs/cdk/tsconfig.json index 03261df5a4..62ebbd9464 100644 --- a/libs/cdk/tsconfig.json +++ b/libs/cdk/tsconfig.json @@ -9,8 +9,5 @@ { "path": "./tsconfig.spec.json" } - ], - "compilerOptions": { - "target": "es2020" - } + ] } diff --git a/libs/cdk/tsconfig.lib.json b/libs/cdk/tsconfig.lib.json index 376bef2c70..322a6ec41d 100644 --- a/libs/cdk/tsconfig.lib.json +++ b/libs/cdk/tsconfig.lib.json @@ -1,11 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "target": "es2020", - "module": "es2015", "inlineSources": true, - "importHelpers": true, - "lib": ["dom", "es2018"] + "importHelpers": true }, "angularCompilerOptions": { "enableIvy": true, @@ -17,6 +14,7 @@ "strictInjectionParameters": true, "enableResourceInlining": true }, + "include": ["**/*.ts"], "exclude": [ "src/test-setup.ts", "**/*.spec.ts", diff --git a/libs/eslint-plugin/package.json b/libs/eslint-plugin/package.json index d5ee8ca9c5..574aba84b9 100644 --- a/libs/eslint-plugin/package.json +++ b/libs/eslint-plugin/package.json @@ -1,6 +1,15 @@ { "name": "@rx-angular/eslint-plugin", "version": "2.1.0", + "publishConfig": { + "access": "public" + }, + "homepage": "https://rx-angular.io/", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/rx-angular/rx-angular.git" + }, "peerDependencies": { "@typescript-eslint/parser": "^6.13.2 || ^7.0.0", "eslint": ">=8.0.0", diff --git a/libs/isr/tsconfig.json b/libs/isr/tsconfig.json index 92049739f6..3ee066c6dc 100644 --- a/libs/isr/tsconfig.json +++ b/libs/isr/tsconfig.json @@ -1,7 +1,5 @@ { "compilerOptions": { - "target": "es2022", - "useDefineForClassFields": false, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, diff --git a/libs/state/CHANGELOG.md b/libs/state/CHANGELOG.md index 7c8a214af7..7834bedf7f 100644 --- a/libs/state/CHANGELOG.md +++ b/libs/state/CHANGELOG.md @@ -2,6 +2,15 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [19.0.3](https://github.com/rx-angular/rx-angular/compare/state@19.0.2...state@19.0.3) (2025-01-28) + + +### Bug Fixes + +* properly include files in tsconfig ([7d26e82](https://github.com/rx-angular/rx-angular/commit/7d26e8200b0e11449e2f1273893c2644eee506da)) + + + ## [19.0.2](https://github.com/rx-angular/rx-angular/compare/state@19.0.1...state@19.0.2) (2024-12-28) diff --git a/libs/state/actions/src/lib/actions.factory.spec.ts b/libs/state/actions/src/lib/actions.factory.spec.ts index c18eeef61d..aa678521f5 100644 --- a/libs/state/actions/src/lib/actions.factory.spec.ts +++ b/libs/state/actions/src/lib/actions.factory.spec.ts @@ -29,121 +29,137 @@ describe('RxActionFactory', () => { beforeAll(() => mockConsole()); it('should get created properly', () => { - const actions = new RxActionFactory<{ prop: string }>().create(); - expect(typeof actions.prop).toBe('function'); - expect(isObservable(actions.prop)).toBeFalsy(); - expect(isObservable(actions.prop$)).toBeTruthy(); + TestBed.runInInjectionContext(() => { + const actions = new RxActionFactory<{ prop: string }>().create(); + expect(typeof actions.prop).toBe('function'); + expect(isObservable(actions.prop)).toBeFalsy(); + expect(isObservable(actions.prop$)).toBeTruthy(); + }); }); it('should emit on the subscribed channels', (done) => { - const values = 'foo'; - const actions = new RxActionFactory<{ prop: string }>().create(); - const exp = values; - actions.prop$.subscribe((result) => { - expect(result).toBe(exp); - done(); + TestBed.runInInjectionContext(() => { + const values = 'foo'; + const actions = new RxActionFactory<{ prop: string }>().create(); + const exp = values; + actions.prop$.subscribe((result) => { + expect(result).toBe(exp); + done(); + }); + actions.prop(values); }); - actions.prop(values); }); it('should maintain channels per create call', (done) => { - const values = 'foo'; - const nextSpy = jest.spyOn({ nextSpy: (_: string) => void 0 }, 'nextSpy'); - const actions = new RxActionFactory<{ prop: string }>().create(); - const actions2 = new RxActionFactory<{ prop: string }>().create(); - const exp = values; - - actions2.prop$.subscribe(nextSpy as unknown as (_: string) => void); - actions.prop$.subscribe((result) => { - expect(result).toBe(exp); - done(); + TestBed.runInInjectionContext(() => { + const values = 'foo'; + const nextSpy = jest.spyOn({ nextSpy: (_: string) => void 0 }, 'nextSpy'); + const actions = new RxActionFactory<{ prop: string }>().create(); + const actions2 = new RxActionFactory<{ prop: string }>().create(); + const exp = values; + + actions2.prop$.subscribe(nextSpy as unknown as (_: string) => void); + actions.prop$.subscribe((result) => { + expect(result).toBe(exp); + done(); + }); + expect(nextSpy).not.toHaveBeenCalled(); + actions.prop(values); }); - expect(nextSpy).not.toHaveBeenCalled(); - actions.prop(values); }); it('should emit and transform on the subscribed channels', (done) => { - const actions = new RxActionFactory<{ prop: string }>().create({ - prop: () => 'transformed', + TestBed.runInInjectionContext(() => { + const actions = new RxActionFactory<{ prop: string }>().create({ + prop: () => 'transformed', + }); + const exp = 'transformed'; + actions.prop$.subscribe((result) => { + expect(result).toBe(exp); + done(); + }); + actions.prop(); }); - const exp = 'transformed'; - actions.prop$.subscribe((result) => { - expect(result).toBe(exp); - done(); - }); - actions.prop(); }); it('should emit on multiple subscribed channels', (done) => { - const value1 = 'foo'; - const value2 = 'bar'; - const actions = new RxActionFactory<{ - prop1: string; - prop2: string; - }>().create(); - const res = {}; - actions.prop1$.subscribe((result) => { - res['prop1'] = result; - }); - actions.prop2$.subscribe((result) => { - res['prop2'] = result; + TestBed.runInInjectionContext(() => { + const value1 = 'foo'; + const value2 = 'bar'; + const actions = new RxActionFactory<{ + prop1: string; + prop2: string; + }>().create(); + const res = {}; + actions.prop1$.subscribe((result) => { + res['prop1'] = result; + }); + actions.prop2$.subscribe((result) => { + res['prop2'] = result; + }); + actions({ prop1: value1, prop2: value2 }); + expect(res).toStrictEqual({ prop1: value1, prop2: value2 }); + done(); }); - actions({ prop1: value1, prop2: value2 }); - expect(res).toStrictEqual({ prop1: value1, prop2: value2 }); - done(); }); it('should emit on multiple subscribed channels over mreged output', (done) => { - const value1 = 'foo'; - const value2 = 'bar'; - const actions = new RxActionFactory<{ - prop1: string; - prop2: string; - }>().create(); - - const res = []; - expect(typeof actions.$).toBe('function'); - actions.$(['prop1', 'prop2']).subscribe((result) => { - res.push(result); + TestBed.runInInjectionContext(() => { + const value1 = 'foo'; + const value2 = 'bar'; + const actions = new RxActionFactory<{ + prop1: string; + prop2: string; + }>().create(); + + const res = []; + expect(typeof actions.$).toBe('function'); + actions.$(['prop1', 'prop2']).subscribe((result) => { + res.push(result); + }); + actions({ prop1: value1, prop2: value2 }); + expect(res.length).toBe(2); + expect(res).toStrictEqual([value1, value2]); + done(); }); - actions({ prop1: value1, prop2: value2 }); - expect(res.length).toBe(2); - expect(res).toStrictEqual([value1, value2]); - done(); }); it('should destroy all created actions', (done) => { - let numCalls = 0; - let numCalls2 = 0; - const factory = new RxActionFactory<{ prop: void }>(); - const actions = factory.create(); - const actions2 = factory.create(); - - actions.prop$.subscribe(() => ++numCalls); - actions2.prop$.subscribe(() => ++numCalls2); - expect(numCalls).toBe(0); - expect(numCalls2).toBe(0); - actions.prop(); - actions2.prop(); - expect(numCalls).toBe(1); - expect(numCalls2).toBe(1); - factory.destroy(); - actions.prop(); - actions2.prop(); - expect(numCalls).toBe(1); - expect(numCalls2).toBe(1); - done(); + TestBed.runInInjectionContext(() => { + let numCalls = 0; + let numCalls2 = 0; + const factory = new RxActionFactory<{ prop: void }>(); + const actions = factory.create(); + const actions2 = factory.create(); + + actions.prop$.subscribe(() => ++numCalls); + actions2.prop$.subscribe(() => ++numCalls2); + expect(numCalls).toBe(0); + expect(numCalls2).toBe(0); + actions.prop(); + actions2.prop(); + expect(numCalls).toBe(1); + expect(numCalls2).toBe(1); + factory.destroy(); + actions.prop(); + actions2.prop(); + expect(numCalls).toBe(1); + expect(numCalls2).toBe(1); + done(); + }); }); it('should throw if a setter is used', (done) => { - const factory = new RxActionFactory<{ prop: number }>(); - const actions = factory.create(); + TestBed.runInInjectionContext(() => { + const factory = new RxActionFactory<{ prop: number }>(); + const actions = factory.create(); - expect(() => { - (actions as any).prop = 0; - }).toThrow(''); + expect(() => { + (actions as any).prop = 0; + }).toThrow(''); - done(); + done(); + }); }); test('should isolate errors and invoke provided ', async () => { diff --git a/libs/state/actions/src/lib/actions.factory.ts b/libs/state/actions/src/lib/actions.factory.ts index 6246891617..8cbad5e94a 100644 --- a/libs/state/actions/src/lib/actions.factory.ts +++ b/libs/state/actions/src/lib/actions.factory.ts @@ -1,4 +1,10 @@ -import { ErrorHandler, Injectable, OnDestroy, Optional } from '@angular/core'; +import { + DestroyRef, + ErrorHandler, + inject, + Injectable, + Optional, +} from '@angular/core'; import { Subject } from 'rxjs'; import { actionProxyHandler } from './proxy'; import { Actions, ActionTransforms, EffectMap, RxActions } from './types'; @@ -22,10 +28,12 @@ type SubjectMap = { [K in keyof T]: Subject }; * } */ @Injectable() -export class RxActionFactory> implements OnDestroy { - private subjects: SubjectMap[] = [] as SubjectMap[]; +export class RxActionFactory> { + private readonly subjects: SubjectMap[] = [] as SubjectMap[]; - constructor(@Optional() private readonly errorHandler?: ErrorHandler) {} + constructor(@Optional() private readonly errorHandler?: ErrorHandler) { + inject(DestroyRef).onDestroy(() => this.destroy()); + } /* * Returns a object based off of the provided typing with a separate setter `[prop](value: T[K]): void` and observable stream `[prop]$: Observable`; @@ -96,12 +104,4 @@ export class RxActionFactory> implements OnDestroy { Object.values(s).forEach((subject: any) => subject.complete()); }); } - - /** - * @internal - * Internally used to clean up potential subscriptions to the subjects. (For Actions it is most probably a rare case but still important to care about) - */ - ngOnDestroy() { - this.destroy(); - } } diff --git a/libs/state/effects/src/lib/effects.service.spec.ts b/libs/state/effects/src/lib/effects.service.spec.ts index 1a2c87cddd..ea2ed7e143 100644 --- a/libs/state/effects/src/lib/effects.service.spec.ts +++ b/libs/state/effects/src/lib/effects.service.spec.ts @@ -364,7 +364,7 @@ describe('RxEffects', () => { service.method1.mockClear(); - (component as any).effects.ngOnDestroy(); + fixture.destroy(); store.state$.next('bar'); diff --git a/libs/state/effects/src/lib/effects.service.ts b/libs/state/effects/src/lib/effects.service.ts index f477d7100f..04cc14d8c6 100644 --- a/libs/state/effects/src/lib/effects.service.ts +++ b/libs/state/effects/src/lib/effects.service.ts @@ -1,4 +1,5 @@ -import { ErrorHandler, Injectable, OnDestroy, Optional } from '@angular/core'; +import { DestroyRef, ErrorHandler, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EMPTY, from, @@ -12,14 +13,14 @@ import { import { catchError, filter, - mapTo, + map, mergeAll, share, takeUntil, tap, } from 'rxjs/operators'; import { DestroyProp, OnDestroy$ } from './model'; -import { toHook, untilDestroyed } from './utils'; +import { toHook } from './utils'; /** * @deprecated - use rxEffects instead @@ -65,13 +66,10 @@ import { toHook, untilDestroyed } from './utils'; * NOTE: Avoid calling register/unregister/subscribe inside the side-effect function. */ @Injectable() -export class RxEffects implements OnDestroy, OnDestroy$ { - constructor( - @Optional() - private readonly errorHandler: ErrorHandler | null - ) {} - +export class RxEffects implements OnDestroy$ { private static nextId = 0; + private readonly destroyRef = inject(DestroyRef); + private readonly errorHandler = inject(ErrorHandler, { optional: true }); readonly _hooks$ = new Subject(); private readonly observables$ = new Subject>(); // we have to use publish here to make it hot (composition happens without subscriber) @@ -80,6 +78,13 @@ export class RxEffects implements OnDestroy, OnDestroy$ { onDestroy$: Observable = this._hooks$.pipe(toHook('destroy')); private readonly destroyers: Record> = {}; + constructor() { + this.destroyRef.onDestroy(() => { + this._hooks$.next({ destroy: true }); + this.subscription.unsubscribe(); + }); + } + /** * Performs a side-effect whenever a source observable emits, and handles its subscription. * @@ -95,7 +100,7 @@ export class RxEffects implements OnDestroy, OnDestroy$ { */ register( sourceObs: ObservableInput, - sideEffectFn: (value: T) => void + sideEffectFn: (value: T) => void, ): number; /** @@ -119,7 +124,7 @@ export class RxEffects implements OnDestroy, OnDestroy$ { register( sourceObs: ObservableInput, // tslint:disable-next-line: unified-signatures - observer: PartialObserver + observer: PartialObserver, ): number; /** @@ -152,7 +157,7 @@ export class RxEffects implements OnDestroy, OnDestroy$ { register( obsOrSub: ObservableInput | Subscription, - fnOrObj?: ((value: T) => void) | PartialObserver + fnOrObj?: ((value: T) => void) | PartialObserver, ): number | void { if (obsOrSub instanceof Subscription) { this.subscription.add(obsOrSub); @@ -160,7 +165,10 @@ export class RxEffects implements OnDestroy, OnDestroy$ { } const effectId = RxEffects.nextId++; const destroy$ = (this.destroyers[effectId] = new Subject()); - const applyBehavior = pipe(mapTo(effectId), takeUntil(destroy$)); + const applyBehavior = pipe( + map(() => effectId), + takeUntil(destroy$), + ); if (fnOrObj != null) { this.observables$.next( from(obsOrSub).pipe( @@ -170,8 +178,8 @@ export class RxEffects implements OnDestroy, OnDestroy$ { this.errorHandler?.handleError(err); return EMPTY; }), - applyBehavior - ) + applyBehavior, + ), ); } else { this.observables$.next(from(obsOrSub).pipe(applyBehavior)); @@ -222,16 +230,8 @@ export class RxEffects implements OnDestroy, OnDestroy$ { untilEffect(effectId: number) { return (source: Observable) => source.pipe( - untilDestroyed(this), - takeUntil(this.effects$.pipe(filter((eId) => eId === effectId))) + takeUntilDestroyed(this.destroyRef), + takeUntil(this.effects$.pipe(filter((eId) => eId === effectId))), ); } - - /** - * @internal - */ - ngOnDestroy(): void { - this._hooks$.next({ destroy: true }); - this.subscription.unsubscribe(); - } } diff --git a/libs/state/effects/src/lib/utils.ts b/libs/state/effects/src/lib/utils.ts index 7735017ba8..70e87eef7a 100644 --- a/libs/state/effects/src/lib/utils.ts +++ b/libs/state/effects/src/lib/utils.ts @@ -1,9 +1,9 @@ -import { MonoTypeOperatorFunction, Observable } from 'rxjs'; -import { filter, map, shareReplay, take, takeUntil } from 'rxjs/operators'; -import { HookProps, OnDestroy$, SingleShotProps } from './model'; +import { Observable } from 'rxjs'; +import { filter, map, shareReplay, take } from 'rxjs/operators'; +import { HookProps, SingleShotProps } from './model'; export function isSingleShotHookNameGuard( - name: unknown + name: unknown, ): name is keyof SingleShotProps { return !!name && typeof name === 'string' && name !== ''; } @@ -16,7 +16,7 @@ const singleShotOperators = (o$: Observable): Observable => o$.pipe( filter((v) => v === true), take(1), - shareReplay() + shareReplay(), ); /** @@ -32,19 +32,6 @@ export function toHook(name: H) { return (o$: Observable): Observable => o$.pipe( map((p) => p[name]), - operators + operators, ); } - -/** - * This operator can be used to take instances that implements `OnDestroy$` and unsubscribes from the given Observable when the instances - * `onDestroy$` Observable emits. - * - * @param instanceWithLifecycle - */ -export function untilDestroyed( - instanceWithLifecycle: OnDestroy$ -): MonoTypeOperatorFunction { - return (source) => - source.pipe(takeUntil(instanceWithLifecycle.onDestroy$)); -} diff --git a/libs/state/package.json b/libs/state/package.json index 1d9a305703..61d8c013ae 100644 --- a/libs/state/package.json +++ b/libs/state/package.json @@ -1,6 +1,6 @@ { "name": "@rx-angular/state", - "version": "19.0.2", + "version": "19.0.3", "description": "@rx-angular/state is a light-weight, flexible, strongly typed and tested tool dedicated to reduce the complexity of managing component state and side effects in angular", "publishConfig": { "access": "public" @@ -43,7 +43,7 @@ }, "peerDependencies": { "@angular/core": "^19.0.0", - "@rx-angular/cdk": "^19.0.1", + "@rx-angular/cdk": "^19.1.0", "rxjs": "^6.5.3 || ^7.4.0" }, "dependencies": { diff --git a/libs/state/spec/rx-state.component.spec.ts b/libs/state/spec/rx-state.component.spec.ts index c76b018b0e..5a1c245d2c 100644 --- a/libs/state/spec/rx-state.component.spec.ts +++ b/libs/state/spec/rx-state.component.spec.ts @@ -136,7 +136,7 @@ describe('InheritanceTestComponent', () => { it('should create', () => { stateChecker.checkSubscriptions(component, 1); - component.ngOnDestroy(); + fixture.destroy(); stateChecker.checkSubscriptions(component, 0); }); }); diff --git a/libs/state/spec/rx-state.service.spec.ts b/libs/state/spec/rx-state.service.spec.ts index 2a5768b288..96653388a8 100644 --- a/libs/state/spec/rx-state.service.spec.ts +++ b/libs/state/spec/rx-state.service.spec.ts @@ -63,7 +63,7 @@ describe('RxStateService', () => { it('should unsubscribe on ngOnDestroy call', () => { stateChecker.checkSubscriptions(service, 1); - service.ngOnDestroy(); + TestBed.resetTestingModule(); stateChecker.checkSubscriptions(service, 0); }); @@ -550,7 +550,7 @@ describe('RxStateService', () => { const tick$ = hot('aaaaaaaaaaaaaaa|', { a: 1 }); const subs = '(^!)'; state.connect(tick$.pipe(map((num) => ({ num })))); - state.ngOnDestroy(); + TestBed.resetTestingModule(); expectObservable(state.select()).toBe(''); expectSubscriptions(tick$.subscriptions).toBe(subs); }); @@ -562,7 +562,7 @@ describe('RxStateService', () => { const tick$ = hot('aaaaaaaaaaaaaaa|', { a: 1 }); const subs = '(^!)'; state.connect('num' as any, tick$); - state.ngOnDestroy(); + TestBed.resetTestingModule(); expectSubscriptions(tick$.subscriptions).toBe(subs); expectObservable(state.select()).toBe(''); }); @@ -574,7 +574,7 @@ describe('RxStateService', () => { const tick$ = hot('aaaaaaaaaaaaaaa|', { a: 1 }); const subs = '(^!)'; state.connect(tick$, (s, v) => ({ num: v * 42 })); - state.ngOnDestroy(); + TestBed.resetTestingModule(); expectObservable(state.select()).toBe(''); expectSubscriptions(tick$.subscriptions).toBe(subs); }); @@ -586,7 +586,7 @@ describe('RxStateService', () => { const tick$ = hot('aaaaaaaaaaaaaaa|', { a: 1 }); const subs = '(^!)'; state.connect('num', tick$, (s, v) => v * 42); - state.ngOnDestroy(); + TestBed.resetTestingModule(); expectObservable(state.select()).toBe(''); expectSubscriptions(tick$.subscriptions).toBe(subs); }); @@ -608,7 +608,7 @@ describe('RxStateService', () => { ), ).toBe(''); expectSubscriptions(interval$.subscriptions).toBe(subs); - state.ngOnDestroy(); + TestBed.resetTestingModule(); }); }); diff --git a/libs/state/spec/rx-state.spec.ts b/libs/state/spec/rx-state.spec.ts index 8b784afeeb..a4b27e4975 100644 --- a/libs/state/spec/rx-state.spec.ts +++ b/libs/state/spec/rx-state.spec.ts @@ -21,7 +21,6 @@ import { rxState, RxStateSetupFn, } from '../src/lib/rx-state'; -import { RxState } from '../src/lib/rx-state.service'; describe(rxState, () => { it('should create rxState', () => { @@ -136,14 +135,6 @@ describe(rxState, () => { }); }); - it('should call ngOnDestroy', () => { - RxState.prototype.ngOnDestroy = jest.fn(); - const { fixture } = setupComponent(); - expect(RxState.prototype.ngOnDestroy).not.toHaveBeenCalled(); - fixture.destroy(); - expect(RxState.prototype.ngOnDestroy).toHaveBeenCalled(); - }); - describe('signals', () => { it('should be undefined when key is undefined', () => { const { component } = setupComponent<{ count: number }>(); @@ -190,8 +181,6 @@ describe(rxState, () => { ); const state = component.state; - fixture.detectChanges(); - // TODO @edbzn: Remove detecting changes twice when we have a better solution fixture.detectChanges(); expect(state.get('count')).toBe(1337); @@ -217,8 +206,6 @@ describe(rxState, () => { ); const state = component.state; - fixture.detectChanges(); - // TODO @edbzn: Remove detecting changes twice when we have a better solution fixture.detectChanges(); expect(state.get('count')).toBe(4); @@ -242,8 +229,6 @@ describe(rxState, () => { ); const state = component.state; - fixture.detectChanges(); - // TODO @edbzn: Remove detecting changes twice when we have a better solution fixture.detectChanges(); expect(state.get('count')).toBe(1337); diff --git a/libs/state/src/lib/rx-state.service.ts b/libs/state/src/lib/rx-state.service.ts index c3ee7ede2b..dedd03ff9e 100644 --- a/libs/state/src/lib/rx-state.service.ts +++ b/libs/state/src/lib/rx-state.service.ts @@ -1,10 +1,10 @@ import { computed, + DestroyRef, inject, Injectable, Injector, isSignal, - OnDestroy, Signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -75,9 +75,7 @@ export type ReadOnly = 'get' | 'select' | 'computed' | 'signal'; * @docsPage RxState */ @Injectable() -export class RxState - implements OnDestroy, Subscribable -{ +export class RxState implements Subscribable { private subscription = new Subscription(); protected scheduler = inject(RX_STATE_SCHEDULER, { optional: true }); @@ -109,13 +107,8 @@ export class RxState */ constructor() { this.subscription.add(this.subscribe()); - } - /** - * @internal - */ - ngOnDestroy(): void { - this.subscription.unsubscribe(); + inject(DestroyRef).onDestroy(() => this.subscription.unsubscribe()); } /** diff --git a/libs/state/src/lib/rx-state.ts b/libs/state/src/lib/rx-state.ts index d82283ec13..05b11ea932 100644 --- a/libs/state/src/lib/rx-state.ts +++ b/libs/state/src/lib/rx-state.ts @@ -1,4 +1,4 @@ -import { assertInInjectionContext, DestroyRef, inject } from '@angular/core'; +import { assertInInjectionContext } from '@angular/core'; import { RxState as LegacyState } from './rx-state.service'; export type RxState = Pick< @@ -52,9 +52,6 @@ export function rxState( assertInInjectionContext(rxState); const legacyState = new LegacyState(); - const destroyRef = inject(DestroyRef); - - destroyRef.onDestroy(() => legacyState.ngOnDestroy()); const state: RxState = { get: legacyState.get.bind(legacyState), diff --git a/libs/state/tsconfig.json b/libs/state/tsconfig.json index 03261df5a4..62ebbd9464 100644 --- a/libs/state/tsconfig.json +++ b/libs/state/tsconfig.json @@ -9,8 +9,5 @@ { "path": "./tsconfig.spec.json" } - ], - "compilerOptions": { - "target": "es2020" - } + ] } diff --git a/libs/state/tsconfig.lib.json b/libs/state/tsconfig.lib.json index b8a6dd3f6e..f67c6e6e50 100644 --- a/libs/state/tsconfig.lib.json +++ b/libs/state/tsconfig.lib.json @@ -1,14 +1,11 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "target": "es2020", - "module": "es2015", "inlineSources": true, "declaration": true, "declarationMap": true, "strictNullChecks": true, - "noImplicitAny": true, - "lib": ["dom", "es2018"] + "noImplicitAny": true }, "angularCompilerOptions": { "enableIvy": true, @@ -18,6 +15,7 @@ "strictMetadataEmit": true, "enableResourceInlining": true }, + "include": ["**/*.ts"], "exclude": [ "./test-setup.ts", "**/*.spec.ts", diff --git a/libs/template/CHANGELOG.md b/libs/template/CHANGELOG.md index 2770e58ce5..c5aa765a1f 100644 --- a/libs/template/CHANGELOG.md +++ b/libs/template/CHANGELOG.md @@ -2,6 +2,29 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +# [19.2.0](https://github.com/rx-angular/rx-angular/compare/template@19.1.2...template@19.2.0) (2025-01-09) + + +### Bug Fixes + +* properly include files in tsconfig ([7d26e82](https://github.com/rx-angular/rx-angular/commit/7d26e8200b0e11449e2f1273893c2644eee506da)) + + +### Features + +* **template:** implement new reconciliation algorithm ([01c770a](https://github.com/rx-angular/rx-angular/commit/01c770a4f87a9add6b5d2fab0b054ea982ff6599)) + + + +## [19.1.2](https://github.com/rx-angular/rx-angular/compare/template@19.1.1...template@19.1.2) (2024-12-28) + + +### Bug Fixes + +* **template:** update peerDependency to cdk ([5528b68](https://github.com/rx-angular/rx-angular/commit/5528b689688de828cccb30418af3a55ac24fcaf3)) + + + ## [19.1.1](https://github.com/rx-angular/rx-angular/compare/template@19.1.0...template@19.1.1) (2024-12-24) diff --git a/libs/template/README.md b/libs/template/README.md index c6565018da..58beee8eb0 100644 --- a/libs/template/README.md +++ b/libs/template/README.md @@ -12,16 +12,16 @@ ## Sub Modules -- [RxLet (\*rxLet)](https://rx-angular.io/docs/template/api/rx-let-directive) -- [RxFor (\*rxFor)](https://rx-angular.io/docs/template/api/rx-for-directive) -- [RxIf (\*rxIf)](https://rx-angular.io/docs/template/api/rx-if-directive) -- [RxUnpatch (unpatch)](https://rx-angular.io/docs/template/api/unpatch-directive) -- [RxPush (push)](https://rx-angular.io/docs/template/api/push-pipe) +- [RxLet (\*rxLet)](https://rx-angular.io/docs/template/rx-let-directive) +- [RxFor (\*rxFor)](https://rx-angular.io/docs/template/rx-for-directive) +- [RxIf (\*rxIf)](https://rx-angular.io/docs/template/rx-if-directive) +- [RxUnpatch (unpatch)](https://rx-angular.io/docs/template/unpatch-directive) +- [RxPush (push)](https://rx-angular.io/docs/template/push-pipe) **Experimental features** -- [🧪 Virtual Scrolling (virtual-scrolling)](https://www.rx-angular.io/docs/template/api/virtual-scrolling) -- [🧪 Viewport Priority (viewport-prio)](https://rx-angular.io/docs/template/api/viewport-prio-directive) +- [🧪 Virtual Scrolling (virtual-scrolling)](https://www.rx-angular.io/docs/template/virtual-scrolling) +- [🧪 Viewport Priority (viewport-prio)](https://rx-angular.io/docs/template/viewport-prio-directive) All experimental features are very stable and already tested in production apps for multiple months. The reason to have them in experimental is so we can make small typing changes without breaking changes. @@ -67,7 +67,7 @@ export class AnyComponent {} ## Version Compatibility | RxAngular | Angular | -|-----------|------------| +| --------- | ---------- | | `^18.0.0` | `^18.0.0` | | `^17.0.0` | `^17.0.0` | | `^16.0.0` | `^16.0.0` | @@ -77,4 +77,3 @@ export class AnyComponent {} | `^1.0.0` | `>=12.0.0` | Regarding the compatibility with RxJS, we generally stick to the compatibilities of the Angular framework itself, for more information about the compatibilities of Angular itself see the [official guide](https://angular.dev/reference/versions). - diff --git a/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/autosize-virtual-scroll-strategy.ts b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/autosize-virtual-scroll-strategy.ts index a405d0d67b..1a47e64d79 100644 --- a/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/autosize-virtual-scroll-strategy.ts +++ b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/autosize-virtual-scroll-strategy.ts @@ -15,7 +15,6 @@ import { MonoTypeOperatorFunction, Observable, of, - pairwise, ReplaySubject, Subject, } from 'rxjs'; @@ -27,6 +26,7 @@ import { groupBy, map, mergeMap, + pairwise, startWith, switchMap, take, diff --git a/libs/template/for/src/index.ts b/libs/template/for/src/index.ts index 43b9b28acc..4841532f06 100644 --- a/libs/template/for/src/index.ts +++ b/libs/template/for/src/index.ts @@ -1,2 +1,4 @@ export { RxFor } from './lib/for.directive'; export { RxForViewContext } from './lib/for-view-context'; +export { provideExperimentalRxForReconciliation } from './lib/provide-experimental-reconciler'; +export { provideLegacyRxForReconciliation } from './lib/provide-legacy-reconciler'; diff --git a/libs/template/for/src/lib/README.md b/libs/template/for/src/lib/README.md index 8268c78ab7..03f2224aa5 100644 --- a/libs/template/for/src/lib/README.md +++ b/libs/template/for/src/lib/README.md @@ -23,4 +23,4 @@ yarn add @rx-angular/template ## Documentation -- [RxFor](https://rx-angular.io/docs/template/api/rx-for-directive) +- [RxFor](https://rx-angular.io/docs/template/rx-for-directive) diff --git a/libs/template/for/src/lib/for.config.ts b/libs/template/for/src/lib/for.config.ts new file mode 100644 index 0000000000..68ef13579b --- /dev/null +++ b/libs/template/for/src/lib/for.config.ts @@ -0,0 +1,10 @@ +import { InjectionToken } from '@angular/core'; +import { LEGACY_RXFOR_RECONCILIATION_FACTORY } from './provide-legacy-reconciler'; +import { RxReconcileFactory } from './reconcile-factory'; + +/** @internal */ +export const INTERNAL_RX_FOR_RECONCILER_TOKEN = + new InjectionToken('rx-for-reconciler', { + providedIn: 'root', + factory: LEGACY_RXFOR_RECONCILIATION_FACTORY, + }); diff --git a/libs/template/for/src/lib/for.directive.ts b/libs/template/for/src/lib/for.directive.ts index a778cb8195..f016204afb 100644 --- a/libs/template/for/src/lib/for.directive.ts +++ b/libs/template/for/src/lib/for.directive.ts @@ -8,7 +8,6 @@ import { Injector, Input, isSignal, - IterableDiffers, NgIterable, NgZone, OnDestroy, @@ -27,11 +26,7 @@ import { RxStrategyNames, RxStrategyProvider, } from '@rx-angular/cdk/render-strategies'; -import { - createListTemplateManager, - RxListManager, - RxListViewComputedContext, -} from '@rx-angular/cdk/template'; +import { RxListViewComputedContext } from '@rx-angular/cdk/template'; import { isObservable, Observable, @@ -41,6 +36,7 @@ import { } from 'rxjs'; import { shareReplay, switchAll } from 'rxjs/operators'; import { RxForViewContext } from './for-view-context'; +import { injectReconciler } from './inject-reconciler'; /** * @description Will be provided through Terser global definitions by Angular CLI @@ -63,7 +59,7 @@ declare const ngDevMode: boolean; * This technique enables non-blocking rendering of lists and can be referred to as `concurrent mode`. * * Read more about this in the [strategies - * section](https://www.rx-angular.io/docs/template/api/rx-for-directive#rxfor-with-concurrent-strategies). + * section](https://www.rx-angular.io/docs/template/rx-for-directive#rxfor-with-concurrent-strategies). * * Furthermore, `RxFor` provides hooks to react to rendered items in form of a `renderCallback: Subject`. * @@ -71,7 +67,7 @@ declare const ngDevMode: boolean; * and transparent for the developer. * Each instance of `RxFor` can be configured to render with different settings. * - * Read more in the [official docs](https://www.rx-angular.io/docs/template/api/rx-for-directive) + * Read more in the [official docs](https://www.rx-angular.io/docs/template/rx-for-directive) * * @docsCategory RxFor * @docsPage RxFor @@ -84,8 +80,6 @@ declare const ngDevMode: boolean; export class RxFor = NgIterable> implements OnInit, DoCheck, OnDestroy { - /** @internal */ - private iterableDiffers = inject(IterableDiffers); /** @internal */ private cdRef = inject(ChangeDetectorRef); /** @internal */ @@ -162,13 +156,14 @@ export class RxFor = NgIterable> * @description * * You can change the used `RenderStrategy` by using the `strategy` input of the `*rxFor`. It accepts - * an `Observable` or [`RxStrategyNames`](https://github.com/rx-angular/rx-angular/blob/b0630f69017cc1871d093e976006066d5f2005b9/libs/cdk/render-strategies/src/lib/model.ts#L52). + * an `Observable` or + * [`RxStrategyNames`](https://github.com/rx-angular/rx-angular/blob/b0630f69017cc1871d093e976006066d5f2005b9/libs/cdk/render-strategies/src/lib/model.ts#L52). * * The default value for strategy is * [`normal`](https://www.rx-angular.io/docs/template/cdk/render-strategies/strategies/concurrent-strategies). * * Read more about this in the - * [official docs](https://www.rx-angular.io/docs/template/api/rx-for-directive#use-render-strategies-strategy). + * [official docs](https://www.rx-angular.io/docs/template/rx-for-directive#use-render-strategies-strategy). * * @example * @@ -215,7 +210,8 @@ export class RxFor = NgIterable> * - `@ContentChildren` * * Read more about this in the - * [official docs](https://www.rx-angular.io/docs/template/api/rx-for-directive#local-strategies-and-view-content-queries-parent). + * [official + * docs](https://www.rx-angular.io/docs/template/rx-for-directive#local-strategies-and-view-content-queries-parent). * * @example * \@Component({ @@ -240,7 +236,8 @@ export class RxFor = NgIterable> * * @param {boolean} renderParent * - * @deprecated this flag will be dropped soon, as it is no longer required when using signal based view & content queries + * @deprecated this flag will be dropped soon, as it is no longer required when using signal based view & content + * queries */ @Input('rxForParent') renderParent = this.strategyProvider.config.parent; @@ -253,7 +250,8 @@ export class RxFor = NgIterable> * Event listeners normally trigger zone. Especially high frequently events cause performance issues. * * Read more about this in the - * [official docs](https://www.rx-angular.io/docs/template/api/rx-for-directive#working-with-event-listeners-patchzone). + * [official + * docs](https://www.rx-angular.io/docs/template/rx-for-directive#working-with-event-listeners-patchzone). * * @example * \@Component({ @@ -280,6 +278,8 @@ export class RxFor = NgIterable> */ @Input('rxForPatchZone') patchZone = this.strategyProvider.config.patchZone; + private defaultTrackBy: TrackByFunction = (i, item) => item; + /** * @description * A function or key that defines how to track changes for items in the iterable. @@ -352,7 +352,7 @@ export class RxFor = NgIterable> ); } if (trackByFnOrKey == null) { - this._trackBy = null; + this._trackBy = this.defaultTrackBy; } else { this._trackBy = typeof trackByFnOrKey !== 'function' @@ -436,17 +436,16 @@ export class RxFor = NgIterable> /** @internal */ private readonly strategy$ = this.strategyInput$.pipe(coerceDistinctWith()); - /** @internal */ - private listManager: RxListManager; - /** @internal */ private _subscription = new Subscription(); /** @internal */ - _trackBy: TrackByFunction; + _trackBy: TrackByFunction = this.defaultTrackBy; /** @internal */ _distinctBy = (a: T, b: T) => a === b; + private reconciler = injectReconciler(); + constructor( private readonly templateRef: TemplateRef>, ) {} @@ -454,29 +453,21 @@ export class RxFor = NgIterable> /** @internal */ ngOnInit() { this._subscription.add(this.values$.subscribe((v) => (this.values = v))); - this.listManager = createListTemplateManager>({ - iterableDiffers: this.iterableDiffers, - renderSettings: { - cdRef: this.cdRef, - strategies: this.strategyProvider.strategies as any, // TODO: move strategyProvider - defaultStrategyName: this.strategyProvider.primaryStrategy, - parent: !!this.renderParent, - patchZone: this.patchZone ? this.ngZone : false, - errorHandler: this.errorHandler, - }, - templateSettings: { + this._subscription.add( + this.reconciler({ + values$: this.values$, + strategy$: this.strategy$, viewContainerRef: this.viewContainerRef, - templateRef: this.template, + template: this.template, + strategyProvider: this.strategyProvider, + errorHandler: this.errorHandler, + cdRef: this.cdRef, + trackBy: this._trackBy, createViewContext: this.createViewContext.bind(this), updateViewContext: this.updateViewContext.bind(this), - }, - trackBy: this._trackBy, - }); - this.listManager.nextStrategy(this.strategy$); - this._subscription.add( - this.listManager - .render(this.values$) - .subscribe((v) => this._renderCallback?.next(v)), + parent: !!this.renderParent, + patchZone: this.patchZone ? this.ngZone : undefined, + }).subscribe((values) => this._renderCallback?.next(values)), ); } diff --git a/libs/template/for/src/lib/inject-reconciler.ts b/libs/template/for/src/lib/inject-reconciler.ts new file mode 100644 index 0000000000..a38c925a68 --- /dev/null +++ b/libs/template/for/src/lib/inject-reconciler.ts @@ -0,0 +1,6 @@ +import { inject } from '@angular/core'; +import { INTERNAL_RX_FOR_RECONCILER_TOKEN } from './for.config'; + +export function injectReconciler() { + return inject(INTERNAL_RX_FOR_RECONCILER_TOKEN); +} diff --git a/libs/template/for/src/lib/provide-experimental-reconciler.ts b/libs/template/for/src/lib/provide-experimental-reconciler.ts new file mode 100644 index 0000000000..27283e7eb6 --- /dev/null +++ b/libs/template/for/src/lib/provide-experimental-reconciler.ts @@ -0,0 +1,91 @@ +import { NgIterable, Provider } from '@angular/core'; +import { onStrategy } from '@rx-angular/cdk/render-strategies'; +import { reconcile, RxLiveCollection } from '@rx-angular/cdk/template'; +import { combineLatest, concat, Observable, of } from 'rxjs'; +import { + catchError, + ignoreElements, + map, + startWith, + switchMap, +} from 'rxjs/operators'; +import { INTERNAL_RX_FOR_RECONCILER_TOKEN } from './for.config'; +import { ReconcileFactoryOptions } from './reconcile-factory'; + +export function provideExperimentalRxForReconciliation(): Provider { + return { + provide: INTERNAL_RX_FOR_RECONCILER_TOKEN, + useFactory: + () => + = NgIterable>( + options: ReconcileFactoryOptions, + ) => { + const { + values$, + strategy$, + viewContainerRef, + template, + strategyProvider, + errorHandler, + createViewContext, + updateViewContext, + cdRef, + trackBy, + parent, + patchZone, + } = options; + const liveCollection = new RxLiveCollection( + viewContainerRef, + template, + strategyProvider, + createViewContext, + updateViewContext, + ); + return combineLatest([ + values$, + strategy$.pipe(startWith(strategyProvider.primaryStrategy)), + ]).pipe( + switchMap(([iterable, strategyName]) => { + if (iterable == null) { + iterable = []; + } + if (!iterable[Symbol.iterator]) { + throw new Error( + `Error trying to diff '${iterable}'. Only arrays and iterables are allowed`, + ); + } + const strategy = strategyProvider.strategies[strategyName] + ? strategyName + : strategyProvider.primaryStrategy; + liveCollection.reset(); + reconcile(liveCollection, iterable, trackBy); + liveCollection.updateIndexes(); + return >liveCollection.flushQueue(strategy).pipe( + (o$) => + parent && liveCollection.needHostUpdate + ? concat( + o$, + onStrategy( + null, + strategyProvider.strategies[strategy], + (_, work, options) => { + work(cdRef, options.scope); + }, + { + scope: (cdRef as any).context ?? cdRef, + ngZone: patchZone, + }, + ).pipe(ignoreElements()), + ) + : o$, + map(() => iterable), + ); + }), + catchError((e) => { + errorHandler.handleError(e); + return of(null); + }), + ); + }, + }; +} diff --git a/libs/template/for/src/lib/provide-legacy-reconciler.ts b/libs/template/for/src/lib/provide-legacy-reconciler.ts new file mode 100644 index 0000000000..2e1df3c319 --- /dev/null +++ b/libs/template/for/src/lib/provide-legacy-reconciler.ts @@ -0,0 +1,60 @@ +import { inject, IterableDiffers, NgIterable, Provider } from '@angular/core'; +import { + createListTemplateManager, + RxDefaultListViewContext, +} from '@rx-angular/cdk/template'; +import { INTERNAL_RX_FOR_RECONCILER_TOKEN } from './for.config'; +import { ReconcileFactoryOptions } from './reconcile-factory'; + +export const LEGACY_RXFOR_RECONCILIATION_FACTORY = () => { + const iterableDiffers = inject(IterableDiffers); + return = NgIterable>( + options: ReconcileFactoryOptions, + ) => { + const { + values$, + strategy$, + viewContainerRef, + template, + strategyProvider, + errorHandler, + createViewContext, + updateViewContext, + cdRef, + trackBy, + parent, + patchZone, + } = options; + const listManager = createListTemplateManager< + T, + RxDefaultListViewContext + >({ + iterableDiffers: iterableDiffers, + renderSettings: { + cdRef: cdRef, + strategies: strategyProvider.strategies as any, // TODO: move strategyProvider + defaultStrategyName: strategyProvider.primaryStrategy, + parent, + patchZone, + errorHandler, + }, + templateSettings: { + viewContainerRef, + templateRef: template, + createViewContext, + updateViewContext, + }, + trackBy, + }); + listManager.nextStrategy(strategy$); + + return listManager.render(values$); + }; +}; + +export function provideLegacyRxForReconciliation(): Provider { + return { + provide: INTERNAL_RX_FOR_RECONCILER_TOKEN, + useFactory: LEGACY_RXFOR_RECONCILIATION_FACTORY, + }; +} diff --git a/libs/template/for/src/lib/reconcile-factory.ts b/libs/template/for/src/lib/reconcile-factory.ts new file mode 100644 index 0000000000..278fcf0599 --- /dev/null +++ b/libs/template/for/src/lib/reconcile-factory.ts @@ -0,0 +1,48 @@ +import { + ChangeDetectorRef, + EmbeddedViewRef, + ErrorHandler, + NgIterable, + NgZone, + TemplateRef, + TrackByFunction, + ViewContainerRef, +} from '@angular/core'; +import { + RxStrategyNames, + RxStrategyProvider, +} from '@rx-angular/cdk/render-strategies'; +import { + RxDefaultListViewContext, + RxListViewComputedContext, +} from '@rx-angular/cdk/template'; +import { Observable } from 'rxjs'; + +export type ReconcileFactoryOptions< + T, + U extends NgIterable = NgIterable, +> = { + values$: Observable; + strategy$: Observable; + viewContainerRef: ViewContainerRef; + template: TemplateRef>; + strategyProvider: RxStrategyProvider; + errorHandler: ErrorHandler; + cdRef: ChangeDetectorRef; + trackBy: TrackByFunction; + createViewContext: ( + item: T, + context: RxListViewComputedContext, + ) => RxDefaultListViewContext; + updateViewContext: ( + item: T, + view: EmbeddedViewRef>, + context: RxListViewComputedContext, + ) => void; + parent?: boolean; + patchZone?: NgZone; +}; + +export type RxReconcileFactory = = NgIterable>( + options: ReconcileFactoryOptions, +) => Observable>; diff --git a/libs/template/for/src/lib/tests/for.directive.observable.spec.ts b/libs/template/for/src/lib/tests/for.directive.observable.spec.ts index 42379e4850..09f0477182 100644 --- a/libs/template/for/src/lib/tests/for.directive.observable.spec.ts +++ b/libs/template/for/src/lib/tests/for.directive.observable.spec.ts @@ -1,8 +1,10 @@ import { ErrorHandler } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { Observable } from 'rxjs'; +import { provideExperimentalRxForReconciliation } from '../provide-experimental-reconciler'; +import { provideLegacyRxForReconciliation } from '../provide-legacy-reconciler'; import { createErrorHandler, createTestComponent as utilCreateTestComponent, @@ -11,10 +13,6 @@ import { thisArg, } from './fixtures'; -const customErrorHandler: ErrorHandler = { - handleError: jest.fn(), -}; - function createTestComponent( template = `
{{item.toString()}};
`, ) { @@ -22,538 +20,543 @@ function createTestComponent( } describe('rxFor with observables', () => { - let fixture: ComponentFixture; - let errorHandler: ErrorHandler; - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + describe.each([['legacy'], ['new']])('conciler: %p', (conciler) => { + let fixture: ComponentFixture; + let errorHandler: ErrorHandler; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - function getComponent(): TestComponent { - return fixture.componentInstance; - } + function getComponent(): TestComponent { + return fixture.componentInstance; + } - function detectChangesAndExpectText(text: string): void { - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toBe(text); - } - - function expectText(text: string) { - expect(fixture.nativeElement.textContent).toBe(text); - } + function detectChangesAndExpectText(text: string): void { + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(text); + } - afterEach(() => { - fixture = null as any; - errorHandler = null as any; - }); + function expectText(text: string) { + expect(fixture.nativeElement.textContent).toBe(text); + } - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [TestComponent], - providers: [ - { - provide: ErrorHandler, - useValue: customErrorHandler, - }, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', + beforeEach(() => { + const customErrorHandler: ErrorHandler = { + handleError: jest.fn(), + }; + TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [ + { + provide: ErrorHandler, + useValue: customErrorHandler, }, - }, - ], + provideRxRenderStrategies({ primaryStrategy: 'native' }), + conciler === 'legacy' + ? provideLegacyRxForReconciliation() + : provideExperimentalRxForReconciliation(), + ], + }); + warnSpy.mockClear(); }); - warnSpy.mockClear(); - }); - it('should subscribe only once to the source', waitForAsync(() => { - fixture = createTestComponent(); - let subscriber = 0; - const observable = new Observable((observer) => { - subscriber++; - observer.next(['1']); + afterEach(() => { + fixture = null as any; + errorHandler = null as any; }); - fixture.componentInstance.itemsHot$ = observable as never; - detectChangesAndExpectText('1;'); - expect(subscriber).toBe(1); - })); - - it('should reflect initial elements', waitForAsync(() => { - fixture = createTestComponent(); - detectChangesAndExpectText('1;2;'); - })); - - it('should reflect added elements', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - const newValues = getComponent().itemsHot$.value; - newValues.push(3); - getComponent().itemsHot$.next(newValues); - expectText('1;2;3;'); - })); - - it('should reflect removed elements', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - const newValues = getComponent().itemsHot$.value; - newValues.splice(1, 1); - getComponent().itemsHot$.next(newValues); - expectText('1;'); - })); - - it('should reflect moved elements', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - const newValues = getComponent().itemsHot$.value; - newValues.splice(0, 1); - newValues.push(1); - getComponent().itemsHot$.next(newValues); - expectText('2;1;'); - })); - - it('should reflect a mix of all changes (additions/removals/moves)', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - getComponent().itemsHot$.next([0, 1, 2, 3, 4, 5]); - getComponent().itemsHot$.next([6, 2, 7, 0, 4, 8]); - - expectText('6;2;7;0;4;8;'); - })); - - it('should iterate over an array of objects', waitForAsync(() => { - const template = - '
  • {{item["name"]}};
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - // INIT - getComponent().itemsHot$.next([{ name: 'misko' }, { name: 'shyam' }]); - expectText('misko;shyam;'); - - // GROW - const values = getComponent().itemsHot$.value; - values.push({ name: 'adam' }); - getComponent().itemsHot$.next(values); - expectText('misko;shyam;adam;'); - - // SHRINK - values.splice(2, 1); - values.splice(0, 1); - getComponent().itemsHot$.next(values); - expectText('shyam;'); - })); - - it('should gracefully handle nulls', waitForAsync(() => { - const template = - '
  • {{item}};
'; - fixture = createTestComponent(template); - getComponent().itemsHot$.next(null); - errorHandler = createErrorHandler(); - fixture.detectChanges(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - expectText(''); - expect(errorSpy).toBeCalledTimes(0); - errorSpy.mockClear(); - })); - - it('should gracefully handle ref changing to null and back', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - detectChangesAndExpectText('1;2;'); - - getComponent().itemsHot$.next(null); - expectText(''); - - getComponent().itemsHot$.next([1, 2, 3]); - expectText('1;2;3;'); - expect(errorSpy).toBeCalledTimes(0); - errorSpy.mockClear(); - })); - - it('should throw on non-iterable ref and suggest using an array', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - const expectedError = new Error( - "NG0901: Cannot find a differ supporting object 'whaaa' of type 'string'", - ); - getComponent().itemsHot$.next('whaaa'); - fixture.detectChanges(); - expect(errorSpy).toHaveBeenCalledWith(expectedError); - errorSpy.mockClear(); - })); - - it('should throw on ref changing to string', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - const expectedError = new Error( - "NG0900: Error trying to diff 'whaaa'. Only arrays and iterables are allowed", - ); - detectChangesAndExpectText('1;2;'); - - getComponent().itemsHot$.next('whaaa'); - expect(errorSpy).toHaveBeenCalledWith(expectedError); - errorSpy.mockClear(); - })); - - it('should works with duplicates', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - - const a = new Foo(); - getComponent().itemsHot$.next([a, a]); - expectText('foo;foo;'); - })); - - it('should repeat over nested arrays', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{item.length}};
|' + - '
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([['a', 'b'], ['c']]); - expectText('a-2;b-2;|c-1;|'); - - getComponent().itemsHot$.next([['e'], ['f', 'g']]); - expectText('e-1;|f-2;g-2;|'); - })); - - it('should repeat over nested arrays with no intermediate element', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{item.length}};
' + - '
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHot$.next([['a', 'b'], ['c']]); - expectText('a-2;b-2;c-1;'); - - getComponent().itemsHot$.next([['e'], ['f', 'g']]); - expectText('e-1;f-2;g-2;'); - })); - - it('should repeat over nested arrays using select with no intermediate element', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{col.length}};
' + - '
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHot$.next([{ items: ['a', 'b', 'c'] }]); - expectText('a-3;b-3;c-3;'); - - getComponent().itemsHot$.next([{ items: ['d', 'e', 'f'] }]); - expectText('d-3;e-3;f-3;'); - })); - - it('should repeat over nested ngIf that are the last node in the rxFor template', waitForAsync(() => { - const template = - `
` + - `
{{i}}|
` + - `
even|
` + - `
`; - - fixture = createTestComponent(template); - fixture.detectChanges(); - - const items = [1]; - getComponent().itemsHot$.next(items); - expectText('0|even|'); - - items.push(1); - getComponent().itemsHot$.next(items); - expectText('0|even|1|'); - - items.push(1); - getComponent().itemsHot$.next(items); - expectText('0|even|1|2|even|'); - })); - - it('should allow of saving the collection', waitForAsync(() => { - const template = - '
  • {{i}}/{{collection.length}} -' + - ' {{item}};
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - expectText('0/2 - 1;1/2 - 2;'); - - getComponent().itemsHot$.next([1, 2, 3]); - expectText('0/3 - 1;1/3 - 2;2/3 - 3;'); - })); - - it('should display indices correctly', waitForAsync(() => { - const template = - '{{i.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - expectText('0123456789'); - - getComponent().itemsHot$.next([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); - expectText('0123456789'); - })); - - it('should display indices$ correctly', waitForAsync(() => { - const template = - '{{(i | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - expectText('0123456789'); - - getComponent().itemsHot$.next([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); - expectText('0123456789'); - })); - - it('should display count correctly', waitForAsync(() => { - const template = - '{{len}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('333'); - - getComponent().itemsHot$.next([4, 3, 2, 1, 0, -1]); - expectText('666666'); - })); - - it('should display count$ correctly', waitForAsync(() => { - const template = - '{{len | async }}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('333'); - - getComponent().itemsHot$.next([4, 3, 2, 1, 0, -1]); - expectText('666666'); - })); - - it('should display first item correctly', waitForAsync(() => { - const template = - '{{isFirst.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('truefalsefalse'); - - getComponent().itemsHot$.next([2, 1]); - expectText('truefalse'); - })); - - it('should display first$ item correctly', waitForAsync(() => { - const template = - '{{(isFirst | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('truefalsefalse'); - - getComponent().itemsHot$.next([2, 1]); - expectText('truefalse'); - })); - - it('should display last item correctly', waitForAsync(() => { - const template = - '{{isLast.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('falsefalsetrue'); - - getComponent().itemsHot$.next([2, 1]); - expectText('falsetrue'); - })); - - it('should display last item correctly', waitForAsync(() => { - const template = - '{{(isLast | async ).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('falsefalsetrue'); - - getComponent().itemsHot$.next([2, 1]); - expectText('falsetrue'); - })); - - it('should display even items correctly', waitForAsync(() => { - const template = - '{{isEven.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('truefalsetrue'); - - getComponent().itemsHot$.next([2, 1]); - expectText('truefalse'); - })); - - it('should display even$ items correctly', waitForAsync(() => { - const template = - '{{(isEven | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2]); - expectText('truefalsetrue'); - - getComponent().itemsHot$.next([2, 1]); - expectText('truefalse'); - })); - - it('should display odd items correctly', waitForAsync(() => { - const template = - '{{isOdd.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2, 3]); - expectText('falsetruefalsetrue'); - - getComponent().itemsHot$.next([2, 1]); - expectText('falsetrue'); - })); - - it('should display odd$ items correctly', waitForAsync(() => { - const template = - '{{(isOdd | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHot$.next([0, 1, 2, 3]); - expectText('falsetruefalsetrue'); - - getComponent().itemsHot$.next([2, 1]); - expectText('falsetrue'); - })); - - it('should allow to use a custom template', waitForAsync(() => { - const template = - '' + - '

{{i}}: {{item}};

'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHot$.next(['a', 'b', 'c']); - expectText('0: a;1: b;2: c;'); - })); - - it('should use a default template if a custom one is null', waitForAsync(() => { - const template = `
    {{i}}: {{item}};
`; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHot$.next(['a', 'b', 'c']); - expectText('0: a;1: b;2: c;'); - })); - - it('should use a custom template when both default and a custom one are present', waitForAsync(() => { - const template = - '{{i}};' + - '{{i}}: {{item}};'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHot$.next(['a', 'b', 'c']); - expectText('0: a;1: b;2: c;'); - })); - - describe('track by', () => { - it('should console.warn if trackBy is not a function', waitForAsync(() => { - const template = `

`; + + it('should subscribe only once to the source', waitForAsync(() => { + fixture = createTestComponent(); + let subscriber = 0; + const observable = new Observable((observer) => { + subscriber++; + observer.next(['1']); + }); + fixture.componentInstance.itemsHot$ = observable as never; + detectChangesAndExpectText('1;'); + expect(subscriber).toBe(1); + })); + + it('should reflect initial elements', waitForAsync(() => { + fixture = createTestComponent(); + detectChangesAndExpectText('1;2;'); + })); + + it('should reflect added elements', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + const newValues = getComponent().itemsHot$.value; + newValues.push(3); + getComponent().itemsHot$.next(newValues); + expectText('1;2;3;'); + })); + + it('should reflect removed elements', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + const newValues = getComponent().itemsHot$.value; + newValues.splice(1, 1); + getComponent().itemsHot$.next(newValues); + expectText('1;'); + })); + + it('should reflect moved elements', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + const newValues = getComponent().itemsHot$.value; + newValues.splice(0, 1); + newValues.push(1); + getComponent().itemsHot$.next(newValues); + expectText('2;1;'); + })); + + it('should reflect a mix of all changes (additions/removals/moves)', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + getComponent().itemsHot$.next([0, 1, 2, 3, 4, 5]); + getComponent().itemsHot$.next([6, 2, 7, 0, 4, 8]); + + expectText('6;2;7;0;4;8;'); + })); + + it('should iterate over an array of objects', waitForAsync(() => { + const template = + '
  • {{item["name"]}};
'; fixture = createTestComponent(template); - fixture.componentInstance.value = 0; fixture.detectChanges(); - expect(warnSpy).toBeCalledTimes(1); + + // INIT + getComponent().itemsHot$.next([{ name: 'misko' }, { name: 'shyam' }]); + expectText('misko;shyam;'); + + // GROW + const values = getComponent().itemsHot$.value; + values.push({ name: 'adam' }); + getComponent().itemsHot$.next(values); + expectText('misko;shyam;adam;'); + + // SHRINK + values.splice(2, 1); + values.splice(0, 1); + getComponent().itemsHot$.next(values); + expectText('shyam;'); })); - it('should track by identity when trackBy is to `null` or `undefined`', waitForAsync(() => { - const template = `

{{ item }}

`; + it('should gracefully handle nulls', waitForAsync(() => { + const template = + '
  • {{item}};
'; fixture = createTestComponent(template); - fixture.componentInstance.itemsHot$.next(['a', 'b', 'c']); - fixture.componentInstance.value = null; - detectChangesAndExpectText('abc'); - fixture.componentInstance.value = undefined; - detectChangesAndExpectText('abc'); - expect(warnSpy).toBeCalledTimes(0); + getComponent().itemsHot$.next(null); + errorHandler = createErrorHandler(); + fixture.detectChanges(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + + expectText(''); + expect(errorSpy).toBeCalledTimes(0); + errorSpy.mockClear(); })); - it('should set the context to the component instance', waitForAsync(() => { - const template = `

`; + it('should gracefully handle ref changing to null and back', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + + detectChangesAndExpectText('1;2;'); + + getComponent().itemsHot$.next(null); + expectText(''); + + getComponent().itemsHot$.next([1, 2, 3]); + expectText('1;2;3;'); + expect(errorSpy).toBeCalledTimes(0); + errorSpy.mockClear(); + })); + + it('should throw on non-iterable ref and suggest using an array', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + const errorValue = 123; + + const expectedError = new Error( + `Error trying to diff '${errorValue}'. Only arrays and iterables are allowed`, + ); + getComponent().itemsHot$.next(errorValue); + fixture.detectChanges(); + expect(errorSpy).toHaveBeenCalledWith(expectedError); + errorSpy.mockClear(); + })); + + it('should throw on ref changing to number', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + const errorValue = 123; + const expectedError = new Error( + `Error trying to diff '${errorValue}'. Only arrays and iterables are allowed`, + ); + detectChangesAndExpectText('1;2;'); + + getComponent().itemsHot$.next(errorValue); + expect(errorSpy).toHaveBeenCalledWith(expectedError); + errorSpy.mockClear(); + })); + + it('should works with duplicates', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + + const a = new Foo(); + getComponent().itemsHot$.next([a, a]); + expectText('foo;foo;'); + })); + + it('should repeat over nested arrays', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{item.length}};
|' + + '
'; fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([['a', 'b'], ['c']]); + expectText('a-2;b-2;|c-1;|'); - setThis(null); + getComponent().itemsHot$.next([['e'], ['f', 'g']]); + expectText('e-1;|f-2;g-2;|'); + })); + + it('should repeat over nested arrays with no intermediate element', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{item.length}};
' + + '
'; + fixture = createTestComponent(template); fixture.detectChanges(); - expect(thisArg).toBe(getComponent()); + getComponent().itemsHot$.next([['a', 'b'], ['c']]); + expectText('a-2;b-2;c-1;'); + + getComponent().itemsHot$.next([['e'], ['f', 'g']]); + expectText('e-1;f-2;g-2;'); })); - it('should not replace tracked items', waitForAsync(() => { - const template = `

{{items[i]}}

`; + it('should repeat over nested arrays using select with no intermediate element', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{col.length}};
' + + '
'; fixture = createTestComponent(template); fixture.detectChanges(); + getComponent().itemsHot$.next([{ items: ['a', 'b', 'c'] }]); + expectText('a-3;b-3;c-3;'); - const buildItemList = () => { - getComponent().itemsHot$.next([{ id: 'a' }]); - return fixture.debugElement.queryAll(By.css('p'))[0]; - }; + getComponent().itemsHot$.next([{ items: ['d', 'e', 'f'] }]); + expectText('d-3;e-3;f-3;'); + })); + + it('should repeat over nested ngIf that are the last node in the rxFor template', waitForAsync(() => { + const template = + `
` + + `
{{i}}|
` + + `
even|
` + + `
`; + + fixture = createTestComponent(template); + fixture.detectChanges(); + + const items = [1]; + getComponent().itemsHot$.next(items); + expectText('0|even|'); + + items.push(1); + getComponent().itemsHot$.next(items); + expectText('0|even|1|'); + + items.push(1); + getComponent().itemsHot$.next(items); + expectText('0|even|1|2|even|'); + })); + + it('should allow of saving the collection', waitForAsync(() => { + const template = + '
  • {{i}}/{{collection.length}} -' + + ' {{item}};
'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + expectText('0/2 - 1;1/2 - 2;'); + + getComponent().itemsHot$.next([1, 2, 3]); + expectText('0/3 - 1;1/3 - 2;2/3 - 3;'); + })); - const firstP = buildItemList(); - const finalP = buildItemList(); - expect(finalP.nativeElement).toBe(firstP.nativeElement); + it('should display indices correctly', waitForAsync(() => { + const template = + '{{i.toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expectText('0123456789'); + + getComponent().itemsHot$.next([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); + expectText('0123456789'); + })); + + it('should display indices$ correctly', waitForAsync(() => { + const template = + '{{(i | async).toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expectText('0123456789'); + + getComponent().itemsHot$.next([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); + expectText('0123456789'); + })); + + it('should display count correctly', waitForAsync(() => { + const template = + '{{len}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2]); + expectText('333'); + + getComponent().itemsHot$.next([4, 3, 2, 1, 0, -1]); + expectText('666666'); + })); + + it('should display count$ correctly', waitForAsync(() => { + const template = + '{{len | async }}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2]); + expectText('333'); + + getComponent().itemsHot$.next([4, 3, 2, 1, 0, -1]); + expectText('666666'); })); - it('should update implicit local variable on view', waitForAsync(() => { - const template = `
{{item['color']}}
`; + it('should display first item correctly', waitForAsync(() => { + const template = + '{{isFirst.toString()}}'; fixture = createTestComponent(template); fixture.detectChanges(); - getComponent().itemsHot$.next([{ id: 'a', color: 'blue' }]); - expectText('blue'); + getComponent().itemsHot$.next([0, 1, 2]); + expectText('truefalsefalse'); - getComponent().itemsHot$.next([{ id: 'a', color: 'red' }]); - expectText('red'); + getComponent().itemsHot$.next([2, 1]); + expectText('truefalse'); })); - it('should move items around and keep them updated ', waitForAsync(() => { - const template = `
{{item['color']}}
`; + it('should display first$ item correctly', waitForAsync(() => { + const template = + '{{(isFirst | async).toString()}}'; fixture = createTestComponent(template); fixture.detectChanges(); - getComponent().itemsHot$.next([ - { id: 'a', color: 'blue' }, - { id: 'b', color: 'yellow' }, - ]); - expectText('blueyellow'); + getComponent().itemsHot$.next([0, 1, 2]); + expectText('truefalsefalse'); - getComponent().itemsHot$.next([ - { id: 'b', color: 'orange' }, - { id: 'a', color: 'red' }, - ]); - expectText('orangered'); + getComponent().itemsHot$.next([2, 1]); + expectText('truefalse'); })); - it('should handle added and removed items properly when tracking by index', waitForAsync(() => { - const template = `
{{item}}
`; + it('should display last item correctly', waitForAsync(() => { + const template = + '{{isLast.toString()}}'; fixture = createTestComponent(template); fixture.detectChanges(); - getComponent().itemsHot$.next(['a', 'b', 'c', 'd']); - getComponent().itemsHot$.next(['e', 'f', 'g', 'h']); - getComponent().itemsHot$.next(['e', 'f', 'h']); - expectText('efh'); + getComponent().itemsHot$.next([0, 1, 2]); + expectText('falsefalsetrue'); + + getComponent().itemsHot$.next([2, 1]); + expectText('falsetrue'); })); + + it('should display last item correctly', waitForAsync(() => { + const template = + '{{(isLast | async ).toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2]); + expectText('falsefalsetrue'); + + getComponent().itemsHot$.next([2, 1]); + expectText('falsetrue'); + })); + + it('should display even items correctly', waitForAsync(() => { + const template = + '{{isEven.toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2]); + expectText('truefalsetrue'); + + getComponent().itemsHot$.next([2, 1]); + expectText('truefalse'); + })); + + it('should display even$ items correctly', waitForAsync(() => { + const template = + '{{(isEven | async).toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2]); + expectText('truefalsetrue'); + + getComponent().itemsHot$.next([2, 1]); + expectText('truefalse'); + })); + + it('should display odd items correctly', waitForAsync(() => { + const template = + '{{isOdd.toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2, 3]); + expectText('falsetruefalsetrue'); + + getComponent().itemsHot$.next([2, 1]); + expectText('falsetrue'); + })); + + it('should display odd$ items correctly', waitForAsync(() => { + const template = + '{{(isOdd | async).toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([0, 1, 2, 3]); + expectText('falsetruefalsetrue'); + + getComponent().itemsHot$.next([2, 1]); + expectText('falsetrue'); + })); + + it('should allow to use a custom template', waitForAsync(() => { + const template = + '' + + '

{{i}}: {{item}};

'; + fixture = createTestComponent(template); + fixture.detectChanges(); + getComponent().itemsHot$.next(['a', 'b', 'c']); + expectText('0: a;1: b;2: c;'); + })); + + it('should use a default template if a custom one is null', waitForAsync(() => { + const template = `
    {{i}}: {{item}};
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + getComponent().itemsHot$.next(['a', 'b', 'c']); + expectText('0: a;1: b;2: c;'); + })); + + it('should use a custom template when both default and a custom one are present', waitForAsync(() => { + const template = + '{{i}};' + + '{{i}}: {{item}};'; + fixture = createTestComponent(template); + fixture.detectChanges(); + getComponent().itemsHot$.next(['a', 'b', 'c']); + expectText('0: a;1: b;2: c;'); + })); + + describe('track by', () => { + it('should console.warn if trackBy is not a function', waitForAsync(() => { + const template = `

`; + fixture = createTestComponent(template); + fixture.componentInstance.value = 0; + fixture.detectChanges(); + expect(warnSpy).toBeCalledTimes(1); + })); + + it('should track by identity when trackBy is to `null` or `undefined`', waitForAsync(() => { + const template = `

{{ item }}

`; + fixture = createTestComponent(template); + fixture.componentInstance.itemsHot$.next(['a', 'b', 'c']); + fixture.componentInstance.value = null; + detectChangesAndExpectText('abc'); + fixture.componentInstance.value = undefined; + detectChangesAndExpectText('abc'); + expect(warnSpy).toBeCalledTimes(0); + })); + + it('should set the context to the component instance', waitForAsync(() => { + const template = `

`; + fixture = createTestComponent(template); + + setThis(null); + fixture.detectChanges(); + expect(thisArg).toBe(getComponent()); + })); + + it('should not replace tracked items', waitForAsync(() => { + const template = `

{{items[i]}}

`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + const buildItemList = () => { + getComponent().itemsHot$.next([{ id: 'a' }]); + return fixture.debugElement.queryAll(By.css('p'))[0]; + }; + + const firstP = buildItemList(); + const finalP = buildItemList(); + expect(finalP.nativeElement).toBe(firstP.nativeElement); + })); + + it('should update implicit local variable on view', waitForAsync(() => { + const template = `
{{item['color']}}
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([{ id: 'a', color: 'blue' }]); + expectText('blue'); + + getComponent().itemsHot$.next([{ id: 'a', color: 'red' }]); + expectText('red'); + })); + + it('should move items around and keep them updated ', waitForAsync(() => { + const template = `
{{item['color']}}
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next([ + { id: 'a', color: 'blue' }, + { id: 'b', color: 'yellow' }, + ]); + expectText('blueyellow'); + + getComponent().itemsHot$.next([ + { id: 'b', color: 'orange' }, + { id: 'a', color: 'red' }, + ]); + expectText('orangered'); + })); + + it('should handle added and removed items properly when tracking by index', waitForAsync(() => { + const template = `
{{item}}
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHot$.next(['a', 'b', 'c', 'd']); + getComponent().itemsHot$.next(['e', 'f', 'g', 'h']); + getComponent().itemsHot$.next(['e', 'f', 'h']); + expectText('efh'); + })); + }); }); }); diff --git a/libs/template/for/src/lib/tests/for.directive.parent-notification.spec.ts b/libs/template/for/src/lib/tests/for.directive.parent-notification.spec.ts index cbe0cb14f4..69b83272be 100644 --- a/libs/template/for/src/lib/tests/for.directive.parent-notification.spec.ts +++ b/libs/template/for/src/lib/tests/for.directive.parent-notification.spec.ts @@ -14,6 +14,8 @@ import { import { mockConsole } from '@test-helpers/rx-angular'; import { asapScheduler, delay } from 'rxjs'; import { RxFor } from '../for.directive'; +import { provideExperimentalRxForReconciliation } from '../provide-experimental-reconciler'; +import { provideLegacyRxForReconciliation } from '../provide-legacy-reconciler'; import { TestComponent } from './fixtures'; const testTemplate = `
@@ -72,153 +74,165 @@ async function rendered(component: TestComponent, behavior: RxRenderBehavior) { } describe('rxFor parent-notifications', () => { - let strategyProvider: RxStrategyProvider; - let behavior: RxRenderBehavior; - - function forEachStrategy(testFn: (strategy: string) => void) { - describe.each([ - ['immediate'], - ['userBlocking'], - ['normal'], - ['low'], - ['idle'], - ])('Strategy: %p', (strategy) => { - beforeEach(() => { - behavior = strategyProvider.strategies[strategy].behavior; - }); - - testFn(strategy); - }); - } - - describe('legacy queries', () => { - let fixture: ComponentFixture; - let errorHandler: ErrorHandler; - let component: ParentNotifyTestComponent; - - afterEach(() => { - fixture = null as any; - errorHandler = null as any; - }); - - beforeAll(() => { - mockConsole(); - }); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ParentNotifyTestComponent], - teardown: { destroyAfterEach: true }, - }); - fixture = TestBed.createComponent(ParentNotifyTestComponent); - component = fixture.componentInstance; - strategyProvider = TestBed.inject(RxStrategyProvider); - }); - - forEachStrategy((strategy) => { - describe('parent: true', () => { + describe.each([['legacy'], ['new']])('conciler: %p', (conciler) => { + let strategyProvider: RxStrategyProvider; + let behavior: RxRenderBehavior; + + function forEachStrategy(testFn: (strategy: string) => void) { + describe.each([ + ['immediate'], + ['userBlocking'], + ['normal'], + ['low'], + ['idle'], + ])('Strategy: %p', (strategy) => { beforeEach(() => { - component.strategy = strategy; - component.parent = true; - fixture.detectChanges(); - component.itemsCold$.next([1, 2]); + behavior = strategyProvider.strategies[strategy].behavior; }); - it('should update ViewChild', async () => { - await rendered(component, behavior); - expect(component.listChildren.length).toBe(2); - }); + testFn(strategy); + }); + } - it('should update parent', async () => { - const cdRef = (component.forChildren.first as any).cdRef; - cdRef.detectChanges = jest.fn(); - await rendered(component, behavior); - expect(cdRef.detectChanges).toHaveBeenCalled(); - }); + describe('legacy queries', () => { + let fixture: ComponentFixture; + let errorHandler: ErrorHandler; + let component: ParentNotifyTestComponent; - it('should scope parent notifications', async () => { - const cdRef = (component.forChildren.first as any).cdRef; - const cdRef2 = (component.forChildren.last as any).cdRef; - expect(cdRef2).toEqual(cdRef); - cdRef.detectChanges = jest.fn(); - await rendered(component, behavior); - expect(cdRef.detectChanges).toHaveBeenCalledTimes(1); - }); + afterEach(() => { + fixture = null as any; + errorHandler = null as any; }); - describe('parent: false', () => { - beforeEach(() => { - component.strategy = strategy; - component.parent = false; - fixture.detectChanges(); - component.itemsCold$.next([1, 2]); - }); - - it('should not update ViewChild', async () => { - await rendered(component, behavior); - expect(component.listChildren.length).toBe(0); - }); + beforeAll(() => { + mockConsole(); + }); - it('should not update parent', async () => { - const cdRef = (component.forChildren.first as any).cdRef; - cdRef.detectChanges = jest.fn(); - const behavior = strategyProvider.strategies[strategy].behavior; - await rendered(component, behavior); - expect(cdRef.detectChanges).not.toHaveBeenCalled(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ParentNotifyTestComponent], + providers: [ + conciler === 'legacy' + ? provideLegacyRxForReconciliation() + : provideExperimentalRxForReconciliation(), + ], + teardown: { destroyAfterEach: true }, }); + fixture = TestBed.createComponent(ParentNotifyTestComponent); + component = fixture.componentInstance; + strategyProvider = TestBed.inject(RxStrategyProvider); }); - }); - /*describe.each([ - ['immediate'], - ['userBlocking'], - ['normal'], - ['low'], - ['idle'], - ])('Strategy: %p', (strategy) => { - let behavior: RxRenderBehavior; + forEachStrategy((strategy) => { + describe('parent: true', () => { + beforeEach(() => { + component.strategy = strategy; + component.parent = true; + fixture.detectChanges(); + component.itemsCold$.next([1, 2]); + }); + + it('should update ViewChild', async () => { + await rendered(component, behavior); + expect(component.listChildren.length).toBe(2); + }); + + it('should update parent', async () => { + const cdRef = (component.forChildren.first as any).cdRef; + cdRef.detectChanges = jest.fn(); + await rendered(component, behavior); + expect(cdRef.detectChanges).toHaveBeenCalled(); + }); + + it('should scope parent notifications', async () => { + const cdRef = (component.forChildren.first as any).cdRef; + const cdRef2 = (component.forChildren.last as any).cdRef; + expect(cdRef2).toEqual(cdRef); + cdRef.detectChanges = jest.fn(); + await rendered(component, behavior); + expect(cdRef.detectChanges).toHaveBeenCalledTimes(1); + }); + }); - beforeEach(() => { - behavior = strategyProvider.strategies[strategy].behavior; + describe('parent: false', () => { + beforeEach(() => { + component.strategy = strategy; + component.parent = false; + fixture.detectChanges(); + component.itemsCold$.next([1, 2]); + }); + + it('should not update ViewChild', async () => { + await rendered(component, behavior); + expect(component.listChildren.length).toBe(0); + }); + + it('should not update parent', async () => { + const cdRef = (component.forChildren.first as any).cdRef; + cdRef.detectChanges = jest.fn(); + const behavior = strategyProvider.strategies[strategy].behavior; + await rendered(component, behavior); + expect(cdRef.detectChanges).not.toHaveBeenCalled(); + }); + }); }); + /*describe.each([ + ['immediate'], + ['userBlocking'], + ['normal'], + ['low'], + ['idle'], + ])('Strategy: %p', (strategy) => { + let behavior: RxRenderBehavior; + beforeEach(() => { + behavior = strategyProvider.strategies[strategy].behavior; + }); - });*/ - }); - describe('signal queries', () => { - let fixture: ComponentFixture; - let component: ParentNotifySignalTestComponent; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ParentNotifySignalTestComponent], - }); - fixture = TestBed.createComponent(ParentNotifySignalTestComponent); - component = fixture.componentInstance; - strategyProvider = TestBed.inject(RxStrategyProvider); + });*/ }); - forEachStrategy((strategy) => { - describe('parent: false', () => { - beforeEach(() => { - component.strategy = strategy; - fixture.detectChanges(); - component.itemsCold$.next([1, 2]); - }); + describe('signal queries', () => { + let fixture: ComponentFixture; + let component: ParentNotifySignalTestComponent; - it('should update viewchildren', async () => { - await rendered(component, behavior); - expect(component.listChildren().length).toBe(2); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ParentNotifySignalTestComponent], + providers: [ + conciler === 'legacy' + ? provideLegacyRxForReconciliation() + : provideExperimentalRxForReconciliation(), + ], }); + fixture = TestBed.createComponent(ParentNotifySignalTestComponent); + component = fixture.componentInstance; + strategyProvider = TestBed.inject(RxStrategyProvider); + }); - it('should not update parent', async () => { - const cdRef = (component.forChildren()[0] as any)?.cdRef; - cdRef.detectChanges = jest.fn(); - const behavior = strategyProvider.strategies[strategy].behavior; - await rendered(component, behavior); - expect(cdRef.detectChanges).not.toHaveBeenCalled(); + forEachStrategy((strategy) => { + describe('parent: false', () => { + beforeEach(() => { + component.strategy = strategy; + fixture.detectChanges(); + component.itemsCold$.next([1, 2]); + }); + + it('should update viewchildren', async () => { + await rendered(component, behavior); + expect(component.listChildren().length).toBe(2); + }); + + it('should not update parent', async () => { + const cdRef = (component.forChildren()[0] as any)?.cdRef; + cdRef.detectChanges = jest.fn(); + const behavior = strategyProvider.strategies[strategy].behavior; + await rendered(component, behavior); + expect(cdRef.detectChanges).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/libs/template/for/src/lib/tests/for.directive.signal.spec.ts b/libs/template/for/src/lib/tests/for.directive.signal.spec.ts index b57f09c75a..a19c0af5af 100644 --- a/libs/template/for/src/lib/tests/for.directive.signal.spec.ts +++ b/libs/template/for/src/lib/tests/for.directive.signal.spec.ts @@ -1,7 +1,9 @@ import { ErrorHandler } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; +import { provideExperimentalRxForReconciliation } from '../provide-experimental-reconciler'; +import { provideLegacyRxForReconciliation } from '../provide-legacy-reconciler'; import { createErrorHandler, createTestComponent as utilCreateTestComponent, @@ -10,10 +12,6 @@ import { thisArg, } from './fixtures'; -const customErrorHandler: ErrorHandler = { - handleError: jest.fn(), -}; - function createTestComponent( template = `
{{item.toString()}};
`, ) { @@ -21,580 +19,585 @@ function createTestComponent( } describe('rxFor with signals', () => { - let fixture: ComponentFixture; - let errorHandler: ErrorHandler; - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + describe.each([['legacy'], ['new']])('conciler: %p', (conciler) => { + let fixture: ComponentFixture; + let errorHandler: ErrorHandler; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - function getComponent(): TestComponent { - return fixture.componentInstance; - } + function getComponent(): TestComponent { + return fixture.componentInstance; + } - function detectChangesAndExpectText(text: string): void { - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toBe(text); - } + function detectChangesAndExpectText(text: string): void { + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(text); + } - function expectText(text: string) { - expect(fixture.nativeElement.textContent).toBe(text); - } + function expectText(text: string) { + expect(fixture.nativeElement.textContent).toBe(text); + } - afterEach(() => { - fixture = null as any; - errorHandler = null as any; - }); + afterEach(() => { + fixture = null as any; + errorHandler = null as any; + }); + + beforeEach(() => { + const customErrorHandler: ErrorHandler = { + handleError: jest.fn(), + }; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [TestComponent], - providers: [ - { - provide: ErrorHandler, - useValue: customErrorHandler, - }, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', + TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [ + { + provide: ErrorHandler, + useValue: customErrorHandler, }, - }, - ], + provideRxRenderStrategies({ primaryStrategy: 'native' }), + conciler === 'legacy' + ? provideLegacyRxForReconciliation() + : provideExperimentalRxForReconciliation(), + ], + }); + warnSpy.mockClear(); }); - warnSpy.mockClear(); - }); - it('should reflect initial elements', waitForAsync(() => { - fixture = createTestComponent(); - detectChangesAndExpectText('1;2;'); - })); - - it('should reflect added elements', () => { - fixture = createTestComponent(); - fixture.detectChanges(); - getComponent().itemsHotSignal.update((x) => { - x.push(3); - return [...x]; + it('should reflect initial elements', waitForAsync(() => { + fixture = createTestComponent(); + detectChangesAndExpectText('1;2;'); + })); + + it('should reflect added elements', () => { + fixture = createTestComponent(); + fixture.detectChanges(); + getComponent().itemsHotSignal.update((x) => { + x.push(3); + return [...x]; + }); + TestBed.flushEffects(); + expectText('1;2;3;'); }); - TestBed.flushEffects(); - expectText('1;2;3;'); - }); - it('should reflect removed elements', () => { - fixture = createTestComponent(); - fixture.detectChanges(); - const newValues = getComponent().itemsHotSignal(); - newValues.splice(1, 1); - getComponent().itemsHotSignal.set([...newValues]); - TestBed.flushEffects(); - expectText('1;'); - }); + it('should reflect removed elements', () => { + fixture = createTestComponent(); + fixture.detectChanges(); + const newValues = getComponent().itemsHotSignal(); + newValues.splice(1, 1); + getComponent().itemsHotSignal.set([...newValues]); + TestBed.flushEffects(); + expectText('1;'); + }); - it('should reflect moved elements', () => { - fixture = createTestComponent(); - fixture.detectChanges(); - const newValues = getComponent().itemsHotSignal(); - newValues.splice(0, 1); - newValues.push(1); - getComponent().itemsHotSignal.set([...newValues]); - TestBed.flushEffects(); - expectText('2;1;'); - }); + it('should reflect moved elements', () => { + fixture = createTestComponent(); + fixture.detectChanges(); + const newValues = getComponent().itemsHotSignal(); + newValues.splice(0, 1); + newValues.push(1); + getComponent().itemsHotSignal.set([...newValues]); + TestBed.flushEffects(); + expectText('2;1;'); + }); - it('should reflect a mix of all changes (additions/removals/moves)', () => { - fixture = createTestComponent(); - fixture.detectChanges(); - getComponent().itemsHotSignal.set([0, 1, 2, 3, 4, 5]); - getComponent().itemsHotSignal.set([6, 2, 7, 0, 4, 8]); + it('should reflect a mix of all changes (additions/removals/moves)', () => { + fixture = createTestComponent(); + fixture.detectChanges(); + getComponent().itemsHotSignal.set([0, 1, 2, 3, 4, 5]); + getComponent().itemsHotSignal.set([6, 2, 7, 0, 4, 8]); - TestBed.flushEffects(); - expectText('6;2;7;0;4;8;'); - }); + TestBed.flushEffects(); + expectText('6;2;7;0;4;8;'); + }); - it('should iterate over an array of objects', waitForAsync(() => { - const template = - '
  • {{item["name"]}};
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - // INIT - getComponent().itemsHotSignal.set([{ name: 'misko' }, { name: 'shyam' }]); - TestBed.flushEffects(); - expectText('misko;shyam;'); - - // GROW - const values = getComponent().itemsHotSignal(); - values.push({ name: 'adam' }); - getComponent().itemsHotSignal.set([...values]); - TestBed.flushEffects(); - expectText('misko;shyam;adam;'); - - // SHRINK - values.splice(2, 1); - values.splice(0, 1); - getComponent().itemsHotSignal.set([...values]); - TestBed.flushEffects(); - expectText('shyam;'); - })); - - it('should gracefully handle nulls', waitForAsync(() => { - const template = - '
  • {{item}};
'; - fixture = createTestComponent(template); - getComponent().itemsHotSignal.set(null); - errorHandler = createErrorHandler(); - fixture.detectChanges(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - expectText(''); - expect(errorSpy).toBeCalledTimes(0); - errorSpy.mockClear(); - })); - - it('should gracefully handle ref changing to null and back', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - detectChangesAndExpectText('1;2;'); - - getComponent().itemsHotSignal.set(null); - TestBed.flushEffects(); - expectText(''); - - getComponent().itemsHotSignal.set([1, 2, 3]); - TestBed.flushEffects(); - expectText('1;2;3;'); - expect(errorSpy).toBeCalledTimes(0); - errorSpy.mockClear(); - })); - - it('should throw on non-iterable ref and suggest using an array', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - const expectedError = new Error( - "NG0901: Cannot find a differ supporting object 'whaaa' of type 'string'", - ); - getComponent().itemsHotSignal.set('whaaa'); - fixture.detectChanges(); - expect(errorSpy).toHaveBeenCalledWith(expectedError); - errorSpy.mockClear(); - })); - - it('should throw on ref changing to string', () => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - const expectedError = new Error( - "NG0900: Error trying to diff 'whaaa'. Only arrays and iterables are allowed", - ); - detectChangesAndExpectText('1;2;'); - - getComponent().itemsHotSignal.set('whaaa'); - TestBed.flushEffects(); - expect(errorSpy).toHaveBeenCalledWith(expectedError); - errorSpy.mockClear(); - }); + it('should iterate over an array of objects', waitForAsync(() => { + const template = + '
  • {{item["name"]}};
'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + // INIT + getComponent().itemsHotSignal.set([{ name: 'misko' }, { name: 'shyam' }]); + TestBed.flushEffects(); + expectText('misko;shyam;'); + + // GROW + const values = getComponent().itemsHotSignal(); + values.push({ name: 'adam' }); + getComponent().itemsHotSignal.set([...values]); + TestBed.flushEffects(); + expectText('misko;shyam;adam;'); + + // SHRINK + values.splice(2, 1); + values.splice(0, 1); + getComponent().itemsHotSignal.set([...values]); + TestBed.flushEffects(); + expectText('shyam;'); + })); - it('should works with duplicates', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - - const a = new Foo(); - getComponent().itemsHotSignal.set([a, a]); - TestBed.flushEffects(); - expectText('foo;foo;'); - })); - - it('should repeat over nested arrays', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{item.length}};
|' + - '
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([['a', 'b'], ['c']]); - TestBed.flushEffects(); - expectText('a-2;b-2;|c-1;|'); - - getComponent().itemsHotSignal.set([['e'], ['f', 'g']]); - TestBed.flushEffects(); - expectText('e-1;|f-2;g-2;|'); - })); - - it('should repeat over nested arrays with no intermediate element', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{item.length}};
' + - '
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHotSignal.set([['a', 'b'], ['c']]); - TestBed.flushEffects(); - expectText('a-2;b-2;c-1;'); - - getComponent().itemsHotSignal.set([['e'], ['f', 'g']]); - TestBed.flushEffects(); - expectText('e-1;f-2;g-2;'); - })); - - it('should repeat over nested arrays using select with no intermediate element', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{col.length}};
' + - '
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHotSignal.set([{ items: ['a', 'b', 'c'] }]); - TestBed.flushEffects(); - expectText('a-3;b-3;c-3;'); - - getComponent().itemsHotSignal.set([{ items: ['d', 'e', 'f'] }]); - TestBed.flushEffects(); - expectText('d-3;e-3;f-3;'); - })); - - it('should repeat over nested ngIf that are the last node in the rxFor template', waitForAsync(() => { - const template = - `
` + - `
{{i}}|
` + - `
even|
` + - `
`; - - fixture = createTestComponent(template); - fixture.detectChanges(); - - const items = [1]; - getComponent().itemsHotSignal.set([...items]); - TestBed.flushEffects(); - expectText('0|even|'); - - items.push(1); - getComponent().itemsHotSignal.set([...items]); - TestBed.flushEffects(); - expectText('0|even|1|'); - - items.push(1); - getComponent().itemsHotSignal.set([...items]); - TestBed.flushEffects(); - expectText('0|even|1|2|even|'); - })); - - it('should allow of saving the collection', waitForAsync(() => { - const template = - '
  • {{i}}/{{collection.length}} -' + - ' {{item}};
'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - expectText('0/2 - 1;1/2 - 2;'); - - getComponent().itemsHotSignal.set([1, 2, 3]); - TestBed.flushEffects(); - expectText('0/3 - 1;1/3 - 2;2/3 - 3;'); - })); - - it('should display indices correctly', waitForAsync(() => { - const template = - '{{i.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - TestBed.flushEffects(); - expectText('0123456789'); - - getComponent().itemsHotSignal.set([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); - TestBed.flushEffects(); - expectText('0123456789'); - })); - - it('should display indices$ correctly', waitForAsync(() => { - const template = - '{{(i | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - TestBed.flushEffects(); - expectText('0123456789'); - - getComponent().itemsHotSignal.set([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); - TestBed.flushEffects(); - expectText('0123456789'); - })); - - it('should display count correctly', waitForAsync(() => { - const template = - '{{len}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('333'); - - getComponent().itemsHotSignal.set([4, 3, 2, 1, 0, -1]); - TestBed.flushEffects(); - expectText('666666'); - })); - - it('should display count$ correctly', waitForAsync(() => { - const template = - '{{len | async }}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('333'); - - getComponent().itemsHotSignal.set([4, 3, 2, 1, 0, -1]); - TestBed.flushEffects(); - expectText('666666'); - })); - - it('should display first item correctly', waitForAsync(() => { - const template = - '{{isFirst.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('truefalsefalse'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('truefalse'); - })); - - it('should display first$ item correctly', waitForAsync(() => { - const template = - '{{(isFirst | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('truefalsefalse'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('truefalse'); - })); - - it('should display last item correctly', waitForAsync(() => { - const template = - '{{isLast.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('falsefalsetrue'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('falsetrue'); - })); - - it('should display last item correctly', waitForAsync(() => { - const template = - '{{(isLast | async ).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('falsefalsetrue'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('falsetrue'); - })); - - it('should display even items correctly', waitForAsync(() => { - const template = - '{{isEven.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('truefalsetrue'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('truefalse'); - })); - - it('should display even$ items correctly', waitForAsync(() => { - const template = - '{{(isEven | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2]); - TestBed.flushEffects(); - expectText('truefalsetrue'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('truefalse'); - })); - - it('should display odd items correctly', waitForAsync(() => { - const template = - '{{isOdd.toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2, 3]); - TestBed.flushEffects(); - expectText('falsetruefalsetrue'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('falsetrue'); - })); - - it('should display odd$ items correctly', waitForAsync(() => { - const template = - '{{(isOdd | async).toString()}}'; - fixture = createTestComponent(template); - fixture.detectChanges(); - - getComponent().itemsHotSignal.set([0, 1, 2, 3]); - TestBed.flushEffects(); - expectText('falsetruefalsetrue'); - - getComponent().itemsHotSignal.set([2, 1]); - TestBed.flushEffects(); - expectText('falsetrue'); - })); - - it('should allow to use a custom template', waitForAsync(() => { - const template = - '' + - '

{{i}}: {{item}};

'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHotSignal.set(['a', 'b', 'c']); - TestBed.flushEffects(); - expectText('0: a;1: b;2: c;'); - })); - - it('should use a default template if a custom one is null', waitForAsync(() => { - const template = `
    {{i}}: {{item}};
`; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHotSignal.set(['a', 'b', 'c']); - TestBed.flushEffects(); - expectText('0: a;1: b;2: c;'); - })); - - it('should use a custom template when both default and a custom one are present', waitForAsync(() => { - const template = - '{{i}};' + - '{{i}}: {{item}};'; - fixture = createTestComponent(template); - fixture.detectChanges(); - getComponent().itemsHotSignal.set(['a', 'b', 'c']); - TestBed.flushEffects(); - expectText('0: a;1: b;2: c;'); - })); - - describe('track by', () => { - it('should console.warn if trackBy is not a function', waitForAsync(() => { - const template = `

`; + it('should gracefully handle nulls', waitForAsync(() => { + const template = + '
  • {{item}};
'; fixture = createTestComponent(template); - fixture.componentInstance.value = 0; + getComponent().itemsHotSignal.set(null); + errorHandler = createErrorHandler(); fixture.detectChanges(); - expect(warnSpy).toBeCalledTimes(1); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + + expectText(''); + expect(errorSpy).toBeCalledTimes(0); + errorSpy.mockClear(); })); - it('should track by identity when trackBy is to `null` or `undefined`', waitForAsync(() => { - const template = `

{{ item }}

`; + it('should gracefully handle ref changing to null and back', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + + detectChangesAndExpectText('1;2;'); + + getComponent().itemsHotSignal.set(null); + TestBed.flushEffects(); + expectText(''); + + getComponent().itemsHotSignal.set([1, 2, 3]); + TestBed.flushEffects(); + expectText('1;2;3;'); + expect(errorSpy).toBeCalledTimes(0); + errorSpy.mockClear(); + })); + + it('should throw on non-iterable ref and suggest using an array', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + const errorValue = 123; + const expectedError = new Error( + `Error trying to diff '${errorValue}'. Only arrays and iterables are allowed`, + ); + getComponent().itemsHotSignal.set(errorValue); + fixture.detectChanges(); + expect(errorSpy).toHaveBeenCalledWith(expectedError); + errorSpy.mockClear(); + })); + + it('should throw on ref changing to number', () => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + const errorValue = 123; + const expectedError = new Error( + `Error trying to diff '${errorValue}'. Only arrays and iterables are allowed`, + ); + detectChangesAndExpectText('1;2;'); + + getComponent().itemsHotSignal.set(errorValue); + TestBed.flushEffects(); + expect(errorSpy).toHaveBeenCalledWith(expectedError); + errorSpy.mockClear(); + }); + + it('should works with duplicates', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + + const a = new Foo(); + getComponent().itemsHotSignal.set([a, a]); + TestBed.flushEffects(); + expectText('foo;foo;'); + })); + + it('should repeat over nested arrays', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{item.length}};
|' + + '
'; fixture = createTestComponent(template); - fixture.componentInstance.itemsHotSignal.set(['a', 'b', 'c']); - fixture.componentInstance.value = null; - detectChangesAndExpectText('abc'); - fixture.componentInstance.value = undefined; - detectChangesAndExpectText('abc'); - expect(warnSpy).toBeCalledTimes(0); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([['a', 'b'], ['c']]); + TestBed.flushEffects(); + expectText('a-2;b-2;|c-1;|'); + + getComponent().itemsHotSignal.set([['e'], ['f', 'g']]); + TestBed.flushEffects(); + expectText('e-1;|f-2;g-2;|'); })); - it('should set the context to the component instance', waitForAsync(() => { - const template = `

`; + it('should repeat over nested arrays with no intermediate element', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{item.length}};
' + + '
'; fixture = createTestComponent(template); + fixture.detectChanges(); + getComponent().itemsHotSignal.set([['a', 'b'], ['c']]); + TestBed.flushEffects(); + expectText('a-2;b-2;c-1;'); + + getComponent().itemsHotSignal.set([['e'], ['f', 'g']]); + TestBed.flushEffects(); + expectText('e-1;f-2;g-2;'); + })); - setThis(null); + it('should repeat over nested arrays using select with no intermediate element', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{col.length}};
' + + '
'; + fixture = createTestComponent(template); fixture.detectChanges(); - expect(thisArg).toBe(getComponent()); + getComponent().itemsHotSignal.set([{ items: ['a', 'b', 'c'] }]); + TestBed.flushEffects(); + expectText('a-3;b-3;c-3;'); + + getComponent().itemsHotSignal.set([{ items: ['d', 'e', 'f'] }]); + TestBed.flushEffects(); + expectText('d-3;e-3;f-3;'); })); - it('should not replace tracked items', waitForAsync(() => { - const template = `

{{items[i]}}

`; + it('should repeat over nested ngIf that are the last node in the rxFor template', waitForAsync(() => { + const template = + `
` + + `
{{i}}|
` + + `
even|
` + + `
`; + fixture = createTestComponent(template); fixture.detectChanges(); - const buildItemList = () => { - getComponent().itemsHotSignal.set([{ id: 'a' }]); - return fixture.debugElement.queryAll(By.css('p'))[0]; - }; + const items = [1]; + getComponent().itemsHotSignal.set([...items]); + TestBed.flushEffects(); + expectText('0|even|'); + + items.push(1); + getComponent().itemsHotSignal.set([...items]); + TestBed.flushEffects(); + expectText('0|even|1|'); + + items.push(1); + getComponent().itemsHotSignal.set([...items]); + TestBed.flushEffects(); + expectText('0|even|1|2|even|'); + })); + + it('should allow of saving the collection', waitForAsync(() => { + const template = + '
  • {{i}}/{{collection.length}} -' + + ' {{item}};
'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + expectText('0/2 - 1;1/2 - 2;'); + + getComponent().itemsHotSignal.set([1, 2, 3]); + TestBed.flushEffects(); + expectText('0/3 - 1;1/3 - 2;2/3 - 3;'); + })); + + it('should display indices correctly', waitForAsync(() => { + const template = + '{{i.toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + TestBed.flushEffects(); + expectText('0123456789'); + + getComponent().itemsHotSignal.set([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); + TestBed.flushEffects(); + expectText('0123456789'); + })); + + it('should display indices$ correctly', waitForAsync(() => { + const template = + '{{(i | async).toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + TestBed.flushEffects(); + expectText('0123456789'); + + getComponent().itemsHotSignal.set([1, 2, 6, 7, 4, 3, 5, 8, 9, 0]); + TestBed.flushEffects(); + expectText('0123456789'); + })); + + it('should display count correctly', waitForAsync(() => { + const template = + '{{len}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2]); + TestBed.flushEffects(); + expectText('333'); + + getComponent().itemsHotSignal.set([4, 3, 2, 1, 0, -1]); + TestBed.flushEffects(); + expectText('666666'); + })); - const firstP = buildItemList(); - const finalP = buildItemList(); - expect(finalP.nativeElement).toBe(firstP.nativeElement); + it('should display count$ correctly', waitForAsync(() => { + const template = + '{{len | async }}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2]); + TestBed.flushEffects(); + expectText('333'); + + getComponent().itemsHotSignal.set([4, 3, 2, 1, 0, -1]); + TestBed.flushEffects(); + expectText('666666'); + })); + + it('should display first item correctly', waitForAsync(() => { + const template = + '{{isFirst.toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2]); + TestBed.flushEffects(); + expectText('truefalsefalse'); + + getComponent().itemsHotSignal.set([2, 1]); + TestBed.flushEffects(); + expectText('truefalse'); })); - it('should update implicit local variable on view', waitForAsync(() => { - const template = `
{{item['color']}}
`; + it('should display first$ item correctly', waitForAsync(() => { + const template = + '{{(isFirst | async).toString()}}'; fixture = createTestComponent(template); fixture.detectChanges(); - getComponent().itemsHotSignal.set([{ id: 'a', color: 'blue' }]); + getComponent().itemsHotSignal.set([0, 1, 2]); TestBed.flushEffects(); - expectText('blue'); + expectText('truefalsefalse'); - getComponent().itemsHotSignal.set([{ id: 'a', color: 'red' }]); + getComponent().itemsHotSignal.set([2, 1]); TestBed.flushEffects(); - expectText('red'); + expectText('truefalse'); })); - it('should move items around and keep them updated ', waitForAsync(() => { - const template = `
{{item['color']}}
`; + it('should display last item correctly', waitForAsync(() => { + const template = + '{{isLast.toString()}}'; fixture = createTestComponent(template); fixture.detectChanges(); - getComponent().itemsHotSignal.set([ - { id: 'a', color: 'blue' }, - { id: 'b', color: 'yellow' }, - ]); + getComponent().itemsHotSignal.set([0, 1, 2]); TestBed.flushEffects(); - expectText('blueyellow'); + expectText('falsefalsetrue'); - getComponent().itemsHotSignal.set([ - { id: 'b', color: 'orange' }, - { id: 'a', color: 'red' }, - ]); + getComponent().itemsHotSignal.set([2, 1]); TestBed.flushEffects(); - expectText('orangered'); + expectText('falsetrue'); })); - it('should handle added and removed items properly when tracking by index', waitForAsync(() => { - const template = `
{{item}}
`; + it('should display last item correctly', waitForAsync(() => { + const template = + '{{(isLast | async ).toString()}}'; fixture = createTestComponent(template); fixture.detectChanges(); - getComponent().itemsHotSignal.set(['a', 'b', 'c', 'd']); - getComponent().itemsHotSignal.set(['e', 'f', 'g', 'h']); - getComponent().itemsHotSignal.set(['e', 'f', 'h']); + getComponent().itemsHotSignal.set([0, 1, 2]); TestBed.flushEffects(); - expectText('efh'); + expectText('falsefalsetrue'); + + getComponent().itemsHotSignal.set([2, 1]); + TestBed.flushEffects(); + expectText('falsetrue'); })); + + it('should display even items correctly', waitForAsync(() => { + const template = + '{{isEven.toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2]); + TestBed.flushEffects(); + expectText('truefalsetrue'); + + getComponent().itemsHotSignal.set([2, 1]); + TestBed.flushEffects(); + expectText('truefalse'); + })); + + it('should display even$ items correctly', waitForAsync(() => { + const template = + '{{(isEven | async).toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2]); + TestBed.flushEffects(); + expectText('truefalsetrue'); + + getComponent().itemsHotSignal.set([2, 1]); + TestBed.flushEffects(); + expectText('truefalse'); + })); + + it('should display odd items correctly', waitForAsync(() => { + const template = + '{{isOdd.toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2, 3]); + TestBed.flushEffects(); + expectText('falsetruefalsetrue'); + + getComponent().itemsHotSignal.set([2, 1]); + TestBed.flushEffects(); + expectText('falsetrue'); + })); + + it('should display odd$ items correctly', waitForAsync(() => { + const template = + '{{(isOdd | async).toString()}}'; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([0, 1, 2, 3]); + TestBed.flushEffects(); + expectText('falsetruefalsetrue'); + + getComponent().itemsHotSignal.set([2, 1]); + TestBed.flushEffects(); + expectText('falsetrue'); + })); + + it('should allow to use a custom template', waitForAsync(() => { + const template = + '' + + '

{{i}}: {{item}};

'; + fixture = createTestComponent(template); + fixture.detectChanges(); + getComponent().itemsHotSignal.set(['a', 'b', 'c']); + TestBed.flushEffects(); + expectText('0: a;1: b;2: c;'); + })); + + it('should use a default template if a custom one is null', waitForAsync(() => { + const template = `
    {{i}}: {{item}};
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + getComponent().itemsHotSignal.set(['a', 'b', 'c']); + TestBed.flushEffects(); + expectText('0: a;1: b;2: c;'); + })); + + it('should use a custom template when both default and a custom one are present', waitForAsync(() => { + const template = + '{{i}};' + + '{{i}}: {{item}};'; + fixture = createTestComponent(template); + fixture.detectChanges(); + getComponent().itemsHotSignal.set(['a', 'b', 'c']); + TestBed.flushEffects(); + expectText('0: a;1: b;2: c;'); + })); + + describe('track by', () => { + it('should console.warn if trackBy is not a function', waitForAsync(() => { + const template = `

`; + fixture = createTestComponent(template); + fixture.componentInstance.value = 0; + fixture.detectChanges(); + expect(warnSpy).toBeCalledTimes(1); + })); + + it('should track by identity when trackBy is to `null` or `undefined`', waitForAsync(() => { + const template = `

{{ item }}

`; + fixture = createTestComponent(template); + fixture.componentInstance.itemsHotSignal.set(['a', 'b', 'c']); + fixture.componentInstance.value = null; + detectChangesAndExpectText('abc'); + fixture.componentInstance.value = undefined; + detectChangesAndExpectText('abc'); + expect(warnSpy).toBeCalledTimes(0); + })); + + it('should set the context to the component instance', waitForAsync(() => { + const template = `

`; + fixture = createTestComponent(template); + + setThis(null); + fixture.detectChanges(); + expect(thisArg).toBe(getComponent()); + })); + + it('should not replace tracked items', waitForAsync(() => { + const template = `

{{items[i]}}

`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + const buildItemList = () => { + getComponent().itemsHotSignal.set([{ id: 'a' }]); + return fixture.debugElement.queryAll(By.css('p'))[0]; + }; + + const firstP = buildItemList(); + const finalP = buildItemList(); + expect(finalP.nativeElement).toBe(firstP.nativeElement); + })); + + it('should update implicit local variable on view', waitForAsync(() => { + const template = `
{{item['color']}}
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([{ id: 'a', color: 'blue' }]); + TestBed.flushEffects(); + expectText('blue'); + + getComponent().itemsHotSignal.set([{ id: 'a', color: 'red' }]); + TestBed.flushEffects(); + expectText('red'); + })); + + it('should move items around and keep them updated ', waitForAsync(() => { + const template = `
{{item['color']}}
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set([ + { id: 'a', color: 'blue' }, + { id: 'b', color: 'yellow' }, + ]); + TestBed.flushEffects(); + expectText('blueyellow'); + + getComponent().itemsHotSignal.set([ + { id: 'b', color: 'orange' }, + { id: 'a', color: 'red' }, + ]); + TestBed.flushEffects(); + expectText('orangered'); + })); + + it('should handle added and removed items properly when tracking by index', waitForAsync(() => { + const template = `
{{item}}
`; + fixture = createTestComponent(template); + fixture.detectChanges(); + + getComponent().itemsHotSignal.set(['a', 'b', 'c', 'd']); + getComponent().itemsHotSignal.set(['e', 'f', 'g', 'h']); + getComponent().itemsHotSignal.set(['e', 'f', 'h']); + TestBed.flushEffects(); + expectText('efh'); + })); + }); }); }); diff --git a/libs/template/for/src/lib/tests/for.directive.spec.ts b/libs/template/for/src/lib/tests/for.directive.spec.ts index 18ec06f30b..21ce3f3a69 100644 --- a/libs/template/for/src/lib/tests/for.directive.spec.ts +++ b/libs/template/for/src/lib/tests/for.directive.spec.ts @@ -1,7 +1,9 @@ import { ErrorHandler } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; +import { provideExperimentalRxForReconciliation } from '../provide-experimental-reconciler'; +import { provideLegacyRxForReconciliation } from '../provide-legacy-reconciler'; import { createErrorHandler, createTestComponent, @@ -10,411 +12,412 @@ import { thisArg, } from './fixtures'; -const customErrorHandler: ErrorHandler = { - handleError: jest.fn(), -}; - describe('rxFor', () => { - let fixture: ComponentFixture; - let errorHandler: ErrorHandler; - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + describe.each([['legacy'], ['new']])('conciler: %p', (conciler) => { + let fixture: ComponentFixture; + let errorHandler: ErrorHandler; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - function getComponent(): TestComponent { - return fixture.componentInstance; - } + function getComponent(): TestComponent { + return fixture.componentInstance; + } - function detectChangesAndExpectText(text: string): void { - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toBe(text); - } + function detectChangesAndExpectText(text: string): void { + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(text); + } - afterEach(() => { - fixture = null as any; - errorHandler = null as any; - }); + afterEach(() => { + fixture = null as any; + errorHandler = null as any; + }); - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [TestComponent], - providers: [ - { - provide: ErrorHandler, - useValue: customErrorHandler, - }, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', + beforeEach(() => { + const customErrorHandler: ErrorHandler = { + handleError: jest.fn(), + }; + TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [ + { + provide: ErrorHandler, + useValue: customErrorHandler, }, - }, - ], + provideRxRenderStrategies({ primaryStrategy: 'native' }), + conciler === 'legacy' + ? provideLegacyRxForReconciliation() + : provideExperimentalRxForReconciliation(), + ], + }); + warnSpy.mockClear(); }); - warnSpy.mockClear(); - }); - it('should reflect initial elements', waitForAsync(() => { - fixture = createTestComponent(); - - detectChangesAndExpectText('1;2;'); - })); - - it('should reflect added elements', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - getComponent().items.push(3); - detectChangesAndExpectText('1;2;3;'); - })); - - it('should reflect removed elements', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - getComponent().items.splice(1, 1); - detectChangesAndExpectText('1;'); - })); - - it('should reflect moved elements', waitForAsync(() => { - fixture = createTestComponent(); - fixture.detectChanges(); - getComponent().items.splice(0, 1); - getComponent().items.push(1); - detectChangesAndExpectText('2;1;'); - })); - - it('should reflect a mix of all changes (additions/removals/moves)', waitForAsync(() => { - fixture = createTestComponent(); - - getComponent().items = [0, 1, 2, 3, 4, 5]; - fixture.detectChanges(); - - getComponent().items = [6, 2, 7, 0, 4, 8]; - - detectChangesAndExpectText('6;2;7;0;4;8;'); - })); - - it('should iterate over an array of objects', waitForAsync(() => { - const template = - '
  • {{item["name"]}};
'; - fixture = createTestComponent(template); - - // INIT - getComponent().items = [{ name: 'misko' }, { name: 'shyam' }]; - detectChangesAndExpectText('misko;shyam;'); - - // GROW - getComponent().items.push({ name: 'adam' }); - detectChangesAndExpectText('misko;shyam;adam;'); - - // SHRINK - getComponent().items.splice(2, 1); - getComponent().items.splice(0, 1); - detectChangesAndExpectText('shyam;'); - })); - - it('should gracefully handle nulls', waitForAsync(() => { - const template = '
  • {{item}};
'; - fixture = createTestComponent(template); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - detectChangesAndExpectText(''); - expect(errorSpy).toBeCalledTimes(0); - errorSpy.mockClear(); - })); - - it('should gracefully handle ref changing to null and back', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - - detectChangesAndExpectText('1;2;'); - - getComponent().items = null!; - detectChangesAndExpectText(''); - expect(errorSpy).toBeCalledTimes(0); - - getComponent().items = [1, 2, 3]; - detectChangesAndExpectText('1;2;3;'); - errorSpy.mockClear(); - })); - - it('should throw on non-iterable ref and suggest using an array', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - const expectedError = new Error( - "NG0901: Cannot find a differ supporting object 'whaaa' of type 'string'", - ); - getComponent().items = 'whaaa'; - fixture.detectChanges(); - expect(errorSpy).toHaveBeenCalledWith(expectedError); - errorSpy.mockClear(); - })); - - it('should throw on ref changing to string', waitForAsync(() => { - fixture = createTestComponent(); - errorHandler = createErrorHandler(); - const errorSpy = jest.spyOn(errorHandler, 'handleError'); - const expectedError = new Error( - "NG0900: Error trying to diff 'whaaa'. Only arrays and iterables are allowed", - ); - detectChangesAndExpectText('1;2;'); - - getComponent().items = 'whaaa'; - fixture.detectChanges(); - expect(errorSpy).toHaveBeenCalledWith(expectedError); - errorSpy.mockClear(); - })); - - it('should works with duplicates', waitForAsync(() => { - fixture = createTestComponent(); - - const a = new Foo(); - getComponent().items = [a, a]; - detectChangesAndExpectText('foo;foo;'); - })); - - it('should repeat over nested arrays', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{item.length}};
|' + - '
'; - fixture = createTestComponent(template); - - getComponent().items = [['a', 'b'], ['c']]; - detectChangesAndExpectText('a-2;b-2;|c-1;|'); - - getComponent().items = [['e'], ['f', 'g']]; - detectChangesAndExpectText('e-1;|f-2;g-2;|'); - })); - - it('should repeat over nested arrays with no intermediate element', waitForAsync(() => { - const template = - '
' + - '
{{subitem}}-{{item.length}};
' + - '
'; - fixture = createTestComponent(template); - - getComponent().items = [['a', 'b'], ['c']]; - detectChangesAndExpectText('a-2;b-2;c-1;'); - - getComponent().items = [['e'], ['f', 'g']]; - detectChangesAndExpectText('e-1;f-2;g-2;'); - })); - - it('should repeat over nested ngIf that are the last node in the rxFor template', waitForAsync(() => { - const template = - `
` + - `
{{i}}|
` + - `
even|
` + - `
`; - - fixture = createTestComponent(template); - - const items = [1]; - getComponent().items = items; - detectChangesAndExpectText('0|even|'); - - items.push(1); - detectChangesAndExpectText('0|even|1|'); - - items.push(1); - detectChangesAndExpectText('0|even|1|2|even|'); - })); - - it('should allow of saving the collection', waitForAsync(() => { - const template = - '
  • {{i}}/{{collection.length}} - {{item}};
'; - fixture = createTestComponent(template); - - detectChangesAndExpectText('0/2 - 1;1/2 - 2;'); - - getComponent().items = [1, 2, 3]; - detectChangesAndExpectText('0/3 - 1;1/3 - 2;2/3 - 3;'); - })); - - it('should display indices correctly', waitForAsync(() => { - const template = - '{{i.toString()}}'; - fixture = createTestComponent(template); - - getComponent().items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; - detectChangesAndExpectText('0123456789'); - - getComponent().items = [1, 2, 6, 7, 4, 3, 5, 8, 9, 0]; - detectChangesAndExpectText('0123456789'); - })); - - it('should display count correctly', waitForAsync(() => { - const template = - '{{len}}'; - fixture = createTestComponent(template); - - getComponent().items = [0, 1, 2]; - detectChangesAndExpectText('333'); - - getComponent().items = [4, 3, 2, 1, 0, -1]; - detectChangesAndExpectText('666666'); - })); - - it('should display first item correctly', waitForAsync(() => { - const template = - '{{isFirst.toString()}}'; - fixture = createTestComponent(template); - - getComponent().items = [0, 1, 2]; - detectChangesAndExpectText('truefalsefalse'); - - getComponent().items = [2, 1]; - detectChangesAndExpectText('truefalse'); - })); - - it('should display last item correctly', waitForAsync(() => { - const template = - '{{isLast.toString()}}'; - fixture = createTestComponent(template); - - getComponent().items = [0, 1, 2]; - detectChangesAndExpectText('falsefalsetrue'); - - getComponent().items = [2, 1]; - detectChangesAndExpectText('falsetrue'); - })); - - it('should display even items correctly', waitForAsync(() => { - const template = - '{{isEven.toString()}}'; - fixture = createTestComponent(template); - - getComponent().items = [0, 1, 2]; - detectChangesAndExpectText('truefalsetrue'); - - getComponent().items = [2, 1]; - detectChangesAndExpectText('truefalse'); - })); - - it('should display odd items correctly', waitForAsync(() => { - const template = - '{{isOdd.toString()}}'; - fixture = createTestComponent(template); - - getComponent().items = [0, 1, 2, 3]; - detectChangesAndExpectText('falsetruefalsetrue'); - - getComponent().items = [2, 1]; - detectChangesAndExpectText('falsetrue'); - })); - - it('should allow to use a custom template', waitForAsync(() => { - const template = - '' + - '

{{i}}: {{item}};

'; - fixture = createTestComponent(template); - getComponent().items = ['a', 'b', 'c']; - fixture.detectChanges(); - detectChangesAndExpectText('0: a;1: b;2: c;'); - })); - - it('should use a default template if a custom one is null', waitForAsync(() => { - const template = `
    {{i}}: {{item}};
`; - fixture = createTestComponent(template); - getComponent().items = ['a', 'b', 'c']; - fixture.detectChanges(); - detectChangesAndExpectText('0: a;1: b;2: c;'); - })); - - it('should use a custom template when both default and a custom one are present', waitForAsync(() => { - const template = - '{{i}};' + - '{{i}}: {{item}};'; - fixture = createTestComponent(template); - getComponent().items = ['a', 'b', 'c']; - fixture.detectChanges(); - detectChangesAndExpectText('0: a;1: b;2: c;'); - })); - - describe('track by', () => { - it('should console.warn if trackBy is not a function', waitForAsync(() => { - const template = `

`; - fixture = createTestComponent(template); - fixture.componentInstance.value = 0; + it('should reflect initial elements', waitForAsync(() => { + fixture = createTestComponent(); + + detectChangesAndExpectText('1;2;'); + })); + + it('should reflect added elements', waitForAsync(() => { + fixture = createTestComponent(); fixture.detectChanges(); - expect(warnSpy).toBeCalledTimes(1); + getComponent().items.push(3); + detectChangesAndExpectText('1;2;3;'); + })); + + it('should reflect removed elements', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + getComponent().items.splice(1, 1); + detectChangesAndExpectText('1;'); + })); + + it('should reflect moved elements', waitForAsync(() => { + fixture = createTestComponent(); + fixture.detectChanges(); + getComponent().items.splice(0, 1); + getComponent().items.push(1); + detectChangesAndExpectText('2;1;'); + })); + + it('should reflect a mix of all changes (additions/removals/moves)', waitForAsync(() => { + fixture = createTestComponent(); + + getComponent().items = [0, 1, 2, 3, 4, 5]; + fixture.detectChanges(); + + getComponent().items = [6, 2, 7, 0, 4, 8]; + + detectChangesAndExpectText('6;2;7;0;4;8;'); })); - it('should track by identity when trackBy is to `null` or `undefined`', waitForAsync(() => { - const template = `

{{ item }}

`; + it('should iterate over an array of objects', waitForAsync(() => { + const template = + '
  • {{item["name"]}};
'; fixture = createTestComponent(template); - fixture.componentInstance.items = ['a', 'b', 'c']; - fixture.componentInstance.value = null; - detectChangesAndExpectText('abc'); - fixture.componentInstance.value = undefined; - detectChangesAndExpectText('abc'); - expect(warnSpy).toBeCalledTimes(0); + + // INIT + getComponent().items = [{ name: 'misko' }, { name: 'shyam' }]; + detectChangesAndExpectText('misko;shyam;'); + + // GROW + getComponent().items.push({ name: 'adam' }); + detectChangesAndExpectText('misko;shyam;adam;'); + + // SHRINK + getComponent().items.splice(2, 1); + getComponent().items.splice(0, 1); + detectChangesAndExpectText('shyam;'); })); - it('should set the context to the component instance', waitForAsync(() => { - const template = `

`; + it('should gracefully handle nulls', waitForAsync(() => { + const template = '
  • {{item}};
'; fixture = createTestComponent(template); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + + detectChangesAndExpectText(''); + expect(errorSpy).toBeCalledTimes(0); + errorSpy.mockClear(); + })); - setThis(null); + it('should gracefully handle ref changing to null and back', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + + detectChangesAndExpectText('1;2;'); + + getComponent().items = null!; + detectChangesAndExpectText(''); + expect(errorSpy).toBeCalledTimes(0); + + getComponent().items = [1, 2, 3]; + detectChangesAndExpectText('1;2;3;'); + errorSpy.mockClear(); + })); + + it('should throw on non-iterable ref and suggest using an array', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + const errorValue = 123; + const expectedError = new Error( + `Error trying to diff '${errorValue}'. Only arrays and iterables are allowed`, + ); + getComponent().items = errorValue; fixture.detectChanges(); - expect(thisArg).toBe(getComponent()); + expect(errorSpy).toHaveBeenCalledWith(expectedError); + errorSpy.mockClear(); })); - it('should not replace tracked items', waitForAsync(() => { - const template = `

{{items[i]}}

`; + it('should throw on ref changing to number', waitForAsync(() => { + fixture = createTestComponent(); + errorHandler = createErrorHandler(); + const errorSpy = jest.spyOn(errorHandler, 'handleError'); + const errorValue = 123; + const expectedError = new Error( + `Error trying to diff '${errorValue}'. Only arrays and iterables are allowed`, + ); + detectChangesAndExpectText('1;2;'); + + getComponent().items = errorValue; + fixture.detectChanges(); + expect(errorSpy).toHaveBeenCalledWith(expectedError); + errorSpy.mockClear(); + })); + + it('should works with duplicates', waitForAsync(() => { + fixture = createTestComponent(); + + const a = new Foo(); + getComponent().items = [a, a]; + detectChangesAndExpectText('foo;foo;'); + })); + + it('should repeat over nested arrays', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{item.length}};
|' + + '
'; fixture = createTestComponent(template); - const buildItemList = () => { - getComponent().items = [{ id: 'a' }]; - fixture.detectChanges(); - return fixture.debugElement.queryAll(By.css('p'))[0]; - }; + getComponent().items = [['a', 'b'], ['c']]; + detectChangesAndExpectText('a-2;b-2;|c-1;|'); + + getComponent().items = [['e'], ['f', 'g']]; + detectChangesAndExpectText('e-1;|f-2;g-2;|'); + })); + + it('should repeat over nested arrays with no intermediate element', waitForAsync(() => { + const template = + '
' + + '
{{subitem}}-{{item.length}};
' + + '
'; + fixture = createTestComponent(template); - const firstP = buildItemList(); - const finalP = buildItemList(); - expect(finalP.nativeElement).toBe(firstP.nativeElement); + getComponent().items = [['a', 'b'], ['c']]; + detectChangesAndExpectText('a-2;b-2;c-1;'); + + getComponent().items = [['e'], ['f', 'g']]; + detectChangesAndExpectText('e-1;f-2;g-2;'); + })); + + it('should repeat over nested ngIf that are the last node in the rxFor template', waitForAsync(() => { + const template = + `
` + + `
{{i}}|
` + + `
even|
` + + `
`; + + fixture = createTestComponent(template); + + const items = [1]; + getComponent().items = items; + detectChangesAndExpectText('0|even|'); + + items.push(1); + detectChangesAndExpectText('0|even|1|'); + + items.push(1); + detectChangesAndExpectText('0|even|1|2|even|'); + })); + + it('should allow of saving the collection', waitForAsync(() => { + const template = + '
  • {{i}}/{{collection.length}} - {{item}};
'; + fixture = createTestComponent(template); + + detectChangesAndExpectText('0/2 - 1;1/2 - 2;'); + + getComponent().items = [1, 2, 3]; + detectChangesAndExpectText('0/3 - 1;1/3 - 2;2/3 - 3;'); + })); + + it('should display indices correctly', waitForAsync(() => { + const template = + '{{i.toString()}}'; + fixture = createTestComponent(template); + + getComponent().items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + detectChangesAndExpectText('0123456789'); + + getComponent().items = [1, 2, 6, 7, 4, 3, 5, 8, 9, 0]; + detectChangesAndExpectText('0123456789'); + })); + + it('should display count correctly', waitForAsync(() => { + const template = + '{{len}}'; + fixture = createTestComponent(template); + + getComponent().items = [0, 1, 2]; + detectChangesAndExpectText('333'); + + getComponent().items = [4, 3, 2, 1, 0, -1]; + detectChangesAndExpectText('666666'); })); - it('should update implicit local variable on view', waitForAsync(() => { - const template = `
{{item['color']}}
`; + it('should display first item correctly', waitForAsync(() => { + const template = + '{{isFirst.toString()}}'; fixture = createTestComponent(template); - getComponent().items = [{ id: 'a', color: 'blue' }]; - detectChangesAndExpectText('blue'); + getComponent().items = [0, 1, 2]; + detectChangesAndExpectText('truefalsefalse'); - getComponent().items = [{ id: 'a', color: 'red' }]; - detectChangesAndExpectText('red'); + getComponent().items = [2, 1]; + detectChangesAndExpectText('truefalse'); })); - it('should move items around and keep them updated ', waitForAsync(() => { - const template = `
{{item['color']}}
`; + it('should display last item correctly', waitForAsync(() => { + const template = + '{{isLast.toString()}}'; fixture = createTestComponent(template); - getComponent().items = [ - { id: 'a', color: 'blue' }, - { id: 'b', color: 'yellow' }, - ]; - detectChangesAndExpectText('blueyellow'); - - getComponent().items = [ - { id: 'b', color: 'orange' }, - { id: 'a', color: 'red' }, - ]; - detectChangesAndExpectText('orangered'); + getComponent().items = [0, 1, 2]; + detectChangesAndExpectText('falsefalsetrue'); + + getComponent().items = [2, 1]; + detectChangesAndExpectText('falsetrue'); + })); + + it('should display even items correctly', waitForAsync(() => { + const template = + '{{isEven.toString()}}'; + fixture = createTestComponent(template); + + getComponent().items = [0, 1, 2]; + detectChangesAndExpectText('truefalsetrue'); + + getComponent().items = [2, 1]; + detectChangesAndExpectText('truefalse'); })); - it('should handle added and removed items properly when tracking by index', waitForAsync(() => { - const template = `
{{item}}
`; + it('should display odd items correctly', waitForAsync(() => { + const template = + '{{isOdd.toString()}}'; fixture = createTestComponent(template); - getComponent().items = ['a', 'b', 'c', 'd']; + getComponent().items = [0, 1, 2, 3]; + detectChangesAndExpectText('falsetruefalsetrue'); + + getComponent().items = [2, 1]; + detectChangesAndExpectText('falsetrue'); + })); + + it('should allow to use a custom template', waitForAsync(() => { + const template = + '' + + '

{{i}}: {{item}};

'; + fixture = createTestComponent(template); + getComponent().items = ['a', 'b', 'c']; fixture.detectChanges(); - getComponent().items = ['e', 'f', 'g', 'h']; + detectChangesAndExpectText('0: a;1: b;2: c;'); + })); + + it('should use a default template if a custom one is null', waitForAsync(() => { + const template = `
    {{i}}: {{item}};
`; + fixture = createTestComponent(template); + getComponent().items = ['a', 'b', 'c']; + fixture.detectChanges(); + detectChangesAndExpectText('0: a;1: b;2: c;'); + })); + + it('should use a custom template when both default and a custom one are present', waitForAsync(() => { + const template = + '{{i}};' + + '{{i}}: {{item}};'; + fixture = createTestComponent(template); + getComponent().items = ['a', 'b', 'c']; fixture.detectChanges(); - getComponent().items = ['e', 'f', 'h']; - detectChangesAndExpectText('efh'); + detectChangesAndExpectText('0: a;1: b;2: c;'); })); + + describe('track by', () => { + it('should console.warn if trackBy is not a function', waitForAsync(() => { + const template = `

`; + fixture = createTestComponent(template); + fixture.componentInstance.value = 0; + fixture.detectChanges(); + expect(warnSpy).toBeCalledTimes(1); + })); + + it('should track by identity when trackBy is to `null` or `undefined`', waitForAsync(() => { + const template = `

{{ item }}

`; + fixture = createTestComponent(template); + fixture.componentInstance.items = ['a', 'b', 'c']; + fixture.componentInstance.value = null; + detectChangesAndExpectText('abc'); + fixture.componentInstance.value = undefined; + detectChangesAndExpectText('abc'); + expect(warnSpy).toBeCalledTimes(0); + })); + + it('should set the context to the component instance', waitForAsync(() => { + const template = `

`; + fixture = createTestComponent(template); + + setThis(null); + fixture.detectChanges(); + expect(thisArg).toBe(getComponent()); + })); + + it('should not replace tracked items', waitForAsync(() => { + const template = `

{{items[i]}}

`; + fixture = createTestComponent(template); + + const buildItemList = () => { + getComponent().items = [{ id: 'a' }]; + fixture.detectChanges(); + return fixture.debugElement.queryAll(By.css('p'))[0]; + }; + + const firstP = buildItemList(); + const finalP = buildItemList(); + expect(finalP.nativeElement).toBe(firstP.nativeElement); + })); + + it('should update implicit local variable on view', waitForAsync(() => { + const template = `
{{item['color']}}
`; + fixture = createTestComponent(template); + + getComponent().items = [{ id: 'a', color: 'blue' }]; + detectChangesAndExpectText('blue'); + + getComponent().items = [{ id: 'a', color: 'red' }]; + detectChangesAndExpectText('red'); + })); + + it('should move items around and keep them updated ', waitForAsync(() => { + const template = `
{{item['color']}}
`; + fixture = createTestComponent(template); + + getComponent().items = [ + { id: 'a', color: 'blue' }, + { id: 'b', color: 'yellow' }, + ]; + detectChangesAndExpectText('blueyellow'); + + getComponent().items = [ + { id: 'b', color: 'orange' }, + { id: 'a', color: 'red' }, + ]; + detectChangesAndExpectText('orangered'); + })); + + it('should handle added and removed items properly when tracking by index', waitForAsync(() => { + const template = `
{{item}}
`; + fixture = createTestComponent(template); + + getComponent().items = ['a', 'b', 'c', 'd']; + fixture.detectChanges(); + getComponent().items = ['e', 'f', 'g', 'h']; + fixture.detectChanges(); + getComponent().items = ['e', 'f', 'h']; + detectChangesAndExpectText('efh'); + })); + }); }); }); diff --git a/libs/template/for/src/lib/tests/for.directive.strategy.spec.ts b/libs/template/for/src/lib/tests/for.directive.strategy.spec.ts index 672218a701..879f7fc03f 100644 --- a/libs/template/for/src/lib/tests/for.directive.strategy.spec.ts +++ b/libs/template/for/src/lib/tests/for.directive.strategy.spec.ts @@ -1,67 +1,69 @@ -import { ErrorHandler } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RxStrategyProvider } from '@rx-angular/cdk/render-strategies'; +import { provideExperimentalRxForReconciliation } from '../provide-experimental-reconciler'; +import { provideLegacyRxForReconciliation } from '../provide-legacy-reconciler'; import { createTestComponent, TestComponent } from './fixtures'; -const customErrorHandler: ErrorHandler = { - handleError: jest.fn(), -}; - describe('rxFor strategies', () => { - let fixture: ComponentFixture; - let errorHandler: ErrorHandler; - let nativeElement: HTMLElement; - let primaryStrategy: string; - let strategyProvider: RxStrategyProvider; - let component: TestComponent; + describe.each([['legacy'], ['new']])('conciler: %p', (conciler) => { + let fixture: ComponentFixture; + let nativeElement: HTMLElement; + let primaryStrategy: string; + let strategyProvider: RxStrategyProvider; + let component: TestComponent; - afterEach(() => { - fixture = null as any; - errorHandler = null as any; - }); + afterEach(() => { + fixture = null as any; + }); - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [TestComponent], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [ + conciler === 'legacy' + ? provideLegacyRxForReconciliation() + : provideExperimentalRxForReconciliation(), + ], + }); + fixture = createTestComponent( + `
{{item.toString()}};
`, + ); + component = fixture.componentInstance; + nativeElement = fixture.nativeElement; + strategyProvider = TestBed.inject(RxStrategyProvider); + primaryStrategy = strategyProvider.primaryStrategy; }); - fixture = createTestComponent( - `
{{item.toString()}};
`, - ); - component = fixture.componentInstance; - nativeElement = fixture.nativeElement; - strategyProvider = TestBed.inject(RxStrategyProvider); - primaryStrategy = strategyProvider.primaryStrategy; - }); - describe.each([ - [''] /* <- Invalid strategy should fallback. */, - ['invalid'] /* <- Same here. */, + describe.each([ + [''] /* <- Invalid strategy should fallback. */, + ['invalid'] /* <- Same here. */, - ['immediate'], - ['userBlocking'], - ['normal'], - ['low'], - ['idle'], - ['native'], - ])('Strategy: %p', (strategy) => { - it('should render with given strategy', (done) => { - component.strategy = strategy; - component.renderedValue$.subscribe((v) => { - expect(v).toEqual([1, 2]); - expect(nativeElement.textContent).toBe('1;2;'); - done(); + ['immediate'], + ['userBlocking'], + ['normal'], + ['low'], + ['idle'], + ['native'], + ])('Strategy: %p', (strategy) => { + it('should render with given strategy', (done) => { + component.strategy = strategy; + component.renderedValue$.subscribe((v) => { + expect(v).toEqual([1, 2]); + expect(nativeElement.textContent).toBe('1;2;'); + done(); + }); + fixture.detectChanges(); }); - fixture.detectChanges(); - }); - it('should not affect primary strategy', (done) => { - component.strategy = strategy; - component.renderedValue$.subscribe((v) => { - expect(v).toEqual([1, 2]); - expect(nativeElement.textContent).toBe('1;2;'); - expect(strategyProvider.primaryStrategy).toBe(primaryStrategy); - done(); + it('should not affect primary strategy', (done) => { + component.strategy = strategy; + component.renderedValue$.subscribe((v) => { + expect(v).toEqual([1, 2]); + expect(nativeElement.textContent).toBe('1;2;'); + expect(strategyProvider.primaryStrategy).toBe(primaryStrategy); + done(); + }); + fixture.detectChanges(); }); - fixture.detectChanges(); }); }); }); diff --git a/libs/template/if/src/lib/if.directive.ts b/libs/template/if/src/lib/if.directive.ts index 072f6cc2a1..a1bd4d1d8a 100644 --- a/libs/template/if/src/lib/if.directive.ts +++ b/libs/template/if/src/lib/if.directive.ts @@ -57,7 +57,7 @@ import { * or triggering global change detection. * * Read more about the RxIf directive in the [official - * docs](https://www.rx-angular.io/docs/template/api/rx-if-directive). + * docs](https://www.rx-angular.io/docs/template/rx-if-directive). * * @example * @@ -125,7 +125,7 @@ export class RxIf * [`normal`](https://www.rx-angular.io/docs/template/cdk/render-strategies/strategies/concurrent-strategies). * * Read more about this in the - * [official docs](https://www.rx-angular.io/docs/template/api/rx-if-directive#use-render-strategies-strategy). + * [official docs](https://www.rx-angular.io/docs/template/rx-if-directive#use-render-strategies-strategy). * * @example * @@ -370,7 +370,7 @@ export class RxIf * * Read more about this in the * [official - * docs](https://www.rx-angular.io/docs/template/api/rx-if-directive#local-strategies-and-view-content-queries-parent). + * docs](https://www.rx-angular.io/docs/template/rx-if-directive#local-strategies-and-view-content-queries-parent). * * @example * \@Component({ @@ -406,7 +406,7 @@ export class RxIf * Especially high frequency events can cause performance issues. * * Read more about this in the - * [official docs](https://www.rx-angular.io/docs/template/api/let-directive#working-with-event-listeners-patchzone). + * [official docs](https://www.rx-angular.io/docs/template/let-directive#working-with-event-listeners-patchzone). * * @example * \@Component({ diff --git a/libs/template/if/src/lib/tests/if.directive.context-templates.spec.ts b/libs/template/if/src/lib/tests/if.directive.context-templates.spec.ts index f5350d3795..4cb15b8cfe 100644 --- a/libs/template/if/src/lib/tests/if.directive.context-templates.spec.ts +++ b/libs/template/if/src/lib/tests/if.directive.context-templates.spec.ts @@ -5,7 +5,7 @@ import { tick, } from '@angular/core/testing'; import { RxNotificationKind } from '@rx-angular/cdk/notifications'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { BehaviorSubject, @@ -52,22 +52,19 @@ const setupTestComponent = () => { TestBed.configureTestingModule({ imports: [TestComponent], providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'urgent', - customStrategies: { - urgent: { - name: 'urgent', - work: (cdRef) => cdRef.detectChanges(), - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), - }, + provideRxRenderStrategies({ + primaryStrategy: 'urgent', + customStrategies: { + urgent: { + name: 'urgent', + work: (cdRef) => cdRef.detectChanges(), + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], }); }; diff --git a/libs/template/if/src/lib/tests/if.directive.context.spec.ts b/libs/template/if/src/lib/tests/if.directive.context.spec.ts index 0926483ed6..0d8ada6f51 100644 --- a/libs/template/if/src/lib/tests/if.directive.context.spec.ts +++ b/libs/template/if/src/lib/tests/if.directive.context.spec.ts @@ -5,7 +5,7 @@ import { tick, } from '@angular/core/testing'; import { RxNotificationKind } from '@rx-angular/cdk/notifications'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { BehaviorSubject, @@ -59,22 +59,19 @@ const setupTestComponent = () => { TestBed.configureTestingModule({ imports: [TestComponent], providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'urgent', - customStrategies: { - urgent: { - name: 'urgent', - work: (cdRef) => cdRef.detectChanges(), - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), - }, + provideRxRenderStrategies({ + primaryStrategy: 'urgent', + customStrategies: { + urgent: { + name: 'urgent', + work: (cdRef) => cdRef.detectChanges(), + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], }); }; diff --git a/libs/template/if/src/lib/tests/if.directive.observable.spec.ts b/libs/template/if/src/lib/tests/if.directive.observable.spec.ts index e76fb5584f..ac6c80a50c 100644 --- a/libs/template/if/src/lib/tests/if.directive.observable.spec.ts +++ b/libs/template/if/src/lib/tests/if.directive.observable.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { BehaviorSubject, of, startWith, tap, throwError } from 'rxjs'; import { createTestComponent, TestComponent } from './fixtures'; @@ -19,24 +19,21 @@ describe('rxIf directive observable values', () => { TestBed.configureTestingModule({ imports: [TestComponent], providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'custom', - customStrategies: { - custom: { - name: 'custom', - work: (cdRef) => { - cdRef.detectChanges(); - }, - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), + provideRxRenderStrategies({ + primaryStrategy: 'custom', + customStrategies: { + custom: { + name: 'custom', + work: (cdRef) => { + cdRef.detectChanges(); }, + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], }); }); diff --git a/libs/template/if/src/lib/tests/if.directive.signal.spec.ts b/libs/template/if/src/lib/tests/if.directive.signal.spec.ts index f75cf4d6fa..e2e09b9cd3 100644 --- a/libs/template/if/src/lib/tests/if.directive.signal.spec.ts +++ b/libs/template/if/src/lib/tests/if.directive.signal.spec.ts @@ -6,7 +6,7 @@ import { waitForAsync, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { startWith, tap, throwError } from 'rxjs'; import { createTestComponent, TestComponent } from './fixtures'; @@ -25,24 +25,21 @@ describe('rxIf directive signal values', () => { TestBed.configureTestingModule({ imports: [TestComponent], providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'custom', - customStrategies: { - custom: { - name: 'custom', - work: (cdRef) => { - cdRef.detectChanges(); - }, - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), + provideRxRenderStrategies({ + primaryStrategy: 'custom', + customStrategies: { + custom: { + name: 'custom', + work: (cdRef) => { + cdRef.detectChanges(); }, + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], }); }); diff --git a/libs/template/if/src/lib/tests/if.directive.spec.ts b/libs/template/if/src/lib/tests/if.directive.spec.ts index 0682ef3c7a..624a0fba01 100644 --- a/libs/template/if/src/lib/tests/if.directive.spec.ts +++ b/libs/template/if/src/lib/tests/if.directive.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { createTestComponent, TestComponent } from './fixtures'; describe('rxIf directive', () => { @@ -17,14 +17,7 @@ describe('rxIf directive', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [TestComponent], - providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, - ], + providers: [provideRxRenderStrategies({ primaryStrategy: 'native' })], }); }); diff --git a/libs/template/let/src/lib/README.md b/libs/template/let/src/lib/README.md index ee32cc443a..676120341f 100644 --- a/libs/template/let/src/lib/README.md +++ b/libs/template/let/src/lib/README.md @@ -23,4 +23,4 @@ yarn add @rx-angular/template ## Documentation -- [RxLet](https://rx-angular.io/docs/template/api/let-directive) +- [RxLet](https://rx-angular.io/docs/template/let-directive) diff --git a/libs/template/let/src/lib/let.directive.ts b/libs/template/let/src/lib/let.directive.ts index 0a64eb6c60..55031abd00 100644 --- a/libs/template/let/src/lib/let.directive.ts +++ b/libs/template/let/src/lib/let.directive.ts @@ -74,7 +74,7 @@ export interface RxLetViewContext extends RxViewContext { * * it leads to too many subscriptions in the template * * it is cumbersome to work with values in the template * - * read more about the LetDirective in the [official docs](https://www.rx-angular.io/docs/template/api/let-directive) + * read more about the LetDirective in the [official docs](https://www.rx-angular.io/docs/template/let-directive) * * **Conclusion - Structural directives** * @@ -150,7 +150,7 @@ export class RxLet implements OnInit, OnDestroy, OnChanges { * [`normal`](https://www.rx-angular.io/docs/template/cdk/render-strategies/strategies/concurrent-strategies). * * Read more about this in the - * [official docs](https://www.rx-angular.io/docs/template/api/let-directive#use-render-strategies-strategy). + * [official docs](https://www.rx-angular.io/docs/template/let-directive#use-render-strategies-strategy). * * @example * @@ -432,7 +432,7 @@ export class RxLet implements OnInit, OnDestroy, OnChanges { * * Read more about this in the * [official - * docs](https://www.rx-angular.io/docs/template/api/let-directive#local-strategies-and-view-content-queries-parent). + * docs](https://www.rx-angular.io/docs/template/let-directive#local-strategies-and-view-content-queries-parent). * * @example * \@Component({ @@ -469,7 +469,7 @@ export class RxLet implements OnInit, OnDestroy, OnChanges { * Event listeners normally trigger zone. Especially high frequently events cause performance issues. * * Read more about this in the - * [official docs](https://www.rx-angular.io/docs/template/api/let-directive#working-with-event-listeners-patchzone). + * [official docs](https://www.rx-angular.io/docs/template/let-directive#working-with-event-listeners-patchzone). * * @example * \@Component({ diff --git a/libs/template/let/src/lib/tests/let.directive.complete.spec.ts b/libs/template/let/src/lib/tests/let.directive.complete.spec.ts index 815db0a09d..01f6d40f27 100644 --- a/libs/template/let/src/lib/tests/let.directive.complete.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.complete.spec.ts @@ -5,7 +5,7 @@ import { ViewContainerRef, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { EMPTY, Observable, of } from 'rxjs'; import { RxLet } from '../let.directive'; @@ -37,12 +37,7 @@ const setupLetDirectiveTestComponentComplete = (): void => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.context.spec.ts b/libs/template/let/src/lib/tests/let.directive.context.spec.ts index 127bd7c9a0..16f165e185 100644 --- a/libs/template/let/src/lib/tests/let.directive.context.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.context.spec.ts @@ -11,7 +11,7 @@ import { TestBed, tick, } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { BehaviorSubject, @@ -72,22 +72,19 @@ const setupTestComponent = () => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'urgent', - customStrategies: { - urgent: { - name: 'urgent', - work: (cdRef) => cdRef.detectChanges(), - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), - }, + provideRxRenderStrategies({ + primaryStrategy: 'urgent', + customStrategies: { + urgent: { + name: 'urgent', + work: (cdRef) => cdRef.detectChanges(), + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], }); }; diff --git a/libs/template/let/src/lib/tests/let.directive.error.spec.ts b/libs/template/let/src/lib/tests/let.directive.error.spec.ts index a380504ef3..c65a103080 100644 --- a/libs/template/let/src/lib/tests/let.directive.error.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.error.spec.ts @@ -5,7 +5,7 @@ import { ViewContainerRef, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { Observable, of, throwError } from 'rxjs'; import { RxLet } from '../let.directive'; @@ -30,12 +30,7 @@ const setupLetDirectiveTestComponentError = (): void => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.next.spec.ts b/libs/template/let/src/lib/tests/let.directive.next.spec.ts index 9e518a6893..6a0f7cc261 100644 --- a/libs/template/let/src/lib/tests/let.directive.next.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.next.spec.ts @@ -6,7 +6,7 @@ import { ViewContainerRef, } from '@angular/core'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { EMPTY, interval, NEVER, Observable, of } from 'rxjs'; import { take } from 'rxjs/operators'; @@ -43,12 +43,7 @@ const setupLetDirectiveTestComponent = (): void => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.rendered.spec.ts b/libs/template/let/src/lib/tests/let.directive.rendered.spec.ts index 1a2c162e1f..869755df2e 100644 --- a/libs/template/let/src/lib/tests/let.directive.rendered.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.rendered.spec.ts @@ -5,7 +5,7 @@ import { ViewContainerRef, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; import { RxLet } from '../let.directive'; @@ -40,12 +40,7 @@ const setupLetDirectiveTestComponent = (): void => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.signal-set.spec.ts b/libs/template/let/src/lib/tests/let.directive.signal-set.spec.ts index 55115a0bbb..a045e2708c 100644 --- a/libs/template/let/src/lib/tests/let.directive.signal-set.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.signal-set.spec.ts @@ -9,7 +9,7 @@ import { WritableSignal, } from '@angular/core'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { interval, NEVER } from 'rxjs'; import { take } from 'rxjs/operators'; @@ -42,12 +42,7 @@ const setupLetDirectiveTestComponent = (): void => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.subscribable.spec.ts b/libs/template/let/src/lib/tests/let.directive.subscribable.spec.ts index 47b33690a7..627c467fb8 100644 --- a/libs/template/let/src/lib/tests/let.directive.subscribable.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.subscribable.spec.ts @@ -5,7 +5,7 @@ import { ViewContainerRef, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { Subscribable } from 'rxjs'; import { RxLet } from '../let.directive'; @@ -37,12 +37,7 @@ const setupLetDirectiveTestComponent = (): void => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.template-binding.all.signal.spec.ts b/libs/template/let/src/lib/tests/let.directive.template-binding.all.signal.spec.ts index 7d9549f627..3cf92dd882 100644 --- a/libs/template/let/src/lib/tests/let.directive.template-binding.all.signal.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.template-binding.all.signal.spec.ts @@ -8,7 +8,7 @@ import { tick, } from '@angular/core/testing'; import { RxNotificationKind } from '@rx-angular/cdk/notifications'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { interval, NEVER, Subject, throwError } from 'rxjs'; import { take, tap } from 'rxjs/operators'; @@ -62,22 +62,19 @@ const setupTestComponent = () => { TestBed.configureTestingModule({ imports: [LetDirectiveAllTemplatesTestComponent], providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'urgent', - customStrategies: { - urgent: { - name: 'urgent', - work: (cdRef) => cdRef.detectChanges(), - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), - }, + provideRxRenderStrategies({ + primaryStrategy: 'urgent', + customStrategies: { + urgent: { + name: 'urgent', + work: (cdRef) => cdRef.detectChanges(), + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], }); }; diff --git a/libs/template/let/src/lib/tests/let.directive.template-binding.all.spec.ts b/libs/template/let/src/lib/tests/let.directive.template-binding.all.spec.ts index 63c352ca8c..09357dce68 100644 --- a/libs/template/let/src/lib/tests/let.directive.template-binding.all.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.template-binding.all.spec.ts @@ -12,7 +12,7 @@ import { tick, } from '@angular/core/testing'; import { RxNotificationKind } from '@rx-angular/cdk/notifications'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { BehaviorSubject, @@ -78,22 +78,19 @@ const setupTestComponent = () => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'urgent', - customStrategies: { - urgent: { - name: 'urgent', - work: (cdRef) => cdRef.detectChanges(), - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), - }, + provideRxRenderStrategies({ + primaryStrategy: 'urgent', + customStrategies: { + urgent: { + name: 'urgent', + work: (cdRef) => cdRef.detectChanges(), + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.template-binding.no-complete.spec.ts b/libs/template/let/src/lib/tests/let.directive.template-binding.no-complete.spec.ts index 20e91b6bd2..6b46fba158 100644 --- a/libs/template/let/src/lib/tests/let.directive.template-binding.no-complete.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.template-binding.no-complete.spec.ts @@ -6,7 +6,7 @@ import { ViewContainerRef, } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { EMPTY, Observable, of } from 'rxjs'; import { RxLet } from '../let.directive'; @@ -39,12 +39,7 @@ const setupTestComponent = () => { { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, TemplateRef, ViewContainerRef, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.template-binding.no-error.spec.ts b/libs/template/let/src/lib/tests/let.directive.template-binding.no-error.spec.ts index a2ec8d746b..f23b5d04c7 100644 --- a/libs/template/let/src/lib/tests/let.directive.template-binding.no-error.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.template-binding.no-error.spec.ts @@ -1,7 +1,7 @@ import { JsonPipe } from '@angular/common'; import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { Observable, of, Subject } from 'rxjs'; import { RxLet } from '../let.directive'; @@ -34,14 +34,7 @@ let nativeElement: HTMLElement; const setupTestComponent = () => { TestBed.configureTestingModule({ - providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, - ], + providers: [provideRxRenderStrategies({ primaryStrategy: 'native' })], imports: [LetDirectiveNoErrorTemplateTestComponent], teardown: { destroyAfterEach: true }, }); diff --git a/libs/template/let/src/lib/tests/let.directive.template-binding.no-suspense.spec.ts b/libs/template/let/src/lib/tests/let.directive.template-binding.no-suspense.spec.ts index 6dd36d13b3..62ec00c5ef 100644 --- a/libs/template/let/src/lib/tests/let.directive.template-binding.no-suspense.spec.ts +++ b/libs/template/let/src/lib/tests/let.directive.template-binding.no-suspense.spec.ts @@ -1,7 +1,7 @@ import { JsonPipe } from '@angular/common'; import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { Observable, of, Subject } from 'rxjs'; import { RxLet } from '../let.directive'; @@ -35,14 +35,7 @@ let nativeElement: HTMLElement; const setupTestComponent = () => { TestBed.configureTestingModule({ imports: [LetDirectiveNoSuspenseTemplateTestComponent], - providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, - ], + providers: [provideRxRenderStrategies({ primaryStrategy: 'native' })], teardown: { destroyAfterEach: true }, }); }; diff --git a/libs/template/package.json b/libs/template/package.json index 3e38ddec0f..6b685ec352 100644 --- a/libs/template/package.json +++ b/libs/template/package.json @@ -1,6 +1,6 @@ { "name": "@rx-angular/template", - "version": "19.1.1", + "version": "19.2.0", "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" @@ -44,7 +44,7 @@ }, "peerDependencies": { "@angular/core": "^19.0.0", - "@rx-angular/cdk": "^19.0.1", + "@rx-angular/cdk": "^19.1.0", "rxjs": "^6.5.3 || ^7.4.0" }, "dependencies": { diff --git a/libs/template/project.json b/libs/template/project.json index 487ba4ea29..9facd15a5f 100644 --- a/libs/template/project.json +++ b/libs/template/project.json @@ -8,7 +8,7 @@ "build-lib": { "executor": "@angular-devkit/build-angular:ng-packagr", "options": { - "tsConfig": "libs/template/tsconfig.lib.json", + "tsConfig": "libs/template/tsconfig.prod.json", "project": "libs/template/ng-package.json" }, "dependsOn": ["^build"], diff --git a/libs/template/push/src/lib/tests/push.pipe.service.spec.ts b/libs/template/push/src/lib/tests/push.pipe.service.spec.ts index 1383bfe501..6b87ab822c 100644 --- a/libs/template/push/src/lib/tests/push.pipe.service.spec.ts +++ b/libs/template/push/src/lib/tests/push.pipe.service.spec.ts @@ -1,6 +1,6 @@ import { ChangeDetectorRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { mockConsole } from '@test-helpers/rx-angular'; import { EMPTY, NEVER, of } from 'rxjs'; import { RxPush } from '../push.pipe'; @@ -13,12 +13,7 @@ const setupPushPipeComponent = () => { providers: [ { provide: ChangeDetectorRef, useClass: MockChangeDetectorRef }, RxPush, - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'native', - }, - }, + provideRxRenderStrategies({ primaryStrategy: 'native' }), ], teardown: { destroyAfterEach: true }, }); 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 99bad5605d..8d5c2ad0d1 100644 --- a/libs/template/push/src/lib/tests/push.pipe.spec.ts +++ b/libs/template/push/src/lib/tests/push.pipe.spec.ts @@ -9,7 +9,7 @@ import { import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { - RX_RENDER_STRATEGIES_CONFIG, + provideRxRenderStrategies, RxStrategyProvider, } from '@rx-angular/cdk/render-strategies'; import { Promise as unpatchedPromise } from '@rx-angular/cdk/zone-less/browser'; @@ -54,24 +54,21 @@ describe('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())), + provideRxRenderStrategies({ + primaryStrategy: 'native', + customStrategies: { + custom: { + name: 'custom', + work: (cdRef) => { + cdRef.detectChanges(); }, + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), ], }); diff --git a/libs/template/tsconfig.json b/libs/template/tsconfig.json index 9e92bca80d..53f7ce9eda 100644 --- a/libs/template/tsconfig.json +++ b/libs/template/tsconfig.json @@ -6,14 +6,14 @@ { "path": "./tsconfig.lib.json" }, + { + "path": "./tsconfig.prod.json" + }, { "path": "./tsconfig.spec.json" }, { "path": "./cypress/tsconfig.cy.json" } - ], - "compilerOptions": { - "target": "es2020" - } + ] } diff --git a/libs/template/tsconfig.lib.json b/libs/template/tsconfig.lib.json index 924b9f34d5..7b6ab45f9d 100644 --- a/libs/template/tsconfig.lib.json +++ b/libs/template/tsconfig.lib.json @@ -1,14 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "target": "es2020", - "module": "es2015", "inlineSources": true, - "importHelpers": true, - "lib": ["dom", "es2018"], - "paths": { - "@rx-angular/cdk/*": ["dist/libs/cdk/*"] - } + "importHelpers": true }, "angularCompilerOptions": { "enableIvy": true, @@ -20,6 +14,7 @@ "strictInjectionParameters": true, "enableResourceInlining": true }, + "include": ["**/*.ts"], "exclude": [ "src/test-setup.ts", "**/*.spec.ts", diff --git a/libs/template/tsconfig.prod.json b/libs/template/tsconfig.prod.json new file mode 100644 index 0000000000..c78ca1201a --- /dev/null +++ b/libs/template/tsconfig.prod.json @@ -0,0 +1,34 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "inlineSources": true, + "importHelpers": true, + "paths": { + "@rx-angular/cdk/*": ["dist/libs/cdk/*"] + } + }, + "angularCompilerOptions": { + "enableIvy": true, + "compilationMode": "partial", + "annotateForClosureCompiler": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": true + }, + "include": ["**/*.ts"], + "exclude": [ + "src/test-setup.ts", + "**/*.spec.ts", + "**/*.test.ts", + "jest.config.ts", + "cypress/**/*", + "cypress.config.ts", + "**/*.cy.ts", + "**/*.cy.js", + "**/*.cy.tsx", + "**/*.cy.jsx", + "**/tests/*.ts" + ] +} diff --git a/libs/template/unpatch/src/lib/README.md b/libs/template/unpatch/src/lib/README.md index 268f7628b2..b38a9216f0 100644 --- a/libs/template/unpatch/src/lib/README.md +++ b/libs/template/unpatch/src/lib/README.md @@ -23,4 +23,4 @@ yarn add @rx-angular/template ## Documentation -- [RxUnpatch](https://rx-angular.io/docs/template/api/unpatch-directive) +- [RxUnpatch](https://rx-angular.io/docs/template/unpatch-directive) diff --git a/libs/template/virtual-view/src/lib/tests/virtual-view.directive.spec.ts b/libs/template/virtual-view/src/lib/tests/virtual-view.directive.spec.ts index 17273c71e4..268fca531e 100644 --- a/libs/template/virtual-view/src/lib/tests/virtual-view.directive.spec.ts +++ b/libs/template/virtual-view/src/lib/tests/virtual-view.directive.spec.ts @@ -1,7 +1,7 @@ import { Component, input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies'; +import { provideRxRenderStrategies } from '@rx-angular/cdk/render-strategies'; import { tap } from 'rxjs'; import { provideVirtualViewConfig } from '../virtual-view.config'; import { RxVirtualView } from '../virtual-view.directive'; @@ -78,24 +78,21 @@ describe('RxVirtualView', () => { TestBed.configureTestingModule({ imports: [VirtualViewTestComponent], providers: [ - { - provide: RX_RENDER_STRATEGIES_CONFIG, - useValue: { - primaryStrategy: 'sync', - customStrategies: { - sync: { - name: 'sync', - work: (cdRef) => { - cdRef.detectChanges(); - }, - behavior: - ({ work }) => - (o$) => - o$.pipe(tap(() => work())), + provideRxRenderStrategies({ + primaryStrategy: 'sync', + customStrategies: { + sync: { + name: 'sync', + work: (cdRef) => { + cdRef.detectChanges(); }, + behavior: + ({ work }) => + (o$) => + o$.pipe(tap(() => work())), }, }, - }, + }), provideVirtualViewConfig({ placeholderStrategy: 'sync', contentStrategy: 'sync', diff --git a/libs/test-helpers/tsconfig.json b/libs/test-helpers/tsconfig.json index 92049739f6..3ee066c6dc 100644 --- a/libs/test-helpers/tsconfig.json +++ b/libs/test-helpers/tsconfig.json @@ -1,7 +1,5 @@ { "compilerOptions": { - "target": "es2022", - "useDefineForClassFields": false, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, diff --git a/package.json b/package.json index 6c9105536c..8b3ec41c98 100644 --- a/package.json +++ b/package.json @@ -68,16 +68,16 @@ "@angular-devkit/build-angular": "19.0.0", "@angular-devkit/core": "19.0.0", "@angular-devkit/schematics": "19.0.0", - "@angular-eslint/eslint-plugin": "18.4.1", - "@angular-eslint/eslint-plugin-template": "18.4.1", - "@angular-eslint/template-parser": "18.4.1", + "@angular-eslint/eslint-plugin": "19.0.2", + "@angular-eslint/eslint-plugin-template": "19.0.2", + "@angular-eslint/template-parser": "19.0.2", "@angular/build": "19.0.0", "@angular/cli": "~19.0.0", "@angular/compiler-cli": "19.0.0", "@angular/language-service": "19.0.0", "@commitlint/cli": "^19.2.1", "@commitlint/config-angular": "^19.1.0", - "@jscutlery/semver": "^4.1.0", + "@jscutlery/semver": "^5.5.1", "@nx-plus/docusaurus": "patch:@nx-plus/docusaurus@npm%3A14.1.0#~/.yarn/patches/@nx-plus-docusaurus-npm-14.1.0-b526e34c01.patch", "@nx/angular": "20.1.0", "@nx/cypress": "20.1.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 358ea747bd..e6905a52a7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,10 +7,11 @@ "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, + "useDefineForClassFields": false, "importHelpers": true, - "target": "es2015", + "target": "ES2022", "module": "esnext", - "lib": ["es2017", "dom"], + "lib": ["es2022", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", diff --git a/yarn.lock b/yarn.lock index f3d82cbf0d..d10ffc52fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -391,19 +391,19 @@ __metadata: languageName: node linkType: hard -"@angular-eslint/bundled-angular-compiler@npm:18.4.1": - version: 18.4.1 - resolution: "@angular-eslint/bundled-angular-compiler@npm:18.4.1" - checksum: 10c0/2dee97efb8e0c5c57e5bdad9438b6bec03cb660bbe08a745adbcffe7d08807db2cf0f95aed8f445b6922f8b50ef7b4b1326d81258d13f0481b12be7752de88a8 +"@angular-eslint/bundled-angular-compiler@npm:19.0.2": + version: 19.0.2 + resolution: "@angular-eslint/bundled-angular-compiler@npm:19.0.2" + checksum: 10c0/e42bbc4acd14884d6b530fe6b62be4909cd84035c3b955061cc228a67b7d4edb3380bbd4572ceda7612c4f40f6eebbbc6e76e88725f40027278afc5e2ca1165d languageName: node linkType: hard -"@angular-eslint/eslint-plugin-template@npm:18.4.1": - version: 18.4.1 - resolution: "@angular-eslint/eslint-plugin-template@npm:18.4.1" +"@angular-eslint/eslint-plugin-template@npm:19.0.2": + version: 19.0.2 + resolution: "@angular-eslint/eslint-plugin-template@npm:19.0.2" dependencies: - "@angular-eslint/bundled-angular-compiler": "npm:18.4.1" - "@angular-eslint/utils": "npm:18.4.1" + "@angular-eslint/bundled-angular-compiler": "npm:19.0.2" + "@angular-eslint/utils": "npm:19.0.2" aria-query: "npm:5.3.2" axobject-query: "npm:4.1.0" peerDependencies: @@ -411,47 +411,47 @@ __metadata: "@typescript-eslint/utils": ^7.11.0 || ^8.0.0 eslint: ^8.57.0 || ^9.0.0 typescript: "*" - checksum: 10c0/f34e2a4922bca70972a44a02387ab2abea2a5f48d7b9c0bfdd3e85662f59d6f12dbda903799cf0c4629db2117170fc64ff28433bf0be1e447058f7680246bec3 + checksum: 10c0/54d35c7f83db6ed40c9f51111dda0238ac3aed0c30d4de4f882f0499a16251565005bf15e365bf098fe8c61301dbcca13f3e8d51fbfbbf57d0eaa46b960a80b2 languageName: node linkType: hard -"@angular-eslint/eslint-plugin@npm:18.4.1": - version: 18.4.1 - resolution: "@angular-eslint/eslint-plugin@npm:18.4.1" +"@angular-eslint/eslint-plugin@npm:19.0.2": + version: 19.0.2 + resolution: "@angular-eslint/eslint-plugin@npm:19.0.2" dependencies: - "@angular-eslint/bundled-angular-compiler": "npm:18.4.1" - "@angular-eslint/utils": "npm:18.4.1" + "@angular-eslint/bundled-angular-compiler": "npm:19.0.2" + "@angular-eslint/utils": "npm:19.0.2" peerDependencies: "@typescript-eslint/utils": ^7.11.0 || ^8.0.0 eslint: ^8.57.0 || ^9.0.0 typescript: "*" - checksum: 10c0/c319ce97f90ef41f55c23460a853f44db389a7bf475a206f7f739549523ea08a6522f28d6cfde9fd5df1e4fa64aac4a19dde9533134352e840e7938b02d02bd6 + checksum: 10c0/77ad1662ad020a772faed7518b786c7dc020b812ffb8b13dc18fcf7ff018b7c4716de574cdf1ed7dc1df01bb343be7a85772273b6df03ccc92519b90ab2bde0b languageName: node linkType: hard -"@angular-eslint/template-parser@npm:18.4.1": - version: 18.4.1 - resolution: "@angular-eslint/template-parser@npm:18.4.1" +"@angular-eslint/template-parser@npm:19.0.2": + version: 19.0.2 + resolution: "@angular-eslint/template-parser@npm:19.0.2" dependencies: - "@angular-eslint/bundled-angular-compiler": "npm:18.4.1" + "@angular-eslint/bundled-angular-compiler": "npm:19.0.2" eslint-scope: "npm:^8.0.2" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: "*" - checksum: 10c0/a7ae4bc5b1bcfd2467a70578e3948471e8e8a09afe1ebf6c3b1dede5dd33d9edb57c81d00e40c01724ab7aba41d78338b86ab5db48dc812cbc9f7a2d9496782d + checksum: 10c0/96d5b786af03f729910571f4917dcb74d352ff225bf5ec13113834f29198904f7f89d2e49cc337c1753fe0b1575c5936c3c0e541cf09d18d0ed4eb15e7db8b01 languageName: node linkType: hard -"@angular-eslint/utils@npm:18.4.1": - version: 18.4.1 - resolution: "@angular-eslint/utils@npm:18.4.1" +"@angular-eslint/utils@npm:19.0.2": + version: 19.0.2 + resolution: "@angular-eslint/utils@npm:19.0.2" dependencies: - "@angular-eslint/bundled-angular-compiler": "npm:18.4.1" + "@angular-eslint/bundled-angular-compiler": "npm:19.0.2" peerDependencies: "@typescript-eslint/utils": ^7.11.0 || ^8.0.0 eslint: ^8.57.0 || ^9.0.0 typescript: "*" - checksum: 10c0/47cc7b7554764673179cee73fd2c01576ed9cf328a2fa0c8e29669b55b91ea73ce789b72472c628b05bb9ae6e6d53cd083ae8997e6aa495705713ab34f14df8e + checksum: 10c0/e1ad0104259bdead95a9923aee831aa2b0d66d8cb4924fb5f8c19f9660ffa79241d93f6bcc4ab75b39682a7895fcd46f172d751230503f0887ea0071f62967fa languageName: node linkType: hard @@ -7378,9 +7378,9 @@ __metadata: languageName: node linkType: hard -"@jscutlery/semver@npm:^4.1.0": - version: 4.1.0 - resolution: "@jscutlery/semver@npm:4.1.0" +"@jscutlery/semver@npm:^5.5.1": + version: 5.5.1 + resolution: "@jscutlery/semver@npm:5.5.1" dependencies: chalk: "npm:4.1.2" conventional-changelog: "npm:^5.1.0" @@ -7400,8 +7400,8 @@ __metadata: inquirer: "npm:8.2.6" rxjs: "npm:7.8.1" peerDependencies: - "@nx/devkit": ^17.0.0 - checksum: 10c0/7e3ce1e307c6f68ed93b2feaa45fcb9ff812f3316cbb19e450f7cbc9d5468e1f71d3f12d75ef42c03b05b72a87194a260248938955f1eae5be58cbd7f4f12190 + "@nx/devkit": ^18.0.0 || ^19.0.0 || ^20.0.0 + checksum: 10c0/a515d11f713471c805b730cfcd0b39739ab51cc5b26dce6538667b56e65dfdf9d47dfcf0b987c4f3eb7c18ba7b71e200df9bcdef48dfb8fc6cf3c772ef840f1c languageName: node linkType: hard @@ -9455,7 +9455,7 @@ __metadata: tslib: "npm:^2.4.1" peerDependencies: "@angular/core": ^19.0.0 - "@rx-angular/cdk": ^19.0.1 + "@rx-angular/cdk": ^19.1.0 rxjs: ^6.5.3 || ^7.4.0 languageName: unknown linkType: soft @@ -9468,7 +9468,7 @@ __metadata: tslib: "npm:^2.4.1" peerDependencies: "@angular/core": ^19.0.0 - "@rx-angular/cdk": ^19.0.1 + "@rx-angular/cdk": ^19.1.0 rxjs: ^6.5.3 || ^7.4.0 languageName: unknown linkType: soft @@ -27440,9 +27440,9 @@ __metadata: "@angular-devkit/build-angular": "npm:19.0.0" "@angular-devkit/core": "npm:19.0.0" "@angular-devkit/schematics": "npm:19.0.0" - "@angular-eslint/eslint-plugin": "npm:18.4.1" - "@angular-eslint/eslint-plugin-template": "npm:18.4.1" - "@angular-eslint/template-parser": "npm:18.4.1" + "@angular-eslint/eslint-plugin": "npm:19.0.2" + "@angular-eslint/eslint-plugin-template": "npm:19.0.2" + "@angular-eslint/template-parser": "npm:19.0.2" "@angular/animations": "npm:19.0.0" "@angular/build": "npm:19.0.0" "@angular/cdk": "npm:19.0.0" @@ -27462,7 +27462,7 @@ __metadata: "@angular/ssr": "npm:19.0.0" "@commitlint/cli": "npm:^19.2.1" "@commitlint/config-angular": "npm:^19.1.0" - "@jscutlery/semver": "npm:^4.1.0" + "@jscutlery/semver": "npm:^5.5.1" "@nx-plus/docusaurus": "patch:@nx-plus/docusaurus@npm%3A14.1.0#~/.yarn/patches/@nx-plus-docusaurus-npm-14.1.0-b526e34c01.patch" "@nx/angular": "npm:20.1.0" "@nx/cypress": "npm:20.1.0"