Skip to content

Instantly share code, notes, and snippets.

@tricki
Last active August 1, 2022 19:46
Show Gist options
  • Save tricki/e897f3e40c15fd4dfa089a0c942acf18 to your computer and use it in GitHub Desktop.
Save tricki/e897f3e40c15fd4dfa089a0c942acf18 to your computer and use it in GitHub Desktop.
Stencil Async Helper (Promise + RXJS/Observables)
import { Component, h, Host, State } from '@stencil/core';
import { Subject } from 'rxjs';
import { async } from '../../utils/StencilAsync';
@Component({
tag: 'async-test',
})
export class RxjsTest {
test$ = new Subject<number>();
testPromiseResolve: (value: unknown) => void;
testPromiseReject: (reason?: any) => void;
testPromise = new Promise<number>((resolve, reject) => (this.testPromiseResolve = resolve, this.testPromiseReject = reject));
@State() useAsync = true;
addTestValue() {
this.test$.next(Math.random());
}
render() {
console.log('render')
return (
<Host>
<section>
<p>
Observable Value: {this.useAsync ? async(this.test$).toFixed() : 'Nothing'}
</p>
<button onClick={() => this.addTestValue()}>ADD VALUE</button>
</section>
<section>
<p>
Promise Value: {this.useAsync ? async(this.testPromise).toFixed() : 'Nothing'}
</p>
<button onClick={() => this.testPromiseResolve(Math.round(Math.random() * 100))}>RESOLVE VALUE</button>
<button onClick={() => this.testPromiseReject(Math.round(Math.random() * 100))}>REJECT VALUE</button>
</section>
<button onClick={() => this.useAsync = !this.useAsync}>TOGGLE ASYNC</button>
</Host>
);
}
}
import { ComponentInterface, forceUpdate, getRenderingRef } from '@stencil/core';
import type { Observable, Subscription } from 'rxjs';
import { ComponentRegistration, ObservableRegistration } from "./types";
export function async<T>(obj: Observable<T> | Promise<T>): T | undefined {
return getAsyncValue(obj);
}
export interface ComponentRegistration<T = any> {
promises: PromiseMap;
observables: Map<Observable<unknown>, ObservableRegistration<T>>;
/**
* An array of all observables that
* were used in the last render.
*/
recentlyUsedObservables: Observable<unknown>[];
origMethods: {
connectedCallback?: () => unknown,
disconnectedCallback?: () => unknown,
render?: () => unknown,
}
}
export type PromiseMap<T = any> = Map<Promise<T>, T | undefined>;
export interface ObservableRegistration<T> {
subscription: Subscription,
result?: T | undefined;
}
/**
* A Map of all components that are currently registered with stencil-async.
*
* @internal
*/
const componentRegistrations = new Map<ComponentInterface, ComponentRegistration>();
function init(component: ComponentInterface) {
if (componentRegistrations.has(component)) {
// component already initialized
return;
}
const compReg: ComponentRegistration = {
promises: new Map(),
observables: new Map(),
recentlyUsedObservables: [],
origMethods: {
connectedCallback: component.connectedCallback,
disconnectedCallback: component.disconnectedCallback,
render: component.render,
},
};
componentRegistrations.set(component, compReg);
component.connectedCallback = function () {
// reinit if reconnected
init(component);
if (compReg.origMethods.connectedCallback) {
compReg.origMethods.connectedCallback.call(component);
}
};
component.disconnectedCallback = function () {
destroy(component);
if (compReg.origMethods.disconnectedCallback) {
compReg.origMethods.disconnectedCallback.call(component);
}
};
component.render = function () {
if (!compReg.origMethods.render) {
// TODO should we still unsubscribe?
return;
}
compReg.recentlyUsedObservables = [];
const renderResult = compReg.origMethods.render.call(component);
// unsubscribe observables that are not used in `render()` anymore
[...compReg.observables.keys()]
.filter(obs => !compReg.recentlyUsedObservables.includes(obs))
.forEach(unusedObs => unregisterObservable(component, unusedObs));
if (
compReg.recentlyUsedObservables.length === 0
&& compReg.promises.size === 0
) {
// completely remove stencil-async from the component
// TODO should this be optimized?
destroy(component);
}
return renderResult;
}
}
function destroy(component: ComponentInterface) {
if (!componentRegistrations.has(component)) {
return;
}
const compReg = componentRegistrations.get(component) as ComponentRegistration;
// unsubscribe all component observables
compReg.observables.forEach(obsReg => obsReg.subscription.unsubscribe());
componentRegistrations.delete(component);
// reset the methods
component.connectedCallback = compReg.origMethods.connectedCallback;
component.disconnectedCallback = compReg.origMethods.disconnectedCallback;
component.render = compReg.origMethods.render;
}
function unregisterObservable(component: ComponentInterface, observable: Observable<unknown>) {
if (!componentRegistrations.has(component)) {
return;
}
const compReg = componentRegistrations.get(component) as ComponentRegistration;
const observableRegistration = compReg.observables.get(observable);
observableRegistration?.subscription.unsubscribe();
compReg.observables.delete(observable);
}
function getComponentRegistration(component: ComponentInterface): ComponentRegistration {
if (!componentRegistrations.has(component)) {
// add registration if it doesn't exist
init(component);
}
return componentRegistrations.get(component)!;
}
function getAsyncValue<T>(obj: Observable<T> | Promise<T>): T | undefined {
if (isPromise(obj)) {
return getPromiseValue(obj as Promise<T>);
}
if (isSubscribable(obj)) {
return getObservableValue(obj as Observable<T>);
}
console.error('Invalid value: ', typeof obj, obj);
}
function getPromiseValue<T>(promise: Promise<T>): T | undefined {
const component = getRenderingRef();
const compReg = getComponentRegistration(component);
if (!compReg.promises.has(promise)) {
compReg.promises.set(promise, null);
promise.then((...res) => {
compReg.promises.set(promise, res);
forceUpdate(component);
});
}
const value = compReg.promises.get(promise);
// don't clean up or it will cause a
// rerender after every render
// if (value) {
// // clean up
// compReg.promises.delete(promise);
// }
return value;
}
function getObservableValue<T>(obs$: Observable<T>): T | undefined {
// This function is not really exported by @stencil/core.
// Taken from @stencil/store.
// @source https://github.com/ionic-team/stencil-store/blob/master/src/subscriptions/stencil.ts#L35
const component = getRenderingRef();
const compReg = getComponentRegistration(component);
compReg.recentlyUsedObservables.push(obs$);
if (!compReg.observables.has(obs$)) {
// subscribe
// We need to create an empty object first
// because the observable might fire immediately.
const observableReg: Partial<ObservableRegistration<T>> = {};
observableReg.subscription = obs$.subscribe(result => {
observableReg.result = result;
forceUpdate(component);
});
compReg.observables.set(obs$, observableReg as ObservableRegistration<T>);
}
return compReg.observables.get(obs$)?.result;
}
export function isPromise(obj: any) {
return !!obj && typeof obj.then === 'function';
}
export function isSubscribable(obj: any) {
return !!obj && typeof obj.subscribe === 'function'
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment