Spike the use-async-scheduler optional feature from RFC 957 - #21554
NullVoxPopuli-ai-agent wants to merge 2 commits into
Conversation
|
rere-benchmark numbers at 4x CPU throttle: ms, lower is better (DB Monitor is fps, higher is better):
Reading:
|
|
Same setup as above, at 8x CPU throttle (medians of 5 interleaved runs, 3 for DB Monitor): ms, lower is better (DB Monitor is fps, higher is better):
8x is out of the headless 60Hz rAF ceiling, so DB Monitor now discriminates: flag on nearly triples fps over main (6.8 → 19.1), with the spike's VM work adding the rest (23.6). Everything else mirrors the 4x picture — flag off ≈ main, flag on takes the async wins (10–20x), small sync benches pay the rAF-flush latency (13.9 → 24.6, 59.3 → 117.2). |
|
Converting to draft since it is a spike |
Adds the `@ember/scheduler` package proposed by RFC 0957:
- `render`, `layout`, `composite`, `next` and `idle` phase functions,
each returning a promise that resolves according to the registered
scheduling strategy
- `registerStrategy`, for providing the scheduling strategy when defining
the Application
- `@ember/scheduler/strategy`, the default strategy implementation, which
flushes the render/layout/composite phases in order via ordered
requestAnimationFrame callbacks within a single frame, prior to paint
The deprecations of @ember/runloop and RSVP described by the RFC are left
to follow-up work; this is the additive API surface.
Fix Safari flake and idle() starvation in the default strategy
The "composite while composite is flushing" test asserted ordering across
two independent channels: a setTimeout task scheduled during frame 1
versus frame 2's requestAnimationFrame callbacks. The HTML spec does not
order pending timer tasks against the next rendering opportunity, and
Safari 15.6 runs the next frame's rAF callbacks first. The test now
anchors entirely to the rAF channel, using a raw requestAnimationFrame
registered ahead of the rescheduled phase windows as the frame-2
boundary.
idle() also armed requestIdleCallback without a timeout; fully-idle or
backgrounded pages can starve rIC indefinitely, leaving the promise
unresolvable. Cap the wait with { timeout: 500 }.
Adds EmberENV._USE_ASYNC_SCHEDULER (the app-wide optional feature from
RFC 0957's migration roadmap, following the default-async-observers
precedent) as the intermediary migration step: when enabled, Ember's own
scheduling moves onto @ember/scheduler while backburner, RSVP, and the
full @ember/runloop API keep working unchanged for everyone who has not
opted in.
With the flag enabled:
- @ember/runloop is re-implemented on the scheduler: run/join/bind
execute their callback directly, schedule('render'/'afterRender') map
onto the render and layout phases, every other queue becomes a
microtask, next maps onto the next phase, and later/debounce/throttle
become setTimeout-backed timers with the same coalescing semantics.
Backburner is not used at all.
- Rendering is scheduled into the scheduler's render phase (one
deduplicated revalidation pass before the next paint) instead of
backburner's render queue; the reflush loop and renderSettled()
semantics carry over.
- RSVP resolves on the microtask queue like a native promise instead of
joining a runloop.
With the flag disabled (the default) every gated call site falls through
to the exact code that exists today; the full suite is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c67c99c to
6ff35df
Compare
|
Rebased onto This PR now targets Local checks on the rebased branch: 🤖 Generated with Claude Code |
Spike for the intermediary migration step discussed in the RFC 957 review meeting: one optional feature flag that moves Ember's own scheduling (rendering, runloop callbacks, RSVP flush) onto
@ember/scheduler, while backburner, RSVP, and the full@ember/runloopAPI keep working exactly as today for everyone who has not opted in. Nothing is dropped or deprecated first. Apps can absorb the timing change while they still run addons that use the runloop, because the runloop API itself is re-implemented on the scheduler.Targets
main. The flag needs@ember/scheduler, so the first commit is #21552's interface commit, rebased ontomain(f3ce59665d). The second commit is the flag (6ff35df2fe).The flag
EmberENV._USE_ASYNC_SCHEDULER, following thedefault-async-observersprecedent (the app-facinguse-async-schedulername would come from@ember/optional-featuresonce this is real). Read lazily at every decision point, so tests can flip it per-module.Flag off (default)
No behavior change: every gated call site falls through to the exact code that exists today. Full suite after the rebase onto
main: 9539 tests, 9521 passed, 18 skipped, 0 failed.Flag on
Runloop, re-implemented on the scheduler (
@ember/runloop/-private/scheduler-loop.ts), per the RFC's transition table:run/join/bindonerrorrouting)schedule('render')render()phaseschedule('afterRender')layout()phaseschedule(anything else)Promise.resolve().then(...)scheduleOnce/oncenextnext()phaselater/debounce/throttle/cancelsetTimeout-backed timers with the same coalescing semanticsbegin/endBackburner is not used at all on this path. If the app has not registered a strategy, the default
FrameStrategyis registered on first use.Rendering goes through the scheduler's render phase instead of backburner's
renderqueue:scheduleRevalidate(both the global-context hook and the renderer's own) schedules one deduplicated revalidation pass before the next paint. The reflush loop (_RERENDER_LOOP_LIMIT) andrenderSettled()semantics carry over. The render phase resolves in-window during its own flush, so reflushes stay within the frame.RSVP resolves on the microtask queue like a native promise instead of joining a runloop; unhandled-rejection re-throw happens on a timeout instead of the private error queue. Ember error dispatch (
Ember.onerror/ dispatch override) is unchanged.Tests
packages/@ember/runloop/tests/use_async_scheduler_test.jsruns the runloop surface andrenderSettled()with the flag enabled (14 tests): syncrun/join/bind, microtaskschedule('actions'), phase ordering (actions→render→afterRender),scheduleOnce/oncededupe, cancellation,later/debounce/throttletiming, andrenderSettledresolving with no backburner runloop.Known gaps (why this is a spike)
settled()/test waiters:_hasScheduledTimers()reports the scheduler-backed timers, and the backend tracks all pending work (_hasPendingWork()), but@ember/test-helpersstill watches backburner directly. The RFC's "test waiters observe the scheduler" story is the critical follow-up before the flag is adoptable.actions→routerTransitions→render→afterRender→destroyper loop. Microtask-mapped queues (actions,routerTransitions,destroy, custom queues) now run FIFO in scheduling order rather than queue order.run()no longer forces a synchronous render flush. There is no runloop to flush. Interop relying on "DOM is updated whenrun()returns" needsawait renderSettled().schedulethey request), not at runloop end.