# Testing an SSR gap, the linkedom gate first, then the deletion pattern

**UPDATE 2026-08-17 (gh#1430/#1436): this repo now HAS a `linkedom` root
devDependency and a shipped shim gate.** `scripts/dev/ssr-linkedom-smoke.mjs`
installs exactly the six globals `custom-elements-ssr/server-shim.js` installs
(document, window, customElements, HTMLElement, Event, CustomEvent, and nothing
else: no Node/Element/Text/DocumentFragment, no matchMedia, no
requestAnimationFrame, no getBoundingClientRect), imports every
`components/*/*.js` entry, and constructs + connects every registered tag the way
`CustomElementRender` does; `packages/web-components/test/ssr-linkedom-smoke.test.js`
runs it as a Node child process and fails on any import or render throw. **For a
shape-1 question ("does this crash under SSR"), run that first**, `node
scripts/dev/ssr-linkedom-smoke.mjs`: it is the consumer's environment, not an
approximation. Add a row to its `FIXTURES` table when a guard lands on a branch only
some attribute value reaches (`swiper-ui[autoplay]` is the model). The rest of this
file is the UNIT-level pattern for a focused regression test in the happy-dom
suite; it stays valid, but it is no longer the only proof available.

The main test suite still runs on `happy-dom` (`vitest.config.js`), which
implements most of the APIs linkedom lacks, so a naive test using the default
environment will not reproduce an SSR-shaped bug. Two consequences:

1. **You cannot trust "the tests pass" as proof an SSR fix works** unless the test
   itself removes the API under test. A guard around `attachInternals` that's never
   exercised without `attachInternals` proves nothing.
2. **The house pattern is deletion, not mocking**, `delete
   HTMLElement.prototype.attachInternals` (or `delete
   Document.prototype.adoptedStyleSheets`, or `delete globalThis.ResizeObserver`)
   inside a `try { ... } finally { restore }` block, in the SAME test environment the
   suite already runs in. This is a closer approximation of "the API is genuinely
   absent" than a mock that might itself paper over the exact code path that would
   throw, deleting the real thing exercises the REAL `typeof x === 'undefined'` /
   `typeof x === 'function'` check the guard actually uses.

## The pattern (descriptor-based restore, copy this shape for a new test)

The shipped test (`packages/web-components/core/element.test.js`, describe block
`'UIElement, SSR browser-API absence (gh#285)'`) restores via a plain `=` assignment
(`HTMLElement.prototype.attachInternals = original`). That works there ONLY because
`original` is always a real function in this repo's test environment (happy-dom always
provides `attachInternals`), it never actually exercises the absent-original case.
**Don't copy that shortcut.** If `original` is ever genuinely `undefined` (a NEW test
targeting an API this environment doesn't have either), a plain assignment leaves the
property present with value `undefined` instead of restoring true absence, later
tests checking `'attachInternals' in HTMLElement.prototype` would wrongly see `true`,
making pass/fail order-dependent (flagged by review on this pack's own PR #293).
Use the descriptor-based restore, the exact shape `adoptedStyleSheets` already uses
below, for every new instance of this pattern:

```js
it('constructs without throwing when attachInternals does not exist', () => {
  const desc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'attachInternals');
  delete HTMLElement.prototype.attachInternals;
  try {
    class El extends UIElement {}
    const tag = registerTestElement(El);
    expect(() => mount(tag)).not.toThrow();
  } finally {
    if (desc) Object.defineProperty(HTMLElement.prototype, 'attachInternals', desc);
  }
});
```

For a global (not a prototype method), the same descriptor-based restore applies, globals are own-properties of `globalThis`, so `Object.getOwnPropertyDescriptor`
works identically:
```js
const desc = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver');
delete globalThis.ResizeObserver;
try {
  // ... construct the component, assert it doesn't throw and degrades sanely ...
} finally {
  if (desc) Object.defineProperty(globalThis, 'ResizeObserver', desc);
}
```

For `document.adoptedStyleSheets` (an own-property on `Document.prototype`), delete
the property descriptor and restore it the same way:
```js
const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'adoptedStyleSheets');
delete Document.prototype.adoptedStyleSheets;
try {
  // ... assert ...
} finally {
  if (desc) Object.defineProperty(Document.prototype, 'adoptedStyleSheets', desc);
}
```

## What this method does NOT catch

Deletion proves "the guarded code path doesn't throw when the API is absent." It does
**not** prove:
- that linkedom's ACTUAL behavior matches "absent" exactly (linkedom may implement a
  PARTIAL or subtly-wrong version of an API rather than nothing at all, always check
  the consumer's bug report for the exact error, don't assume "missing" when the
  report says something more specific);
- anything about shape 2's ORIGINAL destructive-`stamp()` diagnosis (narrowed
  2026-07-17) or shape 5 (declarative data binding, gh#288), neither is an
  API-absence bug, so this deletion-based method doesn't apply to them. Shape
  2's narrowing was verified with exactly the shape of test this section
  describes, a container element given real children (or text) BEFORE
  `document.appendChild()` connects it (simulating server-parsed markup that
  already has content when `connectedCallback` first runs), confirming
  `nav-ui`/`check-ui` preserve pre-existing content while a synthetic
  non-null-template component loses it (see
  [`failure-shapes.md`](failure-shapes.md) §2). That reproduction was a
  throwaway, not a shipped permanent test, if shape 2 needs re-verifying later
  (a new component trips `audit-template-child-conflict.mjs`, or the consumer
  reports something new), rebuild it the same way rather than assuming the old
  narrowing still holds.

  **Shape 2b (the attribute-upgrade-replay gap, gh#284's real fix, 2026-07-18)
  IS testable this way, and now HAS a shipped permanent test**, it's not an
  API-absence bug either, but the exact same "define the tag AFTER the markup
  already exists" sequence reproduces it directly, no deletion trick needed
  (the gap is happy-dom's own upgrade behavior, not a missing API to delete).
  See `packages/web-components/core/element.test.js`,
  `describe('UIElement: SSR attribute-upgrade replay (gh#284)')`, for the
  canonical `mountLateUpgrade()` helper and four tests: confirms the
  environment gap on a bare custom element, confirms a reflected string prop
  and a reflected boolean prop both resolve correctly post-fix, and confirms
  no regression (no extra render pass) on the normal define-before-parse
  path. Use this pattern, not a fresh ad hoc repro, for any future
  late-upgrade/attribute-replay question.

## Live-browser confirmation is still required, separately

The delete/try/finally tests prove the GUARD doesn't crash. They do not prove real
browsers are unaffected, that's a SEPARATE check, done live (Playwright or the
Chrome extension), confirming the real API still gets used correctly when it's
actually present. Both gh#285 and gh#286's fixes were verified this second way before
shipping (a real `ElementInternals` instance still used and functional; a real
`ResizeObserver` tick still corrects state), do both, never one instead of the other.

## The deeper simulation, DONE 2026-08-17

The real-`linkedom` simulation this section used to propose is now the shipped
gate described at the top of this file (`scripts/dev/ssr-linkedom-smoke.mjs`,
`linkedom` pinned as a root devDependency, used only there, the main suite stays
on happy-dom). It sweeps import + connect for the whole catalog; a shape-2/4
question (content preserved across a late upgrade, declarative data seeding) is
still answered by a targeted test, extend the script's `FIXTURES` table with the
attrs/children in question, or write the assertion in
`test/ssr-upgrade-adoption-contract.test.js`'s late-upgrade shape.
