[2.0 rc.13] An async read that rejects under `<Errored>` still triggers `unhandledrejection` in the browser
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
- 58/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, node.js, playwright, typescript
- Lĩnh vực
- backend, frontend, testing-qa
Hướng nghiên cứu
Start by running the self-contained repro.mjs with Playwright and compare the async-reject and streamed cases. Read packages/solid/src/server/signals.ts, packages/web/src/server.ts, client/hydration.ts, and the seroval PROMISE_FAILURE path, then inspect the hydration-records harness. Done means the Errored fallback still renders without browser unhandledrejection or pageerror reports, with regression coverage for pre-shell and streamed rejection.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Describe the bug
An async source that is serialized for hydration rejects, and an <Errored> above it renders its fallback. The HTML is correct, but the inline payload script still creates the source's promise and rejects it with no handler attached. The browser reports Uncaught (in promise) Error: Internal Server Error as an unhandledrejection. The page needs no client JS for this: the event fires from the inline script alone. Error monitoring that listens for it reports an error, and Playwright pageerror checks fail, for an error the boundary already handled.
It happens both when the rejection lands before the shell and when it lands after it under a <Loading>. A synchronous throw caught by the same <Errored> doesn't do this.
Your Example Website or App
Self-contained script below. No JSX, no router, no client bundle; it runs with plain node against the published packages.
Steps to Reproduce the Bug or Issue
// repro.mjs: plain Node ESM, no JSX, no router, no client bundle.
// npm i [email protected] @solidjs/[email protected] (optional: playwright)
// node repro.mjs -> writes out-<case>.html; with playwright, opens each in Chromium
import { writeFileSync } from "node:fs";
import { HydrationScript, createComponent, escape, renderToStream, ssr, ssrHydrationKey } from "@solidjs/web";
import { Errored, Loading, createMemo } from "solid-js";
const read = (ms, fail) => () => {
const value = createMemo(async () => {
await new Promise(r => setTimeout(r, ms));
if (fail) throw new Error("boom");
return "ok";
});
return ssr(["<p", ">", "</p>"], ssrHydrationKey(), escape(value()));
};
const syncThrow = () => {
throw new Error("boom");
};
const errored = child => () =>
createComponent(Errored, { fallback: () => ssr(["<p>fallback</p>"]), get children() { return child(); } });
const loading = child => () =>
createComponent(Loading, { fallback: ssr(["<p>loading</p>"]), get children() { return child(); } });
const page = tree => () =>
ssr(
["<!doctype html><html><head>", "</head><body><main", ">", "</main></body></html>"],
escape(createComponent(HydrationScript, {})),
ssrHydrationKey(),
escape(tree())
);
// Stand-in for a client that reads key "100" late: a module script runs after
// the inline payload, like a client entry would. This is not Solid's hydration.
const CONSUME = `<script type="module">_$HY.r["100"].then(undefined, () => {});</script>`;
const cases = {
"async-reject": { tree: errored(read(0, true)) },
"async-resolve": { tree: errored(read(0, false)) },
"sync-throw": { tree: errored(syncThrow) },
"async-reject-consumed": { tree: errored(read(0, true)), extra: CONSUME },
// shell flushes with the Loading fallback, the read rejects 100 ms later
"streamed": { tree: loading(errored(read(100, true))), pipe: true }
};
const files = [];
for (const [name, { tree, extra, pipe }] of Object.entries(cases)) {
let html;
if (pipe) {
const chunks = [];
const t0 = Date.now();
await new Promise(end =>
renderToStream(page(tree)).pipe({ write: c => chunks.push([Date.now() - t0, String(c)]), end })
);
console.log(`${name}: ${chunks.length} chunks at ${chunks.map(c => c[0]).join(", ")} ms`);
html = chunks.map(c => c[1]).join("");
} else html = String(await renderToStream(page(tree)));
if (extra) html = html.replace("</body>", `${extra}</body>`);
const file = new URL(`out-${name}.html`, import.meta.url);
writeFileSync(file, html);
files.push([name, file]);
}
const { chromium } = await import("playwright").catch(() => ({}));
if (!chromium) {
console.log("playwright not installed: open the out-*.html files and check the console");
process.exit(0);
}
const browser = await chromium.launch();
console.log(`Chromium ${browser.version()}`);
for (const [name, file] of files) {
const tab = await browser.newPage();
await tab.addInitScript(() => {
window.__events = [];
addEventListener("unhandledrejection", e => __events.push(`unhandledrejection: ${e.reason?.message}`));
addEventListener("rejectionhandled", () => __events.push("rejectionhandled"));
});
const pageErrors = [];
tab.on("pageerror", e => pageErrors.push(e.message));
const cdp = await tab.context().newCDPSession(tab);
const devtools = [];
cdp.on("Runtime.exceptionThrown", ({ exceptionDetails: d }) =>
devtools.push(`${d.text} ${d.exception?.description?.split("\n")[0]}`)
);
cdp.on("Runtime.exceptionRevoked", ({ reason }) => devtools.push(`revoked: ${reason}`));
await cdp.send("Runtime.enable");
await tab.goto(file.href);
await tab.waitForTimeout(1000);
console.log(`\n[${name}] <main>: ${JSON.stringify(await tab.locator("main").innerText())}`);
console.log(` pageerror: ${JSON.stringify(pageErrors)}`);
console.log(` window events: ${JSON.stringify(await tab.evaluate(() => __events))}`);
console.log(` devtools console: ${JSON.stringify(devtools)}`);
await tab.close();
}
await browser.close();
npm i [email protected] @solidjs/[email protected] playwright(Playwright is optional; it needsnpx playwright install chromiumonce).node repro.mjs- Without Playwright, open
out-async-reject.htmlfrom disk in a browser. The console showsUncaught (in promise) Error: Internal Server Error.
Results on Chromium 153, the same on rc.13 and on next at e44b2e4:
| Case | Tree | <main> |
pageerror |
Window events |
|---|---|---|---|---|
async-reject |
Errored > read, read rejects, await renderToStream |
fallback | 1 | unhandledrejection |
async-resolve |
same, read resolves | ok | 0 | none |
sync-throw |
Errored > child, child throws synchronously |
fallback | 0 | none |
async-reject-consumed |
async-reject plus the CONSUME module script |
fallback | 1 | unhandledrejection, then rejectionhandled |
streamed |
Loading > Errored > read, read rejects 100 ms after the shell, pipe() |
fallback | 1 | unhandledrejection |
streamed writes 2 chunks (about 1 ms and 100 ms). Served over a real http response, the result is the same.
Payload of async-reject, byte-identical on rc.13 and next:
<script>(self.$R=self.$R||{})[""]=[];_$HY.r["100"]=$R[0]=($R[1]=($R[2]=() => {
const resolver = {
p: 0,
s: 0,
f: 0
};
resolver.p = new Promise((resolve, reject) => {
resolver.s = resolve;
resolver.f = reject;
});
return resolver;
})()).p;($R[4]=(resolver, data) => {
resolver.f(data);
resolver.p.s = 2;
resolver.p.v = data;
})($R[1],$R[3]=new Error("Internal Server Error"));_$HY.r["1"]=$R[3];</script>
Key 100 is the memo's promise, rejected on the spot. Key 1 is the <Errored> record. In streamed, the shell declares _$HY.r["100000"] as a pending promise next to 1_fr. The second chunk rejects the 100000 promise, writes the boundary record at _$HY.r["1000"], and resolves 1_fr with true.
Expected behavior
When <Errored> contains the rejection, the page shows the fallback and the browser reports nothing, as in sync-throw. Instead there is one unhandledrejection for the memo's serialized promise.
Analysis
Links are to next at e44b2e4.
- The server memo hands its deferred promise to the serializer when the compute returns it (signals.ts#L1673-L1677). When the read rejects,
<Errored>renders its fallback and serializes the error at its own id (signals.ts#L3565-L3570). The promise already handed over stays in the payload.<Loading>buffers its subtree's serializations (ssrLoadingBoundary, server/hydration.ts#L265-L290); I didn't find anything similar in the servercreateErrorBoundary. - seroval writes the rejection as
PROMISE_FAILURE, which calls the payload's ownreject(constructors.ts#L34-L41 on seroval main; the same code is in the 1.6.8 dist that rc.13 resolves to today). No handler is attached at that point. - The client attaches a handler only when it consumes the key:
readHydratedValue(client/hydration.ts#L486-L491; the same code is in the rc.13 dist atsolid-js/dist/solid.js:170-171).takeHydrationValue, which the router reads through, does the same. - A hydrating
<Errored>that finds its serialized error throws it on the first run without calling its children (client/hydration.ts#L1500-L1522). So in the first hydration pass the child that would read key100(or100000) doesn't run. The sweeps after hydration skip it too:watchTruncationonly looks at_frkeys (#L2784) andrejectTruncatedRefsskips entries that already settled (#L2843). I'm reading this from the code; the repro has no hydrating client.
async-reject-consumed shows the timing. In this Chromium run, a handler attached from a module script, after the inline payload, only revokes the report: the page gets rejectionhandled and DevTools logs "Handler added to rejected promise", but the pageerror and unhandledrejection listeners have already fired.
next already covers a neighboring case. The comment above abandonSubtree (server.ts#L2543-L2550) says abandoned data ids resolve undefined because nothing consumes them and "a rejection would only raise unhandled-rejection noise". That runs only when a fragment settles with an error (server.ts#L2835). Here the <Errored> contains the error, so no fragment fails: async-reject has no fragment, and in streamed 1_fr resolves clean. The Node side of the same promise is already handled: since #3642, trackSerialized observes it so a pre-shell rejection can't exit the process (server.ts#L2534). The test harness also catches every _$HY.r record, because "without a consumer that leaks an unhandledRejection into the test process" (test/harness/hydration-records.ts#L27-L32), so the suite doesn't see this.
Where to fix it is your call. The options I can see:
- Server: when an
<Errored>switches to its fallback, keep the values serialized under it from reaching the payload as rejections (write them asundefinedlikeabandonSubtreedoes, or drop them if they haven't been flushed).abandonSubtreeitself wouldn't cover it as is: it only settles entries still inpendingSerialized, and a source that already rejected has left it (server.ts#L2539-L2540). - Payload: attach a no-op rejection handler to the promise the payload creates, in seroval's
PROMISE_FAILUREor in Solid's serialization layer, the wayrejectTruncatedRefsdoes for its own rejections (client/hydration.ts#L2863).
A client-side sweep after hydration would be too late: per async-reject-consumed, a handler added after the inline script only revokes the report.
Screenshots or Videos
N/A.
Platform
- OS: macOS 27.0
- Browser: Chromium 153.0.8010.12 through Playwright 1.63.0. Firefox and WebKit not tested.
- Node.js: v24.21.0
- Version:
solid-jsand@solidjs/web2.0.0-rc.13 from npm (seroval 1.6.8 through~1.6.7), andnextat e44b2e4 built locally (seroval 1.6.7). - Production conditions. With
node --conditions=developmentthe same cases reportboominstead ofInternal Server Error.
Additional context
- #2997 is the closest precedent. Its fix (62e78832) "plugged the unhandled-rejection leaks in this flow", and
readHydratedValuecites #2997 for observing a rejection when the client consumes it. This case has no consumer. - #3414 (closed) had a different symptom (a dead counter in the fallback, fixed in 5426ffb4 as an id mismatch), but its results table already listed a
pageerrorfor both async rejection cases. - If the fix keys off the
<Errored>id:query()in@solidjs/routerwrites under the query cache key (src/data/query.ts#L72-L77), not under the boundary's id, so a rejected query under that boundary might need a change in the router too.
- Ngôn ngữ chính
- TypeScript
- Star
- 36.1k
- Fork
- 1.1k
- Merge trung bình
- 10 giờ 7 phút
- Pull request đã merge (30 ngày)
- 289
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ó 4/5 3-5 ngày Mức phù hợp với người mới 48/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 55/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 48/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 58/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 48/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ự
-
area:docs bug triage:confirmed
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
Cotal-AI/Cotal#2875 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
GLM-5.3 available on bedrock nowĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
anomalyco/models.dev#8862 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
fix(data-lake): wizard source step still previews the local slug, not the server-disambiguated oneCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mởdata-lake
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
Maintainer thường phản hồi trong vòng 1 ngày
-
ready-for-triage
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
konflux-ci/konflux-ui#1596 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
enhancement good first issue priority: low size: XS
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
Maintainer thường phản hồi trong vòng 1 ngày