diff --git a/libs/state/README.md b/libs/state/README.md index 78e4e52f59..80f8963556 100644 --- a/libs/state/README.md +++ b/libs/state/README.md @@ -12,6 +12,7 @@ RxState is a lightweight, flexible, strongly typed and tested tool dedicated to ## Sub Modules +- [**{ }** State (root module)](https://github.com/rx-angular/rx-angular/blob/main/libs/state/README.md) - [🧩 Selections](https://github.com/rx-angular/rx-angular/blob/main/libs/state/selections/README.md) - [☁ Effects](https://github.com/rx-angular/rx-angular/blob/main/libs/state/effects/README.md) diff --git a/libs/state/selections/README.md b/libs/state/selections/README.md index 2c91948026..1a7904a6bb 100644 --- a/libs/state/selections/README.md +++ b/libs/state/selections/README.md @@ -6,15 +6,23 @@ ## Slogan -`@rx-angular/state/selections` TBD +`@rx-angular/state/selections` provides performant and boiler plate free helpers for to craft custom selections with just a few lines of code. ## Key features -- ✅ +- ✅ reduces repetitative code to a minimum +- ✅ most common problems solved with a 1 liner +- ✅ distinct values +- ✅ lazy initialization (avoids `?` in the template) +- ✅ shared computation +- ✅ strongly types +- ✅ fully tested + + ## Demos: -- ⚡ GitHub +- ⚡ GitHub Todo ## Install @@ -27,4 +35,3 @@ yarn add @rx-angular/state ## Documentation - [Selections](https://github.com/rx-angular/rx-angular/tree/main/libs/state/selections/docs/Readme.md) - diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index 648364e263..9a2e3589f6 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -1,10 +1,276 @@ # Motivation -TBD +![Selections (1)](https://user-images.githubusercontent.com/10064416/152422745-b3d8e094-d0f0-4810-b1b2-5f81fae25938.png) -* [`Interfaces`](./operators/interfaces.md) -* [`distinctUntilSomeChanged`](./operators/distinct-until-some-changed.md) -* [`select`](./operators/select.md) -* [`selectSlice`](./operators/select-slice.md) -* [`stateful`](./operators/stateful.md) +When managing state you want to maintain a core unit of data. +This data is then later on distributed to multiple places in your component template (local) or whole app (global). + +We can forward this state to their consumers directly or compute specific derivations (selections) for the core unit. + +As an example we could think of the following shape: + +**A list and a list title** +```typescript +interface GlobalModel { + title: string; + list: Array<{ id: number, date: Date }> +} +``` + +This data is consumed in different screens: + +**A list of all items sorted by id** +```typescript +interface SelectionScreen1 { + title: string; + sortDirection: 'asc' | 'desc' | 'none'; + sortedList: Array<{ id: number }> +} +``` + +**A list of items filtered by date** +```typescript +interface SelectionScreen2 { + title: string; + startingDate: Date; + filteredList: { id: number } +} +``` + +The 2 rendered lists are a derivation, a modified version of the core set of items. +One time they are displayed in a sorted order, the other time only filtered subset of the items. + +> **Hint:** +> Derivations are always redundant information of our core data and therefore should not get stored, +> but cached in the derivation logic. + +![Selections (2)](https://user-images.githubusercontent.com/10064416/152422803-bfd07ab2-0a6f-4521-836e-b71677e11923.png) + +As this process contains a lot of gotchas and possible pitfalls in terms of memory usage and performance this small helper library was created. + +# Benefits + +![Selections (3)](https://user-images.githubusercontent.com/10064416/152422856-a483a06c-84e0-4067-9eaa-f3bb54a0156d.png) + + +- Sophisticated set of helpers for any selection problem +- Enables lazy rendering +- Computes only distinct values +- Shares computed result with multiple subscriber +- Select distinct sub-sets +- Select from static values +- Fully tested +- Strongly typed + +# Concepts + +## Selection composition - lazy vs eager + +## Selection composition - functional vs reactive + +## Selection setup - Template vs Class + +As Observables are cold their resulting stream will only get activated by a subscription. +This leads to a situations called: "the late subscriber problem" or "the early subscriber problem". (LINK) + +![Selections (5)](https://user-images.githubusercontent.com/10064416/152422955-cb89d198-1a69-450b-be84-29dd6c8c4fdb.png) + + +In most cases it's best to go with solving problems on the early subscriber side and be sure we never loose values that should render on the screen. + +![Selections (4)](https://user-images.githubusercontent.com/10064416/152422883-0b5f6006-7929-4520-b0b2-79eb61e4eb08.png) + +# Usage + +## select + +`select` is the stand-alone version of the `RxState#select` top level method. It helps to create default selection's from a changing state source. + +```typescritp +// emissions: +// 0. - no emission ever happened +// 1. {a: 1} - incomplete state leads to `?` pollution in the template +// 2. {a: 1, b: 'a'} - render relevant emission +// 2. {a: 1, b: 'a'} - same instance emisssion +// 3. {a: 1, b: 'a', c: true} - render irrelevant change +// 4. {a: 1, b: 'b', c: true} - render relevant emission +const model$: Observable>; +``` +**Problem** +```html + +
+ B: {{vm?.b}} +
+B: {{(model$ | push)?.b}} +``` + +### single property short hand +```typescritp +const vm$ = model$.pipe(select('b')); +``` +```html + +
+ B: {{vm.b}} +
+B: {{(model$ | push).b}} +``` + +### single operators +```typescritp +const vm$: Observable<> = model$.pipe(select(map(({b}) => b === 'a'))); +``` +```html + +
+ B: {{vm.b}} +
+B: {{(model$ | push).b}} +``` + +## selectSlice + +## smosh + +## distinctUntilSomeChanges + +# Advanced derivation architecture + +**The problem** + +We have the following state sources to manage: +- the list of products received form global state - `Product[]` +- the title of the list including it's number of children computen in the component class - `string` +- the sort direction triggered over a UI element click - `boolean` + +A setup of the compoents class based on `RxState` could look like this: + +```typescript +@Component({ + selector: 'app-problem', + template: ` + +

{{vm.title}} - {{vm.sortDirection}}

+ +
+ `, + providers: [RxState], +}) +export class ProblemComponent { + + viewModel$: Observable; // ??? + + constructor(private globalState: GlobalState, private state: RxState) { + this.state.connect('title', this.globalState.title$); + this.state.connect('products', this.globalState.products$); + } + + toggleSort() { + this.state.set('sort', ({sort}) => !sort)) + } +} + +``` + +In a components template we want to render the the UI for the above explained view model `SelectionScreen1`. + +```typescript +interface SelectionScreen1 { + title: string; + sortDirection: 'asc' | 'desc' | 'none'; + sortedList: Array<{ id: number }> +} +``` + +A common implementations looks like this: + + +```typescript +// template removed for brevity +export class ProblemComponent { + + private sortedList$ = this.state.select( + selectSlice(['sortDirection', 'list']), + map(() => { + // sort `list` by `sortDirection` to `sortedList` here + return sortedList; + }) + ); + + viewModel$ = this.state.select( + selectSlice(['title', 'sortedList', 'sortDirection']) + ) + + // ❌ BAD: modle viewmodel mix up 👇 + constructor(private globalState: GlobalState, private state: RxState>) { + // ... + + // ❌ BAD: store derived state 👇 + this.state.connect('sortedList', this.sortedList$); + } + + // ... +} + +``` + +![Selections (6)](https://user-images.githubusercontent.com/10064416/152422999-db8260f0-69e1-4d99-b6ac-b2b1d043b4b7.png) + +By removing the sorted list form the state and moving it into the selection +we can clean up the state's typing and have a nice separation of which data is owned by the component (model) and which data is owned by the template (view model) + +```typescript +// template removed for brevity +export class ProblemComponent { + + private sortedSlice$ = this.state.select( + selectSlice(['sortDirection', 'list']), + map(({list, sortDirection}) => { + // sort `list` by `sortDirection` to `sortedList` here + return { sortDirection, sortedList }; + }) + ); + + // ✔ GOOD: Derive view model from model 👇 + viewModel$ = smosh({ title: this.state.select('title')}, this.sortedSlice$); + + // target API + viewModel$ = smosh({ + prop1: 'prop1', // string + prop2: prop1$ // Observable + }, + slice1$, // Observable<{prop3: 3}> + slice2$ // Observable<{prop4: 'four'}>, + // durationSelector$ (optional) + ); + + + // ✔ GOOD: Derive view model from model 👇 + viewModel$ = smosh({ + title: this.state.select('title') + }, [this.sortedSlice$]); + + + + constructor(private globalState: GlobalState, private state: RxState) { + // ... + + } + + // ... +} + +``` + +![Selections (7)](https://user-images.githubusercontent.com/10064416/152423026-d23326c2-97d5-4bd0-9015-f498c3fc0e55.png) diff --git a/libs/state/selections/spec/smosh.spec.ts b/libs/state/selections/spec/smosh.spec.ts new file mode 100644 index 0000000000..5ec7421b5b --- /dev/null +++ b/libs/state/selections/spec/smosh.spec.ts @@ -0,0 +1,387 @@ +import { TestScheduler } from 'rxjs/testing'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { jestMatcher } from '@test-helpers'; + +import { smosh } from '../src'; +import { coalesceWith } from '@rx-angular/cdk/coalescing'; +import { from, Observable, of } from 'rxjs'; + +let testScheduler: TestScheduler; + +interface ViewModelTest { + prop1?: number; + prop2?: string; + prop3?: boolean; +} + +const _: any = undefined; +const a = 'a'; +const b = 'b'; +const c = 'c'; +const t = true; +const f = false; +const h = 0; +const i = 1; +const j = 2; + +/** + * { + * prop1: h, + * prop2: a, + * prop3: f, + * } + */ +const u = { + prop1: h, + prop2: a, + prop3: f, +}; +/** + * { + * prop1: i, + * prop2: a, + * prop3: f, + * } + */ +const v = { + prop1: i, + prop2: a, + prop3: f, +}; +/** + * { + * prop1: i, + * prop2: b, + * prop3: f, + * } + */ +const w = { + prop1: i, + prop2: b, + prop3: f, +}; +/** + * { + * prop1: i, + * prop2: b, + * prop3: t, + * } + */ +const x = { + prop1: i, + prop2: b, + prop3: t, +}; + +/** + * { + * prop1: h, + * prop2: a, + * prop3: t, + * } + */ +const viewModel1 = { + prop1: h, + prop2: a, + prop3: t, +}; + +beforeEach(() => { + testScheduler = new TestScheduler(jestMatcher); +}); + +// tslint:disable: no-duplicate-string +describe('createSmoshObservable', () => { + describe('with only the object param', () => { + it('should return an observable', () => { + testScheduler.run(({ cold, expectObservable }) => { + const vm$: Observable = smosh({ + prop1: of(1), + prop2: of('42'), + prop3: of(true), + }); + expect(vm$.subscribe).toBeDefined(); + expect(vm$.subscribe().unsubscribe).toBeDefined(); + }); + }); + + it('should return observable that emits if only static values are used', () => { + testScheduler.run(({ cold, expectObservable }) => { + const vm$: Observable = smosh( + { + prop1: i, + prop2: b, + prop3: t, + }, + undefined, + { durationSelector: cold('s') } + ); + const expected = '(x|)'; + expectObservable(vm$).toBe(expected, { x }); + }); + }); + + it('should return observable that emits when all static and observable sources emitted at least once', () => { + testScheduler.run(({ cold, expectObservable }) => { + const vm$: Observable = smosh( + { + prop1: cold('h-i--', { h, i }), + prop2: cold('---b-', { b }), + prop3: t, + }, + undefined, + { durationSelector: cold('s') } + ); + const expected = '---x-'; + expectObservable(vm$).toBe(expected, { x }); + }); + }); + + it('should return observable that not emit if one sources emitted not at least once or is undefined', () => { + testScheduler.run(({ cold, expectObservable }) => { + const vm$: Observable = smosh( + { + prop1: cold('h-h-i-', { h, i }), + prop2: cold('--a-b-', { a, b }), + prop3: undefined, + }, + undefined, + { durationSelector: cold('s') } + ); + const expected = '------'; + expectObservable(vm$).toBe(expected, { x }); + }); + }); + + it('should return observable that emits when all sources emitted at least once', () => { + testScheduler.run(({ cold, expectObservable }) => { + const vm$: Observable = smosh( + { + prop1: cold('h-h-i-', { h, i }), + prop2: cold('--a-b-', { a, b }), + prop3: cold('----t-', { t }), + }, + undefined, + { durationSelector: cold('s') } + ); + const expected = '----x-'; + expectObservable(vm$).toBe(expected, { x }); + }); + }); + + it('should return observable that not emit when if any of the sources not emitted at least once', () => { + testScheduler.run(({ cold, expectObservable }) => { + const vm$: Observable = smosh( + { + prop1: cold('h-h-i-', { h, i }), + prop2: cold('--a-b-', { a, b }), + prop3: cold('------', { t }), + }, + undefined, + { durationSelector: cold('s') } + ); + const expected = '------'; + expectObservable(vm$).toBe(expected); + }); + }); + + it('should emit last for sync values when durationSelector is a Promise', () => { + testScheduler.run(({ cold, expectObservable }) => { + const durationSelector = from(Promise.resolve(1)); + const s1 = cold('(abcdef|)'); + const exp = '(f|)'; + + const result = s1.pipe(coalesceWith(durationSelector)); + expectObservable(result).toBe(exp); + }); + }); + + it('should return observable that does not emits when not all sources emitted at least once', () => { + testScheduler.run(({ cold, expectObservable }) => { + const vm$: Observable = smosh({ + prop1: cold('h-h-i-', { h, i }), + prop2: cold('--a-b-', { a, b }), + prop3: cold('------'), + }); + const expected = '------'; + expectObservable(vm$).toBe(expected); + }); + }); + + it('should return observable that emits only distinct values -- should distinguish between values', () => { + testScheduler.run(({ cold, expectObservable }) => { + const values = { u, v, w, x }; + const vm$: Observable = smosh( + { + prop1: cold('h-h-i-i-i-i', { h, i }), + prop2: cold('a-a-a-b-b-b', { a, b }), + prop3: cold('f-f-f-f-t-t', { f, t }), + }, + undefined, + { durationSelector: cold('s') } + ); + const expected = 'u---v-w-x--'; + expectObservable(vm$).toBe(expected, values); + }); + }); + + it('should ignore changes if any key is undefined', () => { + testScheduler.run(({ cold, expectObservable }) => { + const values = { u, v, w, x }; + const vm$: Observable = smosh( + { + prop1: cold('h-h-i-i-i-i-i', { h, i }), + prop2: cold('_-a-a-_-b-_-b', { _, a, b }), + prop3: cold('f-f-f-f-f-t-t', { f, t }), + }, + undefined, + { durationSelector: cold('s') } + ); + const expected = '--u-v---w-x--'; + expectObservable(vm$).toBe(expected, values); + }); + }); + + it('should return observable that shares the composition', () => { + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + const values = { u, v, w, x }; + const prop1$ = cold('--h--', { h, i }); + const prop2$ = cold('--a--', { a, b }); + const prop3$ = cold('--f--', { f }); + const vm$: Observable = smosh( + { + prop1: prop1$, + prop2: prop2$, + prop3: prop3$, + }, + undefined, + { durationSelector: cold('s') } + ); + const psubs = '^----'; + const expected = '--u--'; + + expectObservable(vm$).toBe(expected, values); + expectSubscriptions(prop1$.subscriptions).toBe(psubs); + expectSubscriptions(prop2$.subscriptions).toBe(psubs); + expectSubscriptions(prop3$.subscriptions).toBe(psubs); + }); + }); + it('should return observable that coalesce sync emissions caused by combineLatest (over emitting)', () => { + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + const values = { u, v, w, x }; + const prop1$ = cold('--h--i-', { h, i }); + const prop2$ = cold('--a--b-', { a, b }); + const prop3$ = cold('--f--t-', { f, t }); + const vm$: Observable = smosh( + { + prop1: prop1$, + prop2: prop2$, + prop3: prop3$, + }, + undefined, + { durationSelector: cold('s') } + ); + const psubs = '^------'; + const expected = '--u--x-'; + + expectObservable(vm$).toBe(expected, values); + expectSubscriptions(prop1$.subscriptions).toBe(psubs); + expectSubscriptions(prop2$.subscriptions).toBe(psubs); + expectSubscriptions(prop3$.subscriptions).toBe(psubs); + }); + }); + + it('should return observable that coalesce sync emissions caused by sync emissions (over emitting)', () => { + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + const values = { u, v, w, x }; + const prop1$ = cold('(hhi)', { h, i }); + const prop2$ = cold('(abb)', { a, b }); + const prop3$ = cold('(fft)', { f, t }); + const vm$: Observable = smosh( + { + prop1: prop1$, + prop2: prop2$, + prop3: prop3$, + }, + undefined, + { durationSelector: cold('s') } + ); + const psubs = '^------'; + const expected = 'x'; + + expectObservable(vm$).toBe(expected, values); + expectSubscriptions(prop1$.subscriptions).toBe(psubs); + expectSubscriptions(prop2$.subscriptions).toBe(psubs); + expectSubscriptions(prop3$.subscriptions).toBe(psubs); + }); + }); + + it('should return observable that coalesce by a custom duration (edge-case)', () => { + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + const values = { u, v, w, x }; + const prop1$ = cold('h--i', { h, i }); + const prop2$ = cold('a--b', { a, b }); + const prop3$ = cold('f--t', { f, t }); + const vm$: Observable = smosh( + { + prop1: prop1$, + prop2: prop2$, + prop3: prop3$, + }, + undefined, + { durationSelector: cold('-----s') } + ); + const psubs = '^-----'; + const expected = '-----x'; + + expectObservable(vm$).toBe(expected, values); + expectSubscriptions(prop1$.subscriptions).toBe(psubs); + expectSubscriptions(prop2$.subscriptions).toBe(psubs); + expectSubscriptions(prop3$.subscriptions).toBe(psubs); + }); + }); + }); + + describe('with only the array param', () => { + it('should replay the last emitted value with all params used', () => { + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + const values = { u, v, w, x }; + const prop1$ = cold('h--i', { h: { prop1: h}, i: { prop1: i} }); + const prop2$ = cold('--a--', { a: { prop2: a} }); + const prop3$ = cold('--f--', { f: { prop3: f } }); + const vm$: Observable = smosh( + undefined, + [prop1$, /*prop2$, prop3$*/], + { durationSelector: cold('s') } + ); + const psubs = '^----'; + const expected = '--u--'; + + expectObservable(vm$).toBe(expected, values); + expectSubscriptions(prop2$.subscriptions).toBe(psubs); + expectSubscriptions(prop3$.subscriptions).toBe(psubs); + }); + }); + }); + it('should replay the last emitted value with all params used', () => { + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + const values = { u, v, w, x }; + const prop2$ = cold('--a--', { a }); + const prop3$ = cold('--f--', { f: { prop3: f } }); + const vm$: Observable = smosh( + { + prop1: h, + prop2: prop2$, + }, + [prop3$], + { durationSelector: cold('s') } + ); + const psubs = '^----'; + const expected = '--u--'; + + expectObservable(vm$).toBe(expected, values); + expectSubscriptions(prop2$.subscriptions).toBe(psubs); + expectSubscriptions(prop3$.subscriptions).toBe(psubs); + }); + }); +}); diff --git a/libs/state/selections/src/index.ts b/libs/state/selections/src/index.ts index 6dbb1a5e1a..263f98fd32 100644 --- a/libs/state/selections/src/index.ts +++ b/libs/state/selections/src/index.ts @@ -17,3 +17,4 @@ export { safePluck } from './lib/utils/safe-pluck'; export { KeyCompareMap, CompareFn, PickSlice } from './lib/interfaces/index'; export { createAccumulationObservable } from './lib/accumulation-observable'; export { createSideEffectObservable } from './lib/side-effect-observable'; +export { smosh } from './lib/smosh'; diff --git a/libs/state/selections/src/lib/interfaces/index.ts b/libs/state/selections/src/lib/interfaces/index.ts index d829643f27..d3138a63e8 100644 --- a/libs/state/selections/src/lib/interfaces/index.ts +++ b/libs/state/selections/src/lib/interfaces/index.ts @@ -1,3 +1,37 @@ +import { Observable } from 'rxjs'; + export { CompareFn } from './compare-fn'; export { KeyCompareMap } from './key-compare-map'; export { PickSlice } from './pick-slice'; + +export type ObservableMap = Record>; + +/** + * Type to map `ObservableMap` to a static record type + * the 'in' syntax forces the type specification by key + */ +export type ObservableAccumulation = { + [K in keyof T]: ExtractObservableValue; +}; + +/** + * This type avoids empty objects + */ +export type NotEmpty }> = Partial & + U[keyof U]; + +export type ExtractObservableValue = T extends Observable + ? R + : never; +export type PropName = keyof T; +export type PropType = T[PropName]; + +/** + * Typed reducer function for the `Array#reduce` method. + */ +export type ArrayReducerFn> = ( + acc: T, + cur?: PropType, + idx?: number +) => T; + diff --git a/libs/state/selections/src/lib/smosh.ts b/libs/state/selections/src/lib/smosh.ts new file mode 100644 index 0000000000..cb68573913 --- /dev/null +++ b/libs/state/selections/src/lib/smosh.ts @@ -0,0 +1,106 @@ +import { combineLatest, from, Observable, scan } from 'rxjs'; +import { distinctUntilChanged, filter, map, shareReplay } from 'rxjs/operators'; + +import { Promise } from '@rx-angular/cdk/zone-less/browser'; +import { coalesceWith } from '@rx-angular/cdk/coalescing'; +import { coerceObservable } from '@rx-angular/cdk/coercing'; +import { ExtractObservableValue, NotEmpty, ObservableMap } from './interfaces'; + +const resolvedPromise = Promise.resolve(); +const resolvedPromise$ = from(resolvedPromise); + +/** + * This Observable creation function helps to accumulate an object of key & Observable of values to + * an Observable of objects of key & value. + * This comes in handy if you quickly want to create subsets as objects/state-slices of different Observables. + * + * The resulting Observable filters out undefined values forwards only distinct values and shared the aggregated output. + * + * @example + * + * Default usage: + * + * const object$: Observable<{ + * prop1: number, + * prop2: string, + * prop3: string + * }> = smosh({ + * prop1: interval(42), + * prop2: of('lorem'), + * prop3: 'test' + * }); + * + * Usage with custom duration selector: + * + * const object$: Observable<{ + * prop1: number, + * prop2: string, + * prop3: string + * }> = smosh({ + * prop1: interval(42), + * prop2: of('lorem'), + * prop3: 'test' + * }, timer(0, 20)); + * + * @param obj - An object of key & Observable values pairs + * @param durationSelector - An Observable determining the duration for the internal coalescing method + */ +export function smosh< + T extends ObservableMap | (Partial & NotEmpty), + U extends Record +>( + obj: Partial, + spreads: Observable[] = [], + options?: { durationSelector: Observable } +): Observable<{ [K in keyof T]: ExtractObservableValue } & U> { + let { durationSelector } = options || {}; + durationSelector = durationSelector || resolvedPromise$; + const keys = Object.keys(obj) as (keyof T)[]; + const observables = keys.map((key) => + // turn values, if they are static, into Observables + coerceObservable(obj[key]).pipe( + // we avoid using the nullish operator later ;) + filter((v) => v !== undefined), + // state "changes" differ from each other, this operator ensures distinct values + distinctUntilChanged() + ) + ); + + const obj$ = combineLatest(observables).pipe( + // mapping array of values to object + map((values) => { + const obj = {} as any; + for (let i = 0; i < values.length; i++) { + obj[keys[i]] = values[i]; + } + return obj; + }) + ); + spreads = spreads.map((o) => + o.pipe( + // we avoid using the nullish operator later ;) + filter((v) => v !== undefined), + // state "changes" differ from each other, this operator ensures distinct values + distinctUntilChanged() + ) + ); + + return combineLatest([...spreads, obj$]) + .pipe( + scan((acc, slices) => { + const ks = slices.flatMap((slice) => Object.keys(slice)) as (keyof T)[]; + slices.forEach((slice) => { + for (let i = 0; i < ks.length; i++) { + acc[ks[i] as any] = slice[ks[i]]; + } + }); + return acc as any; + }, {}) + ) + .pipe( + // As combineLatest will emit multiple times for a change in multiple properties we coalesce those emissions together + coalesceWith(durationSelector), + // by using shareReplay we share the last composition work done to create the accumulated object + shareReplay(1) + ); +}