Skip to content

Yielding to the event loop (in some way) between module evaluations #4400

@domenic

Description

@domenic

This is an issue to discuss a proposed, but still vague, normative change to module evaluation. This would be done in collaboration with TC39 (although it isn't strictly dependent on any changes to ES262).

If you want extra background, please read JS Modules: Determinism vs. ASAP. In terms of that document, the status quo is the super-deterministic strategy. This thread is to explore moving to (some form of) a deterministic-with-yielding strategy. We're not considering the ASAP strategy.

See also some discussion related to top-level await: tc39/proposal-top-level-await#47 (comment)

If you don't want to read those background documents, here's a summary. Currently module evaluation for a single module graph (i.e. a single import() or <script type=module>) happens as one big block of synchronous code, bound by prepare to run script and clean up after running script. The event loop does not intervene in between, but instead resumes after the entire graph has evaluated. This includes immediately running a microtask checkpoint, if the stack is empty.

Side note: for module scripts, the stack is always empty when evaluating them, because we don't allow sync module scripts like we do classic scripts.

This sync-chunk-of-script approach is simple to spec, but it has two problems:

  • Arguably, violating developer expectations. If you have two classic scripts, <script></script><script></script>, a microtask checkpoint will run between them, if the stack is empty. (And maybe sometimes more event loop tasks, since the parser can pause any time?) Example. But if you have two module scripts, import "./1"; import "./2";, right now we do not run a microtask checkpoint between them.

    Stated another way, right now we try to sell developers the intuitive story "microtask checkpoints will run when the stack is empty." But, between evaluating two module scripts, the stack is definitely empty---and we don't run any microtask checkpoints. That's confusing.

  • It increases the likelihood of jank during page loading, as there is a large block of script which per spec you cannot break up the evaluation of. If it takes more than 16 ms, you're missing a frame and blocking user interaction.


The proposal is to allow yielding to the event loop between individual module scripts in the graph. This will help developer expectations, and will potentially decrease jank during page loading---although it will not help total-time-to-execute-script, it just smears it across multiple event loop turns.

What form of yielding does this look like, exactly? A few options.

  • Run a microtask checkpoint only.
    • This helps the top-level await case, if you followed the link to that thread.
    • This helps developer expectations about microtask checkpoints happening on an empty stack.
  • Yield to the event loop by queuing a task for the next module evaluation
    • If we go beyond just microtask checkpoints, this is the most idiomatic approach
    • We could add a dedicated task source for this which would allow browsers to prioritize it appropriately: e.g. they could always prioritize this task source first, to give something similar to the current semantics, or always prioritize it last, which is equivalent to draining the task queue entirely before doing any module evaluation
  • Allow the browser to choose between just-microtask-checkpoint, and queuing a task
    • The difference here is that queuing a task, even on the highest-priority task source, unavoidably brings in other steps. The big ones I see are long-tasks reporting and rendering every 60 Hz (including animation-frame callbacks/intersection observers/resize observers).
    • This is meant to allow implementers to more flexibly tradeoff against the costs of going from JS engine -> event loop and back, especially for large graphs of smaller modules, where too many such transitions might be costly.
    • This kind of optional behavior is kind of bad in general though.

Note that with top-level await, we'll likely end up yielding to the full event loop anyway at some point, e.g. if you do await fetch(x) at top level, a certain subgraph of the overall graph will not execute until the networking task comes back and fulfills the promise. (This also means a module can always cause a yield to the event loop with await new Promise(r => setTimeout(r)) or similar.)

I think the biggest option questions are:

  • Is this a good idea?
  • How should we reason about the expense of the "full event loop" vs. just microtask checkpoints?

/cc @whatwg/modules, @yoavweiss

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions