Skip to content

Instantly share code, notes, and snippets.

@emonkak
Last active December 19, 2018 06:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save emonkak/0366a2c17c26b7e6bb55fa27d899d86a to your computer and use it in GitHub Desktop.
Save emonkak/0366a2c17c26b7e6bb55fa27d899d86a to your computer and use it in GitHub Desktop.
StatefulComponent.ts
export interface PropType<T> {
transform(propKey: string, propValue: string): T;
defaultValue?: T;
}
export type PropTypes<TProps> = { [P in keyof TProps]: PropType<TProps[P]> };
export const string: PropType<string> = {
transform(propKey, propValue) {
return propValue;
},
};
export const number: PropType<number> = {
transform(propKey, propValue) {
return parseInt(propValue, 10);
},
}
export const boolean: PropType<boolean> = {
transform(propKey, propValue) {
return true;
},
defaultValue: false
}
export const json: PropType<any> = {
transform(propKey, propValue) {
return JSON.parse(propValue);
}
}
export function oneOf<T>(...expectedValues: T[]): PropType<T> {
return {
transform(propKey, propValue) {
if (expectedValues.indexOf(propValue as any) < 0) {
const valuesString = JSON.stringify(expectedValues);
throw new Error(`Invalid \`${propKey}\` of value \`${propValue}\`, expected one of \`${valuesString}\`.`);
}
return propValue as any;
}
}
}
import { TemplateResult, render } from 'lit-html';
import { PropTypes } from './propTypes';
interface StatefulComponentClass<TProps, TState> {
new(): StatefulComponent<TProps, TState>;
defaultProps: Partial<TProps>;
getInitialState(props: Readonly<TProps>): TState;
observedAttributes: string[];
propTypes: PropTypes<TProps>;
}
interface Action {
type: string;
}
export type AsyncAction<TAction, TResult> = (commit: (action: TAction) => void) => TResult;
export default interface StatefulComponent<TProps, TState> {
constructor: StatefulComponentClass<TProps, TState>;
}
export interface MountPoint {
element: HTMLElement;
dispose(): void;
}
type Children = Node[];
const ACTION_EVENT = '@@action';
export default abstract class StatefulComponent<TProps = {}, TState = {}> extends HTMLElement {
static get observedAttributes(): string[] {
return Object.keys((this as StatefulComponentClass<any, any>).propTypes)
.map(toAttributeName);
};
static propTypes = {};
static defaultProps = {};
static getInitialState(props: any): any {
return {};
}
props: Readonly<TProps & { children?: Children }>;
state: Readonly<TState>;
readonly mountPoint: MountPoint;
private _pendingProps: Readonly<TProps & { children?: Children }>;
private _pendingState: Readonly<TState>;
private _pendingUpdate: number = 0;
private _isMounted: boolean = false;
private readonly _subscribers: (() => void)[] = [];
constructor() {
super();
this._prepareAccessors();
this.props = this._pendingProps = {} as any;
this.state = this._pendingState = {} as any;
this.mountPoint = this.getMountPoint();
}
connectedCallback() {
Promise.resolve().then(() => {
const props = this._prepareProps();
const state = this._prepareState(props);
this.props = this._pendingProps = props;
this.state = this._pendingState = state;
render(this.render(), this.mountPoint.element);
this._isMounted = true;
this.componentDidMount();
});
}
disconnectedCallback() {
this.componentWillUnmount();
this.mountPoint.dispose();
for (const subscriber of this._subscribers) {
subscriber();
}
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (!this._isMounted) {
return;
}
const { propTypes } = this.constructor;
const propKey = toPropKey(name) as keyof TProps;
const propType = propTypes[propKey];
if (!propType) {
return;
}
const propValue = newValue !== null
? propType.transform(propKey as string, newValue)
: propType.defaultValue;
this._updateProp(propKey, propValue);
}
componentDidMount(): void {
}
componentWillReceiveProps(nextProps: Readonly<TProps>): void {
}
componentDidUpdate(prevProps: Readonly<TProps>, prevState: Readonly<TState>): void {
}
componentWillUnmount(): void {
}
shouldComponentUpdate(nextProps: Readonly<TProps>, nextState: Readonly<TState>): boolean {
return !isShallowEqual(this.props, nextProps) ||
!isShallowEqual(this.state, nextState);
}
getMountPoint(): MountPoint {
return {
element: this,
dispose() {}
};
}
dispatchAction<TAction extends Action, TResult>(action: AsyncAction<TAction, TResult>): TResult {
return action(this.commitAction.bind(this));
}
commitAction<TAction extends Action>(action: TAction): void {
this.emitEvent(ACTION_EVENT, action);
}
applyAction<TAction extends Action>(reducer: (state: TState, action: TAction) => TState, target: EventTarget = this): void {
const subscriber = subscribeAction<TAction>(target, (action) => {
this.setState((state) => reducer(state, action));
});
this._subscribers.push(subscriber);
}
emitEvent<T>(type: string, detail?: T): void {
let event: CustomEvent;
try {
event = new CustomEvent<T>(type, {
bubbles: true,
detail
});
} catch {
event = document.createEvent('CustomEvent');
event.initCustomEvent(type, true, false, detail);
}
this.dispatchEvent(event);
}
setState(state: Readonly<Partial<TState>> | ((state: Readonly<TState>) => TState)): TState {
const nextState = typeof state === 'function' ?
state(this._pendingState) :
Object.assign({}, this._pendingState, state);
if (this._isMounted && this.shouldComponentUpdate(this._pendingProps, nextState)) {
this._scheduleUpdate();
}
this._pendingState = nextState;
return nextState;
}
abstract render(): TemplateResult;
private _prepareAccessors(): void {
const { propTypes } = this.constructor;
for (const propKey in propTypes) {
Object.defineProperty(this, propKey, {
get() {
return this.props[propKey];
},
set(newValue) {
this._updateProp(propKey, newValue);
}
});
}
}
private _prepareProps(): TProps & { children?: Children } {
const { defaultProps, propTypes } = this.constructor;
const props: Partial<TProps & { children?: Children }> = {};
for (const propKey in propTypes) {
const attributeName = toAttributeName(propKey);
const propType = propTypes[propKey];
const value = this.getAttribute(attributeName);
if (value !== null) {
props[propKey] = propType.transform(propKey, value);
} else if (this._pendingProps.hasOwnProperty(propKey)) {
props[propKey] = this._pendingProps[propKey];
} else if (defaultProps.hasOwnProperty(propKey)) {
props[propKey] = defaultProps[propKey];
} else if (propType.hasOwnProperty('defaultValue')) {
props[propKey] = propType.defaultValue;
}
}
if (this.childNodes.length > 0) {
const children = [];
for (let i = 0, l = this.childNodes.length; i < l; i++) {
const child = this.childNodes[i];
if (child.nodeName.toUpperCase() === 'TEMPLATE') {
const templateChildren = (child as HTMLTemplateElement).content.childNodes || [];
for (let j = 0, m = templateChildren.length; j < m; j++) {
const templateChild = templateChildren[j];
children.push(templateChild);
}
} else {
children.push(child);
}
}
props.children = children;
}
return props as TProps & { children?: Children };
}
private _prepareState(props: TProps & { children?: Children }): TState {
const { getInitialState } = this.constructor;
return Object.assign({}, this._pendingState, getInitialState(props));
}
private _updateProp<TKey extends keyof TProps>(key: TKey, newValue: TProps[TKey] | undefined): void {
const nextProps = Object.assign({}, this._pendingProps, {
[key]: newValue
});
if (this._isMounted) {
this.componentWillReceiveProps(nextProps);
if (this.shouldComponentUpdate(nextProps, this._pendingState)) {
this._scheduleUpdate();
}
}
this._pendingProps = nextProps;
}
private _scheduleUpdate(): void {
if (this._pendingUpdate === 0) {
this._pendingUpdate = window.requestAnimationFrame(this._update);
}
}
private _update = () => {
const prevProps = this.props;
const prevState = this.state;
this.props = this._pendingProps;
this.state = this._pendingState;
this._pendingUpdate = 0;
render(this.render(), this.mountPoint.element);
this.componentDidUpdate(prevProps, prevState);
}
}
function isShallowEqual<T>(x: T, y: T): boolean {
if (x !== y) {
for (const k in x) {
if (!(k in y) || x[k] !== y[k]) {
return false
}
}
for (const k in y) {
if (!(k in x) || x[k] !== y[k]) {
return false
}
}
}
return true;
}
function toPropKey(name: string): string {
return name.replace(/-./g, (s) => s.charAt(1).toUpperCase());
}
function toAttributeName(key: string): string {
return key.replace(/[A-Z]/g, (s) => '-' + s.toLowerCase());
}
function subscribeAction<TAction extends Action>(target: EventTarget, subscriber: (action: TAction) => void): () => void {
const listener = (event: Event) => {
const action = (event as CustomEvent<TAction>).detail;
subscriber(action);
};
target.addEventListener(ACTION_EVENT, listener);
return () => {
target.removeEventListener(ACTION_EVENT, listener);
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment