UNPKG

@lume/variable

Version:

Create reactive variables and react their changes.

367 lines (301 loc) 11.7 kB
import {getInheritedDescriptor} from 'lowclass' import {createSignal, createEffect, createRoot, untrack, getListener} from 'solid-js' export interface VariableGetter<T> { (): T } export interface VariableSetter<T> { (value: T): T } /** Represents a reactive variable. The value is set or gotten depending on passing an arg or no arg. */ export interface Variable<T = any> extends Iterable<VariableGetter<T> | VariableSetter<T>> { /** Gets the variable value. */ (value?: undefined): T /** Sets the variable value. */ (value: T): T (value?: T): void | T get: VariableGetter<T> set: VariableSetter<T> // For array destructuring convenience [0]: VariableGetter<T> [1]: VariableSetter<T> [Symbol.iterator](): IterableIterator<VariableGetter<T> | VariableSetter<T>> } function readVariable<T>(this: Variable<T>): T { return this() } function writeVariable<T>(this: Variable<T>, value: T): T { return this(value) } /** * Create a reactive variable. * * @example * let count = variable(0) // count starts at 0 * count(1) // set the value of count to 1 * count(count() + 1) // add 1 * let currentValue = count() // read the current value * console.log(currentValue) // logs "2" to console */ // eslint-disable-next-line typescript/explicit-function-return-type export function variable<T>(value: T) { const [get, set] = createSignal<T>(value, {equals: false}) // FIXME, read arguments.length instead of detecting undefined values, because currently undefined value trigger a read, which means decoraators built on this treat `this.foo = undefined` as a read instead of a write. const variable = ((value?: T) => { if (typeof value === 'undefined') return get() set(() => value) return value }) as Variable<T> // WTF TypeScript, why do I need `any` here. const getter = readVariable.bind(variable as any) as VariableGetter<T> const setter = writeVariable.bind(variable as any) as VariableSetter<T> // For object destructuring convenience. variable.get = getter variable.set = setter // For array destructuring convenience. variable[0] = getter variable[1] = setter variable[Symbol.iterator] = function* () { yield variable[0] yield variable[1] } return variable as [VariableGetter<T>, VariableSetter<T>] & Variable<T> } export type Computation = (previousValue?: unknown) => unknown export type StopFunction = () => void /** * Automatically run a "computation" when any reactive variable used inside the * computation has changed. The "computation" is a function passed into * autorun(). * * @param {Computation} f - A "computation" to re-run when any of the reactive * variables used inside of it change. * @return {StopFunction} - Returns a function that can be called to explicitly * stop the computation from running, allowing it to be garbage collected. */ // TODO Option for autorun() to batch updates into a single update in the next microtask. // TODO Option for autorun() to skip the first run. // TODO Option for autorun() to provide which properties caused the re-run. export function autorun(f: Computation): StopFunction { let stop: StopFunction createRoot(dispose => { stop = dispose createEffect(f) }) return stop! } export function reactive(protoOrClassElement: any, propName?: string, _descriptor?: PropertyDescriptor): any { // If used as a newer Babel decorator const isDecoratorV2 = arguments.length === 1 && 'kind' in protoOrClassElement if (isDecoratorV2) { const classElement = protoOrClassElement // If used as a class decorator. if (classElement.kind === 'class') return {...classElement, finisher: reactiveClassFinisher} // If used as a property or accessor decorator (@reactive isn't intended for // methods). return { ...classElement, finisher(Class: AnyClassWithReactiveProps) { _trackReactiveProperty(Class, classElement.key) return classElement.finisher?.(Class) ?? Class }, } } // Used as a v1 legacy decorator. // If used as a class decorator. if (arguments.length === 1 && typeof protoOrClassElement === 'function') { const Class = protoOrClassElement return reactiveClassFinisher(Class) } // If used as a property or accessor decorator (this isn't intended for // methods). const Class = protoOrClassElement.constructor _trackReactiveProperty(Class, propName!) } export function _trackReactiveProperty(Class: AnyClassWithReactiveProps, propName: string) { if (!Class.reactiveProperties || !Class.hasOwnProperty('reactiveProperties')) Class.reactiveProperties = [] if (!Class.reactiveProperties.includes(propName)) Class.reactiveProperties.push(propName) } function reactiveClassFinisher(Class: AnyClassWithReactiveProps) { if (Class.hasOwnProperty('__isReactive__')) return Class return class ReactiveDecoratorFinisher extends Class { // This is a flag that other decorators can check, f.e. lume/elements @element decorator. static __isReactive__: true = true constructor(...args: any[]) { if (getListener()) { return untrack(() => { const self = Reflect.construct(Class, args, new.target) // super() reactify(self, Class) return self }) } super(...args) reactify(this, Class) } } } function _reactive(obj: ObjWithReactifiedProps, propName: PropertyKey): void { if (typeof propName !== 'string') throw new Error('TODO: support for non-string fields with @reactive decorator') const vName = 'v_' + propName // XXX If obj already has vName, skip making an accessor? I think perhaps // not, because a subclass might override a property so it is not reactive, // and a further subclass might want to make it reactive again in which // case returning early would cause the subclass subclass's property not to // be reactive. // if (obj[vName] !== undefined) return let descriptor: PropertyDescriptor | undefined = getInheritedDescriptor(obj, propName) let originalGet: (() => any) | undefined let originalSet: ((v: any) => void) | undefined let initialValue: unknown // TODO if there is an inherited accessor, we need to ensure we still call // it so that we're extending instead of overriding. Otherwise placing // @reactive on a property will break that functionality in those cases. // // Right now, originalGet will only be called if it is on the current // prototype, but we aren't checking for any accessor that may be inherited. if (descriptor) { originalGet = descriptor.get originalSet = descriptor.set if (originalGet || originalSet) { // reactivity requires both if (!originalGet || !originalSet) { console.warn( 'The `@reactive` decorator was used on an accessor named "' + propName + '" which had a getter or a setter, but not both. Reactivity on accessors works only when accessors have both get and set. In this case the decorator does not do anything.', ) return } delete descriptor.get delete descriptor.set } else { initialValue = descriptor.value // if it isn't writable, we don't need to make a reactive variable because // the value won't change if (!descriptor.writable) { console.warn( 'The `@reactive` decorator was used on a property named ' + propName + ' that is not writable. Reactivity is not enabled for non-writable properties.', ) return } delete descriptor.value delete descriptor.writable } } descriptor = { configurable: true, enumerable: true, ...descriptor, get: originalGet ? function (this: any): unknown { // track reactivity, but get the value from the original getter // XXX this causes initialValue to be held onto even if the original // prototype value has changed. In pratice the original prototype // values usually never change, and these days people don't normally // use prototype values to begin with. const v = __getReactiveVar(this, vName, initialValue) v() return originalGet!.call(this) } : function (this: any): unknown { const v = __getReactiveVar(this, vName, initialValue) return v() }, set: originalSet ? function (this: any, newValue: unknown) { originalSet!.call(this, newValue) const v = __getReactiveVar(this, vName) v(newValue) // __propsSetAtLeastOnce__ is a Set that tracks which reactive // properties have been set at least once. @lume/element uses this // to detect if a reactive prop has been set, and if so will not // overwrite the value with any value from custom element // pre-upgrade. if (!this.__propsSetAtLeastOnce__) this.__propsSetAtLeastOnce__ = new Set<string>() this.__propsSetAtLeastOnce__.add(propName) } : function (this: any, newValue: unknown) { const v = __getReactiveVar(this, vName) v(newValue) if (!this.__propsSetAtLeastOnce__) this.__propsSetAtLeastOnce__ = new Set<string>() this.__propsSetAtLeastOnce__.add(propName) }, } if (!obj.__reactifiedProps__) obj.__reactifiedProps__ = new Set() obj.__reactifiedProps__.add(propName) Object.defineProperty(obj, propName, descriptor) } function __getReactiveVar<T>(instance: Obj<Variable<T>>, vName: string, initialValue: T = undefined!): Variable<T> { // NOTE alternatively, we could use a WeakMap instead of exposing the // variable on the instance. We could also use Symbols keys for // semi-privacy. let v: Variable<T> = instance[vName] if (v) return v instance[vName] = v = variable<T>(initialValue) return v } type AnyClass = new (...args: any[]) => object type AnyClassWithReactiveProps = (new (...args: any[]) => object) & { reactiveProperties?: string[] __isReactive__?: true } // Define (or unshadow) reactive accessors on obj, which is generally `this` // inside of a constructor (this is what the documentation prescribes). export function reactify<T>(obj: T, props: (keyof T)[]): typeof obj export function reactify<C extends AnyClass>(obj: InstanceType<C>, ctor: C): typeof obj export function reactify(obj: Obj, propsOrClass: PropertyKey[] | AnyClassWithReactiveProps) { if (isClass(propsOrClass)) { const Class = propsOrClass // let props = classReactiveProps.get(Class) // if (props) unshadowReactiveAccessors(obj, props) // props = Class.reactiveProperties const props = Class.reactiveProperties if (Array.isArray(props)) createReactiveAccessors(obj, props) } else { const props = propsOrClass createReactiveAccessors(obj, props) } return obj } function isClass(obj: unknown): obj is AnyClass { return typeof obj == 'function' } // Defines a reactive accessor on obj. function createReactiveAccessors(obj: ObjWithReactifiedProps, props: PropertyKey[]) { for (const prop of props) { if (obj.__reactifiedProps__?.has(prop)) continue const initialValue = obj[prop] _reactive(obj, prop) obj[prop] = initialValue } } type Obj<T = unknown> = Record<PropertyKey, T> & {constructor: AnyClass} type ObjWithReactifiedProps<T = unknown> = Obj<T> & {__reactifiedProps__?: Set<PropertyKey>} /** * Allow two reactive variables to depend on each other's values, without * causing an infinite loop. */ export function circular<Type>( first: VariableGetter<Type>, setFirst: (v: Type) => void, second: VariableGetter<Type>, setSecond: (v: Type) => void, ): StopFunction { let initial = true const stop1 = autorun(() => { const v = first() if (initial && !(initial = false)) setSecond(v) else initial = true }) const stop2 = autorun(() => { const v = second() if (initial && !(initial = false)) setFirst(v) else initial = true }) return function stop() { stop1() stop2() } } export const version = '0.10.1'