wcb/test/composition.test.mjs
ayo 20e31e4796
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run
fix: nested components no longer wipe each other on re-render (#59)
2026-07-20 23:46:14 +02:00

200 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { WebComponent } from '../src/WebComponent.js'
import { html } from '../src/html.js'
/**
* Composition: a component that renders another component, several levels deep.
* A parent's vnode never describes what a nested component rendered for itself,
* so the in-place reconciler must treat a nested custom element as opaque —
* patch its props (data flows down) but leave its own children to the child.
* Reconciling them trims the child's rendered content on every ancestor
* re-render and leaves it blank until the child next re-renders on its own.
*/
let mounted = []
/**
* Creates, connects and tracks an element so it is cleaned up between tests.
* @param {string} tag the custom element tag to mount
* @returns {HTMLElement} the connected element
*/
function mount(tag) {
const el = document.createElement(tag)
document.body.appendChild(el)
mounted.push(el)
return el
}
beforeEach(() => {
mounted.forEach((el) => el.remove())
mounted = []
})
describe('light-DOM component nesting', () => {
class Leaf extends WebComponent {
static props = { n: 0 }
get template() {
return html`<span class="leaf">${this.props.n}</span>`
}
}
class Middle extends WebComponent {
static props = { n: 0 }
get template() {
return html`<div class="mid"><c-leaf n=${this.props.n}></c-leaf></div>`
}
}
class Root extends WebComponent {
static props = { n: 0, banner: 'hi' }
get template() {
return html`
<section>
<p class="banner">${this.props.banner}</p>
<c-middle n=${this.props.n}></c-middle>
</section>
`
}
}
beforeAll(() => {
customElements.define('c-leaf', Leaf)
customElements.define('c-middle', Middle)
customElements.define('c-root', Root)
})
it('renders all three levels on first connect', () => {
const root = mount('c-root')
expect(root.querySelector('.banner').textContent).toBe('hi')
expect(root.querySelector('c-leaf .leaf').textContent).toBe('0')
})
it('propagates a prop down every level on re-render', () => {
const root = mount('c-root')
root.props.n = 5
expect(root.querySelector('c-middle').getAttribute('n')).toBe('5')
expect(root.querySelector('c-leaf').getAttribute('n')).toBe('5')
expect(root.querySelector('c-leaf .leaf').textContent).toBe('5')
})
it('does not wipe a nested subtree when an unrelated ancestor prop changes', () => {
const root = mount('c-root')
const leaf = root.querySelector('c-leaf')
// the leaf's own rendered content is present...
expect(leaf.querySelector('.leaf').textContent).toBe('0')
// ...and an ancestor re-render for an unrelated reason leaves it in place
root.props.banner = 'changed'
expect(root.querySelector('c-leaf')).toBe(leaf)
expect(leaf.querySelector('.leaf')).not.toBeNull()
expect(leaf.querySelector('.leaf').textContent).toBe('0')
})
it('preserves a nested components own internal state across ancestor re-renders', () => {
const root = mount('c-root')
const leaf = root.querySelector('c-leaf')
// the leaf mutates its own state, independent of any parent prop
leaf.props.n = 99
expect(leaf.querySelector('.leaf').textContent).toBe('99')
// unrelated ancestor re-render must not reset it
root.props.banner = 'again'
expect(leaf.querySelector('.leaf').textContent).toBe('99')
// ...and the leaf node itself was reused, not rebuilt
expect(root.querySelector('c-leaf')).toBe(leaf)
})
it('reuses the nested element instance rather than rebuilding it', () => {
const root = mount('c-root')
const leaf = root.querySelector('c-leaf')
root.props.n = 1
root.props.n = 2
root.props.banner = 'x'
expect(root.querySelector('c-leaf')).toBe(leaf)
expect(leaf.querySelector('.leaf').textContent).toBe('2')
})
})
describe('conditional nesting', () => {
class Inner extends WebComponent {
static props = { n: 0 }
get template() {
return html`<b class="inner">${this.props.n}</b>`
}
}
class Toggle extends WebComponent {
static props = { show: false }
get template() {
return this.props.show
? html`<div><t-inner n=${7}></t-inner></div>`
: html`<div>empty</div>`
}
}
beforeAll(() => {
customElements.define('t-inner', Inner)
customElements.define('t-toggle', Toggle)
})
it('adds, removes and re-adds a nested component cleanly', () => {
const t = mount('t-toggle')
expect(t.querySelector('t-inner')).toBeNull()
t.props.show = true
expect(t.querySelector('t-inner .inner').textContent).toBe('7')
t.props.show = false
expect(t.querySelector('t-inner')).toBeNull()
expect(t.querySelector('div').textContent).toBe('empty')
t.props.show = true
expect(t.querySelector('t-inner .inner').textContent).toBe('7')
})
})
describe('shadow-DOM nesting is unaffected', () => {
class SLeaf extends WebComponent {
static props = { n: 0 }
static shadowRootInit = { mode: 'open' }
get template() {
return html`<span>${this.props.n}</span>`
}
}
class SRoot extends WebComponent {
static props = { n: 0, banner: 'hi' }
get template() {
return html`<p>${this.props.banner}</p>
<s-leaf n=${this.props.n}></s-leaf>`
}
}
// a light-DOM component that projects parent-authored children into a slot
class SlotHost extends WebComponent {
static props = {}
static shadowRootInit = { mode: 'open' }
get template() {
return html`<div class="frame"><slot></slot></div>`
}
}
class SlotParent extends WebComponent {
static props = { label: 'a' }
get template() {
return html`<slot-host
><em class="proj">${this.props.label}</em></slot-host
>`
}
}
beforeAll(() => {
customElements.define('s-leaf', SLeaf)
customElements.define('s-root', SRoot)
customElements.define('slot-host', SlotHost)
customElements.define('slot-parent', SlotParent)
})
it('a shadow-DOM child keeps its content through an ancestor re-render', () => {
const root = mount('s-root')
const leaf = root.querySelector('s-leaf')
expect(leaf.shadowRoot.querySelector('span').textContent).toBe('0')
root.props.banner = 'changed'
expect(leaf.shadowRoot.querySelector('span').textContent).toBe('0')
root.props.n = 3
expect(leaf.shadowRoot.querySelector('span').textContent).toBe('3')
})
it('parent-authored slot content is still reconciled on the child', () => {
const p = mount('slot-parent')
const host = p.querySelector('slot-host')
expect(host.querySelector('.proj').textContent).toBe('a')
// this light child is genuinely parent-authored, so it must keep updating
p.props.label = 'B'
expect(host.querySelector('.proj').textContent).toBe('B')
})
})