InnerHTML Bindings for SSR

Summary

Applications dealing with rich content, like commerce apps, need to render this content as raw HTML, coming from a database or a CMS. Today, there is no way in LWC to rendered raw HTML content both on the browser and the server. This proposal introduces the lwc:inner-html template directive to inject HTML content.

Basic example

The following template renders raw HTML content inside the <div> HTML element:

example.js

import { LightningElement } from 'lwc';

export default class Example extends HTMLElement {
  content = 'Hello <strong>world</strong>!';
}

example.html

<template>
  <div lwc:inner-html={content} />
</template>

Output:

<x-example>
  # shadow-root
  |   <div>
  |     Hello <strong>world</strong>
  |   </div>
</x-example>

Motivation

It is common in customer-facing apps to render rich content coming from a CMS. As of today, this capability is supported by acting directly with the DOM within renderedCallback(). The templating syntax does not provide any binding capability that renders some content as raw HTML.

The reliance on the renderedCallback() life-cycle hook to render raw HTML content is a real issue for server-side rendering. In SSR, the renderedCallback() is never invoked. There is currently no straightforward way to inject raw HTML content for server-side rendered components.

This proposal introduces a new lwc:inner-html directive to inject raw HTML content that would work both in the browser and on the server.

Detailed design

Runtime behavior

At runtime the lwc:inner-html directive binds the directive value to the Element.innerHTML property. During each rendering cycle, the LWC engine diff the current value with its previous value. If the value changes between two rendering cycles, the LWC engine sets the element innerHTML property to the new value.

Element.innerHTML is a wellknown XSS sink. To prevent malicious content injection via this lwc:inner-html directive, a new sanitizeHtmlContent hook is introduced on the LWC engine. This hook is invoked during the LWC component rendering cycle and can be used to strip out malicious code from the content to be injected. The hook accepts a single content string argument. The content is raw HTML content passed to the lwc:inner-html directive. The sanitizeHtmlContent hook is expected to return the sanitized HTML content as a string. By default, the sanitizeHtmlContent hook is a pass-through.

import * as lwc from 'lwc';
import DOMPurify from 'dompurify';

lwc.sanitizeHtmlContent = function (content) {
    return DOMPurify.sanitize(content);
};

When running in native shadow, the shadow DOM style automatically gets applied to injected content. In synthetic shadow, the lwc:inner-html relies on the same mechanism than lwc:dom="manual" to apply the scoped styles to the manually injected content. The synthetic shadow DOM attaches a MutationObserver on the root element and watches for DOM changes in the subtree to apply the styling attributes.

Compilation restrictions

The lwc:inner-html directive can be applied to elements in the template. The directive accepts a string or an expression value. The LWC template compiler enforces the following restrictions:

<template>
  <!-- Valid -->
  <div lwc:inner-html={content}></div>
  <div lwc:inner-html="<h1>hello</h1>"></div>

  <!-- Invalid -->
  <div lwc:inner-html></div>
  <div lwc:inner-html={content}>With content</div>
  <slot lwc:inner-html={content}></slot>
  <template lwc:inner-html={content}></template>
  <x-foo lwc:inner-html={content}></x-foo>

</template>

Caveats

Usage of custom elements in the injected content

The LWC engine will not attempt to upgrade custom elements injected via lwc:inner-html even if the custom element maps to a known LWC component.

import { LightningElement } from 'lwc';

export default class extends LightningElement {
  content = '<x-button></x-button>';
}
<template>
  <x-button></x-button>
  <div lwc:inner-html={content}></div>
</template>

In the above example, the <x-button> element defined in the template is treated as a potential LWC component. The <x-button> custom element defined in the content is treated like any other custom element. If x-button is registered as a custom element in the global registry, it will be upgraded by the user agent.

Usage of <slot> in the injected content

The LWC compiler transforms the <slot> elements in the template to support slotting in synthetic shadow. <slot> elements injected via the lwc:inner-html will not participate in the slotting when LWC with running in synthetic shadow.

It is also important to call out the opposite effect of injecting <slot> in native shadow. In native shadow, LWC relies on the user agent for slotting. If the content injected via lwc:inner-html contains slot elements, those new slots might conflict with existing slots defined in the template.

Alternatives

Different syntaxes for the innerHTML attribute are possible, but they finally lead to the same results. It is then more a matter of preference and consistency.

Another solution would define a new binding syntax, like for example ${{myhtml}} or ${html:myhtml}}. But such a syntax could be used everywhere a binding is possible, including attribute values. This could lead to undefined behaviors why not providing any value. The proposed syntax makes sure that the innerHTML binding always applies to the content of an element.

Prior Art

Adoption strategy

Because the attribute uses the reserved lwc: prefix, there is no backward compatibility issue, and there is no potential name conflicts.

How we teach this

Documentation has to be updated with this new directive.

Usage of lwc:dom="manual" for dynamic content injection is now obsolete and developers should transition over to lwc:inner-html. This should greatly reduce the number of components using lwc:dom="manual" directive. The lwc:dom="manual" documentation should be updated to reflect this.

undefined