From a9f197d3a5b13c614a13379cfc3e104903fe929b 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] 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. Co-Authored-By: Claude Fable 5 --- package.json | 2 + packages/@ember/scheduler/index.ts | 265 ++++++++++++++++++ packages/@ember/scheduler/package.json | 14 + packages/@ember/scheduler/strategy.ts | 182 ++++++++++++ .../@ember/scheduler/tests/scheduler_test.js | 102 +++++++ .../@ember/scheduler/tests/strategy_test.js | 115 ++++++++ pnpm-lock.yaml | 9 + tests/docs/expected.cjs | 6 + type-tests/@ember/scheduler-test.ts | 28 ++ 9 files changed, 723 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 a6d06d2316d..0bfd99f6fc3 100644 --- a/package.json +++ b/package.json @@ -281,6 +281,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..a170f6757b4 --- /dev/null +++ b/packages/@ember/scheduler/strategy.ts @@ -0,0 +1,182 @@ +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') { + requestIdleCallback(() => resolve()); + } 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..86b48fd8cfb --- /dev/null +++ b/packages/@ember/scheduler/tests/strategy_test.js @@ -0,0 +1,115 @@ +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(); + + let nextPromise = strategy.next().then(() => order.push('next (this frame)')); + let compositePromise = strategy.composite().then(() => order.push('composite (next frame)')); + + await Promise.all([nextPromise, compositePromise]); + + assert.deepEqual(order, ['next (this frame)', '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 25ffa279b3e..0998c2acc83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1073,6 +1073,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 db1994ff30a..2d2855471ec 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -119,6 +119,7 @@ module.exports = { 'component', 'compute', 'computed', + 'composite', 'concat', 'concatenatedProperties', 'container', @@ -250,6 +251,7 @@ module.exports = { 'helper', 'htmlSafe', 'trustHTML', + 'idle', 'if', 'in-element', 'includes', @@ -400,6 +402,7 @@ module.exports = { 'registeredOptionsForType', 'registerOptions', 'registerOptionsForType', + 'registerStrategy', 'registerWaiter', 'registerWarnHandler', 'registrations', @@ -412,6 +415,7 @@ module.exports = { 'removeObject', 'removeObjects', 'removeObserver', + 'render', 'renderComponent', 'renderSettled', 'reopen', @@ -629,6 +633,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() });