Writing IOSync-safe components

A custom element, or any script that builds UI, runs in a page that IOSync keeps in sync with your source. Two things follow. Source edits patch your elements in place instead of recreating them, so a component has to react to attributes and children that change under it. And while live output → input sync is on, every change a component makes to the light DOM can be written into your source. This guide gives the rules that keep a component quiet and let it update live, with shadow roots, the CSS Custom Highlight API and adopted style sheets. The rules follow from how the engine works.

Why components need care

Your light DOM is your source

With live output → input sync on, which is the default, anything a component adds to or changes in its light DOM is adopted into your source: children, text, attributes, classes and inline styles, on the host or on its children. Adoption happens when the page loads, when the output is initialized, and whenever the editor is not focused. The next Load then runs the component again on top of the adopted markup, and duplicates follow.

The componentWhat reaches your sourceDo this instead
Appends children or text to its hostThe new nodes, as adopted sourceRender into a shadow root
Sets classes, attributes or inline styles on its hostEach changeStyle with adoptedStyleSheets, and expose state with custom states
Wraps words in <span> to color themThe spansUse the CSS Custom Highlight API
Appends toasts, popovers or debug panels to <body>The appended elementsPut them in a closed shadow root, or in a container marked oi:exclude
Animates by writing style on light-DOM nodesEvery writeUse CSS animations or Web Animations
Sets an attribute that is the intended stateThe attribute, when its value changesWrite only when the value changes

Undeclared elements reload every edit

While at least one custom element outside a template is not declared sync-safe, every edit anywhere reloads the output, and removing the last such element reloads it once more. Declaring the tag, as the next section shows, keeps edits hot-patching. See When the output reloads.

Declare your element

A sync-safe custom element never forces a reload. You declare one in three ways:

WayMatches
<meta io:sync="my-el">Every element with that tag name.
<meta io:sync="my-">Every element whose tag starts with the prefix. A token that ends in - is a prefix.
sync=pure ExperimentalThe one element instance that carries it.

Tokens in <meta io:sync> are separated by whitespace and are case-insensitive, and all such metas outside templates are combined: <meta io:sync="analog-clock my-">. See <meta io:sync> and sync.

Some elements need no declaration. The built-in tags are pdf-file, pdf-page, zero-md and di-ff, and the built-in prefixes are ef-, edge-, show-, push-, img-, sl-, sp-, smart-, shin-, web-, chat- and c-. The prefixes of Web Awesome (wa-), Material (md-), Ionic (ion-), UI5 (ui5-) and Vaadin (vaadin-) are not built in: declare the ones you use.

Note: The playground's own hint for <meta io:sync> says: "Use it with elements that do not arbitrarily pollute their light DOM by themselves!" The rest of this guide shows how to build elements like that.

Keep the light DOM still

Put internal markup in a shadow root

Prefer a closed shadow root. A closed root is not reachable through element.shadowRoot, so IOSync never observes it, never writes it back and never materializes it. Internal state belongs in JavaScript or in ElementInternals, not in attributes of the host.

An open runtime shadow root is safe only while Shadow Source Materialization (F7) is off, or when its host carries oi:exclude=shadowroot Experimental. See Runtime shadow roots and Shadow Source Materialization.

Style with adopted style sheets

Constructable style sheets produce no DOM nodes and no mutation records, so they never sync back and are never materialized. Put per-instance values, such as a time offset, into a per-instance sheet like :host { --t: … }, not into the host's style attribute.

js
const base = new CSSStyleSheet, time = new CSSStyleSheet
base.replaceSync(':host { display: block }')
root.adoptedStyleSheets = [base, time]
time.replaceSync(`:host { --t: ${t} }`)   // a per-instance value, with no style attribute on the host

The analog clock that the Elefunc blog describes does this with its --t value.

Color text with the Highlight API

Use CSS.highlights and ::highlight(name) instead of wrapping words in <span> elements. A highlight is a set of ranges, not DOM, so the source text stays untouched. Expose state with custom states, this.attachInternals().states and :state(x), rather than with reflected classes.

js
const hits = new Highlight, range = new Range
range.setStart(node, 0); range.setEnd(node, 4)
hits.add(range)
CSS.highlights.set('hit', hits)        // style it with ::highlight(hit) { … }
this.attachInternals().states.add('none')   // and style :host(:state(none))

Animate without writing attributes

An attribute that is the intended state should change from script, but write it only when the value actually changes. The analog clock writes its at attribute while you drag it, and only when the value is new, so the blog post describes the playground wiring as "attribute changes flowing in both directions". A write of the same value is ignored, and every other change is a source edit.

For animation, use CSS animations or Web Animations, which mutate no attributes. Do not write style on light-DOM nodes on every frame.

Keep overlays and generated lists out of the observed tree

Put runtime overlays, such as toasts, popovers and debug panels, in a closed shadow root, or in a container marked oi:exclude or oi:exclude=subtree that has no authored children. Avoid appending them straight to <body>: they would be adopted into the source. document.head is not observed either, but it is not rendered, so it suits only invisible helpers.

In this example the script appends a list item every second. They stay out of the source even with live output → input sync on. A change to an attribute of #log itself, such as log.dataset.count = 3, still syncs back. The list is empty in the source: write containers that a script fills empty, and let the script fill them.

html
<ul id=log oi:exclude=subtree></ul>
<script type=module>
  setInterval(() => log.append(Object.assign(document.createElement('li'), { textContent: new Date().toLocaleTimeString() })), 1000)
</script>

Make source edits apply live

IOSync changes your elements in place instead of recreating them, so a component has to react to the change. Three habits make that work:

js
customElements.define('x-card', class extends HTMLElement {
  static observedAttributes = ['title']
  #root = this.attachShadow({ mode: 'closed' })
  attributeChangedCallback() { this.#render() }   // IOSync sets attributes in place
  connectedCallback() { this.#render() }          // idempotent: it can run again after a move
  connectedMoveCallback() {}                      // where the browser has moveBefore()
  #render() { this.#root.textContent = this.title }
})

IOSync uses moveBefore() where the browser has it (Chrome and Edge 133, Firefox 144) and falls back to insertBefore() elsewhere.

Experimental Give repeated siblings stable id, key or data-key values. They help the engine keep element identity when siblings are inserted or reordered. This is an implementation detail and may change.

Shadow roots in your source

Declarative shadow roots are source-bound

A <template shadowrootmode=open> inside a host belongs to your source. Edits inside it morph the live shadow root in place, runtime nodes inside it are preserved, and changes inside it sync back through the template, whatever the state of Shadow Source Materialization. Edit the CSS or the text in this example:

html
<div>
  <template shadowrootmode=open>
    <style>:host { display: block; padding: 1rem; border: 1px solid; border-radius: .5rem }</style>
    <slot></slot>
  </template>
  Light content
</div>

The host is a <div>, so no io:sync declaration is needed.

Adding and removing a root

Adding a source shadow root to a host that is already live is refused, because it cannot be rolled back: the status reads i→o failed: Adding a shadow root to an existing host cannot be rolled back. Press F5 to apply it. Removing a source-declared shadow root from an existing host replaces the host element, so its identity and state are lost.

Runtime shadow roots and Shadow Source Materialization

Runtime shadow roots are the open roots that scripts create with attachShadow(). They are not written to the source, and mutations inside them are ignored, unless Shadow Source Materialization is on. It is off by default. Toggle it with F7, with the command Toggle Shadow Source Materialization, with srs=1 in the URL, or with sync.shadowRootMaterializationToggle().

  • When it is on, a normal reverse sync, live or F6, writes each reachable runtime open shadow root into the source as <template shadowrootmode=open>…</template>, directly after the host's start tag. Later edits inside it become source-bound edits.
  • Materialized templates always say shadowrootmode=open. They carry no shadowrootdelegatesfocus, shadowrootclonable or shadowrootserializable, and adoptedStyleSheets are not serialized, because they are not DOM. A component that relies on constructable style sheets therefore materializes without its styles.
  • Switching it on writes nothing by itself. The templates appear at the next full snapshot, for example F6.
  • Switching it off removes from the source the templates that this mode created. The live shadow roots, and the edits made in them, stay.

Experimental To keep one host's runtime shadow tree out of materialization, mark the host with oi:exclude=shadowroot. The host's attributes and light DOM still sync. In this example, a source edit from title=Hi to title=Yo re-renders the heading, and nothing reaches the source:

html
<meta io:sync=x-card>
<x-card oi:exclude=shadowroot title=Hi></x-card>
<script type=module>
  customElements.define('x-card', class extends HTMLElement {
    static observedAttributes = ['title']
    #root = this.attachShadow({ mode: 'open' })
    attributeChangedCallback() { this.#root.replaceChildren(Object.assign(document.createElement('h2'), { textContent: this.title })) }
  })
</script>

Dynamically inserted templates

Experimental Playground only In the playground, the output runtime watches the document. When a <template shadowrootmode=open> is inserted dynamically, for example by a hot patch or by your own innerHTML or append(), the runtime attaches it as a real open shadow root. It honors shadowrootdelegatesfocus, but not clonable or serializable. Templates whose host already has a shadow root are removed, and dynamically inserted closed templates stay inert. A deployed page follows the HTML standard instead: only HTML parsing, by the page parser, setHTMLUnsafe() or Document.parseHTMLUnsafe(), attaches declarative shadow roots, and innerHTML does not. Do not rely on the playground's behavior in code you deploy. See Playground and edge differences.

A complete example

This <find-text> element marks every occurrence of a term in its text. It combines a closed shadow root, an adopted style sheet, the Highlight API and a custom state. Edits to term or to the text hot-patch and repaint, and nothing the component does reaches the source.

html
<meta io:sync=find-text>
<style>::highlight(find-text) { background: oklch(88% .17 95) }</style>
<find-text term=sync>Instant I ⇄ O Sync keeps source and output in sync.</find-text>
<script type=module>
  const hits = new Highlight, sheet = new CSSStyleSheet
  CSS.highlights.set('find-text', hits)
  sheet.replaceSync(':host { display: block } :host(:state(none)) { opacity: .6 }')
  customElements.define('find-text', class extends HTMLElement {
    static observedAttributes = ['term']
    #ranges = []
    #states = this.attachInternals().states
    constructor() {
      super()
      const root = this.attachShadow({ mode: 'closed' })
      root.adoptedStyleSheets = [sheet]
      root.append(document.createElement('slot'))
      new MutationObserver(() => this.#paint()).observe(this, { characterData: true, childList: true, subtree: true })
    }
    connectedCallback() { this.#paint() }
    attributeChangedCallback() { this.#paint() }
    #paint() {
      this.#ranges.forEach(r => hits.delete(r))
      this.#ranges = []
      const term = this.getAttribute('term')?.toLowerCase() ?? ''
      if (term) for (const node of this.childNodes) if (node.nodeType === Node.TEXT_NODE)
        for (let i = node.data.toLowerCase().indexOf(term); i >= 0; i = node.data.toLowerCase().indexOf(term, i + term.length)) {
          const range = new Range
          range.setStart(node, i); range.setEnd(node, i + term.length)
          this.#ranges.push(range)
        }
      this.#ranges.forEach(r => hits.add(r))
      this.#ranges.length ? this.#states.delete('none') : this.#states.add('none')
    }
  })
</script>

Why it is safe:

Checklist

A real example: these docs

These docs are a playground document, and their chrome follows the rules above. The article's light DOM is never changed. All dynamic UI, such as the menus, sidebar, table of contents, samples and editors, lives in closed shadow roots, and its state is set only on nodes inside them. The code colors come from the Highlight API, and the theme is an adopted style sheet. Samples are srcdoc frames inside shadow roots. The root document declares no <meta io:sync> for its docs- elements. They are undeclared, so every edit there reloads the output, and the exported files, such as the pages and style sheets, refresh each time.

See also