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.
|
||||
</div>
|
||||
</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">
|
||||
<div class="name">Style objects</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)
|
||||
|
||||
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",
|
||||
"version": "6.1.1",
|
||||
"version": "6.1.0",
|
||||
"description": "A zero-dependency & tiny JS base class for creating reactive custom elements easily",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
"size-limit": [
|
||||
{
|
||||
"path": "./dist/WebComponent.js",
|
||||
"limit": "2.05 KB"
|
||||
"limit": "2 KB"
|
||||
},
|
||||
{
|
||||
"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. |
|
||||
| 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. |
|
||||
|
|
|
|||
|
|
@ -145,17 +145,6 @@ 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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