# Guard patterns, the established fix shape per failure shape

Cites the actual shipped code (gh#285 PR #292, gh#286 PR #290) as the pattern to copy
for a NEW instance of the same failure class, don't re-derive the shape, match it.

## §1 · A browser-only API constructor/method call

**Shape:** `typeof <Global> !== 'undefined'` (for globals: `ResizeObserver`,
`IntersectionObserver`, `MutationObserver`, `PerformanceObserver`) or `typeof
this.<method> === 'function'` (for instance methods: `attachInternals`) or `'<prop>'
in <object>` (for object properties: `document.adoptedStyleSheets`) guards the call;
the ELSE branch is picked by what downstream code does with the reference, there is
no one boilerplate to paste everywhere.

**Three concrete shapes, all live in the repo:**

**(a) Field assigned, used later with `?.` on teardown**, the majority case. Guard the
construction; every later reference to the field must already (or now needs to) use
optional chaining, since the field can legitimately stay unset:
```js
// packages/web-modules/shell/admin-sidebar/admin-sidebar.js, #setupChildResizeObserver
#setupChildResizeObserver() {
  this.#childRO = new ResizeObserver((entries) => { ... });
  this.#childRO.observe(this);
}
// guarded:
if (typeof ResizeObserver !== 'undefined') {
  this.#childRO = new ResizeObserver((entries) => { ... });
  this.#childRO.observe(this);
}
// disconnectedCallback: this.#childRO?.disconnect();  ← already needs the `?.`
```

**(b) Early-return no-op**, when the entire trait/method's purpose IS the observer;
nothing else in the function is worth running without it:
```js
// packages/web-components/traits/resize-observer/resize-observer.js
setup({ host }) {
  if (typeof ResizeObserver === 'undefined') return () => {};
  // ... the rest of the trait, only reached when the API exists
}
```

**(c) Module-level `const`, ternary at declaration**, when the observer is a
singleton constructed at import time, not per-instance:
```js
// packages/web-components/core/data-stream.js
const observer = typeof MutationObserver !== 'undefined'
  ? new MutationObserver((mutations) => { ... })
  : null;
// every later use: observer?.observe(...) / bootstrap() no-ops via the same check
```

**(d) Feature, not environment, `typeof window` is NOT a guard (gh#1430).**
linkedom defines `window` (an object) but neither `matchMedia` nor a numeric
`innerWidth`, so `typeof window !== 'undefined'` passes and the call still throws, at MODULE scope that takes every importer off the server. Test the member:
```js
// packages/web-components/core/responsive.js
const w = typeof window !== 'undefined' ? window.innerWidth : undefined;
if (typeof w !== 'number') return 'lg';                                   // fallback
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { … }
```

**(e) `instanceof <DOM constructor>`, use `core/dom.js` (gh#1436).** Only
`HTMLElement` is a global under custom-elements-ssr; `v instanceof Node` /
`Element` / `Text` / `DocumentFragment` is a `ReferenceError`, not `false`.
`isNode(v)` / `isElement(v)` test `nodeType` structurally (and also accept nodes
from another realm, which the constructor identity check never did):
```js
import { isNode, isElement } from '../../core/dom.js';
if (isNode(result)) cell.replaceChildren(result);          // renderer output
const row = isElement(e.target) ? e.target.closest('…') : null;  // event target
```
`isElement` is deliberately the SUPERSET of a former `instanceof HTMLElement` (it
admits SVG elements too), every swept site only needed "an element", none needed
"an HTML element specifically". Absent-method form of the same idea:
`typeof this.getBoundingClientRect === 'function' && …` (nav-ui), linkedom has no
rect API at all, so an unguarded call is a TypeError, not a zero rect (that zero-rect
case is §3).

**The `this.internals` special case, a shim, not `undefined`.** `UIElement`'s
constructor (`packages/web-components/core/element.js`) needed a DIFFERENT answer
than "guard and leave unset," because ~12 files across the framework call
`this.internals.setValidity(...)` / `.setFormValue(...)` unconditionally (form
participation is a routine, expected thing for `UIFormElement` subclasses to do).
Leaving `this.internals` as `undefined` when `attachInternals` doesn't exist would
just relocate the crash one level deeper, into every one of those 12 consumers' first
validation call. The actual fix is a frozen no-op object matching the EXACT surface
those consumers call (grepped, not guessed: `setFormValue`, `setValidity`,
`checkValidity`, `reportValidity`, `form`, `labels`, `validity`, `validationMessage`,
`willValidate`):
```js
const NOOP_INTERNALS = Object.freeze({
  setFormValue() {}, setValidity() {},
  checkValidity() { return true; }, reportValidity() { return true; },
  get form() { return null; }, get labels() { return []; },
  get validity() { return {}; }, get validationMessage() { return ''; },
  get willValidate() { return false; },
});
// constructor:
this.internals = typeof this.attachInternals === 'function'
  ? this.attachInternals()
  : NOOP_INTERNALS;
```
**When a NEW browser-only API needs the same treatment:** ask "do consumers assume the
returned object/value is always truthy and call methods on it unconditionally?" If
yes, a no-op shim matching the ACTUAL consumed surface (grep for it, don't guess) is
the right answer, not leaving the field `undefined`. If the API is purely
fire-and-forget infrastructure with no return value anyone depends on (most
Observers), a plain construction guard (shapes a/b/c above) is sufficient, no shim
needed.

## §2 · Destructive `stamp()` on connect (gh#284, narrowed to a static audit, not a stamp() patch)

There is no `stamp()`/`connectedCallback` fix, and per the 2026-07-17 narrowing
there isn't meant to be one: the mechanism is real but zero shipped components
are exposed (see [`failure-shapes.md`](failure-shapes.md) §2 for the full survey).
The shipped guard is `scripts/dev/audit-template-child-conflict.mjs`: it flags a
NEW component that pairs a non-null `static template` with a yaml `slots.default`
entry (critical) or a body-text usage example (advisory), i.e. it prevents the
conflict shape from being reintroduced, rather than patching the render lifecycle
every component goes through (150 at the 2026-07 survey; the census grows). **A component author who hits this
audit's finding fixes it by making the template `() => null`** (the pattern every
current children-accepting component already uses, compose via CSS + `render()`'s
own surgical DOM manipulation, matching `avatar-ui`'s `#imgEl`/`#initialsEl`
pattern), NOT by inventing a component-local stamp-skip flag or a marker
attribute; either of those drifts from the established convention and isn't
covered by this audit's "safe" classification. If a genuinely new component NEEDS
both a non-null template AND real consumer children (unlike anything shipped
today), route through `primitive-authoring` before authoring it, that's a real design
question, not a mechanical fix.

## §2b · Reflected properties not initialized from pre-existing attributes on upgrade (gh#284's REAL fix, PR #309)

**Shape:** a `reflect: true` property stays at its class default after a LATE
custom-element upgrade: the element was parsed/appended (or server-rendered)
with its attribute already present, but the tag wasn't `customElements.define`d
until afterward. `<nav-item-ui text="Profile">` renders an empty label; any
other reflected prop on any component is at equal risk under the same
sequence.

**Root cause:** the custom-elements spec's "upgrade an element" algorithm
(§4.13.5 step 6) requires replaying `attributeChangedCallback` for every
attribute already on the element BEFORE `connectedCallback` fires on upgrade.
happy-dom (this repo's test DOM) does not do this, confirmed directly with a
bare `HTMLElement` subclass, no AdiaUI code involved
(`packages/web-components/core/element.test.js`,
`describe('UIElement: SSR attribute-upgrade replay (gh#284)')`, first test).
linkedom is a similarly from-scratch custom-elements registry, so the same gap
is the working hypothesis there too pending direct confirmation.

**Fix (shipped, `packages/web-components/core/element.js`):**
`connectedCallback` re-syncs every declared property from its live attribute
value, before `connected()` runs:

```js
for (const [key, cfg] of Object.entries(ctor.properties)) {
  const attr = cfg.attribute ?? key.toLowerCase();
  if (this.hasAttribute(attr)) {
    this[key] = parseAttr(this.getAttribute(attr), cfg.type ?? String);
  }
}
```

Safe on every environment: `connectedCallback` fires reliably everywhere
(unlike the shimmed `attributeChangedCallback` replay), and the resync is a
no-op where the replay already happened correctly: the property setter's
`Object.is` check short-circuits when the value already matches, so no extra
render pass. This is a FRAMEWORK-LEVEL fix, already shipped, a component
author hitting this shape doesn't need to do anything component-local; if a
reflected prop still looks wrong after upgrade on a version carrying this fix,
that's a new, different bug, don't assume it's this one recurring.

## §3 · A connect-time measurement is "unknown," not "confirmed"

**Shape:** treat a zero (or otherwise clearly-bogus) synchronous read as "no signal
yet" and DEFER the decision to whatever async correction mechanism the component
already has, don't add a new one if an existing `ResizeObserver`/similar already
watches the same dimension.
```js
// packages/web-modules/shell/admin-sidebar/admin-sidebar.js, #syncCollapsedFromWidth
#syncCollapsedFromWidth() {
  const w = this.getBoundingClientRect().width;
  if (w === 0) return;              // ← unknown, not "confirmed collapsed", this.collapsed = w <= SNAP_THRESHOLD;   //   leaves whatever connected() already
}                                          //   restored (persisted state, or default)

// the component's EXISTING ResizeObserver callback (already watching this.host for
// an unrelated reason, flipping select-ui placement) gains ONE more line, so its
// first REAL tick (post-hydration, or after the display:none ancestor is revealed)
// resolves what connect-time couldn't:
#setupChildResizeObserver() {
  this.#childRO = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const w = entry.contentBoxSize[0].inlineSize;
      if (w > 0) this.collapsed = w <= SNAP_THRESHOLD;   // ← the deferred correction
      // ...existing unrelated logic continues unchanged...
    }
  });
}
```
The key move: reuse the observer that's ALREADY there rather than adding a
purpose-built one, a second observer watching the same element for two unrelated
reasons is a maintenance smell, and per §1 above, a NEW observer needs its own
construction guard anyway.

## §4 · Adopt-or-diff, value-diff before mutating, never guess or half-adopt (gh#1678, gh#1687)

**Shape:** don't mutate a DOM position until a comparison proves it actually
needs to change. `setAttribute()` queues a mutation record even when the new
value is byte-identical to the old one (confirmed directly against both
happy-dom and linkedom); `removeAttribute()` on an already-absent attribute
does NOT (the spec's own asymmetry, no compare-first guard needed there).
Two concrete shapes, both shipped, picked by whether the reconciled unit
carries a stable identity key:

**(a) A flat, keyed list, seed the keyed-reconcile map from the adopted
DOM (`pagination-ui`, gh#1687).** `connected()` adopts a pre-existing
server-rendered structural child (`this.#nav = this.querySelector(':scope
> nav[slot="nav"]')`) instead of unconditionally creating a fresh one, but adopting the CONTAINER alone isn't sufficient: `reconcile()`'s own
keyed diff (`core/element.js`) keys off a `parent[KEY_MAP]` populated by
this element's OWN prior render calls, which a freshly-parsed SSR fragment
never has. Without seeding it, the very first render treats every adopted
child as unrecognized and stamps a full duplicate set alongside the
originals. `#seedKeyMapFromAdoptedNav()` positionally zips the adopted
children against the SAME key order the next render would produce; a shape
mismatch (`#childMatchesItem()` checking tag + marker, not just count) is
left unseeded so reconcile falls back to a genuine clean rebuild instead of
miskeying a wrong-tagged survivor in place.

**(b) An arbitrary positional child with no stable key, compare via
`Node.isEqualNode()` (`table-ui`, gh#1678).** Table cells have no identity
key the way a pagination item does (no natural "this is always the id
column" marker independent of position), so `adoptOrDiffChildren()`
(`table.class.js`) builds each fresh candidate exactly as before, then
compares it against the existing child at that position with
`existing.isEqualNode(fresh)`, standard DOM, present under every
environment this framework runs in (browsers, happy-dom, linkedom), never
one of §1's browser-only APIs:
```js
function adoptOrDiffChildren(container, freshChildren) {
  while (container.children.length > freshChildren.length) container.lastChild.remove();
  for (let i = 0; i < freshChildren.length; i++) {
    const existing = container.children[i];
    const fresh = freshChildren[i];
    if (!existing) container.appendChild(fresh);
    else if (RENDERER_OWNED.has(fresh) || !existing.isEqualNode(fresh)) container.replaceChild(fresh, existing);
    // else: matches byte-for-byte AND carries no renderer-owned runtime
    // state (§4.1 below), adopt in place, touch nothing.
  }
}
```
A match adopts in place (zero mutation); a mismatch replaces the position
wholesale exactly as the pre-fix code always did, never a partial patch of
a mismatched node's individual attributes, which would risk leaving a
wrong-tagged or wrong-shaped survivor "fixed" in place instead of really
rebuilt (the same failure mode (a) above guards against via
`#childMatchesItem()`).

### §4.1 · `isEqualNode()` proves structural safety, never runtime-state safety (gh#1678 CodeRabbit follow-up, closed on PR #1756 before merge)

**The gap:** (b) above is a *structural* diff, tag, attributes, text,
descendants. It's a necessary adoption test but not a sufficient one: it
cannot see an event listener (or any other runtime/imperative state) that a
renderer attached to the candidate node it returned. `table.class.js`'s
`#updateRow()` runs a per-cell renderer (`col.render()`, an arbitrary
consumer function; or a built-in cell-type renderer, `typeDef.render`)
BEFORE the candidate ever reaches `adoptOrDiffChildren()`. If that renderer
attached a listener, the candidate can still be `isEqualNode()`-identical to
the existing (e.g. listener-less, SSR-parsed) DOM at that position, the
guard reports a match, adopts the OLD node, discards the fresh one, and the
listener silently never lands. The pre-adopt-or-diff, unconditional-
`replaceChild()` code never had this bug, because it always installed
whatever the renderer had just built, every time.

**The fix, an explicit renderer-owned marker, not a deeper structural
check.** A `Node.isEqualNode()`-shaped fix can only ever prove declarative
shape; it structurally cannot see a listener, so the fix isn't "compare
harder", it's "know which candidates a comparison can't clear in the first
place, and never let structural equality alone adopt one of those." A
module-level `RENDERER_OWNED` `WeakSet` tags exactly the cells built by
something free to attach listeners/runtime state:
```js
const RENDERER_OWNED = new WeakSet();
// ...
if (typeof col.render === 'function') {
  const result = col.render(value, data, cell, dataIndex);
  // ... apply result to cell ...
  RENDERER_OWNED.add(cell);           // arbitrary code, always tag
} else if (typeof col.format !== 'function') {
  const typeDef = cellTypes[col.type || 'text'];
  if (typeDef?.render) {
    typeDef.render(value, data, cell, col.meta);
    if (typeDef.attachesListeners) RENDERER_OWNED.add(cell);  // opt-in only
  }
}
```
`col.render()` is always tagged, it's opaque consumer code, impossible to
introspect for safety. A built-in cell-type renderer is tagged only when
its own registration declares `attachesListeners: true`, in this
framework, currently just `cellTypes.actions` (`cell-types.js`), the one
built-in type that calls `addEventListener()` directly on a node it builds.
Every OTHER built-in cell type (text/number/currency/percent/date/datetime/
boolean/badge/avatar/link/markdown/progress) only sets attributes on
already-declarative custom elements or plain nodes, no listeners, so
tagging them buys nothing and costs the zero-mutation benefit for the
overwhelmingly common case. **This was measured, not assumed**: an earlier
draft of this fix tagged every `typeDef.render` cell unconditionally and
broke the AC-004a zero-mutation test (§ above) for plain text/number/date
cells, narrowed to the declared-flag form before merge.

**Applying this pattern to a NEW adopt-or-diff instance (elsewhere in this
framework, or #1755/#1754 if either goes this direction):** before trusting
`isEqualNode()` alone, ask whether ANY renderer/callback this component
invokes to build a candidate node is free to attach a listener or stash
other runtime state on it. If yes, that candidate needs its own
`RENDERER_OWNED`-shaped tag (or equivalent) and must always be replaced,
never adopted on structural equality alone, an activation step that tries
to re-attach the listener onto the ADOPTED node instead is a fragile
protocol this fix deliberately did not attempt (unclear how to discover
"what would the renderer have attached" without re-running the renderer,
at which point you already have the fresh node to just use).

**(c) Idempotent attribute writes, the write itself must be guarded, not
just the value.** Any attribute set that runs on EVERY render/connect
regardless of whether the value changed (a host's `role`/`tabindex`, an
inline computed style, a row's `data-index`/`aria-selected`) needs a
compare-before-write wrapper, `setAttrIfChanged(el, name, value)`, because
a bare `setAttribute(el, name, sameValue)` still mutates. `removeAttribute`
needs no equivalent guard (already a no-op on an absent attribute per
spec, confirmed directly).

**When to reach for (a) vs (b):** a reconciled LIST with a natural per-item
identity (a page number, a row's primary key) → (a); a fixed-position grid
of cells/fields with no such per-position identity → (b). Both fall back to
the SAME principle on a mismatch: rebuild for real, never guess and never
half-adopt (leave a wrong node "patched" in place instead of replaced).

**The conditional-inject class (querySelector-guard-before-innerHTML, `search-ui`, `pagination-ui`) is this same principle at container
granularity**, `if (!this.querySelector('input-ui')) this.innerHTML = …`
IS an adopt-or-diff check, just a boolean presence check instead of a value
comparison. It's SSR-safe exactly when the querySelector target is
STRUCTURALLY SPECIFIC (an exact tag/slot, never "has any children at all").
See [`failure-shapes.md`](failure-shapes.md) §6 for the full decision and
what it does NOT yet close (post-adopt attribute writes still need (c)'s
idempotent-write guard, not yet applied to `search-ui`/`pagination-ui`).

## Verify targets for a new guard

See [`test-without-linkedom.md`](test-without-linkedom.md) for how to prove a new
guard actually works, deleting the real API in a test, not mocking it.
