Skip to content

html.js render() blanks container after external innerHTML clear (stale-parts cache) #15

@melvincarvalho

Description

@melvincarvalho

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions