Skip to content

Commit b088b9e

Browse files
fix(runtime): override attrs set on Host with values from host element (#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.
1 parent 6484699 commit b088b9e

9 files changed

Lines changed: 142 additions & 8 deletions

File tree

src/declarations/stencil-private.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,10 @@ export interface EventEmitterData<T = any> {
12611261
composed?: boolean;
12621262
}
12631263

1264+
/**
1265+
* An interface extending `HTMLElement` which describes the fields added onto
1266+
* host HTML elements by the Stencil runtime.
1267+
*/
12641268
export interface HostElement extends HTMLElement {
12651269
// web component APIs
12661270
connectedCallback?: () => void;
@@ -1755,12 +1759,18 @@ export interface PlatformRuntime {
17551759
$nonce$?: string | null;
17561760
jmp: (c: Function) => any;
17571761
raf: (c: FrameRequestCallback) => number;
1762+
/**
1763+
* A wrapper for AddEventListener
1764+
*/
17581765
ael: (
17591766
el: EventTarget,
17601767
eventName: string,
17611768
listener: EventListenerOrEventListenerObject,
17621769
options: boolean | AddEventListenerOptions,
17631770
) => void;
1771+
/**
1772+
* A wrapper for `RemoveEventListener`
1773+
*/
17641774
rel: (
17651775
el: EventTarget,
17661776
eventName: string,

src/declarations/stencil-public-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ export interface QueueApi {
499499
/**
500500
* Host
501501
*/
502-
interface HostAttributes {
502+
export interface HostAttributes {
503503
class?: string | { [className: string]: boolean };
504504
style?: { [key: string]: string | undefined };
505505
ref?: (el: HTMLElement | null) => void;

src/runtime/update-component.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,21 @@ const isPromisey = (maybePromise: Promise<void> | unknown): maybePromise is Prom
134134
maybePromise instanceof Promise ||
135135
(maybePromise && (maybePromise as any).then && typeof (maybePromise as Promise<void>).then === 'function');
136136

137-
const updateComponent = async (hostRef: d.HostRef, instance: any, isInitialLoad: boolean) => {
137+
/**
138+
* Update a component given reference to its host elements and so on.
139+
*
140+
* @param hostRef an object containing references to the element's host node,
141+
* VDom nodes, and other metadata
142+
* @param instance a reference to the underlying host element where it will be
143+
* rendered
144+
* @param isInitialLoad whether or not this function is being called as part of
145+
* the first render cycle
146+
*/
147+
const updateComponent = async (
148+
hostRef: d.HostRef,
149+
instance: d.HostElement | d.ComponentInterface,
150+
isInitialLoad: boolean,
151+
) => {
138152
const elm = hostRef.$hostElement$ as d.RenderNode;
139153
const endUpdate = createTime('update', hostRef.$cmpMeta$.$tagName$);
140154
const rc = elm['s-rc'];
@@ -149,9 +163,9 @@ const updateComponent = async (hostRef: d.HostRef, instance: any, isInitialLoad:
149163
}
150164

151165
if (BUILD.hydrateServerSide) {
152-
await callRender(hostRef, instance, elm);
166+
await callRender(hostRef, instance, elm, isInitialLoad);
153167
} else {
154-
callRender(hostRef, instance, elm);
168+
callRender(hostRef, instance, elm, isInitialLoad);
155169
}
156170

157171
if (BUILD.isDev) {
@@ -205,7 +219,19 @@ const updateComponent = async (hostRef: d.HostRef, instance: any, isInitialLoad:
205219

206220
let renderingRef: any = null;
207221

208-
const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement) => {
222+
/**
223+
* Handle making the call to the VDom renderer with the proper context given
224+
* various build variables
225+
*
226+
* @param hostRef an object containing references to the element's host node,
227+
* VDom nodes, and other metadata
228+
* @param instance a reference to the underlying host element where it will be
229+
* rendered
230+
* @param elm the Host element for the component
231+
* @param isInitialLoad whether or not this function is being called as part of
232+
* @returns an empty promise
233+
*/
234+
const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement, isInitialLoad: boolean) => {
209235
// in order for bundlers to correctly treeshake the BUILD object
210236
// we need to ensure BUILD is not deoptimized within a try/catch
211237
// https://rollupjs.org/guide/en/#treeshake tryCatchDeoptimization
@@ -231,9 +257,9 @@ const callRender = (hostRef: d.HostRef, instance: any, elm: HTMLElement) => {
231257
// or we need to update the css class/attrs on the host element
232258
// DOM WRITE!
233259
if (BUILD.hydrateServerSide) {
234-
return Promise.resolve(instance).then((value) => renderVdom(hostRef, value));
260+
return Promise.resolve(instance).then((value) => renderVdom(hostRef, value, isInitialLoad));
235261
} else {
236-
renderVdom(hostRef, instance);
262+
renderVdom(hostRef, instance, isInitialLoad);
237263
}
238264
} else {
239265
elm.textContent = instance;

src/runtime/vdom/set-accessor.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ import { isComplexType } from '@utils';
1313

1414
import { VNODE_FLAGS, XLINK_NS } from '../runtime-constants';
1515

16+
/**
17+
* When running a VDom render set properties present on a VDom node onto the
18+
* corresponding HTML element.
19+
*
20+
* Note that this function has special functionality for the `class`,
21+
* `style`, `key`, and `ref` attributes, as well as event handlers (like
22+
* `onClick`, etc). All others are just passed through as-is.
23+
*
24+
* @param elm the HTMLElement onto which attributes should be set
25+
* @param memberName the name of the attribute to set
26+
* @param oldValue the old value for the attribute
27+
* @param newValue the new value for the attribute
28+
* @param isSvg whether we're in an svg context or not
29+
* @param flags bitflags for Vdom variables
30+
*/
1631
export const setAccessor = (
1732
elm: HTMLElement,
1833
memberName: string,

src/runtime/vdom/vdom-render.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -809,11 +809,18 @@ interface RelocateNodeData {
809809
* @param hostRef data needed to root and render the virtual DOM tree, such as
810810
* the DOM node into which it should be rendered.
811811
* @param renderFnResults the virtual DOM nodes to be rendered
812+
* @param isInitialLoad whether or not this is the first call after page load
812813
*/
813-
export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNode[]) => {
814+
export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNode[], isInitialLoad = false) => {
814815
const hostElm = hostRef.$hostElement$;
815816
const cmpMeta = hostRef.$cmpMeta$;
816817
const oldVNode: d.VNode = hostRef.$vnode$ || newVNode(null, null);
818+
819+
// if `renderFnResults` is a Host node then we can use it directly. If not,
820+
// we need to call `h` again to wrap the children of our component in a
821+
// 'dummy' Host node (well, an empty vnode) since `renderVdom` assumes
822+
// implicitly that the top-level vdom node is 1) an only child and 2)
823+
// contains attrs that need to be set on the host element.
817824
const rootVnode = isHost(renderFnResults) ? renderFnResults : h(null, null, renderFnResults as any);
818825

819826
hostTagName = hostElm.tagName;
@@ -840,6 +847,28 @@ render() {
840847
);
841848
}
842849

850+
// On the first render and *only* on the first render we want to check for
851+
// any attributes set on the host element which are also set on the vdom
852+
// node. If we find them, we override the value on the VDom node attrs with
853+
// the value from the host element, which allows developers building apps
854+
// with Stencil components to override e.g. the `role` attribute on a
855+
// component even if it's already set on the `Host`.
856+
if (isInitialLoad && rootVnode.$attrs$) {
857+
for (const key of Object.keys(rootVnode.$attrs$)) {
858+
// We have a special implementation in `setAccessor` for `style` and
859+
// `class` which reconciles values coming from the VDom with values
860+
// already present on the DOM element, so we don't want to override those
861+
// attributes on the VDom tree with values from the host element if they
862+
// are present.
863+
//
864+
// Likewise, `ref` and `key` are special internal values for the Stencil
865+
// runtime and we don't want to override those either.
866+
if (hostElm.hasAttribute(key) && !['key', 'ref', 'style', 'class'].includes(key)) {
867+
rootVnode.$attrs$[key] = hostElm[key as keyof d.HostElement];
868+
}
869+
}
870+
}
871+
843872
rootVnode.$tag$ = null;
844873
rootVnode.$flags$ |= VNODE_FLAGS.isHost;
845874
hostRef.$vnode$ = rootVnode;

test/karma/test-app/components.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export namespace Components {
124124
}
125125
interface FactoryJsx {
126126
}
127+
interface HostAttrOverride {
128+
}
127129
interface ImageImport {
128130
}
129131
interface InitCssRoot {
@@ -626,6 +628,12 @@ declare global {
626628
prototype: HTMLFactoryJsxElement;
627629
new (): HTMLFactoryJsxElement;
628630
};
631+
interface HTMLHostAttrOverrideElement extends Components.HostAttrOverride, HTMLStencilElement {
632+
}
633+
var HTMLHostAttrOverrideElement: {
634+
prototype: HTMLHostAttrOverrideElement;
635+
new (): HTMLHostAttrOverrideElement;
636+
};
629637
interface HTMLImageImportElement extends Components.ImageImport, HTMLStencilElement {
630638
}
631639
var HTMLImageImportElement: {
@@ -1210,6 +1218,7 @@ declare global {
12101218
"external-import-b": HTMLExternalImportBElement;
12111219
"external-import-c": HTMLExternalImportCElement;
12121220
"factory-jsx": HTMLFactoryJsxElement;
1221+
"host-attr-override": HTMLHostAttrOverrideElement;
12131222
"image-import": HTMLImageImportElement;
12141223
"init-css-root": HTMLInitCssRootElement;
12151224
"input-basic-root": HTMLInputBasicRootElement;
@@ -1416,6 +1425,8 @@ declare namespace LocalJSX {
14161425
}
14171426
interface FactoryJsx {
14181427
}
1428+
interface HostAttrOverride {
1429+
}
14191430
interface ImageImport {
14201431
}
14211432
interface InitCssRoot {
@@ -1682,6 +1693,7 @@ declare namespace LocalJSX {
16821693
"external-import-b": ExternalImportB;
16831694
"external-import-c": ExternalImportC;
16841695
"factory-jsx": FactoryJsx;
1696+
"host-attr-override": HostAttrOverride;
16851697
"image-import": ImageImport;
16861698
"init-css-root": InitCssRoot;
16871699
"input-basic-root": InputBasicRoot;
@@ -1821,6 +1833,7 @@ declare module "@stencil/core" {
18211833
"external-import-b": LocalJSX.ExternalImportB & JSXBase.HTMLAttributes<HTMLExternalImportBElement>;
18221834
"external-import-c": LocalJSX.ExternalImportC & JSXBase.HTMLAttributes<HTMLExternalImportCElement>;
18231835
"factory-jsx": LocalJSX.FactoryJsx & JSXBase.HTMLAttributes<HTMLFactoryJsxElement>;
1836+
"host-attr-override": LocalJSX.HostAttrOverride & JSXBase.HTMLAttributes<HTMLHostAttrOverrideElement>;
18241837
"image-import": LocalJSX.ImageImport & JSXBase.HTMLAttributes<HTMLImageImportElement>;
18251838
"init-css-root": LocalJSX.InitCssRoot & JSXBase.HTMLAttributes<HTMLInitCssRootElement>;
18261839
"input-basic-root": LocalJSX.InputBasicRoot & JSXBase.HTMLAttributes<HTMLInputBasicRootElement>;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Component, Host, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'host-attr-override',
5+
shadow: true,
6+
})
7+
export class HostAttrOverride {
8+
render() {
9+
return (
10+
<Host class="default" role="header">
11+
<slot></slot>
12+
</Host>
13+
);
14+
}
15+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf8">
3+
<script src="./build/testapp.esm.js" type="module"></script>
4+
<script src="./build/testapp.js" nomodule></script>
5+
6+
<host-attr-override class="override"></host-attr-override>
7+
<host-attr-override class="with-role" role="another-role"></host-attr-override>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { setupDomTests } from '../util';
2+
3+
describe('host attribute overrides', function () {
4+
const { setupDom, tearDownDom } = setupDomTests(document);
5+
let app: HTMLElement;
6+
7+
beforeEach(async () => {
8+
app = await setupDom('/host-attr-override/index.html');
9+
});
10+
afterEach(tearDownDom);
11+
12+
it('should merge class set in HTML with that on the Host', async () => {
13+
expect(app.querySelector('.default.override')).not.toBeNull();
14+
});
15+
16+
it('should override non-class attributes', () => {
17+
expect(app.querySelector('.with-role').getAttribute('role')).toBe('another-role');
18+
});
19+
});

0 commit comments

Comments
 (0)