Summary
html.js's render(container, template) keeps a per-container cache of parts for fast patching on subsequent calls with the same template. When a consumer clears container externally between renders (e.g. container.innerHTML = '', or a sibling component appends/replaces its own DOM into the same container), the cached parts reference detached nodes. The next render() call hits the cache, calls patch() against orphaned nodes, and returns without re-appending any DOM. The container stays blank.
The bug is silent — no exception is thrown.
Reproduce
Confirmed against gh-pages html.js (size 9013 bytes) consumed via the LOSOS shell:
import { html, render } from './html.js'
var c = document.createElement('div')
document.body.appendChild(c)
// First render — builds DOM, caches parts.
function tpl(n) { return html`<p>count: ${n}</p>` }
render(c, tpl(1))
console.log(c.innerHTML) // <p>count: 1</p> ✓
// Something else clears the container.
c.innerHTML = ''
console.log(c.innerHTML) // (empty) ✓
// Second render — same template. Bug: stays blank.
render(c, tpl(2))
console.log(c.innerHTML) // (empty) ✗ (expected: <p>count: 2</p>)
Where in the source
html.js:46–51 (gh-pages):
var prev = cache.get(container)
// Same template shape — patch only the holes
if (prev && prev.strings === template.strings) {
patch(prev.parts, prev.values, template.values)
prev.values = template.values
return
}
prev.strings === template.strings matches because tagged template strings arrays are interned by the JS engine — the same source-code template literal returns the same array reference across calls. So the cache hits whenever the same template is re-rendered, regardless of whether the container's DOM was wiped in between.
patch() (line 128) only mutates the existing nodes in prev.parts. If those nodes have been detached from container, the mutations apply to orphan DOM that nothing displays.
Real-world hit
Surfaced via nosdav/browser's LOSOS shell + panes/source-pane.js. The shell's selectPane does content.innerHTML = '' between tab switches; sharing/folder/etc. panes assemble their own DOM directly into content. After visiting any of those panes, switching back to source-pane (which uses LOSOS html + render) renders blank — the cached parts from the first visit are detached, patch silently no-ops, no fresh build happens.
A debug log inside source-pane confirmed the render() call IS reached with valid input — foundEl: true, hasJsonLd: true, textLen: 213 — so the bug is downstream in html.js, not in the pane.
Proposed fix
Validate the cache against the current DOM before using the patch shortcut. One condition added to the existing if:
var prev = cache.get(container)
- // Same template shape — patch only the holes
- if (prev && prev.strings === template.strings) {
+ // Same template shape — patch only the holes. Validate that the
+ // cache's DOM is still attached: if the container was externally
+ // cleared, prev.parts references detached nodes and patch() would
+ // silently no-op.
+ if (
+ prev &&
+ prev.strings === template.strings &&
+ prev.parts.length > 0 &&
+ container.contains(prev.parts[0].node)
+ ) {
patch(prev.parts, prev.values, template.values)
prev.values = template.values
return
}
Both text parts ({type:'text', node: marker}) and attribute parts ({type:'attr', node: el, name}) carry a .node property — findParts (line 105, 120) sets it for both — so the check is uniform.
Costs
- One DOM walk (
Node.contains, O(depth)) per render-call cache-hit. Sub-microsecond for typical pane container depths.
- API surface: unchanged. Same signature, same return value, same params.
- Behavioral change only on the previously-broken path: stale-cache cases now do a fresh build instead of silently producing nothing. No happy-path regression.
- WeakMap eviction story unchanged.
Out of scope
- Partial detachment (some parts still attached, others manually removed by external code). Pre-existing edge case the cache cannot cleanly handle; unaffected by this fix.
Acceptance
- Repro snippet above produces
<p>count: 2</p> after the fix.
nosdav/browser's source-pane no longer goes blank when navigating away and back.
- All existing LOSOS panes continue to render correctly on first call and on subsequent calls where the container's DOM was preserved (the patch fast-path).
Summary
html.js'srender(container, template)keeps a per-container cache ofpartsfor fast patching on subsequent calls with the same template. When a consumer clearscontainerexternally between renders (e.g.container.innerHTML = '', or a sibling component appends/replaces its own DOM into the same container), the cachedpartsreference detached nodes. The nextrender()call hits the cache, callspatch()against orphaned nodes, and returns without re-appending any DOM. The container stays blank.The bug is silent — no exception is thrown.
Reproduce
Confirmed against
gh-pageshtml.js(size 9013 bytes) consumed via the LOSOS shell:Where in the source
html.js:46–51(gh-pages):prev.strings === template.stringsmatches because tagged template strings arrays are interned by the JS engine — the same source-code template literal returns the same array reference across calls. So the cache hits whenever the same template is re-rendered, regardless of whether the container's DOM was wiped in between.patch()(line 128) only mutates the existing nodes inprev.parts. If those nodes have been detached fromcontainer, the mutations apply to orphan DOM that nothing displays.Real-world hit
Surfaced via
nosdav/browser's LOSOS shell +panes/source-pane.js. The shell'sselectPanedoescontent.innerHTML = ''between tab switches; sharing/folder/etc. panes assemble their own DOM directly intocontent. After visiting any of those panes, switching back to source-pane (which uses LOSOShtml+render) renders blank — the cached parts from the first visit are detached, patch silently no-ops, no fresh build happens.A debug log inside source-pane confirmed the
render()call IS reached with valid input —foundEl: true, hasJsonLd: true, textLen: 213— so the bug is downstream inhtml.js, not in the pane.Proposed fix
Validate the cache against the current DOM before using the patch shortcut. One condition added to the existing
if:Both text parts (
{type:'text', node: marker}) and attribute parts ({type:'attr', node: el, name}) carry a.nodeproperty —findParts(line 105, 120) sets it for both — so the check is uniform.Costs
Node.contains, O(depth)) per render-call cache-hit. Sub-microsecond for typical pane container depths.Out of scope
Acceptance
<p>count: 2</p>after the fix.nosdav/browser's source-pane no longer goes blank when navigating away and back.