feat: smart diffing
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run

This commit is contained in:
ayo 2026-07-19 23:32:19 +02:00
parent 56cb1de7db
commit c7d87086a2
18 changed files with 904 additions and 32 deletions

View file

@ -33,12 +33,12 @@ Everything is in `src/` (entry point `src/index.js` re-exports `WebComponent` an
- The constructor deep-clones `static props`, records each prop's `typeof` in a private `#typeMap`, reflects defaults to attributes, and wraps the props object in a **Proxy** (`#handler`). Writing `this.props.someProp = x` serializes the value and calls `setAttribute` (kebab-case), which triggers `attributeChangedCallback``render()`. That attribute-write-driven cycle *is* the reactivity model — there is no separate scheduler.
- The Proxy enforces type stability against `#typeMap` and throws `TypeError` on a type change.
- Lifecycle hooks authors may override: `onInit()` (connected), `afterViewInit()` (after first render), `onDestroy()` (disconnected), `onChanges({property, previousValue, currentValue})` (attribute changed). Note `onChanges` currently receives the **kebab-case** `property`.
- `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is assigned to `innerHTML` directly; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, rebuilt via `createElement` + `replaceChildren`. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
- `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is assigned to `innerHTML` directly; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, built via `createElement` + `replaceChildren` on the *first* render and **reconciled in place** (`patchChildren`, see `src/utils/patch.mjs`) on every render after that, so focus/caret/uncommitted input values survive. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
- `static styles` are applied as a constructable `CSSStyleSheet` via `adoptedStyleSheets`, which **only works with a shadow root** — set `static shadowRootInit` to opt into shadow DOM.
- **`src/html.js`** — the `html` tagged-template function. It is a bundled/minified copy of [`htm`](https://github.com/developit/htm) bound to a hyperscript `h(type, props, children)` that produces plain `{type, props, children}` vnodes. License lives in `vendors/htm/`.
- **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), and `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects). `src/utils/index.js` re-exports all of them.
- **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects — its `applyProp` is the single source of truth for the prop→DOM rule), and `patch` (index-based, non-keyed reconciler used by re-renders; it reuses same-tag elements, applies prop adds/changes/**removals** via `applyProp`, and trims trailing nodes). `src/utils/index.js` re-exports all of them.
## Testing notes

View file

@ -23,6 +23,10 @@ When you extend the `WebComponent` class for your component, you only have to de
The result is a reactive UI on property changes.
When the `template` is an `html` tagged template (a vnode tree), a re-render **patches the existing DOM in place** instead of rebuilding it: elements of the same tag are reused, only changed props/attributes/text are touched, and leftover nodes are trimmed. Transient DOM state on the rendered nodes therefore survives a re-render — focus, caret/selection, an `<input>`'s uncommitted value, `:hover`, and running CSS transitions. A controlled text field can write every keystroke back to a prop without losing focus. The first render still builds the tree from scratch, and string templates still assign to `innerHTML`.
Patching is **index-based and non-keyed**: list items are matched by position, not identity. The resulting DOM is always correct, but preserved state follows the *position* rather than the *item* — remove the first row of a list and the node that held row 1 is rewritten to hold row 2, so a caret, an uncommitted value, or a running transition stays where it was while the content shifts under it. Fixed-order lists and append/remove-at-the-end are unaffected; reorderable lists of focusable or animated items are the sharp edge. A `key` prop for identity-based matching is not implemented.
## Want to get in touch?
There are many ways to get in touch:

View file

@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Render reconciliation</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>
.hint {
opacity: 0.75;
font-size: 0.9em;
}
.seg-group {
display: flex;
gap: 0.25em;
}
.seg.selected {
outline: 2px solid currentColor;
font-weight: 700;
}
#list li {
display: flex;
gap: 0.75em;
align-items: center;
margin-bottom: 0.4em;
}
.row-label {
min-width: 6em;
}
</style>
</head>
<body>
<h1>Render reconciliation</h1>
<p class="lede">
Re-renders of an <code>html</code> template patch the existing DOM in
place instead of rebuilding it, so transient DOM state on the rendered
nodes survives. Each section below demonstrates one part of that.
</p>
<h2>1. Focus, caret and uncommitted value survive</h2>
<p>
A controlled field: every keystroke writes back to a prop and re-renders
the component, yet the <code>&lt;input&gt;</code> is the same element
throughout.
</p>
<controlled-input></controlled-input>
<h2>2. Attribute-only changes patch in place</h2>
<p>
Selecting a tab changes only <code>aria-selected</code> and
<code>class</code>. Reach a tab with the keyboard, press Enter, and focus
stays on it — no node was replaced.
</p>
<segmented-control></segmented-control>
<h2>3. Running CSS transitions are not restarted</h2>
<p>
The box animates for 2s while the component re-renders ten times. Its
vnode is unchanged, so the element is left untouched and the transition
runs to completion.
</p>
<transition-safe></transition-safe>
<h2>4. Removed props and attributes are undone</h2>
<p>
Props that disappear from the new tree are actively reset on the reused
element — a dropped <code>disabled</code>, <code>data-*</code> attribute
and inline <code>style</code> rules all go away.
</p>
<prop-removal></prop-removal>
<h2>5. Lists — and the non-keyed limitation</h2>
<p>
Appending and removing reuses the surviving rows. Reordering, however, is
matched <em>by position</em>: the rendered result is correct, but
preserved state stays with the slot rather than following the item.
</p>
<item-list></item-list>
</body>
</html>

View file

@ -0,0 +1,216 @@
// @ts-check
import { WebComponent, html } from 'web-component-base'
/**
* 1. A controlled text field.
*
* Every keystroke writes back to a prop, which re-renders the component. The
* `<input>` element is reused by the reconciler, so focus, the caret position
* and the typed-but-uncommitted value all survive the re-render. Before
* in-place patching this rebuilt the input and kicked the user out of the field
* on the first character.
*/
class ControlledInput extends WebComponent {
static props = { value: '' }
get template() {
return html`
<label for="field"
>Type here focus and caret survive every render</label
>
<input
id="field"
type="text"
placeholder="start typing..."
oninput=${(e) => (this.props.value = e.target.value)}
/>
<p>
Prop value: <output id="echo">${this.props.value || '(empty)'}</output>
</p>
<p class="hint">Renders: <span id="renders">${this.#renders}</span></p>
`
}
#renders = 0
render() {
this.#renders++
super.render()
}
}
customElements.define('controlled-input', ControlledInput)
/**
* 2. Attribute-only changes patch in place.
*
* Selecting a tab changes nothing but `aria-selected` and `class` on the
* existing buttons the elements themselves are reused, so a tab you reached
* with the keyboard keeps focus after selection. Tab into the strip and use
* the arrow-free Tab/Enter or Space to see focus persist.
*/
class SegmentedControl extends WebComponent {
static props = { value: 'one' }
get template() {
const tab = (id, label) => html`
<button
id=${id}
class=${this.props.value === id ? 'seg selected' : 'seg'}
aria-selected=${String(this.props.value === id)}
onclick=${() => (this.props.value = id)}
>
${label}
</button>
`
return html`
<div role="tablist" class="seg-group">
${tab('one', 'One')} ${tab('two', 'Two')} ${tab('three', 'Three')}
</div>
<p>Selected: <output id="selected">${this.props.value}</output></p>
`
}
}
customElements.define('segmented-control', SegmentedControl)
/**
* 3. Unrelated re-renders don't restart a CSS transition.
*
* The box runs a 2s width transition. Bumping the counter re-renders the whole
* component every 200ms, but the box's vnode is unchanged, so its element is
* left completely untouched and the transition keeps running instead of
* snapping back to the start.
*/
class TransitionSafe extends WebComponent {
static props = { ticks: 0, wide: false }
// self-contained so the transition is guaranteed present wherever the
// component is used — including the e2e spec, which loads no page CSS
static shadowRootInit = { mode: 'open' }
static styles = `
.anim-box {
width: 4em;
height: 2.5em;
background: currentColor;
opacity: 0.5;
border-radius: 6px;
transition: width 2s ease-in-out;
}
.anim-box.wide { width: 90%; }
`
get template() {
return html`
<div
id="box"
class=${this.props.wide ? 'anim-box wide' : 'anim-box'}
></div>
<p>
Unrelated re-renders while it animates:
<output id="ticks">${this.props.ticks}</output>
</p>
<button id="run" onclick=${() => this.#run()}>Animate + re-render</button>
`
}
#run() {
this.props.wide = !this.props.wide
// hammer the component with unrelated re-renders during the transition
let n = 0
const id = setInterval(() => {
this.props.ticks++
if (++n >= 10) clearInterval(id)
}, 200)
}
}
customElements.define('transition-safe', TransitionSafe)
/**
* 4. Prop and attribute removal.
*
* A prop that disappears from the new vnode is actively undone, not left
* behind: a dropped `disabled` goes back to `false`, a dropped `data-*`
* attribute is removed, and dropped `style` rules are cleared.
*/
class PropRemoval extends WebComponent {
static props = { decorated: true }
get template() {
const on = this.props.decorated
// spread so the props are genuinely *absent* when off — that's the removal
// path: present in the old vnode, gone from the new one
const decoration = on
? {
disabled: true,
'data-badge': 'decorated',
style: { outline: '2px solid currentColor', padding: '0.6em' },
}
: {}
return html`
<button id="target" ...${decoration}>target element</button>
<p class="hint">
<code>disabled</code>, <code>data-badge</code> and the inline
<code>style</code> are present only while decorated toggling removes
them from the same element instead of building a new one.
</p>
<button id="toggle" onclick=${() => (this.props.decorated = !on)}>
${on ? 'Remove props' : 'Add props'}
</button>
`
}
}
customElements.define('prop-removal', PropRemoval)
/**
* 5. Lists including the non-keyed limitation.
*
* Growing and shrinking reuses the surviving rows. But patching is index-based,
* so a *reorder* matches nodes by position, not identity: the DOM ends up
* correct, yet each row keeps its slot and has its content rewritten. Type into
* a row, then reorder, and you'll see the caret stay on the position rather
* than follow the item.
*/
class ItemList extends WebComponent {
static props = { items: ['alpha', 'bravo', 'charlie'] }
get template() {
return html`
<ul id="list">
${this.props.items.map(
(item) => html`
<li>
<span class="row-label">${item}</span>
<input class="row-note" placeholder="note for ${item}" />
</li>
`
)}
</ul>
<button
id="add"
onclick=${() => this.#set([...this.props.items, 'delta'])}
>
Append
</button>
<button
id="drop"
onclick=${() => this.#set(this.props.items.slice(0, -1))}
>
Remove last
</button>
<button
id="reverse"
onclick=${() => this.#set([...this.props.items].reverse())}
>
Reverse (shows the non-keyed limitation)
</button>
<p class="hint">
Type a note into the first row, then press <b>Reverse</b>: the labels
swap but your note stays in row 1, because rows are matched by position.
A <code>key</code> prop would be needed to make state follow the item.
</p>
`
}
#set(items) {
this.props.items = items
}
}
customElements.define('item-list', ItemList)

View file

@ -87,6 +87,12 @@
Lists, links, style attributes — plus a lit-html variant.
</div>
</a>
<a class="card" href="./examples/render-reconciliation/index.html">
<div class="name">Render reconciliation</div>
<div class="desc">
In-place patching: focus, caret, transitions and prop removal.
</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>

View file

@ -30,6 +30,9 @@ import {
getCamelCase,
getKebabCase,
createElement,
applyProp,
patchNode,
patchChildren,
} from 'web-component-base/utils'
// or separate files

View file

@ -18,12 +18,8 @@ Note that there's a trade off between productivity & lightweight-ness here, and
Treat it as a **stable alpha** product. Though the public APIs are stable, most examples are only useful for simple atomic use-cases due to remaining work needed on the internals.
<Aside type="caution" title="Important">
For building advanced interactions, there is an in-progress work on smart diffing to prevent component children being wiped on interaction.
</Aside>
<Aside type="tip">
If you have some complex needs, we recommend using the `WebComponent` base class with a more mature rendering approach like `lit-html`, and here's a demo for that: [View on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010).
<Aside type="tip" title="Rendering">
As of v5.2, the re-rendering of an `html` template patches the existing DOM in place to PRESERVE focus, caret/selection, an uncommitted `<input>` value, and running CSS transitions during an interaction. Patching is index-based and non-keyed: list items are matched by position, so preserved state follows the position rather than the item when a list reorders. A `key` prop for identity-based matching is not implemented.
</Aside>
## Installation

View file

@ -109,7 +109,7 @@
"size-limit": [
{
"path": "./dist/WebComponent.js",
"limit": "1.4 KB"
"limit": "1.85 KB"
},
{
"path": "./dist/html.js",
@ -119,6 +119,10 @@
"path": "./dist/utils/create-element.js",
"limit": "0.5 KB"
},
{
"path": "./dist/utils/patch.js",
"limit": "0.8 KB"
},
{
"path": "./dist/utils/deserialize.js",
"limit": "0.5 KB"

View file

@ -10,3 +10,4 @@ A running record of how each correctness/feature change affects the `WebComponen
| Correct empty-string / removed attribute handling; drop native-setter writes | 1.33 kB (≈0) | 1.35 kB | **State no longer corrupts on common attribute changes.** `attr=""` coerced to boolean `true` (echoed as `attr="true"`); `removeAttribute` wrote `null` into the proxy and threw before `render()`/`onChanges()`; `deserialize('', 'number')` threw. `attributeChangedCallback` now keeps strings as-is (`''` stays `''`), resets removed attributes to the declared default, wraps `deserialize` so a malformed value falls back to the raw string instead of skipping render, and no longer writes vestigial `this[prop]` fields that clobbered native setters like `title`/`id`/`lang`. Logic swap — no net size cost. |
| `onChanges` gains a clear attribute-vs-property distinction (**breaking**) | 1.34 kB (+0.01 kB) | 1.35 kB | **Self-documenting change payload.** `onChanges` previously passed only `property`, holding the kebab-case _attribute_ name — confusing in a library where `props` are camelCase. `property` now holds the camelCase _prop_ key (matching `this.props` access) and the kebab attribute name moves to a new `attribute` field. Handlers can read `this.props[property]` directly without re-deriving the key. **Breaking:** code reading `changes.property` for the attribute name must switch to `changes.attribute` (see the migration note on the life-cycle-hooks docs page). |
| Buffer attribute-driven render/onChanges until after `onInit` | 1.35 kB (+0.01 kB) | 1.35 kB | **`onInit` is guaranteed to run first.** Per spec, authored attributes fire `attributeChangedCallback` (→ `render`/`onChanges`) _before_ `connectedCallback`, so components that assume `onInit` already ran break in real browsers (happy-dom hides this). A `#connected` flag now buffers the `render`/`onChanges` side effects until after `onInit`, while the prop value is still applied immediately so `props` is correct inside `onInit`. Pre-connect changes are reflected by the first render and not replayed through `onChanges`. |
| 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. |

View file

@ -9,6 +9,7 @@ import {
getCamelCase,
serialize,
deserialize,
patchChildren,
} from './utils/index.js'
/** Component classes whose defaults have been validated (once per class). */
@ -274,20 +275,25 @@ export class WebComponent extends HTMLElement {
} else if (typeof this.template === 'object') {
const tree = this.template
// TODO: smart diffing
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(tree)) {
this.#applyStyles()
/**
* create element
* - resolve prop values
* - attach event listeners
*/
const el = createElement(tree)
if (this.#prevDOM === undefined) {
/**
* first render: create element
* - resolve prop values
* - attach event listeners
*/
const el = createElement(tree)
if (el) {
if (Array.isArray(el)) this.#host.replaceChildren(...el)
else this.#host.replaceChildren(el)
if (el) {
if (Array.isArray(el)) this.#host.replaceChildren(...el)
else this.#host.replaceChildren(el)
}
} else {
// re-render: reconcile in place so focus, caret/selection, an
// uncommitted <input> value, :hover and running transitions survive
patchChildren(this.#host, this.#prevDOM, tree)
}
this.#prevDOM = tree
}

View file

@ -17,18 +17,9 @@ export function createElement(tree) {
* handle props
*/
if (tree.props) {
Object.entries(tree.props).forEach(([prop, value]) => {
const domProp = prop.toLowerCase()
if (domProp === 'style' && typeof value === 'object' && !!value) {
applyStyles(el, value)
} else if (prop in el) {
el[prop] = value
} else if (domProp in el) {
el[domProp] = value
} else {
el.setAttribute(prop, serialize(value))
}
})
Object.entries(tree.props).forEach(([prop, value]) =>
applyProp(el, prop, value)
)
}
/**
* handle children
@ -43,6 +34,29 @@ export function createElement(tree) {
}
}
/**
* Applies one vnode prop to an element: a `style` object is applied rule by
* rule, a name the element owns as a DOM property is assigned (so event
* handlers and non-string values keep their type), anything else becomes a
* serialized attribute. Shared with the reconciler so a patched element gets
* props by exactly the same rule as a freshly created one.
* @param {Element} el the element to apply the prop to
* @param {string} prop the prop name as written in the vnode
* @param {any} value the prop value
*/
export function applyProp(el, prop, value) {
const domProp = prop.toLowerCase()
if (domProp === 'style' && typeof value === 'object' && !!value) {
applyStyles(el, value)
} else if (prop in el) {
el[prop] = value
} else if (domProp in el) {
el[domProp] = value
} else {
el.setAttribute(prop, serialize(value))
}
}
/**
*
* @param el

View file

@ -2,4 +2,5 @@ export { serialize } from './serialize.mjs'
export { deserialize } from './deserialize.mjs'
export { getCamelCase } from './get-camel-case.mjs'
export { getKebabCase } from './get-kebab-case.mjs'
export { createElement } from './create-element.mjs'
export { createElement, applyProp } from './create-element.mjs'
export { patchChildren, patchNode } from './patch.mjs'

165
src/utils/patch.mjs Normal file
View file

@ -0,0 +1,165 @@
import { createElement, applyProp } from './create-element.mjs'
/**
* Flattens a vnode or a (possibly nested) children array into the flat list of
* nodes `createElement` would have produced it nests arrays into document
* fragments, which append flat. `null`/`undefined` entries are dropped so a
* conditional branch removes its node instead of rendering "null".
* @param {any} children a vnode, a children array, or nothing
* @returns {any[]} the flat list of vnodes
*/
function flatten(children) {
return children == null
? []
: [children].flat(Infinity).filter((c) => c != null)
}
/**
* Undoes a prop that is present in the old vnode but gone from the new one,
* mirroring how `applyProp` set it.
* @param {Element} el the element to patch
* @param {string} prop the prop name as written in the vnode
* @param {any} oldValue the value the prop had in the old vnode
*/
function removeProp(el, prop, oldValue) {
const domProp = prop.toLowerCase()
if (domProp === 'style' && typeof oldValue === 'object' && !!oldValue) {
Object.keys(oldValue).forEach((rule) => {
if (rule in el.style) el.style[rule] = ''
})
return
}
const key = prop in el ? prop : domProp in el ? domProp : null
if (key === null) {
el.removeAttribute(prop)
} else {
// reset to the DOM's own empty value for that property's type: `false` for
// booleans (disabled), `''` for strings (id, className), `null` otherwise
// (event handlers, object-valued props)
const current = el[key]
el[key] =
typeof current === 'boolean'
? false
: typeof current === 'string'
? ''
: null
}
}
/**
* Patches a `style` object rule by rule. `applyProp` only ever *sets* truthy
* rules, so on a reused element a rule that was dropped from the object or
* that went falsy, the usual `{ fontStyle: condition && 'italic' }` shape
* has to be cleared explicitly. A full rebuild used to do this implicitly.
* @param {Element} el the element to patch
* @param {any} oldValue the previous `style` prop value
* @param {any} newValue the new `style` prop value
*/
function patchStyle(el, oldValue, newValue) {
const previous = typeof oldValue === 'object' && !!oldValue ? oldValue : {}
const next = typeof newValue === 'object' && !!newValue ? newValue : {}
Object.keys(previous).forEach((rule) => {
if (!next[rule] && rule in el.style) el.style[rule] = ''
})
applyProp(el, 'style', next)
}
/**
* Patches an element's props in place: applies additions and changes, then
* removes props that are gone. Function props (event handlers) are always
* re-applied because `render()`'s `JSON.stringify` diff can't see them.
* @param {Element} el the element to patch
* @param {object | null | undefined} oldProps props from the previous vnode
* @param {object | null | undefined} newProps props from the new vnode
*/
function patchProps(el, oldProps, newProps) {
const previous = oldProps ?? {}
const next = newProps ?? {}
Object.entries(next).forEach(([prop, value]) => {
const isStyleObject =
prop.toLowerCase() === 'style' &&
(typeof value === 'object' || typeof previous[prop] === 'object')
if (isStyleObject) patchStyle(el, previous[prop], value)
else if (typeof value === 'function' || !Object.is(previous[prop], value))
applyProp(el, prop, value)
})
Object.keys(previous).forEach((prop) => {
if (!(prop in next)) removeProp(el, prop, previous[prop])
})
}
/**
* Puts `node` where `dom` is, or appends it when there is no node to replace.
* @param {Node} parent the parent node being patched
* @param {Node | null | undefined} dom the node being replaced, if any
* @param {Node | null | undefined} node the replacement
*/
function place(parent, dom, node) {
if (!(node instanceof Node)) return
if (dom) parent.replaceChild(node, dom)
else parent.appendChild(node)
}
/**
* Reconciles one DOM node against a new vnode, reusing it whenever it is the
* same kind of node so focus, selection, uncommitted input values, `:hover`
* and running transitions survive the re-render.
* @param {Node} parent the parent node being patched
* @param {Node | null | undefined} dom the existing node at this index, if any
* @param {any} oldVnode the vnode that produced `dom`, if known
* @param {any} newVnode the vnode to render
*/
export function patchNode(parent, dom, oldVnode, newVnode) {
if (newVnode == null) {
if (dom) parent.removeChild(dom)
return
}
// primitive: keep the text node and retarget its data
if (typeof newVnode !== 'object') {
const data = String(newVnode)
if (dom && dom.nodeType === 3) {
if (dom.data !== data) dom.data = data
} else {
place(parent, dom, document.createTextNode(data))
}
return
}
const sameTag =
dom &&
dom.nodeType === 1 &&
dom.tagName.toLowerCase() === String(newVnode.type).toLowerCase()
if (!sameTag) {
place(parent, dom, createElement(newVnode))
return
}
const previous =
typeof oldVnode === 'object' && oldVnode !== null ? oldVnode : {}
patchProps(dom, previous.props, newVnode.props)
patchChildren(dom, previous.children, newVnode.children)
}
/**
* Reconciles a parent's child nodes against a new children list by index
* (non-keyed), trimming any leftover trailing nodes.
* @param {Node} parent the parent node to patch into
* @param {any} oldChildren the previous vnode children (or previous tree)
* @param {any} newChildren the new vnode children (or new tree)
*/
export function patchChildren(parent, oldChildren, newChildren) {
const previous = flatten(oldChildren)
const next = flatten(newChildren)
const nodes = [...parent.childNodes]
next.forEach((vnode, i) => patchNode(parent, nodes[i], previous[i], vnode))
for (let i = next.length; i < nodes.length; i++) parent.removeChild(nodes[i])
}

View file

@ -147,6 +147,32 @@ describe('render()', () => {
expect(el.querySelector('span').textContent).toBe('5')
})
it('patches a prop-triggered re-render in place, keeping focus and caret', () => {
class Patched extends WebComponent {
static props = { label: 'before' }
get template() {
return html`
<label>${this.props.label}</label>
<input type="text" />
`
}
}
const el = mount(Patched)
const input = el.querySelector('input')
input.focus()
input.value = 'uncommitted'
input.setSelectionRange(3, 3)
el.props.label = 'after'
expect(el.querySelector('label').textContent).toBe('after')
// same element instance — not rebuilt
expect(el.querySelector('input')).toBe(input)
expect(input.value).toBe('uncommitted')
expect(document.activeElement).toBe(input)
expect(input.selectionStart).toBe(3)
})
it('can be overridden to use a custom rendering approach', () => {
class Custom extends WebComponent {
render() {

View file

@ -71,6 +71,7 @@ One spec per example folder:
| `use-shadow` | rendering into an open shadow root |
| `constructed-styles` | `static styles` via `adoptedStyleSheets` in the shadow root |
| `templating` | interpolated lists / links via the built-in `html` |
| `render-reconciliation` | in-place patching — focus/caret/uncommitted value, attribute-only updates, uninterrupted transitions, prop+style removal, list grow/shrink, and the non-keyed reorder limitation |
## Intentionally not covered

View file

@ -0,0 +1,125 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/render-reconciliation/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('a controlled input keeps its element, focus, caret and value while typing', () => {
document.body.innerHTML = '<controlled-input></controlled-input>'
const el = document.querySelector('controlled-input')
const input = el.querySelector('#field')
input.focus()
input.value = 'Ayo'
input.setSelectionRange(3, 3)
// the real event the browser fires while typing — this writes a prop, which
// re-renders the whole component
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(el.querySelector('#field')).toBe(input)
expect(document.activeElement).toBe(input)
expect(input.value).toBe('Ayo')
expect(input.selectionStart).toBe(3)
// ...and the rest of the tree did update
expect(el.querySelector('#echo').textContent.trim()).toBe('Ayo')
})
test('an attribute-only change patches in place and keeps keyboard focus', () => {
document.body.innerHTML = '<segmented-control></segmented-control>'
const el = document.querySelector('segmented-control')
const two = el.querySelector('#two')
two.focus()
expect(two.getAttribute('aria-selected')).toBe('false')
two.click()
expect(el.querySelector('#two')).toBe(two)
expect(two.getAttribute('aria-selected')).toBe('true')
expect(two.className).toBe('seg selected')
expect(el.querySelector('#one').getAttribute('aria-selected')).toBe('false')
expect(document.activeElement).toBe(two)
expect(el.querySelector('#selected').textContent.trim()).toBe('two')
})
test('an unrelated re-render leaves an animating element untouched', async () => {
document.body.innerHTML = '<transition-safe></transition-safe>'
const el = document.querySelector('transition-safe')
const root = el.shadowRoot
const box = root.querySelector('#box')
// read layout first so the transition has a resolved start value
const startWidth = box.getBoundingClientRect().width
root.querySelector('#run').click()
// let a few of the unrelated re-renders land mid-transition
await new Promise((resolve) => setTimeout(resolve, 500))
// same element instance, still growing — a rebuilt node would have snapped
// back to the start width
expect(root.querySelector('#box')).toBe(box)
const midFlight = box.getBoundingClientRect().width
expect(midFlight).toBeGreaterThan(startWidth)
// still mid-transition, not snapped to the end value
expect(midFlight).toBeLessThan(el.getBoundingClientRect().width)
expect(Number(root.querySelector('#ticks').textContent)).toBeGreaterThan(0)
})
test('props, attributes and style rules removed from the tree are undone', () => {
document.body.innerHTML = '<prop-removal></prop-removal>'
const el = document.querySelector('prop-removal')
const target = el.querySelector('#target')
expect(target.disabled).toBe(true)
expect(target.getAttribute('data-badge')).toBe('decorated')
expect(target.style.outline).not.toBe('')
el.querySelector('#toggle').click()
// same element, decorations actively undone rather than left behind
expect(el.querySelector('#target')).toBe(target)
expect(target.disabled).toBe(false)
expect(target.hasAttribute('data-badge')).toBe(false)
expect(target.style.outline).toBe('')
})
test('list grow/shrink reuses surviving rows', () => {
document.body.innerHTML = '<item-list></item-list>'
const el = document.querySelector('item-list')
const firstRow = el.querySelector('#list li')
el.querySelector('#add').click()
expect(el.querySelectorAll('#list li').length).toBe(4)
expect(el.querySelector('#list li')).toBe(firstRow)
el.querySelector('#drop').click()
el.querySelector('#drop').click()
const labels = [...el.querySelectorAll('.row-label')].map((s) =>
s.textContent.trim()
)
expect(labels).toEqual(['alpha', 'bravo'])
expect(el.querySelector('#list li')).toBe(firstRow)
})
test('reordering is matched by position, not identity (non-keyed)', () => {
document.body.innerHTML = '<item-list></item-list>'
const el = document.querySelector('item-list')
const rows = [...el.querySelectorAll('#list li')]
const firstNote = el.querySelector('.row-note')
firstNote.value = 'my note'
el.querySelector('#reverse').click()
const labels = [...el.querySelectorAll('.row-label')].map((s) =>
s.textContent.trim()
)
// the rendered result is correct...
expect(labels).toEqual(['charlie', 'bravo', 'alpha'])
// ...but no node moved: each row kept its slot and had its content rewritten,
// so the typed note stayed with position 1 instead of following 'alpha'
expect([...el.querySelectorAll('#list li')]).toEqual(rows)
expect(el.querySelector('.row-note')).toBe(firstNote)
expect(firstNote.value).toBe('my note')
})

View file

@ -35,6 +35,9 @@ describe('utils exports', () => {
'getCamelCase',
'getKebabCase',
'createElement',
'applyProp',
'patchNode',
'patchChildren',
]) {
expect(typeof utils[name], name).toBe('function')
}

218
test/utils/patch.test.mjs Normal file
View file

@ -0,0 +1,218 @@
import { describe, expect, it, vi } from 'vitest'
import { createElement } from '../../src/utils/create-element.mjs'
import { patchChildren } from '../../src/utils/patch.mjs'
import { html } from '../../src/html.js'
/**
* Mounts `tree` into a fresh host the way the first render does, and returns
* the host plus a `patch` that reconciles it against the next tree.
* @param tree the initial vnode tree
*/
function mount(tree) {
const host = document.createElement('div')
document.body.appendChild(host)
const el = createElement(tree)
if (el) host.replaceChildren(el)
let previous = tree
return {
host,
/**
* @param next the next vnode tree
*/
patch(next) {
patchChildren(host, previous, next)
previous = next
},
}
}
describe('patchChildren', () => {
it('updates text in place without replacing the node', () => {
const { host, patch } = mount(html`<p>one</p>`)
const p = host.querySelector('p')
const text = p.firstChild
patch(html`<p>two</p>`)
expect(host.querySelector('p')).toBe(p)
expect(p.firstChild).toBe(text)
expect(p.textContent).toBe('two')
})
it('adds, changes and removes props on the same element', () => {
const { host, patch } = mount(
html`<div id="a" data-keep="1" data-drop="x"></div>`
)
const div = host.querySelector('div')
patch(html`<div id="b" data-keep="1" title="hi"></div>`)
expect(host.querySelector('div')).toBe(div)
expect(div.id).toBe('b')
expect(div.getAttribute('data-keep')).toBe('1')
expect(div.hasAttribute('data-drop')).toBe(false)
expect(div.title).toBe('hi')
})
it('removes a dropped boolean DOM property', () => {
const { host, patch } = mount(html`<input disabled=${true} />`)
const input = host.querySelector('input')
patch(html`<input />`)
expect(host.querySelector('input')).toBe(input)
expect(input.disabled).toBe(false)
})
it('clears style rules that are gone from the new tree', () => {
const { host, patch } = mount(html`<div style=${{ color: 'red' }}>x</div>`)
const div = host.querySelector('div')
patch(html`<div>x</div>`)
expect(host.querySelector('div')).toBe(div)
expect(div.style.color).toBe('')
})
it('clears a style rule that goes falsy in the new style object', () => {
// the `{ fontStyle: condition && 'italic' }` shape: a full rebuild used to
// drop the rule implicitly, a reused element has to be told to
const { host, patch } = mount(
html`<p style=${{ fontStyle: 'italic', color: 'red' }}>x</p>`
)
const p = host.querySelector('p')
patch(html`<p style=${{ fontStyle: false, color: 'red' }}>x</p>`)
expect(host.querySelector('p')).toBe(p)
expect(p.style.fontStyle).toBe('')
expect(p.style.color).toBe('red')
})
it('clears rules dropped from the style object entirely', () => {
const { host, patch } = mount(
html`<p style=${{ outline: '2px solid red' }}>x</p>`
)
const p = host.querySelector('p')
patch(html`<p style=${{}}>x</p>`)
expect(p.style.outline).toBe('')
})
it('re-applies event handlers, which the JSON diff cannot see', () => {
const first = vi.fn()
const second = vi.fn()
const { host, patch } = mount(html`<button onclick=${first}>go</button>`)
patch(html`<button onclick=${second}>go</button>`)
host.querySelector('button').click()
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledOnce()
})
it('replaces a node when the tag changes', () => {
const { host, patch } = mount(html`<p>x</p>`)
const p = host.querySelector('p')
patch(html`<span>x</span>`)
expect(p.isConnected).toBe(false)
expect(host.querySelector('span').textContent).toBe('x')
})
it('grows and shrinks a list, keeping the surviving nodes', () => {
const list = (items) =>
html`<ul>
${items.map((i) => html`<li>${i}</li>`)}
</ul>`
const { host, patch } = mount(list(['a', 'b']))
const first = host.querySelectorAll('li')[0]
patch(list(['a', 'b', 'c']))
let items = [...host.querySelectorAll('li')]
expect(items.map((li) => li.textContent.trim())).toEqual(['a', 'b', 'c'])
expect(items[0]).toBe(first)
patch(list(['a']))
items = [...host.querySelectorAll('li')]
expect(items.map((li) => li.textContent.trim())).toEqual(['a'])
expect(items[0]).toBe(first)
})
/**
* Patching is index-based, so a reordered list is matched by position, not
* identity. These specs pin that documented limitation: the resulting DOM is
* always correct, but a node's preserved state belongs to its *slot*, not to
* the item that used to live there. They are what a future `key` prop would
* change if identity-based matching lands, expect these to be rewritten.
*/
describe('non-keyed limitation', () => {
const list = (items) =>
html`<ul>
${items.map((i) => html`<li>${i}</li>`)}
</ul>`
it('renders a reorder correctly, but rewrites nodes instead of moving them', () => {
const { host, patch } = mount(list(['a', 'b', 'c']))
const [first, second, third] = host.querySelectorAll('li')
patch(list(['c', 'b', 'a']))
const items = [...host.querySelectorAll('li')]
// final DOM is right...
expect(items.map((li) => li.textContent.trim())).toEqual(['c', 'b', 'a'])
// ...but every node stayed in place and had its text rewritten
expect(items).toEqual([first, second, third])
expect(first.textContent.trim()).toBe('c')
})
it('keeps preserved state on the position, not on the item', () => {
const rows = (items) =>
html`<ul>
${items.map((i) => html`<li><input value=${i} /></li>`)}
</ul>`
const { host, patch } = mount(rows(['a', 'b', 'c']))
const inputs = [...host.querySelectorAll('input')]
// the user is typing in the row showing 'b'
inputs[1].focus()
inputs[1].value = 'typing'
// 'a' is removed, so 'b' shifts up into index 0
patch(rows(['b', 'c']))
const after = [...host.querySelectorAll('input')]
expect(after.length).toBe(2)
// focus did NOT follow 'b' to its new position — it stayed on index 1,
// which now holds 'c'
expect(document.activeElement).toBe(inputs[1])
expect(after[1]).toBe(inputs[1])
// and because that slot's declared `value` changed ('b' -> 'c'), the
// typed-but-uncommitted text is overwritten. Preserving an uncommitted
// value only holds while the surrounding vnode for that position is
// unchanged — a reorder is not that.
expect(inputs[1].value).toBe('c')
})
})
it('preserves focus and an uncommitted input value across a re-render', () => {
const view = (label) => html`
<label>${label}</label>
<input type="text" />
`
const { host, patch } = mount(view('before'))
const input = host.querySelector('input')
input.focus()
input.value = 'typed but uncommitted'
patch(view('after'))
expect(host.querySelector('input')).toBe(input)
expect(input.value).toBe('typed but uncommitted')
expect(document.activeElement).toBe(input)
expect(host.querySelector('label').textContent).toBe('after')
})
})