Extending Tessera on the JS side

Copy Markdown View Source

Three ways to change what a contract-driven element does, without touching the package or regenerating contracts. Read docs/CONTRACT.md first — the tag naming and data-tsr-on grammar are load-bearing here. The normative extension contract (Elixir inheritance + this JS surface) is docs/EXTENSION-CONTRACT.md.

1. Behaviors (preferred)

A behavior is a function that runs on every connectedCallback of the element registered for a tag. It may return a cleanup, which runs on the matching disconnectedCallback. Behaviors attach to elements you do not own and need no subclassing or re-registration:

import { behaviors } from "@noizu/tessera-core/behaviors";

behaviors.add("persona-card", (el) => {
  const onClick = () => el.classList.add("is-selected");
  el.addEventListener("click", onClick);
  el.dataset.enriched = "true";

  return () => {
    // runs on disconnect (Hologram re-renders may mount/unmount the host)
    el.removeEventListener("click", onClick);
  };
});

Rules:

  • The registry lives on the bridge singleton (globalThis[Symbol.for("tessera.bridge")]), so a behavior registered from any module copy — any page bundle — is seen by elements loaded from another.
  • Add the same tag+fn twice and it stores once (Set semantics).
  • behaviors.remove(tag, fn) removes one; a throwing behavior is caught and warned, it never breaks the element's connection.
  • Keep behaviors idempotent per connect: they run again after every disconnect/connect cycle.

Use behaviors for DOM enrichment, extra listeners, integrations with other widgets — anything that does not change the element's class identity.

2. Subclass + defineOnce (order matters)

For changes that need the class itself (override lifecycle, change the template), subclass the mixed class and register it yourself:

import { LitElement, html } from "lit";
import { TesseraElement } from "@noizu/tessera-core/element";
import { defineOnce } from "@noizu/tessera-core/define";
import contract from "./persona-card.contract.generated.mjs";

class MyPersonaCard extends TesseraElement(LitElement, contract) {
  render() { /* your template */ }
}

// FIRST definition of the tag wins — register before the library's own
// module evaluates, or your class will be skipped (with one console.warn).
defineOnce("persona-card", MyPersonaCard);

Ordering rule: custom elements allow exactly one definition per tag per document. defineOnce keeps the FIRST registration and warns at most once per tag per document that a later copy was skipped — the warned set lives on the shared globalThis singleton, so duplicate module copies (Hologram bundles every js_import into each page bundle) don't double-warn. To own the tag you must evaluate before the package's element module does — import your subclass module first, or re-register via defineOnce from an earlier-bundled entry. If you cannot control load order, use behaviors (§1) instead of the element swap (§3, not yet implemented).

3. Swapping the element via use ..., element: — Phase 2, NOT YET IMPLEMENTED

This option does not exist yet. use Noizu.Tessera.Component reads only tag: and js:; an element: option is silently ignored today (planned for Phase 2 — tracked in the Phase 1 review, F-04). Do not write code against the sample below until this section says "implemented"; while it is missing, use §1 (behaviors) or §2 (subclass + defineOnce) to own the element.

The planned shape, for reference:

use PersonaKit.PersonaCard,
  element: MyAppWeb.MyCardJS

MyAppWeb.MyCardJS must export the same surface as the package element module (register/0-style no-arg function returning the ctor, per CONTRACT.md §5). It is the escape hatch when the tag must be YOUR class from the start — the adapter never imports the package's element for that component, so there is no race with defineOnce.

Combine with the same contract module (contract.generated.mjs) so attrs, props and data-tsr-on keep matching the Elixir spec.

4. Composing tessera components inside your shadow DOM (re-emit)

Bindings resolve from the document level (CONTRACT.md §4). When element B sits inside element A's shadow root, the retargeted shadow host A is the first candidate for B's composed events: if A binds the same event name, A's action fires and B's never does. If A carries no entry, the bridge falls back to scanning the composed path — open-shadow internals are visible on it — so B's own mapping still fires when B's tag is registered; the event is dropped only when no registered element on the path maps it.

Rely on that fallback and you couple yourself to the child's tag being registered and to your host staying unmapped. The deliberate pattern is to re-emit the child events you want to expose, from yourself:

import { emit } from "@noizu/tessera-core/events";
import { behaviors } from "@noizu/tessera-core/behaviors";

// persona-composer wraps <persona-card> in its own shadow root.
behaviors.add("persona-composer", (el) => {
  const card = el.shadowRoot?.querySelector("persona-card");
  if (!card) return;
  const onPick = (e) => {
    // Re-emit FROM THE OUTER ELEMENT so the bridge sees the composer as the
    // host and the composer's own data-tsr-on binding fires. Map the detail
    // to whatever your contract's event declares.
    emit(el, "persona-select", { personaId: e.detail?.personaId ?? null });
  };
  card.addEventListener("persona-select", onPick);
  return () => card.removeEventListener("persona-select", onPick);
});

The composer then declares event "persona-select" in its Elixir spec and binds it normally (on_persona_select=...) — the child card's binding, if any, stays private to the composer's internals.

Quick reference

NeedUse
Attach DOM behavior/listeners to a shipped elementbehaviors.add
Override the template or lifecyclesubclass + defineOnce before the package loads
Replace the element per host app, controlled from Elixiruse ..., element: — Phase 2, NOT yet implemented (§3)
Expose events from a tessera child nested in your shadow DOMre-emit with emit() from the outer element (§4)
React to contract events from outside the elementplain addEventListener — events are composed and bubble (§1 of the contract)
Send component data to a backendBusClient seam (@noizu/tessera-core/bus); without a transport every call resolves {ok: false, reason: "no-transport"}