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 (
Setsemantics). 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.MyCardJSMyAppWeb.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
| Need | Use |
|---|---|
| Attach DOM behavior/listeners to a shipped element | behaviors.add |
| Override the template or lifecycle | subclass + defineOnce before the package loads |
| Replace the element per host app, controlled from Elixir | use ..., element: — Phase 2, NOT yet implemented (§3) |
| Expose events from a tessera child nested in your shadow DOM | re-emit with emit() from the outer element (§4) |
| React to contract events from outside the element | plain addEventListener — events are composed and bubble (§1 of the contract) |
| Send component data to a backend | BusClient seam (@noizu/tessera-core/bus); without a transport every call resolves {ok: false, reason: "no-transport"} |