diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index f3c454fd095..e098f8eb762 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1261,6 +1261,10 @@ export interface EventEmitterData { composed?: boolean; } +/** + * An interface extending `HTMLElement` which describes the fields added onto + * host HTML elements by the Stencil runtime. + */ export interface HostElement extends HTMLElement { // web component APIs connectedCallback?: () => void; @@ -1755,12 +1759,18 @@ export interface PlatformRuntime { $nonce$?: string | null; jmp: (c: Function) => any; raf: (c: FrameRequestCallback) => number; + /** + * A wrapper for AddEventListener + */ ael: ( el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions, ) => void; + /** + * A wrapper for `RemoveEventListener` + */ rel: ( el: EventTarget, eventName: string, diff --git a/src/declarations/stencil-public-runtime.ts b/src/declarations/stencil-public-runtime.ts index 662e13164a6..d3d2c09a581 100644 --- a/src/declarations/stencil-public-runtime.ts +++ b/src/declarations/stencil-public-runtime.ts @@ -499,7 +499,7 @@ export interface QueueApi { /** * Host */ -interface HostAttributes { +export interface HostAttributes { class?: string | { [className: string]: boolean }; style?: { [key: string]: string | undefined }; ref?: (el: HTMLElement | null) => void; diff --git a/src/runtime/update-component.ts b/src/runtime/update-component.ts index d3d1f17e107..a3b3b39a3d1 100644 --- a/src/runtime/update-component.ts +++ b/src/runtime/update-component.ts @@ -134,7 +134,21 @@ const isPromisey = (maybePromise: Promise | unknown): maybePromise is Prom maybePromise instanceof Promise || (maybePromise && (maybePromise as any).then && typeof (maybePromise as Promise).then === 'function'); -const updateComponent = async (hostRef: d.HostRef, instance: any, isInitialLoad: boolean) => { +/** + * Update a component given reference to its host elements and so on. + * + * @param hostRef an object containing references to the element's host node, + * VDom nodes, and other metadata + * @param instance a reference to the underlying host element where it will be + * rendered + * @param isInitialLoad whether or not this function is being called as part of + * the first render cycle + */ +const updateComponent = async ( + hostRef: d.HostRef, + instance: d.HostElement | d.ComponentInterface, + isInitialLoad: boolean, +) => { const elm = hostRef.$hostElement$ as d.RenderNode; const endUpdate = createTime('update', hostRef.$cmpMeta$.$tagName$); const rc = elm['s-rc']; @@ -149,9 +163,9 @@ const updateComponent = async (hostRef: d.HostRef, instance: any, isInitialLoad: } if (BUILD.hydrateServerSide) { - await callRender(hostRef, instance, elm); + await callRender(hostRef, instance, elm, isInitialLoad); } else { - callRender(hostRef, instance, elm); + callRender(hostRef, instance, elm, isInitialLoad); } if (BUILD.isDev) { @@ -205,7 +219,19 @@ const updateComponent = async (hostRef: d.HostRef, instance: any, isInitialLoad: let renderingRef: any = null; -const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement) => { +/** + * Handle making the call to the VDom renderer with the proper context given + * various build variables + * + * @param hostRef an object containing references to the element's host node, + * VDom nodes, and other metadata + * @param instance a reference to the underlying host element where it will be + * rendered + * @param elm the Host element for the component + * @param isInitialLoad whether or not this function is being called as part of + * @returns an empty promise + */ +const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement, isInitialLoad: boolean) => { // in order for bundlers to correctly treeshake the BUILD object // we need to ensure BUILD is not deoptimized within a try/catch // https://rollupjs.org/guide/en/#treeshake tryCatchDeoptimization @@ -231,9 +257,9 @@ const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement) => { // or we need to update the css class/attrs on the host element // DOM WRITE! if (BUILD.hydrateServerSide) { - return Promise.resolve(instance).then((value) => renderVdom(hostRef, value)); + return Promise.resolve(instance).then((value) => renderVdom(hostRef, value, isInitialLoad)); } else { - renderVdom(hostRef, instance); + renderVdom(hostRef, instance, isInitialLoad); } } else { elm.textContent = instance; diff --git a/src/runtime/vdom/set-accessor.ts b/src/runtime/vdom/set-accessor.ts index 4728895d49c..8fd9e0e2e67 100644 --- a/src/runtime/vdom/set-accessor.ts +++ b/src/runtime/vdom/set-accessor.ts @@ -13,6 +13,21 @@ import { isComplexType } from '@utils'; import { VNODE_FLAGS, XLINK_NS } from '../runtime-constants'; +/** + * When running a VDom render set properties present on a VDom node onto the + * corresponding HTML element. + * + * Note that this function has special functionality for the `class`, + * `style`, `key`, and `ref` attributes, as well as event handlers (like + * `onClick`, etc). All others are just passed through as-is. + * + * @param elm the HTMLElement onto which attributes should be set + * @param memberName the name of the attribute to set + * @param oldValue the old value for the attribute + * @param newValue the new value for the attribute + * @param isSvg whether we're in an svg context or not + * @param flags bitflags for Vdom variables + */ export const setAccessor = ( elm: HTMLElement, memberName: string, diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index fd8a8203a06..ef2f5dcbafd 100644 --- a/src/runtime/vdom/vdom-render.ts +++ b/src/runtime/vdom/vdom-render.ts @@ -809,11 +809,18 @@ interface RelocateNodeData { * @param hostRef data needed to root and render the virtual DOM tree, such as * the DOM node into which it should be rendered. * @param renderFnResults the virtual DOM nodes to be rendered + * @param isInitialLoad whether or not this is the first call after page load */ -export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNode[]) => { +export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNode[], isInitialLoad = false) => { const hostElm = hostRef.$hostElement$; const cmpMeta = hostRef.$cmpMeta$; const oldVNode: d.VNode = hostRef.$vnode$ || newVNode(null, null); + + // if `renderFnResults` is a Host node then we can use it directly. If not, + // we need to call `h` again to wrap the children of our component in a + // 'dummy' Host node (well, an empty vnode) since `renderVdom` assumes + // implicitly that the top-level vdom node is 1) an only child and 2) + // contains attrs that need to be set on the host element. const rootVnode = isHost(renderFnResults) ? renderFnResults : h(null, null, renderFnResults as any); hostTagName = hostElm.tagName; @@ -840,6 +847,28 @@ render() { ); } + // On the first render and *only* on the first render we want to check for + // any attributes set on the host element which are also set on the vdom + // node. If we find them, we override the value on the VDom node attrs with + // the value from the host element, which allows developers building apps + // with Stencil components to override e.g. the `role` attribute on a + // component even if it's already set on the `Host`. + if (isInitialLoad && rootVnode.$attrs$) { + for (const key of Object.keys(rootVnode.$attrs$)) { + // We have a special implementation in `setAccessor` for `style` and + // `class` which reconciles values coming from the VDom with values + // already present on the DOM element, so we don't want to override those + // attributes on the VDom tree with values from the host element if they + // are present. + // + // Likewise, `ref` and `key` are special internal values for the Stencil + // runtime and we don't want to override those either. + if (hostElm.hasAttribute(key) && !['key', 'ref', 'style', 'class'].includes(key)) { + rootVnode.$attrs$[key] = hostElm[key as keyof d.HostElement]; + } + } + } + rootVnode.$tag$ = null; rootVnode.$flags$ |= VNODE_FLAGS.isHost; hostRef.$vnode$ = rootVnode; diff --git a/test/karma/test-app/components.d.ts b/test/karma/test-app/components.d.ts index 7732f5200b4..72ec5a39857 100644 --- a/test/karma/test-app/components.d.ts +++ b/test/karma/test-app/components.d.ts @@ -124,6 +124,8 @@ export namespace Components { } interface FactoryJsx { } + interface HostAttrOverride { + } interface ImageImport { } interface InitCssRoot { @@ -626,6 +628,12 @@ declare global { prototype: HTMLFactoryJsxElement; new (): HTMLFactoryJsxElement; }; + interface HTMLHostAttrOverrideElement extends Components.HostAttrOverride, HTMLStencilElement { + } + var HTMLHostAttrOverrideElement: { + prototype: HTMLHostAttrOverrideElement; + new (): HTMLHostAttrOverrideElement; + }; interface HTMLImageImportElement extends Components.ImageImport, HTMLStencilElement { } var HTMLImageImportElement: { @@ -1210,6 +1218,7 @@ declare global { "external-import-b": HTMLExternalImportBElement; "external-import-c": HTMLExternalImportCElement; "factory-jsx": HTMLFactoryJsxElement; + "host-attr-override": HTMLHostAttrOverrideElement; "image-import": HTMLImageImportElement; "init-css-root": HTMLInitCssRootElement; "input-basic-root": HTMLInputBasicRootElement; @@ -1416,6 +1425,8 @@ declare namespace LocalJSX { } interface FactoryJsx { } + interface HostAttrOverride { + } interface ImageImport { } interface InitCssRoot { @@ -1682,6 +1693,7 @@ declare namespace LocalJSX { "external-import-b": ExternalImportB; "external-import-c": ExternalImportC; "factory-jsx": FactoryJsx; + "host-attr-override": HostAttrOverride; "image-import": ImageImport; "init-css-root": InitCssRoot; "input-basic-root": InputBasicRoot; @@ -1821,6 +1833,7 @@ declare module "@stencil/core" { "external-import-b": LocalJSX.ExternalImportB & JSXBase.HTMLAttributes; "external-import-c": LocalJSX.ExternalImportC & JSXBase.HTMLAttributes; "factory-jsx": LocalJSX.FactoryJsx & JSXBase.HTMLAttributes; + "host-attr-override": LocalJSX.HostAttrOverride & JSXBase.HTMLAttributes; "image-import": LocalJSX.ImageImport & JSXBase.HTMLAttributes; "init-css-root": LocalJSX.InitCssRoot & JSXBase.HTMLAttributes; "input-basic-root": LocalJSX.InputBasicRoot & JSXBase.HTMLAttributes; diff --git a/test/karma/test-app/host-attr-override/host-attr-override.tsx b/test/karma/test-app/host-attr-override/host-attr-override.tsx new file mode 100644 index 00000000000..510ed2ab586 --- /dev/null +++ b/test/karma/test-app/host-attr-override/host-attr-override.tsx @@ -0,0 +1,15 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'host-attr-override', + shadow: true, +}) +export class HostAttrOverride { + render() { + return ( + + + + ); + } +} diff --git a/test/karma/test-app/host-attr-override/index.html b/test/karma/test-app/host-attr-override/index.html new file mode 100644 index 00000000000..3cb0b59f26b --- /dev/null +++ b/test/karma/test-app/host-attr-override/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/karma/test-app/host-attr-override/karma.spec.ts b/test/karma/test-app/host-attr-override/karma.spec.ts new file mode 100644 index 00000000000..e3a08c584c6 --- /dev/null +++ b/test/karma/test-app/host-attr-override/karma.spec.ts @@ -0,0 +1,19 @@ +import { setupDomTests } from '../util'; + +describe('host attribute overrides', function () { + const { setupDom, tearDownDom } = setupDomTests(document); + let app: HTMLElement; + + beforeEach(async () => { + app = await setupDom('/host-attr-override/index.html'); + }); + afterEach(tearDownDom); + + it('should merge class set in HTML with that on the Host', async () => { + expect(app.querySelector('.default.override')).not.toBeNull(); + }); + + it('should override non-class attributes', () => { + expect(app.querySelector('.with-role').getAttribute('role')).toBe('another-role'); + }); +});