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(`