UNPKG

@knyt/luthier

Version:

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

838 lines (837 loc) 30.1 kB
import { __HTMLElement } from "./__stubs"; /// <reference lib="dom.iterable" /> import { elementHasFocus, isCSSStyleSheet, isHTMLElement, isPromiseLike, isServerSide, isShadowRoot, } from "@knyt/artisan"; import { css, getCSSStyleSheetConstructor, isStyleSheet, } from "@knyt/tailor"; import { __hostAdapter, __lifecycle, applyControllableMixin, applyLifecycleMixin, ControllableAdapter, createEffect, EventListenerController, EventListenerManager, hydrateRef, isLifecycleInterrupt, LifecycleAdapter, LifecycleInterrupt, listenTo, StyleSheetAdoptionController, } from "@knyt/tasker"; import { BasicResourceRendererHost, dom, html, normalizeRenderResult, renderElementToString, RenderMode, setRenderMode, update, } from "@knyt/weaver"; import { KNYT_DEBUG_DATA_ATTR } from "./constants"; import { convertPropertiesDefinition } from "./convertPropertiesDefinition"; import { defer } from "./DeferredContent/defer"; import { getConstructorStaticMember } from "./getConstructorStaticMember"; import { HtmxIntegration } from "./HtmxIntegration"; import { __reactiveAdapter, applyReactiveMixin, ReactiveUpdateMode, withReactivity, } from "./Reactive"; /**/ /* * ### Private Remarks * * This must be symbol that's registered globally, so that it can be * used to check if an element is a Knyt element across different * versions of the library. */ const __isKnytElement = Symbol.for("knyt.luthier.isKnytElement"); /** * The base class for all Knyt elements. * * @remarks * * This class is a mixin of the `Reactive` and `Controllable` mixins. * It provides a set of properties and methods that are common to all Knyt elements. * * @public */ /* * ### Private Remarks * * This is an abstract class simply to prevent it from being * instantiated directly. It should be used as a base class. * However, no methods or properties should be marked as * `abstract`. The class should be able to be used as simply as * `class extends KnytElement {}`. */ export class KnytElement extends __HTMLElement() { /** * A reference to a `Document` object that should be used * for rendering. * * @internal scope: package */ get #renderingDocument() { return this.#rootElement.ownerDocument; } /** * An adapter to implement the `ReactiveControllerHost` interface. * * @internal scope: package */ [__hostAdapter] = new ControllableAdapter({ performUpdate: () => this.#handleUpdateSignal(), }); /** @internal scope: package */ /* * ### Private Remarks * * We have to use `any` here, because we don't currently have a way to * provide the expected props to a `KnytElement` instance. * * TODO: Add a generic type parameter to `KnytElement` that * represents the expected props of the element. */ [__lifecycle] = new LifecycleAdapter(); /** * Enables debug mode for the element * * @public */ debug; get #isDebugMode() { if (this.debug === undefined) { this.debug = this.getAttribute(KNYT_DEBUG_DATA_ATTR) === "true"; } return this.debug; } #debugLog(message) { if (this.#isDebugMode) { console.debug(message); } } /** * Defines the reactive properties of the element * * @public */ static properties = {}; /** * The default style sheet(s) adopted by the element * * @public */ // TODO: Add support for an array of style sheets static styleSheet; /** * The observed attributes of the element. * * Do not rename. The name is standardized by the Web Components API. * * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#responding_to_attribute_changes | MDN Reference}. * * @remarks * * This property is derived from the `properties` static property. * It returns the attribute name for each reactive element with an attribute name. * * @public */ static get observedAttributes() { return convertPropertiesDefinition(this.properties).reduce((result, { attributeName }) => { if (attributeName) { result.push(attributeName); } return result; }, []); } /** * Render the element's contents; i.e. the children of the root element. */ async #renderRootContents() { const content = await normalizeRenderResult(this.render()); const resourcePromises = this.#resourceRenderers.render(); const resources = resourcePromises.some(isPromiseLike) ? await Promise.all(resourcePromises) : resourcePromises; if (resources.length === 0) { return content; } return dom.fragment.$(...resources, content); } /** * Updates the root contents of the element. * * @remarks * * This method shouldn't be called directly. This is called * should be called by the `ReactiveControllerHostAdapter` after * a request to update the element is made. * * @internal scope: package */ async #updateRoot() { this.#debugLog("#updateRoot called"); const hostAdapter = this[__hostAdapter]; const lifecycle = this[__lifecycle]; return hostAdapter.stageModification({ shouldModify: this.isConnected, modification: async () => { this.#debugLog("#updateRoot: before update"); const abortController = new AbortController(); const changedProperties = this[__reactiveAdapter]._flushChangedProperties("update"); await lifecycle.performBeforeUpdate({ abortController, changedProperties, }); if (abortController.signal.aborted) return; hostAdapter.updateCallback(); await update(this.#rootElement, await this.#renderRootContents(), { document: this.#renderingDocument, appendChunkSize: this.#appendChunkSize, }); this.#debugLog("#updateRoot: after update"); lifecycle.performAfterUpdate({ changedProperties }); hostAdapter.updatedCallback(); this.#debugLog("#updateRoot: after updated lifecycle hook"); }, }); } /** * Renders the element's contents as a declaration representing the * element's current state. * * @public */ /* * ### Private Remarks * * The `renderRootChildren` should be called internally * to render the root element. */ // TODO: Consider making public render() { return null; } /** * Serializes the element and its contents to an HTML string. * * @remarks * * Useful for server-side rendering or generating static markup. * * Handles both open and closed shadow roots, rendering them as * declarative shadow roots. Adopted stylesheets may be included * in the output if configured. * * @public */ async renderToString(children, options) { const shadowRootMode = isShadowRoot(this.#rootElement) ? this.#rootElement.mode : undefined; // If the element is a container, it doesn't render its own contents. // Instead, it relies on the children passed to this method. const contents = this.#isContainer ? undefined : await this.#renderRootContents(); return renderElementToString({ children, element: this, shadowRootMode, contents, options, }); } /** @internal scope: package */ get [__isKnytElement]() { return true; } /** * Provides quick and easy access to the shadow root of the element, * because `element.shadowRoot` isn't be available immediately after the element * is constructed. * * @internal scope: package */ #rootElement; #styleSheetAdoption; /** * Indicates whether the element is a "Container" element. */ #isContainer; /** * Determines whether the element has focus. */ hasFocus(child) { return elementHasFocus(this.#rootElement, child); } /** * Whether the element has a declarative shadow root * upon construction. */ #hadDeclarativeShadowRoot = false; /** * Determines whether the element should render adopted stylesheets * server-side. * * @see {@link KnytElementOptions.disableStylesheetSSR} */ #disableStylesheetSSR; #appendChunkSize; constructor(options = {}) { super(); const preparedOptions = prepareElementOptions(options); const renderMode = preparedOptions.renderMode ?? RenderMode.Transparent; const shadowRootEnabled = preparedOptions.shadowRoot !== false; const shadowRootInit = preparedOptions.shadowRoot || { mode: "open" }; const htmxInput = preparedOptions.htmx ?? false; const updateMode = preparedOptions.updateMode; if (preparedOptions.debug) { this.debug = true; } this.#isContainer = preparedOptions.container ?? false; this.#disableStylesheetSSR = preparedOptions.disableStylesheetSSR ?? false; this.#appendChunkSize = preparedOptions.appendChunkSize; // This should be the first thing setup in the constructor, // so that the element is properly initialized. withReactivity({ instance: this, properties: getKnytElementProperties(this), hooks: this, options: { updateMode }, }); if (shadowRootEnabled) { const { wasDeclarative, shadowRoot } = ensureShadowRoot(this, shadowRootInit); this.#hadDeclarativeShadowRoot = wasDeclarative; this.#rootElement = shadowRoot; if (htmxInput) { this.addController(new HtmxIntegration(htmxInput, shadowRoot)); } } else { this.#rootElement = this; } setRenderMode(this, renderMode); this.#styleSheetAdoption = createStyleSheetAdoptionController(this, this.#rootElement); this.#postConstruct(); } /** * Called immediately after the constructor completes. * Use this for any initialization that must occur after the element * has been fully constructed. * * @remarks * * Separating this from the constructor helps ensure that * post-construction logic runs only after the element is fully initialized, * improving clarity and maintainability. */ #postConstruct() { // Adopt the static stylesheet if it exists. { const staticStyleSheet = getKnytElementStyleSheet(this); if (staticStyleSheet) { // NOTE: Avoid using `this.#styleSheetAdoption` directly here, // to ensure the stylesheet is adopted properly in both // client-side and server-side rendering scenarios. if (Array.isArray(staticStyleSheet)) { for (const styleSheet of staticStyleSheet) { this.adoptStyleSheet(styleSheet); } } else { this.adoptStyleSheet(staticStyleSheet); } } } } #addStylesheetFromHref(url) { this.addRenderer({ hostRender() { // This is just a micro-optimization to choose the // most efficient builder for the current environment. // It's easy in this case, because the properties and // attributes perfectly match. const builder = isServerSide() ? html : dom; return builder.link.rel("stylesheet").href(url.href); }, }); } /** * Adopts a style sheet into the element's shadow root or light DOM. * * @remarks * * This method can accept either a `StyleSheet` instance or a URL-like * object with an `href` property. If a URL-like object is provided, * it will create a `<link>` element to load the stylesheet. * * During server-side rendering, the style sheet will be rendered as a * `<style>` element. When hydrating on the client, the `<style>` element * will be replaced with an adopted style sheet. * This functionality is enabled by default, but can be disabled by setting * `disableStylesheetSSR` to `true` in the element's options. */ adoptStyleSheet(input) { if ("href" in input && !isCSSStyleSheet(input)) { this.#addStylesheetFromHref(input); return; } const shouldAddStyleSheetAsResource = isServerSide() && !this.#disableStylesheetSSR; if (shouldAddStyleSheetAsResource) { this.addRenderer(normalizeStyleSheet(input, this.#renderingDocument)); return; } this.#styleSheetAdoption.adoptStyleSheet(input); } /** * Drops a style sheet from the element's shadow root or light DOM. */ dropStyleSheet(input) { this.#styleSheetAdoption.dropStyleSheet(input); } /** * A manager for event listener controllers targeting the element. */ listeners = listenTo(this, this); /** * Adds a new event listener controller targeting the element for the given event name and listener. * * @beta This is an experimental API and may change in the future. */ on(eventName, listener, options) { return this.listeners.create(eventName, listener, options); } hydrateRef(name, arg1) { if (arguments.length === 1) { return (value$) => { return this.hydrateRef(name, value$); }; } if (!arg1) { throw new Error(`The second argument to hydrateRef must be a Reference, but received: ${arg1}`); } hydrateRef(this, { name, parent: this.#rootElement, ref: arg1, }); return arg1; } /** * Restores the value of a reactive property after SSR. * * @remarks * * During server-side rendering, reactive properties are not hydrated * automatically. Use this method to restore a reactive property value * after SSR. This runs only once, during the first "beforeMount" * lifecycle hook. The value may still be overridden externally, but * this method will not run again. * * @beta This is an experimental API and may change in the future. */ // TODO: Restrict property names to reactive properties. hydrateProp(propertyName) { const name = `@prop:${propertyName}`; const propRef$ = this.refProp(propertyName); return this.hydrateRef(name, propRef$); } /** * Objects that are used to render additional content in the shadow root. * These renderers are rendered whenever the element is updated. * The renderers are prepended to the shadow root. * * @internal scope: package */ // TODO: Rename for clarity. #resourceRenderers = new BasicResourceRendererHost(); /** * Adds a resource renderer to the element. * * @see {@link ResourceRenderer} */ addRenderer(input) { this.#resourceRenderers.addRenderer(input); } /** * Removes a renderer from the element. */ removeRenderer(input) { this.#resourceRenderers.removeRenderer(input); } /** * Creates an effect controller and adds it to the host. * * @remarks * * Accepts a setup function, called when connected. The setup may return * a teardown function, which is called on disconnection. * * @returns the effect controller instance. */ effect(setup) { return createEffect(this, setup); } // TODO: Move to `ControllableAdapter` defer(...args) { return defer(this, ...args); } async #shouldMount() { if (this.#isContainer) { // If the element is a container, it should not "mount". // Mounting is the process of inserting/updating the element's content. // Container elements do not render their own content. return false; } const abortController = new AbortController(); await this[__lifecycle].performBeforeMount({ abortController, }); return !abortController.signal.aborted; } /** * This is NOT the same as `shouldUpdate` in Lit. * This determines whether the element should update its content, * and is called after every `requestUpdate` invocation. */ async #shouldUpdate() { if (this.#isContainer) { // If the element is a container, it should not "update". // Updating is the process of updating the element's content. // Container elements do not render their own content. return false; } const abortController = new AbortController(); const changedProperties = this[__reactiveAdapter]._flushChangedProperties("updateRequested"); await this[__lifecycle].performUpdateRequested({ abortController, changedProperties, }); return !abortController.signal.aborted; } /** * Handles the connected signal for the element. */ /* * ### Private Remarks * * This shouldn't contain any expensive operations, because the connected callbacks are called * whenever an element is added, removed, or replaced in th DOM. Simple operations like moving an * element 's position in the DOM shouldn't trigger expensive side effects. * * This method should be public, because it is called by the Web Components API. */ async #handleConnectedSignal() { // TODO: Have the build process remove these logs in production builds. this.#debugLog("#handleConnectedSignal called"); try { if (await this.#shouldMount()) { this.#debugLog("#handleConnectedSignal: before mount update request"); this.requestUpdate(); this.#debugLog("#handleConnectedSignal: before mount update complete"); await this.updateComplete; this.#debugLog("#handleConnectedSignal: after mount update complete"); this[__lifecycle].performMounted(); this.#debugLog("#handleConnectedSignal: after mounted lifecycle hook"); } } catch (exception) { this.#handleLifecycleException(exception); } finally { this[__hostAdapter].connectedCallback(); } } async #handleUpdateSignal() { this.#debugLog("#handleUpdateSignal called"); try { if (await this.#shouldUpdate()) { this.#debugLog("#handleUpdateSignal: before update"); await this.#updateRoot(); this.#debugLog("#handleUpdateSignal: after update"); } } catch (exception) { this.#handleLifecycleException(exception); } } #handleLifecycleException(exception) { if (isLifecycleInterrupt(exception)) { this.#handleInterrupted(exception); } else { this.#handleLifecycleError(exception); } } #handleInterrupted(interrupt) { this.#debugLog(`Lifecycle interrupt: ${interrupt.message}`); try { // Notify the lifecycle hooks about the interrupt. this[__lifecycle].performInterrupted(interrupt); } catch (exception) { // If an exception occurs while handling the interrupt, // invoke the exception handler again. // // NOTE: This could potentially result in infinite recursion // if the exception handler throws another interrupt in response // to the original one. While unlikely, it is possible. // // This is intentional, as each interrupt includes a `reason` // property that can be used to control logic flow. Although // throwing an interrupt in response to another is not recommended, // the design allows for it if necessary. this.#handleLifecycleException(exception); } } /** * Handles errors that occur during the element's lifecycle. * * @remarks * * This is the private implementation of the `handleLifecycleError` method, * that can't be overridden by subclasses. It calls the public `handleLifecycleError` * method if it exists, but ensures that the lifecycle hooks are always notified * about the error, and that unhandled errors are logged to the console. */ #handleLifecycleError(error) { this.#debugLog(`Lifecycle error: ${error}`); // Notify the lifecycle hooks about the error. this[__lifecycle].handleError(error); // Call the error handler if it exists. if (typeof this.handleLifecycleError === "function") { this.handleLifecycleError(error); } else { // If no error handler is defined, log the error to the console. console.error("Unhandled lifecycle error:", error); } } /** * Called after the element is connected to the DOM. * * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#lifecycle_callbacks * * @public */ /* * ### Private Remarks * * Do not rename. The name is standardized by the Web Components API. */ connectedCallback() { this.#handleConnectedSignal(); } /** * Called after the element is disconnected from the DOM. * * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#lifecycle_callbacks * * @public */ /* * ### Private Remarks * * Do not rename. The name is standardized by the Web Components API. * * This shouldn't contain any expensive operations, because the connected callbacks are called * whenever an element is added, removed, or replaced in th DOM. Simple operations like moving an * element 's position in the DOM shouldn't trigger expensive side effects. * * This method should be public, because it is called by the Web Components API. */ disconnectedCallback() { this[__lifecycle].performUnmounted(); // NOTE: This can't be mixed in, because it should be accessible via `super.disconnectedCallback()`. this[__hostAdapter].disconnectedCallback(); } /** * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements * * @public */ /* * ### Private Remarks * * Do not rename. The name is standardized by the Web Components API. * This method should be public, because it is called by the Web Components API. */ attributeChangedCallback(name, previousValue, nextValue) { this[__reactiveAdapter].handleAttributeChanged(name, previousValue, nextValue); } /** * Clones the element, including its reactive properties. * * @remarks * * An enhanced version of the default `cloneNode` method that * ensures that attribute values derived from reactive properties * are copied to the cloned element. */ cloneNode(deep) { // Perform the default cloneNode operation. const clone = super.cloneNode(deep); // Ensure that attribute values derived from reactive properties // are copied to the cloned element. // // The native `cloneNode` method copies attributes but not properties. // Since `KnytElement` synchronizes attribute values from properties // only after the element is connected to the DOM (to avoid errors in the constructor), // we must explicitly copy the attribute values from the original element // to the clone immediately after cloning. clone[__reactiveAdapter].setAttributeValues(this[__reactiveAdapter].getAttributeValues()); return clone; } /** * Enables hot module replacement (HMR) support for the element. * * @remarks * * This method is intended for compatibility with Open Web Components' HMR system. * It is invoked when the element is hot-replaced, allowing the element to refresh * or reinitialize itself without a full page reload during development. * * @see https://github.com/open-wc/open-wc/blob/master/docs/docs/development/hot-module-replacement.md * * @alpha This is an experimental API and WILL change in the future without notice. */ hotReplacedCallback() { this.requestUpdate(); } } applyLifecycleMixin(KnytElement, [ "addDelegate", "onBeforeMount", "onMounted", "onUpdateRequested", "onBeforeUpdate", "onAfterUpdate", "onUnmounted", "onErrorCaptured", "removeDelegate", ]); applyReactiveMixin(KnytElement, [ "getProp", "getProps", "observePropChange", "onPropChange", "refProp", "setProp", "setProps", ]); applyControllableMixin(KnytElement, [ "addController", "controlInput", "hold", "removeController", "requestUpdate", "track", "untrack", "updateComplete", "watch", "_getReactiveControllers", ]); export function isKnytElement(value) { return (isHTMLElement(value) && __isKnytElement in value && value[__isKnytElement] === true); } function normalizeStyleSheet(input, documentOrShadow) { if (isStyleSheet(input)) { return input; } if (isCSSStyleSheet(input)) { return css(input); } const $CSSStyleSheet = getCSSStyleSheetConstructor(documentOrShadow); const cssStyleSheet = input.toCSSStyleSheet($CSSStyleSheet); return css(cssStyleSheet); } function getKnytElementStaticMember(element, propertyName) { return getConstructorStaticMember(element, propertyName); } /** * Retrieve the reactive properties from the constructor of an element instance. * * @remarks * * This should be called only once for each class; NOT for each instance. * * @internal scope: package */ function getKnytElementProperties(elementInstance) { const result = getKnytElementStaticMember(elementInstance, "properties"); if (result instanceof Error) { return {}; } return result; } /** * @internal scope: package */ function getKnytElementStyleSheet(elementInstance) { const result = getKnytElementStaticMember(elementInstance, "styleSheet"); if (result instanceof Error) { return undefined; } return result; } function getRootForStyleSheetAdoptionController(rootElement) { return isShadowRoot(rootElement) ? rootElement : rootElement.ownerDocument; } function createStyleSheetAdoptionController(host, rootElement) { return new StyleSheetAdoptionController(host, { root: getRootForStyleSheetAdoptionController(rootElement), }); } /** * Either returns an existing declarative shadow root or creates a new one. * * @see {@link https://web.dev/articles/declarative-shadow-dom} * @internal scope: workspace */ function ensureShadowRoot(el, shadowRootInit) { const supportsDeclarative = Object.hasOwn(HTMLElement.prototype, "attachInternals"); const elementInternals = supportsDeclarative ? el.attachInternals() : undefined; const declarativeShadowRoot = elementInternals?.shadowRoot; if (declarativeShadowRoot?.mode === shadowRootInit.mode) { // Return the existing declarative shadow root if // the mode matches the requested mode. return { wasDeclarative: true, shadowRoot: declarativeShadowRoot, }; } return { wasDeclarative: false, // TODO: Add support for `disabledFeatures` // See https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#disabling_shadow_dom shadowRoot: el.attachShadow(shadowRootInit), }; } /** * Determines whether the element is a "Container" element, * from the given, unprepared options. * * @internal scope: package */ export function isContainerModeEnabled(options) { /** * Container mode was specifically requested. */ if (options.container) { return true; } /** * The Shadow DOM was requested to be disabled. */ const lightDomRequested = options.shadowRoot === false; /** * The render mode was either not specified or set to `transparent`. * The default render mode is `transparent`, so this should be `true` * if the `renderMode` option is not set. */ const hasTransparentRenderMode = options.renderMode === undefined || options.renderMode === RenderMode.Transparent; /** * Container mode is activated when the element does not have a shadow root * and the render mode is `transparent`. */ return lightDomRequested && hasTransparentRenderMode; } /** * Prepares the element options for a KnytElement. */ function prepareElementOptions(options) { if (isContainerModeEnabled(options)) { return { ...options, renderMode: RenderMode.Transparent, shadowRoot: false, updateMode: ReactiveUpdateMode.Manual, disableStylesheetSSR: true, htmx: false, container: true, }; } return options; }