[2.0 rc.15 / next] SSR never evaluates a `ref` on an element that has a spread, so a directive factory's setup misaligns hydration ids
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đá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
- 30/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- javascript, typescript
Hướng nghiên cứu
Start with the compiled SSR output in the issue: an element with a spread and a ref becomes an ssrElement call that drops the ref expression, while the no-spread case keeps _ref$. The repro is repro.sh in the gist, and the attributeExpressions hydratable SSR fixture on next shows the same drop. Done means the spread case evaluates the ref on the server and the spread mode in the repro updates its label to after with no hydration key miss.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Describe the bug
On the server, an element's ref expression is evaluated when the element has no spread (_ref$ = tooltip()), so the owned setup half of a two-phase directive factory runs on the server as it does on the client.
When the element also has a spread, both compilers emit ssrElement(tag, props, children, hydratable) and drop the ref expression entirely. The factory's setup then runs on the client but not on the server. If the setup creates a primitive (the documented titleDirective creates an effect), every hydration id after it in the component is off by one. The next element misses its key, the client creates a detached copy, and the server-rendered node never updates.
Compiled SSR output (@solidjs/compiler; @solidjs/babel-plugin emits the same):
// <div ref={[tooltip(), props.ref]} />
_v$ = _$ssrHydrationKey(), _ref$ = [tooltip(), props.ref], _$ssr(_tmpl$, _v$)
// <div {...rest} ref={[tooltip(), props.ref]} />
_$ssrElement("div", rest, undefined, true)
Repro, using the factory from documentation/solid-2.0/07-dom.md:
// app.tsx
import { createEffect, createSignal } from "solid-js";
// The two-phase directive factory from the 2.0 docs (07-dom.md, "titleDirective").
function titleDirective(source: () => string) {
let el: HTMLElement | undefined;
createEffect(source, (value) => {
if (el) el.title = value;
});
return (nextEl: HTMLElement) => {
el = nextEl;
el.title = source();
};
}
export const [label, setLabel] = createSignal("before");
function Label() {
return <span>{label()}</span>;
}
const rest = { class: "box" };
export function WithoutSpread() {
return (
<section>
<div ref={titleDirective(() => "tip")} />
<Label />
</section>
);
}
export function WithSpread() {
return (
<section>
<div {...rest} ref={titleDirective(() => "tip")} />
<Label />
</section>
);
}
// Workaround: create the factory in the component body, so the server runs its setup too.
export function WithSpreadHoisted() {
const tip = titleDirective(() => "tip");
return (
<section>
<div {...rest} ref={tip} />
<Label />
</section>
);
}
Each component is rendered with renderToString, then hydrated, then setLabel("after") is called:
--- plain
server html: <section _hk=0><div></div><!--$--><span _hk=2>before</span><!--/--></section>
after setLabel: <section _hk="0"><div title="tip"></div><!--$--><span _hk="2">after</span><!--/--></section>
console: (nothing)
--- spread
server html: <section _hk=0><div class="box"></div><!--$--><span _hk=1>before</span><!--/--></section>
after setLabel: <section _hk="0"><div class="box" title="tip"></div><!--$--><span _hk="1">before</span><!--/--></section>
console: Hydration key miss for "2": no server-rendered element carries this key (template: <span>). A detached element was created instead; ...
--- hoisted
server html: <section _hk=1><div class="box"></div><!--$--><span _hk=2>before</span><!--/--></section>
after setLabel: <section _hk="1"><div class="box" title="tip"></div><!--$--><span _hk="2">after</span><!--/--></section>
console: (nothing)
In the spread case the label keeps saying before.
Your Example Website or App
The complete repro is inline below. It needs only the Solid packages, plus happy-dom for the DOM.
Steps to Reproduce the Bug or Issue
package.json, repro.sh, compile.mjs, server.tsx, client.tsx
{
"private": true,
"type": "module",
"scripts": { "repro": "sh repro.sh" },
"dependencies": {
"@solidjs/compiler": "2.0.0-rc.15",
"@solidjs/web": "2.0.0-rc.15",
"solid-js": "2.0.0-rc.15"
},
"devDependencies": {
"@happy-dom/global-registrator": "21.0.1",
"happy-dom": "21.0.1"
}
}
#!/bin/sh
# repro.sh
set -e
cd "$(dirname "$0")"
node compile.mjs app.tsx app.ssr.ts ssr true
node compile.mjs app.tsx app.dom.ts dom true
node compile.mjs server.tsx server.ssr.ts ssr true
node compile.mjs client.tsx client.dom.ts dom true
for mode in plain spread hoisted; do
bun server.ssr.ts $mode > server-$mode.html
bun --conditions=browser --conditions=development client.dom.ts $mode
done
// compile.mjs
import { transform } from "@solidjs/compiler";
import { readFileSync, writeFileSync } from "node:fs";
const [input, output, generate = "dom", hydratable = "false"] = process.argv.slice(2);
const { code } = transform(readFileSync(input, "utf8"), { filename: input, generate, hydratable: hydratable === "true" });
writeFileSync(output, code);
// server.tsx
import { renderToString } from "@solidjs/web";
import { WithoutSpread, WithSpread, WithSpreadHoisted } from "./app.ssr.ts";
const which = { plain: WithoutSpread, spread: WithSpread, hoisted: WithSpreadHoisted }[process.argv[2]!]!;
process.stdout.write(renderToString(() => which({})));
// client.tsx
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
const { readFileSync } = await import("node:fs");
const { hydrate } = await import("@solidjs/web");
const { flush } = await import("solid-js");
const { WithoutSpread, WithSpread, WithSpreadHoisted, setLabel } = await import("./app.dom.ts");
const mode = process.argv[2];
const html = readFileSync(`server-${mode}.html`, "utf8");
const root = document.createElement("div");
document.body.append(root);
(globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} };
root.innerHTML = html;
const messages: string[] = [];
console.error = console.warn = (...a: unknown[]) => messages.push(String(a[0]).split("\n")[0]);
const Which = { plain: WithoutSpread, spread: WithSpread, hoisted: WithSpreadHoisted }[mode!]!;
hydrate(() => <Which />, root);
flush();
setLabel("after");
flush();
console.log(`--- ${mode}`);
console.log(`server html: ${html}`);
console.log(`after setLabel: ${root.innerHTML}`);
console.log(`console: ${messages.length ? messages.join(" | ") : "(nothing)"}`);
- Put the files above next to
app.tsx. bun install && bun run repro
Expected behavior
A ref expression should be evaluated on the server whether or not its element has a spread, as it already is without one. Then a factory's owned setup runs on both sides and hydration ids line up. In the spread case the label should update to after, as it does in the other two.
Screenshots or Videos
No response
Platform
solid-js,@solidjs/web,@solidjs/compiler,@solidjs/babel-plugin2.0.0-rc.15nextat eb4e019: the hydratable SSR fixtureattributeExpressionsstill compiles<li {...spread} class="row" data-kind="item" ref={link} />to anssrElementcall with no ref, while<div ref={refFactory()} />keeps_ref$4 = refFactory().- Bun 1.4.3, Node 24.11.1, happy-dom 21.0.1, macOS
Additional context
There are two workarounds:
- Create the factory in the component body and pass the result:
const tip = titleDirective(...), then<div {...rest} ref={tip} />. This is thehoistedcase above. - Keep primitives out of the factory's setup.
The spread-plus-ref shape is the default for parts in a headless component library, which is where I found this. A part spreads the caller's props and attaches its own behaviour with ref={[behaviour(), props.ref]}.
The serverComponents path already collects a spread element's named ref into a claim thunk (ad1ecc5). Plain SSR evaluates nothing.
Runnable repro: https://gist.github.com/cevr/9cb8063417332ce689b3c852df5809f9 (bun install && bun run repro)
Filed with Claude Code on behalf of @cevr.
- Ngôn ngữ chính
- TypeScript
- Star
- 36.1k
- Fork
- 1.1k
- Merge trung bình
- 11 giờ 49 phút
- Pull request đã merge (30 ngày)
- 346
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- 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.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của solidjs/solid
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 18/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 3/5 Nửa ngày Mức phù hợp với người mới 55/100
Maintainer thường phản hồi trong vòng 1 ngày
-
[2.0 / next] Error formatting and async iterator protocol failures escape memo error handlingĐang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 22/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 14/100
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của solidjs/solid
Issue tương tự
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 83/100
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
ranxianglei/billion-context#3583 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug:new
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
callstackincubator/simlock#673 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
documentation v3
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 73/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 73/100
anomalyco/opencode#56380 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày