skip to content

A page server-renders custom elements with declarative shadow DOM. When the component script finally loads, each element's constructor calls this.attachShadow({ mode: 'open' }) and rewrites its markup, and users see a flash. How do you make the client-side definition adopt the server-rendered shadow root, and what else races here?

level: seniorimportance: should knowfreq 28%

answer

  1. do not assume you own the root
  2. the parser got there first
  3. an own property outranks a prototype accessor
  4. delete, then reassign, at upgrade
  5. the gap between parsed and defined

basics

~20 s

Check for an existing root first — const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }) — and skip re-rendering when the server markup is already there, only wiring listeners. The other race is properties assigned before the element upgraded, which shadow the class accessors.

solid answer

~50 s

Never assume the constructor owns the shadow root. Write `const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' })`, and when the root already has server content, attach listeners to the existing nodes instead of assigning `innerHTML`. Calling `attachShadow` blindly is destructive in both directions: historically it threw `NotSupportedError` on an element that already had a root, and where the newer behaviour lets it adopt a declaratively-created one it *empties* it first — either way your server markup is gone. The second race is the upgrade race: if consumer code sets `el.value = {...}` before `customElements.define` runs, the assignment creates an own data property that permanently shadows the class's prototype setter, so your setter never fires. The fix is to delete and reassign such properties in `connectedCallback`. Between parse and upgrade the element is also inert — style `:not(:defined)` and accept that clicks are dropped.

code

javascript · 30 lines
javascript
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = '<button type="button">Follow</button>';

class UserCard extends HTMLElement {
  #users = [];

  get users() { return this.#users; }
  set users(value) { this.#users = value; this.#render(); }

  connectedCallback() {
    const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
    if (!root.firstChild) {
      root.append(TEMPLATE.content.cloneNode(true));
    }
    root.querySelector('button')?.addEventListener('click', () => this.#follow());
    this.#upgradeProperty('users');
  }

  #upgradeProperty(name) {
    if (Object.hasOwn(this, name)) {
      const value = this[name];
      delete this[name];
      this[name] = value;
    }
  }

  #render() {}
  #follow() {}
}
customElements.define('user-card', UserCard);

go deeper

for a junior

Know that a custom element can find a shadow root already attached by the parser, and that code should check this.shadowRoot before calling attachShadow.

for a middle

Explain why an own data property set before upgrade masks the class's prototype accessor, and walk through the delete-then-reassign fix.

for a senior

Diagnose the whole SSR window: destroyed server markup, lost property assignments, dropped clicks, and the mismatch that follows when server and client templates diverge.

for a principal

Decide the library-wide contract for server-rendered components — who registers definitions and how early, whether adoption is mandatory, and what interactivity you promise before upgrade.

## Two races, one symptom Server-rendered custom elements have a window between "HTML parsed" and "element upgraded" in which the element exists in the DOM but its class does not exist yet. Two different bugs live in that window. ## Race one: the shadow root already exists Declarative shadow DOM means the parser has already attached a shadow root and filled it. The conventional constructor body assumes otherwise: ```js constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = TEMPLATE; // destroys SSR output } ``` On an element that already has a root this either throws `NotSupportedError` — the long-standing rule that an element may have only one shadow root — or, under the newer behaviour that lets `attachShadow()` adopt a declaratively-created root, succeeds by *emptying* it first. Both outcomes lose the server markup; the second is worse because it looks like it worked, and the user sees the flash as correct content is replaced by a rebuilt copy. The adoption pattern: ```js connectedCallback() { const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }); if (!root.firstChild) { root.replaceChildren(TEMPLATE.content.cloneNode(true)); } root.querySelector('button')?.addEventListener('click', this.#onClick); } ``` Two details matter. First, the element must produce *the same* tree on the server and the client, or adoption leaves stale nodes behind and later queries return the wrong element — the same discipline any hydration model demands. Second, `connectedCallback` can run more than once if the element is moved, so guard initialization with a flag or a check like the one above rather than assuming it fires once. If the declarative root was `shadowrootmode="closed"`, `this.shadowRoot` is `null` from outside, but the element itself can reach it through `this.attachInternals().shadowRoot`. ## Race two: properties set before upgrade This is the classic interop bug and it has nothing to do with shadow DOM. Consumer code — a framework rendering the page, a script hydrating data — runs before the component bundle loads and does: ```js document.querySelector('user-card').users = data; ``` At that moment the element is an `HTMLElement` with no `users` accessor, so the assignment creates an **own data property** on the instance. When `customElements.define` later runs and the element upgrades, its prototype gains `get users` / `set users` — but prototype accessors are only consulted when the object has no own property of that name. The own property shadows the setter forever: your setter never fires, your render never runs, and reading `this.users` inside the class returns the raw value while the component shows nothing. The standard remedy runs once per property at upgrade time: ```js #upgradeProperty(name) { if (Object.hasOwn(this, name)) { const value = this[name]; delete this[name]; this[name] = value; // now hits the prototype setter } } connectedCallback() { this.#upgradeProperty('users'); } ``` Deleting the own property re-exposes the accessor; reassigning replays the value through it. Every rich property a consumer might set before definition needs this treatment. ## The inert window Between parse and upgrade the element has no behaviour at all. Clicks land on real DOM but no listener exists, so they are dropped — there is no queue. What you can do: - **Make it look inert.** The `:defined` pseudo-class matches upgraded elements, so `my-widget:not(:defined) { visibility: hidden }` or a skeleton style prevents interaction with something that will not respond. With DSD the opposite is often better — show the server content and disable only the controls. - **Load the definitions early.** A small, high-priority module that registers the element classes shrinks the window; the heavy rendering work can load later. - **Do not fake it.** Recording clicks during the gap and replaying them after upgrade loses the user-activation state, so anything gated on a real gesture — opening a popup, clipboard, fullscreen — fails. ## What to say The headline is that a server-rendered custom element must be written defensively on both fronts: never assume you own the shadow root, and never assume your accessors existed when a consumer first assigned to the element. Both bugs are silent, both are invisible in a client-only development setup, and both appear the moment real SSR or a lazily-loaded bundle enters the picture.

  • Why does deleting the own property and reassigning it actually fix the upgrade race?
    Property lookup checks own properties before the prototype chain, so the own data property created before upgrade permanently masks the accessor added by the class. `delete this.users` removes the mask, and reassigning the saved value then resolves to the prototype setter, which runs your normal side effects such as re-rendering.
  • How would you keep users from clicking a server-rendered component that has not upgraded yet?
    Style on `:defined` — `my-widget:not(:defined)` matches only before upgrade. With declarative shadow DOM you usually keep the content visible and disable just the interactive parts, so the page reads correctly while inert. Also register the element classes from a small early script so the window is short.
  • The declarative shadow root was written with shadowrootmode="closed". How does the class reach it?
    `this.shadowRoot` is null for a closed root, so the element uses `this.attachInternals().shadowRoot`, which exposes the root to the element itself while keeping it hidden from outside code. Calling `attachInternals()` twice on the same element throws, so store the returned object once.

saying these in an interview costs you the question

  • Calling attachShadow unconditionally in the constructor
  • Assuming connectedCallback runs exactly once
  • Believing prototype setters override own properties
  • Thinking DSD hydrates listeners automatically
  • Replaying queued clicks and expecting user activation

context