fix: string templates also renders in shadow DOM
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run

This commit is contained in:
ayo 2026-07-20 20:46:43 +02:00
parent 50a21b817d
commit d230d962ec
3 changed files with 124 additions and 12 deletions

View file

@ -37,7 +37,7 @@ When you extend the `WebComponent` class for your component, you only have to de
The result is a reactive UI on property changes.
When the `template` is an `html` tagged template (a vnode tree), a re-render **patches the existing DOM in place** instead of rebuilding it: elements of the same tag are reused, only changed props/attributes/text are touched, and leftover nodes are trimmed. Transient DOM state on the rendered nodes therefore survives a re-render — focus, caret/selection, an `<input>`'s uncommitted value, `:hover`, and running CSS transitions. A controlled text field can write every keystroke back to a prop without losing focus. The first render still builds the tree from scratch, and string templates still assign to `innerHTML`.
When the `template` is an `html` tagged template (a vnode tree), a re-render **patches the existing DOM in place** instead of rebuilding it: elements of the same tag are reused, only changed props/attributes/text are touched, and leftover nodes are trimmed. Transient DOM state on the rendered nodes therefore survives a re-render — focus, caret/selection, an `<input>`'s uncommitted value, `:hover`, and running CSS transitions. A controlled text field can write every keystroke back to a prop without losing focus.
Patching is **index-based and non-keyed**: list items are matched by position, not identity. The resulting DOM is always correct, but preserved state follows the *position* rather than the *item* — remove the first row of a list and the node that held row 1 is rewritten to hold row 2, so a caret, an uncommitted value, or a running transition stays where it was while the content shifts under it. Fixed-order lists and append/remove-at-the-end are unaffected; reorderable lists of focusable or animated items are the sharp edge. A `key` prop for identity-based matching is not implemented.

View file

@ -354,24 +354,24 @@ export class WebComponent extends HTMLElement {
if (this.constructor.shadowRootInit) {
this.#host = this.attachShadow(this.constructor.shadowRootInit)
}
// adoption appends, so it happens once per instance here rather than per
// render — otherwise every re-render (and every switch between template
// kinds) would stack another copy of each sheet
this.#applyStyles()
}
render() {
if (typeof this.template === 'string') {
this.innerHTML = this.template
} else if (typeof this.template === 'object') {
const tree = this.template
const template = this.template
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(tree)) {
this.#applyStyles()
if (this.#prevDOM === undefined) {
if (template && typeof template === 'object') {
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(template)) {
if (!this.#prevDOM) {
/**
* first render: create element
* - resolve prop values
* - attach event listeners
*/
const el = createElement(tree)
const el = createElement(template)
if (el) {
if (Array.isArray(el)) this.#host.replaceChildren(...el)
@ -380,10 +380,20 @@ export class WebComponent extends HTMLElement {
} else {
// re-render: reconcile in place so focus, caret/selection, an
// uncommitted <input> value, :hover and running transitions survive
patchChildren(this.#host, this.#prevDOM, tree)
patchChildren(this.#host, this.#prevDOM, template)
}
this.#prevDOM = tree
this.#prevDOM = template
}
} else {
// string templates render into #host like vnode ones do, so they
// respect the shadow root instead of writing over consumer-slotted
// light-DOM children. `html``` — the natural way to render nothing —
// yields undefined rather than a vnode, so it lands here too: both it
// and '' empty the rendered subtree. Dropping the vnode bookkeeping
// makes the next vnode render start fresh instead of patching against
// a tree that is no longer on screen.
this.#prevDOM = undefined
this.#host.innerHTML = template ?? ''
}
}

View file

@ -303,6 +303,108 @@ describe('shadow DOM', () => {
})
})
/**
* String and vnode templates render into the same target, so a string
* template on a shadow component lands in the shadow root instead of
* overwriting consumer-slotted light-DOM children.
*/
describe('string templates', () => {
it('renders a string template into the shadow root', () => {
class StringShadow extends WebComponent {
static shadowRootInit = { mode: 'open' }
get template() {
return '<p>in shadow</p>'
}
}
const el = mount(StringShadow)
expect(el.shadowRoot.querySelector('p')?.textContent).toBe('in shadow')
expect(el.querySelector('p')).toBeNull()
})
it('leaves slotted light-DOM children alone', () => {
class Slotting extends WebComponent {
static shadowRootInit = { mode: 'open' }
static props = { active: false }
get template() {
return this.props.active ? '<slot></slot>' : ''
}
}
const el = mount(Slotting)
el.appendChild(
Object.assign(document.createElement('span'), { textContent: 'mine' })
)
el.toggleAttribute('active', true)
el.toggleAttribute('active', false)
expect(el.querySelector('span')?.textContent).toBe('mine')
})
it('empties the rendered subtree for an empty template', () => {
class Conditional extends WebComponent {
static shadowRootInit = { mode: 'open' }
static props = { active: false }
get template() {
return this.props.active ? html`<div>piece</div>` : html``
}
}
const el = mount(Conditional, { active: '' })
expect(el.shadowRoot.querySelector('div')).not.toBeNull()
el.toggleAttribute('active', false)
expect(el.shadowRoot.querySelector('div')).toBeNull()
expect(el.shadowRoot.innerHTML).toBe('')
})
it('alternates element -> string -> element templates', () => {
class Alternating extends WebComponent {
static shadowRootInit = { mode: 'open' }
static props = { mode: 'element' }
get template() {
return this.props.mode === 'element'
? html`<p>element</p>`
: '<em>string</em>'
}
}
const el = mount(Alternating)
expect(el.shadowRoot.querySelector('p')?.textContent).toBe('element')
el.setAttribute('mode', 'string')
expect(el.shadowRoot.querySelector('p')).toBeNull()
expect(el.shadowRoot.querySelector('em')?.textContent).toBe('string')
el.setAttribute('mode', 'element')
expect(el.shadowRoot.querySelector('em')).toBeNull()
expect(el.shadowRoot.querySelector('p')?.textContent).toBe('element')
})
it('still renders a string template into the light DOM by default', () => {
class StringLight extends WebComponent {
get template() {
return '<p>in light</p>'
}
}
const el = mount(StringLight)
expect(el.shadowRoot).toBeNull()
expect(el.querySelector('p')?.textContent).toBe('in light')
})
it('adopts styles once across repeated renders', () => {
class Restyled extends WebComponent {
static shadowRootInit = { mode: 'open' }
static styles = `p { color: red; }`
static props = { label: 'a' }
get template() {
return html`<p>${this.props.label}</p>`
}
}
const el = mount(Restyled)
el.setAttribute('label', 'b')
el.setAttribute('label', 'c')
expect(el.shadowRoot.adoptedStyleSheets).toHaveLength(1)
})
})
/**
* Reactive-prop contract as documented on the docs site
* (guides/usage, guides/prop-access, guides/life-cycle-hooks).