[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

未关闭
#4,709 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
54/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
领域
backend, testing

调研方向

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 write too: until pipe/pipeTo sets firstFlushed, 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 the compute branch (readSerializedOrCompute in solid-js calls compute when !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 a deferStream value exists to be in the document before the shell.
  • The same API gets two outcomes. stream-batch-serialize-failure-4242.spec.tsx pins that a getHydrationWriter().write of a promise that resolves to an unserializable value, before the shell, fails the render (no HTML, and the sibling lib:fine is lost). stream-sync-serialize-failure-4227.spec.tsx pins 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 and onCompleteAll. 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

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

solidjs/solid 的其他 Issue

查看 solidjs/solid 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。