html icon indicating copy to clipboard operation
html copied to clipboard

The order of execution of the formAssociatedCallback hook does not match the implementations

Open dSalieri opened this issue 11 months ago • 6 comments

What is the issue with the HTML Standard?

The specification says the following:

When the user agent resets the form owner of a form-associated custom element and doing so changes the form owner, its formAssociatedCallback is called, given the new form owner (or null if no owner) as an argument.

That is, the call to the formAssociatedCallback hook occurs when 2 actions are performed:

Now let's list the cases that call the reset the form owner:

  1. Insertion steps
  2. Removing steps
  3. Moving steps
  4. Any modification of the form attribute of the listed form-associated element, as well as any modifications related to the ID that may entail a change in the owner of the form
  5. Upgrade element

I am only interested in the first 3 points (in 4 and 5 I did not find any inconsistencies with the implementations).


Let's see under what conditions reset the form owner is called.

Insertion steps

  1. If insertedNode is a form-associated element or the ancestor of a form-associated element, then:

    1. If the form-associated element's parser inserted flag is set, then return.

    2. Reset the form owner of the form-associated element.

The reset the form owner call always occurs for the form-associated element.

Removing steps и Moving steps

If movedNode is a form-associated element or the ancestor of a form-associated element, then:

  1. If the form-associated element has a form owner and the form-associated element and its form owner are no longer in the same tree, then reset the form owner of the form-associated element.

The reset the form owner call occurs if the form owner is not null and the form-associated element and its form owner are in different trees

There are no problems for removing steps, because before its execution in the remove algorithm, the node is removed from the tree in which it was. Now the tree of the removed node is itself.

And here is the problem that concerns the specification.

[!WARNING] For the moving steps the problem is divided into two parts:

It seems to me that the condition for the reset the form owner algorithm in the moving steps should simply be removed. Opinion?


It is also worth clarifying that in all three algorithms the order of formAssociatedCallback comes before any of connectedCallback, disconnectedCallback and connectedMoveCallback.

Here are excerpts from the specification.

For move:

  1. For each shadow-including inclusive descendant inclusiveDescendant of node, in shadow-including tree order:

    1. If inclusiveDescendant is node, then run the moving steps with inclusiveDescendant and oldParent. Otherwise, run the moving steps with inclusiveDescendant and null.

      Because the move algorithm is a separate primitive from insert and remove, it does not invoke the traditional insertion steps or removing steps for inclusiveDescendant.

    2. If inclusiveDescendant is custom and newParent is connected, then enqueue a custom element callback reaction with inclusiveDescendant, callback name "connectedMoveCallback", and « ».

For insert:

  1. For each shadow-including inclusive descendant inclusiveDescendant of node, in shadow-including tree order:

    1. Run the insertion steps with inclusiveDescendant.

    2. If inclusiveDescendant is connected:

      1. If inclusiveDescendant is custom, then enqueue a custom element callback reaction with inclusiveDescendant, callback name "connectedCallback", and « ».

      2. Otherwise, try to upgrade inclusiveDescendant.

      If this successfully upgrades inclusiveDescendant, its connectedCallback will be enqueued automatically during the upgrade an element algorithm.

For remove:

  1. Run the removing steps with node and parent.

  2. Let isParentConnected be parent’s connected.

  3. If node is custom and isParentConnected is true, then enqueue a custom element callback reaction with node, callback name "disconnectedCallback", and « ».

It is intentional for now that custom elements do not get parent passed. This might change in the future if there is a need.

  1. For each shadow-including descendant descendant of node, in shadow-including tree order:

    1. Run the removing steps with descendant and null.

    2. If descendant is custom and isParentConnected is true, then enqueue a custom element callback reaction with descendant, callback name "disconnectedCallback", and « ».


Now to the main thing.

I checked insertBefore and moveBefore in Chrome (for Firefox I could only check insertBefore, since moveBefore is not yet available there). I was surprised by the results. The order of formAssociatedCallback, connectedCallback and disconnectedCallback differs between browsers, and Chrome does not follow the order specified in the specification at all.

Here is an example:

<script>
customElements.define("my-input", class extends HTMLElement {
    static formAssociated = true;
    constructor() {
      super();
      this.internals_ = this.attachInternals();
    }
    connectedCallback() {
      console.log(this, "connectedCallback");
    }
    connectedMoveCallback() {
      console.log(this, "connectedMoveCallback");
    }
    disconnectedCallback() {
      console.log(this, "disconnectedCallback");
    }
    formAssociatedCallback(arg) {
      console.log(this, arg, "formAssociatedCallback");
    }
  });
  function inject(target, el, methodName){
    console.log("---");
    target[methodName](el, null);
  }
</script>
<div id="block1">
   <form id="form1">
     <my-input id="el">element</my-input>
   </form>
</div>
<button id="btn1" onclick="inject(block1, el, 'insertBefore')">block1.insertBefore</button>
<button id="btn2" onclick="inject(form1, el, 'insertBefore')">form1.insertBefore</button>
<button id="btn3" onclick="inject(block1, el, 'moveBefore')">block1.moveBefore</button>
<button id="btn4" onclick="inject(form1, el, 'moveBefore')">form1.moveBefore</button>

If you click on btn2 in Chrome you will see this:

Image

For Firefox the situation is different:

Image

In both browsers it should be in this order:

  • formAssociatedCallback
  • disconnectedCallback
  • formAssociatedCallback
  • connectedCallback

Also if you click on btn4 in Chrome you will see this:

Image

But only connectedMoveCallback should be called. What went wrong?

I have attached a demoscene at this link (open the console and see 8 cases of different DOM manipulations).


To summarize everything written, there are 2 problems:

  • The implementations either do not correspond to the order of calling the formAssociatedCallback hook or correspond, but partially.
  • The problem of determining the moving steps for the form-associated custom element, the reset the form owner algorithm will not be called if the element was moved outside the form from form or, conversely, moved in the form within the same tree.

@domfarolino

dSalieri avatar Mar 16 '25 06:03 dSalieri

There are no problems for removing steps, because before its execution in the remove algorithm, the node is removed from the tree in which it was. Now the tree of the removed node is itself.

That last sentence is not always true. DOM calls the removing steps on every node in the removed subtree, so a removed node's "tree" could still be one of its ancestors further up the removed subtree.


Moving a form-associated element from one tree to another.

For example: If a form-associated element has a form owner value of null, and the element is placed in a form, then the condition for the reset the form owner will not be met because the form owner for the form-associated element is null.

Yep, this is working as intended. If you atomically move a form-associated element (like <input>) from a tree with no form into a tree with a form that would ordinarily be the input's form owner, we do not want to invoke "reset the form owner", because we need to preserve as much state as possible—part of that state is the null-ness of the .form IDL attribute.

See this example
const div = document.querySelector('div');
const form = document.createElement('form');
form.id = 'form1';
document.body.insertBefore(form, div);

div.attachShadow({mode: 'open'}); const input = document.createElement('input'); input.type = 'text'; input.form = 'form1'; div.shadowRoot.append(input); console.log(input.form); // null, because its state was preserved.


Moving a form-associated elements within the same tree.

For example: If a form-associated element has a form owner value of null, and the element is placed in a form. The condition for the reset the form owner will not be met for two reasons. The first is that the form owner is null, and the second is because we are in the same tree.

I don't think I see the problem here. If a form-associated element and a matching-id form are in the same tree, then I think .form would just be wired up correctly anyways. You should never need to rely on the relationship being reset by the moving steps, right?


Now to the main thing.

I think your code example found something interesting. There seems to be two problems:

  1. insertBefore(): When you call insertBefore() on the already-connected custom element, implementations are not consistently firing formAssociatedCallback before disconnected/connected callbacks.
  2. moveBefore(): Chromium is resetting the form owner even in the moving steps, when it shouldn't be invoking the "reset the form owner" steps at all in your example page. For both moveBefore() buttons in your demo, I would never expect a formAssociatedCallback to run (so CLs like https://chromium-review.googlesource.com/c/chromium/src/+/5900117 are probably wrong).

Do you agree with both of these assertions above?

domfarolino avatar Jun 07 '25 01:06 domfarolino

That last sentence is not always true. DOM calls the removing steps on every node in the removed subtree, so a removed node's "tree" could still be one of its ancestors further up the removed subtree.

You are right.

Yep, this is working as intended. If you atomically move a form-associated element (like ) from a tree with no form into a tree with a form that would ordinarily be the input's form owner, we do not want to invoke "reset the form owner", because we need to preserve as much state as possible—part of that state is the null-ness of the .form IDL attribute.

By the way, you are trying to use the IDL attribute form to set the value of "form1", this will always fail because the IDL attribute form does not have a setter. You need to use a content attribute for this. Also, you did not use moveBefore and as a result you were clearly not testing the move algorithm.

I rewrote your example to the correct version, that is, moving from a tree without a form to a tree with a form.

const div = document.querySelector('div');
div.attachShadow({mode: 'open'});
const form = document.createElement('form');
form.id = 'form1';
document.body.insertBefore(form, div);
div.shadowRoot.append(form);

const input = document.createElement('input');
document.body.append(input); /// inserting in a real tree, otherwise it will be HierarchyRequestError

console.log(input.form); // null
form.moveBefore(input, null);
console.log(input.form); // not null

And as a result, input.form is not null.

I don't think I see the problem here. If a form-associated element and a matching-id form are in the same tree, then I think .form would just be wired up correctly anyways. You should never need to rely on the relationship being reset by the moving steps, right?

I understand that you are relying on your example where you "set" the attribute to the value of the form id. I am talking about the case when the form owner is set due to the correct nesting of the form-associated element in a specific form.


Do you agree with both of these assertions above?

  1. Yes, first there should be formAssociatedCallback, and then, for example, connectedCallback or disconnectedCallback.

  2. If the element is moved to the same place without changing the form owner, then yes, formAssociatedCallback definitely should not be called.


What is the point of formAssociatedCallback then if we don't call it when the form owner changes? The note I mentioned in the very first post clearly hints at this. Look at the condition again:

If the form-associated element has a form owner and the form-associated element and its form owner are no longer in the same tree, then reset the form owner of the form-associated element.

Here it is very clear that the condition will fail if the form owner of the form-associated element is null or if the form-associated element is in the same tree with form owner. This means that moving not from form to form will simply never call reset the form owner and therefore will never call formAssociatedCallback.

The funny thing is that the behavior in Chrome does not match the specification and works almost as expected (except when moving to the same place)

@domfarolino

dSalieri avatar Jun 07 '25 14:06 dSalieri

OK so it seems like we need the following tests to ensure this spec condition is fully tested:

  1. Moving a form-associated element from form1 ➡️ form2 in the same tree. There are two variations of this test:
    • First variation: form-associated element with no form content attribute is moved from inside one form, into another form
    • Second variation: form-associated element with form content attribute (not nested inside a form) is moved from one place in its tree, to another place
    • Both assert the form owner is not reset, and formAssociatedCallback is never called
  2. Moving a form-associated element with no form content attribute from inside a form subtree ➡️ outside the form
    • Assert the form owner is not reset, and formAssociatedCallback is never called
  3. Moving a form-associated element (with no form content attribute, and no form owner) from outside a form into a form's subtree
    • Assert the form owner is not reset, and formAssociatedCallback is never called
  4. Moving a form-associated element (with a form content attribute, but no form owner) from a shadow DOM tree into the light DOM tree where a matching form exists.
    • Assert the form owner is not reset, and formAssociatedCallback is never called (i.e., .form is still null)
  5. Moving a form-associated element (with a form content attribute, and a form owner) from a shadow DOM tree into the light DOM tree that contains no forms
    • Assert: finally both conditions are met (the element originally had a form owner; the element is no longer in its owner's tree) and the form owner is reset to null, and formAssociatedCallback is called

These tests would fully test this spec condition. Do they sound good to you?

domfarolino avatar Jun 07 '25 16:06 domfarolino

@domfarolino Looks good, I think.

I've modified the demoscene to suit your tests and added the ability to give a custom element a "form" attribute with any value. You can also remove the attribute if needed. You can now simulate situations from your tests.


I've already tested them and all (except the 5th, but the order of calling callbacks is wrong) failed miserably :)

Here's an overview for the chrome based on your tests:

  1. Moving a form-associated element from form1 ➡️ form2 in the same tree. There are two variations of this test:
    • formAssociatedCallback, connectedMoveCallback, formAssociatedCallback
    • formAssociatedCallback, connectedMoveCallback, formAssociatedCallback
  2. Moving a form-associated element with no form content attribute from inside a form subtree ➡️ outside the form
    • formAssociatedCallback, connectedMoveCallback
  3. Moving a form-associated element (with no form content attribute, and no form owner) from outside a form into a form's subtree
    • connectedMoveCallback, formAssociatedCallback
  4. Moving a form-associated element (with a form content attribute, but no form owner) from a shadow DOM tree into the light DOM tree where a matching form exists.
    • connectedMoveCallback, formAssociatedCallback
  5. Moving a form-associated element (with a form content attribute, and a form owner) from a shadow DOM tree into the light DOM tree that contains no forms
    • formAssociatedCallback, connectedMoveCallback

Are you sure that you want the case for example number 3 doesn't call the formAssociatedCallback, it would look weird to me. What's the point of not calling the formAssociatedCallback if the element changes its form owner?

I want to understand the intent of calling the formAssociatedCallback, and I'm a bit confused that the behavior of move steps is the same as remove steps. What's the point?

dSalieri avatar Jun 08 '25 11:06 dSalieri

Are you sure that you want the case for example number 3 doesn't call the formAssociatedCallback, it would look weird to me.

I'm not talking about what is the idea behavior, I'm just talking about what the tests should assert, to cover the current spec right now.


I am also a little confused about when formAssociatedCallback gets called though, personally. It looks like there is nothing in the form owner reset algorithm that would call it when the form owner changes through that route. @domenic am I missing some other hook? Is https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions:~:text=When%20the%20user%20agent%20resets%20the%20form%20owner%20of%20a%20form%2Dassociated%20custom%20element%20and%20doing%20so%20changes%20the%20form%20owner%2C%20its%20formAssociatedCallback%20is%20called%2C%20given%20the%20new%20form%20owner%20(or%20null%20if%20no%20owner)%20as%20an%20argument supposed to be the COMEFROM-ey invocation of this?

domfarolino avatar Jun 08 '25 16:06 domfarolino

Is https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions:~:text=When%20the%20user%20agent%20resets%20the%20form%20owner%20of%20a%20form%2Dassociated%20custom%20element%20and%20doing%20so%20changes%20the%20form%20owner%2C%20its%20formAssociatedCallback%20is%20called%2C%20given%20the%20new%20form%20owner%20(or%20null%20if%20no%20owner)%20as%20an%20argument supposed to be the COMEFROM-ey invocation of this?

No, that section is supposed to be a non-normative summary of normative requirements elsewhere. The only actual call site is https://html.spec.whatwg.org/#concept-upgrade-an-element , it looks like.

It seems like this has been the case ever since https://github.com/whatwg/html/pull/4383. I haven't followed this very-long thread in detail, but it does generally seem broken. IIRC the intention of formAssociatedCallback was to let custom elements understand when this.form might start returning a new value, and it sounds like that probably isn't the case right now.

domenic avatar Jun 09 '25 05:06 domenic