Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 49 additions & 12 deletions addon/src/settled.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bad bot. this is not what I meant.

I meant in the test-waiters package, when we go beginAsync, we can have that be internally new Promise, and then endAsync resolves the promise.

test-waiters can have a waitersSettled() function which sees if all known promises are resolved (tho, if they resolve, I would also assume they remove themselves from the structure that waitersSettled() would look at)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, and you're right — I built the aggregate version (one module-level deferred that resolved when hasPendingWaiters() went false), not per-operation promises. Redone as you described, in emberjs/ember-test-waiters#525:

  • beginAsync creates a promise for that operation
  • endAsync resolves it and deletes it from the pending map, so the map only ever holds work in flight
  • waitersSettled() composes the promises of what's currently tracked, via a new optional settled() on each waiter

Your design is also strictly better than mine for a reason I hadn't considered: my version called hasPendingWaiters() on every endAsync, which walks every registered waiter and builds debugInfo arrays for the pending ones. Per-operation promises do no scanning at all — endAsync resolves one deferred.

One case the interface forced a decision on: a Waiter implemented directly against the interface has no settled() to compose. Rather than reporting a quiet it can't verify, waitersSettled() returns a never-resolving promise while such a waiter is pending, so the caller's fallback tick drives and pull-only waiters keep working.

Re-validated: test-waiters base-tests 56/56; this repo's test-app 553/553 both against a linked build of the new API and against published test-waiters (degraded to the fallback); limber's full chrome matrix green with 7/7 repeat runs against the runloop-less spike.

Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
// @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 { 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 { 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';

// 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;

let requests: XMLHttpRequest[];
const checkWaiters = Test.checkWaiters;
Expand Down Expand Up @@ -139,14 +142,15 @@ 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
// 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 {
Expand Down Expand Up @@ -209,6 +213,39 @@ export function isSettled(): boolean {
@public
@returns {Promise<void>} resolves when settled
*/
export default function settled(): Promise<void> {
return waitUntil(isSettled, { timeout: Infinity }).then(() => {});
export default async function settled(): Promise<void> {
// Settledness is awaited, not polled: rendering resolves
// `renderSettled()` when it completes, and waiters resolve
// `waitersSettled()` from their operations' own completion promises.
//
// The timers are not a polling cadence, and both are load-bearing:
//
// - 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).
//
// - 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.
//
// The loop re-checks because settling can start more work.
for (;;) {
await Promise.race([
Promise.all([renderSettled(), waitersSettled()]),
new Promise((resolve) => setTimeout(resolve, 50)),
]);

await new Promise((resolve) => setTimeout(resolve, 0));

if (isSettled()) {
return;
}
}
}