Skip to content

createContainer called with pre-0.31 argument list — onCaughtError is null, crashing the process on any caught error #22

Description

@joemckie

Summary

createContainer is called with the pre-0.31 (React 18-era) argument list, but the package depends on react-reconciler@^0.31.0. Two of the three error callbacks land in the wrong slots and onCaughtError ends up null.

The practical effect: any error caught by an <ErrorBoundary> crashes the process with a misleading TypeError: onCaughtError is not a function, instead of being reported. onRecoverableError is also never wired.

Current call

src/reconciler/render.ts (dist/reconciler/render.js:82):

const container = TuiReconciler.createContainer(root, 0, null, false, null, "", onRecoverableError, null);

That's 8 arguments. The 0.31 signature takes 10:

exports.createContainer = function (
  containerInfo, tag, hydrationCallbacks, isStrictMode,
  concurrentUpdatesByDefaultOverride, identifierPrefix,
  onUncaughtError, onCaughtError, onRecoverableError, transitionCallbacks
) {  }

So currently:

slot gets should get
onUncaughtError onRecoverableError uncaught handler
onCaughtError null caught handler
onRecoverableError undefined recoverable handler

The uncaught path works by luck — storm's handler happens to land in the right slot. The caught path is null, so logCaughtError's root.onCaughtError(...) throws.

src/reconciler/render-to-string.ts (dist/reconciler/render-to-string.js:71) has the same 8-arg call, which means renderToString silently fails to collect errors from error boundaries and recoverable errors.

Reproduction

npm install @orchetron/storm@0.2.0 react@19
// repro.tsx — any error inside an ErrorBoundary will do
import { render, Box, Text, ErrorBoundary } from "@orchetron/storm";
import React from "react";

function Boom(): React.ReactNode {
  throw new Error("boom");
}

render(
  <ErrorBoundary fallback={(err) => <Text>caught: {err.message}</Text>}>
    <Box><Boom /></Box>
  </ErrorBoundary>,
);

The fallback paints caught: boom, and then:

Uncaught exception: TypeError: onCaughtError is not a function
    at logCaughtError (…/react-reconciler/cjs/react-reconciler.development.js:5524:9)
    at runWithFiberInDEV (…/react-reconciler/cjs/react-reconciler.development.js:522:16)
    at inst.componentDidCatch.update.callback (…/react-reconciler/cjs/react-reconciler.development.js:5571:11)
    at callCallback (…/react-reconciler/cjs/react-reconciler.development.js:2516:16)
    …
…/@orchetron/storm/dist/core/screen.js:77
            throw err;
            ^

Screen.onUncaughtException rethrows, so the process dies. Because this fires from the error boundary's own commit, it replaces whatever the real error was — the boundary renders its fallback for a frame and then the process exits, so the underlying error is effectively invisible. That's how it presented for me: a genuine bug in my tree (#21) was completely masked by this.

Suggested fix

const onCaughtError = (err) => {
    if (options.onError) options.onError(err);
};
const container = TuiReconciler.createContainer(
    root, 0, null, false, null, "",
    onRecoverableError,  // onUncaughtError
    onCaughtError,       // onCaughtError
    onRecoverableError,  // onRecoverableError
    null,
);

Judgement call worth your input: I deliberately did not route onCaughtError to the default stderr writer. An <ErrorBoundary> that caught an error is already showing its fallback, so writing a raw stack to stderr scribbles over the alt screen. Forwarding only to a user-supplied options.onError seemed right, but you may prefer always logging, or adding a separate option.

For render-to-string.ts the fix is simpler — the same collector in all three slots:

const collect = (error) => { errors.push(error); };
const container = TuiReconciler.createContainer(root, 0, null, false, null, "", collect, collect, collect, null);

Possibly related: peer range

package.json declares:

"peerDependencies": { "react": "^18.0.0 || ^19.0.0" },
"dependencies": { "react-reconciler": "^0.31.0" }

but react-reconciler@0.31.0 itself declares "peerDependencies": { "react": "^19.0.0" }. The ^18.0.0 half of that range can't work regardless of this fix — worth narrowing to ^19.0.0 so React 18 users fail at install rather than at runtime. I haven't actually tried it on React 18, so I may be missing something.

Environment

  • @orchetron/storm 0.2.0 (latest on npm)
  • react 19.2.8, react-reconciler 0.31.0
  • Node 24.15.0, Linux

Activity

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