From d8f3ae20e57f1de7baff486f77540758ad48597b Mon Sep 17 00:00:00 2001 From: Michael Hladky Date: Thu, 3 Feb 2022 21:18:11 +0100 Subject: [PATCH 01/26] feat: reintroduce smosh, add docs to selections --- libs/state/selections/docs/Readme.md | 81 +++++- libs/state/selections/spec/smosh.spec.ts | 255 ++++++++++++++++++ libs/state/selections/src/index.ts | 1 + .../selections/src/lib/interfaces/index.ts | 34 +++ libs/state/selections/src/lib/smosh.ts | 94 +++++++ 5 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 libs/state/selections/spec/smosh.spec.ts create mode 100644 libs/state/selections/src/lib/smosh.ts diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index 648364e263..147c5f20fe 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -1,10 +1,79 @@ # Motivation -TBD + -* [`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 thier 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: { id: number, date: Date } +} +``` + +This data is consumed in different screens: + +**A list of all items sorted by id** +```typescript +interface SeceltionScreen1 { + title: string; + sortDirection: 'asc' | 'desc' | 'none'; + list: { id: number } +} +``` + +**A list of items filtered by date** +```typescript +interface SeceltionScreen2 { + title: string; + startingDate: Date; + list: { 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. + + + +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 + + + +- 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 +- Fully Typed + +## Selection owner - Template vs Class + +As Observables are cold their resulting srteam will only get active too by a subscription. +This leads to situations called the late or the early subscriber problem. (LINK) + + + +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. + + + +## Advanced derivations architecture + + diff --git a/libs/state/selections/spec/smosh.spec.ts b/libs/state/selections/spec/smosh.spec.ts new file mode 100644 index 0000000000..1bdb06f57d --- /dev/null +++ b/libs/state/selections/spec/smosh.spec.ts @@ -0,0 +1,255 @@ +import { TestScheduler } from 'rxjs/testing'; +// tslint:disable-next-line:nx-enforce-module-boundaries +import { jestMatcher } from '@test-helpers'; + +import { smosh } from '@rx-angular/state/selections'; +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', () => { + 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 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 }), + }, cold( 's')) + const expected = '----x-'; + expectObservable(vm$).toBe(expected, { x }); + }); + }); + + 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 }), + }, 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 }), + }, 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$, + }, 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 replay the last emitted value', () => { + testScheduler.run(({ cold, expectObservable }) => {}); + }); + + 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$, + }, 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$, + }, 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$, + }, 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); + }); + }); +}); 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..ae8189ab8d --- /dev/null +++ b/libs/state/selections/src/lib/smosh.ts @@ -0,0 +1,94 @@ +import { combineLatest, from, Observable } 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 { ExtractObservableValue } from '../../../../cdk/internals/core/src/lib/model'; +import { ArrayReducerFn, NotEmpty, ObservableMap, PropName, PropType } from './interfaces'; + +const resolvedPromise = Promise.resolve(); +const resolvedPromise$ = from(resolvedPromise); + +/** + * @internal + * + * Used for typing + */ +function getEntriesToObjectReducerFn>( + keys: PropName[] +): ArrayReducerFn { + return ( + accumulator: T, + currentValue?: PropType, + currentIndex?: number + ): T => { + return { + ...accumulator, + [keys[currentIndex]]: currentValue, + }; + }; +} + +/** + * 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 + * }> = accumulateObservables({ + * prop1: interval(42), + * prop2: of('lorem'), + * prop3: 'test' + * }); + * + * Usage with custom duration selector: + * + * const object$: Observable<{ + * prop1: number, + * prop2: string, + * prop3: string + * }> = accumulateObservables({ + * 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>( + // @TODO type static or Observable to enable mixing of imperative and reatctive values + obj: T, + durationSelector: Observable = resolvedPromise$ +): Observable<{ [K in keyof T]: ExtractObservableValue | T[K] }> { + const keys = Object.keys(obj) as (keyof T)[]; + // @TODO better typing to enable static values => coerceObservable(obj[key]) + const observables = keys.map((key) => + 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() + ) + ); + return combineLatest(observables).pipe( + // As combineLatest will emit multiple times for a change in multiple properties we coalesce those emissions together + coalesceWith(durationSelector), + // mapping array of values to object + map((values) => + values.reduce(getEntriesToObjectReducerFn(keys), {} as any) + ), + // by using shareReplay we share the last composition work done to create the accumulated object + shareReplay(1) + ); +} From a5a0e492971bd67afd1c5c4beff59e661b0c0007 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Thu, 3 Feb 2022 21:22:46 +0100 Subject: [PATCH 02/26] Update libs/state/selections/docs/Readme.md --- libs/state/selections/docs/Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index 147c5f20fe..3846dbe364 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -1,6 +1,6 @@ # Motivation - +![Selections (1)](https://user-images.githubusercontent.com/10064416/152422745-b3d8e094-d0f0-4810-b1b2-5f81fae25938.png) 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). From 78becbd5f7c4dca8bf6083260c98ca51413ef02c Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Thu, 3 Feb 2022 21:23:09 +0100 Subject: [PATCH 03/26] Update libs/state/selections/docs/Readme.md --- libs/state/selections/docs/Readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index 3846dbe364..063f57a577 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -44,7 +44,9 @@ One time they are displayed in a sorted order, the other time only filtered subs > 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. From caed2fa7c27b79cca92ee7a57e1801a56788b27f Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Thu, 3 Feb 2022 21:23:41 +0100 Subject: [PATCH 04/26] Update libs/state/selections/docs/Readme.md --- libs/state/selections/docs/Readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index 063f57a577..f616cbf829 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -72,7 +72,9 @@ This leads to situations called the late or the early subscriber problem. (LINK) 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) + + ## Advanced derivations architecture From 70652abb747272151d0e277b99b75daa79bd7e4c Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Thu, 3 Feb 2022 21:24:12 +0100 Subject: [PATCH 05/26] Update libs/state/selections/docs/Readme.md --- libs/state/selections/docs/Readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index f616cbf829..4e94524b0b 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -68,7 +68,8 @@ As this process contains a lot of gotchas and possible pitfalls in terms of memo As Observables are cold their resulting srteam will only get active too by a subscription. This leads to situations called the late 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. From d622605a41dfd8a1e839f14daba6b17716fc7b3b Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Thu, 3 Feb 2022 21:24:48 +0100 Subject: [PATCH 06/26] Update libs/state/selections/docs/Readme.md --- libs/state/selections/docs/Readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index 4e94524b0b..99553b30b6 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -81,4 +81,5 @@ In most cases it's best to go with solving problems on the early subscriber side +![Selections (7)](https://user-images.githubusercontent.com/10064416/152423026-d23326c2-97d5-4bd0-9015-f498c3fc0e55.png) + From ef3131fa2ec9ef8e3261df574fe4f15fb37a76f3 Mon Sep 17 00:00:00 2001 From: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> Date: Thu, 3 Feb 2022 21:24:56 +0100 Subject: [PATCH 07/26] Update libs/state/selections/docs/Readme.md --- libs/state/selections/docs/Readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/state/selections/docs/Readme.md b/libs/state/selections/docs/Readme.md index 99553b30b6..6fd456c244 100644 --- a/libs/state/selections/docs/Readme.md +++ b/libs/state/selections/docs/Readme.md @@ -79,7 +79,8 @@ In most cases it's best to go with solving problems on the early subscriber side ## Advanced derivations architecture -