mirror of
https://github.com/zoriya/v10.git
synced 2026-08-10 16:09:18 +00:00
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import type { PropertyDeclaration, PropertyDeclarationMap, PropertyValues, ReactiveController } from './types';
|
|
|
|
interface ResolvedMeta {
|
|
props: Map<string, PropertyDeclaration>;
|
|
attrToProp: Map<string, string>;
|
|
}
|
|
|
|
const cache = new WeakMap<typeof ReactiveElement, ResolvedMeta>();
|
|
const propertyKeys = new Map<string, symbol>();
|
|
|
|
/**
|
|
* 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<ReactiveController> = new Set();
|
|
#changedProperties: PropertyValues = new Map();
|
|
#instanceProperties: Map<string, unknown> | 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<boolean>;
|
|
|
|
/**
|
|
* 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<boolean>(
|
|
(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<string, unknown>)[name]);
|
|
delete (this as Record<string, unknown>)[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<string, unknown>)[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<boolean> {
|
|
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<unknown> {
|
|
* await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
* super.scheduleUpdate();
|
|
* }
|
|
* ```
|
|
*/
|
|
protected scheduleUpdate(): void | Promise<unknown> {
|
|
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<string, unknown>)[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<boolean> {
|
|
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<string, PropertyDeclaration>();
|
|
const attrToProp = new Map<string, string>();
|
|
|
|
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<symbol, unknown>)[key];
|
|
},
|
|
set(this: ReactiveElement, value: unknown) {
|
|
const old = (this as unknown as Record<symbol, unknown>)[key];
|
|
(this as unknown as Record<symbol, unknown>)[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;
|
|
}
|