UNPKG

@lit-labs/ssr-dom-shim

Version:

DOM shim for Lit Server Side Rendering (SSR)

582 lines 22.4 kB
/** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ import { ElementInternalsShim } from './lib/element-internals.js'; import { EventShim, CustomEventShim } from './lib/events.js'; export { ariaMixinAttributes, ElementInternals, HYDRATE_INTERNALS_ATTR_PREFIX, } from './lib/element-internals.js'; export { CSSRule, CSSRuleList, CSSStyleSheet, MediaList, StyleSheet, } from './lib/css.js'; export { CustomEvent, Event } from './lib/events.js'; export { IntersectionObserver, MutationObserver, ResizeObserver, } from './lib/observers.js'; // In an empty Node.js vm, we need to patch the global context. // TODO: Remove these globalThis assignments when we remove support // for vm modules (--experimental-vm-modules). globalThis.Event ??= EventShim; globalThis.CustomEvent ??= CustomEventShim; const constructionToken = Symbol(); const isCaptureEventListener = (options) => (typeof options === 'boolean' ? options : (options?.capture ?? false)); const enumerableProperty = { __proto__: null }; enumerableProperty.enumerable = true; Object.freeze(enumerableProperty); /** * This is a basic implementation of an EventTarget. * * This is not fully spec compliant (e.g. validation), * but should work well enough for our use cases. * * @see https://dom.spec.whatwg.org/#eventtarget * * Example Event Path * ------------------ * * Note that this depends on the logic in `packages/labs/ssr/src/lib/render-value.ts`. * Any element that is not a custom element or a slot element is skipped in the chain. * * <main> * <my-el1> * #shadow-dom (open) * <div> * <slot></slot> * <my-el2> * #shadow-dom (closed) * <slot></slot> * <event-dispatcher3></event-dispatcher3> * <slot name="nested"></slot> * </my-el2> * </div> * <event-dispatcher1></event-dispatcher1> * <event-dispatcher2 slot="nested"></event-dispatcher2> * </my-el1> * </main> * * Given the previous structure, the event path of this shim would be as follows, * for the given dispatcher with an event that bubbles (document-fragment * represents a ShadowRoot/#shadow-dom instance): * * <event-dispatcher1>: * [event-dispatcher1, slot{my-el1}, document-fragment{my-el1}, my-el1, document] * * <event-dispatcher2>: * [ * event-dispatcher2, * slot[name="nested"]{my-el1}, * slot{my-el2}, * document-fragment{my-el2}, * my-el2, * document-fragment{my-el1}, * my-el1, * document * ] * * <event-dispatcher3> (without composed): * [event-dispatcher3, document-fragment{my-el2}] * * <event-dispatcher3> (composed): * [ * event-dispatcher3, * document-fragment{my-el2}, * my-el2, * document-fragment{my-el1}, * my-el1, * document * ] */ class EventTarget { constructor() { this.__eventListeners = new Map(); this.__captureEventListeners = new Map(); } addEventListener(type, callback, options) { if (callback === undefined || callback === null) { return; } const eventListenersMap = isCaptureEventListener(options) ? this.__captureEventListeners : this.__eventListeners; let eventListeners = eventListenersMap.get(type); if (eventListeners === undefined) { eventListeners = new Map(); eventListenersMap.set(type, eventListeners); } else if (eventListeners.has(callback)) { return; } const normalizedOptions = typeof options === 'object' && options ? options : {}; normalizedOptions.signal?.addEventListener('abort', () => this.removeEventListener(type, callback, options)); eventListeners.set(callback, normalizedOptions ?? {}); } removeEventListener(type, callback, options) { if (callback === undefined || callback === null) { return; } const eventListenersMap = isCaptureEventListener(options) ? this.__captureEventListeners : this.__eventListeners; const eventListeners = eventListenersMap.get(type); if (eventListeners !== undefined) { eventListeners.delete(callback); if (!eventListeners.size) { eventListenersMap.delete(type); } } } dispatchEvent(event) { let composedPath = this.__resolveFullEventPath(); if (!event.composed && this.__host) { // If the event is not composed and the event was dispatched inside // shadow DOM, we need to stop the event chain before the host of the // shadow DOM. composedPath = composedPath.slice(0, composedPath.indexOf(this.__host)); } // We need to patch various properties that would either be empty or wrong // in this scenario. let stopPropagation = false; let stopImmediatePropagation = false; let eventPhase = EventShim.NONE; let target = null; let tmpTarget = null; let currentTarget = null; const originalStopPropagation = event.stopPropagation; const originalStopImmediatePropagation = event.stopImmediatePropagation; Object.defineProperties(event, { target: { get() { return target ?? tmpTarget; }, ...enumerableProperty, }, srcElement: { get() { return event.target; }, ...enumerableProperty, }, currentTarget: { get() { return currentTarget; }, ...enumerableProperty, }, eventPhase: { get() { return eventPhase; }, ...enumerableProperty, }, composedPath: { value: () => composedPath, ...enumerableProperty, }, stopPropagation: { value: () => { stopPropagation = true; originalStopPropagation.call(event); }, ...enumerableProperty, }, stopImmediatePropagation: { value: () => { stopImmediatePropagation = true; originalStopImmediatePropagation.call(event); }, ...enumerableProperty, }, }); // An event handler can either be a function, an object with a handleEvent // method or null. This function takes care to call the event handler // correctly. const invokeEventListener = (listener, options, eventListenerMap) => { if (typeof listener === 'function') { listener(event); } else if (typeof listener?.handleEvent === 'function') { listener.handleEvent(event); } if (options.once) { eventListenerMap.delete(listener); } }; // When an event is finished being dispatched, which can be after the event // tree has been traversed or stopPropagation/stopImmediatePropagation has // been called. Once that is the case, the currentTarget and eventPhase // need to be reset and a value, representing whether the event has not // been prevented, needs to be returned. const finishDispatch = () => { currentTarget = null; eventPhase = EventShim.NONE; return !event.defaultPrevented; }; // An event starts with the capture order, where it starts from the top. // This is done even if bubbles is set to false, which is the default. const captureEventPath = composedPath.slice().reverse(); // If the event target, which dispatches the event, is either in the light DOM // or the event is not composed, the target is always itself. If that is not // the case, the target needs to be retargeted: https://dom.spec.whatwg.org/#retarget target = !this.__host || !event.composed ? this : null; const retarget = (eventTargets) => { // eslint-disable-next-line @typescript-eslint/no-this-alias tmpTarget = this; while (tmpTarget.__host && eventTargets.includes(tmpTarget.__host)) { tmpTarget = tmpTarget.__host; } }; for (const eventTarget of captureEventPath) { if (!target && (!tmpTarget || tmpTarget === eventTarget.__host)) { retarget(captureEventPath.slice(captureEventPath.indexOf(eventTarget))); } currentTarget = eventTarget; eventPhase = eventTarget === event.target ? EventShim.AT_TARGET : EventShim.CAPTURING_PHASE; const captureEventListeners = eventTarget.__captureEventListeners.get(event.type); if (captureEventListeners) { for (const [listener, options] of captureEventListeners) { invokeEventListener(listener, options, captureEventListeners); if (stopImmediatePropagation) { // Event.stopImmediatePropagation() stops any following invocation // of an event handler even on the same event target. return finishDispatch(); } } } if (stopPropagation) { // Event.stopPropagation() stops any following invocation // of an event handler for any following event targets. return finishDispatch(); } } const bubbleEventPath = event.bubbles ? composedPath : [this]; tmpTarget = null; for (const eventTarget of bubbleEventPath) { if (!target && (!tmpTarget || eventTarget === tmpTarget.__host)) { retarget(bubbleEventPath.slice(0, bubbleEventPath.indexOf(eventTarget) + 1)); } currentTarget = eventTarget; eventPhase = eventTarget === event.target ? EventShim.AT_TARGET : EventShim.BUBBLING_PHASE; const eventListeners = eventTarget.__eventListeners.get(event.type); if (eventListeners) { for (const [listener, options] of eventListeners) { invokeEventListener(listener, options, eventListeners); if (stopImmediatePropagation) { // Event.stopImmediatePropagation() stops any following invocation // of an event handler even on the same event target. return finishDispatch(); } } } if (stopPropagation) { // Event.stopPropagation() stops any following invocation // of an event handler for any following event targets. return finishDispatch(); } } return finishDispatch(); } __resolveFullEventPath() { if (this.__eventPathCache) { return this.__eventPathCache; } else if (!this.__eventTargetParent) { return (this.__eventPathCache = [this, documentShim, windowShim]); } else { return (this.__eventPathCache = [ this, ...this.__eventTargetParent.__resolveFullEventPath(), ]); } } } const EventTargetShimWithRealType = EventTarget; export { EventTargetShimWithRealType as EventTarget }; const attributes = new WeakMap(); const attributesForElement = (element) => { let attrs = attributes.get(element); if (attrs === undefined) { attributes.set(element, (attrs = new Map())); } return attrs; }; // The typings around the exports below are a little funky: // // 1. We want the `name` of the shim classes to match the real ones at runtime, // hence e.g. `class Element`. // 2. We can't shadow the global types with a simple class declaration, because // then we can't reference the global types for casting, hence e.g. // `const ElementShim = class Element`. // 3. We want to export the classes typed as the real ones, hence e.g. // `const ElementShimWithRealType = ElementShim as object as typeof Element;`. // 4. We want the exported names to match the real ones, hence e.g. // `export {ElementShimWithRealType as Element}`. const NodeShim = class Node extends EventTarget { getRootNode(options) { if (options?.composed) { return document; } // getRootNode returns the containing ShadowRoot instance, even if that was // created in closed mode. const host = this.__host; return (host?.__shadowRoot ?? document); } }; const NodeShimWithRealType = NodeShim; export { NodeShimWithRealType as Node }; const DocumentShim = class Document extends NodeShim { get adoptedStyleSheets() { return []; } createTreeWalker() { return {}; } createTextNode() { return {}; } createElement() { return {}; } }; const DocumentShimWithRealType = DocumentShim; export { DocumentShimWithRealType as Document }; const documentShim = new DocumentShim(); const document = documentShim; export { document }; const WindowShim = class Window extends NodeShim { constructor(token) { super(); if (token !== constructionToken) { throw new TypeError('Illegal constructor'); } Object.assign(this, globalThis, { CustomElementRegistry, customElements, document, Document: DocumentShim, Element: ElementShim, EventTarget, HTMLElement: HTMLElementShim, Node: NodeShim, ShadowRoot: ShadowRootShim, window: this, Window: WindowShim, }); } }; const WindowShimWithRealType = WindowShim; export { WindowShimWithRealType as Window }; const ElementShim = class Element extends NodeShim { constructor() { super(...arguments); this.__shadowRootMode = null; this.__shadowRoot = null; this.__internals = null; } get attributes() { return Array.from(attributesForElement(this)).map(([name, value]) => ({ name, value, })); } get shadowRoot() { if (this.__shadowRootMode === 'closed') { return null; } return this.__shadowRoot; } get localName() { return this.constructor.__localName; } get tagName() { return this.localName?.toUpperCase(); } setAttribute(name, value) { // Emulate browser behavior that silently casts all values to string. E.g. // `42` becomes `"42"` and `{}` becomes `"[object Object]""`. attributesForElement(this).set(name, String(value)); } removeAttribute(name) { attributesForElement(this).delete(name); } toggleAttribute(name, force) { // Steps reference https://dom.spec.whatwg.org/#dom-element-toggleattribute if (this.hasAttribute(name)) { // Step 5 if (force === undefined || !force) { this.removeAttribute(name); return false; } } else { // Step 4 if (force === undefined || force) { // Step 4.1 this.setAttribute(name, ''); return true; } else { // Step 4.2 return false; } } // Step 6 return true; } hasAttribute(name) { return attributesForElement(this).has(name); } attachShadow(init) { this.__shadowRootMode = init.mode; const shadowRoot = new ShadowRootShim(constructionToken, init); shadowRoot.__eventTargetParent = this; shadowRoot.__host = this; return (this.__shadowRoot = shadowRoot); } attachInternals() { if (this.__internals !== null) { throw new Error(`Failed to execute 'attachInternals' on 'HTMLElement': ` + `ElementInternals for the specified element was already attached.`); } const internals = new ElementInternalsShim(this); this.__internals = internals; return internals; } getAttribute(name) { const value = attributesForElement(this).get(name); return value ?? null; } }; const ElementShimWithRealType = ElementShim; export { ElementShimWithRealType as Element }; const HTMLElementShim = class HTMLElement extends ElementShim { }; const HTMLElementShimWithRealType = HTMLElementShim; export { HTMLElementShimWithRealType as HTMLElement }; const HTMLSlotElementShim = class HTMLSlotElement extends HTMLElementShim { get localName() { return 'slot'; } }; const HTMLSlotElementShimWithRealType = HTMLSlotElementShim; export { HTMLSlotElementShimWithRealType as HTMLSlotElement }; const ShadowRootShim = class ShadowRoot extends NodeShim { get host() { return this.__host; } constructor(constructionToken, init) { super(); if (constructionToken !== constructionToken) { throw new TypeError('Illegal constructor'); } this.mode = init.mode; } }; const ShadowRootShimWithRealType = ShadowRootShim; export { ShadowRootShimWithRealType as ShadowRoot }; // For convenience, we provide a global instance of a HTMLElement as an event // target. This facilitates registering global event handlers // (e.g. for @lit/context ContextProvider). // We use this in in the SSR render function. // Note, this is a bespoke element and not simply `document` or `window` since // user code relies on these being undefined in the server environment. globalThis.litServerRoot ??= Object.defineProperty(new HTMLElementShimWithRealType(), 'localName', { // Patch localName (and tagName) to return a unique name. get() { return 'lit-server-root'; }, }); function promiseWithResolvers() { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve: resolve, reject: reject }; } class CustomElementRegistry { constructor() { this.__definitions = new Map(); this.__reverseDefinitions = new Map(); this.__pendingWhenDefineds = new Map(); } define(name, ctor) { if (this.__definitions.has(name)) { if (process.env.NODE_ENV === 'development') { console.warn(`'CustomElementRegistry' already has "${name}" defined. ` + `This may have been caused by live reload or hot module ` + `replacement in which case it can be safely ignored.\n` + `Make sure to test your application with a production build as ` + `repeat registrations will throw in production.`); } else { throw new Error(`Failed to execute 'define' on 'CustomElementRegistry': ` + `the name "${name}" has already been used with this registry`); } } if (this.__reverseDefinitions.has(ctor)) { throw new Error(`Failed to execute 'define' on 'CustomElementRegistry': ` + `the constructor has already been used with this registry for the ` + `tag name ${this.__reverseDefinitions.get(ctor)}`); } // Provide tagName and localName for the component. ctor.__localName = name; this.__definitions.set(name, { ctor, // Note it's important we read `observedAttributes` in case it is a getter // with side-effects, as is the case in Lit, where it triggers class // finalization. // // TODO(aomarks) To be spec compliant, we should also capture the // registration-time lifecycle methods like `connectedCallback`. For them // to be actually accessible to e.g. the Lit SSR element renderer, though, // we'd need to introduce a new API for accessing them (since `get` only // returns the constructor). observedAttributes: ctor.observedAttributes ?? [], }); this.__reverseDefinitions.set(ctor, name); this.__pendingWhenDefineds.get(name)?.resolve(ctor); this.__pendingWhenDefineds.delete(name); } get(name) { const definition = this.__definitions.get(name); return definition?.ctor; } getName(ctor) { return this.__reverseDefinitions.get(ctor) ?? null; } initialize(_root) { throw new Error(`customElements.initialize is not currently supported in SSR. ` + `Please file a bug if you need it.`); } upgrade(_element) { // In SSR this doesn't make a lot of sense, so we do nothing. throw new Error(`customElements.upgrade is not currently supported in SSR. ` + `Please file a bug if you need it.`); } async whenDefined(name) { const definition = this.__definitions.get(name); if (definition) { return definition.ctor; } let withResolvers = this.__pendingWhenDefineds.get(name); if (!withResolvers) { withResolvers = promiseWithResolvers(); this.__pendingWhenDefineds.set(name, withResolvers); } return withResolvers.promise; } } const CustomElementRegistryShimWithRealType = CustomElementRegistry; export { CustomElementRegistryShimWithRealType as CustomElementRegistry }; export const customElements = new CustomElementRegistryShimWithRealType(); // The window variable instantiation must happen after all shims // have been declared, as they will be included in the window instance. const windowShim = new WindowShim(constructionToken); const window = windowShim; export { window }; //# sourceMappingURL=index.js.map