diff --git a/AGENTS.md b/AGENTS.md index 7b0cb5e..9b9abf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 1573ccd..4fc2ad5 100644 --- a/README.md +++ b/README.md @@ -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 ``'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: diff --git a/demo/examples/render-reconciliation/index.html b/demo/examples/render-reconciliation/index.html new file mode 100644 index 0000000..85fcb05 --- /dev/null +++ b/demo/examples/render-reconciliation/index.html @@ -0,0 +1,83 @@ + + + + + + Render reconciliation + + + + + + + +

Render reconciliation

+

+ Re-renders of an html 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. +

+ +

1. Focus, caret and uncommitted value survive

+

+ A controlled field: every keystroke writes back to a prop and re-renders + the component, yet the <input> is the same element + throughout. +

+ + +

2. Attribute-only changes patch in place

+

+ Selecting a tab changes only aria-selected and + class. Reach a tab with the keyboard, press Enter, and focus + stays on it — no node was replaced. +

+ + +

3. Running CSS transitions are not restarted

+

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

+ + +

4. Removed props and attributes are undone

+

+ Props that disappear from the new tree are actively reset on the reused + element — a dropped disabled, data-* attribute + and inline style rules all go away. +

+ + +

5. Lists — and the non-keyed limitation

+

+ Appending and removing reuses the surviving rows. Reordering, however, is + matched by position: the rendered result is correct, but + preserved state stays with the slot rather than following the item. +

+ + + diff --git a/demo/examples/render-reconciliation/index.js b/demo/examples/render-reconciliation/index.js new file mode 100644 index 0000000..60183f5 --- /dev/null +++ b/demo/examples/render-reconciliation/index.js @@ -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 + * `` 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` + + (this.props.value = e.target.value)} + /> +

+ Prop value: ${this.props.value || '(empty)'} +

+

Renders: ${this.#renders}

+ ` + } + + #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` + + ` + return html` +
+ ${tab('one', 'One')} ${tab('two', 'Two')} ${tab('three', 'Three')} +
+

Selected: ${this.props.value}

+ ` + } +} +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` +
+

+ Unrelated re-renders while it animates: + ${this.props.ticks} +

+ + ` + } + + #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` + +

+ disabled, data-badge and the inline + style are present only while decorated — toggling removes + them from the same element instead of building a new one. +

+ + ` + } +} +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` + + + + +

+ Type a note into the first row, then press Reverse: the labels + swap but your note stays in row 1, because rows are matched by position. + A key prop would be needed to make state follow the item. +

+ ` + } + + #set(items) { + this.props.items = items + } +} +customElements.define('item-list', ItemList) diff --git a/demo/index.html b/demo/index.html index 5a85201..d378a3c 100644 --- a/demo/index.html +++ b/demo/index.html @@ -87,6 +87,12 @@ Lists, links, style attributes — plus a lit-html variant. + +
Render reconciliation
+
+ In-place patching: focus, caret, transitions and prop removal. +
+
Style objects
Computed, conditional inline styles.
diff --git a/docs/src/content/docs/guides/exports.md b/docs/src/content/docs/guides/exports.md index e535194..52e2876 100644 --- a/docs/src/content/docs/guides/exports.md +++ b/docs/src/content/docs/guides/exports.md @@ -30,6 +30,9 @@ import { getCamelCase, getKebabCase, createElement, + applyProp, + patchNode, + patchChildren, } from 'web-component-base/utils' // or separate files diff --git a/docs/src/content/docs/guides/getting-started.mdx b/docs/src/content/docs/guides/getting-started.mdx index 3ddc5fc..b482683 100644 --- a/docs/src/content/docs/guides/getting-started.mdx +++ b/docs/src/content/docs/guides/getting-started.mdx @@ -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. - - -