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.
|
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.
|
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) {
|
if (this.constructor.shadowRootInit) {
|
||||||
this.#host = this.attachShadow(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() {
|
render() {
|
||||||
if (typeof this.template === 'string') {
|
const template = this.template
|
||||||
this.innerHTML = this.template
|
|
||||||
} else if (typeof this.template === 'object') {
|
|
||||||
const tree = this.template
|
|
||||||
|
|
||||||
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(tree)) {
|
if (template && typeof template === 'object') {
|
||||||
this.#applyStyles()
|
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(template)) {
|
||||||
|
if (!this.#prevDOM) {
|
||||||
if (this.#prevDOM === undefined) {
|
|
||||||
/**
|
/**
|
||||||
* first render: create element
|
* first render: create element
|
||||||
* - resolve prop values
|
* - resolve prop values
|
||||||
* - attach event listeners
|
* - attach event listeners
|
||||||
*/
|
*/
|
||||||
const el = createElement(tree)
|
const el = createElement(template)
|
||||||
|
|
||||||
if (el) {
|
if (el) {
|
||||||
if (Array.isArray(el)) this.#host.replaceChildren(...el)
|
if (Array.isArray(el)) this.#host.replaceChildren(...el)
|
||||||
|
|
@ -380,10 +380,20 @@ export class WebComponent extends HTMLElement {
|
||||||
} else {
|
} else {
|
||||||
// re-render: reconcile in place so focus, caret/selection, an
|
// re-render: reconcile in place so focus, caret/selection, an
|
||||||
// uncommitted <input> value, :hover and running transitions survive
|
// 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
|
* Reactive-prop contract as documented on the docs site
|
||||||
* (guides/usage, guides/prop-access, guides/life-cycle-hooks).
|
* (guides/usage, guides/prop-access, guides/life-cycle-hooks).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue