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`${this.props.n}` } } class Middle extends WebComponent { static props = { n: 0 } get template() { return html`
` } } class Root extends WebComponent { static props = { n: 0, banner: 'hi' } get template() { return html`
` } } 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 component’s 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`${this.props.n}` } } class Toggle extends WebComponent { static props = { show: false } get template() { return this.props.show ? html`
` : html`
empty
` } } 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`${this.props.n}` } } class SRoot extends WebComponent { static props = { n: 0, banner: 'hi' } get template() { return html`

${this.props.banner}

` } } // 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`
` } } class SlotParent extends WebComponent { static props = { label: 'a' } get template() { return html`${this.props.label}` } } 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') }) })