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.
Why Lit loses state on a re-execution
Section titled “Why Lit loses state on a re-execution”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
Section titled “Template interning”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.
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.
In-place class patching
Section titled “In-place class patching”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.
What the plugin can therefore promise
Section titled “What the plugin can therefore promise”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.
Why some shapes cannot be patched
Section titled “Why some shapes cannot be patched”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.
What the indicator colours mean
Section titled “What the indicator colours mean”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.
Relationship to lit
Section titled “Relationship to lit”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.