Skip to content

Solid 2: Nullish reactive JSX spreads crash rendering and halt updates #3297

Closed

Description

@brenelz

Describe the bug

A lone reactive JSX spread crashes when its source is undefined or null. This affects valid optional-props patterns such as <input {...props()} />, where props() returns an object or an absent value.

The runtime throws TypeError: Cannot convert undefined or null to object. Without an enclosing error boundary, it reports REACTIVITY_HALTED and stops processing subsequent reactive updates.

Verified against source at a17dfec, whose package manifests identify the prerelease as 2.0.0-rc.6. This report describes that checkout, not a separate test of the published rc.6 bundles.

Minimal reproduction

Vitest/jsdom TSX test using the normal Solid JSX transform:

/** @jsxImportSource @solidjs/web */
import { expect, test } from "vitest";
import { createSignal, flush } from "solid-js";
import { render } from "@solidjs/web";

test.each([undefined, null])("clearing optional spread to %s", absent => {
  const host = document.createElement("div");
  const [props, setProps] = createSignal<
    { value: string } | undefined | null
  >({ value: "draft" });

  const dispose = render(() => <input {...props()} />, host);
  try {
    expect((host.firstElementChild as HTMLInputElement).value).toBe("draft");

    setProps(absent);
    flush(); // Throws before the assertion below.

    expect((host.firstElementChild as HTMLInputElement).value).toBe("");
  } finally {
    dispose();
  }
});

Starting with an undefined reactive spread also crashes the initial mount. TypeScript accepts the object-or-nullish accessor type above; this is not a definitely-null spread expression such as {...null}.

Expected behavior

Treat the absent source as empty props: retain the input, clear the previously supplied value, and continue processing application updates without throwing.

Actual behavior

Both nullish cases throw from hasOwnProperty in spread():

[REACTIVITY_HALTED] An uncaught error halted the reactive system.
No further updates will be processed.
TypeError: Cannot convert undefined or null to object
    at hasOwnProperty (<anonymous>)
    at packages/web/src/client.ts:724:21

Root cause

spread() at client.ts:720-730 resolves the accessor and calls hasOwn.call(source, "children") and hasOwn.call(source, "ref") without normalizing a nullish source.

Both compilers pass a lone reactive spread directly to this helper. In contrast, adding a static attribute, <input id="field" {...props()} />, selects the merged-props path and the same transitions pass.

Impact and verification

  • Optional field configuration and conditional props can cause a mount failure or an application-wide reactive halt, rather than simply removing attributes.
  • Reproduced with both the native and Babel JSX compilers against source-runtime aliases.
  • Reproduced with development and production flags, and when removing the spread after successful hydration.
  • Platform: macOS, Vitest 4.1.6, jsdom 25.0.1. These are source-runtime test results, not a browser compatibility claim.
  • This differs from 2.0.0-beta.28: input props spread causes value to be `"undefined" when value is undefined #2957: the entire spread source is absent here, rather than an existing props object containing value: undefined.

Reported with OpenCode (GPT-6 Astra).

Activity

  1. ryansolid commented on Sep 7, 2026

    @ryansolid
    Member

    Fixed on next in 6164667 — thanks.

    <input {...props()} /> compiles to spread(el, props) with the accessor passed straight through, so spread() itself has to treat a nullish source as an empty spread; it didn't (hasOwn.call(undefined, …)), and the server's ssrElement had the same hole for a function source resolving to null. Both now normalize: attributes applied by the previous value are removed and reactivity continues. Ships in the next RC.

    — Claude via Cursor

  2. ryansolid commented on Sep 7, 2026

    @ryansolid
    Member

    Fixed on next in 6164667 — thanks for the precise root cause, it was exactly right.

    A lone reactive spread passes its accessor straight into spread() (#3105), so a nullish source reached hasOwn.call and threw inside the render effect. spread() (and ssrElement for a function source resolving to null) now treat a nullish source as an empty spread: attributes applied by the previous value are removed and updates continue. Regression tests cover clearing to undefined/null, mounting absent, and the SSR case.

    — Claude via Cursor

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