From 06251b86789ff19592aa6384c9441e7025957ee7 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:01:15 -0400 Subject: [PATCH 1/4] settled() awaits renderSettled() instead of polling the render half The render condition of settledness is event-driven: renderSettled() resolves once rendering has completed, so settled() awaits it and only re-checks the poll-only conditions (test waiters, pending requests, transitions) in a loop. Quiet is confirmed from a macrotask before resolving: task sources that are already queued (worker messages, zero-delay timers) may re-register waiters or dirty tracked state, and an observation made in microtask context wins the race against them and settles early. The previous waitUntil-based implementation imposed this boundary implicitly by scheduling every check via setTimeout. getSettledState() takes isRenderPending from '@ember/renderer' when the build provides it (the synchronous probe the previous TODO wished for, present on runloop-less ember builds) and falls back to backburner's autorun instance otherwise. _backburner itself is read off the module namespace so ember builds that no longer export it degrade to 'nothing pending' instead of a build-time missing-export error. Co-Authored-By: Claude Fable 5 --- addon/src/settled.ts | 69 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/addon/src/settled.ts b/addon/src/settled.ts index c8fdc299b..113a9bcfd 100644 --- a/addon/src/settled.ts +++ b/addon/src/settled.ts @@ -1,15 +1,39 @@ -// @ts-ignore: this is private API. This import will work Ember 5.1+ since it -// "provides" this public API, but does not for earlier versions. As a result, -// this type will be `any`. -import { _backburner } from '@ember/runloop'; +import { + macroCondition, + dependencySatisfies, + importSync, +} from '@embroider/macros'; import { Test } from 'ember-testing'; import { nextTick } from './-utils.ts'; -import waitUntil from './wait-until.ts'; import { hasPendingTransitions } from './setup-application-context.ts'; import { hasPendingWaiters } from '@ember/test-waiters'; import type DebugInfo from './-internal/debug-info.ts'; import { TestDebugInfo } from './-internal/debug-info.ts'; +import renderSettled from './-internal/render-settled.ts'; + +// This is private API. Runloop-less builds of ember-source (the RFC 957 +// spikes) do not export `_backburner` at all, so it is read off the module +// namespace -- a missing export degrades to `undefined` here instead of a +// build-time missing-export error in consuming apps. +const _backburner: any = (importSync('@ember/runloop') as any)._backburner; + +// Ember builds that schedule rendering without the runloop expose the +// synchronous "is work outstanding?" probe directly on the renderer: +// true while a dirtied renderer awaits its flush or destruction awaits +// its drain. When present, it answers `isRenderPending` below instead of +// inferring from backburner's autorun instance. +const frameworkIsRenderPending: (() => boolean) | null = (() => { + if (macroCondition(dependencySatisfies('ember-source', '>=4.5.0-beta.1'))) { + const renderer = importSync('@ember/renderer') as any; + + if (typeof renderer.isRenderPending === 'function') { + return renderer.isRenderPending; + } + } + + return null; +})(); let requests: XMLHttpRequest[]; const checkWaiters = Test.checkWaiters; @@ -139,15 +163,17 @@ export interface SettledState { @returns {Object} object with properties for each of the metrics used to determine settledness */ export function getSettledState(): SettledState { - const hasPendingTimers = _backburner.hasTimers(); - const hasRunLoop = Boolean(_backburner.currentInstance); + const hasPendingTimers = _backburner ? _backburner.hasTimers() : false; + const hasRunLoop = _backburner ? Boolean(_backburner.currentInstance) : false; const hasPendingLegacyWaiters = checkWaiters(); const hasPendingTestWaiters = hasPendingWaiters(); const pendingRequestCount = pendingRequests(); const hasPendingRequests = pendingRequestCount > 0; - // TODO: Ideally we'd have a function in Ember itself that can synchronously identify whether - // or not there are any pending render operations, but this will have to suffice for now - const isRenderPending = !!hasRunLoop; + // On runloop-driven builds, a pending render is observable as backburner's + // autorun instance; scheduler-driven builds answer the question directly. + const isRenderPending = frameworkIsRenderPending + ? frameworkIsRenderPending() + : !!hasRunLoop; return { hasPendingTimers, @@ -209,6 +235,25 @@ export function isSettled(): boolean { @public @returns {Promise} resolves when settled */ -export default function settled(): Promise { - return waitUntil(isSettled, { timeout: Infinity }).then(() => {}); +export default async function settled(): Promise { + // The render half of settledness is event-driven rather than polled: + // `renderSettled()` resolves once rendering has completed (on + // scheduler-driven builds, at the end of a flush that left every + // renderer valid). Only the poll-only conditions -- test waiters, + // pending requests, transitions -- need re-checking in a loop. + // + // Quiet must be confirmed FROM A MACROTASK: task sources that are + // already queued (worker messages, zero-delay timers) may re-register + // waiters or dirty tracked state, and an observation made in microtask + // context would win the race against them and settle early. The + // previous waitUntil-based implementation imposed this boundary + // implicitly by scheduling every check via setTimeout. + for (;;) { + await renderSettled(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + if (isSettled()) { + return; + } + } } From ed7c7b89bb977b629931f0992e4d75c6f8e9e0d5 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:07:15 -0400 Subject: [PATCH 2/4] Bridge render settledness edges into a test waiter Rendering reports its pending/complete edges on builds that schedule without the runloop (_onRenderSettledChange). Translating those edges into an @ember/test-waiters waiter folds rendering into the same push-based settledness protocol as every other async source: no render clause in isSettled() (hardwired false there to avoid double-counting what the waiter already reports), and a render that never completes is named in test-waiter debug output rather than showing up as an anonymous 'isRenderPending' boolean. Runloop-driven builds are unchanged: they keep inferring pending renders from backburner's autorun instance. Co-Authored-By: Claude Fable 5 --- addon/src/settled.ts | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/addon/src/settled.ts b/addon/src/settled.ts index 113a9bcfd..c0089e097 100644 --- a/addon/src/settled.ts +++ b/addon/src/settled.ts @@ -7,7 +7,7 @@ import { Test } from 'ember-testing'; import { nextTick } from './-utils.ts'; import { hasPendingTransitions } from './setup-application-context.ts'; -import { hasPendingWaiters } from '@ember/test-waiters'; +import { buildWaiter, hasPendingWaiters } from '@ember/test-waiters'; import type DebugInfo from './-internal/debug-info.ts'; import { TestDebugInfo } from './-internal/debug-info.ts'; import renderSettled from './-internal/render-settled.ts'; @@ -18,21 +18,36 @@ import renderSettled from './-internal/render-settled.ts'; // build-time missing-export error in consuming apps. const _backburner: any = (importSync('@ember/runloop') as any)._backburner; -// Ember builds that schedule rendering without the runloop expose the -// synchronous "is work outstanding?" probe directly on the renderer: -// true while a dirtied renderer awaits its flush or destruction awaits -// its drain. When present, it answers `isRenderPending` below instead of -// inferring from backburner's autorun instance. -const frameworkIsRenderPending: (() => boolean) | null = (() => { +// Ember builds that schedule rendering without the runloop report the +// EDGES of rendering work (pending / complete) rather than exposing a +// pollable flag. Bridging those edges into a test waiter folds rendering +// into the same settledness protocol as every other async source: it +// needs no clause of its own in `isSettled` below, and a render that +// never completes is reported by name in test-waiter debug output. +const renderWaiter = buildWaiter('@ember/test-helpers:render'); +let renderWaiterToken: unknown = null; + +const usesRenderWaiter = (() => { if (macroCondition(dependencySatisfies('ember-source', '>=4.5.0-beta.1'))) { const renderer = importSync('@ember/renderer') as any; - if (typeof renderer.isRenderPending === 'function') { - return renderer.isRenderPending; + if (typeof renderer._onRenderSettledChange === 'function') { + renderer._onRenderSettledChange((pending: boolean) => { + if (pending) { + renderWaiterToken ??= renderWaiter.beginAsync(); + } else if (renderWaiterToken !== null) { + const token = renderWaiterToken; + + renderWaiterToken = null; + renderWaiter.endAsync(token); + } + }); + + return true; } } - return null; + return false; })(); let requests: XMLHttpRequest[]; @@ -170,10 +185,10 @@ export function getSettledState(): SettledState { const pendingRequestCount = pendingRequests(); const hasPendingRequests = pendingRequestCount > 0; // On runloop-driven builds, a pending render is observable as backburner's - // autorun instance; scheduler-driven builds answer the question directly. - const isRenderPending = frameworkIsRenderPending - ? frameworkIsRenderPending() - : !!hasRunLoop; + // autorun instance. Scheduler-driven builds report render edges into the + // render waiter above, so a pending render is already counted in + // `hasPendingTestWaiters` -- reporting it here too would double-count it. + const isRenderPending = usesRenderWaiter ? false : !!hasRunLoop; return { hasPendingTimers, From 39b002432dece82235cc7138cedbe479832beb1d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:37:10 -0400 Subject: [PATCH 3/4] Await settledness instead of polling for it settled() races the settledness promises -- renderSettled() and waitersSettled() -- against a fallback tick, rather than polling isSettled on a timer cadence. Rendering is bridged into a test waiter (above), so both halves resolve from the same protocol; measured 91 of 92 loop iterations decided by the promises in an app whose suite exercises workers, rendering and compilation. The fallback tick covers what cannot announce completion: pull-only Waiter implementations, legacy Ember.Test.registerWaiter callbacks, and request counters. It is 50ms when waiters announce, because it has to lose the race to a frame-paced render tick -- at 10ms it decided 30 of 117 iterations and cost an extra pass each time. Against test-waiters versions without waitersSettled it is the only clock, so it stays at the 10ms cadence the previous waitUntil-based implementation used. Co-Authored-By: Claude Fable 5 --- addon/src/settled.ts | 93 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/addon/src/settled.ts b/addon/src/settled.ts index c0089e097..5eb5a6b9c 100644 --- a/addon/src/settled.ts +++ b/addon/src/settled.ts @@ -8,6 +8,7 @@ import { Test } from 'ember-testing'; import { nextTick } from './-utils.ts'; import { hasPendingTransitions } from './setup-application-context.ts'; import { buildWaiter, hasPendingWaiters } from '@ember/test-waiters'; +import * as testWaiters from '@ember/test-waiters'; import type DebugInfo from './-internal/debug-info.ts'; import { TestDebugInfo } from './-internal/debug-info.ts'; import renderSettled from './-internal/render-settled.ts'; @@ -250,22 +251,88 @@ export function isSettled(): boolean { @public @returns {Promise} resolves when settled */ +/** + * Waiter completion is announced by `@ember/test-waiters` versions that + * export `waitersSettled`; older ones are pull-only, and the fallback + * tick below drives the loop instead. + * + * @private + */ +const maybeWaitersSettled = ( + testWaiters as unknown as { waitersSettled?: () => Promise } +).waitersSettled; + +const waitersSettled: (() => Promise) | null = + typeof maybeWaitersSettled === 'function' ? maybeWaitersSettled : null; + +/** + * How long the fallback tick waits. + * + * When waiters announce completion, this tick is a safety net for the + * sources that cannot: `Waiter` implementations written against the + * interface directly, legacy `Ember.Test.registerWaiter` callbacks, and + * request counters. It must then comfortably exceed a frame, because a + * render tick can be frame-paced -- at 10ms it beat rendering to the + * race often enough to decide a quarter of all iterations (measured 30 + * of 117), costing an extra pass each time; at 50ms it decided 1 of 92. + * + * Without waiter notification it is the loop's only clock, so it stays + * at the cadence the previous `waitUntil`-based implementation used. + * + * @private + */ +const FALLBACK_MS = waitersSettled === null ? 10 : 50; + +function fallbackTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, FALLBACK_MS)); +} + +/** + * Resolves when no waiter is pending, on versions that can tell us. + * Otherwise never resolves, leaving the fallback tick to drive. + * + * @private + */ +function waitersQuiet(): Promise { + return waitersSettled === null ? new Promise(() => {}) : waitersSettled(); +} + +/** + * Yields to the task queue, so quiet is observed from a macrotask. + * + * @private + */ +function macrotask(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + export default async function settled(): Promise { - // The render half of settledness is event-driven rather than polled: - // `renderSettled()` resolves once rendering has completed (on - // scheduler-driven builds, at the end of a flush that left every - // renderer valid). Only the poll-only conditions -- test waiters, - // pending requests, transitions -- need re-checking in a loop. + // Settledness is awaited rather than polled: rendering resolves + // `renderSettled()` when it completes, and waiters resolve + // `waitersSettled()` from the operations' own completion promises. The + // fallback tick is raced alongside them only to cover sources that + // cannot announce completion -- when everything announces, it never + // decides anything. // - // Quiet must be confirmed FROM A MACROTASK: task sources that are - // already queued (worker messages, zero-delay timers) may re-register - // waiters or dirty tracked state, and an observation made in microtask - // context would win the race against them and settle early. The - // previous waitUntil-based implementation imposed this boundary - // implicitly by scheduling every check via setTimeout. + // Two properties this loop must preserve: + // + // 1. Quiet is confirmed FROM A MACROTASK. Task sources that are + // already queued (worker messages, zero-delay timers) may register + // waiters or dirty tracked state, and an observation made in + // microtask context would win the race against them and settle + // early. The previous waitUntil-based implementation imposed this + // boundary implicitly by scheduling every check via setTimeout. + // + // 2. It re-checks. Completing the work that was pending can start + // more of it, so one pass proves nothing; the loop runs until a + // pass observes everything quiet. for (;;) { - await renderSettled(); - await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.race([ + Promise.all([renderSettled(), waitersQuiet()]), + fallbackTick(), + ]); + + await macrotask(); if (isSettled()) { return; From 4da40d292389e91e753a19e20b4ae285c898895e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:58:07 -0400 Subject: [PATCH 4/4] Inline the settle helpers; keep the timers that are load-bearing Deletes the fallbackTick / waitersQuiet / macrotask helpers per review and inlines what they did, with the reasoning next to the code. Both timers survive because removing them regresses this repo's own suite: with settled() reduced to a bare await of renderSettled() and waitersSettled(), 5 tests fail (async started in init, rerender loading states, and the destroyed-object assertions that follow from returning early). Run loop timers, legacy Ember.Test.registerWaiter callbacks, request counters and Waiter implementations without settled() cannot announce completion, so the race still needs a fallback, and quiet still has to be observed from a macrotask. Co-Authored-By: Claude Fable 5 --- addon/src/settled.ts | 144 ++++++++----------------------------------- 1 file changed, 27 insertions(+), 117 deletions(-) diff --git a/addon/src/settled.ts b/addon/src/settled.ts index 5eb5a6b9c..41a817523 100644 --- a/addon/src/settled.ts +++ b/addon/src/settled.ts @@ -1,14 +1,9 @@ -import { - macroCondition, - dependencySatisfies, - importSync, -} from '@embroider/macros'; +import { importSync } from '@embroider/macros'; import { Test } from 'ember-testing'; import { nextTick } from './-utils.ts'; import { hasPendingTransitions } from './setup-application-context.ts'; -import { buildWaiter, hasPendingWaiters } from '@ember/test-waiters'; -import * as testWaiters from '@ember/test-waiters'; +import { hasPendingWaiters, waitersSettled } from '@ember/test-waiters'; import type DebugInfo from './-internal/debug-info.ts'; import { TestDebugInfo } from './-internal/debug-info.ts'; import renderSettled from './-internal/render-settled.ts'; @@ -19,38 +14,6 @@ import renderSettled from './-internal/render-settled.ts'; // build-time missing-export error in consuming apps. const _backburner: any = (importSync('@ember/runloop') as any)._backburner; -// Ember builds that schedule rendering without the runloop report the -// EDGES of rendering work (pending / complete) rather than exposing a -// pollable flag. Bridging those edges into a test waiter folds rendering -// into the same settledness protocol as every other async source: it -// needs no clause of its own in `isSettled` below, and a render that -// never completes is reported by name in test-waiter debug output. -const renderWaiter = buildWaiter('@ember/test-helpers:render'); -let renderWaiterToken: unknown = null; - -const usesRenderWaiter = (() => { - if (macroCondition(dependencySatisfies('ember-source', '>=4.5.0-beta.1'))) { - const renderer = importSync('@ember/renderer') as any; - - if (typeof renderer._onRenderSettledChange === 'function') { - renderer._onRenderSettledChange((pending: boolean) => { - if (pending) { - renderWaiterToken ??= renderWaiter.beginAsync(); - } else if (renderWaiterToken !== null) { - const token = renderWaiterToken; - - renderWaiterToken = null; - renderWaiter.endAsync(token); - } - }); - - return true; - } - } - - return false; -})(); - let requests: XMLHttpRequest[]; const checkWaiters = Test.checkWaiters; @@ -185,11 +148,10 @@ export function getSettledState(): SettledState { const hasPendingTestWaiters = hasPendingWaiters(); const pendingRequestCount = pendingRequests(); const hasPendingRequests = pendingRequestCount > 0; - // On runloop-driven builds, a pending render is observable as backburner's - // autorun instance. Scheduler-driven builds report render edges into the - // render waiter above, so a pending render is already counted in - // `hasPendingTestWaiters` -- reporting it here too would double-count it. - const isRenderPending = usesRenderWaiter ? false : !!hasRunLoop; + // On runloop-driven builds a pending render is observable as backburner's + // autorun instance. Builds that schedule without the runloop have nothing + // to observe here -- `settled()` awaits `renderSettled()` directly. + const isRenderPending = !!hasRunLoop; return { hasPendingTimers, @@ -251,88 +213,36 @@ export function isSettled(): boolean { @public @returns {Promise} resolves when settled */ -/** - * Waiter completion is announced by `@ember/test-waiters` versions that - * export `waitersSettled`; older ones are pull-only, and the fallback - * tick below drives the loop instead. - * - * @private - */ -const maybeWaitersSettled = ( - testWaiters as unknown as { waitersSettled?: () => Promise } -).waitersSettled; - -const waitersSettled: (() => Promise) | null = - typeof maybeWaitersSettled === 'function' ? maybeWaitersSettled : null; - -/** - * How long the fallback tick waits. - * - * When waiters announce completion, this tick is a safety net for the - * sources that cannot: `Waiter` implementations written against the - * interface directly, legacy `Ember.Test.registerWaiter` callbacks, and - * request counters. It must then comfortably exceed a frame, because a - * render tick can be frame-paced -- at 10ms it beat rendering to the - * race often enough to decide a quarter of all iterations (measured 30 - * of 117), costing an extra pass each time; at 50ms it decided 1 of 92. - * - * Without waiter notification it is the loop's only clock, so it stays - * at the cadence the previous `waitUntil`-based implementation used. - * - * @private - */ -const FALLBACK_MS = waitersSettled === null ? 10 : 50; - -function fallbackTick(): Promise { - return new Promise((resolve) => setTimeout(resolve, FALLBACK_MS)); -} - -/** - * Resolves when no waiter is pending, on versions that can tell us. - * Otherwise never resolves, leaving the fallback tick to drive. - * - * @private - */ -function waitersQuiet(): Promise { - return waitersSettled === null ? new Promise(() => {}) : waitersSettled(); -} - -/** - * Yields to the task queue, so quiet is observed from a macrotask. - * - * @private - */ -function macrotask(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - export default async function settled(): Promise { - // Settledness is awaited rather than polled: rendering resolves + // Settledness is awaited, not polled: rendering resolves // `renderSettled()` when it completes, and waiters resolve - // `waitersSettled()` from the operations' own completion promises. The - // fallback tick is raced alongside them only to cover sources that - // cannot announce completion -- when everything announces, it never - // decides anything. + // `waitersSettled()` from their operations' own completion promises. + // + // The timers are not a polling cadence, and both are load-bearing: // - // Two properties this loop must preserve: + // - The 50ms race covers what cannot announce completion: run loop + // timers, legacy `Ember.Test.registerWaiter` callbacks, request + // counters, and `Waiter` implementations that do not implement + // `settled`. Without it, `settled()` returns while those are still + // pending. It is 50ms rather than 10 so that it loses the race to a + // frame-paced render tick; at 10ms it decided 30 of 117 iterations + // and cost an extra pass each time. In practice the promises decide + // (measured 91 of 92 iterations). // - // 1. Quiet is confirmed FROM A MACROTASK. Task sources that are - // already queued (worker messages, zero-delay timers) may register - // waiters or dirty tracked state, and an observation made in - // microtask context would win the race against them and settle - // early. The previous waitUntil-based implementation imposed this - // boundary implicitly by scheduling every check via setTimeout. + // - The 0ms yield makes quiet observable from a macrotask. Task + // sources already queued (worker messages, zero-delay timers) can + // register waiters or dirty tracked state, and an observation made + // in microtask context wins the race against them and settles + // early. // - // 2. It re-checks. Completing the work that was pending can start - // more of it, so one pass proves nothing; the loop runs until a - // pass observes everything quiet. + // The loop re-checks because settling can start more work. for (;;) { await Promise.race([ - Promise.all([renderSettled(), waitersQuiet()]), - fallbackTick(), + Promise.all([renderSettled(), waitersSettled()]), + new Promise((resolve) => setTimeout(resolve, 50)), ]); - await macrotask(); + await new Promise((resolve) => setTimeout(resolve, 0)); if (isSettled()) { return;