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:
- Drop
async: true from the entry <ScriptAssets> so module-script default defer semantics apply (parsing completes before the script runs).
- 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
Summary
In
@quilted/preact-browser,BrowserApp.hydrate()can run before the HTML parser has finished populating the#appcontainer. 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>isasync.In
@quilted/preact-browser/source/server/render.tsx,ScriptAssetsfor the entry chunk is rendered withasync: true:For
<script type="module">, the default is alreadydefer(run after parsing). Addingasyncflips it to "execute as soon as fetched" — which can be mid-parse.2.
#waitForDOMNodeonly waits for the element to exist, not for its content.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:
#appwith the same structure.CSSStyleDeclarationwould serialize them at runtime (style=\"--foo: bar; ...\"— spaces after colons), while the original SSR copy is inpreact-render-to-stringformat (style=\"--foo:bar;...\"— no spaces).<!--$s-->...<!--/$s-->markers around any Suspense boundary that suspended during render; the duplicate is not.Suggested fixes
Either or both:
async: truefrom the entry<ScriptAssets>so module-script defaultdefersemantics apply (parsing completes before the script runs).BrowserApp.hydrate()/#waitForDOMNode(), wait fordocument.readyState !== 'loading'(or aDOMContentLoadedlistener) before resolving, so the SSR'd content is fully in the DOM before Preact hydrates.Local workaround
Gating
app.hydrate()onDOMContentLoadedfrom userspace works:Versions
@quilted/preact-browser0.2.8preact10.29.0preact-render-to-string6.6.6/6.6.7