diff --git a/README.md b/README.md index 3e12ebe..051caab 100644 --- a/README.md +++ b/README.md @@ -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 ``'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 ``'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. diff --git a/src/WebComponent.js b/src/WebComponent.js index a577666..08c8347 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -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 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 ?? '' } } diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index 9e89f72..2a8d6b3 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -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 '
in shadow
' + } + } + 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 ? 'element
` + : 'string' + } + } + 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 'in light
' + } + } + 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`${this.props.label}
` + } + } + 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).