UNPKG

@rx-angular/template

Version:

**Fully** Reactive Component Template Rendering in Angular. @rx-angular/template aims to be a reflection of Angular's built in renderings just reactive.

519 lines (510 loc) 20 kB
import * as i0 from '@angular/core'; import { inject, IterableDiffers, InjectionToken, isSignal, ChangeDetectorRef, NgZone, Injector, ViewContainerRef, ErrorHandler, Directive, Input } from '@angular/core'; import { coerceObservableWith, coerceDistinctWith } from '@rx-angular/cdk/coercing'; import { toObservableMicrotaskInternal } from '@rx-angular/cdk/internals/core'; import { RxStrategyProvider, onStrategy } from '@rx-angular/cdk/render-strategies'; import { isObservable, ReplaySubject, Subscription, combineLatest, concat, of } from 'rxjs'; import { switchAll, shareReplay, startWith, switchMap, ignoreElements, map, catchError } from 'rxjs/operators'; import { RxDefaultListViewContext, createListTemplateManager, RxLiveCollection, reconcile } from '@rx-angular/cdk/template'; class RxForViewContext extends RxDefaultListViewContext { constructor(item, rxForOf, customProps) { super(item, customProps); this.rxForOf = rxForOf; } } const LEGACY_RXFOR_RECONCILIATION_FACTORY = () => { const iterableDiffers = inject(IterableDiffers); return (options) => { const { values$, strategy$, viewContainerRef, template, strategyProvider, errorHandler, createViewContext, updateViewContext, cdRef, trackBy, parent, patchZone, } = options; const listManager = createListTemplateManager({ iterableDiffers: iterableDiffers, renderSettings: { cdRef: cdRef, strategies: strategyProvider.strategies, // TODO: move strategyProvider defaultStrategyName: strategyProvider.primaryStrategy, parent, patchZone, errorHandler, }, templateSettings: { viewContainerRef, templateRef: template, createViewContext, updateViewContext, }, trackBy, }); listManager.nextStrategy(strategy$); return listManager.render(values$); }; }; function provideLegacyRxForReconciliation() { return { provide: INTERNAL_RX_FOR_RECONCILER_TOKEN, useFactory: LEGACY_RXFOR_RECONCILIATION_FACTORY, }; } /** @internal */ const INTERNAL_RX_FOR_RECONCILER_TOKEN = new InjectionToken('rx-for-reconciler', { providedIn: 'root', factory: LEGACY_RXFOR_RECONCILIATION_FACTORY, }); function injectReconciler() { return inject(INTERNAL_RX_FOR_RECONCILER_TOKEN); } /** * @Directive RxFor * * @description * * The most common way to render lists in angular is by using the `*ngFor` structural directive. `*ngFor` is able * to take an arbitrary list of data and repeat a defined template per item of the list. However, it can * only do it synchronously. * * Compared to the `NgFor`, `RxFor` treats each child template as single renderable unit. * The change detection of the child templates get prioritized, scheduled and executed by * leveraging `RenderStrategies` under the hood. * 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/rx-for-directive#rxfor-with-concurrent-strategies). * * Furthermore, `RxFor` provides hooks to react to rendered items in form of a `renderCallback: Subject`. * * Together with the `RxRenderStrategies`, this makes the rendering behavior extremely versatile * 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/rx-for-directive) * * @docsCategory RxFor * @docsPage RxFor * @publicApi */ class RxFor { /** * @description * The iterable input * * @example * <ng-container *rxFor="heroes$; let hero"> * <app-hero [hero]="hero"></app-hero> * </ng-container> * * @param { Observable<(U & NgIterable<T>) | undefined | null> * | Signal<(U & NgIterable<T>) | undefined | null> * | (U & NgIterable<T>) * | null * | undefined } potentialSignalOrObservable */ set rxForOf(potentialSignalOrObservable) { if (isSignal(potentialSignalOrObservable)) { this.staticValue = undefined; this.renderStatic = false; this.observables$.next(toObservableMicrotaskInternal(potentialSignalOrObservable, { injector: this.injector, })); } else if (!isObservable(potentialSignalOrObservable)) { this.staticValue = potentialSignalOrObservable; this.renderStatic = true; } else { this.staticValue = undefined; this.renderStatic = false; this.observables$.next(potentialSignalOrObservable); } } set rxForTemplate(value) { this._template = value; } /** * @description * * You can change the used `RenderStrategy` by using the `strategy` input of the `*rxFor`. It accepts * an `Observable<RxStrategyNames>` 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/rx-for-directive#use-render-strategies-strategy). * * @example * * \@Component({ * selector: 'app-root', * template: ` * <ng-container *rxFor="let hero of heroes$; strategy: strategy"> * <app-hero [hero]="hero"></app-hero> * </ng-container> * * <ng-container *rxFor="let hero of heroes$; strategy: strategy$"> * <app-hero [hero]="hero"></app-hero> * </ng-container> * ` * }) * export class AppComponent { * strategy = 'low'; * strategy$ = of('immediate'); * } * * @param {string | Observable<string> | undefined} strategyName * @see {@link strategies} */ set rxForStrategy(strategyName) { this.strategyInput$.next(strategyName); } /** * @description * A function or key that defines how to track changes for items in the iterable. * * When items are added, moved, or removed in the iterable, * the directive must re-render the appropriate DOM nodes. * To minimize churn in the DOM, only nodes that have changed * are re-rendered. * * By default, rxFor assumes that the object instance identifies the node in the iterable (equality check `===`). * When a function or key is supplied, rxFor uses the result to identify the item node. * * @example * \@Component({ * selector: 'app-root', * template: ` * <app-list-component> * <app-list-item * *rxFor=" * let item of items$; * trackBy: 'id'; * " * > * <div>{{ item.name }}</div> * </app-list-item> * </app-list-component> * ` * }) * export class AppComponent { * items$ = itemService.getItems(); * } * * // OR * * \@Component({ * selector: 'app-root', * template: ` * <app-list-component> * <app-list-item * *rxFor=" * let item of items$; * trackBy: trackItem; * " * > * <div>{{ item.name }}</div> * </app-list-item> * </app-list-component> * ` * }) * export class AppComponent { * items$ = itemService.getItems(); * trackItem = (idx, item) => item.id; * } * * @param trackByFnOrKey */ set trackBy(trackByFnOrKey) { if ((typeof ngDevMode === 'undefined' || ngDevMode) && trackByFnOrKey != null && typeof trackByFnOrKey !== 'string' && typeof trackByFnOrKey !== 'function') { console.warn(`trackBy must be a function, but received ${JSON.stringify(trackByFnOrKey)}.`); } if (trackByFnOrKey == null) { this._trackBy = this.defaultTrackBy; } else { this._trackBy = typeof trackByFnOrKey !== 'function' ? (i, a) => a[trackByFnOrKey] : trackByFnOrKey; } } /** * @description * A `Subject` which emits whenever *rxFor finished rendering a set changes to the view. * This enables developers to perform actions when a list has finished rendering. * The `renderCallback` is useful in situations where you rely on specific DOM properties like the `height` a * table after all items got rendered. * It is also possible to use the renderCallback in order to determine if a view should be visible or not. This * way developers can hide a list as long as it has not finished rendering. * * The result of the `renderCallback` will contain the currently rendered set of items in the iterable. * * @example * \Component({ * selector: 'app-root', * template: ` * <app-list-component> * <app-list-item * *rxFor=" * let item of items$; * trackBy: trackItem; * renderCallback: itemsRendered; * "> * <div>{{ item.name }}</div> * </app-list-item> * </app-list-component> * ` * }) * export class AppComponent { * items$: Observable<Item[]> = itemService.getItems(); * trackItem = (idx, item) => item.id; * // this emits whenever rxFor finished rendering changes * itemsRendered = new Subject<Item[]>(); * * constructor(elementRef: ElementRef<HTMLElement>) { * itemsRendered.subscribe(() => { * // items are rendered, we can now scroll * elementRef.scrollTo({bottom: 0}); * }) * } * } * * @param {Subject<U>} renderCallback */ set renderCallback(renderCallback) { this._renderCallback = renderCallback; } get template() { return this._template || this.templateRef; } constructor(templateRef) { this.templateRef = templateRef; /** @internal */ this.cdRef = inject(ChangeDetectorRef); /** @internal */ this.ngZone = inject(NgZone); /** @internal */ this.injector = inject(Injector); /** @internal */ this.viewContainerRef = inject(ViewContainerRef); /** @internal */ this.strategyProvider = inject(RxStrategyProvider); /** @internal */ this.errorHandler = inject(ErrorHandler); /** @internal */ this.renderStatic = false; /** * @description * * When local rendering strategies are used, we need to treat view and content queries in a * special way. * To make `*rxFor` in such situations, a certain mechanism is implemented to * execute change detection on the parent (`parent`). * * This is required if your components state is dependent on its view or content children: * * - `@ViewChild` * - `@ViewChildren` * - `@ContentChild` * - `@ContentChildren` * * Read more about this in the * [official * docs](https://www.rx-angular.io/docs/template/rx-for-directive#local-strategies-and-view-content-queries-parent). * * @example * \@Component({ * selector: 'app-root', * template: ` * <app-list-component> * <app-list-item * *rxFor=" * let item of items$; * trackBy: trackItem; * parent: true; * " * > * <div>{{ item.name }}</div> * </app-list-item> * </app-list-component> * ` * }) * export class AppComponent { * items$ = itemService.getItems(); * } * * @param {boolean} renderParent * * @deprecated this flag will be dropped soon, as it is no longer required when using signal based view & content * queries */ this.renderParent = this.strategyProvider.config.parent; /** * @description * * A flag to control whether *rxFor templates are created within `NgZone` or not. * 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. * * Read more about this in the * [official * docs](https://www.rx-angular.io/docs/template/rx-for-directive#working-with-event-listeners-patchzone). * * @example * \@Component({ * selector: 'app-root', * template: ` * <app-list-component> * <app-list-item * *rxFor=" * let item of items$; * trackBy: trackItem; * patchZone: false; * " * > * <div>{{ item.name }}</div> * </app-list-item> * </app-list-component> * ` * }) * export class AppComponent { * items$ = itemService.getItems(); * } * * @param {boolean} patchZone */ this.patchZone = this.strategyProvider.config.patchZone; this.defaultTrackBy = (i, item) => item; /** @internal */ this.strategyInput$ = new ReplaySubject(1); /** @internal */ this.observables$ = new ReplaySubject(1); /** @internal */ this.values$ = this.observables$.pipe(coerceObservableWith(), switchAll(), shareReplay({ refCount: true, bufferSize: 1 })); /** @internal */ this.values = null; /** @internal */ this.strategy$ = this.strategyInput$.pipe(coerceDistinctWith()); /** @internal */ this._subscription = new Subscription(); /** @internal */ this._trackBy = this.defaultTrackBy; /** @internal */ this._distinctBy = (a, b) => a === b; this.reconciler = injectReconciler(); } /** @internal */ ngOnInit() { this._subscription.add(this.values$.subscribe((v) => (this.values = v))); this._subscription.add(this.reconciler({ values$: this.values$, strategy$: this.strategy$, viewContainerRef: this.viewContainerRef, 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), parent: !!this.renderParent, patchZone: this.patchZone ? this.ngZone : undefined, }).subscribe((values) => this._renderCallback?.next(values))); } /** @internal */ createViewContext(item, computedContext) { return new RxForViewContext(item, this.values, computedContext); } /** @internal */ updateViewContext(item, view, computedContext) { view.context.updateContext(computedContext); view.context.rxForOf = this.values; view.context.$implicit = item; } /** @internal */ ngDoCheck() { if (this.renderStatic) { this.observables$.next(this.staticValue); } } /** @internal */ ngOnDestroy() { this._subscription.unsubscribe(); this.viewContainerRef.clear(); } /** @internal */ static ngTemplateContextGuard(dir, ctx) { return true; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxFor, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.0", type: RxFor, isStandalone: true, selector: "[rxFor][rxForOf]", inputs: { rxForOf: "rxForOf", rxForTemplate: "rxForTemplate", rxForStrategy: "rxForStrategy", renderParent: ["rxForParent", "renderParent"], patchZone: ["rxForPatchZone", "patchZone"], trackBy: ["rxForTrackBy", "trackBy"], renderCallback: ["rxForRenderCallback", "renderCallback"] }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.0", ngImport: i0, type: RxFor, decorators: [{ type: Directive, args: [{ selector: '[rxFor][rxForOf]', standalone: true, }] }], ctorParameters: () => [{ type: i0.TemplateRef }], propDecorators: { rxForOf: [{ type: Input }], rxForTemplate: [{ type: Input }], rxForStrategy: [{ type: Input }], renderParent: [{ type: Input, args: ['rxForParent'] }], patchZone: [{ type: Input, args: ['rxForPatchZone'] }], trackBy: [{ type: Input, args: ['rxForTrackBy'] }], renderCallback: [{ type: Input, args: ['rxForRenderCallback'] }] } }); function provideExperimentalRxForReconciliation() { return { provide: INTERNAL_RX_FOR_RECONCILER_TOKEN, useFactory: () => (options) => { 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.context ?? cdRef, ngZone: patchZone, }).pipe(ignoreElements())) : o$, map(() => iterable)); }), catchError((e) => { errorHandler.handleError(e); return of(null); })); }, }; } /** * Generated bundle index. Do not edit. */ export { RxFor, RxForViewContext, provideExperimentalRxForReconciliation, provideLegacyRxForReconciliation }; //# sourceMappingURL=template-for.mjs.map