[Bug]: Client-side navigation during hydration causes hydration issue.
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 5.9k
- Forks
- 426
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 11
Description
Duplicates
- I have searched the existing issues
Latest version
- I have tested the 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 🌎
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the HydrationRepro page described for apps/tests and use its iframe controls to reproduce navigation during hydration; keep the browser console open for the hydration mismatch. Trace the client-side navigation and hydration entry points involved, then verify that navigating during hydration no longer causes the mismatch, double render, or blank page.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 42/100