Limitations
Eight shapes behave differently under a hot patch, an edit applied to the running page without a reload. Each entry gives the console line it prints, the reason, and the fix; if you are chasing a reload and do not know which one fired, start at Troubleshooting.
The first three are detected, not guessed, and
hmr.onIncompatible decides whether the
page reloads or only warns. The console lines below are the default 'reload'
wording.
Standard accessor decorators
Section titled “Standard accessor decorators”What you see
[lit-plugin] <my-element>: standard accessor decorators — performing full reload.Why. Reactive properties declared with standard TC39 decorators close over private slots created fresh on each class evaluation. Accessors copied onto the canonical class, the class the browser registered first, would read the wrong slot. The plugin checks for this before it touches anything.
What to do. Use experimental decorators with experimentalDecorators: true
and useDefineForClassFields: false, or declare static properties. Both are
patched in place. With onIncompatible: 'warn' the page stays up and you reload
by hand.
Native #private fields
Section titled “Native #private fields”Patched in place by default. In dev the plugin rewrites each #private member
to a Symbol.for() key built from the file, the class and the member name.
Instances created before an edit and methods copied from after it use the same
key, so private state survives the edit. Builds keep real #private.
On Vite 7, esbuild lowers #private members of a decorated class to
module-level WeakMaps and WeakSets before the plugin sees them. The plugin
then fetches each of those from a registry keyed by the file and the slot’s
name instead, with the same effect. What’s left:
- Dev drops the brand check. Reading
obj.#xfrom the wrong object returnsundefinedinstead of throwing, andObject.getOwnPropertySymbols()lists private members. Code that relies on either behaves differently in dev. - Decorated private members such as
@state() accessor #xleave the whole module unrewritten. They already fall under standardaccessordecorators. - Lowered classes inside functions, such as a mixin’s, keep fresh slots on each evaluation, so on Vite 7 their private state fails as before.
- A mixin applied twice in one chain shares its private keys across both applications, since every class it returns has the same key.
- Two same-named classes in one file are told apart by order. Adding one above the other resets the lower one’s private state once.
With hmr.privateFields: false, or a module the rewrite skips, you get the
old failure. A copied reactive-property accessor that touches #private state
throws inside the patch, which is reported as patching failed and honours
onIncompatible:
[lit-plugin] <my-element>: patching failed: Cannot read private member #count from an object whose class did not declare it — performing full reload.A render() or method that touches it throws later, in Lit’s asynchronous
update, as an uncaught TypeError. TypeScript’s private modifier compiles to
a plain property and avoids both.
observedAttributes changes
Section titled “observedAttributes changes”What you see
[lit-plugin] <my-element> changed observedAttributes; the platform registry can't pick this up — reload recommended.Why. The platform snapshots observedAttributes when the element is
defined, and nothing can change that list afterwards. The patch itself succeeds,
so this case never reloads, whatever onIncompatible says. The old attribute
list stays in effect until you reload.
What to do. Reload the page. The message is informational, and the panel’s Components tab keeps a record of it.
Mixed exports
Section titled “Mixed exports”What you see. The component updates, and other values exported from the same module look stale in modules that imported them.
Why. A module exporting a component and other values is self-accepting: it tells Vite it can apply its own updates, so the edit stops there. Importers keep the bindings they already read until they re-execute for their own reasons. Class exports stay correct, because the canonical class object is patched in place.
What to do. Move shared non-class exports into their own module.
Module-level state
Section titled “Module-level state”What you see. A counter, cache, or signal declared at the top level of an edited module resets on every edit.
Why. A hot patch re-executes the edited module, which re-runs its top-level code. Only the component class survives, not the module’s own variables.
What to do. Keep shared signals, context keys, and caches in a module you do not edit in the same loop. See Ecosystem libraries.
Children of an edited template
Section titled “Children of an edited template”What you see. After a parent edit, a child element rendered in the edited template literal keeps its count, but a reference you held to it is stale, and listeners or controllers it set up are new. Less often:
- An edit that removes one child and adds another of the same tag in the same shadow root hands the removed child’s state to the added one.
- A value set through an element directive such as
spread()reverts to the old element’s value. - The old and new element share the same object or array for a carried property, so mutating one mutates the other’s.
- Under
'reuse', a@query(…, true)cached during the edit still points at the discarded element.
Why. Lit clones fresh DOM for an edited template, so every child in it is
a new element. With the default hmr.childState: 'transfer', the plugin pairs
old and new children by tag and order within their shadow root and copies
reactive properties and #private fields across. It skips values the template
sets through attributes and property, boolean or attribute bindings, but it
can’t see what a directive sets. It copies references, not deep clones.
What to do. Move the part you edit often into its own template literal,
returned from a helper method, so the children’s template never changes. For a
child without bindings, hmr.childState: 'reuse' puts the original element
back. 'reset' turns the transfer off. See
hmr.childState.
Interning map growth
Section titled “Interning map growth”What you see. Nothing. Memory use creeps up across a long dev session.
Why. Template interning reuses the same template object for identical template text, so Lit sees an unchanged template as unchanged. The map is page global, and stale versions of edited templates stay in it.
What to do. Nothing, in most sessions. Growth is bounded by how many edits you make, and any full reload clears it.
Rolldown full-bundle dev mode
Section titled “Rolldown full-bundle dev mode”What you see. The HMR features do not apply.
Why. The plugin targets the standard Vite dev server pipeline, where each module is served on its own and re-executed individually.
What to do. Use the standard dev server. There is no supported configuration of the full-bundle mode.