Skip to content

Solid 2 RC.4: should static and reactive <select value> behave differently? #3167

Closed

Description

@yumemi-thomas

I was testing the reproduction from solidjs/solid#1754 in the Solid 2 Playground.

import { createSignal } from "solid-js";
import { render } from "@solidjs/web";

function App() {
  const [selected] = createSignal("2");

  return (
    <div>
      <select value="2">
        <option value="" />
        <option value="1">1</option>
        <option value="2">2</option>
        <option value="3">3</option>
        <option value="4">4</option>
      </select>

      <select value={selected()}>
        {(() => (
          <>
            <option value="" />
            <option value="1">1</option>
            <option value="2">2</option>
            <option value="3">3</option>
            <option value="4">4</option>
          </>
        )) as unknown as Element}
      </select>

      <select {...{}} value={selected()}>
        {(() => (
          <>
            <option value="" />
            <option value="1">1</option>
            <option value="2">2</option>
            <option value="3">3</option>
            <option value="4">4</option>
          </>
        )) as unknown as Element}
      </select>
    </div>
  );
}

if (typeof document !== "undefined") {
  render(() => <App />, document.getElementById("root")!);
}

On Solid 2 RC.4, I get:

  • value="2" → empty option
  • value={selected()} → 2
  • {...{}} value={selected()} → 2

The static form appears in the generated DOM as:

<select value="2">

Browsers do not use a value attribute on <select> to determine its current selection. The reactive forms appear to assign the live select.value property instead.

For comparison, the equivalent Svelte 5 code displays 2, 2, 2:

<script lang="ts">
  let selected = $state("2");
</script>

<div>
  <select value="2">
    <option value=""></option>
    <option value="1">1</option>
    <option value="2">2</option>
    <option value="3">3</option>
    <option value="4">4</option>
  </select>

  <select value={selected}>
    <option value=""></option>
    <option value="1">1</option>
    <option value="2">2</option>
    <option value="3">3</option>
    <option value="4">4</option>
  </select>

  <select {...{}} value={selected}>
    <option value=""></option>
    <option value="1">1</option>
    <option value="2">2</option>
    <option value="3">3</option>
    <option value="4">4</option>
  </select>
</div>

I understand that the first select may originally have been included in #1754 as a raw-browser-behavior baseline. However, in Solid 2, should these equivalent JSX values produce different results?

<select value="2">
<select value={selected()}>

Is the static behavior intentional, or should the compiler also assign the value property after creating the options?

The spread-ordering problem from #1754 appears resolved in RC.4, so this seems like a separate static-literal compiler question.

Activity

  1. nickshiro commented on Sep 1, 2026

    @nickshiro

    Hi, I'd like to work on this.

    I traced the difference to the static attribute path: a literal select value is inlined into the HTML template, while reactive and spread values go through setAttr() and assign the live value property.

    Plan: route only static select.value through the existing property-assignment path, without changing other locked DOM properties. I'll add compiler and runtime regression coverage for literal, static-expression, and dynamic-children case.

  2. yumemi-thomas commented on Sep 1, 2026

    @yumemi-thomas
    Author

    @nickshiro Thanks for tracing it. That explanation matches what I observed.

    Before implementing the change, I think it would be useful to get
    decision from Ryan on the intended semantics. Should a static JSX value="2" follow raw
    HTML attribute behavior, or should all forms of <select value> represent the
    live DOM property consistently?

    This may also affect the precedence of <option selected>, SSR/hydration
    behavior from #3013, and form reset semantics.

    @ryansolid could you confirm the intended behavior?

  3. nickshiro commented on Sep 1, 2026

    @nickshiro

    @yumemi-thomas I'll definitely start with the implementation, because it's not obvious, but the most important thing about changes like this is that the new tests cover these cases. If our hypothesis turns out to be incorrect, we can easily substitute another implementation for the tests.

  4. nickshiro commented on Sep 1, 2026

    @nickshiro

    I implemented the patch and looked into the edge cases, and this does seem to be a semantic decision rather than only a compiler bug.

    The current patch would compile static forms such as:

    <select value="2">
    <select value={"2"}>
    <select value={2}>

    through the same live DOM property assignment already used for non-static values. That has a few observable consequences:

    • value takes precedence over an explicit .
    • form.reset() restores the option’s default selection rather than the static value.
    • A value with no matching option results in selectedIndex === -1.
    • On a multiple select, assigning .value selects only one matching option.
    • getAttribute("value") becomes null, since the value is no longer emitted into the client template.
    • The existing select setter assigns immediately and again in a microtask, so a synchronous change made after creation can be overwritten.

    SSR already strips and resolves it into an option’s selected state for #3013. However, when an option explicitly has selected, SSR preserves that as the default. With this patch, hydration would then apply the live value property and could change the visible selection. A later form reset would return to the explicitly selected option. So the my patch is consistent if the intended rule is that every JSX represents the live DOM property. If static JSX should preserve raw HTML attribute semantics instead, this would be too broad.

  5. Nerivec commented on Sep 1, 2026

    @Nerivec

    Another similar-ish case I've encountered:

    const myOptions = createMemo(async () => await getMyOptions()); // results in: ["1", "2"]
    
    <Loading fallback={<span>Loading...</span>}>
        <select value={props.val}> {/* "2" */}
            <For each={myOptions()}>
                {(val) => (<option value={val}>{val}</option>)}
            </For>
        </select>
    </Loading>

    Once loaded, that example will have the select picking "1", instead of proper "2" (both visually, and in the element's .value prop).
    Workaround: adding selected={val === props.val} on <option> appears to do the trick...
    Behavior seems a bit "off"? You'd expect the <Loading> being around the select to handle this properly.

  6. ryansolid commented on Sep 1, 2026

    @ryansolid
    Member

    Ruling: unify. On <select>, JSX value means selection state everywhere — the static form should behave exactly like the reactive one.

    For context on why this is compile-output-only: the other cells of the matrix already committed to these semantics. SSR resolves a select's value into selected on the matching option(s) at flush time (#3013, both static and dynamic), and the dynamic client path assigns the property after children exist (#1754). The static client render is the last cell out of step — the compiler inlines a value attribute HTML gives no meaning on <select>, and nothing ever assigns the property.

    So the fix is what @nickshiro traced: stop inlining static value on <select> into the template and emit the same post-children property assignment the dynamic path uses. No server changes, no new runtime machinery.

    Scope notes for the implementation:

    • Both compilers need the change: @solidjs/babel-plugin-jsx and the native @solidjs/compiler (there are compiled-parity tests — keep them green).
    • <textarea value="…"> is the same class of bug (no meaningful value attribute in HTML) and should get the same treatment: property assignment on the client; on the server it's element-local — emit the value as escaped text content, no flush pass needed. Include it here or file it separately, your call.
    • <input value> stays as-is. Its value attribute has real default-value semantics in HTML, so inlining is correct there; this ruling only covers elements where the attribute is dead.
    • Regression coverage per your plan (literal, static expression, dynamic children) sounds right — please also cover multiple with a static array/string, since Dynamic <select multiple={...}> loses earlier statically selected options during client render #3179 is in the same neighborhood.

    Go ahead, and thanks for tracing it.

  7. nickshiro commented on Sep 1, 2026

    @nickshiro

    @ryansolid, Thanks for the feedback; I'm already working working on the PR.

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