Gotchas

Things we learned building 14 apps. Read this before you hit the same walls.

1. Don't re-render for high-frequency updates

If you have a timer, audio progress bar, or animation that updates 4+ times per second, don't call renderApp(). It rebuilds the entire DOM — images flicker, network requests spike.

Patch only the elements that change:

// BAD — re-renders 50 rows to update one progress bar
setInterval(function() {
  progress = audio.currentTime / audio.duration
  renderApp()
}, 250)

// GOOD — patches 2 elements directly
setInterval(function() {
  var fill = container.querySelector('.progress-fill')
  var time = container.querySelector('.time-elapsed')
  if (fill) fill.style.width = (audio.currentTime / audio.duration * 100) + '%'
  if (time) time.textContent = formatTime(audio.currentTime)
}, 250)

2. Use the rawData argument

The shell passes parsed JSON-LD as the 4th argument to render. Use it instead of parsing textContent:

// OLD — race condition risk
render(subject, store, container) {
  var data
  try { data = JSON.parse(dataEl.textContent) } catch (e) { return }
}

// NEW — reliable
render(subject, store, container, rawData) {
  var data = rawData
}

3. innerHTML is an XSS risk

html`<div innerHTML="${value}"></div>` sets innerHTML directly. Safe with trusted content. Dangerous with user input.

// SAFE — values go through textContent, no HTML parsing
html`<div>${userInput}</div>`

// DANGEROUS — parses HTML, can execute scripts
html`<div innerHTML="${userInput}"></div>`

// SAFE — sanitize first
var clean = userInput.replace(/<[^>]*>/g, '')
html`<div innerHTML="${clean}"></div>`

4. No </script> in JSON-LD data

If your JSON-LD contains the literal string </script>, the browser's HTML parser will corrupt the DOM when it's written to a script element's textContent. Strip or escape it.

5. store.set() triggers onChange synchronously

Don't call renderApp() after store.set() — it already ran via onChange:

// BAD — renders twice
store.set(node, 'title', 'New')
renderApp()

// GOOD — renders once
store.set(node, 'title', 'New')

Exception: multiple store.set() calls in sequence each trigger a render. For bulk updates, call renderApp() manually after the last one.

6. Use the local shell

Copy shell.js into your project. Don't load it from a CDN:

<script type="module" src="losos/shell.js"></script>

The shell provides built-in tab persistence. No MutationObserver needed.

7. File structure

my-app/
  losos/           ← framework (copy these)
    html.js
    store.js
    shell.js
  lion/            ← JSON-LD store (used by shell)
    index.js
  panes/           ← your app code
    my-pane.js
  data.jsonld
  index.html

8. API apps: bootstrap before shell

For apps that fetch data from an external API, load the shell after the data is ready:

fetch('https://api.example.com/data')
  .then(function(r) { return r.json() })
  .then(function(data) {
    var jsonLd = transformToJsonLd(data)
    window.__myData = jsonLd
    document.getElementById('data').textContent = JSON.stringify(jsonLd)
    document.getElementById('losos').textContent = ''
    var s = document.createElement('script')
    s.type = 'module'
    s.src = 'losos/shell.js'
    document.body.appendChild(s)
  })

9. Every ${} in a tag must be an attribute value

Bare interpolations inside opening tags break attribute parsing silently:

<!-- BAD — bare interpolation corrupts the tag -->
<input type="checkbox" ${isDone ? 'checked' : ''}
  onchange="${fn}" />
<!-- onchange gets stringified as text, not bound as event -->

<!-- GOOD — use attribute value form -->
<input type="checkbox" checked="${isDone}"
  onchange="${fn}" />
<!-- false removes the attribute, true adds it -->

The rule: every ${} inside an opening tag must be in the form attr="${value}". LOSOS doesn't support <div ${expr}> spread syntax.