Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
justinfagnani opened this issue Oct 14, 2024 · 1 comment
Open

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

justinfagnani opened this issue Oct 14, 2024 · 1 comment

Comments

@justinfagnani
Copy link

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

@mmocny
Copy link
Collaborator

mmocny commented Oct 15, 2024

The last use case at the very end of your comment clarifies your motivations best for me: WICG/webcomponents#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).


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants