Skip to content

Render Aware Scheduler Interface - #957

Open
runspired wants to merge 9 commits into
emberjs:mainfrom
runspired:modernized-scheduler
Open

Render Aware Scheduler Interface#957
runspired wants to merge 9 commits into
emberjs:mainfrom
runspired:modernized-scheduler

Conversation

@runspired

@runspired runspired commented Sep 9, 2023

Copy link
Copy Markdown
Contributor

Rendered

This RFC Proposes replacing @ember/runloop (Backburner.js) with an interface for common scheduling needs.

The interface describes intent for when work should be performed in relation to the native event queues and render cycle of the browser. The details of how that work is scheduled and flushed are up to the specific scheduler implementation, allowing for experiments in this space.

Additionally, this RFC proposes deprecations and alterations to associated async primitives such as RSVP to support this exploration.

@runspired runspired added T-ember-data RFCs that impact the ember-data library T-deprecation T-routing T-framework RFCs that impact the ember.js library T-testing T-fastboot RFCs that impact the fastboot library T-learning T-ember-cli RFCs that impact the ember-cli library S-Exploring In the Exploring RFC Stage labels Sep 9, 2023
@github-actions github-actions Bot added the S-Proposed In the Proposed Stage label Sep 9, 2023
@runspired runspired self-assigned this Sep 9, 2023
@runspired runspired removed the S-Proposed In the Proposed Stage label Sep 9, 2023
@kategengler

Copy link
Copy Markdown
Member

Moving to the Exploring stage requires the consensus of the relevant teams. See :https://github.com/emberjs/rfcs#exploring I am removing the label.

@kategengler kategengler added S-Proposed In the Proposed Stage and removed S-Exploring In the Exploring RFC Stage labels Sep 10, 2023
Comment thread text/0957-modernized-scheduler.md Outdated
Comment thread text/0957-modernized-scheduler.md Outdated
@NullVoxPopuli

Copy link
Copy Markdown
Contributor

What are the SSR impacts? (if we were to re-invest SSR in the most ideal way)

  • what is settled?
  • ignore idle? do we ignore it?
    -> flush idle queue?
    -> ensure flushed?
  • how to test with idle?

if you have a lot of timings / queues, how do you prevent folks from just arbitrarily adding things to whatever the "next queue" is?


how do we suggest / teach when folks will want to use one queue over others?

@wagenet wagenet added S-Exploring In the Exploring RFC Stage and removed S-Proposed In the Proposed Stage labels Sep 22, 2023
@wagenet

wagenet commented Sep 22, 2023

Copy link
Copy Markdown
Member

Given that @ef4 and @wycats are generally in favor of this direction we agreed at the RFC review meeting to move to Exploring.

@wycats

wycats commented Sep 23, 2023

Copy link
Copy Markdown
Member

Thumbs up. We got into the details in the spec meeting and I think this is absolutely ready for Exploring.

@ef4

ef4 commented Oct 13, 2023

Copy link
Copy Markdown
Contributor

Status update here: this is looking good, @runspired is leading experimentation in the implementation side as the next action before putting this forward as formal public API.

@runspired

runspired commented Mar 1, 2024

Copy link
Copy Markdown
Contributor Author

As an update: I ran AuditBoard's test suite replacing the RSVP flush with a native microtask instead of flushing in the runloop, and EmberData 4.12.x (which drops all RSVP and almost all runloop utilization). A few observations:

EmberData 4.12.x upgrade itself had a few timing things to work out, but it didn't take us too long to get through them despite ~12k tests, and they all fell into a bucket of bugs that wouldn't exist with the proposed scheduler.

RSVP: changing to flush with a microtask failed only 3 tests, all for an identical cause:
In a route afterModel we had this:

scheduleOnce('afterRender', this, () => {
  this.send('onSetBreadcrumb');
});

With RSVP flushing as a native microtask, this now runs after instead of before the completion handling of many promises associated with the transition. Were we to adopt this proposal though, this would have been a non-issue. It was also 1 shared cause for 3 test failures out of 12k tests that proved easy to fix.

I next tried removing our reliance on @ember/jquery to avoid its wrapping of xhr resolutions into the runloop. We rely on this because we set useFetch: false in all of our adapters. Notably: the default for EmberData has for all of 4.x been useFetch: true.

This resulted in dozens of test failures, possibly as many as a hundred. In debugging, it appears the reason for these failures is that tests that triggered async actions and ember-concurrency tasks were relying on the runloop initiated by jquery to count towards settledness. ember-concurrency itself makes widespread use of the runloop for most task related work. Combined, this means that await settled() and await render() and await click() and similar in tests capture a large body of async that might otherwise escape.

However, fixing this is as simple as ember-concurrency adding its own test waiters. EmberData already uses one, but there are some scenarios it intentionally does not cover (background requests). In these tests the cause was the request was being kicked off several promises deep from the triggering action and thus escaped the bounds of settled due to the missing EC waiter. The reason the jquery runloop usage papered over this was that it joined the next EC tick flush to the promise resolution in the test. Even if EC did not add a waiter, this is a class of issues that would not exist if we were to adopt the proposed RFC because the associated EmberData waiter would have been properly captured despite being multiple promises deep.

Current conclusions:

  • I see no significant blocker to continuing to explore this RFC
  • The biggest risk seems to be to tests not app code, and most prominently in locations where EC is in use
  • We should shore up EC before shipping a new scheduler to avoid friction there
  • apps using useFetch: false should really stop doing so ;)

Additional design note: it might be a nice thing to wrap the functions passed to the on modifier in tests with a waiter if they return a promise. This could avoid a lot of test-waiter creation.

@runspired
runspired force-pushed the modernized-scheduler branch from 75d5fe3 to 0a60be0 Compare March 2, 2024 03:08
@ef4

ef4 commented Mar 8, 2024

Copy link
Copy Markdown
Contributor

Feedback from RFC review discussion:

  • let's add detail to "how we teach this" explaining exactly what is deprecated and how it should appear in the deprecation guide and what people should be told about replacements
  • what's the experience in terms of when people will see deprecations, when should they flip the optional feature flag.
  • what is the rollout timeline? Which deprecations until 6.0? 7.0?

@MelSumner

Copy link
Copy Markdown
Contributor

Just leaving a comment that I am using runloop right now in https://github.com/ember-a11y/ember-a11y-refocus so please consider that with any proposed changes. Thank you!

@runspired

Copy link
Copy Markdown
Contributor Author

@MelSumner I looked, afterRender there could be refactored away in a few different ways but even if you left it as-is it would have a clean migration path to any of a number of the queues.

@johanrd

johanrd commented Nov 13, 2025

Copy link
Copy Markdown

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

an implementation emberjs/ember.js#21493

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

We should move this forward. It's vital for our perf

NullVoxPopuli added a commit that referenced this pull request Aug 4, 2026
Comment thread text/0957-modernized-scheduler.md Outdated
Comment thread text/0957-modernized-scheduler.md Outdated

## Unresolved questions

Is `@ember/scheduler` the right name? Or is there utility in a non-Ember associated name?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I partially think we do want an agnostic name here - but also we thought that when we did backburner and we never did get backburner to be used beyond ember.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

Implementation: emberjs/ember.js#21552

All additive, nothing to worry about with existing code

await idle();
```

### Migration Roadmap

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO:

  • explicitly write scheduling RFC comes first, then deprecate RSVP/runloop (people need a thing to migrate to)

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

From RFC meeting:

migration plan needs more thoroughness:

  • find a way to keep old addons from updating (provide a way to shim rsvp? and shim backburner and the runloop (using the new scheduler))
    • runloop re-implemented (via the optional-feature flag) to use the new scheduler
    • RSVP not used internally
      • same optional feature flag - Ember no longer uses rsvp
    • backburner
      • same optional feature flag - Ember no longer uses backburner

I think this enables the fast implementation of rendering (via the optional feature flag) to land a lot sooner, ahead of deprecating the things

Existing users know this problem space through `@ember/runloop`; the
deprecation guides for RFCs #1219 and #1220 teach by translation:

| Today | After this RFC's roadmap |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: spike a PR that makes the runloop use the scheduler (when the optional feature flag is on)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO:

Also, add a column for "behavior when feature flag is enabled"

@alexraputa

Copy link
Copy Markdown

Thanks for working on this. I read through the current #21552 implementation and the latest #21520 spike, and had a few questions about the public scheduler semantics.


1. Is Strategy intended to describe phase timing rather than scheduling the work itself?

The RFC already says:

the strategy has no knowledge of the work to be done

and the split in #21552 seems to reinforce that - phase one exposes the interface without connecting it to Ember's renderer.

Would it be useful to make that distinction a little more explicit?

Something like:

A Strategy controls when scheduler phase barriers become observable to callers, while the mechanism that determines what renderer work needs to happen is separate.

I think of the separation roughly like this:

flowchart LR
    State["Application / reactive state"]
    Renderer["Ember renderer"]
    DOM["DOM update"]

    Render["render()"]
    Layout["layout()"]
    Composite["composite()"]

    Strategy["Strategy"]

    State --> Renderer
    Renderer --> DOM
    DOM --> Render
    Render --> Layout
    Layout --> Composite

    Strategy -. "phase timing" .-> Render
    Strategy -. "phase timing" .-> Layout
    Strategy -. "phase timing" .-> Composite
Loading

Would that be a useful boundary to establish here, while leaving invalidation and renderer scheduling to the follow-up RFC?


2. What should render() mean when there is no pending render work?

Once Ember rendering is integrated with the scheduler, the renderer-related case seems fairly clear:

state.value = 123;

await render();

// DOM reflecting state.value should be observable here.

I'm less sure about the no-work case:

await render();

The current phase-one FrameStrategy in #21552 isn't connected to the renderer yet, but requesting a phase when no frame exists creates or joins an upcoming frame:

FrameStrategy._phase() / _ensureFrame() / _scheduleFrame()

The #21520 spike explores the renderer-integrated version of this case. Its strategy can request a tick even when there is no reactive dirt, and it has a fallback when no renderer can provide that tick:

RenderClockStrategy tick request / renderer tick handling

Should calling render() itself establish an upcoming render-phase opportunity, even when there is currently no pending renderer work?

And is the intended semantic distinction roughly:

render()
  = a render-phase barrier

render()
  != "render until the application is settled"

In other words, would a clean await render() still be expected to resolve at a render-phase opportunity rather than waiting for some unrelated future invalidation?


3. When renderer integration lands, should the scheduler phases and Ember rendering describe one logical render cycle?

This was the main question I had after comparing #21552 with the current spike.

In #21552, the default strategy owns its frame independently, which seems natural while the interface is intentionally not connected to Ember rendering:

FrameStrategy

The #21520 spike explores one possible end state where the renderer and scheduler share a clock.

The strategy accepts a tick requester and exposes _onRendererTick():

RenderClockStrategy

The renderer connects itself as the tick source here:

schedulerStrategy._setTickRequester(...)

and, after revalidation and the destroy drain, drives the phase windows here:

revalidate()_onRendererTick()

That experiment roughly gives us:

flowchart LR
    State["state mutation"]
    Work["Ember render work"]
    DOM["DOM updates + modifiers"]
    Render["render() phase"]
    Layout["layout() phase"]
    Composite["composite() phase"]

    State --> Work
    Work --> DOM
    DOM --> Render
    Render --> Layout
    Layout --> Composite
Loading

I don't think RFC necessarily needs to decide whether the renderer owns the clock, the strategy owns the clock, or how the underlying tick is scheduled.

But once the renderer is integrated, should these phases be understood as describing the same logical render cycle that produced the DOM state observed by render()?

Something like:

Ember rendering timeline
        +
scheduler phase timeline
        =
one logical render cycle

Would that be a useful public invariant to establish here, while leaving the mechanism used to achieve it to the later async-rendering work?


4. How is registerStrategy() intended to interact with the future renderer integration?

I had two related questions here.

Is the strategy runtime-global or application-scoped?

The current #21552 implementation keeps the registered strategy in module-level state:

registerStrategy() / getStrategy()

while the API is described as registering it while defining the Application.

Is the intended ownership effectively one strategy per loaded Ember runtime, shared by applications/renderers in that runtime?

If so, would it be useful to state that explicitly?

Does a custom strategy also become the renderer's phase policy?

The public scheduler functions resolve their strategy through:

registeredStrategy ?? defaultStrategy

while the experimental renderer integration currently connects directly to the imported default schedulerStrategy:

schedulerStrategy._setTickRequester(...)

That seems completely reasonable for a spike, and I'm not assuming that this split represents intended API semantics.

If an application eventually does this:

registerStrategy(customStrategy);

is that intended to replace the phase policy for both:

public @ember/scheduler phase functions

and:

future Ember renderer integration

or is registerStrategy() only intended to affect calls made through the public scheduler API?

Would this relationship be something useful to clarify in RFC, while leaving the actual renderer-to-strategy connection to the follow-up RFC?


5. Are the development-mode phase restrictions intended to be fully enforceable?

The phase table is useful:

Phase Write reactive state Read DOM Write DOM
tasks
render
layout
composite ⚠️ discouraged
next
idle

The RFC says violations of the ❌ rules error in development mode.

For reactive state changes, I can see how Ember could observe that.

I'm less sure about arbitrary native DOM operations:

await layout();

element.style.width = '10px';

or:

await composite();

element.getBoundingClientRect();

Is the intention that these are semantic rules for the phases, with development assertions where Ember is actually able to observe a violation?

If so, would that distinction be useful to mention so the RFC doesn't imply that Ember necessarily intercepts arbitrary DOM reads and writes?


Small question: should MessageChannel be categorized as a task rather than a microtask?

The Frames section currently groups MessageChannel with queueMicrotask, Promise reactions, and MutationObserver.

My understanding is that MessagePort messages are dispatched as tasks rather than microtasks:

https://html.spec.whatwg.org/multipage/web-messaging.html#dom-messageport-postmessage

This distinction also seems relevant to the later scheduler experiments, where MessageChannel is useful specifically as a task boundary.

Would it make sense to adjust that part of the description?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-Exploring In the Exploring RFC Stage T-deprecation T-ember-cli RFCs that impact the ember-cli library T-ember-data RFCs that impact the ember-data library T-fastboot RFCs that impact the fastboot library T-framework RFCs that impact the ember.js library T-learning T-routing T-testing

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

9 participants