Skip to content

How hot patching works

Plain Vite HMR re-runs a module; Lit then sees every template as new and rebuilds the whole component. This page explains the two mechanisms that stop that, and why some shapes still force a reload.

lit-html answers “is this the same template?” by the object identity of the TemplateStringsArray, not by its text. Two arrays with identical content are two different templates to it.

Vite HMR re-executes the module you edited. Every html`…` in it produces a fresh strings array, so lit-html sees every template as new and rebuilds the component’s entire subtree. Focus, selection, scroll offsets and input values go with it, child @state resets, and cached DOM references are left pointing at detached nodes.

The class has the same problem from the other side. The re-executed module calls customElements.define() again, the platform registry refuses to redefine a tag, and the instances already on the page keep the prototype chain they were built with.

Template interning means reusing the same template object for identical template text, so Lit sees an unchanged template as unchanged.

On the dev server the plugin wraps Lit’s html, svg, mathml and css tags. Each wrapper looks up its strings array by content: the first array seen for a given content becomes canonical, and every later array with the same content maps onto it. Keys are namespaced per tag, so an html and an svg template with equal text never share a parsed template.

The effect is local. Only the literal whose text actually changed gets a new identity; its siblings keep theirs and keep their DOM.

The playground fixture below is the shape that makes it visible. Edit the marked header literal and only the <header> is rebuilt: the <ul> keeps its DOM identity, and the <input> keeps focus, value and selection.

playground/src/hmr-siblings.ts
import {LitElement, css, html} from 'lit';
import {customElement} from 'lit/decorators.js';
/**
* The key fixture: `render()` composes two separate `html` literals plus an
* `<input>` in the outer template. Editing the header literal must rebuild
* only the header part — the list keeps DOM identity and the input keeps
* focus, value, and selection.
*/
@customElement('hmr-siblings')
export class HmrSiblings extends LitElement {
static override styles = css`
input:focus {
outline: 2px solid dodgerblue;
}
.badge {
font-size: 0.8em;
color: var(--muted, #666);
}
`;
private renders = 0;
private renderHeader() {
return html`<header id="header"><h2>Siblings: HELLO</h2></header>`;
}
private renderList() {
return html`
<ul id="list">
<li>alpha</li>
<li>beta</li>
<li>gamma</li>
</ul>
`;
}
override render() {
return html`
${this.renderHeader()} ${this.renderList()}
<input id="text" placeholder="type here" />
<span class="badge" id="badge">renders: 0</span>
`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
const badge = this.renderRoot.querySelector('#badge');
if (badge !== null) {
badge.textContent = `renders: ${this.renders}`;
}
}
}

Edit a string inside renderList() instead and the trade flips: the list rebuilds, the header does not.

Interning saves the DOM. Patching saves the class.

The plugin intercepts customElements.define(). When the re-executed module redefines an existing tag, the new class is not registered. The plugin patches the canonical class instead: the class the browser registered first, updated in place rather than replaced.

Patching syncs own members from the new class onto the canonical one: keys that disappeared are deleted, then every remaining own descriptor is copied, for the prototype and the statics. Reactive property values are snapshotted first and written back through the new accessors, static styles is re-adopted, and every live instance re-renders once.

Both mechanisms are dev-server only. A production build never loads either one.

Because the class object is patched and not swapped, every reference to it stays valid: the custom elements registry, the instances on the page, other modules that imported the class, and controllers that closed over it.

Because interning keeps unchanged templates unchanged, the DOM those instances own is not rebuilt. Focus, selection, scroll position, input values and child component state survive an edit to their parent.

An edited template is new to Lit, so it clones fresh DOM for it, and every custom element inside is a new instance. For a moment after each patch the plugin pairs the elements that leave a shadow root with the new ones of the same tag that arrive in it, in document order, and hands each new element its predecessor’s state. With hmr.childState: 'reuse', a new element that carries no bindings is replaced by its predecessor, which keeps its full identity.

One thing does not: state declared at the top level of the module you edited. That module re-executes, so its own consts are re-created. Only the class rides through.

An incompatible patch is a component shape the plugin cannot update in place, so it reloads or warns instead. Four causes matter.

Standard accessor decorators. Reactive properties declared with TC39 decorators close over private slots created fresh on every class evaluation. Accessors copied onto the canonical class would read the wrong slot. The plugin detects this before it touches anything.

Native #private fields, with the rewrite off. Instances carry the private brand from the previous class evaluation, and methods copied from the new class expect the new one. By default the dev server rewrites #private members to shared Symbol.for() keys, which removes the brand and lets the patch go through. With hmr.privateFields: false, a copied accessor that touches private state fails the patch, and a method or render() that touches it throws later on every update.

A changed observedAttributes list. The platform snapshots that list at define time and nothing can change it afterwards. The patch succeeds, so this case never reloads; the old list stays in effect until you reload by hand.

New reactive properties on live instances. A property the edit introduces gets its initial value in the constructor, which has already run for every instance on the page. Those instances have no value for it, so a template that reads it throws. Reload once after adding one.

Limitations lists all of them with the console line each prints.

The indicator reports which path an update took. Green means a component re-rendered, and the running count advances. Cyan means something updated without re-rendering a component, in practice a shared stylesheet: one CSSStyleSheet object adopted by many shadow roots, swapped in place. A full reload resets the count, which is the quickest way to tell a reload from a patch.

This package started in a fork of the lit monorepo as a proposed @lit-labs/vite-hmr package, and was never published from there. It now lives on its own. Lit is kept here as a read-only submodule for reference and canary testing against lit main.