From 05ce2b6159c186141bf541d08745f9dae3c08662 Mon Sep 17 00:00:00 2001 From: Max Battcher Date: Wed, 30 Aug 2023 14:40:19 -0400 Subject: [PATCH 1/4] Add bindSuspense (bindSuspense #52) --- projects/angular-pharkas/README.md | 36 +++++++++- .../angular-pharkas/src/pharkas.component.ts | 68 ++++++++++++++++++- .../test/documentation.test.ts | 17 +++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/projects/angular-pharkas/README.md b/projects/angular-pharkas/README.md index e71ddc3..0db6b73 100644 --- a/projects/angular-pharkas/README.md +++ b/projects/angular-pharkas/README.md @@ -179,10 +179,42 @@ managing the DOM. (If you were to build a "push" alternative to Angular's Reacti for instance.) In such cases there are "Immediate" variants of bindings. You may not need them, and the defaults should do what you need in most cases. -### Error Blinkenlights +### Suspense + +A component may bind a suspense observable: + +```ts +@Component({ + // … +}) +export class MyExampleComponent extends PharkasComponent { + constructor(ref: ChangeDetectorRef) { + super(ref) + + // Build some observables… + + this.bindSuspense(someSuspenseObservable) + } +} +``` + +When this `Observable` emits `true`, template change notifications are skipped for +non-immediate template bindings until the next `false` is emitted. The `pharkasSuspense` +blinkenlight reflects this suspense state. + +This may be useful for instance while a loading operation is taking place to display some +simple loading indicator and reduce "UI bouncing" with intermediate states. + +Note that this only suspends normal change detection pushes. It will not entirely eliminate +such "UI bouncing" as for instance an immediate binding will still trigger Angular change +detection. + +### Blinkenlights Pharkas provides a set of blinkenlights (lights intended to blink a status) for very basic error -status indication. These are "last chance error reporting" blinkenlights for generic error +status indication or suspense states (such as loading). + +The error blinkenlights are "last chance error reporting" blinkenlights for generic error situations when any observable provided to `bind` or `bindEffect` or an "Immediate" variant of such has thrown an error. diff --git a/projects/angular-pharkas/src/pharkas.component.ts b/projects/angular-pharkas/src/pharkas.component.ts index 42f6bb0..705d439 100644 --- a/projects/angular-pharkas/src/pharkas.component.ts +++ b/projects/angular-pharkas/src/pharkas.component.ts @@ -10,14 +10,23 @@ import { import { animationFrameScheduler, BehaviorSubject, + combineLatest, + concat, isObservable, merge, Observable, + of, ReplaySubject, Subject, Subscription, } from 'rxjs' -import { debounceTime, observeOn, share } from 'rxjs/operators' +import { + debounceTime, + filter, + observeOn, + pairwise, + share, +} from 'rxjs/operators' const subscription = Symbol('subscription') const props = Symbol('props') @@ -52,6 +61,8 @@ interface PharkasMeta { changeSubject: Subject templateError: BehaviorSubject effectError: boolean + suspense: BehaviorSubject + suspenseBound: boolean } function bindSubject(observable: Observable, subject: Subject) { @@ -112,6 +123,29 @@ function bindTemplateSubject( }) } +function bindSuspense(observable: Observable, meta: PharkasMeta) { + // Binding multiple suspense observables is probably a mistake, but the default + // merge behavior is probably good enough + if (meta.suspenseBound && isDevMode()) { + console.warn('Suspense already bound for component') + } + meta.suspenseBound = true + return observable.subscribe({ + next: (value) => meta.suspense.next(value), + error: (err: any) => { + if (isDevMode()) { + console.warn(`Suspense binding error`, err) + } + meta.templateError.next(true) + }, + complete: () => { + if (isDevMode()) { + console.warn('Suspense binding completed') + } + }, + }) +} + /** * Pharkas Base Component * @@ -131,6 +165,8 @@ export class PharkasComponent implements OnInit, OnDestroy { changeSubject: new Subject(), effectError: false, templateError: new BehaviorSubject(false), + suspense: new BehaviorSubject(false), + suspenseBound: false, } //#region *** Blinkenlights *** @@ -161,6 +197,13 @@ export class PharkasComponent implements OnInit, OnDestroy { public get pharkasTemplateError() { return this[pharkas].templateError.value } + /** + * Template change notifications are suspended for non-immediate bindings. + * (Observed from a `bindSuspense`.) + */ + public get pharkasSuspense() { + return this[pharkas].suspense.value + } //#endregion @@ -365,6 +408,17 @@ export class PharkasComponent implements OnInit, OnDestroy { } as PharkasProp) } + /** + * Bind a suspense observable + * + * While this observable emits `true`, template change notifications will skipped + * for non-immediate bindings and the `pharkasSuspense` blinkenlight will be `true`. + * @param observable Suspense + */ + protected bindSuspense(observable: Observable) { + this[subscription].add(bindSuspense(observable, this[pharkas])) + } + //#endregion /** @@ -523,7 +577,17 @@ export class PharkasComponent implements OnInit, OnDestroy { } } if (observables.length) { - const displayObservable = merge(...observables).pipe( + const displayObservable = combineLatest([ + concat( + of([false, false]), + this[pharkas].suspense.asObservable().pipe(pairwise()) + ), + merge(...observables), + ]).pipe( + filter( + ([[lastSuspense, suspense]]) => + !suspense || (lastSuspense && lastSuspense !== suspense) + ), debounceTime(0, animationFrameScheduler), share() ) diff --git a/projects/angular-pharkas/test/documentation.test.ts b/projects/angular-pharkas/test/documentation.test.ts index 0f070a4..3843b79 100644 --- a/projects/angular-pharkas/test/documentation.test.ts +++ b/projects/angular-pharkas/test/documentation.test.ts @@ -124,6 +124,23 @@ describe('works as documented in README', () => { expect(exampleInstance.testDisplay).toEqual('Hello World') }) + it('can have a suspense binding', () => { + class MyExampleComponent extends PharkasComponent { + constructor(ref: ChangeDetectorRef) { + super(ref) + + const suspense = of(true) + + this.bindSuspense(suspense) + } + } + expect(MyExampleComponent).toBeDefined() + + const exampleInstance = new MyExampleComponent(null!) + expect(exampleInstance).toBeTruthy() + expect(exampleInstance.pharkasSuspense).toBeTruthy() + }) + it('can define a callback', () => { class MyExampleComponent extends PharkasComponent { // Type only, no implementation: From c1980bd28ef1a7ee39c3b3ff0dedc6c42fd5a4fa Mon Sep 17 00:00:00 2001 From: Max Battcher Date: Wed, 30 Aug 2023 17:44:01 -0400 Subject: [PATCH 2/4] Add suspense behavior to test component "highlight" --- .../test-app/src/pharkas-test.component.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/projects/test-app/src/pharkas-test.component.ts b/projects/test-app/src/pharkas-test.component.ts index 8e0fe0e..dcfbd6e 100644 --- a/projects/test-app/src/pharkas-test.component.ts +++ b/projects/test-app/src/pharkas-test.component.ts @@ -17,6 +17,13 @@ import { scan } from 'rxjs/operators' [class.is-warning]="highlighted" > Hello {{ testDisplay }} + + Updating + `, }) export class PharkasTestComponent extends PharkasComponent { @@ -55,16 +62,19 @@ export class PharkasTestComponent extends PharkasComponent const click = this.useCallback('handleClick') this.bindOutput(this.mickey, click) + const highlighted = click.pipe(scan((acc) => !acc, false as boolean)) + // Simple "state" logic to toggle a highlight on click - this.bind( - 'highlighted', - click.pipe(scan((acc) => !acc, false as boolean)), - false - ) + this.bind('highlighted', highlighted, false) + + // Suspend while highlighted + this.bindSuspense(highlighted) // Dumb console log test of handleClick this.bindEffect(click, ([mouseEvent]) => console.log('clicked', mouseEvent)) + // this.bindEffect(this.pharkasChangeNotifications, () => console.log('tick')) + console.log(this) } } From 11423c815020d7da8136f3b10100adc946578430 Mon Sep 17 00:00:00 2001 From: Max Battcher Date: Wed, 30 Aug 2023 17:51:36 -0400 Subject: [PATCH 3/4] Avoid suspense operators when not bound --- .../angular-pharkas/src/pharkas.component.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/projects/angular-pharkas/src/pharkas.component.ts b/projects/angular-pharkas/src/pharkas.component.ts index 705d439..2401700 100644 --- a/projects/angular-pharkas/src/pharkas.component.ts +++ b/projects/angular-pharkas/src/pharkas.component.ts @@ -577,17 +577,22 @@ export class PharkasComponent implements OnInit, OnDestroy { } } if (observables.length) { - const displayObservable = combineLatest([ - concat( - of([false, false]), - this[pharkas].suspense.asObservable().pipe(pairwise()) - ), - merge(...observables), - ]).pipe( - filter( - ([[lastSuspense, suspense]]) => - !suspense || (lastSuspense && lastSuspense !== suspense) - ), + const merged = merge(...observables) + const suspensed = this[pharkas].suspenseBound + ? combineLatest([ + concat( + of([false, false]), + this[pharkas].suspense.asObservable().pipe(pairwise()) + ), + merged, + ]).pipe( + filter( + ([[lastSuspense, suspense]]) => + !suspense || (lastSuspense && lastSuspense !== suspense) + ) + ) + : merged + const displayObservable = suspensed.pipe( debounceTime(0, animationFrameScheduler), share() ) From 93be74ffaed0744eff1048e108b1b2d63555514b Mon Sep 17 00:00:00 2001 From: Max Battcher Date: Wed, 30 Aug 2023 17:57:56 -0400 Subject: [PATCH 4/4] Bump semver major for angular-pharkas New protected method and new blinkenlight, two big semver breaks. --- projects/angular-pharkas/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/angular-pharkas/package.json b/projects/angular-pharkas/package.json index bdd8c04..21ce9c7 100644 --- a/projects/angular-pharkas/package.json +++ b/projects/angular-pharkas/package.json @@ -1,6 +1,6 @@ { "name": "angular-pharkas", - "version": "5.2.1", + "version": "6.0.0", "peerDependencies": { "@angular/common": "^13.3.0", "@angular/core": "^13.3.0"