Hot-swap
swap() replaces the template of a live component and patches only what actually
differs. State survives, DOM nodes survive where they can, and nested components are carried
across rather than remounted.
It exists for tools that revise a running UI: visual editors, live playgrounds, generated components, HMR.
import Lips, { compileTemplate } from '@lipsjs/lips'
const app = lips.render('editor', template).appendTo('#app')
const { ir } = compileTemplate( revisedSource )
const report = app.swap( ir )
What it does
swap() diffs the new IR against the live one by expression source, not by identity, so
two independent compilations of related templates compare correctly.
| Difference | What happens |
|---|---|
| Nothing changed | subtree kept wholesale, zero DOM work |
| A binding changed | effects rewired onto the same nodes |
| A child block changed | that block re-executed at its existing anchor |
| Static markup changed | that block rebuilt — with its components salvaged |
State lives outside the IR, so it survives by construction.
The report
const report = app.swap( ir )
report.changes
// [ { kind: 'binds', path: 'root' },
// { kind: 'block', path: 'root/2' } ]
report.salvaged
// [ 'layer-panel', 'toolbar' ]
changes lists every region touched — kind is skeleton, binds or block, and path
locates it in the block tree. An editor can use it to highlight exactly what a revision
affected.
salvaged names the component instances carried through instead of remounted.
Instance salvage
When a revision changes a block’s static markup, that block has to be rebuilt. Rather than destroy everything inside it, Lips salvages the component instances: each is released from the region, offered to the fresh render, and re-homed there.
A salvaged component keeps its state, its DOM nodes, its handlers and its event listeners. Only its parent-side wiring — input expressions and event instructions — is rebuilt against the new call site. No lifecycle hook fires: from the component’s point of view, nothing happened.
// v1
`<div class="card"><stepper/></div>`
// v2 — the wrapper changed, the stepper did not
`<section class="card wide"><stepper/></section>`
The stepper keeps its count. Components the revision dropped are destroyed normally, with
onDestroy and component:destroy.
The same applies when only a component’s own call site changes — <panel list=state.a/>
becoming <panel list=state.b/> re-wires the instance instead of replacing it. Inputs the
revision removed are cleared from it.
Identity
A key input decides which live instance a call site claims:
<!-- follows the key, wherever the revision moves it -->
<layer key=layer.id name=layer.name/>
<!-- follows position among same-name siblings -->
<layer name=layer.name/>
Without a key, components match by position among same-name components in render order —
the rule keyless JSX lists already follow. Give regenerated components a stable key if a
revision may reorder or relocate them; that is what lets an instance survive being moved
somewhere else in the tree.
What still resets
Salvage preserves state and node identity, not DOM attachment. Nodes are detached and
re-inserted, so anything that reacts to being re-parented still does — an <iframe> reloads,
CSS transitions restart. Keyboard focus is restored automatically after a swap.
<if> and <for> bodies are not salvaged as blocks; only the components inside them are.
Working with generated templates
Because compilation is a plain function and diagnostics never throw, a bad revision is reportable rather than fatal:
const { ir, diagnostics } = compileTemplate( generatedSource )
const errors = diagnostics.filter( d => d.severity === 'error' )
if( errors.length ){
// hand back line/col/code/hint — nothing was applied
return showErrors( errors )
}
const { changes, salvaged } = app.swap( ir )
highlight( changes )
For untrusted generated markup, render the preview with mode: 'interpreted' so nothing is
ever passed to Function. See Precompile & CSP.
Example: a live editor loop
import Lips, { compileTemplate } from '@lipsjs/lips'
const lips = new Lips()
const preview = lips.render('preview', { state: { count: 0 }, default: source })
preview.appendTo('#preview')
editor.on('change', next => {
const { ir, diagnostics } = compileTemplate( next )
if( diagnostics.some( d => d.severity === 'error' ) ){
markProblems( diagnostics )
return
}
const { changes, salvaged } = preview.swap( ir )
flashRegions( changes )
console.log(`kept ${salvaged.length} component instances`)
})
The preview keeps its state across every keystroke that compiles.