helix-home
helix-home copied to clipboard
Custom Elements for Helix Blocks
Overview
Should we change the Markup generated by helix-pipeline-service for blocks (Tables in Word/GDocs) from generic div to custom HTML elements?
Details
I've built a tiny POC here https://github.com/trieloff/superdevoluy (thanks to @kptdobe for offering a small project to start with)
The site is using three types of block markup:
<helix-card><div><div><p><strong>Propriétaires</strong></p><p>Courriel: <a href="mailto:[email protected]">[email protected]</a></p></div></div></helix-card>
<helix-card sealed="true"><div><div><p><strong>Propriétaires</strong></p><p>Courriel: <a href="mailto:[email protected]">[email protected]</a></p></div></div></helix-card>
<div class="card"><div><div><p><strong>Propriétaires</strong></p><p>Courriel: <a href="mailto:[email protected]">[email protected]</a></p></div></div></div>
- a regular custom element
- a custom element using Shadow DOM
- Helix as usual
I'm loading the JS for the custom elements using
<script src="/elements/card/card.js" type="module" async></script>
Where Helix as usual needs a small script to bootstrap the decoration of block divs like this
/**
* Loads JS and CSS for all blocks in a container element.
* @param {Element} $main The container element
*/
async function loadBlocks($main) {
$main
.querySelectorAll('div.section-wrapper > div > .block')
.forEach(async ($block) => loadBlock($block));
}
/**
* Loads JS and CSS for a block.
* @param {Element} $block The block element
*/
export async function loadBlock($block) {
const blockName = $block.getAttribute('data-block-name');
try {
const mod = await import(`/blocks/${blockName}/${blockName}.js`);
if (mod.default) {
await mod.default($block, blockName, document);
}
} catch (err) {
// eslint-disable-next-line no-console
console.log(`failed to load module for ${blockName}`, err);
}
loadCSS(`/blocks/${blockName}/${blockName}.css`);
}
The Custom Elements approach just uses the built-in features of the browser.
The code to register a block or custom element is very similar:
async function decorateBlockCard($block) {
let html = '';
$block.querySelectorAll('p').forEach((p) => {
html += `${p.innerHTML}<br>`;
});
$block.innerHTML = html;
}
export default async function decorate($block) {
decorateBlockCard($block);
}
vs.
import { loadCSS } from '/scripts/scripts.js';
class Card extends HTMLElement {
constructor() {
super();
let html = '';
let root = this;
if (this.getAttribute('sealed')) {
this.attachShadow({ mode: 'open' });
root = this.shadowRoot;
}
this.querySelectorAll('p').forEach((p) => {
html += `${p.innerHTML}<br>`;
});
root.innerHTML = `<div style="background: blue; margin-bottom: 50px;">${html}</div>`;
}
}
customElements.define('helix-card', Card);
loadCSS(`/elements/card/card.css`);
The if condition and this.attachShadow({ mode: 'open' }); and root = this.shadowRoot; are only needed if you want to use Shadow DOM, but without getting too much ahead of myself, you probably don't want to use Shadow DOM.
The result are three blocks, styled slightly differently to outline the differences of the approaches:
Regular Custom Elements (green)
They behave just like normal elements, and the custom CSS rules in a separate file
main .section-wrapper helix-card > div {
text-align: left;
margin-bottom: 50px;
background: green!important;
}
work just as expected. There is no need to keep these rules in a separate file, you can just put them into the main CSS if you like – or not. Also note that a rule from the main CSS is able to seep into the compontent and add a (strong) into the strong tag's contents.
strong::after {
content: " (strong)";
}
Custom Elements with Shadow DOM (blue)
These elements behave as if they would be living in an iframe on the document, there is no external styling that can seep into the element, not even the background: green!important; line from the custom CSS is able to override the inline style="background: blue; margin-bottom: 50px;" attribute.
Note that neither the styling of the strong tag (with extra content) nor the styling of the a tag is making it through, giving the entire component an extremely strong vanilla flavor.
Unless you really, really hate the cascading nature of CSS, there is almost no reason to use Shadow DOM in Helix projects, as there is no boundary of trust between the person that writes the component and the person that writes the main CSS.
If you don't want Shadow DOM, the Card class can be simplified to:
class Card extends HTMLElement {
constructor() {
super();
let html = '';
this.querySelectorAll('p').forEach((p) => {
html += `${p.innerHTML}<br>`;
});
this.innerHTML = `<div>${html}</div>`;
}
}
Helix as usual
Your block is just regular HTML on the page, with regular styling, albeit with the neccessity of a custom loader and a non-standard constructor API like this (repeated from above):
async function decorateBlockCard($block) {
let html = '';
$block.querySelectorAll('p').forEach((p) => {
html += `${p.innerHTML}<br>`;
});
$block.innerHTML = html;
}
export default async function decorate($block) {
decorateBlockCard($block);
}
Proposed Actions
- port the rest of the superdevoluy site to use HTML Custom elements (regular) with a static
index.html - measure page speed and determine the best way to load the
card.jsscripts for each of the custom elements (straight in the HTML, with a bootstrap script, etc.) - adjust https://github.com/adobe/helix-pipeline-service/blob/main/src/steps/create-page-blocks.js to create custom element markup instead of plain
divs (@tripodsan any idea how we can turn this into a feature flag) - determine impact
- decide
Supported in all browsers relevant for Helix https://caniuse.com/mdn-api_window_customelements
3. adjust https://github.com/adobe/helix-pipeline-service/blob/main/src/steps/create-page-blocks.js to create custom element markup instead of plain
divs (@tripodsan any idea how we can turn this into a feature flag)
The easiest would be to use the version lock edge-dict and hardcode (custom--superdevoluy--kptadobe=ci112233) (@stefan-guggisberg we might not hardcode the pipeline version right now :-)
Interesting! Fwiw, I started playing with using a shadow-DOMed web component for the sidekick to keep the page CSS from bleeding into it. Would love to share/compare outcomes.
Since this issue is essentuially asking a question, should we turn it into a discussion?
As of https://github.com/trieloff/superdevoluy/commit/ce4c9f00b29ab446cd242b4260d14ed432a39797 all blocks have been ported to custom elements.
Minor concern: if the server gives me a helix-card in my DOM, I would assume this is a standard "helix" component shared across all projects with a predefined structure... But this is wrong, this is a project specific element.
If these represent "blocks", block-card may imply less things. Maybe.
After the complete porting, I've tried some more variants:
- Eager Loading of Custom Elements (each custom element has a
scripttag before use) like this: https://github.com/trieloff/superdevoluy/blob/8d85a67e15b90f210c9e7f412e0b587a9c7c006a/index.html#L15-L16 - Lazy Loading of Custom Elements (each custom element will be
imported when first encountered: https://github.com/trieloff/superdevoluy/blob/9f5dd4dd1b5e2fff27c24dcfa4196ab74c89ff79/scripts/scripts.js#L151-L152) - Reduction of Boilerplate (the
customElements.definecall is centralized in the block decoration script like this: https://github.com/trieloff/superdevoluy/commit/ec8015c9f623ac1fee3f1cfd67ecf3fb33d0e147) - Inline Styles (instead of having one CSS file for each block, the CSS is part of the JS in a template literal like this: https://github.com/trieloff/superdevoluy/compare/noboilerplate...trieloff:inlinestyles)
I've run the Chrome Profiler on each variant (using hlx.live) and included @kptdobe's original site, too:
Original

Eager Custom Elements

Lazy Custom Elements

No Boiler Plate

Inline Styles

Summary
| Variant | URL | Loading | Scripting | Rendering | Painting | System | Idle | Total | FCP | LCP | Onload |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Original | https://main--superdevoluy--kptdobe.hlx.live/ | 10 | 138 | 141 | 89 | 79 | 1092 | 1549 | 249 | 413 | 424 |
| Eager Custom Elements | https://main--superdevoluy--trieloff.hlx.live/index.html | 13 | 152 | 181 | 66 | 109 | 525 | 1045 | 237 | 383 | 397 |
| Lazy Custom Elements | https://lazy--superdevoluy--trieloff.hlx.live/index.html | 8 | 144 | 69 | 51 | 42 | 699 | 1013 | 235 | 386 | 388 |
| No Boilerplate Custom Elements | https://noboilerplate--superdevoluy--trieloff.hlx.live/index.html | 9 | 133 | 77 | 93 | 45 | 598 | 955 | 259 | 367 | 378 |
| Inline Styles Custom Elements | https://inlinestyles--superdevoluy--trieloff.hlx.live/index.html | 13 | 191 | 101 | 41 | 54 | 568 | 967 | 309 | 440 | 460 |
- Overall, Custom Elements seem to be beneficial when it comes to rendering performance
- the approach that I would pick for loading/coding custom elements is No Boilerplate
- Having CSS in JavaScript does not just look weird, it is also slower than the reference and I'd abstain from this approach