feat: smart diffing
This commit is contained in:
parent
56cb1de7db
commit
c7d87086a2
18 changed files with 904 additions and 32 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
83
demo/examples/render-reconciliation/index.html
Normal file
83
demo/examples/render-reconciliation/index.html
Normal 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><input></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>
|
||||
216
demo/examples/render-reconciliation/index.js
Normal file
216
demo/examples/render-reconciliation/index.js
Normal 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)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import {
|
|||
getCamelCase,
|
||||
getKebabCase,
|
||||
createElement,
|
||||
applyProp,
|
||||
patchNode,
|
||||
patchChildren,
|
||||
} from 'web-component-base/utils'
|
||||
|
||||
// or separate files
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getCamelCase,
|
||||
serialize,
|
||||
deserialize,
|
||||
patchChildren,
|
||||
} from './utils/index.js'
|
||||
|
||||
/** Component classes whose defaults have been validated (once per class). */
|
||||
|
|
@ -274,12 +275,12 @@ 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()
|
||||
|
||||
if (this.#prevDOM === undefined) {
|
||||
/**
|
||||
* create element
|
||||
* first render: create element
|
||||
* - resolve prop values
|
||||
* - attach event listeners
|
||||
*/
|
||||
|
|
@ -289,6 +290,11 @@ export class WebComponent extends HTMLElement {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
165
src/utils/patch.mjs
Normal 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])
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
125
test/e2e/render-reconciliation.test.mjs
Normal file
125
test/e2e/render-reconciliation.test.mjs
Normal 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')
|
||||
})
|
||||
|
|
@ -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
218
test/utils/patch.test.mjs
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue