@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
JavaScript
/// <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);
}