Skip to content

BrowserApp.hydrate() can race with HTML parser, duplicating the rendered app #926

Description

@lemonmade

Summary

In @quilted/preact-browser, BrowserApp.hydrate() can run before the HTML parser has finished populating the #app container. When that happens, Preact builds fresh DOM inside the half-parsed container, the parser then appends the remaining SSR'd nodes, and the whole app ends up rendered twice in the DOM. The bug is intermittent (depends on script-fetch vs parsing timing) and easy to miss in development.

Root cause

Two interacting pieces:

1. The entry <script> is async.

In @quilted/preact-browser/source/server/render.tsx, ScriptAssets for the entry chunk is rendered with async: true:

jsx(ScriptAssets, {
  scripts: dedup([entryAssets.script.asset], renderedAssets),
  async: true,
})

For <script type="module">, the default is already defer (run after parsing). Adding async flips it to "execute as soon as fetched" — which can be mid-parse.

2. #waitForDOMNode only waits for the element to exist, not for its content.

async #waitForDOMNode() {
  return this.#queryForDOMNode() ?? new Promise((resolve) => {
    const observer = new MutationObserver(() => {
      const el = this.#queryForDOMNode();
      if (el) { observer.disconnect(); resolve(el); }
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });
  });
}

This resolves as soon as <div id=\"app\">'s opening tag is parsed — its SSR'd children may not have streamed in yet.

Symptoms

The whole app appears twice in the document. Telltale signs:

  • Two top-level subtrees inside #app with the same structure.
  • The "extra" copy's element attributes are formatted as a CSSStyleDeclaration would serialize them at runtime (style=\"--foo: bar; ...\" — spaces after colons), while the original SSR copy is in preact-render-to-string format (style=\"--foo:bar;...\" — no spaces).
  • The original SSR copy is wrapped in <!--$s-->...<!--/$s--> markers around any Suspense boundary that suspended during render; the duplicate is not.

Suggested fixes

Either or both:

  1. Drop async: true from the entry <ScriptAssets> so module-script default defer semantics apply (parsing completes before the script runs).
  2. In BrowserApp.hydrate() / #waitForDOMNode(), wait for document.readyState !== 'loading' (or a DOMContentLoaded listener) before resolving, so the SSR'd content is fully in the DOM before Preact hydrates.

Local workaround

Gating app.hydrate() on DOMContentLoaded from userspace works:

if (document.readyState === 'loading') {
  await new Promise((resolve) =>
    document.addEventListener('DOMContentLoaded', () => resolve(), { once: true }),
  );
}
await app.hydrate();

Versions

  • @quilted/preact-browser 0.2.8
  • preact 10.29.0
  • preact-render-to-string 6.6.6/6.6.7

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions