Compare commits
2 commits
main
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
| eaadef6716 | |||
| 8f8b09acd3 |
9 changed files with 2 additions and 436 deletions
|
|
@ -1,61 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Nested composition</title>
|
|
||||||
<script type="module" src="./index.js"></script>
|
|
||||||
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
|
||||||
<link rel="stylesheet" href="../../shell.css" />
|
|
||||||
<script type="module" src="../../shell.js"></script>
|
|
||||||
<style>
|
|
||||||
.board {
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
padding: 1em;
|
|
||||||
max-width: 32em;
|
|
||||||
}
|
|
||||||
.board header {
|
|
||||||
display: flex;
|
|
||||||
gap: 1em;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 0.75em;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75em;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.4em;
|
|
||||||
}
|
|
||||||
.row-name {
|
|
||||||
min-width: 5em;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Nested composition</h1>
|
|
||||||
<p class="lede">
|
|
||||||
A component within a component within a component. Each
|
|
||||||
<code><tick-counter></code> lives inside a
|
|
||||||
<code><counter-row></code>, which lives inside a
|
|
||||||
<code><counter-board></code> — three levels, all light DOM.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ol>
|
|
||||||
<li>Click a few counters so their counts differ.</li>
|
|
||||||
<li>
|
|
||||||
Click <strong>Rename board</strong>. That changes only the board's
|
|
||||||
title and re-renders the <em>root</em> component.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
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.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<counter-board></counter-board>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
// @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 — `<tick-counter></tick-counter>` 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`
|
|
||||||
<button onclick=${() => this.#bump()}>
|
|
||||||
${this.props.label}: <strong class="count">${this.#count}</strong>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
#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`
|
|
||||||
<div class="row">
|
|
||||||
<span class="row-name">${this.props.name}</span>
|
|
||||||
<tick-counter label=${this.props.name}></tick-counter>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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`
|
|
||||||
<section class="board">
|
|
||||||
<header>
|
|
||||||
<h3 id="board-title">${this.props.title}</h3>
|
|
||||||
<button id="rename" onclick=${() => this.#rename()}>
|
|
||||||
Rename board (re-renders the root)
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<counter-row name="alpha"></counter-row>
|
|
||||||
<counter-row name="bravo"></counter-row>
|
|
||||||
<counter-row name="charlie"></counter-row>
|
|
||||||
</section>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
#renamed = 0
|
|
||||||
#rename() {
|
|
||||||
this.props.title = `Board ${String.fromCharCode(66 + (++this.#renamed % 25))}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customElements.define('counter-board', CounterBoard)
|
|
||||||
|
|
@ -114,13 +114,6 @@
|
||||||
In-place patching: focus, caret, transitions and prop removal.
|
In-place patching: focus, caret, transitions and prop removal.
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a class="card" href="./examples/nested-composition/index.html">
|
|
||||||
<div class="name">Nested composition</div>
|
|
||||||
<div class="desc">
|
|
||||||
Components three levels deep keep their own DOM across an ancestor
|
|
||||||
re-render.
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a class="card" href="./examples/style-objects/index.html">
|
<a class="card" href="./examples/style-objects/index.html">
|
||||||
<div class="name">Style objects</div>
|
<div class="name">Style objects</div>
|
||||||
<div class="desc">Computed, conditional inline styles.</div>
|
<div class="desc">Computed, conditional inline styles.</div>
|
||||||
|
|
|
||||||
|
|
@ -12,35 +12,3 @@ 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)
|
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.
|
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`
|
|
||||||
<h3>${this.props.title}</h3>
|
|
||||||
<counter-row name="alpha"></counter-row>
|
|
||||||
<counter-row name="bravo"></counter-row>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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 `<slot>`, 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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web-component-base",
|
"name": "web-component-base",
|
||||||
"version": "6.1.1",
|
"version": "6.1.0",
|
||||||
"description": "A zero-dependency & tiny JS base class for creating reactive custom elements easily",
|
"description": "A zero-dependency & tiny JS base class for creating reactive custom elements easily",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
"size-limit": [
|
"size-limit": [
|
||||||
{
|
{
|
||||||
"path": "./dist/WebComponent.js",
|
"path": "./dist/WebComponent.js",
|
||||||
"limit": "2.05 KB"
|
"limit": "2 KB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/html.js",
|
"path": "./dist/html.js",
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,3 @@ 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 `<input>` 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. |
|
| 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 `<input>` 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. |
|
| 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. |
|
| 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. |
|
|
||||||
|
|
|
||||||
|
|
@ -145,17 +145,6 @@ export function patchNode(parent, dom, oldVnode, newVnode) {
|
||||||
const previous =
|
const previous =
|
||||||
typeof oldVnode === 'object' && oldVnode !== null ? oldVnode : {}
|
typeof oldVnode === 'object' && oldVnode !== null ? oldVnode : {}
|
||||||
patchProps(dom, previous.props, newVnode.props)
|
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)
|
patchChildren(dom, previous.children, newVnode.children)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
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 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`<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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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 = '<counter-board></counter-board>'
|
|
||||||
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 = '<counter-board></counter-board>'
|
|
||||||
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'])
|
|
||||||
})
|
|
||||||
Loading…
Reference in a new issue