diff --git a/demo/examples/nested-composition/index.html b/demo/examples/nested-composition/index.html
new file mode 100644
index 0000000..99f95da
--- /dev/null
+++ b/demo/examples/nested-composition/index.html
@@ -0,0 +1,61 @@
+
+
+
+
+ A component within a component within a component. Each
+ <tick-counter> lives inside a
+ <counter-row>, which lives inside a
+ <counter-board> — three levels, all light DOM.
+
+
+
Style objects
Computed, conditional inline styles.
diff --git a/docs/src/content/docs/guides/template-vs-render.md b/docs/src/content/docs/guides/template-vs-render.md
index e603746..4cca073 100644
--- a/docs/src/content/docs/guides/template-vs-render.md
+++ b/docs/src/content/docs/guides/template-vs-render.md
@@ -12,3 +12,35 @@ This mental model attempts to reduce the cognitive complexity of authoring compo
1. Overriding the `render()` function for handling a custom `template` is also possible. Here's an example of using `lit-html`: [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010)
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/) for the two template kinds, and [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/) for what an in-place re-render preserves — focus, caret position and an uncommitted input value all survive.
+
+## Composing components
+
+A component's `template` can contain other components, nested as deep as you like:
+
+```js
+class CounterBoard extends WebComponent {
+ static props = { title: 'Board' }
+ get template() {
+ return html`
+ ${this.props.title}
+
+
+ `
+ }
+}
+```
+
+Each nested component **owns the DOM it renders for itself**. When an outer
+component re-renders, the reconciler patches the props it passes down to a
+nested element (that is how data flows from parent to child) but never touches
+the element's own children — so a nested component keeps its rendered content
+and any internal state even when an ancestor re-renders for an unrelated
+reason. Data flows down as attributes, so pass values a nested component can
+read back from an attribute: primitives, or objects/arrays that survive a
+JSON round-trip. See it live: [Nested composition demo ↗](https://demo.webcomponent.io/examples/nested-composition/).
+
+The one exception is **slot projection**: children you write _inside_ a
+shadow-DOM component's tag are your content, projected into its ``, so the
+parent keeps reconciling those. A light-DOM component, by contrast, renders over
+its own children, so pass data to it through attributes rather than as projected
+children.
diff --git a/package.json b/package.json
index dcef9e8..eded804 100644
--- a/package.json
+++ b/package.json
@@ -109,7 +109,7 @@
"size-limit": [
{
"path": "./dist/WebComponent.js",
- "limit": "2 KB"
+ "limit": "2.05 KB"
},
{
"path": "./dist/html.js",
diff --git a/size-change-log.md b/size-change-log.md
index 260bd40..66db927 100644
--- a/size-change-log.md
+++ b/size-change-log.md
@@ -13,3 +13,4 @@ A running record of how each correctness/feature change affects the `WebComponen
| Reconcile `render()` in place instead of `replaceChildren` (WCB-REQ-06) | 1.81 kB (+0.43 kB) | 1.4 → 1.85 kB | **Re-renders no longer destroy DOM state.** The vnode path rebuilt the whole subtree and swapped it on every prop change, so focus, caret/selection, an uncommitted ` ` value, `:hover` and running CSS transitions were wiped on the component's own rendered nodes — text fields lost focus mid-typing and a segmented control lost keyboard focus on select. Re-renders now reconcile the existing DOM against the new vnode tree in place (`src/utils/patch.mjs`: index-based, non-keyed — reuse same-tag elements, patch prop/attr adds, changes and removals by the same rule `createElement` applies, recurse children, trim extras). The first render still builds via `createElement`, the `JSON.stringify` no-op skip is unchanged, and `html`'s vnode shape and string templates are untouched. Reusing an element also means a `style` rule that is dropped or goes falsy (`{ fontStyle: condition && 'italic' }`) must now be cleared explicitly — a rebuild used to do that implicitly. The reconciler lives in its own zero-dep module, but `size-limit` measures each entry *with its dependencies*, so the core budget had to move regardless; no runtime dependency was added. |
| Reflect boolean props as bare attributes (WCB-REQ-07, **breaking**) | 1.93 kB (+0.12 kB) | 1.85 → 1.95 kB | **`toggleAttribute()` and `[attr]` CSS selectors work again.** Booleans reflected as the strings `flag="true"`/`flag="false"`, so the attribute always existed: `el.toggleAttribute('flag', true)` was a silent no-op (it only adds/removes, never rewrites a value — no `attributeChangedCallback`, no re-render), and presence selectors like `:host([flag])` matched the stamped `flag="false"` too, painting "on" styles onto elements whose prop was `false`. Boolean props now follow HTML in both directions: reflecting `true` sets the **bare** attribute, `false` **removes** it, and attribute removal means `false` rather than the declared default. Reads are strict — **any present value is `true`**, including the literal `flag="false"`, exactly like native `disabled="false"`. **Breaking:** `setAttribute(name, String(bool))` now always means `true`; migrate those call sites to `toggleAttribute(name, bool)`. Two dev warnings carry most of the cost and make the migration safe: one when a boolean attribute is written as a literal `"true"`/`"false"` string (the silent inversion), and one once-per-class when a boolean prop declares a `true` default (HTML has no true-default boolean attribute — absence must mean both "false" and "default"). `applyProp` also stops stamping `"false"` for boolean vnode props with no matching DOM property, which a nested `WebComponent` would otherwise read back as `true`. Limit raised to fit the warnings. |
| Overrideable `toAttribute` / `fromAttribute` converters (#56) | 1.96 kB (+0.03 kB) | 1.95 → 2 kB | **An escape hatch for the edge cases strict boolean semantics create.** The reflection and parsing rules were hardcoded, so a prop needing custom serialization (a `Date`, a delimited list, an enumerated `"true"`/`"false"` attribute) had no seam to hook into. Two overrideable methods now own both directions — `toAttribute(name, value)` returns the attribute value, where **`null` removes the attribute**, and `fromAttribute(name, value)` parses a present attribute back into a prop value. Both take the camelCase prop key, matching `static props` and `onChanges`'s `property`. The existing behavior became the default implementations (pure code movement — boolean presence/absence is now simply `toAttribute` returning `''` or `null`), so subclasses customize one prop and delegate the rest to `super`. Cheaper than a per-prop converter map, which would cost a lookup on every prop even for the vast majority of components that never need one. Limit raised for the two non-manglable public method names. |
+| Don't reconcile a nested component's own light DOM | 2.01 kB (+0.05 kB) | 2 → 2.05 kB | **Nested components stop erasing each other.** The in-place reconciler recursed into every same-tag element it reused, including nested custom elements. A parent's vnode never describes what a nested light-DOM component rendered *for itself*, so the trailing-node trim in `patchChildren` deleted that content on every ancestor re-render — and since the child only re-renders when its own props change, it stayed blank. Any `WebComponent` that renders another `WebComponent` (without shadow DOM) lost the inner subtree the first time the outer one re-rendered, even for an unrelated prop. `patchNode` now treats a custom element that has no open shadow root as opaque: it still patches the element's props (that is how data flows down) but leaves the children to the child. Shadow-DOM components are exempt — their own content lives in the shadow root, invisible to the parent, so any *light* children are genuine parent-authored slot content that must still be reconciled. Limit raised 50 B to fit the one-line guard. |
diff --git a/src/utils/patch.mjs b/src/utils/patch.mjs
index 8f70d60..12533a5 100644
--- a/src/utils/patch.mjs
+++ b/src/utils/patch.mjs
@@ -145,6 +145,17 @@ export function patchNode(parent, dom, oldVnode, newVnode) {
const previous =
typeof oldVnode === 'object' && oldVnode !== null ? oldVnode : {}
patchProps(dom, previous.props, newVnode.props)
+
+ // A nested custom element that renders its own light DOM owns that subtree:
+ // the parent's vnode never describes what the child rendered for itself, so
+ // reconciling those children here would trim them away and leave the child
+ // blank until it happens to re-render on its own. Patch the element's props
+ // (that is how data flows down) but leave its children to the child.
+ // A shadow-DOM component is exempt — its own content lives in the shadow
+ // root, invisible here, so any *light* children are genuine parent-authored
+ // slot content that the parent must keep reconciling.
+ if (dom.tagName.includes('-') && !dom.shadowRoot) return
+
patchChildren(dom, previous.children, newVnode.children)
}
diff --git a/test/composition.test.mjs b/test/composition.test.mjs
new file mode 100644
index 0000000..f87eb18
--- /dev/null
+++ b/test/composition.test.mjs
@@ -0,0 +1,200 @@
+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`
+
+ ${this.props.banner}
+
+
+ `
+ }
+ }
+ 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')
+ })
+})
diff --git a/test/e2e/nested-composition.test.mjs b/test/e2e/nested-composition.test.mjs
new file mode 100644
index 0000000..1c8d6f2
--- /dev/null
+++ b/test/e2e/nested-composition.test.mjs
@@ -0,0 +1,46 @@
+import { beforeEach, expect, test } from 'vitest'
+import '../../demo/examples/nested-composition/index.js'
+
+beforeEach(() => {
+ document.body.innerHTML = ''
+})
+
+test('three-level nesting renders all levels on connect', () => {
+ document.body.innerHTML = ' '
+ const board = document.querySelector('counter-board')
+
+ const rows = board.querySelectorAll('counter-row')
+ expect(rows.length).toBe(3)
+ // each row hosts a leaf counter that rendered its own button + count
+ rows.forEach((row) => {
+ expect(row.querySelector('tick-counter .count').textContent.trim()).toBe(
+ '0'
+ )
+ })
+})
+
+test('an ancestor re-render keeps each nested counter’s own state', () => {
+ document.body.innerHTML = ' '
+ const board = document.querySelector('counter-board')
+ const counters = [...board.querySelectorAll('tick-counter')]
+
+ // click the leaf counters different numbers of times — pure self-state
+ counters[0].querySelector('button').click()
+ counters[1].querySelector('button').click()
+ counters[1].querySelector('button').click()
+
+ expect(
+ counters.map((c) => c.querySelector('.count').textContent.trim())
+ ).toEqual(['1', '2', '0'])
+
+ // re-render the ROOT for an unrelated reason
+ const titleBefore = board.querySelector('#board-title').textContent
+ board.querySelector('#rename').click()
+ expect(board.querySelector('#board-title').textContent).not.toBe(titleBefore)
+
+ // the same counter elements are reused and their counts are intact
+ expect([...board.querySelectorAll('tick-counter')]).toEqual(counters)
+ expect(
+ counters.map((c) => c.querySelector('.count').textContent.trim())
+ ).toEqual(['1', '2', '0'])
+})