Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Removing options from select and re-appending changes selected

I just realized a strange behaviour when removing and re-appending options to a select element. It happens that if one of the options is selected, after appended, the next item becomes selected instead of the original one. Consider the following html:

var $opts = $("#sel option").remove();
console.log($opts);
$("#sel").append($opts);
<select id="sel">
    <option>A</option>
    <option>B</option>
    <option selected="selected">C</option>
    <option>D</option>
</select>
(Look in the console.)
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>

(Or as a fiddle)

It makes the option with value "D" to be selected and not the option with value "C", as defined originally. Notice the options printed in the console, the attribute selected is changed after the remove() method.

Why does that happen?

Note: I know how to fix it or work around it, that's not the question. The question is why does it happen?

like image 729
DontVoteMeDown Avatar asked Sep 25 '15 12:09

DontVoteMeDown


People also ask

How do I remove option selected value selected option?

The option to be removed is selected by getting the select box. The value to be removed is specified on the value selector (value='optionValue') on the select box. The remove() method is then used to remove this selected option.

How do I remove select option?

Select remove() MethodThe remove() method is used to remove an option from a drop-down list. Tip: To add an option to a drop-down list, use the add() method.

How do you append to a select option?

Method 1: Append the option tag to the select boxThe select box is selected with the jQuery selector and this option is added with the append() method. The append() method inserts the specified content as the last child of the jQuery collection. Hence the option is added to the select element.


1 Answers

Fascinating question.

I believe it's down to the browser automatically selecting an option when the only selected option is removed, and then what happens when you add an option into a box that has the selected flag set. It also appears to be browser-specific, which probably shouldn't surprise us. But even more, Chrome varies its behavior depending on how closely we're watching. That did surprise me. Details below.

First, let's use something like your updated example, but where the one that we start out with selected is neither first nor last, and see where we end up. So we'll have A, B, C, D, and E, and start out with B selected:

$("input[type=button]").on("click", function() {
  var $opts = $("#sel option").remove();
  $("#sel").append($opts);
});
<select id="sel">
    <option>A</option>
    <option selected="selected">B</option>
    <option>C</option>
    <option>D</option>
    <option>E</option>
</select>
<input type="button" value="Go">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>

The results we observe vary by browser:

  • Chrome ends up on C, the one after the one that was selected at the outset (not the last one).
  • IE11 ends up on B, which is to say, it doesn't seem to change the selected flags as we remove/add.
  • Firefox ends up on E (the last one).

To make matters worse, Chrome's behavior varies depending on whether we observe the selected property of the elements as this is happening or not. Heisenbug! If we observe it, it ends up doing what Firefox does (E) instead of C.

That gives us a big clue as to what's happening in Chrome. But let's start with the easy ones:

IE11

It would appear that IE11 just leaves the flags alone, probably because it doesn't worry about updating them while the JavaScript code is running, only when it yields back and IE updates its UI.

Firefox

Firefox makes sense if it's proactive about setting the selected flag on an option when there are none selected in the box (because we removed the selected one):

  1. We remove A, B remains selected.
  2. We remove B, C becomes selected because B isn't in the box anymore and the box needs to have at least one selected option. The removed option's flag remains set, which is important in a minute
  3. Step 2 repeats when we remove C and D.
  4. We remove E. At this point, C, D, and E all have the flag set.
  5. We add A to the box. Since it's the only option the box has, it gets its selected flag set.
  6. We add B to the box. Since B's selected flag is set, it becomes the selected property and A is de-selected.
  7. Step 6 repeats as we add C, D, and E back.
  8. We end up with E selected.

We can watch IE11 and Firefox happen, since they don't change their behavior depending on whether we observe them:

var $opts = $("#sel option");
showOptions("Before", $opts);
$opts.each(function() {
  var opt = $(this);
  opt.remove();
  showOptions("After removing " + opt.text(), $opts);
});
$opts.each(function() {
  var opt = $(this);
  $("#sel").append(opt);
  showOptions("After appending " + opt.text(), $opts);
});
function showOptions(label, $opts) {
  var row = $("<tr>");
  row.append($("<td>").text(label).addClass("label"));
  $opts.each(function() {
    row.append($("<td>").text(this.selected ? "*" : "o"));
  });
  row.appendTo("#results");
}
th, td {
  min-width: 3em;
  text-align: center;
  padding: 4px;
}
table, th, td {
  border-collapse: collapse;
  border: 1px solid #eee;
}
.label {
  text-align: left;
}
<select id="sel">
    <option>A</option>
    <option selected="selected">B</option>
    <option>C</option>
    <option>D</option>
    <option>E</option>
</select>
<table>
  <thead>
    <tr>
      <th></th>
      <th>A</th>
      <th>B</th>
      <th>C</th>
      <th>D</th>
      <th>E</th>
    </tr>
  </thead>
  <tbody id="results">
  </tbody>
</table>
* = selected, o = not selected

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>

Chrome

Which brings us to the hard one: Chrome. Chrome changes its behavior based on whether we observe the selected property as we go. Specifically, it changes its behavior if we observe selected on the options that are still in the select box as we remove them. It doesn't care if we look at selected on the removed ones, and it doesn't care whether we look at selected as we're appending. Just as we're removing, and just the ones we haven't removed yet.

First, here's proof:

$("#btn").on("click", function() {
  var observeLTE = true;
  var observeGT = true;
  var index;
  var $opts = $("#sel option");
  showOptions("Before", $opts);
  observeLTE = $("#cbremove")[0].checked;
  observeGT = $("#cbremoveun")[0].checked;
  $opts.each(function(i) {
    var opt = $(this);
    index = i;
    opt.remove();
    showOptions("After removing " + opt.text(), $opts);
  });
  observeLTE = observeGT = $("#cbappend")[0].checked;
  $opts.each(function(i) {
    var opt = $(this);
    index = i;
    $("#sel").append(opt);
    showOptions("After appending " + opt.text(), $opts);
  });

  function showOptions(label, $opts) {
    var row = $("<tr>");
    row.append($("<td>").text(label).addClass("label"));
    $opts.each(function(i) {
      var selected;
      if ((observeLTE && i <= index) || (observeGT && i > index)) {
        selected = this.selected ? "*" : "o";
      } else {
        selected = "-";
      }
      row.append($("<td>").text(selected));
    });
    row.appendTo("#results");
  }
});
th, td {
  min-width: 3em;
  text-align: center;
  padding: 4px;
}
table, th, td {
  border-collapse: collapse;
  border: 1px solid #eee;
}
.label {
  text-align: left;
}
<div>
  <label>
    <input id="cbremove" type="checkbox">Observe <code>selected</code> on removed options while removing
  </label>
</div>
<div>
  <label>
    <input id="cbremoveun" type="checkbox">Observe <code>selected</code> on UNremoved options while removing
  </label>
</div>
<div>
  <label>
    <input id="cbappend" type="checkbox">Observe <code>selected</code> while appending
  </label>
</div>
<select id="sel">
  <option>A</option>
  <option selected="selected">B</option>
  <option>C</option>
  <option>D</option>
  <option>E</option>
</select>
<input id="btn" type="button" value="Go">
<table>
  <thead>
    <tr>
      <th></th>
      <th>A</th>
      <th>B</th>
      <th>C</th>
      <th>D</th>
      <th>E</th>
    </tr>
  </thead>
  <tbody id="results">
  </tbody>
</table>
* = selected, o = not selected, - = not observed

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>

If you give that a spin (on Chrome, obviously), you'll notice that you end up on C regardless of what you do with the first or last checkbox, but checking the middle checkbox makes you end up on E.

That gives us enough data to infer an explanation. I can't say it actually is the explanation, just that it fits the available data. If someone wanted to be more thorough, they could step through Chrome's internal code in a debug build. I decided that was overkill.

So here's my answer, which I stress again is inferred:

  1. Chrome sets the selected property on the HTMLOptionElement instance lazily. That is, if you don't read the property's value, Chrome doesn't check to make sure that the property reflects the current state of things.
  2. It reads the selected property when queuing a change event.
  3. It doesn't queue a change event if one is already pending for that select box.

Based on that, here's what I think happens in Chrome:

  1. We remove A, B remains selected. No change event because the value hasn't changed.
  2. We remove B, C becomes selected because B isn't in the box anymore and the box needs to have at least one selected option. This queues a change event, which observes selected and thus sets the property on C.
  3. We remove C. D becomes selected (in some sense), but because there's a change event pending, no new change event is queued, and so D's selected isn't observed and its value isn't updated; it remains false even though if we read it, it would turn true.
  4. Same again when removing D. No change event, no observation.
  5. We end up with the option elements having selected as true on B and C, but none of the others, because D and E weren't observed while they were the selected option and didn't update their value. We know those are their values because we're allowed to observe them at that point.
  6. We add back the elements in order, and as with Firefox, adding an element that has selected = true when another element is already selected deselects the previous element. So B is deselected when C is added. C is left alone when we add D and E because their selected property is false.

Whew, that was fun to figure out.

like image 98
T.J. Crowder Avatar answered Nov 15 '22 03:11

T.J. Crowder