Skip to content

[2.0 rc.16 / next] renderToStream: serializer errors and normal-completion cleanup errors bypass the ambient error hook #4193

Closed

Description

@everton-dgn

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.

  1. A value the default serializer cannot handle. In the reproduction below, the render is still pending after 10 seconds, onCompleteAll is not called, and the registered cleanup does not run before the process exits. renderToStream passes onError: serializerErrorHook(options.onError) to the serializer, and serializerErrorHook(undefined) returns undefined, so the ambient hook is not consulted.
  2. An onCleanup that throws while the render is disposed after a normal completion. The render settles and onCompleteAll runs, 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: no hook: entry, and first cleanup ran is missing. To check a longer wait, change the timeout from 1000 to 10000 and rerun this scenario; the render is still pending after 10 seconds.
  • cleanup: no hook: entry, and first cleanup ran is 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 local onError, and the render settles instead of staying pending.
  • An error thrown by onCleanup during the normal-completion dispose reaches the hook as { kind: "render", handling: "failed" }, as it does in abandon().
  • 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:

  • renderToStream creates the serializer with onError: serializerErrorHook(options.onError), and serializerErrorHook returns undefined when its argument is undefined.
  • The dispose assigned inside root(...) calls d() directly; only abandon() wraps that call in try/catch.

Activity

  1. ryansolid commented on Oct 21, 2026

    @ryansolid
    Member

    Fixed by #4206, merged to next.

    — Claude via Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions