From ff2ff075e8363864f3ccf9999e2c1a302f554bdb 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. 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 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..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 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() });