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 @@ + + + + + + Nested composition + + + + + + + +

Nested composition

+

+ 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. +

+ +
    +
  1. Click a few counters so their counts differ.
  2. +
  3. + Click Rename board. That changes only the board's + title and re-renders the root component. +
  4. +
  5. + The counts stay exactly as you left them. A nested component owns the + DOM it renders for itself; an ancestor re-render patches props down but + never trims the inner subtree. +
  6. +
+ + + + diff --git a/demo/examples/nested-composition/index.js b/demo/examples/nested-composition/index.js new file mode 100644 index 0000000..4f42a72 --- /dev/null +++ b/demo/examples/nested-composition/index.js @@ -0,0 +1,76 @@ +// @ts-check +import { WebComponent, html } from 'web-component-base' + +/** + * Composition: a component within a component within a component. + * + * The three classes below nest three levels deep, all rendering to light DOM. + * The point of interest is what happens to the inner subtrees when an *outer* + * component re-renders for a reason unrelated to them. + * + * A parent's vnode never describes what a nested component rendered for + * itself — `` is empty in the row's template even + * though the counter fills itself with a button and a count. The in-place + * reconciler therefore treats a nested custom element as opaque: it patches the + * element's props (so data still flows down) but leaves the element's own + * children alone. Reconciling them would trim away the child's rendered content + * on every ancestor re-render and leave it blank until it next re-rendered on + * its own. + */ + +/** Level 3 (leaf): owns its own count entirely — no prop feeds it. */ +class TickCounter extends WebComponent { + static props = { label: 'tick' } + #count = 0 + get template() { + return html` + + ` + } + #bump() { + this.#count++ + this.render() + } +} +customElements.define('tick-counter', TickCounter) + +/** Level 2 (middle): a labelled row that hosts a leaf counter. */ +class CounterRow extends WebComponent { + static props = { name: 'row' } + get template() { + return html` +
+ ${this.props.name} + +
+ ` + } +} +customElements.define('counter-row', CounterRow) + +/** Level 1 (root): a board that re-renders on an unrelated "rename". */ +class CounterBoard extends WebComponent { + static props = { title: 'Board A' } + get template() { + return html` +
+
+

${this.props.title}

+ +
+ + + +
+ ` + } + #renamed = 0 + #rename() { + this.props.title = `Board ${String.fromCharCode(66 + (++this.#renamed % 25))}` + } +} +customElements.define('counter-board', CounterBoard) diff --git a/demo/index.html b/demo/index.html index 8050ab6..4d10a81 100644 --- a/demo/index.html +++ b/demo/index.html @@ -114,6 +114,13 @@ In-place patching: focus, caret, transitions and prop removal. + +
Nested composition
+
+ Components three levels deep keep their own DOM across an ancestor + re-render. +
+
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` +
+ + +
+ ` + } + } + 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']) +})