fix: string templates also renders in shadow DOM
This commit is contained in:
parent
50a21b817d
commit
d230d962ec
3 changed files with 124 additions and 12 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue