Repository navigation
Closed
Description
Two errors inside renderToStream do not reach the hook installed with configureServerErrors({ onError }) when the call has no local onError. Both end as unhandled rejections.
- A value the default serializer cannot handle. In the reproduction below, the render is still pending after 10 seconds,
onCompleteAllis not called, and the registered cleanup does not run before the process exits.renderToStreampassesonError: serializerErrorHook(options.onError)to the serializer, andserializerErrorHook(undefined)returnsundefined, so the ambient hook is not consulted. - An
onCleanupthat throws while the render is disposed after a normal completion. The render settles andonCompleteAllruns, but the ambient hook does not receive the cleanup error, and the cleanup registered before the throwing one does not run.abandon()already catches errors thrown by its dispose and reports them as{ kind: "render", handling: "failed" }(its source comment points to renderToStream: an onCleanup that throws during signal abort crashes the server process #4029). The normal-completion path calls the dispose without that guard.
Reproduction
Verified with solid-js and @solidjs/web 2.0.0-rc.16 from npm, on Node 24.22.0 and Bun 1.4.3. Save this as repro.mjs next to those packages and run node repro.mjs baseline, node repro.mjs unserializable and node repro.mjs cleanup:
import { configureServerErrors, createRequestEvent, renderToStream, ssr } from '@solidjs/web';
import { provideRequestEvent } from '@solidjs/web/storage';
import { createComponent, createMemo, Loading, onCleanup } from 'solid-js';
const scenario = process.argv[2]; // "baseline" | "unserializable" | "cleanup"
const log = [];
process.on('unhandledRejection', error => log.push(`unhandledRejection: ${error.message}`));
configureServerErrors({
onError(error, { kind, handling }) {
log.push(`hook: ${kind}/${handling}`);
}
});
function Page() {
onCleanup(() => log.push('first cleanup ran'));
if (scenario === 'cleanup') {
onCleanup(() => {
throw new Error('cleanup threw');
});
}
const data = createMemo(() =>
Promise.resolve(scenario === 'unserializable' ? () => 'fn' : 'resolved')
);
return ssr(['<main>', '</main>'], createComponent(Loading, {
fallback: 'pending',
get children() {
return ssr(['<p>', '</p>'], createMemo(() => String(data())));
}
}));
}
const event = createRequestEvent(new Request('https://example.test/'));
let didCompleteAll = false;
const result = await Promise.race([
provideRequestEvent(event, () =>
renderToStream(Page, { onCompleteAll() { didCompleteAll = true; } })
).then(() => 'settled', error => `rejected: ${error.message}`),
new Promise(resolve => setTimeout(() => resolve('pending after 1s'), 1000))
]);
await new Promise(resolve => setTimeout(resolve, 50));
console.log(JSON.stringify({ scenario, result, didCompleteAll, log }));
process.exit(0);Observed
Same output on Node and Bun:
{"scenario":"baseline","result":"settled","didCompleteAll":true,"log":["first cleanup ran"]}
{"scenario":"unserializable","result":"pending after 1s","didCompleteAll":false,"log":["unhandledRejection: Seroval Error (step: 1)"]}
{"scenario":"cleanup","result":"settled","didCompleteAll":true,"log":["unhandledRejection: cleanup threw"]}
unserializable: nohook:entry, andfirst cleanup ranis missing. To check a longer wait, change the timeout from1000to10000and rerun this scenario; the render is still pending after 10 seconds.cleanup: nohook:entry, andfirst cleanup ranis missing. Cleanups run in reverse order of registration, so the throwing one runs first and the one registered before it does not run.
A local onError changes case 1. Add this to the renderToStream options and rerun unserializable:
onError(error, { kind, handling }) {
log.push(`local: ${kind}/${handling}`);
}On Node and Bun this prints:
{"scenario":"unserializable","result":"settled","didCompleteAll":true,"log":["local: render/serialize","first cleanup ran"]}
The local callback gets the error and the render completes, but the ambient hook is still not called.
Expected
- A serializer error reaches the ambient hook as
{ kind: "render", handling: "serialize" }even without a localonError, and the render settles instead of staying pending. - An error thrown by
onCleanupduring the normal-completion dispose reaches the hook as{ kind: "render", handling: "failed" }, as it does inabandon(). - The remaining cleanups still run. This part is not covered by the guard in
abandon()either: it catches the error after the owner's cleanup loop has already stopped.
Where
packages/web/src/server.ts on next (9f64ca9) still has both paths:
renderToStreamcreates the serializer withonError: serializerErrorHook(options.onError), andserializerErrorHookreturnsundefinedwhen its argument isundefined.- The
disposeassigned insideroot(...)callsd()directly; onlyabandon()wraps that call intry/catch.
Metadata
Metadata
Assignees
Labels
No labels
Activity
fix(web): route renderToStream serializer and cleanup errors to the a…
ryansolid commentedon Oct 21, 2026
Generated by Claude Code
fix: a throwing cleanup no longer aborts the rest of the teardown (#4213
test(web): share the settle harness between the renderToStream failur…
fix(web): renderToStream completes when a value fails to serialize sy…