Duplicates
Latest version
Current behavior 😯
When a page (mainly a streaming one) is initially loaded and navigated out of during hyration, it will cause a hydration warning on the new page, which is caught by the ErrorBoundary, triggering a double render (#2297).
Expected behavior 🤔
Not sure what should be the goal behavior, but most likely discarding the pending hydration process would be ideal?
Steps to reproduce 🕹
Here's a page you can add in apps/tests:
import { createSignal, onCleanup } from "solid-js";
// The page loaded in the frame. It still has client work in flight while it
// hydrates, which is what keeps the window open long enough for a navigation
// to land in the middle of it.
const PAGE = "/client-only";
// Where the navigation goes. Any other route works.
const TARGET = "/server-function-ping";
// The window is only tens of milliseconds wide and moves from machine to
// machine, so the sweep walks a range instead of guessing one value.
const SWEEP = [50, 55, 60, 65, 70, 75, 80, 85, 90, 100, 110, 120, 140];
interface FrameState {
url: string;
children: number;
navLists: number;
}
export default function HydrationRepro() {
const [delay, setDelay] = createSignal(60);
const [status, setStatus] = createSignal("idle");
const [broken, setBroken] = createSignal(false);
let frame: HTMLIFrameElement | undefined;
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
function readFrame(): FrameState {
const win = frame?.contentWindow;
const doc = frame?.contentDocument;
const app = doc?.getElementById("app");
return {
url: win ? win.location.pathname : "",
children: app ? app.children.length : 0,
navLists: doc ? doc.querySelectorAll("#app ul").length : 0,
};
}
// A healthy load leaves the layout's nav list and the route's own content
// in place. A broken one loses them.
function isBroken(state: FrameState) {
// -1 means the navigation never happened, so the run says nothing.
if (state.children < 0) return false;
return state.children !== 2 || state.navLists !== 1;
}
async function loadFrame(navigateAfter: number | null): Promise<FrameState> {
if (!frame) return { url: "", children: 0, navLists: 0 };
frame.src = PAGE;
if (navigateAfter === null) {
await wait(900);
return readFrame();
}
await wait(navigateAfter);
// Exactly what a link click or a `navigate()` call does. Only the timing
// is pinned, so the navigation lands while the frame is still hydrating.
// Before the frame commits its first response it is still on about:blank,
// where pushState to an app URL throws; that attempt is simply too early.
const win = frame.contentWindow;
if (!win || win.location.origin !== window.location.origin) {
return { url: "", children: -1, navLists: -1 };
}
try {
win.history.pushState({}, "", TARGET);
} catch {
return { url: "", children: -1, navLists: -1 };
}
await wait(700);
return readFrame();
}
async function runOnce() {
setStatus(`loading ${PAGE}, navigating to ${TARGET} after ${delay()}ms…`);
const state = await loadFrame(delay());
setBroken(isBroken(state));
setStatus(
isBroken(state)
? `broken at ${delay()}ms — frame is at ${state.url} with ${state.children} element(s) under #app`
: `survived at ${delay()}ms — frame is at ${state.url} and rendered normally; try another delay or run the sweep`,
);
}
async function runSweep() {
for (const candidate of SWEEP) {
if (cancelled) return;
setStatus(`trying a navigation ${candidate}ms into the load…`);
const state = await loadFrame(candidate);
if (isBroken(state)) {
setDelay(candidate);
setBroken(true);
setStatus(
`broken at ${candidate}ms — frame is at ${state.url} with ${state.children} element(s) under #app; the console has the mismatch`,
);
return;
}
}
setBroken(false);
setStatus("no delay in the sweep hit the window this time — run it again");
}
async function runControl() {
setStatus(`loading ${PAGE} without navigating…`);
const state = await loadFrame(null);
setBroken(false);
setStatus(
`control load — frame is at ${state.url} with ${state.children} element(s) under #app`,
);
}
return (
<main id="hydration-repro">
<h1>Hydration mismatch on navigation</h1>
<p>
A client-side navigation that lands while the document is still hydrating makes Solid look
for the new route's markup inside the previous page's markup. It throws{" "}
<code>Hydration Mismatch. Unable to find DOM nodes for hydration key: …</code>, the error
boundary catches it, and the app is re-rendered on the client — which is why the page ends
up rendered twice or blank.
</p>
<p>
The frame below loads <code>{PAGE}</code>, a page that still has client work in flight while
it hydrates. The buttons change the frame's URL to <code>{TARGET}</code> partway through the
load, which is what a link click or a <code>navigate()</code> call would do; only the timing
is pinned. <strong>Open the browser console first</strong> — the mismatch is reported there,
and the dev toolbar's error viewer opens with it.
</p>
<p>
<button id="hydration-repro-sweep" type="button" onClick={runSweep}>
Reproduce (sweep delays)
</button>{" "}
<button id="hydration-repro-break" type="button" onClick={runOnce}>
Navigate after
</button>{" "}
<input
type="number"
min="0"
max="2000"
step="5"
value={delay()}
onInput={event => setDelay(event.currentTarget.valueAsNumber || 0)}
/>{" "}
ms{" "}
<button id="hydration-repro-control" type="button" onClick={runControl}>
Load normally
</button>
</p>
<p>
Frame state:{" "}
<output id="hydration-repro-status" data-broken={broken() ? "true" : "false"}>
{status()}
</output>
</p>
<p>
A healthy load leaves two elements under the frame's <code>#app</code>: the layout's nav list
and the page's own content. A broken one loses them. The window is a race only tens of
milliseconds wide, so a single delay may miss it — the sweep walks a range until one lands.
</p>
<iframe
id="hydration-repro-frame"
title="hydration repro"
ref={element => (frame = element)}
style={{ width: "100%", height: "18rem", border: "1px solid currentColor" }}
/>
</main>
);
}
Context 🔦
Related to #2297
Your environment 🌎
Duplicates
Latest version
Current behavior 😯
When a page (mainly a streaming one) is initially loaded and navigated out of during hyration, it will cause a hydration warning on the new page, which is caught by the ErrorBoundary, triggering a double render (#2297).
Expected behavior 🤔
Not sure what should be the goal behavior, but most likely discarding the pending hydration process would be ideal?
Steps to reproduce 🕹
Here's a page you can add in apps/tests:
Context 🔦
Related to #2297
Your environment 🌎