[2.0 rc.21 / next] renderToStream: a value that fails to serialize inside the write before the first flush still sends the shell and calls onCompleteAll
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 54/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 技术栈
- javascript, typescript
调研方向
Start with packages/web/src/server.ts around the renderToStream serializer onError condition identified in the issue, then read the existing stream serialization tests and the serialize entry in documentation/solid-2.0/12-ssr-http.md. Reproduce the synchronous hand-over failure before the first flush and compare it with the async case. Done means the pre-flush behavior matches the expected failed render and the affected tests and documentation reflect the chosen behavior.
由索引模型根据 Issue 内容生成。
描述
Follow-up to #4647 and #4659. Since #4659, when the default serializer fails on an async value before the first flush, the render fails: nothing is sent, onCompleteAll does not fire and createSSRResponse answers 500. When the value fails while it is being handed to the serializer instead (a deferStream value, or getHydrationWriter().write with a settled value), also before the first flush, the hook hears the same render/serialize failure, but the render completes: the shell goes out without that key's hydration record, a piped stream ends normally, onCompleteAll runs and createSSRResponse answers 200.
For comparison, a deferStream value with no <Errored> around it that rejects before the first flush already fails the render (handling: "failed", nothing sent). Resolving to a value that cannot be serialized is what lets it complete.
Versions
solid-js, @solidjs/web and @solidjs/signals 2.0.0-rc.21 from npm (@solidjs/web pins seroval and seroval-plugins to 1.7.0), Node 26.13.0. I did not build next, but the condition quoted below is unchanged in its source at cf839ea.
Reproduction
Save as repro.mjs in a project with solid-js and @solidjs/web 2.0.0-rc.21, then run it with sync or async, and await or pipe:
import { renderToStream, ssr, escape } from "@solidjs/web";
import { createMemo } from "solid-js";
// "sync": deferStream holds the shell, then the resolved value is written and
// fails inside the write. "async": the pending promise is written and fails
// when it settles. Both failures happen before anything is sent.
const mode = process.argv[2] ?? "sync";
const consume = process.argv[3] ?? "await";
class Unserializable {}
function App() {
const ok = createMemo(() => Promise.resolve("ok"), { deferStream: true });
const value = createMemo(() => Promise.resolve(new Unserializable()), {
deferStream: mode === "sync"
});
return ssr(["<main>", "</main>"], () => escape(`${ok()} ${value() instanceof Unserializable}`));
}
let completedAll = false;
const errors = [];
const stream = renderToStream(App, {
onCompleteAll: () => (completedAll = true),
onError: (error, info) => errors.push(`${error?.constructor?.name} (${info?.handling})`)
});
const summary = out => ({
main: /<main>.*?<\/main>/.exec(out)?.[0] ?? null,
keys: [...out.matchAll(/_\$HY\.r\["([^"]+)"\]/g)].map(m => m[1])
});
if (consume === "pipe") {
let sent = "";
let ended = false;
stream.pipe({ write: chunk => (sent += chunk), end: () => (ended = true) });
await new Promise(resolve => setTimeout(resolve, 200));
console.log(mode, consume, JSON.stringify({ ...summary(sent), ended, completedAll, errors }));
} else {
const html = await stream;
console.log(mode, consume, JSON.stringify({ ...summary(html), completedAll, errors }));
}
Observed
$ node repro.mjs sync await
sync await {"main":"<main>ok true</main>","keys":["0"],"completedAll":true,"errors":["SerovalUnsupportedTypeError (serialize)"]}
$ node repro.mjs sync pipe
sync pipe {"main":"<main>ok true</main>","keys":["0"],"ended":true,"completedAll":true,"errors":["SerovalUnsupportedTypeError (serialize)"]}
$ node repro.mjs async await
async await {"main":null,"keys":[],"completedAll":false,"errors":["SerovalUnsupportedTypeError (serialize)"]}
$ node repro.mjs async pipe
async pipe {"main":null,"keys":[],"ended":true,"completedAll":false,"errors":["SerovalUnsupportedTypeError (serialize)"]}
In sync, key 0 (the ok memo) is in the document and key 1 (the failing memo) is not; with a serializable value in its place, both keys are written. node --conditions=development prints the same lines.
With the same App, this shows the status a server would send:
import { createRequestEvent, createSSRResponse } from "@solidjs/web";
import { provideRequestEvent } from "@solidjs/web/storage";
const event = createRequestEvent(new Request("https://example.com/"));
const response = await provideRequestEvent(event, () =>
createSSRResponse(renderToStream(App, { onError() {} }), event)
);
const body = await response.text();
console.log(mode, response.status, JSON.stringify(/<main>.*?<\/main>/.exec(body)?.[0] ?? body));
sync 200 "<main>ok true</main>"
async 500 ""
Expected
Before the first flush, sync behaves like async: the render fails, nothing is sent, onCompleteAll is not called and createSSRResponse answers 500. (With pipe, onCompleteShell still runs before the render is abandoned, as it already does in the async case.) This report does not ask for any change after the first flush.
Cause
renderToStream marks the render as stranded only when the failure arrives outside a write (packages/web/src/server.ts L2572-L2575 on next):
onError: err => {
reportFailure(err, "serialize");
if (!writing && !options.serializer && !firstFlushed) stranded = true;
},
seroval reports a value it cannot parse synchronously, from inside serializer.write. handOver counts that call in writing, so for a deferStream value (written once its promise resolves) or a getHydrationWriter().write the condition is false and the render goes on. This is deliberate: the comment above writing (L2365-L2373) says "Keep synchronous failures completing as before (#4227)", #4659 lists it among the unchanged cases ("A failure during hand-over still completes (#4227), because that value never reaches the client"), and the serialize entry in 12-ssr-http.md says "A value that fails as it is handed over never reaches the client, and the render goes on without it". The published rc.21 has the same condition in dist/server.js, dist/server.dev.js and dist/server.observe.js.
Why the carve-out should not cover the window before the first flush
- It is older than the first-flush split. Completing is what #4229 made every serialization failure do, back when that was the only outcome. My draft #4650 left synchronous failures as they were, and #4659 kept that when it moved the line to the first flush, following the ruling quoted in its description ("failing ssr on failed serialization feels reasonable otherwise we could be midstream"). The comment's own premise, "Before the first flush nothing has reached the client, so the render fails", holds for a failure inside
writetoo: untilpipe/pipeTosetsfirstFlushed, the shell and the hydration tasks are still buffered, and an awaited render never sets it. - "That value never reaches the client" does not leave a whole document. The shell was rendered from the value (
ok true) but has no record for its key. When hydrating, the memo's read takes thecomputebranch (readSerializedOrComputeinsolid-jscallscomputewhen!sharedConfig.has(id)), so the computation runs again on the client and its result can differ from what the server rendered. For the async case, #4659 settled on "Nothing is refetched on hydration", and adeferStreamvalue exists to be in the document before the shell. - The same API gets two outcomes.
stream-batch-serialize-failure-4242.spec.tsxpins that agetHydrationWriter().writeof a promise that resolves to an unserializable value, before the shell, fails the render (no HTML, and the siblinglib:fineis lost).stream-sync-serialize-failure-4227.spec.tsxpins that the same write with a settled value completes and keeps its siblings. - The hook cannot tell them apart. Both failures arrive as
{ kind: "render", handling: "serialize" }; one ends in a failed render, the other in a 200 shell andonCompleteAll. To keep that shell from going out, a server would have to wrap the sink and check a flag set by the hook before the first write. - The comment also notes that a buffered stream can emit its initial chunk and fail inside
write. Before the first flush that chunk is still in the task buffer; after it, the condition is already off.
Possible change
Drop !writing from that condition:
if (!options.serializer && !firstFlushed) stranded = true;
We carry exactly this one-line change on rc.21 as a local patch (dist/server.js and dist/server.dev.js). With it, the same scripts print:
sync await {"main":null,"keys":[],"completedAll":false,"errors":["SerovalUnsupportedTypeError (serialize)"]}
sync pipe {"main":null,"keys":[],"ended":true,"completedAll":false,"errors":["SerovalUnsupportedTypeError (serialize)"]}
sync 500 ""
and the async lines do not change.
What it covers: like the async case today, the failure fails the whole render before the first flush, even when the value is read inside an <Errored> (an async value that fails there already does this). It applies to every synchronous hand-over, so a library that writes a settled value it cannot serialize through getHydrationWriter().write would fail the page instead of dropping one key. It changes stream-sync-serialize-failure-4227.spec.tsx, which pins completion for a deferStream value and for getHydrationWriter().write, and the serialize entry in 12-ssr-http.md. The other specs on next that check render/serialize (stream-ambient-error-hook-4193, stream-batch-serialize-failure-4242, stream-async-serialize-failure-4647 and concurrent-render-error-policy) use async values, which already fail outside write. If completing on a settled library write is wanted, the carve-out could stay for getHydrationWriter() only, though an async write through the same API already fails the render before the first flush.
After the first flush, a value that fails inside the write still never reaches the client, so the client computes that key again, while an async value that fails there is rejected on the client (#4659). That gap is outside this report.
Related
- #4227 and #4229: a synchronous failure used to hang the render; fixed by completing it.
- #4647, #4650 and #4659: an async failure fails the render before the first flush and rejects the key on the client after it.
- #4193 and #4206: serializer failures reach the error hook.
- #4242 and #4246: one failing value no longer strands the pre-shell batch.
- 主要语言
- TypeScript
- 星标
- 36.2k
- 派生
- 1.1k
- 平均合并
- 9 小时
- 30 天内合并 PR
- 463
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
solidjs/solid 的其他 Issue
-
`<style>` and `<script>` children inside MathML are not escaped in SSR (and static DOM templates), allowing markup injection可能已有人在做 @santhiprakash 今天认领。 未关闭
难度 3/5 1-2 天 新手友好度 68/100
维护者通常 1 天内回复
-
难度 3/5 1-2 天 新手友好度 72/100
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 55/100
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 55/100
维护者通常 1 天内回复
-
1.x
难度 4/5 3-5 天 新手友好度 45/100
维护者通常 1 天内回复
相似的 Issue
-
bug needs-triage potential-regression
难度 2/5 1-3 小时 新手友好度 78/100
aws/aws-sdk-js-v3#8408 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 65/100
colinhacks/zod#6743 ·
维护者通常 1 天内回复
-
[BUG] Packing list: the weight field drops digits on a slow connection and saves the wrong weight未关闭
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 79/100
openai/openai-node#3124 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 73/100
anthropics/anthropic-sdk-typescript#1296 ·
维护者通常 8 天内回复