forms: skip FormData entry for <select> with no selectedness candidate

Per the HTML Living Standard "constructing the form data set" algorithm,
a <select> element appends one entry per option whose selectedness is
true. A non-multiple <select> derives its selectedness from an
explicitly-selected option, falling back to the first non-disabled
option in tree order. With zero options (or only disabled options), no
option is selected, so no entry should be appended.

The singular-select fast path in collectForm previously broke from the
block with select.getValue(frame) regardless of whether any option
existed, causing FormData to emit a phantom (name, "") entry for an
empty <select>. Guard the fast path on the existence of at least one
non-disabled <option> child and skip the entry otherwise.

Closes #2262
This commit is contained in:
Navid EMAD
2026-04-27 08:04:21 +02:00
parent 9fbade4573
commit 67239adb11
2 changed files with 77 additions and 0 deletions

View File

@@ -813,6 +813,72 @@
}
</script>
<script id=selectWithoutOptions>
{
// Per HTML spec (constructing form data set, step 5.5), a <select> only
// appends entries for options whose selectedness is true. With zero options,
// nothing is selected and no entry is appended.
const form = document.createElement('form');
const sel = document.createElement('select');
sel.name = 'choice';
form.appendChild(sel);
const sibling = document.createElement('input');
sibling.name = 'sibling';
sibling.value = 'kept';
form.appendChild(sibling);
const fd = new FormData(form);
testing.expectEqual(false, fd.has('choice'));
testing.expectEqual(null, fd.get('choice'));
testing.expectEqual([], fd.getAll('choice'));
testing.expectEqual('kept', fd.get('sibling'));
testing.expectEqual([['sibling', 'kept']], Array.from(fd.entries()));
}
</script>
<script id=selectMultipleWithoutOptions>
{
// Same rule for <select multiple> — with zero options, no entry.
const form = document.createElement('form');
const sel = document.createElement('select');
sel.name = 'tags';
sel.multiple = true;
form.appendChild(sel);
const fd = new FormData(form);
testing.expectEqual(false, fd.has('tags'));
testing.expectEqual([], fd.getAll('tags'));
testing.expectEqual([], Array.from(fd.entries()));
}
</script>
<script id=selectAllOptionsDisabled>
{
// A single-select with only disabled options has no auto-selected option,
// so no entry is appended.
const form = document.createElement('form');
const sel = document.createElement('select');
sel.name = 'mode';
const opt = document.createElement('option');
opt.value = 'off';
opt.disabled = true;
opt.textContent = 'off';
sel.appendChild(opt);
form.appendChild(sel);
const fd = new FormData(form);
testing.expectEqual(false, fd.has('mode'));
testing.expectEqual(null, fd.get('mode'));
testing.expectEqual([], fd.getAll('mode'));
}
</script>
<script id=formDataEventModifyFormData>
{
// Listeners can modify formData during the event

View File

@@ -196,6 +196,17 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *F
if (element.is(Form.Select)) |select| {
if (select.getMultiple() == false) {
// Per the HTML spec, a single-select's selectedness comes
// from an explicitly-selected option, falling back to the
// first non-disabled option in tree order. With no options
// (or only disabled ones), nothing is selected and no
// entry is appended.
var children = select.asNode().childrenIterator();
const has_candidate = while (children.next()) |child| {
const option = child.is(Form.Select.Option) orelse continue;
if (!option.getDisabled()) break true;
} else false;
if (!has_candidate) continue;
break :blk select.getValue(frame);
}