Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

hydrate() run mid-parse leaves dead server nodes beside fresh client copies (async entry script)

Đang mở
#3,971 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

@ryansolid đang làm issue này rồi.

Từ ngày 12/10/2026.

  • #4002 của @ryansolid — đang mở

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
20/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
javascript, typescript
Lĩnh vực
build-system, frontend

Hướng nghiên cứu

Start with the reproduction spec at packages/web/test/hydration/hydrate-partially-parsed-shell.spec.tsx, run with vite.config.hydrate.mjs, and confirm the serverButton.disabled assertion fails. Then read hydrate(), gatherHydratable() and getNextElement() in the web package to see how a missing _hk node is rendered from its template. Done means the claimed server node is reused, and the existing hydration tests still pass. The vite-plugin fix (dropping async from the entry script) is a separate, smaller change; agree on which fix to pursue before editing.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

Describe the bug

hydrate() can run while the HTML parser is still inside the server-rendered shell, and then the nodes not yet parsed end up duplicated: a fresh client node plus the server node, which stays in the document dead (no listeners, no bindings).

How it happens:

  1. @solidjs/vite-plugin injects the client entry as <script type="module" async> in <head>, and the generated entry calls hydrate(() => <Document>…</Document>, document) as soon as the module runs. An async module runs as soon as it is ready, even mid-parse.
  2. The document arrives in a single chunk (renderMode: "async", 13 KB in our case), but on a slow CPU Chrome parses it in slices and yields between them, and the ready module runs in one of those gaps.
  3. hydrate() → gatherHydratable() collects only the _hk elements parsed so far. When the walk reaches a key past the parser, getNextElement() misses and renders the node from its template. Since the fresh node is detached, insertExpression() keeps current, and the node gets inserted on a later update of that region.
  4. The parser then adds the server's copy. It is never claimed, so it stays in the DOM in its server state.

What we saw: a widget showed two "Edit" buttons, a live one without _hk and a <button disabled _hk="…"> that is the server render, permanently disabled. This happened in a Playwright run in an emulated linux/amd64 container (slow JS and slow parsing). It is rare in normal conditions, but it is a correctness bug rather than a cosmetic one: the page shows a dead control.

Versions: solid-js / @solidjs/web 2.0.0-rc.14, @solidjs/vite-plugin 3.0.0-next.47, Chromium (Playwright 1.64).

Your Example Website or App

No public app. Below is a self-contained hydration test that reproduces it against packages/web (vite.config.hydrate.mjs).

Steps to Reproduce the Bug or Issue

Put this file at packages/web/test/hydration/hydrate-partially-parsed-shell.spec.tsx and run pnpm exec vitest run --config vite.config.hydrate.mjs test/hydration/hydrate-partially-parsed-shell.spec.tsx. The server markup was captured with renderToString of the same component.

/**
 * @jsxImportSource @solidjs/web
 * @vitest-environment jsdom
 *
 * Server markup (renderToString of the same component):
 *   <main _hk=0><p>one</p><!--$--><button _hk=1 disabled>edit</button><!--/--></main>
 */
import { expect, test } from "vitest";
import { createSignal, enableHydration, flush } from "solid-js";
import { hydrate } from "@solidjs/web";

enableHydration();

test("hydrate() while the parser is still inside the shell", async () => {
  (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} };
  const container = document.createElement("div");
  document.body.appendChild(container);
  Object.defineProperty(document, "readyState", { value: "loading", configurable: true });

  // The parser has reached the hole before the button and yielded.
  container.innerHTML = "<main _hk=0><p>one</p><!--$-->";
  const main = container.querySelector("main")!;

  let setCount!: (n: number) => void;
  const Row = (props: { n: number }) => <button disabled={props.n === 0}>edit</button>;
  const dispose = hydrate(() => {
    const [count, _setCount] = createSignal(0);
    setCount = _setCount;
    return (
      <main>
        <p>one</p>
        <Row n={count()} />
      </main>
    );
  }, container);
  flush();

  // The parser resumes and finishes the document.
  main.insertAdjacentHTML("beforeend", "<button _hk=1 disabled>edit</button><!--/-->");
  const serverButton = main.querySelector("button")!;
  Object.defineProperty(document, "readyState", { value: "interactive", configurable: true });
  document.dispatchEvent(new Event("DOMContentLoaded"));
  await new Promise(r => setTimeout(r, 10));
  flush();

  expect(main.querySelectorAll("button").length).toBe(1);
  expect(main.querySelector("button")).toBe(serverButton);
  setCount(1);
  flush();
  expect(serverButton.disabled).toBe(false); // fails: the server button is never claimed

  dispose();
  container.remove();
  delete (document as any).readyState;
});

To see it in a browser, serve a page whose <body> contains a parser-blocking <script src="/slow.js"> (answered after ~1.5 s) right before a hydrated button. The async entry runs while the parser waits, and the button after the script stays in its server state.

Expected behavior

hydrate() claims every server node of the shell, whenever the entry script happens to run, so no dead server copy is left beside a client copy.

Screenshots or Videos

No response

Platform
  • OS: Linux (Playwright container mcr.microsoft.com/playwright:v1.64.0-noble, linux/amd64 emulated on Apple Silicon); reproduced deterministically on macOS with the test above
  • Browser: Chromium
  • Version: solid-js / @solidjs/web 2.0.0-rc.14, @solidjs/vite-plugin 3.0.0-next.47
Additional context

Possible fixes, from the narrowest to the widest:

  1. vite-plugin: with renderMode: "async" the document is complete when it is sent, so async on the entry buys nothing and opens this race. The plugin could emit the entry without async in that mode, so it runs deferred, after parsing. A one-line change near headParts.push(... ' async' ...).
  2. hydrate() waits for the shell: a naive "defer while document.readyState === "loading"" fixes the case above but breaks the streaming contract: 14 existing hydration tests call hydrate() during loading with a complete shell and expect it to hydrate at once. A correct version needs to know where the shell ends, for example the server serializes the last _hk of the shell, and hydrate() defers only while that node is not yet in the DOM (until it appears or DOMContentLoaded).

As a workaround in our app, Document.tsx (imported by the generated entry) does a top-level await for DOMContentLoaded on the client, which holds hydrate() back until the document is parsed.

Ngôn ngữ chính
TypeScript
Star
36.1k
Fork
1.1k
Merge trung bình
10 giờ 44 phút
Pull request đã merge (30 ngày)
339

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của solidjs/solid

Tất cả issue của solidjs/solid

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.