Skip to content

fix: patch children of focused Form-Associated Custom Elements (FACEs)#4323

Open
carlosescri wants to merge 3 commits into
phoenixframework:mainfrom
carlosescri:carlos/fix-face-webcomponents-children-patching
Open

fix: patch children of focused Form-Associated Custom Elements (FACEs)#4323
carlosescri wants to merge 3 commits into
phoenixframework:mainfrom
carlosescri:carlos/fix-face-webcomponents-children-patching

Conversation

@carlosescri

Copy link
Copy Markdown

I hope this helps someone.

Problem

When a Form-Associated Custom Element (FACE) has focus, onBeforeElUpdated in dom_patch.ts identifies it as a focused form input via DOM.isEditableInput() and returns false. This tells morphdom to skip the element and all its children.

For native inputs (<input>, <select>, <textarea>) this is correct, their value lives in the DOM and we don't want morphdom to overwrite what the user is typing. But FACEs are fundamentally different: they store their value in ElementInternals via setFormValue(), not in DOM attributes or children. Their light-DOM children are structural (e.g. slotted elements, in my case is a custom select web component with slotted elements) and must be patched normally.

How to reproduce

  1. Create a FACE with static formAssociated = true that has light-DOM children managed by LiveView (e.g. a slotted button with a phx-click attribute whose payload changes on each render).
  2. Give the FACE focus (click on it).
  3. Trigger a server re-render that updates the children (e.g. via phx-change).
  4. The server sends the correct diff (visible in WebSocket inspector), but the children in the DOM are never updated.

Root cause

document.activeElement === <my-face-element>   (always true for shadow DOM hosts)
DOM.isEditableInput(<my-face-element>)         → true  (formAssociated = true)
isFocusedFormEl                                → true
return false                                   → morphdom skips children

Solution

A new helper DOM.isFormAssociatedCustomElement() detects FACEs. When a focused FACE is encountered, the patch returns the element instead of false, which tells morphdom to update attributes and descend into morphChildren as usual. Native inputs retain their existing behavior.

Notes

  • delegatesFocus: true on the shadow root does not help — document.activeElement is always the host element regardless of this setting.
  • LiveView already has explicit FACE support (isFormAssociated(), isEditableInput()), and FACEs participate correctly in form serialization via FormData. This fix completes that support by ensuring their children are also patched.
  • All existing JS tests pass (252/252).

@carlosescri

Copy link
Copy Markdown
Author

My use case was found in a showcase of the component. I can pass a function that receives the selected values and you can return a JS command to be applied on phx-click. In my case it just displays a flash message with the provided values.

The problem was that those values weren't patched on form change and the JS command was never updated although values were present in the websocket patch response.

<Form.select_v2
  field={f[:other_country]}
  prompt="Select one or more countries"
  multiple={true}
  search={true}
  options={UISelectSchema.options(:countries_by_continent)}
  apply={&(JS.push("apply-selection", value: %{selected_values: &1}))}
/>

renders:

<ui-select
  id="ui-library_other_country"
  name="select[other_country][]"
  ...
  class="..."
  data-phx-id="m17-phx-GMBrixe-pkv_jRgE"
>
  <optgroup label="Africa">
    <option value="Egypt">Egypt</option>
    <option value="Nigeria">Nigeria</option>
    <option value="South Africa">South Africa</option>
    <option value="Kenya">Kenya</option>
  </optgroup>
  <hr>
  ...
  
  <button
    type="button"
    class="..."
    slot="actions"
    phx-click='[["push",{"value":{"selected_values":[]},"event":"apply-selection"}]]'
    data-phx-id="m18-phx-GMBrixe-pkv_jRgE"
  >
    <span class="...">
      Aplicar
    </span>
  </button>
</ui-select>
Captura desde 2026-07-08 22-47-16

Before

The user selects a country and clicks on "Aplicar" (Apply). The phx-click of the button should reflect the value but it didn't.

Captura desde 2026-07-08 23-00-45

After

Captura desde 2026-07-08 22-47-30

@SteffenDE

Copy link
Copy Markdown
Member

Quoting the README

JS contributions are very welcome, but please do not include an updated priv/static/phoenix_live_view.js in pull requests. The maintainers will update it as part of the release process.

Are you sure that this approach works with all form associated custom elements? I’m wondering if, should we merge this, we’d get bug reports from users that did rely on this behavior.

@carlosescri carlosescri force-pushed the carlos/fix-face-webcomponents-children-patching branch from bd30c08 to e9e0a84 Compare July 10, 2026 06:49
@carlosescri

carlosescri commented Jul 10, 2026

Copy link
Copy Markdown
Author

Are you sure that this approach works with all form associated custom elements? I’m wondering if, should we merge this, we’d get bug reports from users that did rely on this behavior.

Thanks for the response and the interest on this PR @SteffenDE.

Fast answer: NO 😅. That's why I've added a specific set of tests for this issue, which I should have done from the beginning:

You should have access to all artifacts in case you want to review them.

Now I can say I'm more confident on the solution 😄.

carlosescri and others added 3 commits July 10, 2026 12:30
Form-Associated Custom Elements store their value in ElementInternals
(via setFormValue), not in DOM attributes or children. When a FACE has
focus, morphdom should still descend into its light-DOM children to
patch them normally, since skipping children is only necessary for
native inputs whose value lives in the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Five scenarios covering Form-Associated Custom Element morphdom
behavior: basic FACE with focus, FACE with input child, slotted
input with focus, delegatesFocus, and non-FACE control case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers the pattern used by components like ui-select: shadow DOM with
delegatesFocus, a focusable input inside the shadow root, and slotted
light DOM children that must be patched while the host has focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@carlosescri carlosescri force-pushed the carlos/fix-face-webcomponents-children-patching branch from 9aead4e to 61ac933 Compare July 10, 2026 10:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants