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 1/7] 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() }); From ed3e2abbfe863e27864e01f71d5f3d6a94fd4813 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:01:34 -0400 Subject: [PATCH 2/7] Skip unchanged {{#each}} item subtrees during updates The UpdatingVM walks every updating opcode of every list item on every render: cache groups (JumpIfNotModifiedOpcode) exist only at component boundaries, so a list of plain template rows revalidates every binding even when nothing in a row changed. Collect each item's consumed tags in a tracking frame (via a new frame-finalizer hook on UpdatingVMFrame) and skip the item's entire subtree while that combined tag validates. Trivial items opt out: for a text node or two, validating a combined tag costs as much as updating, so collection would be pure overhead. An item is trivial when it has <= 2 opcodes and no nested block -- a nested block child means an arbitrarily large subtree hides behind a small top-level count. dbmon-style workloads (fat rows, sparse changes): ~1.6x fps at 8x CPU throttle, ~6x (rAF-capped) at 4x. Dense-change / tiny-item workloads and the krausest bench: neutral. Co-Authored-By: Claude Fable 5 --- packages/@glimmer/runtime/lib/vm/update.ts | 84 ++++++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 92981cc0531..f9b21ca9694 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -16,6 +16,7 @@ import type { } from '@glimmer/interfaces'; import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable'; import type { Reference } from '@glimmer/reference/lib/reference'; +import type { Revision, Tag } from '@glimmer/interfaces'; import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; @@ -23,7 +24,8 @@ import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; +import { beginTrackFrame, consumeTag, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; +import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -77,7 +79,7 @@ export class UpdatingVM implements IUpdatingVM { let opcode = this.frame.nextStatement(); if (opcode === undefined) { - frameStack.pop(); + frameStack.pop()?.finalize(false); continue; } @@ -93,13 +95,13 @@ export class UpdatingVM implements IUpdatingVM { this.frame.goto(index); } - try(ops: UpdatingOpcode[], handler: Nullable) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + try(ops: UpdatingOpcode[], handler: Nullable, finalizer?: (didError: boolean) => void) { + this.frameStack.push(new UpdatingVMFrame(ops, handler, finalizer)); } throw() { this.frame.handleException(); - this.frameStack.pop(); + this.frameStack.pop()?.finalize(true); } } @@ -178,6 +180,15 @@ export class ListItemOpcode extends TryOpcode { public retained = false; public index = -1; + /** + * Everything this item's subtree consumed during its last update, + * combined. When still valid, the whole subtree is skipped -- one tag + * validation instead of walking every opcode in the item. + */ + private subtreeTag: Nullable = null; + private subtreeRevision: Revision = INITIAL; + private isTrivial: boolean | null = null; + constructor( state: Closure, context: EvaluationContext, @@ -189,6 +200,52 @@ export class ListItemOpcode extends TryOpcode { super(state, context, bounds, []); } + override evaluate(vm: UpdatingVM) { + // Trivial items (a text node or two) can't win: validating their + // combined tag costs as much as just updating them, so collection + // would be pure overhead. Skipping only pays off for items with a + // real subtree -- more than a couple of opcodes, or any nested + // block (a nested block child means an arbitrarily large subtree + // hides behind a small top-level count). + if (this.isTrivial ?? (this.isTrivial = computeIsTrivial(this.children))) { + vm.try(this.children, this); + return; + } + + let { subtreeTag } = this; + + if ( + subtreeTag !== null && + !vm.alwaysRevalidate && + validateTag(subtreeTag, this.subtreeRevision) + ) { + // propagate this item's dependencies to any enclosing tracking + // frame, exactly as executing the children would have + consumeTag(subtreeTag); + return; + } + + beginTrackFrame(); + vm.try(this.children, this, (didError) => { + // always balance beginTrackFrame, even when unwinding + let tag = endTrackFrame(); + + if (didError) return; + + this.subtreeTag = tag; + this.subtreeRevision = valueForTag(tag); + consumeTag(tag); + }); + } + + override handleException() { + // children are about to be rebuilt; the collected tag and triviality + // no longer describe them + this.subtreeTag = null; + this.isTrivial = null; + super.handleException(); + } + shouldRemove(): boolean { return !this.retained; } @@ -198,6 +255,16 @@ export class ListItemOpcode extends TryOpcode { } } +function computeIsTrivial(children: UpdatingOpcode[]): boolean { + if (children.length > 2) return false; + + for (const child of children) { + if (child instanceof BlockOpcode) return false; + } + + return true; +} + export class ListBlockOpcode extends BlockOpcode { public type = 'list-block'; declare public children: ListItemOpcode[]; @@ -428,7 +495,8 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + private finalizer?: (didError: boolean) => void ) {} goto(index: number) { @@ -439,6 +507,10 @@ class UpdatingVMFrame { return this.ops[this.current++]; } + finalize(didError: boolean) { + this.finalizer?.(didError); + } + handleException() { if (this.exceptionHandler) { this.exceptionHandler.handleException(); From b7a4e38c1a60de1a2c8c5135740531a7ca0a45c5 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:05:27 -0400 Subject: [PATCH 3/7] 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 }. Co-Authored-By: Claude Fable 5 --- packages/@ember/scheduler/strategy.ts | 5 ++++- .../@ember/scheduler/tests/strategy_test.js | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/@ember/scheduler/strategy.ts b/packages/@ember/scheduler/strategy.ts index a170f6757b4..602bd356b76 100644 --- a/packages/@ember/scheduler/strategy.ts +++ b/packages/@ember/scheduler/strategy.ts @@ -106,7 +106,10 @@ export class FrameStrategy implements Strategy { idle(): Promise { return new Promise((resolve) => { if (typeof requestIdleCallback === 'function') { - requestIdleCallback(() => resolve()); + // 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); } diff --git a/packages/@ember/scheduler/tests/strategy_test.js b/packages/@ember/scheduler/tests/strategy_test.js index 86b48fd8cfb..8ef9cb1cff8 100644 --- a/packages/@ember/scheduler/tests/strategy_test.js +++ b/packages/@ember/scheduler/tests/strategy_test.js @@ -1,3 +1,4 @@ +/* globals requestAnimationFrame: false */ import defaultStrategy, { FrameStrategy } from '../strategy'; import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; @@ -86,12 +87,23 @@ moduleFor( await strategy.composite(); - let nextPromise = strategy.next().then(() => order.push('next (this frame)')); + // 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([nextPromise, compositePromise]); + await Promise.all([frameBoundary, compositePromise]); - assert.deepEqual(order, ['next (this frame)', 'composite (next frame)']); + assert.deepEqual(order, ['frame 2 began', 'composite (next frame)']); } async ['@test work can be scheduled again after a frame completes'](assert) { From e562c085437209d8454b63d39e40a279629e0846 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:10:51 -0400 Subject: [PATCH 4/7] Adaptive tri-mode flush scheduler on clean main (scheduler lever only) Ports the race-flush scheduler from the spike stack onto upstream main with no other levers (no subtree-skip, no legacy-read deletion, no iteration fast paths), to isolate what scheduling alone is worth. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 955511c4389..5f6b9e5110b 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -134,6 +134,11 @@ export class ComponentRootState implements RendererRoot { } } +function flushViaChannel(state: RendererState): void { + // eslint-disable-next-line dot-notation -- private access helper + (state as any).runChannelFlush(); +} + const renderers: BaseRenderer[] = []; export function _resetRenderers() { @@ -197,10 +202,28 @@ function resolveRenderPromise() { } } +/** + * SPIKE: revalidation deferred to an animation frame is *expected* to + * leave the renderer invalid at runloop end -- the frame will handle + * it. Without this, loopEnd spins NO_OP runloops (recursing via join) + * until the loop guard throws. + */ +let framePending = false; + +export function setFramePending(value: boolean) { + framePending = value; +} + let loops = 0; function loopEnd() { for (let renderer of renderers) { if (!renderer.isValid()) { + if (framePending) { + // the scheduled frame will revalidate; its own runloop will + // re-enter loopEnd and resolve the render promise + return; + } + if (loops > ENV._RERENDER_LOOP_LIMIT) { loops = 0; // TODO: do something better @@ -368,8 +391,115 @@ export class RendererState { } } + #flushScheduled = false; + #rafHandle: number | null = null; + #channel: MessageChannel | null = null; + #channelArmed = false; + #flushesThisFrame = 0; + #lastFlushEnd = 0; + + /** + * SPIKE: task-coalesced flushing, zoneless-Angular shaped but + * adaptive: + * + * - a flush is scheduled as a race between an UNCLAMPED macrotask + * (MessageChannel -- setTimeout's 4ms nesting clamp would make + * render->microtask->set chains crawl) and requestAnimationFrame + * - every update inside the current task + microtasks coalesces into + * one flush; awaited (microtask) update loops stop paying a render + * per resume + * - adaptive frame alignment: when several flushes land within one + * frame (a sustained external stream like a worker firehose), the + * macrotask leg stands down and flushes ride rAF until the burst + * subsides + */ scheduleRevalidate(renderer: BaseRenderer): void { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); + if (typeof requestAnimationFrame !== 'function') { + _backburner.scheduleOnce('render', this, this.revalidate, renderer); + return; + } + + if (this.#flushScheduled) { + return; + } + + this.#flushScheduled = true; + setFramePending(true); + + const flush = (viaFrame: boolean) => { + if (!this.#flushScheduled) return; + + if (viaFrame) { + this.#flushesThisFrame = 0; + this.#rafHandle = null; + } else { + this.#flushesThisFrame++; + if (this.#rafHandle !== null) { + cancelAnimationFrame(this.#rafHandle); + this.#rafHandle = null; + } + } + + this.#flushScheduled = false; + setFramePending(false); + + _backburner.join(() => { + this.revalidate(renderer); + + // dirt produced synchronously by the render itself (e.g. an + // after-render effect advancing a loop) flushes in the same + // task; only dirt arriving between tasks waits + let guard = 0; + + while (!this.isValid() && guard++ < 1_000_000) { + this.revalidate(renderer); + } + }); + + this.#lastFlushEnd = performance.now(); + }; + + // Distinguish dependent chains from external streams: chain dirt + // (render -> microtask -> set) arrives ~immediately after the last + // flush; it flushes at MICROTASK speed (same task turn, like the + // classic runloop) so sequential render-coupled loops don't pay a + // task hop per step. This cannot defeat coalescing of awaited + // update loops: those drain their entire microtask chain before + // their FIRST flush ever runs. Stream dirt (worker messages) + // arrives whole milliseconds later in fresh tasks: it flushes on a + // race of unclamped macrotask + rAF, standing the macrotask leg + // down under sustained bursts so flushes ride the frame. + const isChain = performance.now() - this.#lastFlushEnd < 1; + + if (isChain) { + queueMicrotask(() => flush(false)); + return; + } + + this.#rafHandle = requestAnimationFrame(() => flush(true)); + + if (this.#flushesThisFrame < 3) { + if (this.#channel === null) { + this.#channel = new MessageChannel(); + this.#channel.port1.onmessage = () => { + this.#channelArmed = false; + flushViaChannel(this); + }; + } + + if (!this.#channelArmed) { + this.#channelArmed = true; + this.#currentFlush = flush; + this.#channel.port2.postMessage(null); + } + } + } + + #currentFlush: ((viaFrame: boolean) => void) | null = null; + + /** @internal channel-leg trampoline */ + runChannelFlush(): void { + this.#currentFlush?.(false); } isValid(): boolean { From 3a3b9e22819a842adf1eaf7bd089a14ca2f22db1 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:39:56 -0400 Subject: [PATCH 5/7] Diet the flush machinery; keep macrotask leg alive under rAF starvation Per-dirt-event allocation is gone: the microtask, frame, and revalidate-until-stable callbacks are persistent fields, and the chain leg pays one performance.now() per event instead of two. Dependent chains (render -> effect -> set) re-enter scheduleRevalidate once per step, so closure churn there was measurable GC pressure. The macrotask stand-down now only applies while rAF is being serviced: backgrounded/occluded pages stop firing rAF, and since the per-frame counter is only reset by a frame firing, standing down there stranded all rendering until the tab became visible again. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 121 ++++++++++-------- 1 file changed, 68 insertions(+), 53 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 5f6b9e5110b..0a403dc4b1f 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -134,11 +134,6 @@ export class ComponentRootState implements RendererRoot { } } -function flushViaChannel(state: RendererState): void { - // eslint-disable-next-line dot-notation -- private access helper - (state as any).runChannelFlush(); -} - const renderers: BaseRenderer[] = []; export function _resetRenderers() { @@ -392,11 +387,63 @@ export class RendererState { } #flushScheduled = false; + #renderer: BaseRenderer | null = null; #rafHandle: number | null = null; #channel: MessageChannel | null = null; #channelArmed = false; #flushesThisFrame = 0; #lastFlushEnd = 0; + #lastFrameAt = 0; + + // scheduling must not allocate per dirt event: dependent chains + // (render -> effect -> set) re-enter scheduleRevalidate once per step, + // and per-step closures were measurable GC pressure in exactly that + // case, so every callback here is persistent + #microtaskFlush = () => this.#flush(false); + + #frameFlush = () => { + this.#lastFrameAt = performance.now(); + this.#flush(true); + }; + + #revalidateUntilStable = () => { + const renderer = this.#renderer; + + if (renderer === null) return; + + this.revalidate(renderer); + + // dirt produced synchronously by the render itself (e.g. an + // after-render effect advancing a loop) flushes in the same task; + // only dirt arriving between tasks waits + let guard = 0; + + while (!this.isValid() && guard++ < 1_000_000) { + this.revalidate(renderer); + } + }; + + #flush(viaFrame: boolean): void { + if (!this.#flushScheduled) return; + + if (viaFrame) { + this.#flushesThisFrame = 0; + this.#rafHandle = null; + } else { + this.#flushesThisFrame++; + if (this.#rafHandle !== null) { + cancelAnimationFrame(this.#rafHandle); + this.#rafHandle = null; + } + } + + this.#flushScheduled = false; + setFramePending(false); + + _backburner.join(this.#revalidateUntilStable); + + this.#lastFlushEnd = performance.now(); + } /** * SPIKE: task-coalesced flushing, zoneless-Angular shaped but @@ -411,7 +458,8 @@ export class RendererState { * - adaptive frame alignment: when several flushes land within one * frame (a sustained external stream like a worker firehose), the * macrotask leg stands down and flushes ride rAF until the burst - * subsides + * subsides -- unless rAF itself has stopped being serviced (see + * below) */ scheduleRevalidate(renderer: BaseRenderer): void { if (typeof requestAnimationFrame !== 'function') { @@ -423,41 +471,11 @@ export class RendererState { return; } + this.#renderer = renderer; this.#flushScheduled = true; setFramePending(true); - const flush = (viaFrame: boolean) => { - if (!this.#flushScheduled) return; - - if (viaFrame) { - this.#flushesThisFrame = 0; - this.#rafHandle = null; - } else { - this.#flushesThisFrame++; - if (this.#rafHandle !== null) { - cancelAnimationFrame(this.#rafHandle); - this.#rafHandle = null; - } - } - - this.#flushScheduled = false; - setFramePending(false); - - _backburner.join(() => { - this.revalidate(renderer); - - // dirt produced synchronously by the render itself (e.g. an - // after-render effect advancing a loop) flushes in the same - // task; only dirt arriving between tasks waits - let guard = 0; - - while (!this.isValid() && guard++ < 1_000_000) { - this.revalidate(renderer); - } - }); - - this.#lastFlushEnd = performance.now(); - }; + const now = performance.now(); // Distinguish dependent chains from external streams: chain dirt // (render -> microtask -> set) arrives ~immediately after the last @@ -469,39 +487,36 @@ export class RendererState { // arrives whole milliseconds later in fresh tasks: it flushes on a // race of unclamped macrotask + rAF, standing the macrotask leg // down under sustained bursts so flushes ride the frame. - const isChain = performance.now() - this.#lastFlushEnd < 1; - - if (isChain) { - queueMicrotask(() => flush(false)); + if (now - this.#lastFlushEnd < 1) { + queueMicrotask(this.#microtaskFlush); return; } - this.#rafHandle = requestAnimationFrame(() => flush(true)); + this.#rafHandle = requestAnimationFrame(this.#frameFlush); - if (this.#flushesThisFrame < 3) { + // The stand-down only applies while rAF is actually being serviced: + // backgrounded/occluded pages stop firing rAF entirely, and since + // the per-frame counter is only reset by a frame firing, standing + // the macrotask leg down there would strand all rendering until the + // tab becomes visible again. + const rafStarved = now - this.#lastFrameAt > 250; + + if (this.#flushesThisFrame < 3 || rafStarved) { if (this.#channel === null) { this.#channel = new MessageChannel(); this.#channel.port1.onmessage = () => { this.#channelArmed = false; - flushViaChannel(this); + this.#flush(false); }; } if (!this.#channelArmed) { this.#channelArmed = true; - this.#currentFlush = flush; this.#channel.port2.postMessage(null); } } } - #currentFlush: ((viaFrame: boolean) => void) | null = null; - - /** @internal channel-leg trampoline */ - runChannelFlush(): void { - this.#currentFlush?.(false); - } - isValid(): boolean { return ( this.#destroyed || this.#roots.length === 0 || validateTag(CURRENT_TAG, this.#lastRevision) From a80bce4cfdbf0da1298772493da961be343e041d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:56:19 -0400 Subject: [PATCH 6/7] Fix double-flushing and stand-down starvation in the chain leg Instrumentation showed 146,989 flushes for a 100k-step dependent chain: dirt produced during a flush re-armed a whole new flush even though the flush-until-stable loop had already rendered it, because the scheduled flag cleared before the join. It now clears after, so in-flush dirt dedupes into the running flush. 8x-throttled chain bench: 16.7s -> 5.5s (stock runloop: 3.8s). Chain flushes also inflated the per-frame counter that stands the macrotask leg down, starving misclassified stream dirt of its unclamped leg for up to a full frame (373ms gaps observed under load). Only stream-scheduled flushes count now. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 0a403dc4b1f..ca61a18f5f9 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -394,6 +394,7 @@ export class RendererState { #flushesThisFrame = 0; #lastFlushEnd = 0; #lastFrameAt = 0; + #viaStream = false; // scheduling must not allocate per dirt event: dependent chains // (render -> effect -> set) re-enter scheduleRevalidate once per step, @@ -430,18 +431,26 @@ export class RendererState { this.#flushesThisFrame = 0; this.#rafHandle = null; } else { - this.#flushesThisFrame++; + if (this.#viaStream) { + // only stream-scheduled flushes count toward the rAF + // stand-down: chain flushes are same-task and invisible to + // frames, and counting them would starve later stream dirt of + // its macrotask leg + this.#flushesThisFrame++; + } if (this.#rafHandle !== null) { cancelAnimationFrame(this.#rafHandle); this.#rafHandle = null; } } - this.#flushScheduled = false; - setFramePending(false); + this.#viaStream = false; _backburner.join(this.#revalidateUntilStable); + this.#flushScheduled = false; + setFramePending(false); + this.#lastFlushEnd = performance.now(); } @@ -492,6 +501,7 @@ export class RendererState { return; } + this.#viaStream = true; this.#rafHandle = requestAnimationFrame(this.#frameFlush); // The stand-down only applies while rAF is actually being serviced: From f8b4d0cf1eb025acfe24ab7885ec410593ecc3ca Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:22:03 -0400 Subject: [PATCH 7/7] End state: remove backburner from the render path entirely Tag invalidation now notifies the renderer's scheduler directly -- replacing the classic wiring where every dirty tag called _backburner.ensureInstance() and rendering was driven by the autorun's begin hook rerendering all renderers. Destruction queues in the environment module and is drained by the scheduler's flush (classic actions-before-destroy ordering preserved), with a fallback microtask when nothing is rendering. renderSettled resolves at the end of any flush that leaves every renderer valid, instead of in backburner's end event. The SSR fallback flushes on a microtask. The renderer no longer imports @ember/runloop at all; the runloop remains as a userland library that does not participate in rendering. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 104 +++++++----------- .../-internals/glimmer/lib/environment.ts | 66 ++++++++++- 2 files changed, 100 insertions(+), 70 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index ca61a18f5f9..9c4db10b065 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -1,7 +1,5 @@ -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 { associateDestroyableChild, destroy, @@ -28,14 +26,16 @@ import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/ import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { SimpleDocument, SimpleElement } from '@simple-dom/interface'; import { hasDOM } from '../../browser-environment'; -import { EmberEnvironmentDelegate } from './environment'; +import { + EmberEnvironmentDelegate, + _setNotifyRevalidate, + _drainScheduledDestroys, +} from './environment'; import ResolverImpl from './resolver'; import { EvaluationContextImpl } from '@glimmer/opcode-compiler/lib/program-context'; export type IBuilder = (env: Environment, cursor: Cursor) => TreeBuilder; -const NO_OP = () => {}; - // This wrapper logic prevents us from rerendering in case of a hard failure // during render. This prevents infinite revalidation type loops from occuring, // and ensures that errors are not swallowed by subsequent follow on failures. @@ -151,11 +151,15 @@ function deregister(renderer: BaseRenderer): void { renderers.splice(index, 1); } -function loopBegin(): void { +// SPIKE (RFC 957 end state): tag invalidation notifies the renderer's +// scheduler directly -- there is no runloop in the render path. This +// replaces the classic wiring where every dirty tag spun up a +// backburner autorun whose `begin` hook rerendered the renderers. +_setNotifyRevalidate(() => { for (let renderer of renderers) { renderer.rerender(); } -} +}); interface RenderSettledDeferred { promise: Promise; @@ -166,8 +170,9 @@ let renderSettledDeferred: RenderSettledDeferred | null = null; /* Returns a promise which will resolve when rendering has settled. Settled in this context is defined as when all of the tags in use are "current" (e.g. - `renderers.every(r => r._isValid())`). When this is checked at the _end_ of - the run loop, this essentially guarantees that all rendering is completed. + `renderers.every(r => r._isValid())`). Resolution is attempted at the end + of every scheduler flush; if nothing is dirty when this is called, it + settles on a microtask. @method renderSettled @returns {Promise} a promise which fulfills when rendering has settled @@ -177,64 +182,23 @@ 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 - _backburner.schedule('actions', null, NO_OP); - } + queueMicrotask(resolveRenderPromiseIfSettled); } return renderSettledDeferred.promise; } -function resolveRenderPromise() { - if (renderSettledDeferred !== null) { - let resolve = renderSettledDeferred.resolve; - renderSettledDeferred = null; +function resolveRenderPromiseIfSettled() { + if (renderSettledDeferred === null) return; - _backburner.join(null, resolve); - } -} - -/** - * SPIKE: revalidation deferred to an animation frame is *expected* to - * leave the renderer invalid at runloop end -- the frame will handle - * it. Without this, loopEnd spins NO_OP runloops (recursing via join) - * until the loop guard throws. - */ -let framePending = false; - -export function setFramePending(value: boolean) { - framePending = value; -} - -let loops = 0; -function loopEnd() { for (let renderer of renderers) { - if (!renderer.isValid()) { - if (framePending) { - // the scheduled frame will revalidate; its own runloop will - // re-enter loopEnd and resolve the render promise - return; - } - - if (loops > ENV._RERENDER_LOOP_LIMIT) { - loops = 0; - // TODO: do something better - renderer.destroy(); - throw new Error('infinite rendering invalidation detected'); - } - loops++; - return _backburner.join(null, NO_OP); - } + if (!renderer.isValid()) return; } - loops = 0; - resolveRenderPromise(); -} -_backburner.on('begin', loopBegin); -_backburner.on('end', loopEnd); + let resolve = renderSettledDeferred.resolve; + renderSettledDeferred = null; + resolve(); +} type Resolver = ClassicResolver; @@ -446,12 +410,19 @@ export class RendererState { this.#viaStream = false; - _backburner.join(this.#revalidateUntilStable); + this.#revalidateUntilStable(); + // the flag clears after revalidation so dirt produced mid-flush + // dedupes into the running flush's stability loop, but before the + // destroy drain, whose destructors may legitimately dirty state + // that needs a new flush this.#flushScheduled = false; - setFramePending(false); + + _drainScheduledDestroys(); this.#lastFlushEnd = performance.now(); + + resolveRenderPromiseIfSettled(); } /** @@ -471,18 +442,19 @@ export class RendererState { * below) */ scheduleRevalidate(renderer: BaseRenderer): void { - if (typeof requestAnimationFrame !== 'function') { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); - return; - } - if (this.#flushScheduled) { return; } this.#renderer = renderer; this.#flushScheduled = true; - setFramePending(true); + + // no paint to schedule against in SSR environments; flush on a + // microtask so rendering completes within the current task's drain + if (typeof requestAnimationFrame !== 'function') { + queueMicrotask(this.#microtaskFlush); + return; + } const now = performance.now(); diff --git a/packages/@ember/-internals/glimmer/lib/environment.ts b/packages/@ember/-internals/glimmer/lib/environment.ts index fc2d99f4f4d..d8ad4f94a1b 100644 --- a/packages/@ember/-internals/glimmer/lib/environment.ts +++ b/packages/@ember/-internals/glimmer/lib/environment.ts @@ -6,7 +6,6 @@ import getDebugName from '@ember/-internals/utils/lib/get-debug-name'; import { constructStyleDeprecationMessage } from '@ember/-internals/views/lib/system/utils'; import { assert, deprecate, warn } from '@ember/debug'; 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 type { EnvironmentDelegate } from '@glimmer/runtime/lib/environment'; @@ -17,11 +16,65 @@ import toBool from './utils/to-bool'; /////////// +// SPIKE (RFC 957 end state): tag invalidation and destruction no longer +// flow through the runloop. Invalidation notifies the renderer's +// scheduler directly; destruction work queues here and is drained by +// the scheduler's flush (or a fallback microtask when nothing is +// rendering). The setter indirection exists only to avoid a module +// cycle with the renderer. + +let notifyRevalidate: () => void = () => {}; + +export function _setNotifyRevalidate(fn: () => void): void { + notifyRevalidate = fn; +} + +interface ScheduledDestructor { + destroyable: object; + destructor: (destroyable: object) => void; +} + +const scheduledDestructors: ScheduledDestructor[] = []; +const scheduledFinalizers: Array<() => void> = []; + +let destroyDrainArmed = false; + +/** + * Runs pending destructors, then finalizers -- the classic + * actions-before-destroy queue ordering. Destruction can schedule + * further destruction, so drain until quiet. + */ +export function _drainScheduledDestroys(): void { + destroyDrainArmed = false; + + while (scheduledDestructors.length > 0 || scheduledFinalizers.length > 0) { + const destructors = scheduledDestructors.splice(0); + for (const { destroyable, destructor } of destructors) { + destructor(destroyable); + } + + const finalizers = scheduledFinalizers.splice(0); + for (const finalize of finalizers) { + finalize(); + } + } +} + +function armDestroyDrain(): void { + if (destroyDrainArmed) return; + destroyDrainArmed = true; + queueMicrotask(() => { + if (destroyDrainArmed) { + _drainScheduledDestroys(); + } + }); +} + // Setup global context setGlobalContext({ scheduleRevalidate() { - _backburner.ensureInstance(); + notifyRevalidate(); }, toBool, @@ -33,11 +86,16 @@ setGlobalContext({ setPath: set, scheduleDestroy(destroyable, destructor) { - schedule('actions', null, destructor, destroyable); + scheduledDestructors.push({ + destroyable, + destructor: destructor as (destroyable: object) => void, + }); + armDestroyDrain(); }, scheduleDestroyed(finalizeDestructor) { - schedule('destroy', null, finalizeDestructor); + scheduledFinalizers.push(finalizeDestructor); + armDestroyDrain(); }, warnIfStyleNotTrusted(value: unknown) {