Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/compiler-nullish-checkbox-radio-value.md
Original file line number Diff line number Diff line change
@@ -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).
56 changes: 50 additions & 6 deletions packages/babel-plugin/src/dom/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type DOMSetAttrOptions = {
tagName?: string;
styleProperty?: boolean;
classProperty?: boolean;
checkableInput?: boolean;
};
type SpreadOptions = {
elem: babelTypes.Expression;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -428,10 +429,17 @@ export function setAttr(
// carries the select-value retry and input/textarea nullish special cases.
// `<select value>` always goes through it: a microtask only sees options
// inserted in the same turn, so options that arrive later never get
// selected (#3928).
// selected (#3928). So does a non-literal value/defaultValue on an input
// that may be a checkbox or radio: a nullish value must remove the
// attribute so the checked box submits its native "on" (#4009, #4017),
// and setProperty already tests the live `type` for that.
if (
(config.hydratable && (isLocked || namespace !== "prop")) ||
(name === "value" && tagName === "select")
(name === "value" && tagName === "select") ||
(checkableInput &&
(name === "value" || name === "defaultValue") &&
!t.isStringLiteral(value) &&
!t.isNumericLiteral(value))
) {
return t.callExpression(registerImportMethod(path, "setProperty"), [
elem,
Expand All @@ -451,7 +459,8 @@ export function setAttr(
!t.isStringLiteral(value) &&
!t.isNumericLiteral(value)
) {
// prevents undefined on input/textarea.value/defaultValue, fallback to empty string
// prevents undefined on text input/textarea.value/defaultValue, fallback
// to empty string (#2957)
return t.assignmentExpression(
"=",
t.memberExpression(elem, t.identifier(name)),
Expand Down Expand Up @@ -492,6 +501,35 @@ function detectResolvableEventHandler(
return t.isFunction(handler);
}

/**
* Whether an `<input>` 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<babelTypes.JSXElement>,
results: DOMTransformResult
Expand All @@ -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
Expand Down Expand Up @@ -1182,6 +1221,7 @@ function transformAttributes(
key,
value: value.expression,
tagName,
checkableInput,
styleProperty: isStyleProperty,
classProperty: isClassProperty
});
Expand All @@ -1190,6 +1230,7 @@ function transformAttributes(
t.expressionStatement(
setAttr(attribute, elem, key, value.expression, {
tagName,
checkableInput,
styleProperty: isStyleProperty,
classProperty: isClassProperty
})
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions packages/babel-plugin/src/dom/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -296,7 +299,8 @@ function wrapDynamics(
setAttr(path, elem, key, propIdent, {
tagName,
dynamic: true,
prevId: optionalPropMember
prevId: optionalPropMember,
checkableInput
})
)
);
Expand All @@ -316,7 +320,8 @@ function wrapDynamics(
tagName,
dynamic: true,
styleProperty,
classProperty
classProperty,
checkableInput
})
)
)
Expand Down
1 change: 1 addition & 0 deletions packages/babel-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export interface DynamicBinding {
tagName?: string;
styleProperty?: boolean;
classProperty?: boolean;
checkableInput?: boolean;
}

export interface SetAttrOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,21 @@ const template97 = <div class={["todo", { active: isActive(), [props.name]: prop
const template98 = <div class={["todo", { active: isActive() }, props.extra]} />;

const template99 = <div class={["todo", "item", { todo: false, active: isActive() }]} />;

// #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 = <input type="checkbox" name="agree" value={value()} checked />;

const template101 = <input type="radio" name="choice" defaultValue={value()} />;

const template102 = <input type="text" value={value()} />;

const template103 = <input value={value()} />;

const template104 = <input type={kind()} value={value()} />;

const template105 = <input type="Checkbox" value={value} />;

const template106 = <input type="checkbox" value={value()} defaultValue={other()} />;

const template107 = <input type="email" value={value()} defaultValue={other()} />;
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ var _tmpl$ = /*#__PURE__*/ _$template(`<div><h1><a href=/>Welcome`),
),
_tmpl$50 = /*#__PURE__*/ _$template(`<video src=test.mp4 muted>`),
_tmpl$51 = /*#__PURE__*/ _$template(`<div class=todo>`),
_tmpl$52 = /*#__PURE__*/ _$template(`<div class="todo item">`);
_tmpl$52 = /*#__PURE__*/ _$template(`<div class="todo item">`),
_tmpl$53 = /*#__PURE__*/ _$template(`<input type=checkbox name=agree checked>`),
_tmpl$54 = /*#__PURE__*/ _$template(`<input type=radio name=choice>`),
_tmpl$55 = /*#__PURE__*/ _$template(`<input type=text>`),
_tmpl$56 = /*#__PURE__*/ _$template(`<input>`),
_tmpl$57 = /*#__PURE__*/ _$template(`<input type=Checkbox>`),
_tmpl$58 = /*#__PURE__*/ _$template(`<input type=email>`);
import * as styles from "./styles.module.css";
import { binding } from "somewhere";
function refFn() {}
Expand Down Expand Up @@ -844,4 +850,78 @@ _$effect(
}
);
const template99 = _el$123;

// #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 "".
var _el$124 = _tmpl$53();
_$effect(
() => value(),
_v$ => {
_$setProperty(_el$124, "value", _v$);
}
);
const template100 = _el$124;
var _el$125 = _tmpl$54();
_$effect(
() => value(),
_v$ => {
_$setProperty(_el$125, "defaultValue", _v$);
}
);
const template101 = _el$125;
var _el$126 = _tmpl$55();
_$effect(
() => value(),
_v$ => {
_el$126.value = _v$ ?? "";
}
);
const template102 = _el$126;
var _el$127 = _tmpl$56();
_$effect(
() => value(),
_v$ => {
_el$127.value = _v$ ?? "";
}
);
const template103 = _el$127;
var _el$128 = _tmpl$56();
_$effect(
() => ({
e: kind(),
t: value()
}),
({ e, t }, _p$) => {
e !== _p$?.e && _$setAttribute(_el$128, "type", e);
_$setProperty(_el$128, "value", t);
}
);
const template104 = _el$128;
var _el$129 = _tmpl$57();
_$setProperty(_el$129, "value", value);
const template105 = _el$129;
var _el$130 = _tmpl$9();
_$effect(
() => ({
e: value(),
t: other()
}),
({ e, t }, _p$) => {
_$setProperty(_el$130, "value", e);
t !== _p$?.t && _$setProperty(_el$130, "defaultValue", t);
}
);
const template106 = _el$130;
var _el$131 = _tmpl$58();
_$effect(
() => ({
e: value(),
t: other()
}),
({ e, t }, _p$) => {
_el$131.value = e ?? "";
t !== _p$?.t && (_el$131.defaultValue = t ?? "");
}
);
const template107 = _el$131;
_$delegateEvents(["click", "input"]);
Loading
Loading