Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

[2.0 rc.9] renderToString: an async memo that rejects after the render is an unhandled rejection and exits Node

クローズ
#3,570 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
3/5
見積もり時間
半日
初心者へのやさしさ
76/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
node.js, typescript
領域
backend, web-dev

調査の方向性

Start in packages/solid/src/server/signals.ts, especially createDeferredPromise and the deferred rejection paths around lines 741, 760, and 1299-1324. Run the supplied repro.mjs commands from packages/web against a built checkout, then verify that all renderToString variants remain alive after 50 ms and exit with code 0 without changing the existing stream behavior.

索引モデルが issue の本文から書いたものです。

説明

Describe the bug

renderToString is synchronous, so an async memo created during the render is still in flight when the HTML is returned. If that promise later rejects, the rejection has no handler, and with Node's default --unhandled-rejections=throw the server process exits with code 1.

It happens when the memo is read under <Loading>, when it is read under <Errored> around <Loading>, and when it is never read at all, so there is no boundary the app can add to prevent it. The same tree rendered with renderToStream is fine.

const App = () => {
  const data = createMemo(async () => {
    await new Promise(r => setTimeout(r, 5));
    throw new Error("fetch failed");
  });
  return (
    <Errored fallback="error">
      <Loading fallback="loading">{data()}</Loading>
    </Errored>
  );
};

const html = renderToString(() => <App />); // returns the fallback HTML
// about 5 ms later: "Error: fetch failed" is an unhandled rejection, exit code 1

A data fetch that fails (network error, a 500 from an upstream API) is an ordinary event. Here it takes the whole server down a few milliseconds after the response was already produced, and every other request in flight goes with it.

Your Example Website or App

Self-contained script below. It has no JSX, so it runs with plain node against the built packages.

Steps to Reproduce the Bug or Issue
// repro.mjs — run from packages/web of a built checkout
import { createComponent, createMemo, Errored, Loading } from "solid-js";
import { renderToString, renderToStream } from "@solidjs/web";

const [mode, variant] = process.argv.slice(2);
const App = () => {
  const data = createMemo(async () => {
    await new Promise(r => setTimeout(r, 5));
    throw new Error("fetch failed");
  });
  const loading = () => createComponent(Loading, { fallback: "loading", get children() { return data(); } });
  if (variant === "unread") return "static";
  if (variant === "loading") return loading();
  return createComponent(Errored, { fallback: "error", get children() { return loading(); } });
};

const html = mode === "string" ? renderToString(App) : await renderToStream(App, { onError() {} });
console.log("html:", html.replace(/<script[\s\S]*?<\/script>/g, "").slice(0, 60));
setTimeout(() => console.log("still alive after 50 ms"), 50);
Command Output Exit code
node repro.mjs string unread html: static, then Error: fetch failed 1
node repro.mjs string loading html: loading, then Error: fetch failed 1
node repro.mjs string errored+loading html: loading, then Error: fetch failed 1
node repro.mjs stream unread html: static, still alive after 50 ms 0
node repro.mjs stream errored+loading still alive after 50 ms 0

The results are the same with node --conditions=development. (stream loading, with no <Errored>, fails for a different reason: #3569.)

Expected behavior

renderToString returns the fallback HTML and the process keeps running. The late rejection is either dropped (the client fetches again after hydration) or routed to the server error hook, but it is not left unhandled.

Platform
  • solid-js / @solidjs/web 2.0.0-rc.9 (next @ 37fd1e67), production and development server builds
  • Node 24.21, macOS arm64
Additional context

In packages/solid/src/server/signals.ts a memo's deferred.promise is only observed when the render serializes it: serializes = !!(ctx?.async && ctx.serialize && id && !noHydrate), then if (serializes) ctx.serialize(id, deferred.promise, deferStream) (lines 1320-1324). Under renderToString, ctx.async is false, so nothing ever attaches a handler to that promise. When the user's promise rejects, settleServerAsync calls deferred.reject(error) (line 760, and line 741 for a synchronous throw on a rerun), and that rejection is unhandled.

The same file already covers the equivalent case for an abandoned duplicate flight with (result as any).then(undefined, () => {}) (around line 1299), with a comment about --unhandled-rejections=strict.

As a check, I ran the script against a copy of packages/solid/dist/server.js in which createDeferredPromise adds promise.then(undefined, () => {}) right after it creates the promise. All three string rows then print still alive after 50 ms and exit with 0, and the stream errored+loading row is unchanged. Marking the rejection as observed there does not stop the real consumers (ctx.serialize, the pending-retry subscriber) from seeing it.

主要言語
TypeScript
スター
36.1k
フォーク
1.1k
平均マージ
11時間 49分
マージ済み PR(30日)
346

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

solidjs/solid のほかの issue

solidjs/solid の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。