From f3ce59665dd2d86f87b0af8f5d4d50ca62c1ac51 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:32:56 -0400 Subject: [PATCH 1/2] Implement RFC #957: Render Aware Scheduler Interface 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 }. --- package.json | 2 + packages/@ember/scheduler/index.ts | 265 ++++++++++++++++++ packages/@ember/scheduler/package.json | 14 + packages/@ember/scheduler/strategy.ts | 185 ++++++++++++ .../@ember/scheduler/tests/scheduler_test.js | 102 +++++++ .../@ember/scheduler/tests/strategy_test.js | 127 +++++++++ pnpm-lock.yaml | 9 + tests/docs/expected.cjs | 6 + type-tests/@ember/scheduler-test.ts | 28 ++ 9 files changed, 738 insertions(+) create mode 100644 packages/@ember/scheduler/index.ts create mode 100644 packages/@ember/scheduler/package.json create mode 100644 packages/@ember/scheduler/strategy.ts create mode 100644 packages/@ember/scheduler/tests/scheduler_test.js create mode 100644 packages/@ember/scheduler/tests/strategy_test.js create mode 100644 type-tests/@ember/scheduler-test.ts diff --git a/package.json b/package.json index dfac084f3da..9b79388c76a 100644 --- a/package.json +++ b/package.json @@ -313,6 +313,8 @@ "@ember/routing/router-service.js": "ember-source/@ember/routing/router-service.js", "@ember/routing/router.js": "ember-source/@ember/routing/router.js", "@ember/runloop/index.js": "ember-source/@ember/runloop/index.js", + "@ember/scheduler/index.js": "ember-source/@ember/scheduler/index.js", + "@ember/scheduler/strategy.js": "ember-source/@ember/scheduler/strategy.js", "@ember/service/index.js": "ember-source/@ember/service/index.js", "@ember/template-compilation/index.js": "ember-source/@ember/template-compilation/index.js", "@ember/template-compiler/-internal-primitives.js": "ember-source/@ember/template-compiler/-internal-primitives.js", diff --git a/packages/@ember/scheduler/index.ts b/packages/@ember/scheduler/index.ts new file mode 100644 index 00000000000..2410c394eff --- /dev/null +++ b/packages/@ember/scheduler/index.ts @@ -0,0 +1,265 @@ +import { assert } from '@ember/debug'; + +/** + The `@ember/scheduler` package provides a render-aware scheduling interface, + as described by [RFC 0957](https://rfcs.emberjs.com/id/0957-modernized-scheduler). + + 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 + implementation (referred to as a "strategy"), allowing for experimentation + in this space. + + Work is scheduled into a phase by awaiting the promise returned from that + phase's function: + + ```js + import { render, layout, composite, next, idle } from '@ember/scheduler'; + + async function repositionTooltip(tooltip) { + // wait for updated DOM, before the browser paints + await render(); + + // wait to read layout information, after `render` but before paint + await layout(); + let rect = tooltip.target.getBoundingClientRect(); + + // wait to write DOM, after `layout` but before paint + await composite(); + tooltip.element.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + } + ``` + + Since the scheduler does not itself store any callbacks, there is no need + to tell the scheduler to cancel work. Instead, if your work requires + cancellation or cleanup, handle this at the point the work was scheduled: + + ```js + import { render } from '@ember/scheduler'; + + class Example extends Component { + async doWork() { + await render(); + if (this.isDestroyed) { + return; + } + // ... + } + } + ``` + + @module @ember/scheduler + @public +*/ + +/** + * An implementation of the scheduler interface. The strategy chooses when + * the promise for each phase will resolve, and what happens when a phase is + * requested while another phase is flushing. + * + * Notably, a strategy has no knowledge of the work to be done. This keeps + * scheduling overhead light and enables async stack traces for scheduled + * work to maintain the context of where the work was scheduled. + */ +export interface Strategy { + render(): Promise; + layout(): Promise; + composite(): Promise; + next(): Promise; + idle(): Promise; +} + +let registeredStrategy: Strategy | null = null; + +/** + Registers the scheduling strategy which the phase functions of + `@ember/scheduler` delegate to. + + The scheduling strategy should be registered once, when defining the + Application: + + ```js + import Application from '@ember/application'; + import { registerStrategy } from '@ember/scheduler'; + + // the default scheduler implementation + import strategy from '@ember/scheduler/strategy'; + + export default class App extends Application { + // ... + } + + registerStrategy(strategy); + ``` + + A strategy is any object implementing the scheduler interface: + + ```ts + interface Strategy { + render(): Promise; + layout(): Promise; + composite(): Promise; + next(): Promise; + idle(): Promise; + } + ``` + + @method registerStrategy + @for @ember/scheduler + @param {Strategy} strategy the scheduling strategy to delegate to + @static + @public +*/ +export function registerStrategy(strategy: Strategy): void { + assert( + 'Cannot call `registerStrategy`: a different scheduling strategy has already been registered. The scheduling strategy should be registered exactly once, when defining the Application.', + registeredStrategy === null || registeredStrategy === strategy + ); + registeredStrategy = strategy; +} + +// Private API used by tests to swap out the registered strategy. +export function _clearRegisteredStrategy(): void { + registeredStrategy = null; +} + +function getStrategy(phaseName: string): Strategy { + assert( + `Attempted to schedule work into the '${phaseName}' phase, but no scheduling strategy is registered. Register a strategy when defining your Application, e.g. the default strategy:\n\n\timport { registerStrategy } from '@ember/scheduler';\n\timport strategy from '@ember/scheduler/strategy';\n\n\tregisterStrategy(strategy);`, + registeredStrategy !== null + ); + return registeredStrategy; +} + +/** + Returns a promise which resolves once Ember has rendered new DOM containing + the changes you've just made, guaranteeing that your work has access to that + DOM prior to the next paint. + + ```js + import { render } from '@ember/scheduler'; + + // ... + + await render(); + ``` + + During the render phase, updates to reactive state are allowed, but Ember + does not guarantee that any updates will rerender before the next paint; + this is up to the strategy to decide. Writing DOM during this phase will + error in development. + + @method render + @for @ember/scheduler + @return {Promise} a promise which resolves during the render phase + @static + @public +*/ +export function render(): Promise { + return getStrategy('render').render(); +} + +/** + Returns a promise which resolves after the render phase and prior to the + next paint. + + ```js + import { layout } from '@ember/scheduler'; + + // ... + + await layout(); + ``` + + This phase is for work that needs to read DOM but does not require + adjusting reactive state. Writing DOM during this phase will error in + development. + + @method layout + @for @ember/scheduler + @return {Promise} a promise which resolves during the layout phase + @static + @public +*/ +export function layout(): Promise { + return getStrategy('layout').layout(); +} + +/** + Returns a promise which resolves after the layout phase and prior to the + next paint. + + ```js + import { composite } from '@ember/scheduler'; + + // ... + + await composite(); + ``` + + This phase is for work that needs to write DOM but does not require reading + DOM state or adjusting reactive state. It is ideal for updating animations + or moving tooltips to a final position based on measurements made during + the layout phase. + + Users should take every opportunity to avoid reading DOM in this phase to + avoid forced layouts and interleaved read/write of DOM state. + + @method composite + @for @ember/scheduler + @return {Promise} a promise which resolves during the composite phase + @static + @public +*/ +export function composite(): Promise { + return getStrategy('composite').composite(); +} + +/** + Returns a promise which resolves as a task once the browser has completed + the current frame. + + ```js + import { next } from '@ember/scheduler'; + + // ... + + await next(); + ``` + + This phase is for work that needs to escape the current frame but is still + a relatively high priority. + + @method next + @for @ember/scheduler + @return {Promise} a promise which resolves in a task after the current frame completes + @static + @public +*/ +export function next(): Promise { + return getStrategy('next').next(); +} + +/** + Returns a promise which resolves once the browser is under less load. + + ```js + import { idle } from '@ember/scheduler'; + + // ... + + await idle(); + ``` + + This phase is for work that is low priority, most commonly tasks like + background fetch, server pings, or analytics processing. + + @method idle + @for @ember/scheduler + @return {Promise} a promise which resolves when the browser is idle + @static + @public +*/ +export function idle(): Promise { + return getStrategy('idle').idle(); +} diff --git a/packages/@ember/scheduler/package.json b/packages/@ember/scheduler/package.json new file mode 100644 index 00000000000..5bd1d404312 --- /dev/null +++ b/packages/@ember/scheduler/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ember/scheduler", + "private": true, + "type": "module", + "exports": { + ".": "./index.ts", + "./strategy": "./strategy.ts", + "./*": "./*.ts" + }, + "dependencies": { + "@ember/debug": "workspace:*", + "internal-test-helpers": "workspace:*" + } +} diff --git a/packages/@ember/scheduler/strategy.ts b/packages/@ember/scheduler/strategy.ts new file mode 100644 index 00000000000..602bd356b76 --- /dev/null +++ b/packages/@ember/scheduler/strategy.ts @@ -0,0 +1,185 @@ +import type { Strategy } from '@ember/scheduler'; + +/** + The default implementation of the scheduler interface described by + [RFC 0957](https://rfcs.emberjs.com/id/0957-modernized-scheduler). + + ```js + import { registerStrategy } from '@ember/scheduler'; + import strategy from '@ember/scheduler/strategy'; + + registerStrategy(strategy); + ``` + + This strategy conceptualizes work as belonging to a "Frame", where a Frame + constitutes the time between when states of the DOM are observable to a + user. Each Frame flushes the `render`, `layout` and `composite` phases in + order via `requestAnimationFrame`, prior to the browser's next paint. + + - work scheduled while no Frame is flushing resolves in the corresponding + phase of the upcoming Frame + - scheduling into a phase that the flushing Frame has not yet reached + resolves "just-in-time" within the current Frame + - scheduling into `render` while `render` is flushing resolves recursively + within the current Frame's render phase + - scheduling into a phase the flushing Frame has already passed (or into + `layout`/`composite` while that same phase is flushing) resolves in the + next Frame + + @module @ember/scheduler/strategy + @public +*/ + +type FramePhase = 'render' | 'layout' | 'composite'; + +const PHASE_ORDER: Record = { + render: 0, + layout: 1, + composite: 2, +}; + +// requestAnimationFrame is unavailable in SSR environments such as FastBoot. +// There is no paint to schedule against there, so degrade to timers: phases +// still resolve in order, since equal-delay timeouts run FIFO. +function onFrameTask(callback: () => void): void { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => callback()); + } else { + setTimeout(callback, 0); + } +} + +class Deferred { + declare promise: Promise; + declare resolve: () => void; + + constructor() { + this.promise = new Promise((resolve) => { + this.resolve = resolve; + }); + } +} + +class Frame { + render = new Deferred(); + layout = new Deferred(); + composite = new Deferred(); + complete = new Deferred(); +} + +export class FrameStrategy implements Strategy { + /** the frame whose phase callbacks are registered but have not yet completed */ + private _frame: Frame | null = null; + + /** while a frame is flushing, the frame scheduled to run after it */ + private _nextFrame: Frame | null = null; + + /** the phase window currently being flushed, if any */ + private _flushing: FramePhase | null = null; + + render(): Promise { + if (this._flushing === 'render') { + // recursive scheduling into `render` resolves within the current + // render window + return Promise.resolve(); + } + return this._phase('render'); + } + + layout(): Promise { + return this._phase('layout'); + } + + composite(): Promise { + return this._phase('composite'); + } + + next(): Promise { + // once the frame in flight (or the upcoming frame) has completed, yield + // to a new task. `requestAnimationFrame` callbacks run before the paint, + // so a timer scheduled from `complete` lands after it. + return this._ensureFrame().complete.promise.then( + () => new Promise((resolve) => setTimeout(resolve, 0)) + ); + } + + idle(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === 'function') { + // an idle period may never arrive: fully-idle or backgrounded pages + // can starve requestIdleCallback indefinitely, so cap the wait to + // keep the promise resolvable + requestIdleCallback(() => resolve(), { timeout: 500 }); + } else { + setTimeout(resolve, 0); + } + }); + } + + private _phase(name: FramePhase): Promise { + let flushing = this._flushing; + + if (flushing === null) { + return this._ensureFrame()[name].promise; + } + + if (PHASE_ORDER[name] > PHASE_ORDER[flushing]) { + // this phase of the flushing frame is still upcoming, resolve + // just-in-time within the current frame + return this._frame![name].promise; + } + + // the window for this phase has already flushed this frame + return this._ensureNextFrame()[name].promise; + } + + private _ensureFrame(): Frame { + if (this._frame === null) { + this._frame = this._scheduleFrame(); + } + return this._frame; + } + + private _ensureNextFrame(): Frame { + if (this._nextFrame === null) { + this._nextFrame = this._scheduleFrame(); + } + return this._nextFrame; + } + + private _scheduleFrame(): Frame { + let frame = new Frame(); + + // callbacks registered with the browser in the same frame run in + // registration order, giving us ordered phase windows within a single + // frame, all before the next paint. Microtasks (and thus work awaiting a + // phase) flush between callbacks. When a frame is scheduled while another + // frame is flushing, the browser runs these callbacks in the next frame. + onFrameTask(() => { + this._flushing = 'render'; + frame.render.resolve(); + }); + onFrameTask(() => { + this._flushing = 'layout'; + frame.layout.resolve(); + }); + onFrameTask(() => { + this._flushing = 'composite'; + frame.composite.resolve(); + }); + onFrameTask(() => { + this._flushing = null; + if (this._frame === frame) { + this._frame = this._nextFrame; + this._nextFrame = null; + } + frame.complete.resolve(); + }); + + return frame; + } +} + +const strategy: Strategy = new FrameStrategy(); + +export default strategy; diff --git a/packages/@ember/scheduler/tests/scheduler_test.js b/packages/@ember/scheduler/tests/scheduler_test.js new file mode 100644 index 00000000000..0f14cc8fc7f --- /dev/null +++ b/packages/@ember/scheduler/tests/scheduler_test.js @@ -0,0 +1,102 @@ +import { + render, + layout, + composite, + next, + idle, + registerStrategy, + _clearRegisteredStrategy, +} from '..'; +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; + +class StubStrategy { + calls = []; + + render() { + this.calls.push('render'); + return Promise.resolve(); + } + + layout() { + this.calls.push('layout'); + return Promise.resolve(); + } + + composite() { + this.calls.push('composite'); + return Promise.resolve(); + } + + next() { + this.calls.push('next'); + return Promise.resolve(); + } + + idle() { + this.calls.push('idle'); + return Promise.resolve(); + } +} + +moduleFor( + '@ember/scheduler', + class extends AbstractTestCase { + teardown() { + _clearRegisteredStrategy(); + } + + ['@test phase functions assert when no strategy is registered'](assert) { + for (let phase of [render, layout, composite, next, idle]) { + expectAssertion(() => { + phase(); + }, /no scheduling strategy is registered/); + } + + assert.expect(5); + } + + ['@test phase functions delegate to the registered strategy'](assert) { + let strategy = new StubStrategy(); + registerStrategy(strategy); + + render(); + layout(); + composite(); + next(); + idle(); + + assert.deepEqual(strategy.calls, ['render', 'layout', 'composite', 'next', 'idle']); + } + + ['@test phase functions return the promise produced by the strategy'](assert) { + let expected = Promise.resolve(); + + registerStrategy({ + render: () => expected, + layout: () => expected, + composite: () => expected, + next: () => expected, + idle: () => expected, + }); + + for (let phase of [render, layout, composite, next, idle]) { + assert.strictEqual(phase(), expected); + } + } + + ['@test registerStrategy asserts when a different strategy is already registered'](assert) { + let strategy = new StubStrategy(); + registerStrategy(strategy); + + // re-registering the same strategy is a no-op + registerStrategy(strategy); + + expectAssertion(() => { + registerStrategy(new StubStrategy()); + }, /a different scheduling strategy has already been registered/); + + render(); + assert.deepEqual(strategy.calls, ['render'], 'the original strategy remains registered'); + } + } +); diff --git a/packages/@ember/scheduler/tests/strategy_test.js b/packages/@ember/scheduler/tests/strategy_test.js new file mode 100644 index 00000000000..8ef9cb1cff8 --- /dev/null +++ b/packages/@ember/scheduler/tests/strategy_test.js @@ -0,0 +1,127 @@ +/* globals requestAnimationFrame: false */ +import defaultStrategy, { FrameStrategy } from '../strategy'; +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; + +moduleFor( + '@ember/scheduler/strategy', + class extends AbstractTestCase { + ['@test the default export is a FrameStrategy'](assert) { + assert.ok(defaultStrategy instanceof FrameStrategy); + } + + async ['@test phases resolve in order within a single frame'](assert) { + let strategy = new FrameStrategy(); + let order = []; + + await Promise.all([ + strategy.next().then(() => order.push('next')), + strategy.composite().then(() => order.push('composite')), + strategy.render().then(() => order.push('render')), + strategy.layout().then(() => order.push('layout')), + ]); + + assert.deepEqual(order, ['render', 'layout', 'composite', 'next']); + } + + async ['@test scheduling into render while render is flushing resolves within the current frame']( + assert + ) { + let strategy = new FrameStrategy(); + let order = []; + + let layoutPromise = strategy.layout().then(() => order.push('layout')); + + await strategy.render(); + order.push('render'); + + await strategy.render(); + order.push('render again'); + + await layoutPromise; + + assert.deepEqual(order, ['render', 'render again', 'layout']); + } + + async ['@test scheduling just-in-time during the render window resolves within the current frame']( + assert + ) { + let strategy = new FrameStrategy(); + let order = []; + + await strategy.render(); + + let layoutPromise = strategy.layout().then(() => order.push('layout')); + let compositePromise = strategy.composite().then(() => order.push('composite')); + let nextPromise = strategy.next().then(() => order.push('next')); + + await Promise.all([layoutPromise, compositePromise, nextPromise]); + + assert.deepEqual(order, ['layout', 'composite', 'next']); + } + + async ['@test scheduling into an already-flushed phase resolves in the next frame'](assert) { + let strategy = new FrameStrategy(); + let order = []; + + // wait until the layout window of the first frame + await strategy.layout(); + + await Promise.all([ + strategy.composite().then(() => order.push('composite (this frame)')), + strategy.render().then(() => order.push('render (next frame)')), + strategy.layout().then(() => order.push('layout (next frame)')), + ]); + + assert.deepEqual(order, [ + 'composite (this frame)', + 'render (next frame)', + 'layout (next frame)', + ]); + } + + async ['@test scheduling into composite while composite is flushing resolves in the next frame']( + assert + ) { + let strategy = new FrameStrategy(); + let order = []; + + await strategy.composite(); + + // a raw requestAnimationFrame registered now (during frame 1's + // composite window) marks the start of frame 2, ahead of the phase + // windows the strategy schedules for it below. Anchoring to the rAF + // channel keeps this deterministic: whether a timer task scheduled + // during frame 1 runs before or after frame 2's rAF callbacks is + // browser-defined (Safari orders it differently than Chrome). + let frameBoundary = new Promise((resolve) => + requestAnimationFrame(() => { + order.push('frame 2 began'); + resolve(); + }) + ); + let compositePromise = strategy.composite().then(() => order.push('composite (next frame)')); + + await Promise.all([frameBoundary, compositePromise]); + + assert.deepEqual(order, ['frame 2 began', 'composite (next frame)']); + } + + async ['@test work can be scheduled again after a frame completes'](assert) { + let strategy = new FrameStrategy(); + + await strategy.next(); + await strategy.render(); + await strategy.next(); + + assert.ok(true, 'phases continue to resolve in subsequent frames'); + } + + async ['@test idle resolves'](assert) { + let strategy = new FrameStrategy(); + + await strategy.idle(); + + assert.ok(true, 'idle resolved'); + } + } +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73af5a19048..626d07ec2f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1079,6 +1079,15 @@ importers: specifier: workspace:* version: link:../../internal-test-helpers + packages/@ember/scheduler: + dependencies: + '@ember/debug': + specifier: workspace:* + version: link:../debug + internal-test-helpers: + specifier: workspace:* + version: link:../../internal-test-helpers + packages/@ember/service: dependencies: '@ember/-internals': diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index d57649b8575..55c10adb023 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -119,6 +119,7 @@ module.exports = { 'component', 'compute', 'computed', + 'composite', 'concat', 'concatenatedProperties', 'container', @@ -248,6 +249,7 @@ module.exports = { 'helper', 'htmlSafe', 'trustHTML', + 'idle', 'if', 'in-element', 'includes', @@ -396,6 +398,7 @@ module.exports = { 'registeredOptionsForType', 'registerOptions', 'registerOptionsForType', + 'registerStrategy', 'registerWaiter', 'registerWarnHandler', 'registrations', @@ -408,6 +411,7 @@ module.exports = { 'removeObject', 'removeObjects', 'removeObserver', + 'render', 'renderComponent', 'renderSettled', 'reopen', @@ -624,6 +628,8 @@ module.exports = { '@ember/routing/router-service', '@ember/routing/transition', '@ember/runloop', + '@ember/scheduler', + '@ember/scheduler/strategy', '@ember/service', '@ember/template', '@ember/test', diff --git a/type-tests/@ember/scheduler-test.ts b/type-tests/@ember/scheduler-test.ts new file mode 100644 index 00000000000..90a730cb473 --- /dev/null +++ b/type-tests/@ember/scheduler-test.ts @@ -0,0 +1,28 @@ +import { render, layout, composite, next, idle, registerStrategy } from '@ember/scheduler'; +import type { Strategy } from '@ember/scheduler'; +import strategy, { FrameStrategy } from '@ember/scheduler/strategy'; +import { expectTypeOf } from 'expect-type'; + +expectTypeOf(render()).toEqualTypeOf>(); +expectTypeOf(layout()).toEqualTypeOf>(); +expectTypeOf(composite()).toEqualTypeOf>(); +expectTypeOf(next()).toEqualTypeOf>(); +expectTypeOf(idle()).toEqualTypeOf>(); + +expectTypeOf(registerStrategy(strategy)).toEqualTypeOf(); +expectTypeOf(strategy).toMatchTypeOf(); +expectTypeOf(new FrameStrategy()).toMatchTypeOf(); + +// @ts-expect-error requires a strategy +registerStrategy(); + +registerStrategy({ + render: () => Promise.resolve(), + layout: () => Promise.resolve(), + composite: () => Promise.resolve(), + next: () => Promise.resolve(), + idle: () => Promise.resolve(), +}); + +// @ts-expect-error an incomplete strategy is rejected +registerStrategy({ render: () => Promise.resolve() }); From 6ff35df2fe7ab0175c41b723abcab69c8c80178e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:06:42 -0400 Subject: [PATCH 2/2] Spike the use-async-scheduler optional feature from RFC 957 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 --- package.json | 1 + .../@ember/-internals/environment/lib/env.ts | 16 + .../glimmer/lib/async-revalidate.ts | 22 + .../-internals/glimmer/lib/base-renderer.ts | 58 ++- .../-internals/glimmer/lib/environment.ts | 5 + .../@ember/-internals/runtime/lib/ext/rsvp.ts | 15 +- .../@ember/runloop/-private/scheduler-loop.ts | 380 ++++++++++++++++++ packages/@ember/runloop/index.ts | 48 +++ packages/@ember/runloop/package.json | 1 + .../runloop/tests/use_async_scheduler_test.js | 218 ++++++++++ packages/@ember/scheduler/index.ts | 6 + pnpm-lock.yaml | 3 + tests/docs/expected.cjs | 1 + 13 files changed, 766 insertions(+), 8 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/async-revalidate.ts create mode 100644 packages/@ember/runloop/-private/scheduler-loop.ts create mode 100644 packages/@ember/runloop/tests/use_async_scheduler_test.js diff --git a/package.json b/package.json index 9b79388c76a..ceffeaa29d7 100644 --- a/package.json +++ b/package.json @@ -312,6 +312,7 @@ "@ember/routing/route.js": "ember-source/@ember/routing/route.js", "@ember/routing/router-service.js": "ember-source/@ember/routing/router-service.js", "@ember/routing/router.js": "ember-source/@ember/routing/router.js", + "@ember/runloop/-private/scheduler-loop.js": "ember-source/@ember/runloop/-private/scheduler-loop.js", "@ember/runloop/index.js": "ember-source/@ember/runloop/index.js", "@ember/scheduler/index.js": "ember-source/@ember/scheduler/index.js", "@ember/scheduler/strategy.js": "ember-source/@ember/scheduler/strategy.js", diff --git a/packages/@ember/-internals/environment/lib/env.ts b/packages/@ember/-internals/environment/lib/env.ts index 4c3e37fe4a8..c546e7402fe 100644 --- a/packages/@ember/-internals/environment/lib/env.ts +++ b/packages/@ember/-internals/environment/lib/env.ts @@ -125,6 +125,22 @@ export const ENV = { */ _DEFAULT_ASYNC_OBSERVERS: false, + /** + Whether Ember schedules its own work (rendering, runloop-scheduled + callbacks, promise resolution) through `@ember/scheduler` instead of + backburner and RSVP, per RFC 0957. + + This is not intended to be set directly, as the implementation may change in + the future. Use `@ember/optional-features` instead. + + @property _USE_ASYNC_SCHEDULER + @for EmberENV + @type Boolean + @default false + @private + */ + _USE_ASYNC_SCHEDULER: false, + /** Controls the maximum number of scheduled rerenders without "settling". In general, applications should not need to modify this environment variable, but please diff --git a/packages/@ember/-internals/glimmer/lib/async-revalidate.ts b/packages/@ember/-internals/glimmer/lib/async-revalidate.ts new file mode 100644 index 00000000000..484ec75f0ae --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/async-revalidate.ts @@ -0,0 +1,22 @@ +import { scheduleOnce } from '@ember/runloop'; + +// Indirection between environment.ts (which needs to schedule revalidation +// from the glimmer global context) and base-renderer.ts (which owns the +// renderers and implements the flush), so neither has to import the other. + +let flushFn: () => void = () => {}; + +export function _setAsyncRenderFlush(fn: () => void): void { + flushFn = fn; +} + +function runAsyncRenderFlush(): void { + flushFn(); +} + +// Schedules the async rendering pass (deduplicated) into the scheduler's +// render phase. Only used when the `use-async-scheduler` optional feature +// is enabled. +export function _scheduleAsyncRevalidate(): void { + scheduleOnce('render', null, runAsyncRenderFlush); +} diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 90addfe68e5..f981671817b 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -1,7 +1,9 @@ import { ENV } from '@ember/-internals/environment/lib/env'; import type { InternalOwner } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; -import { _backburner, _getCurrentRunLoop } from '@ember/runloop'; +import { _backburner, _getCurrentRunLoop, schedule } from '@ember/runloop'; +import { flushAsyncObservers } from '@ember/-internals/metal/lib/observer'; +import { _scheduleAsyncRevalidate, _setAsyncRenderFlush } from './async-revalidate'; import { associateDestroyableChild, destroy, @@ -176,10 +178,14 @@ export function renderSettled() { let resolve!: () => void; let promise = new Promise((r) => (resolve = r)); renderSettledDeferred = { promise, resolve }; - // if there is no current runloop, the promise created above will not have - // a chance to resolve (because its resolved in backburner's "end" event) - if (!_getCurrentRunLoop()) { - // ensure a runloop has been kicked off + if (ENV._USE_ASYNC_SCHEDULER) { + // ensure a revalidation pass is scheduled; it resolves the promise + // once every renderer is valid + _scheduleAsyncRevalidate(); + } else if (!_getCurrentRunLoop()) { + // if there is no current runloop, the promise created above will not + // have a chance to resolve (because its resolved in backburner's "end" + // event); ensure a runloop has been kicked off _backburner.schedule('actions', null, NO_OP); } } @@ -192,7 +198,11 @@ function resolveRenderPromise() { let resolve = renderSettledDeferred.resolve; renderSettledDeferred = null; - _backburner.join(null, resolve); + if (ENV._USE_ASYNC_SCHEDULER) { + resolve(); + } else { + _backburner.join(null, resolve); + } } } @@ -217,6 +227,38 @@ function loopEnd() { _backburner.on('begin', loopBegin); _backburner.on('end', loopEnd); +// The async rendering pass used when the `use-async-scheduler` optional +// feature is enabled: revalidation is scheduled into the scheduler's render +// phase instead of backburner's `render` queue, so rendering happens before +// the next paint rather than at the end of the current runloop. +function flushAsyncRendering(): void { + flushAsyncObservers(schedule); + + for (let renderer of renderers) { + renderer.state.revalidate(renderer); + } + + for (let renderer of renderers) { + if (!renderer.isValid()) { + if (loops > ENV._RERENDER_LOOP_LIMIT) { + loops = 0; + // TODO: do something better + renderer.destroy(); + throw new Error('infinite rendering invalidation detected'); + } + loops++; + // the render phase resolves in-window when awaited during its own + // flush, so this reflush happens within the same frame + _scheduleAsyncRevalidate(); + return; + } + } + loops = 0; + resolveRenderPromise(); +} + +_setAsyncRenderFlush(flushAsyncRendering); + type Resolver = ClassicResolver; interface RendererData { @@ -368,6 +410,10 @@ export class RendererState { } scheduleRevalidate(renderer: BaseRenderer): void { + if (ENV._USE_ASYNC_SCHEDULER) { + _scheduleAsyncRevalidate(); + return; + } _backburner.scheduleOnce('render', this, this.revalidate, renderer); } diff --git a/packages/@ember/-internals/glimmer/lib/environment.ts b/packages/@ember/-internals/glimmer/lib/environment.ts index fc2d99f4f4d..27c578cf84e 100644 --- a/packages/@ember/-internals/glimmer/lib/environment.ts +++ b/packages/@ember/-internals/glimmer/lib/environment.ts @@ -9,6 +9,7 @@ import type { DeprecationOptions } from '@ember/debug/lib/deprecate'; import { schedule, _backburner } from '@ember/runloop'; import { DEBUG } from '@glimmer/env'; import setGlobalContext from '@glimmer/global-context'; +import { _scheduleAsyncRevalidate } from './async-revalidate'; import type { EnvironmentDelegate } from '@glimmer/runtime/lib/environment'; import { debug } from '@glimmer/validator/lib/debug'; import toIterator from './utils/iterator'; @@ -21,6 +22,10 @@ import toBool from './utils/to-bool'; setGlobalContext({ scheduleRevalidate() { + if (ENV._USE_ASYNC_SCHEDULER) { + _scheduleAsyncRevalidate(); + return; + } _backburner.ensureInstance(); }, diff --git a/packages/@ember/-internals/runtime/lib/ext/rsvp.ts b/packages/@ember/-internals/runtime/lib/ext/rsvp.ts index 45419961a9a..bf04110680c 100644 --- a/packages/@ember/-internals/runtime/lib/ext/rsvp.ts +++ b/packages/@ember/-internals/runtime/lib/ext/rsvp.ts @@ -1,13 +1,24 @@ import * as RSVP from 'rsvp'; +import { ENV } from '@ember/-internals/environment/lib/env'; import { _backburner, _rsvpErrorQueue } from '@ember/runloop'; import { getDispatchOverride } from '@ember/-internals/error-handling'; import { assert } from '@ember/debug'; -RSVP.configure('async', (callback: unknown, promise: unknown) => { +RSVP.configure('async', (callback: Function, promise: unknown) => { + if (ENV._USE_ASYNC_SCHEDULER) { + // RFC 0957: Ember no longer routes RSVP's flush through the runloop; + // resolution happens on the microtask queue like a native promise. + void Promise.resolve().then(() => callback(promise)); + return; + } _backburner.schedule('actions', null, callback, promise); }); -RSVP.configure('after', (cb: unknown) => { +RSVP.configure('after', (cb: () => void) => { + if (ENV._USE_ASYNC_SCHEDULER) { + setTimeout(cb, 0); + return; + } _backburner.schedule(_rsvpErrorQueue, null, cb); }); diff --git a/packages/@ember/runloop/-private/scheduler-loop.ts b/packages/@ember/runloop/-private/scheduler-loop.ts new file mode 100644 index 00000000000..fd66d12bcc5 --- /dev/null +++ b/packages/@ember/runloop/-private/scheduler-loop.ts @@ -0,0 +1,380 @@ +import { onErrorTarget } from '@ember/-internals/error-handling'; +import { + render as renderPhase, + layout as layoutPhase, + next as nextPhase, + registerStrategy, + _getRegisteredStrategy, +} from '@ember/scheduler'; +import defaultStrategy from '@ember/scheduler/strategy'; +import type { AnyFn } from '@ember/-internals/utility-types'; + +/** + * The scheduler-backed runloop, active when the `use-async-scheduler` + * optional feature (`EmberENV._USE_ASYNC_SCHEDULER`) is enabled. + * + * Implements the transition semantics from RFC 0957's migration roadmap: + * `run`/`join` execute their callback directly, `schedule('render')` and + * `schedule('afterRender')` map onto the scheduler's render and layout + * phases, every other queue becomes a microtask, `next` maps onto the + * scheduler's next phase, and the timer methods (`later`, `debounce`, + * `throttle`) are backed by `setTimeout` rather than backburner's timer + * heap. + * + * Backburner is not used at all on this path. + */ + +export interface SchedulerTimer { + cancelled: boolean; + finished: boolean; + cleanup?: () => void; +} + +export function isSchedulerTimer(timer: unknown): timer is SchedulerTimer { + return timer !== null && typeof timer === 'object' && 'cancelled' in timer && 'finished' in timer; +} + +function ensureStrategy(): void { + if (_getRegisteredStrategy() === null) { + registerStrategy(defaultStrategy); + } +} + +type Target = object | null | undefined; +type Method = AnyFn | string; + +interface ParsedArgs { + target: Target; + method: Method; + args: unknown[]; +} + +// Mirrors backburner's argument parsing: `(method)`, `(method, ...args)`, +// `(target, method, ...args)`, where `method` may be the name of a method +// on `target`. +function parseArgs(args: unknown[]): ParsedArgs { + if (args.length === 1) { + return { target: null, method: args[0] as Method, args: [] }; + } + + let [first, second, ...rest] = args; + if ( + typeof second === 'function' || + (typeof second === 'string' && first !== null && typeof first === 'object' && second in first) + ) { + return { target: first as Target, method: second as Method, args: rest }; + } + + return { target: null, method: first as Method, args: args.slice(1) }; +} + +function resolveMethod(target: Target, method: Method): AnyFn { + if (typeof method === 'string') { + return (target as Record)[method] as AnyFn; + } + return method; +} + +function invokeWithOnError(target: Target, method: Method, args: unknown[]): unknown { + let fn = resolveMethod(target, method); + let onError = onErrorTarget.onerror; + if (onError) { + try { + return fn.apply(target, args); + } catch (error) { + onError(error); + return; + } + } + return fn.apply(target, args); +} + +let pendingCount = 0; +let pendingTimerCount = 0; + +const activeTimers = new Set(); + +function makeTimer(): SchedulerTimer { + pendingCount++; + return { cancelled: false, finished: false }; +} + +function finish(timer: SchedulerTimer): void { + if (!timer.finished) { + timer.finished = true; + pendingCount--; + } +} + +export function run(...args: unknown[]): unknown { + let { target, method, args: methodArgs } = parseArgs(args); + return invokeWithOnError(target, method, methodArgs); +} + +// With no runloop there is nothing to join; execute directly. +export const join = run; + +// Deduplication bookkeeping for scheduleOnce: queue -> target -> method. +// The method key is the raw string or function so `scheduleOnce('render', +// obj, 'update')` and `scheduleOnce('render', obj, obj.update)` behave the +// same way they do under backburner. +const NULL_TARGET = Symbol('null-target'); +interface OnceEntry { + timer: SchedulerTimer; + args: unknown[]; +} +const onceMap = new Map>>(); + +function phaseFor(queue: string): (() => Promise) | null { + if (queue === 'render') return renderPhase; + if (queue === 'afterRender') return layoutPhase; + return null; +} + +function scheduleInvoke(queue: string, callback: () => void): void { + ensureStrategy(); + let phase = phaseFor(queue); + if (phase) { + void phase().then(callback); + } else { + // RFC 0957: `schedule('actions', doWork)` becomes + // `Promise.resolve().then(doWork)`. + void Promise.resolve().then(callback); + } +} + +export function schedule(queue: string, ...rest: unknown[]): SchedulerTimer { + let { target, method, args } = parseArgs(rest); + let timer = makeTimer(); + + scheduleInvoke(queue, () => { + if (timer.cancelled) return; + finish(timer); + invokeWithOnError(target, method, args); + }); + + return timer; +} + +export function scheduleOnce(queue: string, ...rest: unknown[]): SchedulerTimer { + let { target, method, args } = parseArgs(rest); + + let targetKey = target ?? NULL_TARGET; + let byTarget = onceMap.get(queue); + if (byTarget === undefined) { + byTarget = new Map(); + onceMap.set(queue, byTarget); + } + let byMethod = byTarget.get(targetKey); + if (byMethod === undefined) { + byMethod = new Map(); + byTarget.set(targetKey, byMethod); + } + + let existing = byMethod.get(method); + if (existing !== undefined && !existing.timer.cancelled) { + // Same queue/target/method: new arguments replace the previous call. + existing.args = args; + return existing.timer; + } + + let timer = makeTimer(); + let entry: OnceEntry = { timer, args }; + byMethod.set(method, entry); + timer.cleanup = () => byMethod.delete(method); + + scheduleInvoke(queue, () => { + if (timer.cancelled) return; + finish(timer); + byMethod.delete(method); + invokeWithOnError(target, method, entry.args); + }); + + return timer; +} + +export function next(...args: unknown[]): SchedulerTimer { + let { target, method, args: methodArgs } = parseArgs(args); + let timer = makeTimer(); + + ensureStrategy(); + void nextPhase().then(() => { + if (timer.cancelled) return; + finish(timer); + invokeWithOnError(target, method, methodArgs); + }); + + return timer; +} + +function isCoercableNumber(value: unknown): value is number | string { + return typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)); +} + +function popWait(args: unknown[], fallback: number): number { + if (args.length > 0 && isCoercableNumber(args[args.length - 1])) { + return Number(args.pop()); + } + return fallback; +} + +// Arms (or re-arms) `timer` to fire `fire` after `wait` ms. `onDone` runs +// exactly once per arming, whether the timer fires or is cancelled — it is +// where dedupe-map entries get removed. +function setTimer( + timer: SchedulerTimer, + wait: number, + fire: () => void, + onDone?: () => void +): void { + pendingTimerCount++; + activeTimers.add(timer); + + let id = setTimeout(() => { + pendingTimerCount--; + activeTimers.delete(timer); + if (timer.cancelled) return; + finish(timer); + onDone?.(); + fire(); + }, wait); + + timer.cleanup = () => { + clearTimeout(id); + pendingTimerCount--; + activeTimers.delete(timer); + onDone?.(); + }; +} + +export function later(...args: unknown[]): SchedulerTimer { + let wait = popWait(args, 0); + let { target, method, args: methodArgs } = parseArgs(args); + let timer = makeTimer(); + + setTimer(timer, wait, () => invokeWithOnError(target, method, methodArgs)); + + return timer; +} + +interface DedupedTimerEntry { + timer: SchedulerTimer; + args: unknown[]; +} + +const debounceMap = new Map>(); +const throttleMap = new Map>(); + +function dedupeEntries( + map: Map>, + target: Target +): Map { + let targetKey = target ?? NULL_TARGET; + let byMethod = map.get(targetKey); + if (byMethod === undefined) { + byMethod = new Map(); + map.set(targetKey, byMethod); + } + return byMethod; +} + +export function debounce(...args: unknown[]): SchedulerTimer { + let immediate = false; + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; + } + let wait = popWait(args, 0); + let { target, method, args: methodArgs } = parseArgs(args); + + let byMethod = dedupeEntries(debounceMap, target); + let entry = byMethod.get(method); + + if (entry === undefined) { + entry = { timer: makeTimer(), args: methodArgs }; + if (immediate) { + invokeWithOnError(target, method, methodArgs); + } + } else { + // Restart the wait period; latest arguments win. Disarm the previous + // timeout (this also removes the dedupe entry, re-added below). + entry.args = methodArgs; + entry.timer.cleanup?.(); + } + + let current = entry; + byMethod.set(method, current); + setTimer( + current.timer, + wait, + () => { + if (!immediate) invokeWithOnError(target, method, current.args); + }, + () => byMethod.delete(method) + ); + + return current.timer; +} + +export function throttle(...args: unknown[]): SchedulerTimer { + let immediate = true; + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; + } + let wait = popWait(args, 0); + let { target, method, args: methodArgs } = parseArgs(args); + + let byMethod = dedupeEntries(throttleMap, target); + let existing = byMethod.get(method); + + if (existing !== undefined) { + // Within the spacing period: coalesce into the existing timer. + existing.args = methodArgs; + return existing.timer; + } + + if (immediate) { + invokeWithOnError(target, method, methodArgs); + } + + let timer = makeTimer(); + let entry: DedupedTimerEntry = { timer, args: methodArgs }; + byMethod.set(method, entry); + + setTimer( + timer, + wait, + () => { + if (!immediate) invokeWithOnError(target, method, entry.args); + }, + () => byMethod.delete(method) + ); + + return timer; +} + +export function cancel(timer?: unknown): boolean { + if (!isSchedulerTimer(timer) || timer.cancelled || timer.finished) { + return false; + } + timer.cancelled = true; + finish(timer); + timer.cleanup?.(); + return true; +} + +export function hasTimers(): boolean { + return pendingTimerCount > 0; +} + +export function cancelTimers(): void { + for (let timer of Array.from(activeTimers)) { + cancel(timer); + } +} + +// Whether any work (queue items, phases, or timers) is still pending. +// The settled()/test-waiter integration described by RFC 0957 hangs off of +// this. +export function _hasPendingWork(): boolean { + return pendingCount > 0; +} diff --git a/packages/@ember/runloop/index.ts b/packages/@ember/runloop/index.ts index db079ea25b2..fa2fbc3989a 100644 --- a/packages/@ember/runloop/index.ts +++ b/packages/@ember/runloop/index.ts @@ -1,8 +1,10 @@ import { assert } from '@ember/debug'; +import { ENV } from '@ember/-internals/environment/lib/env'; import { onErrorTarget } from '@ember/-internals/error-handling'; import { flushAsyncObservers } from '@ember/-internals/metal/lib/observer'; import Backburner, { type Timer, type DeferredActionQueues } from 'backburner.js'; import type { AnyFn } from '@ember/-internals/utility-types'; +import * as schedulerLoop from './-private/scheduler-loop'; export type { Timer }; @@ -146,6 +148,9 @@ export function run( ...args: T[U] extends AnyFn ? Parameters : [] ): T[U] extends AnyFn ? ReturnType : unknown; export function run(...args: any[]): unknown { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.run(...args); + } // @ts-expect-error TS doesn't like our spread args return _backburner.run(...args); } @@ -206,6 +211,9 @@ export function join( ...args: T[U] extends AnyFn ? Parameters : [] ): T[U] extends AnyFn ? ReturnType | void : void; export function join(methodOrTarget: any, methodOrArg?: any, ...additionalArgs: any[]): any { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.join(methodOrTarget, methodOrArg, ...additionalArgs); + } return _backburner.join(methodOrTarget, methodOrArg, ...additionalArgs); } @@ -351,6 +359,10 @@ export function bind(...curried: any[]): any { @public */ export function begin() { + if (ENV._USE_ASYNC_SCHEDULER) { + // There is no runloop to open; work is scheduled as it arrives. + return; + } _backburner.begin(); } @@ -374,6 +386,9 @@ export function begin() { @public */ export function end() { + if (ENV._USE_ASYNC_SCHEDULER) { + return; + } _backburner.end(); } @@ -436,17 +451,28 @@ export function schedule( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function schedule(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + // @ts-expect-error TS doesn't like the rest args here + return schedulerLoop.schedule(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.schedule(...args); } // Used by global test teardown export function _hasScheduledTimers() { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.hasTimers(); + } return _backburner.hasTimers(); } // Used by global test teardown export function _cancelTimers() { + if (ENV._USE_ASYNC_SCHEDULER) { + schedulerLoop.cancelTimers(); + return; + } _backburner.cancelTimers(); } @@ -495,6 +521,9 @@ export function later( ...args: [...args: T[U] extends AnyFn ? Parameters : [], wait: string | number] ): Timer; export function later(...args: any): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.later(...args) as unknown as Timer; + } return _backburner.later(...args); } @@ -525,6 +554,9 @@ export function once( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function once(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.scheduleOnce('actions', ...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.scheduleOnce('actions', ...args); } @@ -619,6 +651,10 @@ export function scheduleOnce( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function scheduleOnce(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + // @ts-expect-error TS doesn't like the rest args here + return schedulerLoop.scheduleOnce(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.scheduleOnce(...args); } @@ -705,6 +741,9 @@ export function next( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function next(...args: any[]) { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.next(...args) as unknown as Timer; + } return _backburner.later(...args, 1); } @@ -776,6 +815,9 @@ export function next(...args: any[]) { @public */ export function cancel(timer?: Timer): boolean { + if (schedulerLoop.isSchedulerTimer(timer)) { + return schedulerLoop.cancel(timer); + } return _backburner.cancel(timer); } @@ -872,6 +914,9 @@ export function debounce( ] ): Timer; export function debounce(...args: any[]) { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.debounce(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.debounce(...args); } @@ -938,6 +983,9 @@ export function throttle( ] ): Timer; export function throttle(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.throttle(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.throttle(...args); } diff --git a/packages/@ember/runloop/package.json b/packages/@ember/runloop/package.json index e0b194a2af7..63ac517f2f6 100644 --- a/packages/@ember/runloop/package.json +++ b/packages/@ember/runloop/package.json @@ -12,6 +12,7 @@ "@ember/array": "workspace:*", "@ember/debug": "workspace:*", "@ember/object": "workspace:*", + "@ember/scheduler": "workspace:*", "@ember/utils": "workspace:*", "@glimmer/env": "workspace:*", "@glimmer/validator": "workspace:*", diff --git a/packages/@ember/runloop/tests/use_async_scheduler_test.js b/packages/@ember/runloop/tests/use_async_scheduler_test.js new file mode 100644 index 00000000000..0336426b5f6 --- /dev/null +++ b/packages/@ember/runloop/tests/use_async_scheduler_test.js @@ -0,0 +1,218 @@ +import { ENV } from '@ember/-internals/environment'; +import { _clearRegisteredStrategy } from '@ember/scheduler'; +import { renderSettled } from '@ember/-internals/glimmer'; +import { + run, + join, + bind, + schedule, + scheduleOnce, + once, + next, + later, + cancel, + debounce, + throttle, + _hasScheduledTimers, + _cancelTimers, +} from '..'; +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; + +moduleFor( + 'system/run_loop/use_async_scheduler_test', + class extends AbstractTestCase { + beforeEach() { + ENV._USE_ASYNC_SCHEDULER = true; + _clearRegisteredStrategy(); + } + + teardown() { + _cancelTimers(); + ENV._USE_ASYNC_SCHEDULER = false; + _clearRegisteredStrategy(); + } + + ['@test run executes the callback synchronously and returns its value'](assert) { + let order = []; + + let result = run(() => { + order.push('inside'); + return 42; + }); + + order.push('after'); + + assert.equal(result, 42, 'returns the callback value'); + assert.deepEqual(order, ['inside', 'after'], 'callback ran synchronously'); + } + + ['@test run resolves a string method on a target'](assert) { + let target = { + count: 0, + increment(amount) { + this.count += amount; + return this.count; + }, + }; + + let result = run(target, 'increment', 5); + + assert.equal(result, 5, 'method invoked with target as this'); + } + + ['@test join executes directly and returns its value'](assert) { + let result = join(() => 'joined'); + assert.equal(result, 'joined', 'join returns the callback value'); + } + + ['@test bind returns a function that executes in the bound context'](assert) { + let target = { + name: 'target', + getName() { + return this.name; + }, + }; + + let bound = bind(target, target.getName); + + assert.equal(bound(), 'target', 'bound function sees its target'); + } + + async ['@test schedule("actions") defers to a microtask'](assert) { + let order = []; + + schedule('actions', () => order.push('scheduled')); + order.push('sync'); + + assert.deepEqual(order, ['sync'], 'not invoked synchronously'); + + await Promise.resolve(); + + assert.deepEqual(order, ['sync', 'scheduled'], 'invoked on the microtask queue'); + } + + async ['@test schedule("render") and schedule("afterRender") run in phase order'](assert) { + let order = []; + let done = new Promise((resolve) => { + schedule('afterRender', () => { + order.push('afterRender'); + resolve(); + }); + }); + schedule('render', () => order.push('render')); + schedule('actions', () => order.push('actions')); + + await done; + + assert.deepEqual( + order, + ['actions', 'render', 'afterRender'], + 'microtask before render phase before layout phase' + ); + } + + async ['@test a scheduled item can be cancelled'](assert) { + let hasRan = false; + + let timer = schedule('actions', () => (hasRan = true)); + let cancelled = cancel(timer); + + await Promise.resolve(); + + assert.true(cancelled, 'cancel reported success'); + assert.false(hasRan, 'cancelled item did not run'); + } + + async ['@test scheduleOnce deduplicates by queue, target and method'](assert) { + let invocations = []; + let target = { + record(value) { + invocations.push(value); + }, + }; + + let first = scheduleOnce('actions', target, 'record', 1); + let second = scheduleOnce('actions', target, 'record', 2); + + assert.strictEqual(first, second, 'both calls share one timer'); + + await Promise.resolve(); + + assert.deepEqual(invocations, [2], 'ran once, with the latest arguments'); + } + + async ['@test once deduplicates on the actions queue'](assert) { + let count = 0; + let increment = () => count++; + + once(increment); + once(increment); + + await Promise.resolve(); + + assert.equal(count, 1, 'ran once'); + } + + async ['@test next schedules into the next phase'](assert) { + let hasRan = false; + + await new Promise((resolve) => { + next(() => { + hasRan = true; + resolve(); + }); + }); + + assert.true(hasRan, 'next callback ran'); + } + + async ['@test later fires after the wait and can be cancelled'](assert) { + let fired = []; + + later(() => fired.push('kept'), 1); + let timer = later(() => fired.push('cancelled'), 1); + + assert.true(_hasScheduledTimers(), 'timers are pending'); + + cancel(timer); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(fired, ['kept'], 'only the un-cancelled timer fired'); + assert.false(_hasScheduledTimers(), 'no timers remain'); + } + + async ['@test debounce collapses repeated calls into one trailing invocation'](assert) { + let invocations = []; + let record = (value) => invocations.push(value); + + debounce(null, record, 1, 5); + debounce(null, record, 2, 5); + debounce(null, record, 3, 5); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + assert.deepEqual(invocations, [3], 'ran once with the latest arguments'); + } + + async ['@test throttle invokes on the leading edge and coalesces the rest'](assert) { + let invocations = []; + let record = (value) => invocations.push(value); + + throttle(null, record, 1, 20); + throttle(null, record, 2, 20); + throttle(null, record, 3, 20); + + assert.deepEqual(invocations, [1], 'invoked immediately, once'); + + await new Promise((resolve) => setTimeout(resolve, 40)); + + assert.deepEqual(invocations, [1], 'no trailing invocation'); + } + + async ['@test renderSettled resolves through the scheduler'](assert) { + await renderSettled(); + assert.ok(true, 'renderSettled resolved with no backburner runloop'); + } + } +); diff --git a/packages/@ember/scheduler/index.ts b/packages/@ember/scheduler/index.ts index 2410c394eff..03b120f2cb2 100644 --- a/packages/@ember/scheduler/index.ts +++ b/packages/@ember/scheduler/index.ts @@ -123,6 +123,12 @@ export function _clearRegisteredStrategy(): void { registeredStrategy = null; } +// Private API used by the runloop's scheduler backend to fall back to the +// default strategy when the app has not registered one. +export function _getRegisteredStrategy(): Strategy | null { + return registeredStrategy; +} + function getStrategy(phaseName: string): Strategy { assert( `Attempted to schedule work into the '${phaseName}' phase, but no scheduling strategy is registered. Register a strategy when defining your Application, e.g. the default strategy:\n\n\timport { registerStrategy } from '@ember/scheduler';\n\timport strategy from '@ember/scheduler/strategy';\n\n\tregisterStrategy(strategy);`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 626d07ec2f0..bd9b0a6037a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1060,6 +1060,9 @@ importers: '@ember/object': specifier: workspace:* version: link:../object + '@ember/scheduler': + specifier: workspace:* + version: link:../scheduler '@ember/utils': specifier: workspace:* version: link:../utils diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index 55c10adb023..22c8e11a12a 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -10,6 +10,7 @@ module.exports = { '_DEBUG_RENDER_TREE', '_DEFAULT_ASYNC_OBSERVERS', '_RERENDER_LOOP_LIMIT', + '_USE_ASYNC_SCHEDULER', '_ALL_DEPRECATIONS_ENABLED', '_OVERRIDE_DEPRECATION_VERSION', 'Input',