UNPKG

@knyt/luthier

Version:

A library for building standardized, type-safe native web components with full SSR and hydration support.

639 lines (638 loc) 25 kB
/// <reference lib="dom.iterable" /> import { attributeValueToString, Beacon, createReference, isNonNullableObject, mapRef, replaceReferenceMutator, strictEqual, unknownToAttributeValue, unwrapRef, } from "@knyt/artisan"; import { EventStation } from "event-station"; import { convertPropertiesDefinition } from "./convertPropertiesDefinition"; /** * A private symbol used to store the reactive properties on the prototype. * * This symbol doesn't need to be in the runtime-wide symbol registry (`Symbol.for`), * because it is only used internally in this module. */ const __reactiveProperties = Symbol("__reactiveProperties"); const PropertyChangeEventName = "KnytPropertyChange"; /** * This symbol is used to attach the reactive adapter to an element. * * @remarks * * This symbol must be in the runtime-wide symbol registry * (`Symbol.for`) so that the reactive adapter can be * accessed from within different contexts. * * @internal scope: package */ export const __reactiveAdapter = Symbol.for("knyt.luthier.reactiveAdapter"); /** * Determines how the reactive adapter will handle updates. * * @internal scope: workspace */ export var ReactiveUpdateMode; (function (ReactiveUpdateMode) { /** * Automatically request an update on the host when a property changes. * * @public */ ReactiveUpdateMode["Reactive"] = "reactive"; /** * Does not automatically request an update when a property changes. * However, an event will still be emitted when a property changes. * * @public */ ReactiveUpdateMode["Manual"] = "manual"; })(ReactiveUpdateMode || (ReactiveUpdateMode = {})); var ReactivePropertyChangeOrigin; (function (ReactivePropertyChangeOrigin) { /** * The property change originated from a property setter. */ ReactivePropertyChangeOrigin[ReactivePropertyChangeOrigin["Property"] = 0] = "Property"; /** * The property change originated from an attribute change. */ ReactivePropertyChangeOrigin[ReactivePropertyChangeOrigin["Attribute"] = 1] = "Attribute"; })(ReactivePropertyChangeOrigin || (ReactivePropertyChangeOrigin = {})); var HookName; (function (HookName) { HookName["Update"] = "update"; HookName["UpdateRequest"] = "updateRequested"; })(HookName || (HookName = {})); const hookNames = Object.values(HookName); /** * A reactive adapter for managing reactive properties on an element. */ /* * ### Private Remarks * * We can't use native private properties with mixins, * so we're using symbols to effectively create private properties. */ // TODO: Move into its own module. export class ReactiveAdapter { /** @internal scope: package */ #reactiveProperties; /** @internal scope: package */ #hooks; /** @internal scope: package */ #updateMode; /** @internal scope: package */ #propValues; /** * An event emitter for reactive property changes. * * @internal scope: package */ #reactivePropertyEmitter; /** * Determine if a property is currently being updated. * This is used to prevent unnecessary updates when a property is associated with an attribute. * * @internal scope: package */ #isPropertyUpdating$; isPropertyUpdating$; /** * Indicates whether the host object has completed its construction. * This flag is set to `true` after the construction phase finishes. * * @remarks * * All objects in JavaScript are constructed synchronously, so it is safe to * assume the host is fully constructed after a single microtask is queued. * The `ReactiveAdapter` is attached during the host's construction, and this * property is set to `true` after a microtask to guarantee the host is ready. * * This is mainly used to ensure that attributes are only set after the host * is fully constructed, since setting attributes on DOM elements during * construction is not allowed and will throw an error. */ #isConstructed = false; /** * A record of changed properties since the occurrence * of certain lifecycle hooks. */ #changedProperties = { [HookName.Update]: new Map(), [HookName.UpdateRequest]: new Map(), }; constructor({ reactiveProperties, hooks, options, }) { this.#hooks = hooks ?? {}; this.#isPropertyUpdating$ = createReference(false); this.#propValues = new Map(); this.#reactiveProperties = reactiveProperties; this.#reactivePropertyEmitter = new EventStation(); this.#updateMode = options?.updateMode ?? ReactiveUpdateMode.Reactive; this.isPropertyUpdating$ = this.#isPropertyUpdating$.asReadonly(); queueMicrotask(() => { // We can safely assume that the host object is fully constructed // after a microtask has been queued, because all objects are // constructed synchronously. this.#isConstructed = true; // Sync attribute values after the element is constructed. // This is to ensure that the attributes are set after the element // is fully constructed, and not during the construction phase. this.syncAttributeValues(); }); } /** * Get the value of a reactive property. */ getProp(name) { const config = this._findPropConfig(name); if (!config) { throw new Error(`Property "${String(name)}" is not a reactive property.`); } return this._getReactivePropertyValue(config); } /** * Set the value of a reactive property. */ setProp(name, value) { const config = this._findPropConfig(name); if (!config) { throw new Error(`Property "${String(name)}" is not a reactive property.`); } this._setReactivePropertyValue(ReactivePropertyChangeOrigin.Property, config, value); } /** * Set the values of multiple reactive properties at once. */ /* * ### Private Remarks * * This is a convenience method that allows you to set multiple * reactive properties at once. It will iterate over the provided * properties and set each one using the `setProp` method. */ setProps(props) { for (const [propertyName, nextValue] of Object.entries(props)) { this.setProp(propertyName, nextValue); } } /** * Create an observable that emits property changes */ // TODO: Constrain property names to reactive properties. observePropChange() { // NOTE: This is intentionally not a `Reference` type, // because it is not a stateful reference, but rather an observable // that emits property change events. const changeSignals = Beacon.withEmitter(); const listenerSubscription = this.onPropChange((payload) => { changeSignals.next(payload); }); const subscription = { unsubscribe: () => { // First, unsubscribe from the property change listener. // This will prevent the listener from emitting any more events. listenerSubscription.unsubscribe(); // Then, complete the property change observable. // This will emit a completion signal to all subscribers, // and terminate the observable. changeSignals.complete(); }, }; return Object.assign(changeSignals.beacon, { subscription }); } /** * @internal scope: package */ _findPropConfig(propertyName) { return this.#reactiveProperties.find(({ propertyName: configPropertyName }) => configPropertyName === propertyName); } refProp(propertyName, arg1) { const prop$ = this._refPropOnly(propertyName); if ( // While we could check if `arg1` is null or undefined, // it's more reliable to check the number of arguments, // for handling overloaded methods. arguments.length === 1) { return prop$; } if (arg1 == null) { throw new Error("The second argument must be a transform function or a fallback value."); } const transform = typeof arg1 === "function" ? // If `arg1` is a function, use it as the transform function. arg1 : // If `arg1` is not a function, use it as a fallback value. (value) => value ?? arg1; return mapRef(prop$, transform); } _refPropOnly(propertyName) { const config = this._findPropConfig(propertyName); if (!config) { throw new Error(`Property "${String(propertyName)}" is not a reactive property.`); } /** * A reference used to observe changes to the property. */ /* * ### Private Remarks * * This shouldn't use `hold`, because it doesn't need * to request an update on the host. `KnytElement` will request * an update when the property changes. */ const initialValue = this.getProp(propertyName); const value$ = createReference(initialValue, { comparator: config.comparator, }); const subscription = this.onPropChange(propertyName, (currentValue) => { value$.set(currentValue); }); // We're replacing the mutator to so that the property is updated // instead of the reference value. When the property is updated, // the new value will propagate to the reference via the // `onPropChange` handler. const modifiedRef = replaceReferenceMutator(value$, (_origin$) => (nextValue) => { // Ignore the origin reference (`_origin$`), and just set the property directly // using the reactive adapter. this._setReactivePropertyValue(ReactivePropertyChangeOrigin.Property, config, nextValue); }); return Object.assign(modifiedRef, { subscription }); } onPropChange(arg1, arg2) { let listeners; if (typeof arg1 === "function") { const listener = arg1; listeners = this.#reactivePropertyEmitter.on(PropertyChangeEventName, (event) => { listener(event); }); } else if (typeof arg2 === "function") { const propertyName = arg1; const listener = arg2; listeners = this.#reactivePropertyEmitter.on(PropertyChangeEventName, (event) => { if (event.propertyName === propertyName) { listener(event.currentValue, event.previousValue); } }); } else { throw new Error("Invalid arguments"); } return { unsubscribe() { listeners.off(); }, }; } /** * Set a reactive property value from a property setter or an attribute change. */ _setReactivePropertyValue(changeOrigin, config, nextValue) { const { attributeName, propertyName, toAttributeValue = unknownToAttributeValue, comparator = strictEqual, } = config; this.#isPropertyUpdating$.set(true); const previousValue = this.#propValues.get(propertyName); // The next value is always set, regardless of the comparator. // The comparator is used to determine if change notification // should be emitted. Mutating the current property value is a // separate concern that is not related to the comparator. this.#propValues.set(propertyName, nextValue); const isElementConnected = this.#hooks.isConnected; const isElementConstructed = this.#isConstructed; if (attributeName && // The DOM doesn't allow setting attributes on an element // while it is being constructed, so we only set attributes // when the element is finished constructing. isElementConstructed && changeOrigin === ReactivePropertyChangeOrigin.Property) { const nextAttributeValue = toAttributeValue(nextValue); // The source of truth is the property, so we reference the attribute value // by converting the property value to an attribute value. const prevAttributeValue = toAttributeValue(previousValue); if (nextAttributeValue === null) { this.#hooks.removeAttribute?.(attributeName); } else if (nextAttributeValue !== prevAttributeValue) { this.#hooks.setAttribute?.(attributeName, nextAttributeValue); } } if (!comparator(previousValue, nextValue)) { const changedProperties = this.#changedProperties; for (const hookName of hookNames) { changedProperties[hookName].set(propertyName, nextValue); } // Emit a property change event first, before requesting an update. // // All side effects are asynchronous, so we need to wait for the next // microtask to request the update. queueMicrotask(() => { this.#reactivePropertyEmitter.emit(PropertyChangeEventName, { currentValue: nextValue, previousValue, propertyName, }); }); if ( // Updates are only requested when the element is connected to the DOM, // because: // // > The specification recommends that, as far as possible, developers // > should implement custom element setup in this callback (`connectedCallback`) // > rather than the constructor. // > https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks // // While it's ultimately the responsibility of the element to determine // a requested update should be applied, we can avoid unnecessary // operations by only requesting updates when the element is connected // to the DOM. isElementConnected && this.#updateMode === ReactiveUpdateMode.Reactive) { // All side effects are asynchronous, so we need to wait for the next // microtask to request the update. // // !!! Important !!! // // This is used to prevent updates from occurring when the element // is connected to the DOM due to a declarative shadow DOM being // used. // // When a declarative shadow DOM is used, an element will already be // connected to the DOM while its being constructed. As a result, // if it receives updates (via observed attributes, etc.), updates may // be requested in the middle of the element's constructor. // // This is a significant issue, because an element isn't considered ready // to handle updates until after the extended constructor has fully // completed its execution. queueMicrotask(() => { this.#hooks.requestUpdate?.(); }); } } this.#isPropertyUpdating$.set(false); } /** * Get a reactive property value. * * @internal scope: package */ _getReactivePropertyValue(config) { return this.#propValues.get(config.propertyName); } /** * Retrieves the changed properties since the last call to this method, * and resets the changed properties map. */ _flushChangedProperties(hookName) { const changedProperties = this.#changedProperties[hookName]; this.#changedProperties[hookName] = new Map(); return changedProperties; } /** * Get all reactive property values as an object. * * @remarks * Useful for accessing multiple properties at once. For best type safety, * prefer accessing individual properties directly. * * @public */ getProps() { return Object.fromEntries(this.#propValues); } /** * Set the attribute values from reactive properties that have attribute names. * * @remarks * * While this method can technically be called at any time, it is primarily * intended to be called after the host has been fully constructed. This is * because attributes cannot be set on an element during its construction phase, * and doing so will result in the browser throwing an error. */ /* * ### Private Remarks * * This method exists, because attributes can not be set in the constructor. * Doing so will result in the browser throwing an error. * As a result, we must set attribute values after the element is connected. * * @internal scope: package */ syncAttributeValues() { this.setAttributeValues(this.getAttributeValues()); } /** * Creates a dictionary of attribute names and their corresponding values * based on the reactive properties that have an associated attribute name. */ getAttributeValues() { const attributeValues = {}; for (const config of this.#reactiveProperties) { const { attributeName, propertyName, toAttributeValue = unknownToAttributeValue, } = config; if (!attributeName) continue; const propertyValue = this.#propValues.get(propertyName); const attributeValue = toAttributeValue(propertyValue); attributeValues[attributeName] = attributeValue; } return attributeValues; } /** * Set attribute values on the element using the provided attribute values * using the hooks defined in the constructor. */ setAttributeValues(attributeValues) { for (const attributeName in attributeValues) { const attributeValue = attributeValues[attributeName]; if (attributeValue === null) { this.#hooks.removeAttribute?.(attributeName); } else { this.#hooks.setAttribute?.(attributeName, attributeValue); } } } /** * @internal scope: package */ _setPropertyFromAttributeChange(name, _prevAttributeValue, nextAttributeValue) { const propertyConfig = this.#reactiveProperties.find(({ attributeName }) => attributeName === name); if (!propertyConfig) return; const toPropertyValue = propertyConfig.toPropertyValue ?? attributeValueToString; const nextValue = toPropertyValue(nextAttributeValue); this._setReactivePropertyValue(ReactivePropertyChangeOrigin.Attribute, propertyConfig, nextValue); } /** * Creates a reference that maps a property value to a transformed value. * * @deprecated Use `mapRef` instead. */ mapProp(propertyName, transform) { return mapRef({ origin: this.refProp(propertyName), transform, }); } /** * @deprecated Use `unwrapRef` instead. */ unwrapProp(propertyName) { return unwrapRef(this.refProp(propertyName)); } /** * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements */ handleAttributeChanged(name, previousValue, nextValue) { // To avoid unnecessary updates when a property is associated with an attribute, // we prevent attribute updates while a property is being updated. // This is because the property setter will handle the attribute update, // and attributes are synchronous. if (!this.isPropertyUpdating$.get()) { this._setPropertyFromAttributeChange(name, previousValue, nextValue); } } } /** * Determines whether the input is a {@link ReactiveAdapter}. */ export function isReactiveAdapter(value) { return (isNonNullableObject(value) && "_findPropConfig" in value && typeof value["_findPropConfig"] === "function" && "_setReactivePropertyValue" in value && typeof value["_setReactivePropertyValue"] === "function" && "_getReactivePropertyValue" in value && typeof value["_getReactivePropertyValue"] === "function"); } /** * Determines whether the input is a {@link Reactive}. */ export function isReactive(value) { return (isNonNullableObject(value) && __reactiveAdapter in value && isReactiveAdapter(value[__reactiveAdapter])); } /** * Retrieves the reactive adapter from an object. * * @returns The reactive adapter if the object is reactive, * otherwise `undefined`. * * @internal scope: package */ export function getReactiveAdapter(value) { if (isReactive(value)) { return value[__reactiveAdapter]; } return undefined; } /** * Assert that the object is a {@link Reactive}. */ export function assertReactive(value) { if (!isReactive(value)) { throw new TypeError("Object is not reactive"); } } /** * Defines reactive properties on a given object. */ export function makeReactive(targetObj, properties) { const reactiveProperties = convertPropertiesDefinition(properties); for (const config of reactiveProperties) { Object.defineProperty(targetObj, config.propertyName, { get() { // TODO: Remove in production assertReactive(this); return this[__reactiveAdapter]._getReactivePropertyValue(config); }, set(nextValue) { // TODO: Remove in production assertReactive(this); this[__reactiveAdapter]._setReactivePropertyValue(ReactivePropertyChangeOrigin.Property, config, nextValue); }, }); } return reactiveProperties; } /** * Adds reactivity to an instance of a class. * * @remarks * * Although the function works on class instances, it modifies the prototype of the class. * The prototype is modified only once, so all instances of the class will have the same reactive properties. * The trade-off is miniscule overhead to check if the prototype has already been modified * on each instance creation. */ /* * ### Private Remarks * * The reason why this is performed at construction time is because `properties` are typically * store statically on the class constructor. If we were to perform this when the class is defined, * then we would need to apply the mixin to every class. * * For example, instead of the API looking like this: * * ```ts * class MyElement extends KnytElement { * static properties = {}; * } * ``` * * It would have to look something like this: * * ```ts * const properties = {}; * * class MyElement extends KnytMixin(HTMLElement, properties) {} * ``` * * While that's not a bad API, TypeScript doesn't like it. * TypeScript also treats `HTMLElement` specially. Writing anything * other than `extends HTMLElement` breaks the type system for * some reason. Maybe it's a bug, maybe it's a feature. */ export function withReactivity({ instance, properties, hooks, options, }) { const proto = Object.getPrototypeOf(instance); let reactiveProperties; if (Object.hasOwn(proto, __reactiveProperties)) { // Reactive properties have already been prepared. reactiveProperties = proto[__reactiveProperties]; } else { reactiveProperties = makeReactive(proto, properties); Object.defineProperty(proto, __reactiveProperties, { get() { return reactiveProperties; }, }); } instance[__reactiveAdapter] = new ReactiveAdapter({ reactiveProperties, hooks, options, }); } function attachReactiveMembers(targetObj, members = []) { for (const memberName of members) { if (typeof ReactiveAdapter.prototype[memberName] !== "function") { throw new Error(`Method "${memberName}" does not exist on the ReactiveAdapter prototype.`); } if (memberName in targetObj) { throw new Error(`Member "${memberName}" already exists on the prototype. Please rename the member.`); } targetObj[memberName] = function (...args) { // TODO: Remove in production. assertReactive(this); return this[__reactiveAdapter][memberName](...args); }; } } const mixedConstructors = new WeakSet(); export function applyReactiveMixin(Constructor, members = []) { if (mixedConstructors.has(Constructor)) return; mixedConstructors.add(Constructor); const proto = Constructor.prototype; attachReactiveMembers(proto, members); }