# @zakkster/lite-element

Zero-GC reactive Custom Elements on `@zakkster/lite-signal`. Setup runs once
on connect; you build the DOM and declare reactive bindings. No virtual DOM,
no templating language. **Component state survives synchronous reparents**
(sort, drag-and-drop, `insertBefore`) -- the very moves that destroy
React/Vue/Lit components.

ESM-only. Node >= 18. Single-file `Element.js`, zero runtime dependencies.
Peer dependency: `@zakkster/lite-signal ^1.1.3`.

## Install

    npm i @zakkster/lite-element @zakkster/lite-signal

## Imports

    import {
        mount, define, renderToString, toDeclarativeShadow,
    } from "@zakkster/lite-element";

## Exports

### `mount(host, setup) -> unmount`
Mount a setup function against any node. Returns a **synchronous** unmount.
Framework-free; use directly for islands, portals, or non-Custom-Element trees.

- `host: HTMLElement` -- node setup operates against.
- `setup: (host, scope) => void` -- wires bindings (see Scope API).
- Returns: `() => void` -- idempotent; calling twice is safe.

### `define(name, setup, options?) -> CustomElementConstructor`
Register a reactive Custom Element. Setup runs on first connect; teardown is
**deferred one microtask** and **gated on `isConnected`**. A synchronous
reparent that fires `disconnectedCallback` immediately followed by
`connectedCallback` is a no-op -- the scope stays live, state is preserved.

- `name: string` -- Custom Element tag name.
- `setup: (host, scope) => void` -- same shape as for `mount`.
- `config?` -- all optional (1.1):
  - `observedAttributes: string[]` -- attributes that become reactive (useAttr/prop).
  - `formAssociated: boolean` -- attaches ElementInternals (scope.internals) + form callbacks.
  - `shadow: "open"|"closed"` -- attaches (or adopts an existing declarative) shadow root.
  - `extends: string` -- customized built-in tag, forwarded to customElements.define.

### `renderToString(host, setup, { shadow? }) -> string`  (1.1)
Server-render a component. `host` is an element you create from any server DOM
(happy-dom, linkedom, jsdom). setup runs once, its DOM output is serialized, and
the scope is disposed immediately (one-shot, no leak). With `{ shadow }`, returns
the shadow root's HTML wrapped as a declarative shadow DOM template.

### `toDeclarativeShadow(innerHTML, mode = "open") -> string`  (1.1)
Wrap light-DOM HTML in `<template shadowrootmode="...">` for emitting shadow roots
from a server. The client adopts the parsed shadow root on connect.

## Scope API

Every method registers a teardown automatically. Mounting N nodes through the
scope allocates `nodes.push(handle)` per node -- no per-node closure.

- `state(value, opts?) -> Signal<T>` -- writable signal. Same API as
  lite-signal's `signal`: callable read, `peek`, `set`, `update`, `subscribe`.
- `computed(fn, opts?) -> ReadSignal<T>` -- derived value. Lazy + memoised.
- `effect(fn) -> Unsubscribe` -- side effect; runs now and on dep change.
- `bind(el, prop, fn) -> Unsubscribe` -- `el[prop] = fn()` on dep change.
  `Object.is` cutoff suppresses redundant writes.
- `attr(el, name, fn) -> Unsubscribe` -- set HTML attribute reactively. Coerces:
  `null`/`undefined`/`false` -> `removeAttribute`; `true` -> empty string;
  otherwise `String(v)`.
- `show(el, fn) -> Unsubscribe` -- `el.hidden = !fn()`.
- `on(el, type, handler, opts?) -> Unsubscribe` -- event listener, auto-removed
  on unmount. Returns an early-removal function.
- `onCleanup(fn): void` -- arbitrary teardown (timers, observers,
  subscriptions). Cleanups run in **reverse registration order**; throws are
  swallowed so teardown completes.
- `batch(fn): T` -- re-export of lite-signal's `batch`.

### Scope, 1.1 additions

- `root` -- rendering root: the shadow root when defined with `{ shadow }`, else
  the host. Build DOM into this.
- `hydrating` -- `true` when the host already had server-rendered content at
  connect time (light children, or a declarative-shadow-DOM root). Skip
  `innerHTML` assignment and bind to the existing nodes.
- `internals` -- ElementInternals when defined with `{ formAssociated }`, else null.
- `useAttr(name) -> () => string|null` -- reactive read of an observed attribute;
  updates on attributeChangedCallback. Only observed attributes are reactive.
- `prop(name, initial, { type?, attribute?, reflect? }) -> Signal<T>` -- reactive
  property with a `host[name]` getter/setter. `type` (Number/Boolean/String) coerces
  the attribute; `reflect` writes the value back to it; when the attribute is
  observed, external changes flow in too (Object.is breaks the reflect<->observe loop).
- `onAdopted(cb)`, `onFormReset(cb)`, `onFormDisabled(cb)`, `onFormStateRestore(cb)`
  -- lifecycle hooks; fire on the matching Custom Element callbacks, disposed with
  the scope.

## Key invariants

- **Setup runs once per mount.** Synchronous reparents do NOT re-run setup.
- **Setup runs `untrack`'d** -- reading a signal in setup does not make setup
  reactive. Use `effect`/`bind`/`attr`/`show` for that.
- **Disposal order: nodes first (reverse insertion), then cleanups (reverse).**
  Effects unsubscribe from their deps before listeners detach.
- **The reparent gate is exactly one microtask.** Code that yields
  (`setTimeout`, `await`, idle callback) and then reconnects gets a fresh scope.
- **`unmount` is idempotent.** Calling it twice is safe.
- **Pool-clean teardown.** `stats().activeNodes` returns to baseline after
  every mount+unmount cycle. Verified via 1000-cycle audit test.
- **`define` config is structured (1.1):** `observedAttributes` / `formAssociated`
  / `shadow` / `extends`. Only `extends` is forwarded to `customElements.define`.

## The reparent mechanism (the killer feature)

    disconnectedCallback() {
        queueMicrotask(() => {
            // Survived a synchronous reparent? isConnected is true again -> keep it.
            if (!this.isConnected && this.__unmount) {
                this.__unmount();
                this.__unmount = null;
            }
        });
    }

The browser fires `disconnect -> connect` synchronously on `insertBefore` (the
shape of a sort, a drag-and-drop, or a parent change). By deferring teardown
one microtask and gating on `isConnected`, lite-element treats reparents as
zero-work no-ops while still tearing down promptly on genuine disconnects.

`queueMicrotask` (not `setTimeout`) means a real disconnect tears down at the
end of the current task -- so a Node process that disconnects an element can
still exit cleanly.

## Benchmarks (Node 23, measured on consumer hardware)

- Mount+unmount, small component:        ~340K cycles/sec, ~420 B transient/op, ~3 B retained/op
- Mount+unmount, medium component:       ~190K cycles/sec, ~660 B transient/op
- Bind throughput, single cell:          **~6.2M writes/sec**, 8 B transient/op
- Bind throughput, 100 cells × 1 sig:    ~95K propagations/sec
- Reparent (lite-element survives, no work): **~1.97M ops/sec**, ~265 B/op
- Rebuild (destroy+remount baseline):    ~143K ops/sec, ~2.1 KB/op
- **Reparent vs rebuild: ~13× wall-clock speedup, ~1.8 KB less alloc per move.**

Reproduce: `npm run bench`. Numbers vary across hardware and Node version
(typical range: 7-15× reparent speedup, 5-6M bind writes/sec); the ratios are
stable.

## Common patterns

### Counter Custom Element

    define("counter-el", (host, { state, bind, on }) => {
        const n = state(0);
        host.innerHTML = `<button>+</button><span></span>`;
        const [btn, out] = host.children;
        on(btn, "click", () => n.update(v => v + 1));
        bind(out, "textContent", () => `Count: ${n()}`);
    });

### Two-way bound input

    define("text-field", (host, { state, bind, on }) => {
        const value = state("");
        host.innerHTML = `<input type="text">`;
        const input = host.firstChild;
        bind(input, "value", () => value());
        on(input, "input", e => value.set(e.target.value));
    });

### Island on a server-rendered page

    import { mount } from "@zakkster/lite-element";

    mount(document.getElementById("hero-cta"), (host, { state, bind, on }) => {
        const visits = state(0);
        on(host, "click", () => visits.update(v => v + 1));
        bind(host.querySelector(".count"), "textContent", () => visits());
    });

### External subscription with cleanup

    define("clock-el", (host, { state, bind, onCleanup }) => {
        const t = state(Date.now());
        const id = setInterval(() => t.set(Date.now()), 1000);
        onCleanup(() => clearInterval(id));
        bind(host, "textContent", () => new Date(t()).toLocaleTimeString());
    });

### Reactive attribute + reflected prop (1.1)

    define("x-stepper", (host, { prop, bind }) => {
        const value = prop("value", 0, { type: Number, reflect: true });
        bind(host, "textContent", () => String(value()));
    }, { observedAttributes: ["value"] });
    // el.value = 5  -> attribute "value" = "5"; setAttribute("value","9") -> el.value === 9

### Form-associated control (1.1)

    define("rating-input", (host, { state, internals, bind, on, onFormReset }) => {
        const score = state(0);
        const commit = () => internals.setFormValue(String(score()));
        bind(host, "textContent", () => score());
        on(host, "click", () => { score.update(v => v + 1); commit(); });
        commit();
        onFormReset(() => { score.set(0); commit(); });
    }, { formAssociated: true });

### Server render + hydrate (1.1)

    // server (host from happy-dom / linkedom / jsdom):
    const dsd = renderToString(serverDoc.createElement("x-card"), cardSetup, { shadow: "open" });
    // client:
    define("x-card", (host, scope) => {
        if (!scope.hydrating) scope.root.innerHTML = `<h3></h3>`;   // create only if fresh
        scope.bind(scope.root.children[0], "textContent", () => title());
    }, { shadow: "open" });

## What this is NOT

- Not a virtual DOM (bindings write to real nodes)
- Not a templating library (`host.innerHTML = "..."` is the idiom)
- SSR is render-then-serialize against a server DOM you supply (`renderToString`);
  hydration is adopt-existing-DOM via `scope.hydrating`. Not a streaming SSR framework.
- Shadow DOM is opt-in via `{ shadow }` (declarative-shadow-DOM-aware on connect);
  not a styling or slot-distribution abstraction.
- Form association is wired (`{ formAssociated }` + `scope.internals` + form
  callbacks); validation UI and form semantics are the platform's.
- Not a router (bring your own)

## Files

- `Element.js`     -- source (single file, ~440 lines)
- `Element.d.ts`   -- TypeScript definitions
- `README.md`      -- full docs with diagrams, benchmarks, patterns
- `CHANGELOG.md`   -- v1.0.0 + v1.1.0 release notes
- `llms.txt`       -- this file
- `LICENSE.txt`    -- MIT
- `test/*.test.js` -- 51-test suite across 5 files:
    - `test/_setup.js`                       -- DOM stub (HTMLElementStub, customElements stub, flush())
    - `test/01-mount.test.js`                -- mount() lifecycle + every Scope method, line by line (17 tests)
    - `test/02-define.test.js`               -- Custom Element lifecycle: connect/disconnect/reparent storm,
                                                1000-cycle leak audit, multi-instance state isolation (10 tests)
    - `test/03-edge-cases.test.js`           -- idempotency, double-mount, throw-in-bind recovery,
                                                onCleanup ordering, hidden+bind interplay (8 tests)
    - `test/04-defined-1.1-features.test.js` -- prop reflection (both directions, no infinite loop),
                                                useAttr in a defined element, form association,
                                                adoptedCallback, shadow + hydration, SSR helpers (13 tests)
    - `test/zero-gc.test.js`                 -- hot-path contract: < 5 B/op retained per bind write,
                                                1000 mount/unmount cycles → activeNodes baseline,
                                                no-op writes do not re-run effects (3 tests)
  Run `npm test` (50 pass, 1 skip without `--expose-gc`) or
  `npm run test:gc` (51/51).
- `bench/bench.mjs` -- six-scenario benchmark (`npm run bench`)
- `demo/index.html` -- interactive four-scene demo

## Author

Zahary Shinikchiev - MIT - https://github.com/PeshoVurtoleta/lite-element
