bikeshaving / crank

The Just JavaScript Framework
https://crank.js.org
MIT License
2.69k stars 76 forks source link

Precise DOM mutations #242

Closed brainkim closed 8 months ago

brainkim commented 1 year ago

Lately, I’ve been working on a contenteditable-based editor library, and one of the interesting challenges about working with these types of editors is that when you re-render, DOM nodes may be mutated in a way that the browser didn’t intend. Specifically, the behavior of selections, the cursor that moves around as you type, is fragile, and mutations around the selection will often cause the cursor to appear somewhere the user didn’t intend, like moving to the beginning of the line that you’re editing. For text editors, this is pretty much a show-stopping bug, in the sense that it is really annoying and disorienting for the user whenever it happens.

The solution to this species of bug is to read the selection before DOM mutations happen, and write this selection back after DOM mutations have finished. However, the “before” and “after” here is the problem, in the sense that in an ideal world, the reading and writing would happen in the same synchronous block of code: read selection, make mutations, write selection. Any time I tried to split up the read/write logic based on things like requestAnimationFrame() or some other asynchronous timing, the result has been soul-crushing race conditions, because there is only a small window of time where the correct selection can be read. Inevitably, if you try to split up this read/mutate/write logic you end up with the same selection bugs that you were trying to prevent.

Luckily, Crank is always synchronous for component trees which only contain synchronous components, so this selection fixing logic is easy to implement. The editor I’m currently playing with has all editable content defined as a single, sync generator component, which only has sync function components for children.

Unfortunately, this sort of constraint doesn’t lend itself well to the building of a “library,” or in this case, reusable utility functions or components which do the selection fixing logic automagically. Ideally, the components you define should be composable; you should be able to call a stateful component from a stateless component and vice-versa, you shouldn’t have to worry about child components being independently stateful. The situation gets even hairier when you have components with dynamic, transcluded children, in which case you wouldn’t know what kinds of child components might be passed in. Requiring declarative editors to be defined exclusively with a top-level sync generator component with no async or stateful child components, violates this ideal.

One possible way to mitigate this is to define a contract where, every stateful component which plays a part in the editable area has to call the selection fixing logic independently. However, I realized that when to read the selection as part of the ”read/mutate/write” sandwich is non-trivial. For the case of a sync generator component with sync children, you can simply read the selection when the generator function is actually executing, because everything is nice and synchronous. But the story gets more complicated when working with async components, because suddenly you’re reading stuff microtasks away from when the DOM is actually mutated in the best case, and possibly more time when components contains async children, because we defer the initial DOM mutations until those async children have resolved. Additionally, putting the reading of the selection right before a component yields or returns in the case of async components is annoying, insofar as components can have multiple yields/returns.

We need a callback, probably the one passed to this.schedule(), which fires synchronously right before a component performs the DOM mutations the component is responsible for. Unfortunately, the timings of when schedule() fires hasn’t been that accurate, especially when working with async components.

There is also a matter of what actual DOM mutations a component is “responsible” for. For instance, in the following component, the outer <div> seems like it’s the responsibility of the parent, and Crank can make the guarantee that framework-caused mutations to the outer div will only happen if the <Parent> component is refreshed or updated.

function Parent({children}) {
  for ({children} of this) {
    yield (
      <div class="outer">
        <Child>
          <div class="inner" />
        </Child>
        {children}
      </div>
    );
  }
}

On the other hand, the inner <div> is passed into the <Child> component as its children, and it might be added or removed based on the whims of the child component, so clearly the parent component can’t be solely responsible for changes to that inner <div>. And what do we do about dynamic children? This is all a bit of a headache.

Ultimately, it seems like whether a component is responsible for its descendant DOM mutations is based on whether or not there is another component between the root and the virtual host element. The problem is that, internally, we call DOM mutating functions independently for every element, and not based on when components commit, so I’m not actually sure that it works out Crank has a strict single responsibility rule for every DOM mutation being done in a single block for a single component (or the root if the rendering was initiated by renderer.render()).

This was rambling and probably makes sense only to me, but I think I can sum up this desire as the following three guarantees for Crank:

  1. The this.schedule() callback should fire synchronously before a component’s mutations occur, regardless of whether a component is sync or async.
  2. All DOM nodes which a component is responsible for, as defined by host element children which are not themselves the descendants of other components, should be mutated in a single synchronous pass. Crank should cause mutations to elements a component is responsible for if and only if that component is updated or refreshed. This extends to attribute/property mutations as well as DOM nodes being added and removed. It might not apply to when DOM nodes are created. Ideally, we should create DOM nodes as soon as possible, so that we can make them available, for instance, to the schedule() callback. All mutations which happen to DOM nodes that are not attached to the current document are probably fine.
  3. The this.flush() callback should fire synchronously after a component’s mutations occur, regardless of whether a component is sync or async.

It may already be the case that this logic is implemented, in which case this is all a matter of testing. I think the idea of components being responsible for, or “owning,” DOM nodes and mutations is appealing, not just for the niche text editor use-case, but also for making sure that the DOM is consistent view of state, and for sourcing potential errors caused by DOM mutations. It would be nice if, for instance, an error caused by a DOM mutation of a readonly property could somehow be linked to the component which is “responsible” for the error. It might also help with performance too, who knows.

brainkim commented 8 months ago

I’m doing astronaut analysis here and I don’t really understand what I’m writing. Converting this to a discussion.