@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
JavaScript
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 = `:${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;
}