whatwg / dom

DOM Standard
https://dom.spec.whatwg.org/
Other
1.55k stars 286 forks source link

Atomic move operation for element reparenting & reordering #1255

Open domfarolino opened 5 months ago

domfarolino commented 5 months ago

What problem are you trying to solve?

Chrome (@domfarolino, @noamr, @mfreed7) is interested in pursuing the addition of an atomic move primitive in the DOM Standard. This would allow an element to be re-parented or re-ordered without today's side effects of first being removed and then inserted.

Here are all of the prior issues/PRs I could find related to this problem space:

Problem

Without an atomic move operation, re-parenting or re-ordering elements involves first removing them and then re-inserting them. With the DOM Standard's current removal/insertion model, this resets lots of state on various elements, including iframe document state, selection/focus on <input>s, and more. See @josepharhar's reparenting demo for a more exhaustive list of state that gets reset.

This causes lots of developer pain, as recently voiced on X by frameworks like HTMX, and other companies such as Wix, Microsoft, and internally at Google.

This state-resetting is in part caused by the DOM Standard's current insertion & removal model. While well-defined, its model of insertion and removal steps has two issues, both captured by https://github.com/whatwg/dom/issues/808:

  1. Undesirable model: The current DOM Standard allows for the non-atomic insertion of multiple nodes at a time. In practice, this means when appending e.g., a DocumentFragment, script can run in between each individual child insertion, thus observing DOM state before the entire fragment insertion is complete.
  2. Interop issues: While Safari matches the spec, Chromium & Gecko have a model that ensures all DOM mutations are synchronously performed before any script runs as a result of the mutations.

What solutions exist today?

One very limited partial solution that does not actually involve any DOM tree manipulation, is this shadow DOM example that @emilio had posted a while back: https://github.com/whatwg/html/issues/5484#issuecomment-620481794 (see my brief recreation of it below).

Screen Recording 2024-01-29 at 5 00 26 PM

But as mentioned, this does not seem to perform any real DOM mutations; rather, the slot mutation seems to just visually compose the element in the right place. Throughout this example, the iframe's actual parent does not change.


Otherwise, we know there is some historical precedent for trying to solve this problem with WebKit's since-rolled-back "magic iframes". See https://github.com/whatwg/html/issues/5484#issuecomment-619642936 and https://bugs.webkit.org/show_bug.cgi?id=13574#c12. We believe that the concerns from that old approach can be ameliorated by:

How would you solve it?

Solution

To lay the groundwork for an atomic move primitive in the DOM Standard, we plan on resolving https://github.com/whatwg/dom/issues/808 by introducing a model desired by @annevk, @domfarolino, @noamr, and @mfreed7, that resembles Gecko & Chromium's model of handling all script-executing insertion/removal side-effects after all DOM mutations are done, for any given insertion.

With this in place, we believe it will be much easier to separate out the cases where we can simply skip the invocation of insertion/removal side-effects for nodes that are atomically moved in the DOM. This will make us, and implementers, confident that there won't be any way to observe an inconsistent DOM state while atomically moving an element, or experience other nasty unknown side-effects.

The API shape for this new primitive is an open question. Below are a few ideas:


Compatibility issues here take the form relying on insertion/removal side-effects which no longer happen during an atomic move. They vary depending on the shape of our final design.

  1. With a new DOM API/setting that developers have to affirmatively opt-into, you could atomically move fragments/subtrees constructed by other library code that's unaware it's being atomically moved. Those fragments may be built in a way that relies on non-atomic move side-effects (though we haven't heard of such concerns directly yet).
  2. Consider an element attribute that changes the behavior of all DOM mutation APIs to behave atomically on already-connected nodes in its subtree. You could minimize compat concerns by externally-constructed portions of the subtree to opt-out of atomic moves with the same attribute. But what would that mean exactly, to have part of a subtree move atomically and part of it not?

A non-exhaustive list of additional complexities that would be nice to track/discuss before a formal design:

Anything else?

No response

jarhar commented 5 months ago

你的邮件我已收到,谢

WebReflection commented 5 months ago

First of all, thank you! I've been vocal about this issue about forever and part of one of the biggest discussions you've linked.

As author of various "reactive" libraries and somehow veteran of the "DOM diffing field", I'd like to add an idea:

The API shape for this new primitive is an open question. Below are a few ideas:

I understand a node can be moved from <main> to an <aside> element and this proposal should still work but I think we should not discard the Range API:

On top of this I hope whatever solution comes to mind works well with DOM diffing, so that new nodes can even pass through the usual DOM dance when the parent is changed or they become live, removed nodes that won't land anywhere else would eventually invoke disconnectedCallback if Custom Elements, but nodes already present in that container and moved around basically do nothing in terms of state, they are just shuffled in the layout, if they do.

As quick idea to eventually signal a node is going to be moved in an atomic way, and assuming it's targeting also a live parent, I think something like parent.insertBeforeAtomic(node[, reference]) could be an interesting approach to consider as that basically solves everything, from append to prepend to any other case insertBefore works wonderfully well and it hints that such node should:

As insertBefore covers append, appendChild, prepend, before and after with ease, it might be the easiest starting point to have something working and useful for the variety of virtual fragments based solutions and diffing APIs out there.

I hope this answer of mine makes sense and maybe trigger some even better idea / API.

edit on after thoughts another companion of the API should be reflected in MutationObserver, or better, MutationRecord ... so far we have addedNodes and removedNodes but nothing about movedNodes which will still be desired for most convoluted edge cases.

The movedNodes record will contain, beside of course the target, a from parent container and a to parent container which might be the same if moved internally but it would signal previous parent and new parent otherwise that something different is within their content.

1cg commented 5 months ago

This would be a fantastic addition of functionality for web development in general and for web libraries in particular. Currently if developers want to preserve the state of a node when updating the DOM they need to be extremely careful not to remove that node from the DOM.

Morphing (https://github.com/patrick-steele-idem/morphdom) is an idea that has developed around addressing this. I have created an extension to the original morphdom algorithm called idiomorph (https://github.com/bigskysoftware/idiomorph/) and the demo for idiomorph shows how it preserves a video in a situation when morphdom cannot. 37Signals has recently integrated idiomorph into Turbo 8 & Rails (https://radanskoric.com/articles/turbo-morphing-deep-dive-idiomorph)

If you look at the details of the idiomorph demo you will see it's set up in a particular way: namely, the video cannot change the depth in the DOM at which it is placed, nor can any of the types of the parent nodes of the video change. This is a severe restriction on what sorts of UI changes idiomorph can handle. With the ability to reparent elements idiomorph could offer much better user experience, handling much more significant changes to the DOM without losing state such as video playback, input focus, etc.

Note that it's not only morphing algorithms like idiomorph that would benefit from this change: nearly any library that mutates the DOM would benefit from this ability. Even virtual DOM based libraries, when the rubber meets the road, need to update the actual DOM and move actual elements around. This change would benefit them tremendously.

Thank you for considering it!

smaug---- commented 4 months ago

Anything else?

Add some complexity to selection/range: how to deal with Shadow DOM when the host moves around and selection is partially in shadow DOM?

ydogandjiev commented 4 months ago

This is a very exciting proposal! In the Microsoft Teams Platform, we extensively use iframes to host embedded apps in the Teams Web/Desktop Clients. When a user navigates away from an experience powered by one of these embedded apps and comes back to it later, we provide the ability for them to keep their iframe cached in the DOM (in a hidden state) and then re-show it later when it's needed again. To implement this functionality, we had to resort to creating the embedded app frames under the body of our page and absolute position them in the right place within our UX. This approach has lots of obvious disadvantages (e.g. breaks the accessibility tree, requires us to run a bounds synchronization loop, etc.) and the only reason we had to resort to it was because moving the iframe in the DOM would reload the embedded app from scratch thus negating any benefits of caching the frame. This proposal would allow us to implement a much more ideal iframe caching solution!

Note the location of the iframe in the DOM and its absolute positioning in this recording: https://github.com/whatwg/dom/assets/3357245/7fd4d2a7-2c2d-4bed-9a78-9c60f26a42f4

infogulch commented 4 months ago

The WHATNOT meetings that occurred after this issue was created deferred discussion about the topic. I wonder what next steps would be needed to move this issue forward. The next meeting is on March 28 (#10215).

noamr commented 4 months ago

The WHATNOT meetings that occurred after this issue was created deferred discussion about the topic. I wonder what next steps would be needed to move this issue forward. The next meeting is on March 28 (#10215).

I hope we can get to it in the 28.3 WHATNOT. @domfarolino @past ?

past commented 4 months ago

It's already on the agenda, so if the interested parties are attending we will discuss this.

iteriani commented 4 months ago

Are the imperative and declarative APIs meant to slowly replace the existing APIs over time? Or do we need to choose between one or the other because of potential overhead?

noamr commented 4 months ago

Are the imperative and declarative APIs meant to slowly replace the existing APIs over time? Or do we need to choose between one or the other because of potential overhead?

If I understand the question, it's mainly for backwards compatibility. In some cases you might want the existing behavior or something subtle in your app relies on it, so we can't just change it under the hood.

sebmarkbage commented 4 months ago

This would be very nice for React since we currently basically just live with things sometimes incorrectly resetting. A couple of notes on the API options:

The thing that does causes a change is the place where the move happens. But even then it's kind of random which one gets moved and which one implicitly moves by everything around it moving. We don't remove all children and then reinsert them. So sometimes things preserve state.

A new API for insertion/move seems like a better option.

We'd basically like to just always the same API for all moves - which can be thousands at a time. This means that this API would have to be really fast - similar to insertBefore. An API like append(node, {atomic: true}) doesn't seem good because the allocation and creation of potentially new objects and reading back the value from C++ to JS isn't exactly fast. Since this is a high performance API, this seems like a bad option.

Something new like replaceChildAtomic would be easy to adopt inside a library and faster.

rniwa commented 4 months ago

One thing that's nice to nail down is whether re-ordering of child nodes is enough or we need to support re-parenting (i.e. parent node changing from one node to another). Supporting the latter is a lot more challenging than just supporting re-ordering.

1cg commented 4 months ago

Definitely would prefer full re-parenting. I gave an htmx demo of an morph-based swap at Github where you could flip back and forth between two pages and a video keeps working:

https://www.youtube.com/watch?v=Gj6Bez2182k&t=2100s

The dark secret of that demo was that I had to really carefully structure the HTML in the first and second pages to make sure that the video stayed at the same depth w/ the same parent element types to make the video playing keep working. Would be far better for HTML authors if they could change the HTML structure entirely, just build page 1 the way they want and build page 2 the way they want, and we could swap elements into their new spots by ID.

domfarolino commented 4 months ago

(For the purpose of brevity, I will begin using the SPAM acronym that we've been toying around with internally, which means "state-preserving atomic move". The most obvious example is an iframe that gets SPAM-moved doesn't lose its document or otherwise get torn down).


  • Associating with a subtree [...] Basically for a React app we'd just add it to the entire document, but that also affects any subtrees embedded inside the document [...]. It forces us to basically break the whole app to opt into it.

The thing that does causes a change is the place where the move happens. [...] A new API for insertion/move seems like a better option.

@sebmarkbage I understand your hesitation around a new subtree-associated-HTML-attribute — in that it would be over-broad, affecting tons of nested content that a framework might not own, possibly breaking parts of an app that doesn't expect SPAM moves to happen. But I'm curious if a new DOM API really gets you out from under that over-broadness, while still being useful? What would you expect orderedList.replaceChildAtomic(newListItem, oldListItem) to do, where newListItem is an <li> with a bunch of app-specific (not framework-owned) child content, including <iframe>s?

I guess I had in mind that the imperative API would force-SPAM-move the "state-preservable" elements in the subtree that's moving, so that any nested iframes do not get their documents reset[^1]. But if that API would not preserve nested iframe state, then the only way it would be possible to actually preserve that iframe's state in this case is if the application took care to apply an iframe-specific HTML attribute to it, specifying that it opts into SPAM moves:

  • Associating with the node that gets moved e.g. an option on the