diff --git a/package.json b/package.json index 0bfd99f6fc3..ac0d6bc8e3a 100644 --- a/package.json +++ b/package.json @@ -280,6 +280,7 @@ "@ember/routing/route.js": "ember-source/@ember/routing/route.js", "@ember/routing/router-service.js": "ember-source/@ember/routing/router-service.js", "@ember/routing/router.js": "ember-source/@ember/routing/router.js", + "@ember/runloop/-private/scheduler-loop.js": "ember-source/@ember/runloop/-private/scheduler-loop.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", diff --git a/packages/@ember/-internals/environment/lib/env.ts b/packages/@ember/-internals/environment/lib/env.ts index 4e624f0bdef..a0f43a5c775 100644 --- a/packages/@ember/-internals/environment/lib/env.ts +++ b/packages/@ember/-internals/environment/lib/env.ts @@ -143,6 +143,22 @@ export const ENV = { */ _DEFAULT_ASYNC_OBSERVERS: false, + /** + Whether Ember schedules its own work (rendering, runloop-scheduled + callbacks, promise resolution) through `@ember/scheduler` instead of + backburner and RSVP, per RFC 0957. + + This is not intended to be set directly, as the implementation may change in + the future. Use `@ember/optional-features` instead. + + @property _USE_ASYNC_SCHEDULER + @for EmberENV + @type Boolean + @default false + @private + */ + _USE_ASYNC_SCHEDULER: false, + /** Controls the maximum number of scheduled rerenders without "settling". In general, applications should not need to modify this environment variable, but please diff --git a/packages/@ember/-internals/glimmer/lib/async-revalidate.ts b/packages/@ember/-internals/glimmer/lib/async-revalidate.ts new file mode 100644 index 00000000000..484ec75f0ae --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/async-revalidate.ts @@ -0,0 +1,22 @@ +import { scheduleOnce } from '@ember/runloop'; + +// Indirection between environment.ts (which needs to schedule revalidation +// from the glimmer global context) and base-renderer.ts (which owns the +// renderers and implements the flush), so neither has to import the other. + +let flushFn: () => void = () => {}; + +export function _setAsyncRenderFlush(fn: () => void): void { + flushFn = fn; +} + +function runAsyncRenderFlush(): void { + flushFn(); +} + +// Schedules the async rendering pass (deduplicated) into the scheduler's +// render phase. Only used when the `use-async-scheduler` optional feature +// is enabled. +export function _scheduleAsyncRevalidate(): void { + scheduleOnce('render', null, runAsyncRenderFlush); +} diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 955511c4389..c1f3e3934bc 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -1,7 +1,9 @@ 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 { _backburner, _getCurrentRunLoop, schedule } from '@ember/runloop'; +import { flushAsyncObservers } from '@ember/-internals/metal/lib/observer'; +import { _scheduleAsyncRevalidate, _setAsyncRenderFlush } from './async-revalidate'; import { associateDestroyableChild, destroy, @@ -177,10 +179,14 @@ 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 + if (ENV._USE_ASYNC_SCHEDULER) { + // ensure a revalidation pass is scheduled; it resolves the promise + // once every renderer is valid + _scheduleAsyncRevalidate(); + } else if (!_getCurrentRunLoop()) { + // 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); ensure a runloop has been kicked off _backburner.schedule('actions', null, NO_OP); } } @@ -193,7 +199,11 @@ function resolveRenderPromise() { let resolve = renderSettledDeferred.resolve; renderSettledDeferred = null; - _backburner.join(null, resolve); + if (ENV._USE_ASYNC_SCHEDULER) { + resolve(); + } else { + _backburner.join(null, resolve); + } } } @@ -218,6 +228,38 @@ function loopEnd() { _backburner.on('begin', loopBegin); _backburner.on('end', loopEnd); +// The async rendering pass used when the `use-async-scheduler` optional +// feature is enabled: revalidation is scheduled into the scheduler's render +// phase instead of backburner's `render` queue, so rendering happens before +// the next paint rather than at the end of the current runloop. +function flushAsyncRendering(): void { + flushAsyncObservers(schedule); + + for (let renderer of renderers) { + renderer.state.revalidate(renderer); + } + + for (let renderer of renderers) { + if (!renderer.isValid()) { + if (loops > ENV._RERENDER_LOOP_LIMIT) { + loops = 0; + // TODO: do something better + renderer.destroy(); + throw new Error('infinite rendering invalidation detected'); + } + loops++; + // the render phase resolves in-window when awaited during its own + // flush, so this reflush happens within the same frame + _scheduleAsyncRevalidate(); + return; + } + } + loops = 0; + resolveRenderPromise(); +} + +_setAsyncRenderFlush(flushAsyncRendering); + type Resolver = ClassicResolver; interface RendererData { @@ -369,6 +411,10 @@ export class RendererState { } scheduleRevalidate(renderer: BaseRenderer): void { + if (ENV._USE_ASYNC_SCHEDULER) { + _scheduleAsyncRevalidate(); + return; + } _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..27c578cf84e 100644 --- a/packages/@ember/-internals/glimmer/lib/environment.ts +++ b/packages/@ember/-internals/glimmer/lib/environment.ts @@ -9,6 +9,7 @@ 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 { _scheduleAsyncRevalidate } from './async-revalidate'; import type { EnvironmentDelegate } from '@glimmer/runtime/lib/environment'; import { debug } from '@glimmer/validator/lib/debug'; import toIterator from './utils/iterator'; @@ -21,6 +22,10 @@ import toBool from './utils/to-bool'; setGlobalContext({ scheduleRevalidate() { + if (ENV._USE_ASYNC_SCHEDULER) { + _scheduleAsyncRevalidate(); + return; + } _backburner.ensureInstance(); }, diff --git a/packages/@ember/-internals/runtime/lib/ext/rsvp.ts b/packages/@ember/-internals/runtime/lib/ext/rsvp.ts index 45419961a9a..bf04110680c 100644 --- a/packages/@ember/-internals/runtime/lib/ext/rsvp.ts +++ b/packages/@ember/-internals/runtime/lib/ext/rsvp.ts @@ -1,13 +1,24 @@ import * as RSVP from 'rsvp'; +import { ENV } from '@ember/-internals/environment/lib/env'; import { _backburner, _rsvpErrorQueue } from '@ember/runloop'; import { getDispatchOverride } from '@ember/-internals/error-handling'; import { assert } from '@ember/debug'; -RSVP.configure('async', (callback: unknown, promise: unknown) => { +RSVP.configure('async', (callback: Function, promise: unknown) => { + if (ENV._USE_ASYNC_SCHEDULER) { + // RFC 0957: Ember no longer routes RSVP's flush through the runloop; + // resolution happens on the microtask queue like a native promise. + void Promise.resolve().then(() => callback(promise)); + return; + } _backburner.schedule('actions', null, callback, promise); }); -RSVP.configure('after', (cb: unknown) => { +RSVP.configure('after', (cb: () => void) => { + if (ENV._USE_ASYNC_SCHEDULER) { + setTimeout(cb, 0); + return; + } _backburner.schedule(_rsvpErrorQueue, null, cb); }); diff --git a/packages/@ember/runloop/-private/scheduler-loop.ts b/packages/@ember/runloop/-private/scheduler-loop.ts new file mode 100644 index 00000000000..fd66d12bcc5 --- /dev/null +++ b/packages/@ember/runloop/-private/scheduler-loop.ts @@ -0,0 +1,380 @@ +import { onErrorTarget } from '@ember/-internals/error-handling'; +import { + render as renderPhase, + layout as layoutPhase, + next as nextPhase, + registerStrategy, + _getRegisteredStrategy, +} from '@ember/scheduler'; +import defaultStrategy from '@ember/scheduler/strategy'; +import type { AnyFn } from '@ember/-internals/utility-types'; + +/** + * The scheduler-backed runloop, active when the `use-async-scheduler` + * optional feature (`EmberENV._USE_ASYNC_SCHEDULER`) is enabled. + * + * Implements the transition semantics from RFC 0957's migration roadmap: + * `run`/`join` execute their callback directly, `schedule('render')` and + * `schedule('afterRender')` map onto the scheduler's render and layout + * phases, every other queue becomes a microtask, `next` maps onto the + * scheduler's next phase, and the timer methods (`later`, `debounce`, + * `throttle`) are backed by `setTimeout` rather than backburner's timer + * heap. + * + * Backburner is not used at all on this path. + */ + +export interface SchedulerTimer { + cancelled: boolean; + finished: boolean; + cleanup?: () => void; +} + +export function isSchedulerTimer(timer: unknown): timer is SchedulerTimer { + return timer !== null && typeof timer === 'object' && 'cancelled' in timer && 'finished' in timer; +} + +function ensureStrategy(): void { + if (_getRegisteredStrategy() === null) { + registerStrategy(defaultStrategy); + } +} + +type Target = object | null | undefined; +type Method = AnyFn | string; + +interface ParsedArgs { + target: Target; + method: Method; + args: unknown[]; +} + +// Mirrors backburner's argument parsing: `(method)`, `(method, ...args)`, +// `(target, method, ...args)`, where `method` may be the name of a method +// on `target`. +function parseArgs(args: unknown[]): ParsedArgs { + if (args.length === 1) { + return { target: null, method: args[0] as Method, args: [] }; + } + + let [first, second, ...rest] = args; + if ( + typeof second === 'function' || + (typeof second === 'string' && first !== null && typeof first === 'object' && second in first) + ) { + return { target: first as Target, method: second as Method, args: rest }; + } + + return { target: null, method: first as Method, args: args.slice(1) }; +} + +function resolveMethod(target: Target, method: Method): AnyFn { + if (typeof method === 'string') { + return (target as Record)[method] as AnyFn; + } + return method; +} + +function invokeWithOnError(target: Target, method: Method, args: unknown[]): unknown { + let fn = resolveMethod(target, method); + let onError = onErrorTarget.onerror; + if (onError) { + try { + return fn.apply(target, args); + } catch (error) { + onError(error); + return; + } + } + return fn.apply(target, args); +} + +let pendingCount = 0; +let pendingTimerCount = 0; + +const activeTimers = new Set(); + +function makeTimer(): SchedulerTimer { + pendingCount++; + return { cancelled: false, finished: false }; +} + +function finish(timer: SchedulerTimer): void { + if (!timer.finished) { + timer.finished = true; + pendingCount--; + } +} + +export function run(...args: unknown[]): unknown { + let { target, method, args: methodArgs } = parseArgs(args); + return invokeWithOnError(target, method, methodArgs); +} + +// With no runloop there is nothing to join; execute directly. +export const join = run; + +// Deduplication bookkeeping for scheduleOnce: queue -> target -> method. +// The method key is the raw string or function so `scheduleOnce('render', +// obj, 'update')` and `scheduleOnce('render', obj, obj.update)` behave the +// same way they do under backburner. +const NULL_TARGET = Symbol('null-target'); +interface OnceEntry { + timer: SchedulerTimer; + args: unknown[]; +} +const onceMap = new Map>>(); + +function phaseFor(queue: string): (() => Promise) | null { + if (queue === 'render') return renderPhase; + if (queue === 'afterRender') return layoutPhase; + return null; +} + +function scheduleInvoke(queue: string, callback: () => void): void { + ensureStrategy(); + let phase = phaseFor(queue); + if (phase) { + void phase().then(callback); + } else { + // RFC 0957: `schedule('actions', doWork)` becomes + // `Promise.resolve().then(doWork)`. + void Promise.resolve().then(callback); + } +} + +export function schedule(queue: string, ...rest: unknown[]): SchedulerTimer { + let { target, method, args } = parseArgs(rest); + let timer = makeTimer(); + + scheduleInvoke(queue, () => { + if (timer.cancelled) return; + finish(timer); + invokeWithOnError(target, method, args); + }); + + return timer; +} + +export function scheduleOnce(queue: string, ...rest: unknown[]): SchedulerTimer { + let { target, method, args } = parseArgs(rest); + + let targetKey = target ?? NULL_TARGET; + let byTarget = onceMap.get(queue); + if (byTarget === undefined) { + byTarget = new Map(); + onceMap.set(queue, byTarget); + } + let byMethod = byTarget.get(targetKey); + if (byMethod === undefined) { + byMethod = new Map(); + byTarget.set(targetKey, byMethod); + } + + let existing = byMethod.get(method); + if (existing !== undefined && !existing.timer.cancelled) { + // Same queue/target/method: new arguments replace the previous call. + existing.args = args; + return existing.timer; + } + + let timer = makeTimer(); + let entry: OnceEntry = { timer, args }; + byMethod.set(method, entry); + timer.cleanup = () => byMethod.delete(method); + + scheduleInvoke(queue, () => { + if (timer.cancelled) return; + finish(timer); + byMethod.delete(method); + invokeWithOnError(target, method, entry.args); + }); + + return timer; +} + +export function next(...args: unknown[]): SchedulerTimer { + let { target, method, args: methodArgs } = parseArgs(args); + let timer = makeTimer(); + + ensureStrategy(); + void nextPhase().then(() => { + if (timer.cancelled) return; + finish(timer); + invokeWithOnError(target, method, methodArgs); + }); + + return timer; +} + +function isCoercableNumber(value: unknown): value is number | string { + return typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)); +} + +function popWait(args: unknown[], fallback: number): number { + if (args.length > 0 && isCoercableNumber(args[args.length - 1])) { + return Number(args.pop()); + } + return fallback; +} + +// Arms (or re-arms) `timer` to fire `fire` after `wait` ms. `onDone` runs +// exactly once per arming, whether the timer fires or is cancelled — it is +// where dedupe-map entries get removed. +function setTimer( + timer: SchedulerTimer, + wait: number, + fire: () => void, + onDone?: () => void +): void { + pendingTimerCount++; + activeTimers.add(timer); + + let id = setTimeout(() => { + pendingTimerCount--; + activeTimers.delete(timer); + if (timer.cancelled) return; + finish(timer); + onDone?.(); + fire(); + }, wait); + + timer.cleanup = () => { + clearTimeout(id); + pendingTimerCount--; + activeTimers.delete(timer); + onDone?.(); + }; +} + +export function later(...args: unknown[]): SchedulerTimer { + let wait = popWait(args, 0); + let { target, method, args: methodArgs } = parseArgs(args); + let timer = makeTimer(); + + setTimer(timer, wait, () => invokeWithOnError(target, method, methodArgs)); + + return timer; +} + +interface DedupedTimerEntry { + timer: SchedulerTimer; + args: unknown[]; +} + +const debounceMap = new Map>(); +const throttleMap = new Map>(); + +function dedupeEntries( + map: Map>, + target: Target +): Map { + let targetKey = target ?? NULL_TARGET; + let byMethod = map.get(targetKey); + if (byMethod === undefined) { + byMethod = new Map(); + map.set(targetKey, byMethod); + } + return byMethod; +} + +export function debounce(...args: unknown[]): SchedulerTimer { + let immediate = false; + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; + } + let wait = popWait(args, 0); + let { target, method, args: methodArgs } = parseArgs(args); + + let byMethod = dedupeEntries(debounceMap, target); + let entry = byMethod.get(method); + + if (entry === undefined) { + entry = { timer: makeTimer(), args: methodArgs }; + if (immediate) { + invokeWithOnError(target, method, methodArgs); + } + } else { + // Restart the wait period; latest arguments win. Disarm the previous + // timeout (this also removes the dedupe entry, re-added below). + entry.args = methodArgs; + entry.timer.cleanup?.(); + } + + let current = entry; + byMethod.set(method, current); + setTimer( + current.timer, + wait, + () => { + if (!immediate) invokeWithOnError(target, method, current.args); + }, + () => byMethod.delete(method) + ); + + return current.timer; +} + +export function throttle(...args: unknown[]): SchedulerTimer { + let immediate = true; + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; + } + let wait = popWait(args, 0); + let { target, method, args: methodArgs } = parseArgs(args); + + let byMethod = dedupeEntries(throttleMap, target); + let existing = byMethod.get(method); + + if (existing !== undefined) { + // Within the spacing period: coalesce into the existing timer. + existing.args = methodArgs; + return existing.timer; + } + + if (immediate) { + invokeWithOnError(target, method, methodArgs); + } + + let timer = makeTimer(); + let entry: DedupedTimerEntry = { timer, args: methodArgs }; + byMethod.set(method, entry); + + setTimer( + timer, + wait, + () => { + if (!immediate) invokeWithOnError(target, method, entry.args); + }, + () => byMethod.delete(method) + ); + + return timer; +} + +export function cancel(timer?: unknown): boolean { + if (!isSchedulerTimer(timer) || timer.cancelled || timer.finished) { + return false; + } + timer.cancelled = true; + finish(timer); + timer.cleanup?.(); + return true; +} + +export function hasTimers(): boolean { + return pendingTimerCount > 0; +} + +export function cancelTimers(): void { + for (let timer of Array.from(activeTimers)) { + cancel(timer); + } +} + +// Whether any work (queue items, phases, or timers) is still pending. +// The settled()/test-waiter integration described by RFC 0957 hangs off of +// this. +export function _hasPendingWork(): boolean { + return pendingCount > 0; +} diff --git a/packages/@ember/runloop/index.ts b/packages/@ember/runloop/index.ts index db079ea25b2..fa2fbc3989a 100644 --- a/packages/@ember/runloop/index.ts +++ b/packages/@ember/runloop/index.ts @@ -1,8 +1,10 @@ import { assert } from '@ember/debug'; +import { ENV } from '@ember/-internals/environment/lib/env'; import { onErrorTarget } from '@ember/-internals/error-handling'; import { flushAsyncObservers } from '@ember/-internals/metal/lib/observer'; import Backburner, { type Timer, type DeferredActionQueues } from 'backburner.js'; import type { AnyFn } from '@ember/-internals/utility-types'; +import * as schedulerLoop from './-private/scheduler-loop'; export type { Timer }; @@ -146,6 +148,9 @@ export function run( ...args: T[U] extends AnyFn ? Parameters : [] ): T[U] extends AnyFn ? ReturnType : unknown; export function run(...args: any[]): unknown { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.run(...args); + } // @ts-expect-error TS doesn't like our spread args return _backburner.run(...args); } @@ -206,6 +211,9 @@ export function join( ...args: T[U] extends AnyFn ? Parameters : [] ): T[U] extends AnyFn ? ReturnType | void : void; export function join(methodOrTarget: any, methodOrArg?: any, ...additionalArgs: any[]): any { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.join(methodOrTarget, methodOrArg, ...additionalArgs); + } return _backburner.join(methodOrTarget, methodOrArg, ...additionalArgs); } @@ -351,6 +359,10 @@ export function bind(...curried: any[]): any { @public */ export function begin() { + if (ENV._USE_ASYNC_SCHEDULER) { + // There is no runloop to open; work is scheduled as it arrives. + return; + } _backburner.begin(); } @@ -374,6 +386,9 @@ export function begin() { @public */ export function end() { + if (ENV._USE_ASYNC_SCHEDULER) { + return; + } _backburner.end(); } @@ -436,17 +451,28 @@ export function schedule( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function schedule(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + // @ts-expect-error TS doesn't like the rest args here + return schedulerLoop.schedule(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.schedule(...args); } // Used by global test teardown export function _hasScheduledTimers() { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.hasTimers(); + } return _backburner.hasTimers(); } // Used by global test teardown export function _cancelTimers() { + if (ENV._USE_ASYNC_SCHEDULER) { + schedulerLoop.cancelTimers(); + return; + } _backburner.cancelTimers(); } @@ -495,6 +521,9 @@ export function later( ...args: [...args: T[U] extends AnyFn ? Parameters : [], wait: string | number] ): Timer; export function later(...args: any): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.later(...args) as unknown as Timer; + } return _backburner.later(...args); } @@ -525,6 +554,9 @@ export function once( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function once(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.scheduleOnce('actions', ...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.scheduleOnce('actions', ...args); } @@ -619,6 +651,10 @@ export function scheduleOnce( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function scheduleOnce(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + // @ts-expect-error TS doesn't like the rest args here + return schedulerLoop.scheduleOnce(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.scheduleOnce(...args); } @@ -705,6 +741,9 @@ export function next( ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; export function next(...args: any[]) { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.next(...args) as unknown as Timer; + } return _backburner.later(...args, 1); } @@ -776,6 +815,9 @@ export function next(...args: any[]) { @public */ export function cancel(timer?: Timer): boolean { + if (schedulerLoop.isSchedulerTimer(timer)) { + return schedulerLoop.cancel(timer); + } return _backburner.cancel(timer); } @@ -872,6 +914,9 @@ export function debounce( ] ): Timer; export function debounce(...args: any[]) { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.debounce(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.debounce(...args); } @@ -938,6 +983,9 @@ export function throttle( ] ): Timer; export function throttle(...args: any[]): Timer { + if (ENV._USE_ASYNC_SCHEDULER) { + return schedulerLoop.throttle(...args) as unknown as Timer; + } // @ts-expect-error TS doesn't like the rest args here return _backburner.throttle(...args); } diff --git a/packages/@ember/runloop/package.json b/packages/@ember/runloop/package.json index e0b194a2af7..63ac517f2f6 100644 --- a/packages/@ember/runloop/package.json +++ b/packages/@ember/runloop/package.json @@ -12,6 +12,7 @@ "@ember/array": "workspace:*", "@ember/debug": "workspace:*", "@ember/object": "workspace:*", + "@ember/scheduler": "workspace:*", "@ember/utils": "workspace:*", "@glimmer/env": "workspace:*", "@glimmer/validator": "workspace:*", diff --git a/packages/@ember/runloop/tests/use_async_scheduler_test.js b/packages/@ember/runloop/tests/use_async_scheduler_test.js new file mode 100644 index 00000000000..0336426b5f6 --- /dev/null +++ b/packages/@ember/runloop/tests/use_async_scheduler_test.js @@ -0,0 +1,218 @@ +import { ENV } from '@ember/-internals/environment'; +import { _clearRegisteredStrategy } from '@ember/scheduler'; +import { renderSettled } from '@ember/-internals/glimmer'; +import { + run, + join, + bind, + schedule, + scheduleOnce, + once, + next, + later, + cancel, + debounce, + throttle, + _hasScheduledTimers, + _cancelTimers, +} from '..'; +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; + +moduleFor( + 'system/run_loop/use_async_scheduler_test', + class extends AbstractTestCase { + beforeEach() { + ENV._USE_ASYNC_SCHEDULER = true; + _clearRegisteredStrategy(); + } + + teardown() { + _cancelTimers(); + ENV._USE_ASYNC_SCHEDULER = false; + _clearRegisteredStrategy(); + } + + ['@test run executes the callback synchronously and returns its value'](assert) { + let order = []; + + let result = run(() => { + order.push('inside'); + return 42; + }); + + order.push('after'); + + assert.equal(result, 42, 'returns the callback value'); + assert.deepEqual(order, ['inside', 'after'], 'callback ran synchronously'); + } + + ['@test run resolves a string method on a target'](assert) { + let target = { + count: 0, + increment(amount) { + this.count += amount; + return this.count; + }, + }; + + let result = run(target, 'increment', 5); + + assert.equal(result, 5, 'method invoked with target as this'); + } + + ['@test join executes directly and returns its value'](assert) { + let result = join(() => 'joined'); + assert.equal(result, 'joined', 'join returns the callback value'); + } + + ['@test bind returns a function that executes in the bound context'](assert) { + let target = { + name: 'target', + getName() { + return this.name; + }, + }; + + let bound = bind(target, target.getName); + + assert.equal(bound(), 'target', 'bound function sees its target'); + } + + async ['@test schedule("actions") defers to a microtask'](assert) { + let order = []; + + schedule('actions', () => order.push('scheduled')); + order.push('sync'); + + assert.deepEqual(order, ['sync'], 'not invoked synchronously'); + + await Promise.resolve(); + + assert.deepEqual(order, ['sync', 'scheduled'], 'invoked on the microtask queue'); + } + + async ['@test schedule("render") and schedule("afterRender") run in phase order'](assert) { + let order = []; + let done = new Promise((resolve) => { + schedule('afterRender', () => { + order.push('afterRender'); + resolve(); + }); + }); + schedule('render', () => order.push('render')); + schedule('actions', () => order.push('actions')); + + await done; + + assert.deepEqual( + order, + ['actions', 'render', 'afterRender'], + 'microtask before render phase before layout phase' + ); + } + + async ['@test a scheduled item can be cancelled'](assert) { + let hasRan = false; + + let timer = schedule('actions', () => (hasRan = true)); + let cancelled = cancel(timer); + + await Promise.resolve(); + + assert.true(cancelled, 'cancel reported success'); + assert.false(hasRan, 'cancelled item did not run'); + } + + async ['@test scheduleOnce deduplicates by queue, target and method'](assert) { + let invocations = []; + let target = { + record(value) { + invocations.push(value); + }, + }; + + let first = scheduleOnce('actions', target, 'record', 1); + let second = scheduleOnce('actions', target, 'record', 2); + + assert.strictEqual(first, second, 'both calls share one timer'); + + await Promise.resolve(); + + assert.deepEqual(invocations, [2], 'ran once, with the latest arguments'); + } + + async ['@test once deduplicates on the actions queue'](assert) { + let count = 0; + let increment = () => count++; + + once(increment); + once(increment); + + await Promise.resolve(); + + assert.equal(count, 1, 'ran once'); + } + + async ['@test next schedules into the next phase'](assert) { + let hasRan = false; + + await new Promise((resolve) => { + next(() => { + hasRan = true; + resolve(); + }); + }); + + assert.true(hasRan, 'next callback ran'); + } + + async ['@test later fires after the wait and can be cancelled'](assert) { + let fired = []; + + later(() => fired.push('kept'), 1); + let timer = later(() => fired.push('cancelled'), 1); + + assert.true(_hasScheduledTimers(), 'timers are pending'); + + cancel(timer); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(fired, ['kept'], 'only the un-cancelled timer fired'); + assert.false(_hasScheduledTimers(), 'no timers remain'); + } + + async ['@test debounce collapses repeated calls into one trailing invocation'](assert) { + let invocations = []; + let record = (value) => invocations.push(value); + + debounce(null, record, 1, 5); + debounce(null, record, 2, 5); + debounce(null, record, 3, 5); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + assert.deepEqual(invocations, [3], 'ran once with the latest arguments'); + } + + async ['@test throttle invokes on the leading edge and coalesces the rest'](assert) { + let invocations = []; + let record = (value) => invocations.push(value); + + throttle(null, record, 1, 20); + throttle(null, record, 2, 20); + throttle(null, record, 3, 20); + + assert.deepEqual(invocations, [1], 'invoked immediately, once'); + + await new Promise((resolve) => setTimeout(resolve, 40)); + + assert.deepEqual(invocations, [1], 'no trailing invocation'); + } + + async ['@test renderSettled resolves through the scheduler'](assert) { + await renderSettled(); + assert.ok(true, 'renderSettled resolved with no backburner runloop'); + } + } +); diff --git a/packages/@ember/scheduler/index.ts b/packages/@ember/scheduler/index.ts index 2410c394eff..03b120f2cb2 100644 --- a/packages/@ember/scheduler/index.ts +++ b/packages/@ember/scheduler/index.ts @@ -123,6 +123,12 @@ export function _clearRegisteredStrategy(): void { registeredStrategy = null; } +// Private API used by the runloop's scheduler backend to fall back to the +// default strategy when the app has not registered one. +export function _getRegisteredStrategy(): Strategy | null { + return registeredStrategy; +} + 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);`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0998c2acc83..cfa34481cfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1054,6 +1054,9 @@ importers: '@ember/object': specifier: workspace:* version: link:../object + '@ember/scheduler': + specifier: workspace:* + version: link:../scheduler '@ember/utils': specifier: workspace:* version: link:../utils diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index 2d2855471ec..ee792b69c74 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -11,6 +11,7 @@ module.exports = { '_DEBUG_RENDER_TREE', '_DEFAULT_ASYNC_OBSERVERS', '_RERENDER_LOOP_LIMIT', + '_USE_ASYNC_SCHEDULER', '_ALL_DEPRECATIONS_ENABLED', '_OVERRIDE_DEPRECATION_VERSION', 'Input',