# SSR failure shapes, symptom → root cause → status

The §-numbered root-cause classes below (count the `##` headings, the list
grows) have surfaced from real SSR consumers (adiav2's
`admin-portal-fe` and `factory-dashboard`, server-rendering AdiaUI via Astro 5 +
`custom-elements-ssr`, which runs on linkedom, a DOM shim with no layout engine and
missing many browser APIs). A new SSR bug report almost always maps onto one of
them; misclassifying it (e.g. treating a measurement-timing bug as a missing-API
bug) sends the fix to the wrong place. Check symptom against this table first.

## 1 · Browser-only API called unconditionally → crash

**Symptom:** the SSR pass throws, often at construction, before any component-specific
code runs. Stack trace points at `attachInternals`, `new ResizeObserver(...)`, `new
IntersectionObserver(...)`, `new MutationObserver(...)`, `new PerformanceObserver(...)`,
or a write to `document.adoptedStyleSheets`.

**Root cause:** the call site assumes the API exists. linkedom implements none of these, not a partial/quirky implementation, an absence. Any unconditional call throws
`TypeError` or `ReferenceError` (undefined global) immediately.

**Status: FIXED, twice, and now GATED.** First wave gh#285 (PR #292, merged
2026-07-17): `UIElement`'s constructor (`packages/web-components/core/element.js`)
plus a sweep of component/trait/module files: the per-file tally is
[`status-ledger.md`](status-ledger.md)'s #285 row (the ledger, not this line, is
the count of record). Second wave gh#1430 + gh#1436 (2026-08-17, found by adiav2's
first SSR admission trial at 0.8.40): the sweep had missed
- **environment-detection instead of feature-detection at MODULE scope**, `core/responsive.js` guarded `window.matchMedia(...)` behind `typeof window !==
  'undefined'`; linkedom HAS a `window`, just no `matchMedia` and no numeric
  `innerWidth`, so six components (`block`/`col`/`grid`/`row`/`text`/`demo-toggle`)
  could not be IMPORTED server-side at all;
- **bare `instanceof Node|Element|HTMLElement`** (15 sites, `core/template.js`
  `applyValue`, table/list-window renderer results, event-target checks), a
  `ReferenceError`, not `false`, because custom-elements-ssr installs ONLY
  `HTMLElement` as a global (never `Node`, `Element`, `Text`, `DocumentFragment`);
  now `core/dom.js` `isNode()`/`isElement()` (structural `nodeType` tests);
- **`requestAnimationFrame` / `MutationObserver` / `getBoundingClientRect` at
  connect** in feed-item/toast, noodles, preview, toolbar (rAF), nav-group,
  stepper (MutationObserver, the #292 sweep guarded four Observer sites, not
  these two) and nav-ui (`getBoundingClientRect` is absent, not zero, on linkedom).

**Why happy-dom could not see any of it:** it implements every one of those APIs.
The gate that closes the class is `scripts/dev/ssr-linkedom-smoke.mjs`, run by
`packages/web-components/test/ssr-linkedom-smoke.test.js`, a real `linkedom`
devDependency installing EXACTLY the six globals `custom-elements-ssr/server-shim.js`
installs, then importing every `components/*/*.js` entry and constructing +
connecting every registered tag (124/124 import, every tag renders). A new
shape-1 instance fails that test, not a consumer's build. The fix pattern
(feature-detect + fallback matched to how the reference is used downstream) is
[`guard-patterns.md`](guard-patterns.md), apply it to any NEW call site; don't
re-derive the shape from scratch, and don't guard on `typeof window`.

## 2 · `connectedCallback` destructively re-stamps existing DOM → content loss

**Symptom (as originally reported):** the component renders in the SSR HTML response
as an EMPTY or STRUCTURALLY WRONG tag, not a crash, a silent loss. Two named
variants:
- A **container** (`admin-shell`, `admin-sidebar`, `nav-ui`) loses its nested custom-element
  children, `<nav-item-ui>` inside `<nav-ui>` simply isn't in the response.
- A **projected-text** leaf (`<text-ui>Adia Admin</text-ui>`, `<avatar-ui>A</avatar-ui>`,
  `<badge-ui>warning</badge-ui>`) goes out as an empty tag: the authored text is gone.

**Root cause (still real):** `UIElement.connectedCallback` (`packages/web-components/core/element.js`)
runs `stamp(result, this)` (the `stamp` function in `packages/web-components/core/template.js`,
currently at line 194) with the OWN template's output, this OVERWRITES the element's
existing children rather than adopting/patching them. In a real browser this is
correct and invisible: nothing exists inside the element yet at first connect. Under
SSR, `connectedCallback` runs a SECOND TIME against the server-parsed DOM (which
already has real, meaningful children from the HTML response), the stamp silently
replaces them.

**Status: NARROWED, downgraded 2026-07-17** (gh#284). Verified against 0.8.4: the
mechanism is real (a synthetic repro, a component with a non-null template AND
projected text, does lose it), but **every named example in the issue currently
has `static template = () => null`**: `admin-shell`, `admin-sidebar`, `nav-ui`,
`text-ui`, `badge-ui`, `avatar-ui` all skip `stamp()` entirely (`if (result)
stamp(result, this)`, `null` never enters the branch). A framework-wide survey
(150 components at the time) found every component with a NON-null template derives its
visible content from properties/attributes only (`check-ui`'s `label=`,
`switch-ui`'s `label=`/`hint=`, `skip-nav`'s `text=`), never from light-DOM
children, so the destructive replace, where it does fire, only ever regenerates
identical, template-owned content. Zero shipped components are exposed to the
originally-reported symptom right now.

**What shipped instead of a `stamp()` fix:** the two candidate directions the issue
named (adopt-and-patch, or skip-on-marker) were explicitly rejected as
disproportionate, "changes the render lifecycle of every primitive in the
framework (127+), not a mechanical guard sweep", for a risk with zero current
instances. Shipped a forward-looking STATIC AUDIT instead:
`scripts/dev/audit-template-child-conflict.mjs`, flags (critical) any future
component pairing a non-null `static template` with a yaml `slots.default` entry
(the container shape), and (advisory) a non-null template paired with a body-text
usage example (the projected-text shape, no mechanical yaml signal to check). Wired
into `primitive-authoring`'s structural-gate sequence. This is an operator ruling (not a
unilateral call), see gh#284's comment thread for the full reasoning and the
empirical survey it's based on.

**Current consumer workaround still exists but may be over-conservative:**
`adiav2`'s SSR component registration is restricted to attribute-only-content
components (`button-ui text="…"`, `icon-ui name="…"`), per this narrowing, EVERY
currently-shipped component (container or leaf) with real content already
qualifies as attribute/property-driven, so the restriction may no longer be
necessary for content-loss reasons. Don't assume this without the consumer
confirming it via the issue thread, see [`consumer-workarounds.md`](consumer-workarounds.md).

**UPDATE 2026-07-18, a DIFFERENT, real bug was found and fixed in the same
investigation area (gh#284's comment thread, PR #309).** The narrowing above
rules out `stamp()`'s destructive replace as a live risk, but it does NOT mean
attribute/property-driven components were actually safe under a late/SSR
upgrade. They weren't, for an unrelated reason: the custom-elements spec's
"upgrade an element" algorithm (§4.13.5 step 6) requires replaying
`attributeChangedCallback` for every attribute already present on an element
BEFORE `connectedCallback` fires on upgrade, and happy-dom (this repo's test
DOM) skips that replay entirely, confirmed with a bare, framework-free custom
element, no AdiaUI code involved. linkedom (the real SSR consumer's shim) is a
similarly from-scratch custom-elements registry, so the same gap is expected
there too. Net effect: `<nav-item-ui text="Profile">`, exactly the
attribute-driven shape this narrowing said was safe, rendered with an EMPTY
label after a late upgrade, because `this.text` never got initialized from the
attribute at all. **Fixed**: `UIElement.connectedCallback` now re-syncs every
declared property from its live attribute value before `connected()` runs
(`packages/web-components/core/element.js`), see
[`guard-patterns.md`](guard-patterns.md) §2b for the fix shape. Regression
tests: `packages/web-components/core/element.test.js`,
`describe('UIElement: SSR attribute-upgrade replay (gh#284)')`.

This means the FULL current picture for shape 2 is: destructive `stamp()`
re-mounting is real-but-latent (static audit catches a future regression);
the attribute-upgrade-replay gap was real-and-live (now fixed in #309). A
future "content vanished under SSR" report should check the attribute-replay
mechanism FIRST: it's the one that was actually firing.

### Worked example, answering a new "content vanished" report

**Ask:** "`<text-ui>Adia Admin</text-ui>` renders as an empty tag in our SSR
output, is this a known issue?"

**Answer:** It matches shape 2's SYMPTOM (`connectedCallback` destructively
re-stamping existing DOM), but shape 2 was narrowed on 2026-07-17, verify
against the CURRENT code before reusing the old answer, because this is
exactly the case it no longer covers. `text-ui`'s `static template`
(`packages/web-components/components/text/text.class.js`) is `() => null`, `connectedCallback`'s `if (result) stamp(result, this)` never enters the
branch, so `stamp()` never touches `<text-ui>`'s children at all. This
component isn't exposed to shape 2; something else is dropping the text, check whether `text-ui` is even registered server-side (a different,
structural gap: is the tag defined before the SSR pass runs?), or whether
another mutation (a parent re-render, `innerHTML` elsewhere) is clearing
it. **The general lesson, not just this one component:** before answering
"yes, known issue, shape 2" for ANY new report, grep the component's own
`static template`, if it's the literal `() => null`, shape 2 cannot be the
cause, no matter how closely the symptom matches the old description. This
survey (150 components at the 2026-07 survey, the census has since grown)
cites exactly why every current children-accepting component is unaffected.

## 3 · A connect-time layout MEASUREMENT is meaningless before real layout exists

**Symptom:** a component makes a decision (a boolean state, a mode, a snapped value)
by synchronously reading `getBoundingClientRect()` (or similar) inside `connected()`,
and that decision comes out WRONG, not crashed, not empty, just incorrect, in any
environment where real layout hasn't happened yet. This includes linkedom (no layout
engine at all, always returns a 0×0 rect) but ALSO a real browser connecting an
element before its first layout pass (inside a `display:none` ancestor, for instance), SSR is the environment that surfaces it reliably, but the bug is not SSR-specific.

**Root cause:** treating "the rect read 0" as equivalent to "the rect really is 0", they are NOT the same fact. A zero rect from a shim/pre-layout read means "unknown,"
and a decision derived from "unknown" as if it were "confirmed small" is a category
error, not a rendering gap.

**Status: FIXED for `admin-sidebar`'s specific instance** (gh#286, PR #290, merged
2026-07-17), see [`guard-patterns.md`](guard-patterns.md) §3 for the fix shape
(treat a zero read as unknown, defer to the component's own `ResizeObserver`'s first
real tick). **The general pattern is NOT swept framework-wide**, any OTHER component
that derives a persistent decision from a synchronous connect-time measurement
carries the same latent bug, undiscovered until someone hits it. If you're
investigating a "wrong initial state under SSR" report that ISN'T a missing-API crash
(shape 1) or a content-loss (shape 2), check whether the component reads a rect/size
synchronously at connect and treat that as the working hypothesis first.

## 4 · Feature gap, not a bug, property-only components can't seed from SSR HTML

**Symptom:** not a bug report at all, a `table-ui`/`chart-ui`/`select-ui` with
programmatic-only content (`.columns`, `.data`, `.options` set as JS properties)
renders correctly in the browser but is STRUCTURALLY ABSENT from the SSR HTML response,
because JS property assignments don't serialize into server-rendered markup. The
component pops in empty and fills in after a post-hydration wiring script runs.

**Root cause:** no declarative (HTML-serializable) form of the data these components
need exists yet, property-only content is a deliberate API shape for CONSUMERS with a
live client, not a gap in any individual component's code.

**Status: CLOSED 2026-07-18** (gh#288). The premise that this was blocked on shape
2/2b never held, `table-ui`/`chart-ui`/`select-ui` all use `static template = ()
=> null`, so neither the narrowed stamp() mechanism nor its later fix ever bore on
this at all. On investigation the scope was also narrower than filed:

- **`select-ui`** already parsed native `<option>`/`<optgroup>` children
  declaratively at connect (`#parseOptions()`, `select.class.js`), plain HTML,
  serializes into SSR output fine. No gap, no fix needed.
- **`chart-ui`** already hydrated `.data` from a JSON-array `data="[…]"` HTML
  attribute at connect (`chart.class.js` `connected()`), shipped in earlier
  work, just never reconciled against this issue.
- **`table-ui`** was the actual gap: `.columns` had `<col-def>` children as its
  declarative form, but `.data` (row records) had none. Fixed in the SAME shape
  as chart-ui's existing pattern (framework consistency over inventing a new
  mechanism) rather than the JSON-script-child form originally proposed:
  `table-ui` now hydrates `.data` from `data="[…]"` at connect too, guarded so
  a real programmatic `.data =` set before connect always wins. Works together
  with `<col-def>` children for a fully static-HTML table. Tests:
  `packages/web-components/components/table/table.test.js`,
  `describe('table-ui: declarative data="[…]" attribute (gh#288)')`.

## 5 · A custom render path unconditionally rebuilds a subtree that already matches, SSR adopt-in-place

**Symptom:** distinct from shape 2: this is not `stamp()`'s destructive
replace (shape 2 is a `static template` mechanism, narrowed to zero live
instances). This is a component with `static template = () => null` whose
OWN hand-written `render()`/`connected()` still unconditionally
`replaceChild()`s or `setAttribute()`s every position on every invocation,
including the very first upgrade render against a byte-identical
server-rendered subtree. Not a crash (shape 1), not empty content (shape 2),
not a wrong measurement (shape 3): the rendered RESULT is correct, but a
server-rendered subtree that already matched it gets torn down and rebuilt
anyway, violating a consumer's zero-subtree-mutation adoption contract
(adiav2's spec-ssr-kit AC-004a) and showing up as spurious host-attribute
churn (`role`, `tabindex`, an inline `grid-template-columns`) even when the
values never actually change.

**Root cause, two related sub-causes:**
- **No value-diff before mutating.** A freshly-built candidate node/attribute
  value is written to the DOM unconditionally, never compared against what's
  already there. `setAttribute()` queues a mutation record even when the new
  value is byte-identical to the old one (confirmed directly: happy-dom and
  linkedom both fire a record on a same-value `setAttribute` call, but never
  fire one for a `removeAttribute()` on an already-absent attribute, the
  spec's own asymmetry), so "the value happens to match" is never enough on
  its own; the write itself has to be skipped.
- **No adopt-existing-DOM path at all** for the container-level rebuild
  (rows, cells, header), every position gets a fresh node and a
  `replaceChild()`, whether or not the existing one is already correct.

**Status: fixed for `table-ui`** (PR #1756, gh#1678, merged 2026-08-20). `table.class.js`
`render()`/`connected()`: a module-level `adoptOrDiffChildren()` helper
compares each freshly-computed cell against its existing DOM position via
`Node.isEqualNode()`, standard DOM, present under linkedom, happy-dom, and
real browsers alike, never one of the browser-only APIs §1 above guards, and only calls `replaceChild()` on an actual mismatch; a match adopts the
existing node in place, zero mutation. Host/row-level attribute writes
(`role`, `tabindex`, the grid-template-columns inline style, `data-index`,
`aria-selected`) go through a `setAttrIfChanged()` guard for the same
reason. This generalizes pagination-ui's own first-connect adoption fix
(gh#1687, see `guard-patterns.md` §4) from a flat, keyed item list to an
arbitrary positional child (a header cell, a row cell) via a value check
instead of a shape/key check, since a table cell has no stable identity key
of its own the way a pagination item does. Tests:
`packages/web-components/components/table/table.test.js`,
`describe('table-ui: SSR adopt-or-diff render path (gh#1678)')`, a real
`MutationObserver` proves zero mutations on a byte-identical upgrade, and a
deliberately-corrupted single cell proves the fallback rebuilds ONLY that
position, never a wider or a half-adopted rebuild.

### 5.1 · Structural equality is not sufficient, renderer-owned runtime state

**A second, distinct hazard inside the same fix, found by CodeRabbit on
PR #1756 and closed in the same PR before merge.** `Node.isEqualNode()` is a
*necessary* adoption test (structurally different nodes obviously can't be
adopted) but not a *sufficient* one: it compares tag/attributes/text/
descendants only, it has no way to see an event listener a renderer
attached to the node it returned. `table.class.js`'s `#updateRow()` runs
`col.render()` (an arbitrary consumer-supplied cell renderer) or a built-in
cell-type renderer (`typeDef.render`) BEFORE `adoptOrDiffChildren()` ever
compares the result. If that renderer attaches a listener to the node it
hands back, the candidate can still be structurally byte-identical to the
existing (listener-less, e.g. SSR-parsed) DOM, `isEqualNode()` reports a
match, the guard adopts the OLD node and silently discards the fresh one,
and the listener is gone. The pre-fix unconditional-`replaceChild()`
behavior never had this bug, because it always installed whatever the
renderer had just built.

**Fix shape:** a module-level `RENDERER_OWNED` `WeakSet` tags exactly the
candidate cells built by something free to attach runtime state, `col.render()`
always (arbitrary code, impossible to introspect for safety), and a
built-in cell-type renderer only when its registration explicitly declares
`attachesListeners: true` (currently only `cellTypes.actions`, the one
built-in type that calls `addEventListener()` directly, see
`cell-types.js`). `adoptOrDiffChildren()` always replaces a
`RENDERER_OWNED` candidate, never adopts it via the structural-equality
path, even on an `isEqualNode()` match. Deliberately NOT tagged: `col.format()`,
the plain-text fallback, and every other built-in cell type
(text/number/currency/percent/date/datetime/boolean/badge/avatar/link/
markdown/progress), each of those only sets attributes on already-
declarative custom elements or plain nodes with no listeners, so tagging
them would trade away the zero-mutation benefit for the overwhelming common
case with no correctness gain. A first attempt at this fix tagged EVERY
`typeDef.render` cell unconditionally and broke the AC-004a zero-mutation
test above for exactly that reason, narrowed to the declared-flag form
before merge. Test: `table.test.js`, `'a renderer-owned cell (col.render
attaching a listener) stays interactive after an SSR-adopted upgrade'`, a
button's click listener, attached inside `col.render()`, still fires after
an SSR-parsed (listener-less, structurally identical) upgrade.

**The general lesson for any OTHER adopt-or-diff work** (elsewhere in this
framework, or a future component): `isEqualNode()`/any purely-structural
diff can only prove a node's DECLARATIVE shape is safe to keep, never that
its imperative/runtime state (listeners, closures, anything a renderer
callback stashed on it) is. A renderer whose output is reused across
positions or invocations needs its own explicit "does this renderer attach
runtime state" declaration (the `attachesListeners` pattern above, or
equivalent) rather than assuming structural equality is enough. See
`guard-patterns.md` §4.1 for the pattern to copy.

**Not (yet) swept framework-wide.** Any OTHER component with a hand-written
`render()`/`connected()` that unconditionally rebuilds or re-stamps a
subtree carries the same latent gap until it's individually checked against
this shape, there is no static audit for this one the way shape 2 has
`audit-template-child-conflict.mjs`. Check for: a `replaceChild()`/
`setAttribute()` call inside a `render()`/`connected()` with no preceding
comparison against the existing DOM. The renderer-owned hazard in §5.1 is a
further, separate thing to check for even once an adopt-or-diff path
exists: does any renderer this component invokes attach a listener or
other runtime state, and if so, is it excluded from the structural-adopt
path the way `RENDERER_OWNED` excludes it here.

## 6 · The conditional-inject class, querySelector-guard-before-innerHTML, decision recorded (gh#1678)

**Shape:** `connected()` checks for a pre-existing structural child before
stamping one, `if (!this.querySelector('input-ui')) { this.innerHTML =
…; }` (`search-ui`) or `this.#nav = this.querySelector(':scope >
nav[slot="nav"]'); if (!this.#nav) { … create fresh … }` (`pagination-ui`,
gh#1687). Two real, already-shipped instances; this is not a hypothetical
pattern.

**Decision (gh#1678 requirement 3):** this IS the correct, SSR-safe shape
for a component that owns exactly one structural child slot: it is the
SAME "adopt when it structurally matches, rebuild fresh when it doesn't"
principle §5 above ships for table-ui's cells and gh#1687 ships for
pagination-ui's item list, one level coarser (a single child, not a keyed
list or a per-cell diff). It is SSR-safe on exactly one condition: **the
guard's own `querySelector` target must be STRUCTURALLY specific**, the
exact expected tag (`input-ui`) or slot (`nav[slot="nav"]`), never a
generic "does this element have any children at all" check, or a
mismatched pre-existing child (stale markup, a different component's
leftover DOM) gets silently adopted and mis-rendered. Both shipped
instances already satisfy this.

**What this decision does NOT yet close.** Adoption alone doesn't reach the
zero-mutation bar §5 establishes, a component can correctly ADOPT the
pre-existing child and then still unconditionally re-`setAttribute()` it in
every subsequent `render()` pass, the exact §5 sub-cause. `search-ui`'s
`render()` (`this.#inputEl.setAttribute('placeholder', this.placeholder)`,
the `disabled` set/remove pair) does this today, a byte-identical SSR
`<search-ui>` fragment upgrades with the right element adopted, but still
takes 1–2 redundant attribute-mutation records on that first render.
`pagination-ui`'s own `reconcile()`-driven `#updateItem()` writes are the
same shape one level down. **Scoped OUT of gh#1678** (table-ui's own render
path is that ticket's actual evidence and fix), tracked as a follow-up:
extend `setAttrIfChanged()`-style idempotent guards to `search-ui`'s
`render()` and `pagination-ui`'s `#updateItem()`/`#createItem()` writes,
gh#1755 (filed alongside gh#1678's PR).
