Skip to content

feat(forms): experimental prototype signal forms support for select[multiple]#68350

Open
sonukapoor wants to merge 5 commits intoangular:mainfrom
sonukapoor:issue-67563-signal-forms-multiselect-experimental
Open

feat(forms): experimental prototype signal forms support for select[multiple]#68350
sonukapoor wants to merge 5 commits intoangular:mainfrom
sonukapoor:issue-67563-signal-forms-multiselect-experimental

Conversation

@sonukapoor
Copy link
Copy Markdown
Contributor

@sonukapoor sonukapoor commented Apr 23, 2026

Add an initial Signal Forms implementation for <select multiple>.

This is an experimental draft implementation of Signal Forms support for <select multiple>, and I’m mainly looking for feedback on the direction before taking it further.

I discussed this issue with @AndrewKushnir, who suggested putting together a draft PR so the team could comment on the approach in code.

The current scope is intentionally small:

  • support native <select multiple>
  • synchronize model -> DOM and DOM -> model
  • resync correctly when options change
  • preserve DOM option order in the emitted value

Not included in this prototype:

  • compareWith
  • object-valued options / ngValue semantics
  • broader selection abstractions

Also adds focused coverage for:

  • Signal Forms type-checking of <select multiple>
  • runtime synchronization for multi-select controls

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe:

What is the current behavior?

Issue Number: #67563

Does this PR introduce a breaking change?

  • Yes
  • No

Add an initial Signal Forms implementation for <select multiple>.

This prototype (for angular#67563) adds a dedicated runtime path for
multi-select controls and updates Signal Forms type checking so
<select multiple [formField]> is treated as string[] instead of string.

The current scope is intentionally small:
- support native <select multiple>
- synchronize model -> DOM and DOM -> model
- resync correctly when options change
- preserve DOM option order in the emitted value

Not included in this prototype:
- compareWith
- object-valued options / ngValue semantics
- broader selection abstractions

Also adds focused coverage for:
- Signal Forms type-checking of <select multiple>
- runtime synchronization for multi-select controls
@angular-robot angular-robot Bot added detected: feature PR contains a feature commit area: forms labels Apr 23, 2026
@ngbot ngbot Bot added this to the Backlog milestone Apr 23, 2026
@JeanMeche JeanMeche requested review from alxhub and leonsenft April 23, 2026 13:45
@sonukapoor sonukapoor marked this pull request as ready for review April 23, 2026 22:15
Comment thread packages/forms/signals/test/web/form_field.spec.ts Outdated
Comment thread packages/forms/signals/src/directive/control_select_multiple.ts Outdated
Comment on lines +68 to +87
function getSelectMultipleControlValue(select: HTMLSelectElement): string[] {
const selected: string[] = [];
const selectedOptions = select.selectedOptions;

if (selectedOptions !== undefined) {
for (let i = 0; i < selectedOptions.length; i++) {
selected.push(selectedOptions[i].value);
}
return selected;
}

for (let i = 0; i < select.options.length; i++) {
const option = select.options[i];
if (option.selected) {
selected.push(option.value);
}
}

return selected;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious why we need to cover both cases. Can you add a comment about that

Copy link
Copy Markdown
Contributor Author

@sonukapoor sonukapoor Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment to the fallback branch explaining why it exists. selectedOptions isn't available in all environments (older jsdom in particular), so we iterate options and check the selected flag as a fallback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

older jsdom in particular

Angular doesn't use JSDOM but Domino. But it looks like this argument still hold. I couldn't find any trace of selectedOptions on https://github.com/angular/domino.

Copy link
Copy Markdown
Contributor Author

@sonukapoor sonukapoor Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good correction, thanks. It's Domino, not jsdom. I updated the comment to reflect that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I second guessing this. If we have that 2nd part that checks for selected. Do we actually need selectedOptions ?

Copy link
Copy Markdown
Contributor Author

@sonukapoor sonukapoor Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The options/selected fallback is functionally equivalent and works in all environments, so selectedOptions was only ever a minor performance optimization. For typical multi-selects the difference is negligible. Dropped the selectedOptions path entirely and simplified to a single loop.

Comment thread packages/forms/signals/src/directive/control_select_multiple.ts
- Remove $any() cast from the test — the TCB already enforces string[]
  for select[multiple], so no workaround is needed
- Extract the CONTROL_BINDING_NAMES update loop into a shared
  applyControlStateBindings() helper in bindings.ts, used by both the
  native and select-multiple control paths
- Throw a RuntimeError (code 1921) in setSelectMultipleControlValue when
  a non-array value is passed; the guard is unconditional so production
  never silently corrupts the selection state
- Move the selectedOptions fallback comment to the fallback branch where
  it actually explains the divergence
@sonukapoor sonukapoor requested a review from JeanMeche April 27, 2026 13:12
… comment

Angular uses Domino for server-side rendering, not jsdom. The fallback
comment now correctly names Domino as the environment where
`selectedOptions` is unavailable.
@sonukapoor sonukapoor force-pushed the issue-67563-signal-forms-multiselect-experimental branch from b4ab903 to 950ab18 Compare April 27, 2026 13:29
…e path

Removed the selectedOptions fast path and use only the options/selected
fallback, which works correctly in all environments including Domino.
The performance difference is negligible for typical multi-selects and
the simpler single-path implementation is easier to follow.
return true;
}

return node.inputs.some(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this return true for any property binding to multiple? What if it's been set to false?

<select [multiple]="false" ... >

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think dynamic [type] bindings raise a similar problem and it looks like we address those above by falling back to all value types. We may need to do something similar here, where the type because string | string[].

Copy link
Copy Markdown
Contributor Author

@sonukapoor sonukapoor Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I replaced hasMultipleSelect with getSelectMultipleMode that returns 'static' | 'dynamic' | 'none'. The static attribute case still maps to string[], but any property binding now maps to string | string[] since we can't know the value at compile time.

Copy link
Copy Markdown
Contributor Author

@sonukapoor sonukapoor Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have also addressed, alongside the first comment, getSelectMultipleMode now returns 'dynamic' for any property binding, and the caller maps that to string | string[]. Same approach as the hasDynamicType handling for [type] bindings on <input>.

export function applyControlStateBindings(
bindings: {[key: string]: unknown},
state: ReadonlyFieldState<unknown>,
onUpdate: (name: ControlBindingKey, value: unknown) => void,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the onUpdate callback is identical for both cases. Can we just inline that here and avoid the closure allocation?

Copy link
Copy Markdown
Contributor Author

@sonukapoor sonukapoor Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The callback was identical in both callers since input and select are both just parent.nativeFormElement. Removed the onUpdate parameter entirely. applyControlStateBindings now takes host and parent directly and performs the two operations inline. A ControlStateBindingTarget structural interface avoids pulling FormField into bindings.ts (which would create a cycle). Both call sites are now just applyControlStateBindings(bindings, state, host, parent).

} else if (
this.elementIsNativeFormElement &&
this.nativeFormElement.tagName === 'SELECT' &&
(this.nativeFormElement as HTMLSelectElement).multiple
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same concern raised with the type check code above. If the element has a [multiple] property binding, it may not be set yet at this point. Do we want to delegate all <select> elements through a selectControlCreate(), which dynamically handles multiple? Alternatively we can prohibit a dynamic multiple binding if we prefer.

Copy link
Copy Markdown
Contributor Author

@sonukapoor sonukapoor Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the prohibit option. The check happens at the start of the first update cycle (after Angular has applied property bindings), so it catches the case where [multiple] was set dynamically before ɵngControlCreate had a chance to see the correct value. If select.multiple doesn't match what was assumed at creation time, a DYNAMIC_SELECT_MULTIPLE_BINDING error is thrown in dev mode pointing to the static attribute as the fix. A unified dynamic handler could be a follow-up if there's demand for it.

…] prototype

- Fix hasMultipleSelect to distinguish static vs dynamic [multiple]
  bindings. A property binding [multiple]="..." can evaluate to false
  at runtime, so the type checker now returns string|string[] for
  dynamic bindings rather than always assuming string[].
- Remove the onUpdate callback from applyControlStateBindings. Both
  callers had identical callbacks, so the operations are now inlined
  directly using host/parent parameters. A ControlStateBindingTarget
  structural interface avoids introducing a circular import.
- Add a dev-mode guard that throws DYNAMIC_SELECT_MULTIPLE_BINDING if
  the select.multiple state doesn't match what was observed at
  ɵngControlCreate time. Dynamic [multiple] bindings are not supported;
  the static attribute should be used instead.
@sonukapoor sonukapoor requested a review from leonsenft April 28, 2026 01:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: forms detected: feature PR contains a feature commit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants