Zubra docs

8. Read the output#

Zubra doesn't hide the platform. A component compiles to things you can open and read: the HTML a server sends, a JavaScript module, and TypeScript typings. Each one is standard web platform code, or a plain description of it, and each exists because the platform has a rule. This chapter reads one component's output closely, then goes through the rules that make hand-written Web Components awkward and shows where Zubra handles each one. The aim isn't to protect you from the rules. It's to let you see them, so that when you need to write a custom element without Zubra, you can.

If you've never written a custom element by hand, start with the README's hand-written version of the copy button (about 45 lines). Everything below is the same behaviour, split differently.

Four things to read#

Read How What it shows
The HTML zubra compile x.zb What's in the page before any JavaScript runs: declarative shadow DOM, hydration markers
The module zubra compile x.zb --emit js What makes this component different from others: props, events, flags, template, parts
The typings zubra compile x.zb --emit dts The element's public interface: properties, events and their detail, the form-control API
The element class runtime/src/element.js, defineElement What every component has in common: lifecycle, attributes, events, shadow DOM, forms

The last row matters. The compiled module contains no class. It hands a description to $define, and one class definition in the runtime turns that description into a custom element. That's why modules are small, and why the platform's rules are in two places: the module says what this component is, and defineElement says how any component behaves as a custom element.

One component, line by line#

<script>
  let count = 0
</script>
<click-counter>
  <button onclick={() => count++}>Clicked {count} times</button>
</click-counter>

What a server sends:

<click-counter>
  <button>Clicked 0 times</button>
</click-counter>

It's the component's template with the first value filled in. No marker is needed, because nothing in this component is a conditional or a list. When the element upgrades in the browser, it finds the button by the same path and binds to it where it stands.

What the browser runs (--emit js):

// Generated by zubra from click-counter.zb. Do not edit.
import { $define, $r, $t } from "@zubra/runtime";
import { state as __zubraState, computed as __zubraComputed } from "@zubra/runtime";

export const tag = "click-counter";

export default $define(tag, {
  props: {
  },
  abi: 3,
  shadow: false,
  form: false,
  slots: [],
  events: [],
  template: "\n  <button></button>\n",
  setup($p, $emit) {
function __zubraAssign(signal, value) { signal.set(value); return value }
function __zubraUpdate(signal, delta, prefix) { const old = signal.get(); signal.set(old + delta); return prefix ? old + delta : old }
  const count = __zubraState(0)
    return [
      { path: [0], on: "click", handler: () => (() => __zubraUpdate(count, 1, false)) },
      { path: [0], text: () => "Clicked " + $t($r(count)) + " times" },
    ];
  },
});
  • $define(tag, { … }) is the runtime's defineElement. It builds the class and calls customElements.define.
  • abi: 3 versions the contract between module and runtime. A runtime that doesn't know a module's abi refuses it with an error instead of misbehaving.
  • shadow, form, slots, events are the flags and lists the class reads. Sections 7 and 8 below change two of them.
  • template is static HTML. It's parsed once into a <template> and cloned for each element. The holes are empty: <button></button>.
  • setup is your <script>, run once per element, not once per update. let count = 0 became a Signal.State, and count++ became a call that sets it.
  • The array setup returns is the list of parts: the places in the template that change. path: [0] is the route from the element's root to a node, counting elements (and hydration markers), not whitespace. on: "click" adds a listener. text: is a function that reads count ($r reads a signal, $t turns a value into text), so it re-runs, and rewrites that one text node, only when count changes. Nothing re-renders.
  • That shape, a template plus a list of parts, is the one the DOM Parts proposal describes. Parts in production is what building it taught.

The rules, and where each one lives#

1. A name needs a hyphen, and a name can be defined once#

The rule. customElements.define() throws a SyntaxError for a name without a hyphen, and a NotSupportedError if the name is already registered (HTML Standard).

In Zubra. The first is a compile error, long before a browser sees it. Rename the host tag in the example to counter:

error: `counter` is not a valid custom-element name
 --> click-counter.zb:4:1
  |
4 | <counter>
  | ^
  = help: custom element names need a hyphen, e.g. `counter-element`

The second can't bite. defineElement asks customElements.get(tag) first and returns the class already there, so loading a component twice (two bundles, a CDN copy next to your own) is harmless. In development, a second definition of the same tag swaps the new version into the live elements, which is how hot reloading keeps their state.

2. A constructor can't touch attributes or children#

The rule. A custom element's constructor must not inspect its attributes or children, because an element made with document.createElement has none yet, and must not add any, or createElement and the parser can't create it (HTML Standard).

In Zubra. The constructor only sets things up: it attaches ElementInternals, creates a signal for each prop, and listens for the component's own events. It renders nothing. Rendering starts in connectedCallback, which calls #boot: attach the shadow root if you asked for one, run your <script> once, bind the parts. That's why setup is a function in the module. It doesn't run until the element is in a document.

3. Attributes are strings; properties are values#

The rule. HTML attributes are always strings, and an element hears about changes only to the ones listed in static observedAttributes, through attributeChangedCallback (MDN). Objects and arrays have to arrive as properties.

In Zubra. Every prop in the module names its attribute, and the class builds observedAttributes from that list. How a string becomes a value depends on the prop's default (parseAttribute in the runtime):

function parseAttribute(spec, raw) {
  const fallback = spec.default;
  if (typeof fallback === 'boolean') return fallback ? raw !== 'false' : raw !== null;
  if (raw === null) return clone(fallback);
  if (typeof fallback === 'number') return Number(raw);
  if (typeof fallback === 'string') return raw;
  if (fallback === undefined || fallback === null) {
    try { return JSON.parse(raw); } catch { return raw; }
  }
  // A framework's server rendering stringified an object it sets as a property once it runs
  // (Svelte does this): not given yet, rather than invalid.
  if (raw === '[object Object]') return clone(fallback);
  try {
    return JSON.parse(raw);
  } catch {
    console.warn(`zubra: attribute "${spec.attribute}" is not valid JSON:`, raw);
    return clone(fallback);
  }
}

A number default parses as a number, a string stays a string, a boolean follows HTML's convention (present means true), and everything else is read as JSON. Add step={1} to the host tag and use it in the handler, and the output changes like this:

+    "step": { attribute: "step", default: 1, required: false },
-      { path: [0], on: "click", handler: () => (() => __zubraUpdate(count, 1, false)) },
+      { path: [0], on: "click", handler: () => (() => __zubraAssign(count, count.get() + ($p.step))) },

The prop appears in props with its attribute name and default, and in the handler it's read as $p.step, the object of live props. Properties are the real interface: frameworks set el.step = 3 directly, and objects and arrays arrive as values.

One-way. An attribute sets the prop, but assigning the prop doesn't write the attribute back. Hand-written elements often reflect props to attributes so that selectors like [open] work. Zubra doesn't, so style from state with class: toggles in the template rather than attribute selectors.

4. Properties set before the element exists#

The rule. An element can be in the page before its module has loaded. If a framework sets el.items = […] in that window, nothing intercepts it: the value lands on the element object itself, and when the class arrives, that own property hides the class's accessor. Hand-written elements have to re-assign such values in the constructor.

In Zubra. The constructor takes them over, so values a framework set early aren't lost:

for (const [name, spec] of Object.entries(props)) {
  this.#signals[name] = new Signal.State(clone(spec.default));
  // A framework may have set the property before the element was defined; that landed
  // as an own property shadowing the accessor. Take it over.
  if (Object.hasOwn(this, name)) {
    const value = this[name];
    delete this[name];
    this.#given.add(name);
    this.#signals[name].set(value);
  }
}

5. Elements come and go#

The rule. connectedCallback and disconnectedCallback run every time an element is inserted or removed, including when it's moved. Whatever one starts, the other has to stop, and a listener can only be removed with the same function reference that added it.

In Zubra. When an element disconnects, the cleanups your onMount returned and your onUnmount hooks run, and then its reactive scope is paused rather than thrown away:

for (const cleanup of this.#cleanups.splice(0)) cleanup();
for (const fn of this.#hooks.unmount) fn();
// Paused, not disposed: long-lived signals stop holding this element, and if it comes
// back only the parts whose inputs changed meanwhile are re-run.
this.#scope.pause();

So moving an element is cheap, and reconnecting it re-runs only the parts whose inputs changed in between. Listeners from your template are bound by the runtime through the parts, so there's no handler reference for you to keep (the README's hand-written version stores #onClick for exactly this reason). A browser test, leaks.test.js, checks that removed elements can be garbage-collected, even when they read a signal that outlives them.

6. Events cross boundaries only if you let them#

The rule. A CustomEvent doesn't bubble unless you ask, and an event fired inside a shadow root doesn't leave it unless it's composed. And a native event with the same name as yours (a change from an inner <input>) reaches listeners on the host too.

In Zubra. Importing emit from @zubra/runtime and calling emit('countchange', count) in the script adds the name to the module's events:

  events: ["countchange"],

and the class fires it like this:

#emit(name, detail) {
  const event = new CustomEvent(name, {
    detail, bubbles: true, composed: shadow, cancelable: name.startsWith('before'),
  });
  fired.set(event, this);
  return this.dispatchEvent(event);
}

It always bubbles, it's composed only when the component uses shadow DOM, and only before… events are cancelable, as on the platform. The constructor adds a guard for each declared event:

for (const name of events) {
  this.addEventListener(name, (event) => {
    if (fired.get(event) !== this) event.stopImmediatePropagation();
  });
}

An inner event with the same name stops at the host, so listeners outside see only the component's own, as with a built-in element. The typings pick the event up too (--emit dts), which is what lets Vue's @countchange, React's onCountchange and Svelte's oncountchange all work from the one DOM event.

7. Shadow DOM is a choice#

The rule. A shadow root hides a component's markup and styles from the page, and the page's CSS from the component. It's powerful and has a cost: page CSS (Tailwind, a design system) no longer reaches in. Servers can only produce one as declarative shadow DOM, a <template shadowrootmode>.

In Zubra. Light DOM is the default. Add shadow to the host tag, and the module changes by one flag:

-  shadow: false,
+  shadow: true,

and the server's HTML changes shape, so the browser builds the shadow root with no JavaScript:

<click-counter><template shadowrootmode="open">
  <button>Clicked 0 times</button>
</template></click-counter>

When the element upgrades, #boot reuses that root (this.shadowRoot ?? this.attachShadow({ mode: 'open' })). Styles differ too. A light-DOM component's CSS is scoped with the platform's own @scope, which stops at content other people slot in. Here is the styles line for the README's copy button:

  styles: "@layer theme, base, components, utilities;\n@layer components {\n@scope (copy-button) to ([data-zb-slotted=\"copy-button\"]) {\n  button.done { color: green; }\n}\n}\n",

A shadow component's CSS is used as written, because the root does the scoping. Here is a small star rating with shadow:

  styles: "@layer theme, base, components, utilities;\n@layer components {\n  button { font: inherit; }\n}\n",

Each is delivered as one constructed stylesheet shared by every instance. Styles in the README has the rest.

8. Form controls need ElementInternals#

The rule. For an element to take part in a <form> (a name, a value, validation, reset), the class must declare static formAssociated = true, call attachInternals(), and implement the form callbacks.

In Zubra. Add form-associated to the host tag:

-  form: false,
+  form: true,

and the typings gain the API native controls have:

+  /** The form this control belongs to, as for native controls. */
+  readonly form: HTMLFormElement | null;
+  /** The `name` attribute: the key this control submits under. */
+  name: string | null;
+  readonly validity: ValidityState;
+  readonly validationMessage: string;
+  readonly willValidate: boolean;
+  checkValidity(): boolean;
+  reportValidity(): boolean;

The class does the rest: formAssociated is set from that flag, the form callbacks (formResetCallback, formStateRestoreCallback, formDisabledCallback) call your onFormReset, onFormRestore and onFormDisabled hooks, and a control with exactly one :model prop submits that prop's value without you writing formValue.

9. HTML first, JavaScript second#

The rule. An element should be useful as markup alone, and a framework that renders on the server shouldn't have to render it twice in the browser.

In Zubra. Compile with props and you get the page's HTML, with the values in it:

<copy-button text="npm create zubra">
  <button type="button">
    Copy
  </button>
</copy-button>

When the element upgrades, #boot checks whether its content has the template's shape. If it does, it finds each part's node by its path and binds in place: nothing is rebuilt. If it doesn't, it renders fresh. A light-DOM component with slots takes the mismatched children as slot content; any other component logs a console warning that says so. Conditionals and lists carry markers (<?zb [?> and <?zb ]?>, processing instructions) so the element can find where a region starts and ends. Server rendering and Server rendering, honestly cover what renders where.

Change one line, read the diff#

The fastest way to learn a rule is to break it. Keep the output of the component you're editing, change one thing, and diff:

zubra compile click-counter.zb --emit js > before.js
# edit click-counter.zb, then:
zubra compile click-counter.zb --emit js | diff before.js -
Change What to look for
Add shadow to the host tag shadow: true; the HTML wraps its content in <template shadowrootmode="open">
Add form-associated form: true; --emit dts gains form, validity, checkValidity()
Add step={1} and use it A new entry in props; reads become $p.step
Import emit and call emit('countchange', count) events: ["countchange"]
Rename the host tag to counter A compile error about the hyphen
Add <style> styled: true and a styles string wrapped in @scope (light DOM) or not (shadow)

Where to go next#

  • The runtime's class. In runtime/src/element.js, read parseAttribute, then defineElement: the class (constructor, attributeChangedCallback, connectedCallback, disconnectedCallback, #emit), then #boot. The class is about 300 lines. The rest of the file is the machinery that binds parts: text, attributes, events, conditionals, lists and slots.
  • The proposals the runtime is shaped like. Signals, DOM Parts and declarative custom elements. The aim is for the platform to absorb what Zubra proves, so the runtime shrinks as native features ship.
  • The platform's own documentation. Using custom elements on MDN, and the custom elements section of the HTML Standard.

Back to: the guide

Edit this page on GitHub · Zubra 5.0.3