Skip to content

Commit

Permalink
fix(runtime): override attrs set on Host with values from host element (
Browse files Browse the repository at this point in the history
#4548)

This adds a runtime check for attributes set on the host element
instance ('from outside') before the attributes set on the `Host`
component ('from inside') are set on it. This allows developers to, for
instance, override a `role` attribute set on `Host` (see #3052) or to
accomplish something similar for any other attribute.
  • Loading branch information
alicewriteswrongs committed Jul 31, 2023
1 parent 6484699 commit b088b9e
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 8 deletions.
10 changes: 10 additions & 0 deletions src/declarations/stencil-private.ts
Expand Up @@ -1261,6 +1261,10 @@ export interface EventEmitterData<T = any> {
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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/declarations/stencil-public-runtime.ts
Expand Up @@ -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;
Expand Down
38 changes: 32 additions & 6 deletions src/runtime/update-component.ts
Expand Up @@ -134,7 +134,21 @@ const isPromisey = (maybePromise: Promise<void> | unknown): maybePromise is Prom
maybePromise instanceof Promise ||
(maybePromise && (maybePromise as any).then && typeof (maybePromise as Promise<void>).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'];
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/runtime/vdom/set-accessor.ts
Expand Up @@ -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,
Expand Down
31 changes: 30 additions & 1 deletion src/runtime/vdom/vdom-render.ts
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions test/karma/test-app/components.d.ts
Expand Up @@ -124,6 +124,8 @@ export namespace Components {
}
interface FactoryJsx {
}
interface HostAttrOverride {
}
interface ImageImport {
}
interface InitCssRoot {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1416,6 +1425,8 @@ declare namespace LocalJSX {
}
interface FactoryJsx {
}
interface HostAttrOverride {
}
interface ImageImport {
}
interface InitCssRoot {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1821,6 +1833,7 @@ declare module "@stencil/core" {
"external-import-b": LocalJSX.ExternalImportB & JSXBase.HTMLAttributes<HTMLExternalImportBElement>;
"external-import-c": LocalJSX.ExternalImportC & JSXBase.HTMLAttributes<HTMLExternalImportCElement>;
"factory-jsx": LocalJSX.FactoryJsx & JSXBase.HTMLAttributes<HTMLFactoryJsxElement>;
"host-attr-override": LocalJSX.HostAttrOverride & JSXBase.HTMLAttributes<HTMLHostAttrOverrideElement>;
"image-import": LocalJSX.ImageImport & JSXBase.HTMLAttributes<HTMLImageImportElement>;
"init-css-root": LocalJSX.InitCssRoot & JSXBase.HTMLAttributes<HTMLInitCssRootElement>;
"input-basic-root": LocalJSX.InputBasicRoot & JSXBase.HTMLAttributes<HTMLInputBasicRootElement>;
Expand Down
15 changes: 15 additions & 0 deletions 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 (
<Host class="default" role="header">
<slot></slot>
</Host>
);
}
}
7 changes: 7 additions & 0 deletions test/karma/test-app/host-attr-override/index.html
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<meta charset="utf8">
<script src="./build/testapp.esm.js" type="module"></script>
<script src="./build/testapp.js" nomodule></script>

<host-attr-override class="override"></host-attr-override>
<host-attr-override class="with-role" role="another-role"></host-attr-override>
19 changes: 19 additions & 0 deletions 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');
});
});

0 comments on commit b088b9e

Please sign in to comment.