From 494fa5e6f753d46c55107041c8f76732c31aa4fb Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:15:42 -0400 Subject: [PATCH 1/2] Hook the renderer into the scheduler through a classic-default adapter The scheduler interface gains its first consumer. The renderer now always schedules revalidation through the active strategy via an internal, callback-based seam (_scheduleRevalidate) -- revalidation is hotter than any user phase, so the renderer does not allocate promises per invalidation. The ambient default is a new ClassicStrategy that reproduces today's behavior exactly: revalidation is the same scheduleOnce('render', ...) with the same dedupe (the flush callback is stable per renderer), and the public phase functions map onto the runloop's queues (render -> 'render', layout -> 'afterRender', composite behind layout within the same flush, next -> runloop next, idle -> requestIdleCallback with a starvation timeout). No registration required; existing applications observe no timing change -- the full suite passes unmodified. registerStrategy remains the swap seam: a render-aware strategy that implements _scheduleRevalidate takes over revalidation scheduling, which is where the performance benefits arrive when such a strategy becomes the default. A registered strategy without the seam leaves the renderer on classic scheduling. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 23 ++++++- packages/@ember/scheduler/-private/classic.ts | 63 +++++++++++++++++++ packages/@ember/scheduler/index.ts | 43 +++++++++---- packages/@ember/scheduler/package.json | 5 +- .../@ember/scheduler/tests/scheduler_test.js | 42 ++++++++++--- 5 files changed, 155 insertions(+), 21 deletions(-) create mode 100644 packages/@ember/scheduler/-private/classic.ts diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 955511c4389..da459ecd36e 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -30,6 +30,7 @@ import type { SimpleDocument, SimpleElement } from '@simple-dom/interface'; import { hasDOM } from '../../browser-environment'; import { EmberEnvironmentDelegate } from './environment'; import ResolverImpl from './resolver'; +import { _getStrategy } from '@ember/scheduler'; import { EvaluationContextImpl } from '@glimmer/opcode-compiler/lib/program-context'; export type IBuilder = (env: Environment, cursor: Cursor) => TreeBuilder; @@ -368,8 +369,28 @@ export class RendererState { } } + #renderer: BaseRenderer | null = null; + + // stable identity so strategies (and classic scheduleOnce dedupe) can + // coalesce repeat scheduling between flushes + #revalidateCurrent = (): void => { + if (this.#renderer !== null) { + this.revalidate(this.#renderer); + } + }; + scheduleRevalidate(renderer: BaseRenderer): void { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); + this.#renderer = renderer; + + const strategy = _getStrategy(); + + if (strategy._scheduleRevalidate !== undefined) { + strategy._scheduleRevalidate(this.#revalidateCurrent); + } else { + // a registered strategy without the internal seam leaves the + // renderer on classic runloop scheduling + _backburner.scheduleOnce('render', this, this.revalidate, renderer); + } } isValid(): boolean { diff --git a/packages/@ember/scheduler/-private/classic.ts b/packages/@ember/scheduler/-private/classic.ts new file mode 100644 index 00000000000..34ffe4435bb --- /dev/null +++ b/packages/@ember/scheduler/-private/classic.ts @@ -0,0 +1,63 @@ +import { _backburner, next as runloopNext, schedule } from '@ember/runloop'; +import type { Strategy } from '@ember/scheduler'; + +/** + * The ambient default strategy: schedules exactly the way Ember works + * today, so existing applications observe no change in timing. Phases + * map onto the runloop's queues (`render`, then `afterRender`, with + * `composite` re-scheduled behind `layout`'s queue entries within the + * same flush), and the renderer's revalidation is a + * `scheduleOnce('render', ...)`, just as it always was. + * + * Render-aware scheduling (frame-aligned phases, coalesced + * revalidation) is what a swapped-in strategy provides -- see + * `@ember/scheduler/strategy` -- and becomes the source of performance + * wins when it becomes the default. + * + * @internal + */ +class ClassicStrategy implements Strategy { + render(): Promise { + return new Promise((resolve) => schedule('render', null, resolve)); + } + + layout(): Promise { + return new Promise((resolve) => schedule('afterRender', null, resolve)); + } + + composite(): Promise { + return new Promise((resolve) => + schedule('afterRender', null, () => schedule('afterRender', null, resolve)) + ); + } + + next(): Promise { + return new Promise((resolve) => runloopNext(null, resolve)); + } + + idle(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === 'function') { + // fully-idle or backgrounded pages can starve requestIdleCallback + // indefinitely; cap the wait to keep the promise resolvable + requestIdleCallback(() => resolve(), { timeout: 500 }); + } else { + setTimeout(resolve, 0); + } + }); + } + + /** + * The renderer's internal seam: how revalidation gets scheduled. + * Classic behavior is a runloop `scheduleOnce`, preserving today's + * timing exactly (the flush callback is stable per renderer, so + * scheduleOnce's dedupe applies as before). + */ + _scheduleRevalidate(flush: () => void): void { + _backburner.scheduleOnce('render', null, flush); + } +} + +const classicStrategy: ClassicStrategy = new ClassicStrategy(); + +export default classicStrategy; diff --git a/packages/@ember/scheduler/index.ts b/packages/@ember/scheduler/index.ts index 2410c394eff..a837f4d8d1f 100644 --- a/packages/@ember/scheduler/index.ts +++ b/packages/@ember/scheduler/index.ts @@ -1,4 +1,5 @@ import { assert } from '@ember/debug'; +import classicStrategy from '@ember/scheduler/-private/classic'; /** The `@ember/scheduler` package provides a render-aware scheduling interface, @@ -67,6 +68,18 @@ export interface Strategy { composite(): Promise; next(): Promise; idle(): Promise; + + /** + * Internal seam used by the renderer to schedule revalidation. The + * public phase functions are for user work; revalidation is hotter + * than any user phase, so the renderer talks to the strategy through + * this callback-based hook rather than allocating promises per + * invalidation. Optional: strategies that do not implement it leave + * the renderer on its classic runloop scheduling. + * + * @internal + */ + _scheduleRevalidate?(flush: () => void): void; } let registeredStrategy: Strategy | null = null; @@ -123,12 +136,20 @@ 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; +function getStrategy(): Strategy { + // the classic strategy is the ambient default: existing applications + // keep today's runloop scheduling with no registration required, and + // `registerStrategy` swaps in a render-aware implementation + return registeredStrategy ?? classicStrategy; +} + +/** + * The renderer's accessor for the active strategy. + * + * @internal + */ +export function _getStrategy(): Strategy { + return getStrategy(); } /** @@ -156,7 +177,7 @@ function getStrategy(phaseName: string): Strategy { @public */ export function render(): Promise { - return getStrategy('render').render(); + return getStrategy().render(); } /** @@ -182,7 +203,7 @@ export function render(): Promise { @public */ export function layout(): Promise { - return getStrategy('layout').layout(); + return getStrategy().layout(); } /** @@ -212,7 +233,7 @@ export function layout(): Promise { @public */ export function composite(): Promise { - return getStrategy('composite').composite(); + return getStrategy().composite(); } /** @@ -237,7 +258,7 @@ export function composite(): Promise { @public */ export function next(): Promise { - return getStrategy('next').next(); + return getStrategy().next(); } /** @@ -261,5 +282,5 @@ export function next(): Promise { @public */ export function idle(): Promise { - return getStrategy('idle').idle(); + return getStrategy().idle(); } diff --git a/packages/@ember/scheduler/package.json b/packages/@ember/scheduler/package.json index 5bd1d404312..db843346fb5 100644 --- a/packages/@ember/scheduler/package.json +++ b/packages/@ember/scheduler/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@ember/debug": "workspace:*", - "internal-test-helpers": "workspace:*" + "internal-test-helpers": "workspace:*", + "@ember/runloop": "workspace:*" } -} +} \ No newline at end of file diff --git a/packages/@ember/scheduler/tests/scheduler_test.js b/packages/@ember/scheduler/tests/scheduler_test.js index 0f14cc8fc7f..69b4a0f9b0d 100644 --- a/packages/@ember/scheduler/tests/scheduler_test.js +++ b/packages/@ember/scheduler/tests/scheduler_test.js @@ -6,6 +6,7 @@ import { idle, registerStrategy, _clearRegisteredStrategy, + _getStrategy, } from '..'; import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; @@ -45,14 +46,41 @@ moduleFor( _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/); - } + async ['@test phase functions fall back to the classic strategy when none is registered']( + assert + ) { + // no registerStrategy call: the ambient classic default handles + // phases with runloop semantics + let order = []; + + await Promise.all([ + composite().then(() => order.push('composite')), + layout().then(() => order.push('layout')), + render().then(() => order.push('render')), + ]); + + assert.deepEqual(order, ['render', 'layout', 'composite']); + } + + ['@test the renderer seam prefers a registered strategy that implements it'](assert) { + let scheduled = []; + + registerStrategy({ + render: () => Promise.resolve(), + layout: () => Promise.resolve(), + composite: () => Promise.resolve(), + next: () => Promise.resolve(), + idle: () => Promise.resolve(), + _scheduleRevalidate(flush) { + scheduled.push(flush); + }, + }); + + let flush = () => {}; + _getStrategy()._scheduleRevalidate(flush); - assert.expect(5); + assert.strictEqual(scheduled.length, 1, 'the registered strategy received the flush'); + assert.strictEqual(scheduled[0], flush, 'with the stable callback'); } ['@test phase functions delegate to the registered strategy'](assert) { From df40e9e43da81e59e41d1b84b8b87776aabcff26 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:42:44 -0400 Subject: [PATCH 2/2] Register the classic strategy at the glimmer hookup, drop the accessors Review feedback: no getStrategy indirection and no ambient fallback. The active strategy is an exported live binding the phase functions and the renderer access directly (with a pre-boot assert), and the classic default is established where the glimmer<->ember hookup happens -- registerStrategy(classicStrategy) at environment initialization. registerStrategy permits one application swap over the classic default and still asserts on conflicting registrations. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 10 +-- .../-internals/glimmer/lib/environment.ts | 9 +++ packages/@ember/scheduler/index.ts | 63 +++++++++++-------- .../@ember/scheduler/tests/scheduler_test.js | 43 ++++++++++--- 4 files changed, 88 insertions(+), 37 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index da459ecd36e..83078e3e92c 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -30,7 +30,7 @@ import type { SimpleDocument, SimpleElement } from '@simple-dom/interface'; import { hasDOM } from '../../browser-environment'; import { EmberEnvironmentDelegate } from './environment'; import ResolverImpl from './resolver'; -import { _getStrategy } from '@ember/scheduler'; +import { _registeredStrategy } from '@ember/scheduler'; import { EvaluationContextImpl } from '@glimmer/opcode-compiler/lib/program-context'; export type IBuilder = (env: Environment, cursor: Cursor) => TreeBuilder; @@ -382,13 +382,13 @@ export class RendererState { scheduleRevalidate(renderer: BaseRenderer): void { this.#renderer = renderer; - const strategy = _getStrategy(); + const strategy = _registeredStrategy; - if (strategy._scheduleRevalidate !== undefined) { + if (strategy !== null && strategy._scheduleRevalidate !== undefined) { strategy._scheduleRevalidate(this.#revalidateCurrent); } else { - // a registered strategy without the internal seam leaves the - // renderer on classic runloop scheduling + // pre-boot (no strategy registered yet) or a registered strategy + // without the internal seam: classic runloop scheduling _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..d292af27570 100644 --- a/packages/@ember/-internals/glimmer/lib/environment.ts +++ b/packages/@ember/-internals/glimmer/lib/environment.ts @@ -9,6 +9,8 @@ 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 { registerStrategy } from '@ember/scheduler'; +import classicStrategy from '@ember/scheduler/-private/classic'; import type { EnvironmentDelegate } from '@glimmer/runtime/lib/environment'; import { debug } from '@glimmer/validator/lib/debug'; import toIterator from './utils/iterator'; @@ -17,6 +19,13 @@ import toBool from './utils/to-bool'; /////////// +// The glimmer<->ember hookup is where the framework's scheduling +// strategy is established: classic (today's runloop scheduling) unless +// an application swaps it via `registerStrategy`. +registerStrategy(classicStrategy); + +/////////// + // Setup global context setGlobalContext({ diff --git a/packages/@ember/scheduler/index.ts b/packages/@ember/scheduler/index.ts index a837f4d8d1f..b2e17e7cb04 100644 --- a/packages/@ember/scheduler/index.ts +++ b/packages/@ember/scheduler/index.ts @@ -82,7 +82,14 @@ export interface Strategy { _scheduleRevalidate?(flush: () => void): void; } -let registeredStrategy: Strategy | null = null; +/** + * The active strategy, as a live binding for the renderer's hot path. + * The framework registers the classic strategy at the glimmer<->ember + * hookup during boot, so this is non-null in any booted application. + * + * @internal + */ +export let _registeredStrategy: Strategy | null = null; /** Registers the scheduling strategy which the phase functions of @@ -126,30 +133,16 @@ let registeredStrategy: Strategy | null = null; 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 === null || + _registeredStrategy === strategy || + _registeredStrategy === classicStrategy ); - registeredStrategy = strategy; + _registeredStrategy = strategy; } // Private API used by tests to swap out the registered strategy. export function _clearRegisteredStrategy(): void { - registeredStrategy = null; -} - -function getStrategy(): Strategy { - // the classic strategy is the ambient default: existing applications - // keep today's runloop scheduling with no registration required, and - // `registerStrategy` swaps in a render-aware implementation - return registeredStrategy ?? classicStrategy; -} - -/** - * The renderer's accessor for the active strategy. - * - * @internal - */ -export function _getStrategy(): Strategy { - return getStrategy(); + _registeredStrategy = null; } /** @@ -177,7 +170,11 @@ export function _getStrategy(): Strategy { @public */ export function render(): Promise { - return getStrategy().render(); + assert( + `Attempted to schedule work into the 'render' phase before a scheduling strategy was available. The framework registers the default strategy during boot.`, + _registeredStrategy !== null + ); + return _registeredStrategy.render(); } /** @@ -203,7 +200,11 @@ export function render(): Promise { @public */ export function layout(): Promise { - return getStrategy().layout(); + assert( + `Attempted to schedule work into the 'layout' phase before a scheduling strategy was available. The framework registers the default strategy during boot.`, + _registeredStrategy !== null + ); + return _registeredStrategy.layout(); } /** @@ -233,7 +234,11 @@ export function layout(): Promise { @public */ export function composite(): Promise { - return getStrategy().composite(); + assert( + `Attempted to schedule work into the 'composite' phase before a scheduling strategy was available. The framework registers the default strategy during boot.`, + _registeredStrategy !== null + ); + return _registeredStrategy.composite(); } /** @@ -258,7 +263,11 @@ export function composite(): Promise { @public */ export function next(): Promise { - return getStrategy().next(); + assert( + `Attempted to schedule work into the 'next' phase before a scheduling strategy was available. The framework registers the default strategy during boot.`, + _registeredStrategy !== null + ); + return _registeredStrategy.next(); } /** @@ -282,5 +291,9 @@ export function next(): Promise { @public */ export function idle(): Promise { - return getStrategy().idle(); + assert( + `Attempted to schedule work into the 'idle' phase before a scheduling strategy was available. The framework registers the default strategy during boot.`, + _registeredStrategy !== null + ); + return _registeredStrategy.idle(); } diff --git a/packages/@ember/scheduler/tests/scheduler_test.js b/packages/@ember/scheduler/tests/scheduler_test.js index 69b4a0f9b0d..4260653bc3a 100644 --- a/packages/@ember/scheduler/tests/scheduler_test.js +++ b/packages/@ember/scheduler/tests/scheduler_test.js @@ -6,8 +6,9 @@ import { idle, registerStrategy, _clearRegisteredStrategy, - _getStrategy, + _registeredStrategy, } from '..'; +import classicStrategy from '../-private/classic'; import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; class StubStrategy { @@ -46,11 +47,25 @@ moduleFor( _clearRegisteredStrategy(); } - async ['@test phase functions fall back to the classic strategy when none is registered']( - assert - ) { - // no registerStrategy call: the ambient classic default handles - // phases with runloop semantics + ['@test phase functions assert before any strategy is registered'](assert) { + // the framework hookup registered classic when the bundle loaded; + // simulate the pre-boot state + _clearRegisteredStrategy(); + + for (let phase of [render, layout, composite, next, idle]) { + expectAssertion(() => { + phase(); + }, /before a scheduling strategy was available/); + } + + assert.expect(5); + } + + async ['@test the classic strategy resolves phases in runloop order'](assert) { + // the framework registers this at the glimmer<->ember hookup + // during boot; tests clear registration, so re-register here + registerStrategy(classicStrategy); + let order = []; await Promise.all([ @@ -77,7 +92,7 @@ moduleFor( }); let flush = () => {}; - _getStrategy()._scheduleRevalidate(flush); + _registeredStrategy._scheduleRevalidate(flush); assert.strictEqual(scheduled.length, 1, 'the registered strategy received the flush'); assert.strictEqual(scheduled[0], flush, 'with the stable callback'); @@ -112,6 +127,20 @@ moduleFor( } } + ['@test registerStrategy may replace the classic default, once'](assert) { + registerStrategy(classicStrategy); + + let strategy = new StubStrategy(); + registerStrategy(strategy); + + render(); + assert.deepEqual(strategy.calls, ['render'], 'the swapped-in strategy is active'); + + expectAssertion(() => { + registerStrategy(new StubStrategy()); + }, /a different scheduling strategy has already been registered/); + } + ['@test registerStrategy asserts when a different strategy is already registered'](assert) { let strategy = new StubStrategy(); registerStrategy(strategy);