-
Notifications
You must be signed in to change notification settings - Fork 46
Description
I think there is a need for a priority that's higher than "user-blocking" for certain DOM rendering cases.
This priority would be synchronous wrt the outermost scheduler.postTask()
call, but nested tasks would have the same ordering as they do now: nested tasks would run after their parent task's body, but before the parent's postTask()
returns.
Motivation
Often you need to update DOM and coordinate among multiple actors in parent/child relationships - one of the original use cases for the scheduling API. Sometimes the coupling between parents and children is very loose. The parent doesn't call an API on the child to get it to update it's DOM, but it may cause one or more state changes that the child reacts to by updating.
The requirements on this kind of loosely-couple system are:
- Batching: Each component may react to multiple state changes, but should run their side effects in one task.
- Ordering: Parents should run before the children. Parents may cause multiple state changes on children, and the each child should only run it's side effects once due to batching.
Using either the current scheduling API, or just relying on microtasks, you can do a pretty good of getting parent -> child rendering order and batching. Each component adds it's update task to the microtask queue. In that update task they create and modify children. The children then schedule their own update tasks in response to those changes, which are added to the queue. Eventually the whole tree of components has added and executed their tasks in top-down tree order, and the update is complete.
This works pretty well, if you can live with the asynchronicity.
There are two main problems though:
-
Asynchronicity in general. Many use cases call for being able to update DOM and synchronously rely on the changes. These cases are often related to measurement, events, and shadow DOM slotting.
Some cases where async is problematic:
- Virtualization libraries rely on being able to measure the height of elements they are laying out. Some libraries assume sync updates of DOM being laid out. Others may be able to handle async, but don't have a standard way of waiting for the update to finish.
- Events fired from slotted elements. The event path can change after rendering, so if rendering isn't synchronous, events from slotted children could be missed by listeners set by the update/render.
For example, with this HTML:
<parent-element> <child-element></child-element> </parent-element>
The
<parent-element>
may render a shadow root with a<slot>
and an event listener on that<slot>
(or a container of it). If the child first an event synchronously, the parent won't have rendered the slot and added the event listener, and will miss the event. -
Knowing when a tree of updates have completed. There is no way to know when the microtask queue has been fully flushed. Users of components often want to do something with the component, and even if they can run in an async context, have a hard time knowing when the component is finished updating.
const el = document.querySelector('my-element'); // This assignment will cause the element to update, which could cause any number of children to also update, // each in their own microtask. el.foo = '123'; // The only thing we can await to be sure the whole tree of components and their microtasks have competed is a task or rAF: await new Promise((res) => requestAnimationFrame(res)); el.offsetHeight;
Being able to have the outer task be run synchronously would mean that this construction is always safe:
<parent-element>
<child-element></child-element>
</parent-element>
and component users could do this:
el.foo = '123';
el.offsetHeight;
Something that native elements can do.
Examples
Here's an example of two functions that produce tasks and the timing that would be ideal:
const A = () => {
scheduler.postTask(() => {
console.log('A:1');
B();
console.log('A:2');
}, {priority: "user-blocking"});
};
const B = () => {
scheduler.postTask(() => {
console.log('B:1');
}, {priority: "user-blocking"});
};
console.log('start');
A();
console.log('end');
With user-blocking priority, this produces the log:
start
end
A:1
A:2
B:1
Ideally, we would produce this log:
start
A:1
A:2
B:1
end
Hazards
I presume some people will have the immediate reaction of thinking that a sync API is too hazardous - that it would encourage the read/write striping that can cause a lot of blocking layouts. I think this is somewhat true, but modern DOM rendering libraries have encouraged a structure of code and declarative templates that largely eliminate this problem. Yet those rendering libraries often have their own internal schedulers that can schedule updates exactly as described here: synchronous to the outermost layer, batched and tree-ordered within. I think some frameworks will need the scheduler API to support that to migrate without breaking assumptions their consumer make, and more decoupled components, like web components, don't yet have a sync centralized scheduler they could rely on.
Possible implementation strategies
Nanotask queue
One way to implement this is with another queue that's flushed before the postTask()
call returns. Many years ago this was discussed as a "nanotask" queue. Today we have a similar queue in the custom elements reaction queue. That queue could be generalized to support this kind of task.
Using a queue would sidestep the need to track task ownership and the parent/child relationships.
Ownership tracking
Another strategy is to do explicit task ownership tracking. Each task would have it's own list of child tasks and wait for them to be completed before returning.
The most powerful version of this approach would be one where the task tree can be a sparse subset of the tree of objects that own the tasks and that any set of pending tasks is run in top-first order. This is also very similar to how some framework schedulers work.
The benefit of this approach is that it can handle cross-tree updates optimally.
There are a lot of data-management patterns where multiple components may be notified of data changes. In response to those changes components update, and often propagate changes down the tree. What you want to avoid is a child updating before its parent, the the parent's update triggers a second update on the child.
This would solve the tree-aware task scheduler issue I opened in WICG/webcomponents#1055