With a key function, data() runs bindKey in two linear passes: it maps each existing element's key to the element (a repeated key sends it to exit); then each datum whose key is in the map becomes update and is removed, and each missing key an enter placeholder. Leftovers exit. join() ends with order():
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<ul><li>harbor</li><li>deepweb</li><li>saffron</li></ul>
<script>
d3.selectAll('li').datum(function () { return this.textContent; }); // key the old elements
const log = (label, sel) => console.log(label, sel.data().join(' ') || '(none)');
d3.select('ul').selectAll('li').data(['saffron', 'summit', 'harbor', 'harbor'], d => d)
.join(enter => { log('enter: ', enter); return enter.append('li').text(d => d); },
update => { log('update:', update); return update; },
exit => { log('exit: ', exit); return exit.remove(); });
console.log('DOM: ', d3.selectAll('li').nodes().map(n => n.textContent).join(' '));
</script>enter: summit harbor update: saffron harbor exit: deepweb DOM: saffron summit harbor harbor
The second harbor finds its key taken and enters. Without a key, bindIndex pairs by position, so reordered data rewrites attributes instead of moving elements.