WICG / scheduling-apis

APIs for scheduling and controlling prioritized tasks.
https://wicg.github.io/scheduling-apis/
Other
908 stars 45 forks source link

A near-synchronous priority higher than "user-blocking" #112

Open justinfagnani opened 6 days ago

justinfagnani commented 6 days ago

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:

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:

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 https://github.com/WICG/webcomponents/issues/1055

mmocny commented 5 days ago

The last use case at the very end of your comment clarifies your motivations best for me: https://github.com/WICG/webcomponents/issues/1055.

As you say, there are many framework schedulers that do this, and, perhaps some primitives are needed to help build those. My gut reaction is that just a platform "task scheduler" is not really the right mechanism for accomplishing these goals-- but it certainly seems worthy of exploring.

Do proposals like Signals or Observables also help address these use cases (perhaps more directly)?

Those at least have similarities:

priority would be synchronous wrt the outermost scheduler.postTask() call

and

coordinate among multiple actors in parent/child relationships

...But those can more directly model some of the problems of Batching and Ordering than just pure opaque task scheduling can. You specifically point out problems of:

Parents may cause multiple state changes on children, and the each child should only run it's side effects once due to batching.

...and this implies that parents would want to abort and/or adjust scheduled tasks as triggered effects change... This sounds like a higher order problem. A solution to that problem might need to leverage some missing task scheduling primitives.


Some of your examples (Virtualization libraries, reading layout props after render complete) seem to me to risk being antipatterns (with lots of layout thrashing) if implemented incorrectly.

You show an example of using requestAnimationFrame as the only existing mechanism to synchronize-- but frame-aligned layout effects seem like a good pattern, no? There have been calls for requestPostAnimationFrame which might be better suited for some of this.

I think these use cases should be considered, and are related to { waitForRender: true } (aka scheduler.render()) proposal.


In your "examples" section:

Ideally, we would produce this log:

I think the order of your example, even with the tree-aware-scheduling, would actually have been:

start
end
A:1
A:2
B:1

...In other words, the contents of the A() postTask should only start to execute after the current task yields. I think any other "implicit behaviour" such as automatically starting to run the outer-most postTask would be... surprising.

However, we've heard requests for something like a TaskController.flush() api, which might fit here.

If you register tasks with a custom TaskController, you allow the platform to schedule those as distinct "macrotasks" at some given priority. But with a theoretical flush() you can effectively get handles back to the original callbacks and effectively just force a call to them. Potentially this needs to happen recursively (as we do for microtask queues).

(The original use-case there was for document unloading type use cases, and other idleUntilUrgent patterns).