From bb5bb6f3856f708a81c9d0e0b335bc62eae8d624 Mon Sep 17 00:00:00 2001 From: Michael Berger Date: Wed, 24 Jul 2024 11:42:54 +0200 Subject: [PATCH 1/5] demo: complete rx-stateful demo --- .../all-use-cases.component.html | 11 +++ .../all-use-cases.component.scss | 6 ++ .../all-use-cases.component.spec.ts | 21 ++++ .../all-use-cases/all-use-cases.component.ts | 97 +++++++++++++++++++ .../rx-stateful-state-visualizer.component.ts | 24 +++++ apps/demo-rx-stateful/src/app/app.routes.ts | 7 +- 6 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html create mode 100644 apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.scss create mode 100644 apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.spec.ts create mode 100644 apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts create mode 100644 apps/demo-rx-stateful/src/app/all-use-cases/rx-stateful-state-visualizer.component.ts diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html new file mode 100644 index 0000000..ecb41d5 --- /dev/null +++ b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html @@ -0,0 +1,11 @@ +
+

Case 1

+
+ +
+ @if(case1$ | async ; as data){ + + } +
+
+
diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.scss b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.scss new file mode 100644 index 0000000..a50e10a --- /dev/null +++ b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.scss @@ -0,0 +1,6 @@ +button{ + padding: 8px 16px; + border-radius: 9999px; + font-weight: bold; + border: 2px solid deepskyblue; +} diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.spec.ts b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.spec.ts new file mode 100644 index 0000000..99016a4 --- /dev/null +++ b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AllUseCasesComponent } from './all-use-cases.component'; + +describe('AllUseCasesComponent', () => { + let component: AllUseCasesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AllUseCasesComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AllUseCasesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts new file mode 100644 index 0000000..684bd39 --- /dev/null +++ b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts @@ -0,0 +1,97 @@ +import {Component, inject, Injectable} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import {HttpClient, HttpErrorResponse} from "@angular/common/http"; +import {delay, of, scan, Subject, switchMap, timer} from "rxjs"; +import {RxStateful, rxStateful$, withAutoRefetch, withRefetchOnTrigger} from "@angular-kit/rx-stateful"; +import {Todo} from "../types"; +import {RxStatefulStateVisualizerComponent} from "./rx-stateful-state-visualizer.component"; + +type Data = { + id: number; + name: string +} + +const DATA: Data[] = [ + {id: 1, name: 'ahsd'}, + {id: 2, name: 'asdffdsa'}, + {id: 3, name: 'eeasdf'}, +] + +@Injectable({providedIn: 'root'}) +export class DataService { + private readonly http = inject(HttpClient) + + + getData(opts?: {delay?: number}){ + return timer(opts?.delay ?? 1000).pipe( + switchMap(() => of(DATA)) + ) + } +} + +@Component({ + selector: 'demo-all-use-cases', + standalone: true, + imports: [CommonModule, RxStatefulStateVisualizerComponent], + templateUrl: './all-use-cases.component.html', + styleUrl: './all-use-cases.component.scss', +}) +export class AllUseCasesComponent { + private readonly data = inject(DataService) + readonly refresh$$ = new Subject() + refreshInterval = 10000 + /** + * Für alle Use Cases eine demo machen + */ + + /** + * Case 1 + * Basic Usage with automatic refetch and a refreshtrigger + */ + case1$ = rxStateful$( + this.data.getData(), + { + refetchStrategies: [ + withRefetchOnTrigger(this.refresh$$), + //withAutoRefetch(this.refreshInterval, 1000000) + ], + suspenseThresholdMs: 500, + suspenseTimeMs: 1000, + keepValueOnRefresh: false, + keepErrorOnRefresh: false, + errorMappingFn: (error) => error.message, + } + ).pipe( + scan, { + index: number; + value: RxStateful + }[]>((acc, value, index) => { + // @ts-ignore + acc.push({ index, value }); + + return acc; + }, []) + ) + + /** + * Case Basic Usage non flickering + */ + + /** + * Case Basic Usage flaky API + */ + //case2$ + + /** + * Case - sourcetrigger function + */ + + + /** + * Case - sourcetrigger function non flickering + */ + + /** + * Case - sourcetrigger function flaky api + */ +} diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/rx-stateful-state-visualizer.component.ts b/apps/demo-rx-stateful/src/app/all-use-cases/rx-stateful-state-visualizer.component.ts new file mode 100644 index 0000000..38e3d4a --- /dev/null +++ b/apps/demo-rx-stateful/src/app/all-use-cases/rx-stateful-state-visualizer.component.ts @@ -0,0 +1,24 @@ +import {Component, input} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import {RxStateful} from "@angular-kit/rx-stateful"; + +type Input = { + index: number; + value: RxStateful +} + +@Component({ + selector: 'rx-stateful-state-visualizer', + standalone: true, + imports: [CommonModule], + template: ` +
    + @for(s of state(); track s.index){ +
  • {{s |json}}
  • + } +
+ ` +}) +export class RxStatefulStateVisualizerComponent { + state = input([]) +} diff --git a/apps/demo-rx-stateful/src/app/app.routes.ts b/apps/demo-rx-stateful/src/app/app.routes.ts index 4cea463..5493e88 100644 --- a/apps/demo-rx-stateful/src/app/app.routes.ts +++ b/apps/demo-rx-stateful/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Route } from '@angular/router'; import {DemoPaginationComponent} from "./demos/demo-pagination.component"; import {DemoBasicUsageComponent} from "./demos/demo-basic-usage.component"; +import {AllUseCasesComponent} from "./all-use-cases/all-use-cases.component"; export const appRoutes: Route[] = [ { @@ -13,5 +14,9 @@ export const appRoutes: Route[] = [ path: 'pagination', component: DemoPaginationComponent, }, - + { + title: 'all-cases', + path: 'all-cases', + component: AllUseCasesComponent, + }, ]; From 735577688d39f6970dca612426b5831688e182eb Mon Sep 17 00:00:00 2001 From: Michael Berger Date: Thu, 25 Jul 2024 09:04:26 +0200 Subject: [PATCH 2/5] #111 fix(rx-stateful): fix that initially refresh trigger and sharedSource$ emit multiple values --- libs/rx-stateful/src/lib/rx-stateful$.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libs/rx-stateful/src/lib/rx-stateful$.ts b/libs/rx-stateful/src/lib/rx-stateful$.ts index 285c091..490e5d2 100644 --- a/libs/rx-stateful/src/lib/rx-stateful$.ts +++ b/libs/rx-stateful/src/lib/rx-stateful$.ts @@ -323,12 +323,19 @@ function createState$( */ // @ts-ignore todo refreshTriggerIsBehaivorSubject(mergedConfig) ? skip(1) : pipe(), + // @ts-ignore switchMap(() => sharedSource$.pipe( map(v => mapToValue(v)), deriveInitialValue(mergedConfig) ), ), + share({ + connector: () => new ReplaySubject(1), + resetOnError: true, + resetOnComplete: true, + resetOnRefCountZero: true, + }), ) as Observable>> From b0606ca54f52b6304cb85ae32bb76f285234205f Mon Sep 17 00:00:00 2001 From: Michael Berger Date: Thu, 25 Jul 2024 09:14:44 +0200 Subject: [PATCH 3/5] chore demo: enhance rx-stateful demos --- .../all-use-cases.component.html | 36 +++- .../all-use-cases/all-use-cases.component.ts | 80 +++++++-- .../non-flicker/non-flicker.component.ts | 165 ++++++++++++++++++ 3 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html index ecb41d5..eeb0345 100644 --- a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html +++ b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html @@ -1,11 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-

Case 1

+

Bugfix Reproduction Normal Case

- +
- @if(case1$ | async ; as data){ + @if(two$ | async ; as data){ }
+ + diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts index 684bd39..aef4caa 100644 --- a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts +++ b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.ts @@ -1,10 +1,11 @@ import {Component, inject, Injectable} from '@angular/core'; import { CommonModule } from '@angular/common'; import {HttpClient, HttpErrorResponse} from "@angular/common/http"; -import {delay, of, scan, Subject, switchMap, timer} from "rxjs"; +import {delay, Observable, of, OperatorFunction, scan, Subject, switchMap, timer} from "rxjs"; import {RxStateful, rxStateful$, withAutoRefetch, withRefetchOnTrigger} from "@angular-kit/rx-stateful"; import {Todo} from "../types"; import {RxStatefulStateVisualizerComponent} from "./rx-stateful-state-visualizer.component"; +import {NonFlickerComponent} from "./non-flicker/non-flicker.component"; type Data = { id: number; @@ -27,16 +28,23 @@ export class DataService { switchMap(() => of(DATA)) ) } + + getById(id: number, opts?: {delay?: number}){ + return timer(opts?.delay ?? 1000).pipe( + switchMap(() => of(DATA.find(v =>v.id === id))) + ) + } } @Component({ selector: 'demo-all-use-cases', standalone: true, - imports: [CommonModule, RxStatefulStateVisualizerComponent], + imports: [CommonModule, RxStatefulStateVisualizerComponent, NonFlickerComponent], templateUrl: './all-use-cases.component.html', styleUrl: './all-use-cases.component.scss', }) export class AllUseCasesComponent { + private readonly http = inject(HttpClient) private readonly data = inject(DataService) readonly refresh$$ = new Subject() refreshInterval = 10000 @@ -55,22 +63,14 @@ export class AllUseCasesComponent { withRefetchOnTrigger(this.refresh$$), //withAutoRefetch(this.refreshInterval, 1000000) ], - suspenseThresholdMs: 500, - suspenseTimeMs: 1000, + suspenseThresholdMs: 0, + suspenseTimeMs: 0, keepValueOnRefresh: false, keepErrorOnRefresh: false, errorMappingFn: (error) => error.message, } ).pipe( - scan, { - index: number; - value: RxStateful - }[]>((acc, value, index) => { - // @ts-ignore - acc.push({ index, value }); - - return acc; - }, []) + collectState() ) /** @@ -94,4 +94,58 @@ export class AllUseCasesComponent { /** * Case - sourcetrigger function flaky api */ + + /** + * Case Bug Reproduction https://github.com/mikelgo/angular-kit/issues/111 + */ + + deleteAction$ = new Subject() + + delete$ = rxStateful$( + // id => this.http.get(`https://jsonplaceholder.typicode.com/posts/${id}`), + id => timer(1000).pipe( + switchMap(() => of(null)) + ), + { + suspenseTimeMs: 0, + suspenseThresholdMs: 0, + sourceTriggerConfig: { + operator: 'switch', + trigger: this.deleteAction$ + } + } + ).pipe( + collectState() + ) + + /** + * Case Normal for Bug repro + */ + refresh$ = new Subject() + two$ = rxStateful$( + timer(1000).pipe( + switchMap(() => of(null)) + ), + { + refetchStrategies: [withRefetchOnTrigger(this.refresh$)] + } + ).pipe( + collectState() + ) +} + + +function collectState(): OperatorFunction, { + index: number; + value: RxStateful +}[]>{ + return scan, { + index: number; + value: RxStateful + }[]>((acc, value, index) => { + // @ts-ignore + acc.push({ index, value }); + + return acc; + }, []) } diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts b/apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts new file mode 100644 index 0000000..142484a --- /dev/null +++ b/apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts @@ -0,0 +1,165 @@ +import {Component, inject} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import {HttpClient} from "@angular/common/http"; +import {ActivatedRoute} from "@angular/router"; +import {BehaviorSubject, concatAll, delay, map, scan, Subject, switchMap, tap, toArray} from "rxjs"; +import {provideRxStatefulClient, RxStatefulClient, withConfig} from "@angular-kit/rx-stateful/experimental"; +import {rxStateful$, withRefetchOnTrigger} from "@angular-kit/rx-stateful"; + +@Component({ + selector: 'demo-non-flicker', + standalone: true, + imports: [CommonModule], + template: ` +

DemoRxStatefulComponent

+
+ +
+
+

State

+
+
{{ state.value | json }}
+
loading
+
+
+
+

State Accumulated

+
    +
  • {{ v | json }}
  • +
+
+ + + + + + + + + + + + + + + + + + + `, + styles: ` + :host { + display: block; + } + `, + providers: [ + provideRxStatefulClient( + withConfig({ keepValueOnRefresh: false, errorMappingFn: (e) => e}) + ), + // provideRxStatefulConfig({keepValueOnRefresh: true, errorMappingFn: (e) => e}) + ], +}) +export class NonFlickerComponent { + private http = inject(HttpClient); + private route = inject(ActivatedRoute); + refresh$$ = new Subject(); + + client = inject(RxStatefulClient); + + query$ = this.route.params; + + value$ = this.query$.pipe(switchMap(() => this.client.request(this.fetch()).pipe( + map(v => v.value) + ))); + + // instance = this.client.request(this.fetch(), { + // keepValueOnRefresh: false, + // keepErrorOnRefresh: false, + // refreshTrigger$: this.refresh$$, + // refetchStrategies: [withAutoRefetch(10000, 20000)], + // }); + // state$ = this.instance; + // stateAccumulated$ = this.state$.pipe( + // tap(console.log), + // scan((acc, value, index) => { + // @ts-ignore + // acc.push({ index, value }); + // + // return acc; + // }, []) + // ); + + + state$ = rxStateful$(this.fetch(400), { + keepValueOnRefresh: false, + keepErrorOnRefresh: false, + refreshTrigger$: this.refresh$$, + suspenseTimeMs: 1000, + suspenseThresholdMs: 500 + }); + + stateAccumulated$ = this.state$.pipe( + tap(x => console.log({state: x})), + scan((acc, value, index) => { + // @ts-ignore + acc.push({ index, value }); + + return acc; + }, []) + ); + readonly page$$ = new BehaviorSubject(0) + readonly page$ = this.page$$.pipe( + scan((acc, curr) => acc + curr, 0) + ) + + state2$ = rxStateful$( + (page) => this.fetchPage({ + page, + delayInMs: 1000 + }).pipe( + + ), + { + sourceTriggerConfig: { + trigger: this.page$ + }, + refetchStrategies: withRefetchOnTrigger(this.refresh$$) + } + ) + state2Accumulated$ = this.state2$.pipe( + tap(x => console.log({state: x})), + scan((acc, value, index) => { + // @ts-ignore + acc.push({ index, value }); + + return acc; + }, []) + ); + + fetch(delayInMs = 800) { + return this.http.get('https://jsonplaceholder.typicode.com/todos/1').pipe( + delay(delayInMs), + map((v) => v?.title), + // tap(console.log) + ); + } + + fetchPage(params: { + delayInMs:number, + page: number + }) { + + return this.http.get(`https://jsonplaceholder.typicode.com/todos?_start=${params.page * 5}&_limit=5`).pipe( + delay(params.delayInMs), + concatAll(), + // @ts-ignore + map((v) => v?.id), + toArray() + ); + } + + constructor() { + this.state$.subscribe(); + this.state$.subscribe(); + } +} From 427d098dbe2f5a70871690d077ff305d5dae27c1 Mon Sep 17 00:00:00 2001 From: Michael Berger Date: Thu, 25 Jul 2024 09:26:37 +0200 Subject: [PATCH 4/5] #111 fix(rx-stateful): fix not emitting subsequent values The issue was caused when a source does not emit a thruthy value. This is e.g. the case for Endpoints which send an empty response back, e.g. after a delete-operation. The problem can easiliy be simulated by ```ts rxStateful$( timer(1000).pipe(switchMap(() => of(null))) ) ``` --- libs/rx-stateful/src/lib/rx-stateful$.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/rx-stateful/src/lib/rx-stateful$.ts b/libs/rx-stateful/src/lib/rx-stateful$.ts index 490e5d2..7d7cac5 100644 --- a/libs/rx-stateful/src/lib/rx-stateful$.ts +++ b/libs/rx-stateful/src/lib/rx-stateful$.ts @@ -212,8 +212,8 @@ function createState$( refreshedValue$.pipe( // with this we make sure that we do not turn off the suspsense state as long as a request is running // @ts-ignore - filter(v => !!v.value) - ), + filter(v => v.context !== 'suspense') + ), timer(suspenseThreshold + suspenseTime)] ).pipe(map(() => false)) ) @@ -234,7 +234,7 @@ function createState$( valueFromSourceTrigger$.pipe( // with this we make sure that we do not turn off the suspsense state as long as a request is running // @ts-ignore - filter(v => !!v.value) + filter(v => v.context !== 'suspense') ), timer(suspenseThreshold + suspenseTime) ]).pipe(map(() => false)) @@ -354,7 +354,7 @@ function createState$( combineLatest([ refreshedRequest$.pipe( // with this we make sure that we do not turn off the suspsense state as long as a request is running - filter(v => !!v.value) + filter(v => v.context !== 'suspense') ), timer(suspenseThreshold + suspenseTime)] ).pipe(map(() => false)) From 23f61fbf6f3af1b29fbb227002f452d66ef1a038 Mon Sep 17 00:00:00 2001 From: Michael Berger Date: Fri, 26 Jul 2024 07:43:15 +0200 Subject: [PATCH 5/5] chore: ehnance demos --- .../all-use-cases.component.html | 22 +++---- .../non-flicker/non-flicker.component.ts | 62 ++++++++++--------- .../demo-rx-stateful.component.ts | 2 +- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html index eeb0345..934953f 100644 --- a/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html +++ b/apps/demo-rx-stateful/src/app/all-use-cases/all-use-cases.component.html @@ -11,17 +11,17 @@ - - - - - - - - - - - +
+

Bugfix Reproduction

+
+ +
+ @if(delete$ | async ; as data){ + + } +
+
+
diff --git a/apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts b/apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts index 142484a..f0826c6 100644 --- a/apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts +++ b/apps/demo-rx-stateful/src/app/all-use-cases/non-flicker/non-flicker.component.ts @@ -12,22 +12,22 @@ import {rxStateful$, withRefetchOnTrigger} from "@angular-kit/rx-stateful"; imports: [CommonModule], template: `

DemoRxStatefulComponent

-
- -
-
-

State

-
-
{{ state.value | json }}
-
loading
-
-
-
-

State Accumulated

-
    -
  • {{ v | json }}
  • -
-
+ + + + + + + + + + + + + + + + @@ -35,17 +35,17 @@ import {rxStateful$, withRefetchOnTrigger} from "@angular-kit/rx-stateful"; - - - - - - - - - - - +
+ + + +
+

State Accumulated

+
    +
  • {{ v | json }}
  • +
+
+
`, styles: ` :host { @@ -90,11 +90,11 @@ export class NonFlickerComponent { // ); - state$ = rxStateful$(this.fetch(400), { + state$ = rxStateful$(this.fetch(450), { keepValueOnRefresh: false, keepErrorOnRefresh: false, refreshTrigger$: this.refresh$$, - suspenseTimeMs: 1000, + suspenseTimeMs: 3000, suspenseThresholdMs: 500 }); @@ -115,11 +115,13 @@ export class NonFlickerComponent { state2$ = rxStateful$( (page) => this.fetchPage({ page, - delayInMs: 1000 + delayInMs: 5000 }).pipe( ), { + suspenseThresholdMs: 500, + suspenseTimeMs: 2000, sourceTriggerConfig: { trigger: this.page$ }, diff --git a/apps/demo/src/app/demos/demo-rx-stateful/demo-rx-stateful.component.ts b/apps/demo/src/app/demos/demo-rx-stateful/demo-rx-stateful.component.ts index ce15c84..ce6e034 100644 --- a/apps/demo/src/app/demos/demo-rx-stateful/demo-rx-stateful.component.ts +++ b/apps/demo/src/app/demos/demo-rx-stateful/demo-rx-stateful.component.ts @@ -88,7 +88,7 @@ export class DemoRxStatefulComponent { // ); - state$ = rxStateful$(this.fetch(4000), { + state$ = rxStateful$(this.fetch(400), { keepValueOnRefresh: false, keepErrorOnRefresh: false, refreshTrigger$: this.refresh$$,