Skip to content

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

Description

@lxsmnsyc

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 🌎

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions