solidjs / solidjs/solid-start

[Bug]: Client-side navigation during hydration causes hydration issue.

Open
#2,313 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.