Skip to content

Commit

Permalink
Merge pull request #53 from WorldMaker:max/shocked-louse
Browse files Browse the repository at this point in the history
Add bindSuspense (#52)
  • Loading branch information
WorldMaker authored Aug 30, 2023
2 parents c69f630 + 93be74f commit f7dbe9c
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 10 deletions.
36 changes: 34 additions & 2 deletions projects/angular-pharkas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyExampleComponent> {
constructor(ref: ChangeDetectorRef) {
super(ref)

// Build some observables…

this.bindSuspense(someSuspenseObservable)
}
}
```

When this `Observable<boolean>` 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.

Expand Down
2 changes: 1 addition & 1 deletion projects/angular-pharkas/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
73 changes: 71 additions & 2 deletions projects/angular-pharkas/src/pharkas.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -52,6 +61,8 @@ interface PharkasMeta {
changeSubject: Subject<void>
templateError: BehaviorSubject<boolean>
effectError: boolean
suspense: BehaviorSubject<boolean>
suspenseBound: boolean
}

function bindSubject<T>(observable: Observable<T>, subject: Subject<T>) {
Expand Down Expand Up @@ -112,6 +123,29 @@ function bindTemplateSubject<T>(
})
}

function bindSuspense(observable: Observable<boolean>, 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
*
Expand All @@ -131,6 +165,8 @@ export class PharkasComponent<TViewModel> implements OnInit, OnDestroy {
changeSubject: new Subject<void>(),
effectError: false,
templateError: new BehaviorSubject<boolean>(false),
suspense: new BehaviorSubject<boolean>(false),
suspenseBound: false,
}

//#region *** Blinkenlights ***
Expand Down Expand Up @@ -161,6 +197,13 @@ export class PharkasComponent<TViewModel> 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

Expand Down Expand Up @@ -365,6 +408,17 @@ export class PharkasComponent<TViewModel> implements OnInit, OnDestroy {
} as PharkasProp<unknown>)
}

/**
* 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<boolean>) {
this[subscription].add(bindSuspense(observable, this[pharkas]))
}

//#endregion

/**
Expand Down Expand Up @@ -523,7 +577,22 @@ export class PharkasComponent<TViewModel> implements OnInit, OnDestroy {
}
}
if (observables.length) {
const displayObservable = merge(...observables).pipe(
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()
)
Expand Down
17 changes: 17 additions & 0 deletions projects/angular-pharkas/test/documentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyExampleComponent> {
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<MyExampleComponent> {
// Type only, no implementation:
Expand Down
20 changes: 15 additions & 5 deletions projects/test-app/src/pharkas-test.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import { scan } from 'rxjs/operators'
[class.is-warning]="highlighted"
>
Hello {{ testDisplay }}
<progress
*ngIf="pharkasSuspense"
class="progress is-small is-primary"
max="100"
>
Updating
</progress>
</div>`,
})
export class PharkasTestComponent extends PharkasComponent<PharkasTestComponent> {
Expand Down Expand Up @@ -55,16 +62,19 @@ export class PharkasTestComponent extends PharkasComponent<PharkasTestComponent>
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)
}
}

0 comments on commit f7dbe9c

Please sign in to comment.