lit-element-state
Version:
Simple shared app state management for LitElement
259 lines (201 loc) • 7.04 kB
JavaScript
export const observeState = superclass => class extends superclass {
constructor() {
super();
this._observers = [];
}
update(changedProperties) {
stateRecorder.start();
super.update(changedProperties);
this._initStateObservers();
}
connectedCallback() {
super.connectedCallback();
if (this._wasConnected) {
this.requestUpdate();
delete this._wasConnected;
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._wasConnected = true;
this._clearStateObservers();
}
_initStateObservers() {
this._clearStateObservers();
if (!this.isConnected) return;
this._addStateObservers(stateRecorder.finish());
}
_addStateObservers(stateVars) {
for (let [state, keys] of stateVars) {
const observer = () => this.requestUpdate();
this._observers.push([state, observer]);
state.addObserver(observer, keys);
}
}
_clearStateObservers() {
for (let [state, observer] of this._observers) {
state.removeObserver(observer);
}
this._observers = [];
}
}
export class LitState {
constructor() {
this._observers = [];
this._initStateVars();
}
addObserver(observer, keys) {
this._observers.push({observer, keys});
}
removeObserver(observer) {
this._observers = this._observers.filter(observerObj => observerObj.observer !== observer);
}
_initStateVars() {
if (this.constructor.stateVarOptions) {
for (let [key, options] of Object.entries(this.constructor.stateVarOptions)) {
this._initStateVar(key, options);
}
}
if (this.constructor.stateVars) {
for (let [key, value] of Object.entries(this.constructor.stateVars)) {
this._initStateVar(key, {});
this[key] = value;
}
}
}
_initStateVar(key, options) {
if (this.hasOwnProperty(key)) {
// Property already defined, so don't re-define.
return;
}
options = this._parseOptions(options);
const stateVar = new options.handler({
options: options,
recordRead: () => this._recordRead(key),
notifyChange: () => this._notifyChange(key)
});
Object.defineProperty(
this,
key,
{
get() {
return stateVar.get();
},
set(value) {
if (stateVar.shouldSetValue(value)) {
stateVar.set(value);
}
},
configurable: true,
enumerable: true
}
);
}
_parseOptions(options) {
if (!options.handler) {
options.handler = StateVar;
} else {
// In case of a custom `StateVar` handler is provided, we offer a
// second way of providing options to your custom handler class.
//
// You can decorate a *method* with `@stateVar()` instead of a
// variable. The method must return an object, and that object will
// be assigned to the `options` object.
//
// Within the method you have access to the `this` context. So you
// can access other properties and methods from your state class.
// And you can add arrow function callbacks where you can access
// `this`. This provides a lot of possibilities for a custom
// handler class.
if (options.propertyMethod && options.propertyMethod.kind === 'method') {
Object.assign(options, options.propertyMethod.descriptor.value.call(this));
}
}
return options;
}
_recordRead(key) {
stateRecorder.recordRead(this, key);
}
_notifyChange(key) {
for (const observerObj of this._observers) {
if (!observerObj.keys || observerObj.keys.includes(key)) {
observerObj.observer(key);
}
};
}
}
export class StateVar {
constructor(args) {
this.options = args.options; // The options given in the `stateVar` declaration
this.recordRead = args.recordRead; // Callback to indicate the `stateVar` is read
this.notifyChange = args.notifyChange; // Callback to indicate the `stateVar` value has changed
this.value = undefined; // The initial value
}
// Called when the `stateVar` on the `LitState` class is read (for example:
// `myState.myStateVar`). Should return the value of the `stateVar`.
get() {
this.recordRead();
return this.value;
}
// Called before the `set()` method is called. If this method returns
// `false`, the `set()` method won't be called. This can be used for
// validation and/or optimization.
shouldSetValue(value) {
return this.value !== value;
}
// Called when the `stateVar` on the `LitState` class is set (for example:
// `myState.myStateVar = 'value'`.
set(value) {
this.value = value;
this.notifyChange();
}
}
export function stateVar(options = {}) {
return element => {
return {
kind: 'field',
key: Symbol(),
placement: 'own',
descriptor: {},
initializer() {
if (typeof element.initializer === 'function') {
this[element.key] = element.initializer.call(this);
}
},
finisher(litStateClass) {
if (element.kind === 'method') {
// You can decorate a *method* with `@stateVar()` instead
// of a variable. When the state class is constructed, this
// method will be called, and it's return value must be an
// object that will be added to the options the stateVar
// handler will receive.
options.propertyMethod = element;
}
if (litStateClass.stateVarOptions === undefined) {
litStateClass.stateVarOptions = {};
}
litStateClass.stateVarOptions[element.key] = options;
}
};
};
}
class StateRecorder {
constructor() {
this._log = null;
}
start() {
this._log = new Map();
}
recordRead(stateObj, key) {
if (this._log === null) return;
const keys = this._log.get(stateObj) || [];
if (!keys.includes(key)) keys.push(key);
this._log.set(stateObj, keys);
}
finish() {
const stateVars = this._log;
this._log = null;
return stateVars;
}
}
export const stateRecorder = new StateRecorder();