hydrate() run mid-parse leaves dead server nodes beside fresh client copies (async entry script)
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 20/100
- Issue 类型
- 缺陷
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- javascript, typescript
- 领域
- build-system, frontend
调研方向
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.
由索引模型根据 Issue 内容生成。
描述
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:
@solidjs/vite-plugininjects the client entry as<script type="module" async>in<head>, and the generated entry callshydrate(() => <Document>…</Document>, document)as soon as the module runs. An async module runs as soon as it is ready, even mid-parse.- 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. hydrate()→gatherHydratable()collects only the_hkelements 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()keepscurrent, and the node gets inserted on a later update of that region.- 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:
- vite-plugin: with
renderMode: "async"the document is complete when it is sent, soasyncon the entry buys nothing and opens this race. The plugin could emit the entry withoutasyncin that mode, so it runs deferred, after parsing. A one-line change nearheadParts.push(... ' async' ...). hydrate()waits for the shell: a naive "defer whiledocument.readyState === "loading"" fixes the case above but breaks the streaming contract: 14 existing hydration tests callhydrate()duringloadingwith 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_hkof the shell, andhydrate()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.
- 主要语言
- TypeScript
- 星标
- 36.1k
- 派生
- 1.1k
- 平均合并
- 9 小时 56 分钟
- 30 天内合并 PR
- 336
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
solidjs/solid 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 66/100
维护者通常 1 天内回复
-
[diagnostics rc.14 / next] Browser bridge drops shared references, so `expectNoSilentHolds` throws on any capture with a `LONG_HOLD`可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 2/5 1-3 小时 新手友好度 72/100
维护者通常 1 天内回复
-
难度 5/5 一周以上 新手友好度 12/100
维护者通常 1 天内回复
-
难度 3/5 半天 新手友好度 32/100
维护者通常 1 天内回复
-
[2.0 rc.14 / next] SSR: async memo retries run under another concurrent render's context可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 3/5 1-2 天 新手友好度 52/100
维护者通常 1 天内回复
相似的 Issue
-
nextjs-tracking
难度 2/5 1-3 小时 新手友好度 74/100
cloudflare/vinext#3887 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 86/100
apache/rocketmq-dashboard#6331 ·
维护者通常 5 天内回复
-
bug cache clinical-safety P1-critical
难度 2/5 1-3 小时 新手友好度 76/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 76/100
juspay/blend-design-system#1788 ·
维护者通常 1 天内回复
-
[Analytics] Add Focus Time-Block Efficiency Ratio Calculator in src/lib/focus/efficiency-ratio.ts未关闭analytics beginner friendly community contributions-welcome enhancement good first issue hacktoberfest help wanted up-for-grabs
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 1 天内回复