From 39012902e0ed14bda0eff4df3e881f45f4e4c6cb Mon Sep 17 00:00:00 2001 From: DarkWiiPlayer Date: Tue, 6 Feb 2024 17:07:31 +0100 Subject: [PATCH] Full refactor of State (now Observable) * Renamed to Observable * Updated skooma.js to match the API --- state.js => observable.js | 318 +++++++++++++++++++++----------------- skooma.js | 120 +++++++------- 2 files changed, 234 insertions(+), 204 deletions(-) rename state.js => observable.js (51%) diff --git a/state.js b/observable.js similarity index 51% rename from state.js rename to observable.js index 49fe065..6eb981c 100644 --- a/state.js +++ b/observable.js @@ -1,11 +1,28 @@ +/** @type FinalizationRegistry */ const abortRegistry = new FinalizationRegistry(controller => controller.abort()) +/** @param {String} string */ const camelToKebab = string => string.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) +/** @param {String} string */ const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a+b.toUpperCase()) -export class ChangeEvent extends Event { +const identity = object=>object + +const target = Symbol("Proxy Target") + +/* Custom Event Classes */ + +export class SynchronousChangeEvent extends Event { + constructor(change) { + super('synchronous', {cancelable: true}) + this.change = change + } +} + +export class MultiChangeEvent extends Event { #final #values + constructor(...changes) { super('change') this.changes = changes @@ -40,27 +57,43 @@ export class ChangeEvent extends Event { } } -export class SimpleState extends EventTarget { +export class ValueChangeEvent extends MultiChangeEvent { + get value() { + return this.final.value + } +} + +/* Observable Classes */ + +export class Observable extends EventTarget { #synchronous + /** @type Array<{name:string, from, to}> */ #queue - #nested = new Map() - #weakRef = new WeakRef(this) #abortController = new AbortController - constructor({synchronous, methods}={}) { + #ref = new WeakRef(this) + get ref() { return this.#ref } + + observable = true + + constructor({synchronous}={}) { super() + if (this.constructor === Observable) { + throw new TypeError("Cannot instantiate abstract class") + } this.#synchronous = !!synchronous abortRegistry.register(this, this.#abortController) - // Try running a "Changed" method for every changed property - // Can be disabled to maybe squeeze out some performance - if (methods ?? true) { - this.addEventListener("change", ({final}) => { - final.forEach((value, prop) => { - if (`${prop}Changed` in this) this[`${prop}Changed`](value) - }) - }) - } + this.proxy = new Proxy(this.constructor.prototype.proxy, { + get: (target, prop) => target.call(this, prop) + }) + } + + proxy(prop, {get, set, ...options}={}) { + const proxy = new ProxiedObservableValue(this, prop, options) + if (get) proxy.get = get + if (set) proxy.set = set + return proxy } subscribe(prop, callback) { @@ -74,152 +107,150 @@ export class SimpleState extends EventTarget { return () => controller.abort() } - get() { return this.value } - set(value) { this.value = value } - - emit(property, from, to, options={}) { - const change = {property, from, to, ...options} + enqueue(property, from, to, mutation=false) { + const change = {property, from, to, mutation} + if (!this.dispatchEvent(new SynchronousChangeEvent(change))) return false if (!this.synchronous) { if (!this.#queue) { this.#queue = [] queueMicrotask(() => { - this.dispatchEvent(new ChangeEvent(...this.#queue)) + this.emit(...this.#queue) this.#queue = undefined }) } this.#queue.push(change) } else { - this.dispatchEvent(new ChangeEvent([change])) + this.emit(change) } + return true } - adopt(prop, state) { - let handlers = this.#nested.get(state) - if (!handlers) { - // Actual adoption - handlers = new Map() - this.#nested.set(state, handlers) - } - const ref = this.#weakRef - const handler = () => ref.deref()?.emit(prop, state, state, {state: true}) - - handlers.set(prop, handler) - state.addEventListener("change", handler, {signal: this.ignal}) - } - - disown(prop, state) { - const handlers = this.#nested.get(state) - const handler = handlers.get(prop) - state.removeEventListener("change", handler) - handlers.delete(prop) - if (handlers.size == 0) { - this.#nested.delete(state) - } + emit() { + throw new TypeError(`${this.constructor.name} did not define an 'emit' method`) } get signal() { return this.#abortController.signal } get synchronous() { return this.#synchronous } } -export class State extends SimpleState { - #target +export class ObservableObject extends Observable { #shallow - static isState(object) { return SimpleState.prototype.isPrototypeOf(object) } - constructor(target={}, {shallow, ...options}={}) { super(options) - this.#shallow = !!shallow - this.#target = target + this[target] = target this.values = new Proxy(target, { - set: (_target, prop, value) => { - const old = this.get(prop) - if (old !== value) { - this.emit(prop, old, value) - if (this.#shallow) { - if (State.isState(old)) this.disown(prop, old) - if (State.isState(value)) this.adopt(prop, value) + set: (target, prop, value) => { + const old = target[prop] + if (old === value) { + return true + } else { + if (this.enqueue(prop, old, value)) { + if (!this.#shallow) { + if (old instanceof Observable) this.disown(prop, old) + if (value instanceof Observable) this.adopt(prop, value) + } + target[prop] = value + return true + } else { + return false } - this.set(prop, value) } - return true }, - get: (_target, prop) => this.get(prop), + get: (target, prop) => target[prop], }) } - forward(property="value", methods) { - return new ForwardState(this, property, methods) + proxy(prop, {get, set, ...options}={}) { + const proxy = new ProxiedObservableValue(this, prop, {values: this.values, ...options}) + if (get) proxy.get = get + if (set) proxy.set = set + return proxy } - set(prop, value) { - if (arguments.length === 1) return this.set("value", prop) - this.#target[prop] = value + emit(...changes) { + this.dispatchEvent(new MultiChangeEvent(...changes)) } - get(prop="value") { - return this.#target[prop] + /** @type Map> */ + #nested = new Map() + + adopt(prop, observable) { + let handlers = this.#nested.get(observable) + if (!handlers) { + // Actual adoption + handlers = new Map() + this.#nested.set(observable, handlers) + } + const ref = this.ref + const handler = () => ref.deref()?.emit(prop, observable, observable, {observable: true}) + + handlers.set(prop, handler) + observable.addEventListener("change", handler, {signal: this.signal}) } - set value(value) { this.set(value) } - get value() { return this.get() } + disown(prop, observable) { + const handlers = this.#nested.get(observable) + const handler = handlers.get(prop) + observable.removeEventListener("change", handler) + handlers.delete(prop) + if (handlers.size == 0) { + this.#nested.delete(observable) + } + } } -export class ForwardState extends SimpleState { - #backend - #property - #methods +export class ObservableValue extends Observable { + #value - constructor(backend, property, methods = {}) { - super() - this.#methods = methods - this.#backend = backend - this.#property = property - - const ref = new WeakRef(this) - const abortController = new AbortController() - abortRegistry.register(this, abortController) - backend.addEventListener("change", event => { - const state = ref.deref() - if (state) { - let relevantChanges = event.changes - .filter(({property: name}) => name === property) - const get = methods.get - if (methods.get) { - relevantChanges = relevantChanges.map( - ({from, to}) => ({property: "value", from: get(from), to: get(to)}) - ) - } else { - relevantChanges = relevantChanges.map( - ({from, to}) => ({property: "value", from, to}) - ) - } - if (relevantChanges.length > 0) - state.dispatchEvent(new ChangeEvent(...relevantChanges)) - } else { - abortController.abort() - } - }, {signal: abortController.signal}) - } - - get value() { - const methods = this.#methods - if (methods.get) { - return methods.get(this.#backend.values[this.#property]) - } else { - return this.#backend.values[this.#property] - } + constructor(value, options) { + super(options) + this.#value = value } + get value() { return this.#value } set value(value) { - const methods = this.#methods - if (methods.set) { - this.#backend.values[this.#property] = methods.set(value) - } else { - this.#backend.values[this.#property] = value + if (this.enqueue("value", this.#value, value)) { + this.#value = value } } + + emit(...changes) { + this.dispatchEvent(new ValueChangeEvent(...changes)) + } +} + +class ProxiedObservableValue extends ObservableValue { + #backend + #values + #prop + + constructor(backend, prop, {values=backend, ...options}={}) { + super(options) + this.#backend = backend + this.#values = values + this.#prop = prop + + const ref = this.ref + backend.addEventListener("synchronous", event => { + const {property, from, to, ...rest} = event.change + if (property == this.#prop) { + ref.deref()?.enqueue({ + property, + from: this.get(from), + to: this.get(to), + ...rest + }) + } + }, { signal: this.signal }) + } + + get = identity + set = identity + + get value() { return this.get(this.#values[this.#prop]) } + set value(value) { this.#values[this.#prop] = this.set(value) } } const attributeObserver = new MutationObserver(mutations => { @@ -242,7 +273,8 @@ export const component = (name, generator, methods) => { const Element = class extends HTMLElement{ constructor() { super() - this.state = new State(Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value]))) + const target = Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value])) + this.state = new ObservableObject(target) this.state.addEventListener("change", event => { for (const {property, to: value} of event.changes) { const kebabName = camelToKebab(property) @@ -251,7 +283,8 @@ export const component = (name, generator, methods) => { } }) attributeObserver.observe(this, {attributes: true}) - this.replaceChildren(generator.call(this, this.state)) + const content = generator.call(this, this.state) + if (content) this.replaceChildren(content) } } if (methods) { @@ -261,7 +294,7 @@ export const component = (name, generator, methods) => { return Element; } -class ComposedState extends SimpleState { +class ObservableComposition extends ObservableValue { #func #states @@ -276,9 +309,8 @@ class ComposedState extends SimpleState { const ref = new WeakRef(this) states.forEach(state => { - state.addEventListener("change", event => { - const value = event.final.get("value") - if (value) ref.deref()?.scheduleUpdate() + state.addEventListener("change", () => { + ref.deref()?.scheduleUpdate() }, {signal: abortController.signal}) }) @@ -295,8 +327,8 @@ class ComposedState extends SimpleState { this.#microtaskQueued = false this.update() }) + this.#microtaskQueued = true } - this.#microtaskQueued = true } } @@ -304,17 +336,15 @@ class ComposedState extends SimpleState { const value = this.#func(...this.#states.map(state => state.value)) const change = {property: "value", from: this.value, to: value} this.value = value - this.dispatchEvent(new ChangeEvent(change)) + this.emit(change) } } -export const compose = func => (...states) => new ComposedState(func, {synchronous: false}, ...states) - -const eventName = "mutation" +export const compose = func => (...states) => new ObservableComposition(func, {}, ...states) class MutationEvent extends Event { constructor() { - super(eventName, {bubbles: true}) + super("mutation", {bubbles: true}) } } @@ -324,24 +354,24 @@ const mutationObserver = new MutationObserver(mutations => { } }) -export class DOMState extends SimpleState { - #target +export class ObservableElement extends Observable { #getValue #equal - #old + #value #changedValue = false - constructor(target, options) { + constructor(target, {get, equal, ...options}={}) { super(options) - this.#target = target - this.#getValue = options.get ?? (target => target.value) - this.#equal = options.equal ?? ((a, b) => a===b) + this[target] = target - this.#old = this.#getValue(target) + this.#getValue = get ?? (target => target.value) + this.#equal = equal ?? ((a, b) => a===b) + + this.#value = this.#getValue(target) const controller = new AbortController() - target.addEventListener(eventName, event=>{this.update(event)}, {signal: controller.signal}) + target.addEventListener("mutation", event => { this.update(event) }, {signal: controller.signal}) abortRegistry.register(this, controller) mutationObserver.observe(target, { @@ -352,22 +382,22 @@ export class DOMState extends SimpleState { }) } - get value() { return this.#old } + get value() { return this.#value } update() { - const current = this.#getValue(this.#target) + const current = this.#getValue(this[target]) - if (this.#equal(this.#old, current)) return + if (this.#equal(this.#value, current)) return - this.#old = current + this.#value = current if (this.synchronous) { - this.dispatchEvent(new ChangeEvent(["value", current])) + this.dispatchEvent(new MultiChangeEvent(["value", current])) } else { if (!this.#changedValue) { queueMicrotask(() => { this.#changedValue = false - this.dispatchEvent(new ChangeEvent(["value", this.#changedValue])) + this.dispatchEvent(new MultiChangeEvent(["value", this.#changedValue])) }) this.#changedValue = current } @@ -396,5 +426,3 @@ export class MapStorage extends Storage { this.#map.clear() } } - -export default State diff --git a/skooma.js b/skooma.js index 85d0bae..1cc2288 100644 --- a/skooma.js +++ b/skooma.js @@ -24,51 +24,6 @@ const insertStyles = (rule, styles) => { rule.setProperty(snakeToCSS(key), value.toString()) } -const processAttribute = (attribute) => { - if (typeof attribute == "string" || typeof attribute == "number") - return attribute - else if (attribute && "join" in attribute) - return attribute.join(" ") - else - return JSON.stringify(attribute) -} - -const defined = (value, fallback) => typeof value != "undefined" ? value : fallback -const getCustom = args => args.reduce( - (current, argument) => Array.isArray(argument) - ? defined(getCustom(argument), current) - : (argument && typeof argument == "object") - ? defined(argument.is, current) - : current - ,undefined -) - -export const isObservable = object => object - && typeof object == "object" - && !(object instanceof HTMLElement) - && object.subscribe - -const toChild = arg => { - if (typeof arg == "string" || typeof arg == "number") - return document.createTextNode(arg) - else if (arg instanceof HTMLElement) - return arg - else if (isObservable(arg)) - return reactiveChild(arg) -} - -const reactiveChild = observable => { - let ref - const abort = observable.subscribe(value => { - if (ref && !ref.deref()) return abort() - const child = toChild(value) ?? document.createComment("Placeholder for reactive content") - untilDeathDoThemPart(child, observable) - if (ref) ref.deref().replaceWith(child) - ref = new WeakRef(child) - }) - return ref.deref() -} - const specialAttributes = { value: { get() { return this.value }, @@ -95,6 +50,47 @@ const specialAttributes = { } } +const processAttribute = (attribute) => { + if (typeof attribute == "string" || typeof attribute == "number") + return attribute + else if (attribute && "join" in attribute) + return attribute.join(" ") + else + return JSON.stringify(attribute) +} + +const defined = (value, fallback) => typeof value != "undefined" ? value : fallback +const getCustom = args => args.reduce( + (current, argument) => Array.isArray(argument) + ? defined(getCustom(argument), current) + : (argument && typeof argument == "object") + ? defined(argument.is, current) + : current + ,undefined +) + +export const isObservable = object => object && object.observable + +const toElement = arg => { + if (typeof arg == "string" || typeof arg == "number") + return document.createTextNode(arg) + else if (arg instanceof HTMLElement) + return arg + else if (isObservable(arg)) + return reactiveElement(arg) +} + +export const reactiveElement = observable => { + const element = toElement(observable.value) + untilDeathDoThemPart(element, observable) + const ref = new WeakRef(element) + observable.addEventListener("change", () => { + const next = reactiveElement(observable) + ref.deref()?.replaceWith(next) + }, {once: true}) + return element +} + const setAttribute = (element, attribute, value, cleanupSignal) => { const special = specialAttributes[attribute] if (isObservable(value)) @@ -114,19 +110,20 @@ const setAttribute = (element, attribute, value, cleanupSignal) => { // (Two-way) binding between an attribute and a state container const setReactiveAttribute = (element, attribute, observable) => { - untilDeathDoThemPart(element, observable) const multiAbort = new MultiAbortController() - let old - observable.subscribe(value => { - old = value + + observable.addEventListener("change", () => { multiAbort.abort() - setAttribute(element, attribute, value, multiAbort.signal) + setAttribute(element, attribute, observable.value, multiAbort.signal) }) + setAttribute(element, attribute, observable.value, multiAbort.signal) + const special = specialAttributes[attribute] - if (special?.hook && observable.set) { + if (special.hook) { + untilDeathDoThemPart(element, observable) special.hook.call(element, () => { - const value = special.get.call(element, attribute) - if (value != old) observable.set(value) + const current = special.get.call(element, attribute) + if (current != observable.value) observable.value = current }) } } @@ -137,7 +134,7 @@ const processArgs = (element, ...args) => { if (arg instanceof Array) { processArgs(element, ...arg) } else { - const child = toChild(arg) + const child = toElement(arg) if (child) element.append(child) else if (arg === undefined || arg == null) @@ -185,18 +182,23 @@ export const handle = fn => event => { event.preventDefault(); return fn(event) export const fragment = (...elements) => { const fragment = new DocumentFragment() for (const element of elements) - fragment.append(element) + fragment.append(toElement(element)) return fragment } -// Turns a template literal into document fragment. -// Strings are returned as text nodes. -// Elements are inserted in between. +/** +Turns a template literal into document fragment. +Strings are returned as text nodes. +Elements are inserted in between. +@param {Array} literals +@param {Array} items +@return {DocumentFragment} +*/ const textFromTemplate = (literals, items) => { const fragment = new DocumentFragment() for (const key in items) { fragment.append(document.createTextNode(literals[key])) - fragment.append(items[key]) + fragment.append(toElement(items[key])) } fragment.append(document.createTextNode(literals.at(-1))) return fragment