From 5cac49cd0ffe39c8adcc526aed040aad95ae9f4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:10:29 +0000 Subject: [PATCH] fix(compiler): nullish checkbox/radio value removes the attribute in non-hydratable builds (#4017) Both compilers inlined the #2957 text-field fallback (`el.value = v ?? ""`) for value/defaultValue on every input, so a client-only build never reached the #4009 runtime path and a checked checkbox with a nullish value submitted "" instead of "on". When an 's static `type` is checkbox or radio (case-insensitive), or its type cannot be resolved at compile time (expression, prop:type, attr:type), a non-literal value/defaultValue now compiles to the existing `setProperty` runtime helper, which already tests the live type. Inputs with no type or another static type, and textareas, keep the inline `?? ""`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NcUAu8BQ6Xsf8XRBhr9V1w --- .../compiler-nullish-checkbox-radio-value.md | 6 + packages/babel-plugin/src/dom/element.ts | 56 +- packages/babel-plugin/src/dom/template.ts | 11 +- packages/babel-plugin/src/types.ts | 1 + .../attributeExpressions/code.js | 18 + .../attributeExpressions/output.js | 82 +- .../__tests__/dom-nullish-input-value.test.js | 214 ++++ .../dom/attributeExpressions/output.js | 64 + .../dom_fixtures--attributeExpressions.diff | 1093 +++++++++-------- .../dom_fixtures--attributeExpressions.diff | 4 +- .../dom_fixtures--attributeExpressions.diff | 4 +- packages/compiler/src/dom/attrs.rs | 54 +- packages/compiler/src/dom/dynamics.rs | 4 + packages/compiler/src/dom/set_attr.rs | 18 +- 14 files changed, 1104 insertions(+), 525 deletions(-) create mode 100644 .changeset/compiler-nullish-checkbox-radio-value.md create mode 100644 packages/compiler/__tests__/dom-nullish-input-value.test.js diff --git a/.changeset/compiler-nullish-checkbox-radio-value.md b/.changeset/compiler-nullish-checkbox-radio-value.md new file mode 100644 index 000000000..8e6bd229d --- /dev/null +++ b/.changeset/compiler-nullish-checkbox-radio-value.md @@ -0,0 +1,6 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +--- + +Non-hydratable client builds now remove the `value` attribute when a checkbox or radio input binds a nullish `value` or `defaultValue`, matching the runtime fix from #4009, so a checked box submits `"on"` instead of `""`. Both compilers route these bindings through `setProperty` when the input's static `type` is `checkbox` or `radio`, or when its `type` cannot be known at compile time (an expression, `prop:type`, `attr:type`). Text inputs, inputs without a `type`, and textareas still clear to `""` inline (#2957). diff --git a/packages/babel-plugin/src/dom/element.ts b/packages/babel-plugin/src/dom/element.ts index 483f303b9..7611fbd4b 100644 --- a/packages/babel-plugin/src/dom/element.ts +++ b/packages/babel-plugin/src/dom/element.ts @@ -57,6 +57,7 @@ type DOMSetAttrOptions = { tagName?: string; styleProperty?: boolean; classProperty?: boolean; + checkableInput?: boolean; }; type SpreadOptions = { elem: babelTypes.Expression; @@ -354,7 +355,7 @@ export function setAttr( elem: babelTypes.Expression, name: string, value: babelTypes.Expression, - { dynamic, prevId, tagName, styleProperty, classProperty }: DOMSetAttrOptions = {} + { dynamic, prevId, tagName, styleProperty, classProperty, checkableInput }: DOMSetAttrOptions = {} ): babelTypes.Expression { // pull out namespace const config = getConfig(path); @@ -428,10 +429,17 @@ export function setAttr( // carries the select-value retry and input/textarea nullish special cases. // `` may be a checkbox or radio, whose nullish + * value/defaultValue removes the `value` attribute (restoring the native "on" + * submission value) instead of clearing it to "" like a text field (#4017). + * Only a plain `type` written as a string literal is resolved here; a missing + * `type` is a text input. Anything else (an expression, `prop:type`, + * `attr:type`, a spread) is unknown and treated as possibly checkable, which + * stays correct because setProperty tests the live `type`. Mirrored by the + * native compiler's `input_may_be_checkable` (dom/attrs.rs). + */ +function inputMayBeCheckable(node: babelTypes.JSXElement): boolean { + let checkable = false; + for (const attribute of node.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attribute)) return true; + const name = attribute.name; + if (t.isJSXNamespacedName(name)) { + if (name.name.name === "type") return true; + continue; + } + if (name.name !== "type") continue; + let value: babelTypes.Node | null | undefined = attribute.value; + if (t.isJSXExpressionContainer(value)) value = value.expression; + if (value == null) checkable = false; + else if (t.isStringLiteral(value)) checkable = /^(checkbox|radio)$/i.test(value.value); + else return true; + } + return checkable; +} + function transformAttributes( path: BabelPath, results: DOMTransformResult @@ -504,7 +542,8 @@ function transformAttributes( const tagName = getTagName(path.node), hasChildren = path.node.children.length > 0, hasSpread = attributes.some(attribute => t.isJSXSpreadAttribute(attribute.node)), - config = getConfig(path); + config = getConfig(path), + checkableInput = tagName === "input" && inputMayBeCheckable(path.node); const refs: babelTypes.Statement[] = []; // preprocess spreads @@ -1182,6 +1221,7 @@ function transformAttributes( key, value: value.expression, tagName, + checkableInput, styleProperty: isStyleProperty, classProperty: isClassProperty }); @@ -1190,6 +1230,7 @@ function transformAttributes( t.expressionStatement( setAttr(attribute, elem, key, value.expression, { tagName, + checkableInput, styleProperty: isStyleProperty, classProperty: isClassProperty }) @@ -1238,7 +1279,10 @@ function transformAttributes( ) { results.exprs.push( t.expressionStatement( - setAttr(attribute, elem, key, staticValue as babelTypes.Expression, { tagName }) + setAttr(attribute, elem, key, staticValue as babelTypes.Expression, { + tagName, + checkableInput + }) ) ); } else { diff --git a/packages/babel-plugin/src/dom/template.ts b/packages/babel-plugin/src/dom/template.ts index 391496487..189b2dbd5 100644 --- a/packages/babel-plugin/src/dom/template.ts +++ b/packages/babel-plugin/src/dom/template.ts @@ -258,6 +258,7 @@ function wrapDynamics( t.expressionStatement( setAttr(path, dynamics[0].elem, dynamics[0].key, newValue, { tagName: dynamics[0].tagName, + checkableInput: dynamics[0].checkableInput, dynamic: true, prevId: prevValue, styleProperty: dynamics[0].styleProperty, @@ -278,7 +279,9 @@ function wrapDynamics( const statements: t.ExpressionStatement[] = []; const properties: t.Identifier[] = []; - dynamics.forEach(({ elem, key, value, tagName, styleProperty, classProperty }, index) => { + dynamics.forEach((binding, index) => { + const { elem, key, tagName, styleProperty, classProperty, checkableInput } = binding; + let value = binding.value; const propIdent = t.identifier(getNumberedId(index)); const propMember = t.memberExpression(prevId, propIdent); const optionalPropMember = t.optionalMemberExpression(prevId, propIdent, false, true); @@ -296,7 +299,8 @@ function wrapDynamics( setAttr(path, elem, key, propIdent, { tagName, dynamic: true, - prevId: optionalPropMember + prevId: optionalPropMember, + checkableInput }) ) ); @@ -316,7 +320,8 @@ function wrapDynamics( tagName, dynamic: true, styleProperty, - classProperty + classProperty, + checkableInput }) ) ) diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index 63928913e..200ebd2e4 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -161,6 +161,7 @@ export interface DynamicBinding { tagName?: string; styleProperty?: boolean; classProperty?: boolean; + checkableInput?: boolean; } export interface SetAttrOptions { diff --git a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/code.js b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/code.js index c65d8e8c9..3371bacdc 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/code.js +++ b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/code.js @@ -421,3 +421,21 @@ const template97 =
; const template99 =
; + +// #4017: a nullish value/defaultValue on a checkbox or radio (or an input whose +// type is not statically known) removes the attribute; text inputs clear to "". +const template100 = ; + +const template101 = ; + +const template102 = ; + +const template103 = ; + +const template104 = ; + +const template105 = ; + +const template106 = ; + +const template107 = ; diff --git a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js index 9d4529fb5..f0499579a 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js @@ -74,7 +74,13 @@ var _tmpl$ = /*#__PURE__*/ _$template(`

Welcome`), ), _tmpl$50 = /*#__PURE__*/ _$template(`