import type { PropertyDeclaration, PropertyDeclarationMap, PropertyValues, ReactiveController } from './types'; interface ResolvedMeta { props: Map; attrToProp: Map; } const cache = new WeakMap(); const propertyKeys = new Map(); /** * Lightweight reactive custom element base class. * * Drop-in subset of Lit's `ReactiveElement` — supports `static properties`, * attribute reflection, batched async updates, and reactive controllers. * No Shadow DOM, no `static styles`, no decorators. * * Updates are batched using the same Promise-based scheduling as Lit: * property changes enqueue a microtask, and the update is gated behind * `connectedCallback` so the first update only runs once the element * is in the document. * * Subclasses that extend another element with properties must spread them: * * @example * ```ts * class MyButton extends ReactiveElement { * static override properties = { * label: { type: String }, * disabled: { type: Boolean }, * }; * * label = 'Click me'; * disabled = false; * * protected override update(changed: PropertyValues): void { * super.update(changed); * this.textContent = this.label; * } * } * * // Inheritance — spread parent properties * class FancyButton extends MyButton { * static override properties = { * ...MyButton.properties, * variant: { type: String }, * }; * * variant = 'primary'; * } * ``` */ export class ReactiveElement extends HTMLElement { /** * User-supplied object that maps property names to * {@linkcode PropertyDeclaration} objects containing options for configuring * reactive properties. When a reactive property is set the element will * update and render. */ static properties: PropertyDeclarationMap = {}; /** * Returns a list of attributes corresponding to the registered properties. */ static get observedAttributes(): string[] { return [...resolve(this).attrToProp.keys()]; } // --- Instance state --- #controllers: Set = new Set(); #changedProperties: PropertyValues = new Map(); #instanceProperties: Map | undefined; /** * Promise that gates the first update until `connectedCallback`. Also * used to serialize updates — each `#enqueueUpdate` awaits the previous * `#updatePromise`, so property changes are batched and updates never * overlap. Matches Lit's scheduling model. */ #updatePromise: Promise; /** * True if there is a pending update as a result of calling * `requestUpdate()`. Should only be read. */ isUpdatePending = false; /** * Is set to `true` after the first update. The element code cannot assume * that the DOM is fully initialized before the element `hasUpdated`. */ hasUpdated = false; constructor() { super(); // Initialize to an unresolved Promise so we can make sure the element // has connected before the first update. The resolver is assigned to // `this.enableUpdating`, overriding the no-op prototype method. this.#updatePromise = new Promise( (res) => (this.enableUpdating = res as (requestedUpdate: boolean) => void) ); // Save instance properties that might shadow prototype accessors. // Handles the "upgrade" case where properties were set before registration. const { props } = resolve(this.constructor as typeof ReactiveElement); for (const name of props.keys()) { if (Object.hasOwn(this, name)) { (this.#instanceProperties ??= new Map()).set(name, (this as Record)[name]); delete (this as Record)[name]; } } // Enqueue the first update. It won't run until connectedCallback calls // `this.enableUpdating(true)` which resolves the #updatePromise gate. this.requestUpdate(); } /** * Note, this method should be considered final and not overridden. It is * overridden on the element instance with a function that triggers the * first update. */ protected enableUpdating(_requestedUpdate: boolean): void {} /** * Registers a {@linkcode ReactiveController} to participate in the * element's reactive update cycle. The element automatically calls into * any registered controllers during its lifecycle callbacks. * * If the element is connected when `addController()` is called, the * controller's `hostConnected()` callback will be immediately called. */ addController(controller: ReactiveController): void { this.#controllers.add(controller); if (this.isConnected) { controller.hostConnected?.(); } } /** Removes a {@linkcode ReactiveController} from the element. */ removeController(controller: ReactiveController): void { this.#controllers.delete(controller); } /** * On first connection, enables updating and notifies controllers. */ connectedCallback(): void { this.enableUpdating(true); for (const c of this.#controllers) { c.hostConnected?.(); } } disconnectedCallback(): void { for (const c of this.#controllers) { c.hostDisconnected?.(); } } /** * Synchronizes property values when attributes change. * * Specifically, when an attribute is set, the corresponding property is * set. You should rarely need to implement this callback. If this method * is overridden, `super.attributeChangedCallback(name, _old, value)` must * be called. */ attributeChangedCallback(attr: string, oldValue: string | null, newValue: string | null): void { if (oldValue === newValue) return; const { props, attrToProp } = resolve(this.constructor as typeof ReactiveElement); const propName = attrToProp.get(attr); if (!propName) return; const decl = props.get(propName); if (!decl) return; let value: unknown = newValue; if (decl.type === Boolean) { value = newValue !== null; } else if (decl.type === Number) { value = newValue === null ? null : Number(newValue); } (this as Record)[propName] = value; } /** * Requests an update which is processed asynchronously. This should be * called when an element should update based on some state not triggered * by setting a reactive property. In this case, pass no arguments. It * should also be called when manually implementing a property setter. In * this case, pass the property `name` and `oldValue` to ensure that any * configured property options are honored. */ requestUpdate(name?: string, oldValue?: unknown): void { if (name !== undefined) { this.#changedProperties.set(name, oldValue); } if (this.isUpdatePending) return; this.#updatePromise = this.#enqueueUpdate(); } /** * Sets up the element to asynchronously update. Awaits the previous * `#updatePromise` which both serializes updates and (on first update) * waits for `connectedCallback` to resolve the gate. */ async #enqueueUpdate(): Promise { this.isUpdatePending = true; try { // Ensure any previous update has resolved before updating. // This `await` also ensures that property changes are batched. await this.#updatePromise; } catch (e: unknown) { // Refire any previous errors async so they do not disrupt the // update cycle. Promise.reject(e); } const result = this.scheduleUpdate(); // If `scheduleUpdate` returns a Promise, we await it. This is done to // enable coordinating updates with a scheduler. if (result != null) { await result; } return !this.isUpdatePending; } /** * Schedules an element update. You can override this method to change the * timing of updates by returning a Promise. The update will await the * returned Promise, and you should resolve the Promise to allow the update * to proceed. If this method is overridden, `super.scheduleUpdate()` must * be called. * * For instance, to schedule updates to occur just before the next frame: * * ```ts * override protected async scheduleUpdate(): Promise { * await new Promise((resolve) => requestAnimationFrame(() => resolve())); * super.scheduleUpdate(); * } * ``` */ protected scheduleUpdate(): void | Promise { this.performUpdate(); } /** * Performs an element update. Note, if an exception is thrown during the * update, `firstUpdated` and `updated` will not be called. * * Call `performUpdate()` to immediately process a pending update. This * should generally not be needed, but it can be done in rare cases when * you need to update synchronously. */ protected performUpdate(): void { // Abort any update if one is not pending when this is called. // This can happen if `performUpdate` is called early to "flush" // the update. if (!this.isUpdatePending) return; // Restore saved instance properties on first update. if (!this.hasUpdated && this.#instanceProperties) { for (const [name, value] of this.#instanceProperties) { (this as Record)[name] = value; } this.#instanceProperties = undefined; } const changed = this.#changedProperties; this.willUpdate(changed); for (const c of this.#controllers) { c.hostUpdate?.(); } this.update(changed); // The update is no longer pending and further updates are now allowed. this.#changedProperties = new Map(); this.isUpdatePending = false; for (const c of this.#controllers) { c.hostUpdated?.(); } if (!this.hasUpdated) { this.hasUpdated = true; this.firstUpdated(changed); } this.updated(changed); } /** * Invoked before `update()` to compute values needed during the update. * * Implement `willUpdate` to compute property values that depend on other * properties and are used in the rest of the update process. * * ```ts * willUpdate(changed) { * if (changed.has('firstName') || changed.has('lastName')) { * this.sha = computeSHA(`${this.firstName} ${this.lastName}`); * } * } * ``` */ protected willUpdate(_changed: PropertyValues): void {} /** * Updates the element. This method reflects property values to attributes * and can be overridden to render and keep updated element DOM. Setting * properties inside this method will *not* trigger another update. */ protected update(_changed: PropertyValues): void {} /** * Invoked when the element is first updated. Implement to perform one * time work on the element after update. * * Setting properties inside this method will trigger the element to * update again after this update cycle completes. */ protected firstUpdated(_changed: PropertyValues): void {} /** * Invoked whenever the element is updated. Implement to perform * post-updating tasks via DOM APIs, for example, focusing an element. * * Setting properties inside this method will trigger the element to * update again after this update cycle completes. */ protected updated(_changed: PropertyValues): void {} /** * Returns a Promise that resolves when the element has completed updating. * The Promise value is a boolean that is `true` if the element completed * the update without triggering another update. The Promise result is * `false` if a property was set inside `updated()`. */ get updateComplete(): Promise { return this.#updatePromise; } } /** * Resolve `ctor.properties` into lookup Maps and install reactive accessors * on the prototype. Runs once per class, result is cached. * * Subclasses that need parent properties must spread them: * `static override properties = { ...Parent.properties, ... }`. */ function resolve(ctor: typeof ReactiveElement): ResolvedMeta { const existing = cache.get(ctor); if (existing) return existing; const props = new Map(); const attrToProp = new Map(); for (const [name, decl] of Object.entries(ctor.properties)) { props.set(name, decl); attrToProp.set(decl.attribute ?? name, name); // Install reactive accessor on the prototype if (!Object.getOwnPropertyDescriptor(ctor.prototype, name)?.get) { let key = propertyKeys.get(name); if (!key) { key = Symbol(name); propertyKeys.set(name, key); } Object.defineProperty(ctor.prototype, name, { get(this: ReactiveElement) { return (this as unknown as Record)[key]; }, set(this: ReactiveElement, value: unknown) { const old = (this as unknown as Record)[key]; (this as unknown as Record)[key] = value; if (!Object.is(old, value)) { this.requestUpdate(name, old); } }, configurable: true, enumerable: true, }); } } const meta: ResolvedMeta = { props, attrToProp }; cache.set(ctor, meta); return meta; }