diff --git a/build-metadata.json b/build-metadata.json new file mode 100644 index 00000000000..a85dc183f51 --- /dev/null +++ b/build-metadata.json @@ -0,0 +1,6 @@ +{ + "version": "7.3.0-alpha.1.all-optimizations+60f0fc94", + "buildType": "all-optimizations", + "SHA": "60f0fc94376a6f41079d3a4344b9f10f8ff8783a", + "assetPath": "/all-optimizations/shas/60f0fc94376a6f41079d3a4344b9f10f8ff8783a.tgz" +} diff --git a/package.json b/package.json index dfac084f3da..0a19945c92a 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,7 @@ }, "license": "MIT", "sideEffects": [ - "./dist/*/packages/ember-testing/index.js", - "./dist/*/packages/@ember/-internals/runtime/lib/ext/rsvp.js" + "./dist/*/packages/ember-testing/index.js" ], "type": "module", "exports": { @@ -85,7 +84,6 @@ "@babel/core": "^7.24.4", "@embroider/addon-shim": "^1.10.2", "@simple-dom/interface": "^1.4.0", - "backburner.js": "^2.8.0", "broccoli-file-creator": "^2.1.1", "chalk": "^4.0.0", "ember-cli-get-component-path-option": "^1.0.0", @@ -219,7 +217,6 @@ "@ember/-internals/routing/route-managers/registry.js": "ember-source/@ember/-internals/routing/route-managers/registry.js", "@ember/-internals/routing/route-managers/root-outlet.js": "ember-source/@ember/-internals/routing/route-managers/root-outlet.js", "@ember/-internals/runtime/index.js": "ember-source/@ember/-internals/runtime/index.js", - "@ember/-internals/runtime/lib/ext/rsvp.js": "ember-source/@ember/-internals/runtime/lib/ext/rsvp.js", "@ember/-internals/runtime/lib/mixins/-proxy.js": "ember-source/@ember/-internals/runtime/lib/mixins/-proxy.js", "@ember/-internals/runtime/lib/mixins/action_handler.js": "ember-source/@ember/-internals/runtime/lib/mixins/action_handler.js", "@ember/-internals/runtime/lib/mixins/comparable.js": "ember-source/@ember/-internals/runtime/lib/mixins/comparable.js", @@ -313,6 +310,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", @@ -371,7 +370,6 @@ "@glimmer/vm/index.js": "ember-source/@glimmer/vm/index.js", "@glimmer/wire-format/index.js": "ember-source/@glimmer/wire-format/index.js", "@simple-dom/document/index.js": "ember-source/@simple-dom/document/index.js", - "backburner.js/index.js": "ember-source/backburner.js/index.js", "dag-map/index.js": "ember-source/dag-map/index.js", "ember-template-compiler/index.js": "ember-source/ember-template-compiler/index.js", "ember-testing/index.js": "ember-source/ember-testing/index.js", @@ -383,8 +381,7 @@ "ember-testing/lib/test/waiters.js": "ember-source/ember-testing/lib/test/waiters.js", "ember/version.js": "ember-source/ember/version.js", "route-recognizer/index.js": "ember-source/route-recognizer/index.js", - "router_js/index.js": "ember-source/router_js/index.js", - "rsvp/index.js": "ember-source/rsvp/index.js" + "router_js/index.js": "ember-source/router_js/index.js" } } } diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 90addfe68e5..f4ca66fe8f4 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,15 +26,22 @@ 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, + _beginRenderTransaction, + _drainScheduledDestroys, + _endRenderTransaction, + _hasScheduledDestroys, + _setDestroyQueueObserver, +} from './environment'; import ResolverImpl from './resolver'; +import schedulerStrategy from '@ember/scheduler/strategy'; import { renderers } from './renderers'; 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. @@ -150,12 +155,59 @@ 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(); } + + // the pending edge for the settledness observer + sampleSettledState(); +}); + +// Settledness edges. Work is outstanding while any renderer awaits its +// flush or destruction awaits its drain -- the window classic's autorun +// instance covered. Rather than exporting that level as a probe, the +// renderer reports its EDGES to a single observer (test infrastructure +// registers a bridge that translates them into an @ember/test-waiters +// waiter, folding rendering into the one settledness protocol the +// ecosystem already has). The level is sampled at every site that can +// change it; with no observer registered the sites pay one null check. +let settledObserver: ((pending: boolean) => void) | null = null; +let observedPending = false; + +function sampleSettledState(): void { + if (settledObserver === null) return; + + const pending = renderers.some((renderer) => !renderer.isValid()) || _hasScheduledDestroys(); + + if (pending !== observedPending) { + observedPending = pending; + settledObserver(pending); + } } +export function _onRenderSettledChange(observer: (pending: boolean) => void): void { + settledObserver = observer; + observedPending = false; + sampleSettledState(); +} + +_setDestroyQueueObserver(sampleSettledState); + +// The default @ember/scheduler strategy IS this clock: awaited phases +// request a tick here (a clean renderer revalidates as a no-op and the +// tick still arrives pre-paint), and every tick that leaves the +// renderer valid drives the strategy's phase windows below. +schedulerStrategy._setTickRequester(() => { + for (let renderer of renderers) { + renderer.rerender(); + } +}); + interface RenderSettledDeferred { promise: Promise; resolve: () => void; @@ -165,8 +217,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 @@ -176,46 +229,37 @@ 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); + // Resolution belongs to the end of a scheduler flush -- classic + // resolved at the end of the next runloop flush, whose render queue + // had already run. Request a tick (a no-op revalidation when + // nothing is dirty) and the flush resolves on its way out; work + // that lands before that tick, like an un-awaited render() call + // dirtying the renderer, coalesces into the same flush and is + // rendered before resolution. With no renderers yet (pre-boot), + // settle on a microtask. + if (renderers.length === 0) { + queueMicrotask(resolveRenderPromiseIfSettled); + } else { + for (let renderer of renderers) { + renderer.rerender(); + } } } return renderSettledDeferred.promise; } -function resolveRenderPromise() { - if (renderSettledDeferred !== null) { - let resolve = renderSettledDeferred.resolve; - renderSettledDeferred = null; - - _backburner.join(null, resolve); - } -} +function resolveRenderPromiseIfSettled() { + if (renderSettledDeferred === null) return; -let loops = 0; -function loopEnd() { 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++; - 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; @@ -304,6 +348,7 @@ export class RendererState { // used to prevent calling _renderRoots again (see above) // while we are actively rendering roots this.#inRenderTransaction = true; + _beginRenderTransaction(); let completedWithoutError = false; try { @@ -314,6 +359,7 @@ export class RendererState { this.#lastRevision = valueForTag(CURRENT_TAG); } this.#inRenderTransaction = false; + _endRenderTransaction(); } } @@ -367,8 +413,183 @@ export class RendererState { } } + #flushScheduled = false; + #renderer: BaseRenderer | null = null; + #rafHandle: number | null = null; + #channel: MessageChannel | null = null; + #channelArmed = false; + #flushesThisFrame = 0; + #lastFrameAt = 0; + #viaStream = false; + + /** + * True during the microtask drain that follows a tick. Dirt arriving + * in that window comes from the tick's own continuations + * (render-coupled follow-ups: an after-render effect awaiting a + * microtask before setting state), so the next tick is scheduled at + * microtask speed; anything later takes the frame-paced legs. This is + * the same classifier Angular's zoneless scheduler ships + * (useMicrotaskScheduler + switchToMicrotaskScheduler): semantically + * exact, no wall clocks, no misclassification under CPU throttle. + */ + #microtaskWindow = false; + + #closeMicrotaskWindow = () => { + this.#microtaskWindow = false; + }; + + // 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); + }; + + /** + * consecutive ticks that ended still-dirty (state was dirtied while + * we were rendering). A few settle rounds run at microtask speed for + * legitimate measure-then-adjust patterns; past that, ticking + * degrades to the frame-paced stream legs so a pathological + * render->dirty loop paints between ticks instead of freezing the + * thread. Replaces the old unbounded flush-until-stable loop. + */ + #settleRounds = 0; + + #flush(viaFrame: boolean): void { + if (!this.#flushScheduled) return; + + if (viaFrame) { + this.#flushesThisFrame = 0; + this.#rafHandle = null; + } else { + 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.#viaStream = false; + + const renderer = this.#renderer; + + if (renderer === null) return; + + // clock semantics: one render per tick, taking whatever has been + // dirtied so far. Code that keeps dirtying state while we render + // just accumulates work for the next tick -- the flag stays set + // through revalidation so mid-render dirt dedupes into this tick's + // snapshot rather than arming machinery, and clears before the + // destroy drain, whose destructors may dirty state that genuinely + // belongs to the next tick. + this.revalidate(renderer); + + this.#flushScheduled = false; + + this.#microtaskWindow = true; + queueMicrotask(this.#closeMicrotaskWindow); + + _drainScheduledDestroys(); + + if (this.isValid()) { + this.#settleRounds = 0; + schedulerStrategy._onRendererTick(); + resolveRenderPromiseIfSettled(); + } else if (this.#settleRounds < 3) { + this.#settleRounds++; + this.#flushScheduled = true; + queueMicrotask(this.#microtaskFlush); + } else { + this.#armStreamTick(renderer, performance.now()); + } + + // the quiet edge for the settledness observer (a no-op while the + // settle rounds above still hold the renderer invalid) + sampleSettledState(); + } + + /** + * 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 -- unless rAF itself has stopped being serviced (see + * below) + */ scheduleRevalidate(renderer: BaseRenderer): void { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); + if (this.#flushScheduled) { + return; + } + + this.#renderer = renderer; + this.#flushScheduled = true; + + // Dirt inside the post-tick microtask window is render-coupled + // (chain) work and ticks at microtask speed, like the classic + // runloop -- this cannot defeat coalescing of awaited update loops, + // which drain their entire microtask chain before their first tick + // ever runs. Everything else is stream dirt and takes the + // frame-paced legs. SSR has no paint to schedule against, so it + // always ticks on a microtask. + if (this.#microtaskWindow || typeof requestAnimationFrame !== 'function') { + queueMicrotask(this.#microtaskFlush); + return; + } + + this.#armStreamTick(renderer, performance.now()); + } + + /** + * The frame-paced legs of the clock: a tick arrives at the next + * rendering opportunity (rAF), raced by an unclamped macrotask that + * stands down under sustained per-frame bursts so flushes ride the + * frame. + */ + #armStreamTick(renderer: BaseRenderer, now: number): void { + this.#renderer = renderer; + this.#flushScheduled = true; + this.#viaStream = true; + this.#rafHandle = requestAnimationFrame(this.#frameFlush); + + // 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; + this.#flush(false); + }; + } + + if (!this.#channelArmed) { + this.#channelArmed = true; + this.#channel.port2.postMessage(null); + } + } } isValid(): boolean { diff --git a/packages/@ember/-internals/glimmer/lib/environment.ts b/packages/@ember/-internals/glimmer/lib/environment.ts index fc2d99f4f4d..832903d2876 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,104 @@ 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; +let draining = false; +let renderTransactionDepth = 0; + +export function _hasScheduledDestroys(): boolean { + return scheduledDestructors.length > 0 || scheduledFinalizers.length > 0; +} + +// Notified when the destroy queues go non-empty or fully drain, so the +// renderer's settledness observer sees destroy-only work (teardown with +// nothing dirty) without polling. The setter indirection avoids a +// module cycle with the renderer. +let destroyQueueObserver: (() => void) | null = null; + +export function _setDestroyQueueObserver(observer: () => void): void { + destroyQueueObserver = observer; +} + +export function _beginRenderTransaction(): void { + renderTransactionDepth++; +} + +export function _endRenderTransaction(): void { + renderTransactionDepth--; +} + +/** + * Runs pending destructors, then finalizers -- the classic + * actions-before-destroy queue ordering. Destruction can schedule + * further destruction, so drain until quiet. + * + * Draining is skipped while a drain is already running (the outer + * loop picks up whatever was scheduled) or while roots are mid-render + * (running destructors would mutate DOM under the updating VM); in + * both cases the pending work is picked up by the caller that holds + * the guard, or by the armed fallback microtask. + */ +export function _drainScheduledDestroys(): void { + if (draining || renderTransactionDepth > 0) return; + + destroyDrainArmed = false; + draining = true; + + try { + 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(); + } + } + } finally { + draining = false; + } + + destroyQueueObserver?.(); +} + +function armDestroyDrain(): void { + if (destroyDrainArmed) return; + destroyDrainArmed = true; + queueMicrotask(() => { + if (destroyDrainArmed) { + _drainScheduledDestroys(); + } + }); +} + // Setup global context setGlobalContext({ scheduleRevalidate() { - _backburner.ensureInstance(); + notifyRevalidate(); }, toBool, @@ -33,11 +125,24 @@ setGlobalContext({ setPath: set, scheduleDestroy(destroyable, destructor) { - schedule('actions', null, destructor, destroyable); + const wasEmpty = !_hasScheduledDestroys(); + + scheduledDestructors.push({ + destroyable, + destructor: destructor as (destroyable: object) => void, + }); + armDestroyDrain(); + + if (wasEmpty) destroyQueueObserver?.(); }, scheduleDestroyed(finalizeDestructor) { - schedule('destroy', null, finalizeDestructor); + const wasEmpty = !_hasScheduledDestroys(); + + scheduledFinalizers.push(finalizeDestructor); + armDestroyDrain(); + + if (wasEmpty) destroyQueueObserver?.(); }, warnIfStyleNotTrusted(value: unknown) { diff --git a/packages/@ember/-internals/glimmer/lib/helper.ts b/packages/@ember/-internals/glimmer/lib/helper.ts index 81e59cca7e0..334482ea7fe 100644 --- a/packages/@ember/-internals/glimmer/lib/helper.ts +++ b/packages/@ember/-internals/glimmer/lib/helper.ts @@ -8,7 +8,6 @@ import { setOwner } from '@ember/-internals/owner'; import { FrameworkObject } from '@ember/object/-internals'; import getDebugName from '@ember/-internals/utils/lib/get-debug-name'; import { assert } from '@ember/debug'; -import { join } from '@ember/runloop'; import type { Arguments, HelperManager } from '@glimmer/interfaces'; import { helperCapabilities } from '@glimmer/manager/lib/public/helper'; import { setHelperManager } from '@glimmer/manager/lib/public/api'; @@ -188,7 +187,7 @@ export default class Helper extends FrameworkObject { @since 1.13.0 */ recompute() { - join(() => dirtyTag(this[RECOMPUTE_TAG])); + dirtyTag(this[RECOMPUTE_TAG]); } } /* eslint-enable import/export */ diff --git a/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js b/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js index 409ba12fe02..31bfd38adc9 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js @@ -7,7 +7,7 @@ import { import Component from '@glimmer/component'; import Route from '@ember/routing/route'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Controller from '@ember/controller'; import Engine from '@ember/engine'; import { next } from '@ember/runloop'; diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js index 53c0ff4601f..87f3d5b94ef 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js @@ -1,5 +1,5 @@ import Controller from '@ember/controller'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import { precompileTemplate } from '@ember/template-compilation'; import { diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-curly-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-curly-test.js index f5ec3491c2b..2e206c99c22 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-curly-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-curly-test.js @@ -1,5 +1,5 @@ import Controller from '@ember/controller'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import { precompileTemplate } from '@ember/template-compilation'; import { diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js index 2a4d84bedd1..79de233384a 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js @@ -7,7 +7,7 @@ import { } from 'internal-test-helpers'; import Controller, { inject as injectController } from '@ember/controller'; import { A as emberA } from '@ember/array'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import NoneLocation from '@ember/routing/none-location'; import { service } from '@ember/service'; diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js index e1313862bba..9e9f5f199d7 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js @@ -6,7 +6,7 @@ import { } from 'internal-test-helpers'; import Controller, { inject as injectController } from '@ember/controller'; import { A as emberA } from '@ember/array'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import NoneLocation from '@ember/routing/none-location'; import { service } from '@ember/service'; diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js index f121b32aaf2..ec65390acaf 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js @@ -1,4 +1,4 @@ -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import { precompileTemplate } from '@ember/template-compilation'; import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js index d2a831a5e3c..813c3901c37 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js @@ -1,4 +1,4 @@ -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import { precompileTemplate } from '@ember/template-compilation'; import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; diff --git a/packages/@ember/-internals/glimmer/tests/integration/syntax/each-test.js b/packages/@ember/-internals/glimmer/tests/integration/syntax/each-test.js index 83216933002..9135fc9c2fa 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/syntax/each-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/syntax/each-test.js @@ -12,7 +12,7 @@ import { notifyPropertyChange } from '@ember/-internals/metal'; import { get, set, computed } from '@ember/object'; import { A as emberA } from '@ember/array'; import ArrayProxy from '@ember/array/proxy'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; diff --git a/packages/@ember/-internals/package.json b/packages/@ember/-internals/package.json index 7f5f825722b..c0d0657c98b 100644 --- a/packages/@ember/-internals/package.json +++ b/packages/@ember/-internals/package.json @@ -42,6 +42,7 @@ "@ember/reactive": "workspace:*", "@ember/routing": "workspace:*", "@ember/runloop": "workspace:*", + "@ember/scheduler": "workspace:*", "@ember/service": "workspace:*", "@ember/template-factory": "workspace:*", "@ember/utils": "workspace:*", diff --git a/packages/@ember/-internals/routing/route-managers/classic/manager.ts b/packages/@ember/-internals/routing/route-managers/classic/manager.ts index 9f4af816012..d015ef5ca6a 100644 --- a/packages/@ember/-internals/routing/route-managers/classic/manager.ts +++ b/packages/@ember/-internals/routing/route-managers/classic/manager.ts @@ -15,7 +15,6 @@ import { makeRouteTemplate } from '@ember/-internals/glimmer/lib/component-manag import { precompileTemplate } from '@ember/template-compilation'; import { createConstRef } from '@glimmer/reference/lib/reference'; import { CLASSIC_OUTLET } from './outlet-component'; -import { Promise as RSVPPromise } from 'rsvp'; import { cancel, scheduleOnce } from '@ember/runloop'; import type { InternalRouteInfo, RouteInfo, Transition } from 'router_js'; import { throwIfAborted } from 'router_js'; @@ -132,7 +131,7 @@ export class ClassicRouteManager implements RouteManagerWithClassicInterop { throwIfAborted(transition); }) @@ -167,7 +166,7 @@ export class ClassicRouteManager implements RouteManagerWithClassicInterop transition.resolvedModels![routeInfo.name]); + return Promise.resolve(result).then(() => transition.resolvedModels![routeInfo.name]); }); } @@ -215,7 +214,7 @@ export class ClassicRouteManager implements RouteManagerWithClassicInterop { - return RSVPPromise.resolve(buildClassicInvokable(bucket)); + return Promise.resolve(buildClassicInvokable(bucket)); } qp(bucket: ClassicRouteBucket): QueryParamMeta { diff --git a/packages/@ember/-internals/runtime/index.ts b/packages/@ember/-internals/runtime/index.ts index ec028a7a2da..df5d423fe12 100644 --- a/packages/@ember/-internals/runtime/index.ts +++ b/packages/@ember/-internals/runtime/index.ts @@ -5,5 +5,3 @@ export { default as ActionHandler } from './lib/mixins/action_handler'; export { default as _ProxyMixin, contentFor as _contentFor } from './lib/mixins/-proxy'; export { default as MutableEnumerable } from '@ember/enumerable/mutable'; export { default as TargetActionSupport } from './lib/mixins/target_action_support'; - -export { default as RSVP, onerrorDefault } from './lib/ext/rsvp'; // just for side effect of extending Ember.RSVP diff --git a/packages/@ember/-internals/runtime/lib/ext/rsvp.ts b/packages/@ember/-internals/runtime/lib/ext/rsvp.ts deleted file mode 100644 index 45419961a9a..00000000000 --- a/packages/@ember/-internals/runtime/lib/ext/rsvp.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as RSVP from 'rsvp'; -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) => { - _backburner.schedule('actions', null, callback, promise); -}); - -RSVP.configure('after', (cb: unknown) => { - _backburner.schedule(_rsvpErrorQueue, null, cb); -}); - -RSVP.on('error', onerrorDefault); - -export function onerrorDefault(reason: unknown) { - let error = errorFor(reason); - if (error) { - let overrideDispatch = getDispatchOverride(); - if (overrideDispatch) { - overrideDispatch(error); - } else { - throw error; - } - } -} - -interface ReasonWithErrorThrown { - errorThrown: unknown; -} - -interface ReasonWithName { - name: unknown; -} - -interface UnrecognizedURLError { - name: 'UnrecognizedURLError'; - message: string; -} - -function errorFor(reason: unknown) { - if (!reason) return; - - let withErrorThrown = reason as ReasonWithErrorThrown; - if (withErrorThrown.errorThrown) { - return unwrapErrorThrown(withErrorThrown); - } - - let withName = reason as UnrecognizedURLError; - if (withName.name === 'UnrecognizedURLError') { - assert(`The URL '${withName.message}' did not match any routes in your application`, false); - // @ts-expect-error We'll hit this if the assert is stripped - return; - } - - if ((reason as ReasonWithName).name === 'TransitionAborted') { - return; - } - - return reason; -} - -function unwrapErrorThrown(reason: ReasonWithErrorThrown) { - let error = reason.errorThrown; - if (typeof error === 'string') { - error = new Error(error); - } - Object.defineProperty(error, '__reason_with_error_thrown__', { - value: reason, - enumerable: false, - }); - return error; -} - -export default RSVP; diff --git a/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts index 34ca8f41fba..0245f409652 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts @@ -1,4 +1,5 @@ -import { schedule, join } from '@ember/runloop'; +import { scheduleDestroyed } from '@glimmer/global-context'; +import { _drainScheduledDestroys } from '@ember/-internals/glimmer/lib/environment'; /** @module ember */ @@ -43,10 +44,16 @@ const ContainerProxyMixin = Mixin[INTERNAL_MIXIN_CREATE]({ let container = this.__container__; if (container) { - join(() => { - container.destroy(); - schedule('destroy', container, 'finalizeDestroy'); - }); + container.destroy(); + scheduleDestroyed(() => container.finalizeDestroy()); + + // Classic wrapped the two calls above in `join`, which outside a + // run loop flushed every queue before returning -- embedders + // (SSR/prerender workers, FastBoot) rely on instance.destroy() + // having torn down its rendered DOM by the time it returns, and + // then reuse or reset the document. The drain no-ops when called + // mid-render or mid-drain; the tick/microtask drain covers those. + _drainScheduledDestroys(); } this._super(); diff --git a/packages/@ember/-internals/runtime/tests/ext/rsvp_test.js b/packages/@ember/-internals/runtime/tests/ext/rsvp_test.js deleted file mode 100644 index e142f9258c3..00000000000 --- a/packages/@ember/-internals/runtime/tests/ext/rsvp_test.js +++ /dev/null @@ -1,269 +0,0 @@ -import { next, run, schedule } from '@ember/runloop'; -import { getOnerror, setOnerror } from '@ember/-internals/error-handling'; -import RSVP from '../../lib/ext/rsvp'; -import { isTesting, setTesting } from '@ember/debug'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -const ORIGINAL_ONERROR = getOnerror(); - -moduleFor( - 'Ember.RSVP', - class extends AbstractTestCase { - afterEach() { - setOnerror(ORIGINAL_ONERROR); - } - - ['@test Ensure that errors thrown from within a promise are sent to the console'](assert) { - let error = new Error('Error thrown in a promise for testing purposes.'); - - try { - run(function () { - new RSVP.Promise(function () { - throw error; - }); - }); - assert.ok(false, 'expected assertion to be thrown'); - } catch (e) { - assert.equal(e, error, 'error was re-thrown'); - } - } - - ['@test TransitionAborted errors are not re-thrown'](assert) { - assert.expect(1); - let fakeTransitionAbort = { name: 'TransitionAborted' }; - - run(RSVP, 'reject', fakeTransitionAbort); - - assert.ok(true, 'did not throw an error when dealing with TransitionAborted'); - } - - ['@test Can reject with non-Error object'](assert) { - let wasEmberTesting = isTesting(); - setTesting(false); - assert.expect(1); - - try { - run(RSVP, 'reject', 'foo'); - } catch (e) { - assert.equal(e, 'foo', 'should throw with rejection message'); - } finally { - setTesting(wasEmberTesting); - } - } - - ['@test Can reject with no arguments'](assert) { - let wasEmberTesting = isTesting(); - setTesting(false); - assert.expect(1); - - try { - run(RSVP, 'reject'); - } catch { - assert.ok(false, 'should not throw'); - } finally { - setTesting(wasEmberTesting); - } - - assert.ok(true); - } - - ['@test rejections like jqXHR which have errorThrown property work'](assert) { - assert.expect(2); - - let wasEmberTesting = isTesting(); - let wasOnError = getOnerror(); - - try { - setTesting(false); - setOnerror((error) => { - assert.equal(error, actualError, 'expected the real error on the jqXHR'); - assert.equal( - error.__reason_with_error_thrown__, - jqXHR, - 'also retains a helpful reference to the rejection reason' - ); - }); - - let actualError = new Error('OMG what really happened'); - let jqXHR = { - errorThrown: actualError, - }; - - run(RSVP, 'reject', jqXHR); - } finally { - setOnerror(wasOnError); - setTesting(wasEmberTesting); - } - } - - ['@test rejections where the errorThrown is a string should wrap the sting in an error object']( - assert - ) { - assert.expect(2); - - let wasEmberTesting = isTesting(); - let wasOnError = getOnerror(); - - try { - setTesting(false); - setOnerror((error) => { - assert.equal(error.message, actualError, 'expected the real error on the jqXHR'); - assert.equal( - error.__reason_with_error_thrown__, - jqXHR, - 'also retains a helpful reference to the rejection reason' - ); - }); - - let actualError = 'OMG what really happened'; - let jqXHR = { - errorThrown: actualError, - }; - - run(RSVP, 'reject', jqXHR); - } finally { - setOnerror(wasOnError); - setTesting(wasEmberTesting); - } - } - - ['@test rejections can be serialized to JSON'](assert) { - assert.expect(2); - - let wasEmberTesting = isTesting(); - let wasOnError = getOnerror(); - - try { - setTesting(false); - setOnerror((error) => { - assert.equal(error.message, 'a fail'); - assert.ok(JSON.stringify(error), 'Error can be serialized'); - }); - - let jqXHR = { - errorThrown: new Error('a fail'), - }; - - run(RSVP, 'reject', jqXHR); - } finally { - setOnerror(wasOnError); - setTesting(wasEmberTesting); - } - } - } -); - -const reason = 'i failed'; -function ajax() { - return new RSVP.Promise(function (resolve) { - setTimeout(resolve, 0); // fake true / foreign async - }); -} - -moduleFor( - 'Ember.test: rejection assertions', - class extends AbstractTestCase { - ['@test unambigiously unhandled rejection'](assert) { - assert.throws(function () { - run(function () { - RSVP.Promise.reject(reason); - }); // something is funky, we should likely assert - }, reason); - } - - ['@test sync handled'](assert) { - run(function () { - RSVP.Promise.reject(reason).catch(function () {}); - }); // handled, we shouldn't need to assert. - assert.ok(true, 'reached end of test'); - } - - ['@test handled within the same micro-task (via Ember.RVP.Promise)'](assert) { - run(function () { - let rejection = RSVP.Promise.reject(reason); - RSVP.Promise.resolve(1).then(() => rejection.catch(function () {})); - }); // handled, we shouldn't need to assert. - assert.ok(true, 'reached end of test'); - } - - ['@test handled within the same micro-task (via direct run-loop)'](assert) { - run(function () { - let rejection = RSVP.Promise.reject(reason); - schedule('afterRender', () => rejection.catch(function () {})); - }); // handled, we shouldn't need to assert. - assert.ok(true, 'reached end of test'); - } - - ['@test handled in the next microTask queue flush (next)'](assert) { - assert.expect(2); - let done = assert.async(); - - assert.throws(function () { - run(function () { - let rejection = RSVP.Promise.reject(reason); - - next(() => { - rejection.catch(function () {}); - assert.ok(true, 'reached end of test'); - done(); - }); - }); - }, reason); - - // a promise rejection survived a full flush of the run-loop without being handled - // this is very likely an issue. - } - - ['@test handled in the same microTask Queue flush do to data locality'](assert) { - // an ambiguous scenario, this may or may not assert - // it depends on the locality of `user#1` - let store = { - find() { - return RSVP.Promise.resolve(1); - }, - }; - run(function () { - let rejection = RSVP.Promise.reject(reason); - store.find('user', 1).then(() => rejection.catch(function () {})); - }); - - assert.ok(true, 'reached end of test'); - } - - ['@test handled in a different microTask Queue flush do to data locality'](assert) { - let done = assert.async(); - // an ambiguous scenario, this may or may not assert - // it depends on the locality of `user#1` - let store = { - find() { - return ajax(); - }, - }; - assert.throws(function () { - run(function () { - let rejection = RSVP.Promise.reject(reason); - store.find('user', 1).then(() => { - rejection.catch(function () {}); - assert.ok(true, 'reached end of test'); - done(); - }); - }); - }, reason); - } - - ['@test handled in the next microTask queue flush (ajax example)'](assert) { - let done = assert.async(); - - assert.throws(function () { - run(function () { - let rejection = RSVP.Promise.reject(reason); - ajax().then(() => { - rejection.catch(function () {}); - assert.ok(true, 'reached end of test'); - done(); - }); - }); - }, reason); - } - } -); diff --git a/packages/@ember/-internals/runtime/tests/mixins/promise_proxy_test.js b/packages/@ember/-internals/runtime/tests/mixins/promise_proxy_test.js index 9efe631f1c1..d36245dc98a 100644 --- a/packages/@ember/-internals/runtime/tests/mixins/promise_proxy_test.js +++ b/packages/@ember/-internals/runtime/tests/mixins/promise_proxy_test.js @@ -2,8 +2,8 @@ import { run } from '@ember/runloop'; import { get } from '@ember/object'; import ObjectProxy from '@ember/object/proxy'; import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; -import EmberRSVP from '../../lib/ext/rsvp'; -import { onerrorDefault } from '../../lib/ext/rsvp'; +import * as EmberRSVP from 'rsvp'; +const onerrorDefault = () => {}; import * as RSVP from 'rsvp'; import { moduleFor, diff --git a/packages/@ember/-internals/utils/lib/microtask-scheduling.ts b/packages/@ember/-internals/utils/lib/microtask-scheduling.ts new file mode 100644 index 00000000000..4e0b3dff100 --- /dev/null +++ b/packages/@ember/-internals/utils/lib/microtask-scheduling.ts @@ -0,0 +1,50 @@ +/** + * SPIKE (runloop removal): the minimal scheduling vocabulary the + * framework actually needed from the runloop, rebuilt on microtasks. + * Rendering no longer flushes at runloop end, so "later in this loop" + * semantics collapse to "on a microtask" -- batched behind the current + * task's synchronous work, ahead of the scheduler's next tick. + */ + +const SCHEDULED_METHODS = new WeakMap>(); + +/** + * `once(target, method)` replacement: coalesces repeat requests for the + * same (target, method) until the scheduled microtask runs. + */ +export function scheduleMethodOnce(target: object, method: PropertyKey): void { + let methods = SCHEDULED_METHODS.get(target); + + if (methods === undefined) { + methods = new Set(); + SCHEDULED_METHODS.set(target, methods); + } + + if (methods.has(method)) return; + + methods.add(method); + + queueMicrotask(() => { + methods.delete(method); + (target as Record void>)[method]!(); + }); +} + +export interface CancelableMicrotask { + cancelled: boolean; +} + +/** + * `scheduleOnce` + `cancel` replacement for one-shot deferred work. + */ +export function scheduleCancelableMicrotask(fn: () => void): CancelableMicrotask { + const token: CancelableMicrotask = { cancelled: false }; + + queueMicrotask(() => { + if (!token.cancelled) { + fn(); + } + }); + + return token; +} diff --git a/packages/@ember/-internals/views/lib/views/states.ts b/packages/@ember/-internals/views/lib/views/states.ts index 3dead388b0f..fad32fa36c0 100644 --- a/packages/@ember/-internals/views/lib/views/states.ts +++ b/packages/@ember/-internals/views/lib/views/states.ts @@ -2,7 +2,6 @@ import { teardownMandatorySetter } from '@ember/-internals/utils/lib/mandatory-s import type Component from '@ember/-internals/glimmer/lib/component'; import { assert } from '@ember/debug'; import { flaggedInstrument } from '@ember/instrumentation'; -import { join } from '@ember/runloop'; import { DEBUG } from '@glimmer/env'; import { hasCoreViewListener, sendCoreViewEvent } from './core-view-utils'; @@ -50,9 +49,7 @@ const HAS_ELEMENT: Readonly = Object.freeze({ // Handler should be able to re-dispatch events, so we don't // preventDefault or stopPropagation. return flaggedInstrument(`interaction.${eventName}`, { event, view }, () => { - return join(() => { - return sendCoreViewEvent(view, eventName, [event]); - }); + return sendCoreViewEvent(view, eventName, [event]); }); } else { return true; // continue event propagation diff --git a/packages/@ember/application/index.ts b/packages/@ember/application/index.ts index e4ac76f8de3..d7c5af8d321 100644 --- a/packages/@ember/application/index.ts +++ b/packages/@ember/application/index.ts @@ -9,9 +9,28 @@ import { ENV } from '@ember/-internals/environment/lib/env'; import hasDOM from '@ember/-internals/browser-environment/lib/has-dom'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; -import { join, once, run, schedule } from '@ember/runloop'; import libraries from '@ember/-internals/metal/lib/libraries'; -import RSVP from '@ember/-internals/runtime/lib/ext/rsvp'; + +// resolve/reject use method syntax on purpose: property-syntax function +// types are strictly contravariant in T under strictFunctionTypes, which +// would stop `Deferred` (via `_bootResolver: Deferred`) from +// being assignable to `Deferred` at consumer call sites like +// `setApplication()`. RSVP's Deferred was method-syntax (bivariant) too. +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} + +function makeDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} import EventDispatcher from '@ember/-internals/views/lib/system/event_dispatcher'; import Route from '@ember/routing/route'; import Router from '@ember/routing/router'; @@ -490,7 +509,7 @@ class Application extends Engine { // If it's not actually a Document then it will evaluate false which is fine for our // purposes. if (document === null || (document as Document).readyState !== 'loading') { - schedule('actions', this, this.domReady); + queueMicrotask(() => this.domReady()); } else { // Ideally we'd just check `document instanceof Document` but currently some tests pass a fake document. assert( @@ -502,7 +521,7 @@ class Application extends Engine { let callback = () => { document.removeEventListener('DOMContentLoaded', callback); - run(this, this.domReady); + this.domReady(); }; document.addEventListener('DOMContentLoaded', callback); @@ -637,7 +656,7 @@ class Application extends Engine { this._readinessDeferrals--; if (this._readinessDeferrals === 0) { - once(this, this.didBecomeReady); + this.didBecomeReady(); } } @@ -683,7 +702,7 @@ class Application extends Engine { return this._bootPromise; } - _bootResolver: ReturnType<(typeof RSVP)['defer']> | null = null; + _bootResolver: Deferred | null = null; /** Unfortunately, a lot of existing code assumes the booting process is @@ -707,8 +726,8 @@ class Application extends Engine { // boot promise exists for book-keeping purposes: if anything went wrong in // the boot process, we need to store the error as a rejection on the boot // promise so that a future caller of `boot()` can tell what failed. - let defer = (this._bootResolver = RSVP.defer()); - this._bootPromise = defer.promise as Promise; + let defer = (this._bootResolver = makeDeferred()); + this._bootPromise = defer.promise; try { this.runInitializers(); @@ -817,14 +836,10 @@ class Application extends Engine { this._bootResolver = null; this._booted = false; - function handleReset(this: Application) { - assert('expected instance', instance); - run(instance, 'destroy'); - this._buildDeprecatedInstance(); - schedule('actions', this, '_bootSync'); - } - - join(this, handleReset); + assert('expected instance', instance); + instance.destroy(); + this._buildDeprecatedInstance(); + queueMicrotask(() => this._bootSync()); } /** @@ -1102,7 +1117,7 @@ class Application extends Engine { .boot(options) .then(() => instance.visit(url)) .catch((error) => { - run(instance, 'destroy'); + instance.destroy(); throw error; }); }); diff --git a/packages/@ember/application/tests/visit_test.js b/packages/@ember/application/tests/visit_test.js index 8bfbe36b718..2475e2b5460 100644 --- a/packages/@ember/application/tests/visit_test.js +++ b/packages/@ember/application/tests/visit_test.js @@ -8,7 +8,8 @@ import { import { service } from '@ember/service'; import { setComponentTemplate } from '@glimmer/manager'; import EmberObject from '@ember/object'; -import { RSVP, onerrorDefault } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; +const onerrorDefault = () => {}; import { later } from '@ember/runloop'; import { action } from '@ember/object'; import Application from '@ember/application'; diff --git a/packages/@ember/debug/data-adapter.ts b/packages/@ember/debug/data-adapter.ts index 4b37faaff4b..b6987dc4692 100644 --- a/packages/@ember/debug/data-adapter.ts +++ b/packages/@ember/debug/data-adapter.ts @@ -1,6 +1,5 @@ import type Owner from '@ember/owner'; import { getOwner } from '@ember/-internals/owner'; -import { _backburner, next } from '@ember/runloop'; import { get } from '@ember/-internals/metal/lib/property_get'; import { dasherize } from '@ember/-internals/string'; import Namespace from '@ember/application/namespace'; @@ -166,7 +165,7 @@ class TypeWatcher { consumeTag(tagFor(records, '[]')); if (hasBeenAccessed === true) { - next(onChange); + setTimeout(onChange, 0); } else { hasBeenAccessed = true; } @@ -226,6 +225,8 @@ export default class DataAdapter extends EmberObject { typeWatchers: Map void; revalidate: () => void }> = new Map(); flushWatchers: (() => void) | null = null; + _flushInterval: ReturnType | undefined = undefined; + // TODO: Revisit this declare containerDebugAdapter: ContainerDebugAdapter; @@ -436,10 +437,13 @@ export default class DataAdapter extends EmberObject { this.recordsWatchers.forEach((watcher) => watcher.revalidate()); }; - _backburner.on('end', this.flushWatchers); + // SPIKE (runloop removal): watchers used to revalidate at + // runloop end; poll instead -- this is inspector-only tooling + this._flushInterval = setInterval(this.flushWatchers, 100); } } else if (this.typeWatchers.size === 0 && this.recordsWatchers.size === 0) { - _backburner.off('end', this.flushWatchers); + clearInterval(this._flushInterval); + this._flushInterval = undefined; this.flushWatchers = null; } } @@ -458,7 +462,8 @@ export default class DataAdapter extends EmberObject { this.releaseMethods.forEach((fn) => fn()); if (this.flushWatchers) { - _backburner.off('end', this.flushWatchers); + clearInterval(this._flushInterval); + this._flushInterval = undefined; } } diff --git a/packages/@ember/engine/instance.ts b/packages/@ember/engine/instance.ts index b49d1280121..c39769272d5 100644 --- a/packages/@ember/engine/instance.ts +++ b/packages/@ember/engine/instance.ts @@ -3,7 +3,6 @@ */ import EmberObject from '@ember/object'; -import RSVP from '@ember/-internals/runtime/lib/ext/rsvp'; import { assert } from '@ember/debug'; import { default as Registry, privatize as P } from '@ember/-internals/container/lib/registry'; import { guidFor } from '@ember/-internals/utils/lib/guid'; @@ -108,7 +107,7 @@ class EngineInstance extends EmberObject.extend(RegistryProxyMixin, ContainerPro this._booted = false; } - _bootPromise: RSVP.Promise | null = null; + _bootPromise: Promise | null = null; /** Initialize the `EngineInstance` and return a promise that resolves @@ -128,7 +127,7 @@ class EngineInstance extends EmberObject.extend(RegistryProxyMixin, ContainerPro return this._bootPromise; } - this._bootPromise = new RSVP.Promise((resolve) => { + this._bootPromise = new Promise((resolve) => { resolve(this._bootSync(options)); }); diff --git a/packages/@ember/object/promise-proxy-mixin.ts b/packages/@ember/object/promise-proxy-mixin.ts index 977d645b68c..e4cc80287ec 100644 --- a/packages/@ember/object/promise-proxy-mixin.ts +++ b/packages/@ember/object/promise-proxy-mixin.ts @@ -5,14 +5,13 @@ import Mixin from '@ember/object/mixin'; import { INTERNAL_MIXIN_CREATE } from '@ember/-internals/utils/lib/internal-mixin-create'; import { DEPRECATIONS, deprecateUntil } from '@ember/-internals/deprecations'; import type { AnyFn, MethodNamesOf } from '@ember/-internals/utility-types'; -import type RSVP from 'rsvp'; import type CoreObject from '@ember/object/core'; /** @module @ember/object/promise-proxy-mixin */ -function tap(proxy: PromiseProxyMixin, promise: RSVP.Promise) { +function tap(proxy: PromiseProxyMixin, promise: Promise) { setProperties(proxy, { isFulfilled: false, isRejected: false, @@ -42,8 +41,7 @@ function tap(proxy: PromiseProxyMixin, promise: RSVP.Promise) { }); } throw reason; - }, - 'Ember: PromiseProxy' + } ); } @@ -242,7 +240,7 @@ const PromiseProxyMixin = Mixin[INTERNAL_MIXIN_CREATE]({ get() { throw new Error("PromiseProxy's promise must be set"); }, - set(_key, promise: RSVP.Promise) { + set(_key, promise: Promise) { return tap(this, promise); }, }), diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts index f9084bb1483..6dd6fe82cae 100644 --- a/packages/@ember/renderer/index.ts +++ b/packages/@ember/renderer/index.ts @@ -63,6 +63,22 @@ export { renderSettled } from '@ember/-internals/glimmer/lib/base-renderer'; +/** + Registers an observer notified at the edges of rendering work: called + with `true` when rendering (or destruction) becomes outstanding, and + with `false` when it has completed. Test infrastructure uses this to + represent rendering as a test waiter, so settledness stays a single + push-based protocol rather than something that must be polled. + + Only one observer is registered at a time; registering replaces the + previous one. + + @method _onRenderSettledChange + @param {Function} observer called with `true` on pending, `false` on quiet + @private +*/ +export { _onRenderSettledChange } from '@ember/-internals/glimmer/lib/base-renderer'; + /** * Render a component into a DOM element. * diff --git a/packages/@ember/routing/hash-location.ts b/packages/@ember/routing/hash-location.ts index 2dfd8f31b10..2a1509b288e 100644 --- a/packages/@ember/routing/hash-location.ts +++ b/packages/@ember/routing/hash-location.ts @@ -1,5 +1,4 @@ import EmberObject from '@ember/object'; -import { bind } from '@ember/runloop'; import type { default as EmberLocation, UpdateCallback } from '@ember/routing/location'; import { getHash } from './lib/location-utils'; @@ -128,7 +127,8 @@ export default class HashLocation extends EmberObject implements EmberLocation { */ onUpdateURL(callback: UpdateCallback): void { this._removeEventListener(); - this._hashchangeHandler = bind(this, function (this: HashLocation, _event: Event) { + this._hashchangeHandler = ((handler: (this: HashLocation, _event: Event) => void) => + handler.bind(this))(function (this: HashLocation, _event: Event) { let path = this.getURL(); if (this.lastSetURL === path) { return; diff --git a/packages/@ember/routing/route.ts b/packages/@ember/routing/route.ts index d3078d53886..752394e95b8 100644 --- a/packages/@ember/routing/route.ts +++ b/packages/@ember/routing/route.ts @@ -31,7 +31,7 @@ import { isTesting } from '@ember/debug/lib/testing'; import { assert } from '@ember/debug'; import EngineInstance from '@ember/engine/instance'; import { dependentKeyCompat } from '@ember/object/compat'; -import { once } from '@ember/runloop'; +import { scheduleMethodOnce } from '@ember/-internals/utils/lib/microtask-scheduling'; import { setRouteManager } from '@ember/-internals/routing/route-managers/registry'; import { ClassicRouteManager } from '@ember/-internals/routing/route-managers/classic/manager'; import { hasClassicInterop } from '@ember/-internals/routing/route-managers/api'; @@ -1016,7 +1016,7 @@ class Route extends EmberObject.extend(ActionHandler) { // `_setOutlets` itself is the single `shouldRender` gate (it returns // early when the app was booted with `shouldRender: false`). - once(this._router, '_setOutlets'); + scheduleMethodOnce(this._router, '_setOutlets'); // Setup can cause changes to QPs which need to be propogated immediately in // some situations. Eventually, we should work on making these async somehow. @@ -1527,7 +1527,7 @@ class Route extends EmberObject.extend(ActionHandler) { */ teardownViews() { if (this._router) { - once(this._router, '_setOutlets'); + scheduleMethodOnce(this._router, '_setOutlets'); } } diff --git a/packages/@ember/routing/router.ts b/packages/@ember/routing/router.ts index 3f6a356eaa1..4012df09462 100644 --- a/packages/@ember/routing/router.ts +++ b/packages/@ember/routing/router.ts @@ -31,7 +31,8 @@ import EmberObject from '@ember/object'; import Evented from '@ember/object/evented'; import { A as emberA } from '@ember/array'; import { assert, info } from '@ember/debug'; -import { cancel, later, once, run } from '@ember/runloop'; +import { cancel, later } from '@ember/runloop'; +import { scheduleMethodOnce } from '@ember/-internals/utils/lib/microtask-scheduling'; import { associateDestroyableChild } from '@glimmer/destroyable'; import { DEBUG } from '@glimmer/env'; import { @@ -503,7 +504,7 @@ class EmberRouter extends EmberObject { } updateURL(path: string) { - once(() => { + queueMicrotask(() => { location.setURL(path); set(router, 'currentURL', path); }); @@ -554,7 +555,7 @@ class EmberRouter extends EmberObject { routeDidChange(transition: Transition) { router.set('currentRoute', transition.to); - once(() => { + queueMicrotask(() => { sendEvent(router, 'routeDidChange', [transition]); if (DEBUG) { @@ -601,7 +602,7 @@ class EmberRouter extends EmberObject { location.replaceURL!(url); set(router, 'currentURL', url); }; - once(doReplaceURL); + queueMicrotask(doReplaceURL); } else { this.updateURL(url); } @@ -623,7 +624,7 @@ class EmberRouter extends EmberObject { cancel(this.#pendingOutletFlush); this.#pendingOutletFlush = null; } - once(router as any, '_setOutlets'); + scheduleMethodOnce(router, '_setOutlets'); return; } @@ -1008,7 +1009,7 @@ class EmberRouter extends EmberObject { for (let id in instanceMap) { let instance: EngineInstance | undefined = instanceMap[id]; assert('has instance', instance); - run(instance, 'destroy'); + instance.destroy(); } } } @@ -1020,7 +1021,7 @@ class EmberRouter extends EmberObject { */ _activeQPChanged(queryParameterName: string, newValue: unknown) { this._queuedQPChanges[queryParameterName] = newValue; - once(this, this._fireQueryParamTransition); + scheduleMethodOnce(this, '_fireQueryParamTransition'); } // The queryParameterName is QueryParam['urlKey'] diff --git a/packages/@ember/runloop/-private/backburner.ts b/packages/@ember/runloop/-private/backburner.ts deleted file mode 100644 index cfa78cfeeda..00000000000 --- a/packages/@ember/runloop/-private/backburner.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This exists solely to provide a compatibility shim for `ember-test-helpers` -// as part of the transition off of DefinitelyTyped. Long-term, this should be -// structured differently: -// -// 1. `backburner.js` should provide actual public types for things which are -// actually part of its public contract: these are currently used in key -// areas as "intimate" API. -// 2. `ember-test-helpers` should use the types from `backburner.js` directly, -// rather than relying on Ember's re-export. - -import type Backburner from 'backburner.js'; -export type { Backburner }; -export type DebugInfo = ReturnType; - -export type { IQueueItem as QueueItem } from 'backburner.js/dist/backburner/interfaces'; -export type { DeferredActionQueues } from 'backburner.js'; diff --git a/packages/@ember/runloop/index.ts b/packages/@ember/runloop/index.ts index db079ea25b2..0bb16c32460 100644 --- a/packages/@ember/runloop/index.ts +++ b/packages/@ember/runloop/index.ts @@ -1,10 +1,16 @@ -import { assert } from '@ember/debug'; -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'; +/** + SPIKE (backburner removal): the run loop is gone. What remains here is + a dependency-free compatibility surface so the module specifier keeps + resolving: `run`/`join`/`bind` are plain calls, queues collapse to + microtasks, and timers are native timers. Rendering is driven by + `@ember/scheduler`; nothing in the framework schedules through this + module anymore. + + @module @ember/runloop + @public +*/ -export type { Timer }; +import type { AnyFn } from '@ember/-internals/utility-types'; // Partial types from https://medium.com/codex/currying-in-typescript-ca5226c85b85 @@ -33,104 +39,53 @@ type RemainingParams = PartialPa ? All : never; -let currentRunLoop: DeferredActionQueues | null = null; -export function _getCurrentRunLoop() { - return currentRunLoop; -} - -function onBegin(current: DeferredActionQueues) { - currentRunLoop = current; +export interface Timeout { + kind: 'timeout'; + id: ReturnType; } -function onEnd(_current: DeferredActionQueues, next: DeferredActionQueues) { - currentRunLoop = next; - - flushAsyncObservers(schedule); +export interface Microtask { + kind: 'microtask'; + cancelled: boolean; } -function flush(queueName: string, next: () => void) { - if (queueName === 'render' || queueName === _rsvpErrorQueue) { - flushAsyncObservers(schedule); - } +export type Timer = Timeout | Microtask; - next(); +interface TargetAndMethod { + target: object | null; + method: AnyFn; + args: unknown[]; } -export const _rsvpErrorQueue = `${Math.random()}${Date.now()}`.replace('.', ''); - -/** - Array of named queues. This array determines the order in which queues - are flushed at the end of the RunLoop. You can define your own queues by - simply adding the queue name to this array. Normally you should not need - to inspect or modify this property. - - @property queues - @type Array - @default ['actions', 'destroy'] - @private -*/ -export const _queues = [ - 'actions', - - // used in router transitions to prevent unnecessary loading state entry - // if all context promises resolve on the 'actions' queue first - 'routerTransitions', +function resolveInvocation(args: unknown[]): TargetAndMethod { + let target: object | null = null; + let method: unknown = args[0]; + let rest = args.slice(1); - 'render', - 'afterRender', - 'destroy', + if (typeof method !== 'function' && args.length > 1) { + target = args[0] as object; + method = args[1]; + rest = args.slice(2); - // used to re-throw unhandled RSVP rejection errors specifically in this - // position to avoid breaking anything rendered in the other sections - _rsvpErrorQueue, -]; + if (typeof method === 'string') { + method = (target as Record)[method]; + } + } -/** - * @internal - * @private - */ -export const _backburner = new Backburner(_queues, { - defaultQueue: 'actions', - onBegin, - onEnd, - onErrorTarget, - onErrorMethod: 'onerror', - flush, -}); + return { target, method: method as AnyFn, args: rest }; +} -/** - @module @ember/runloop -*/ -// .......................................................... -// run - this is ideally the only public API the dev sees -// +function invoke({ target, method, args }: TargetAndMethod): unknown { + return method.apply(target, args); +} /** - Runs the passed target and method inside of a RunLoop, ensuring any - deferred actions including bindings and views updates are flushed at the - end. + Runs the passed function immediately. With no run loop, this is a + plain call. - Normally you should not need to invoke this method yourself. However if - you are implementing raw event handlers when interfacing with other - libraries or plugins, you should probably wrap all of your code inside this - call. - - ```javascript - import { run } from '@ember/runloop'; - - run(function() { - // code to be executed within a RunLoop - }); - ``` @method run @for @ember/runloop @static - @param {Object} [target] target of method to call - @param {Function|String} method Method to invoke. - May be a function or a string. If you pass a string - then it will be looked up on the passed target. - @param {Object} [args*] Any additional arguments you wish to pass to the method. - @return {Object} return value from invoking the passed function. @public */ export function run any>(method: F): ReturnType; @@ -145,53 +100,17 @@ export function run( method: U, ...args: T[U] extends AnyFn ? Parameters : [] ): T[U] extends AnyFn ? ReturnType : unknown; -export function run(...args: any[]): unknown { - // @ts-expect-error TS doesn't like our spread args - return _backburner.run(...args); +export function run(...args: unknown[]): unknown { + return invoke(resolveInvocation(args)); } /** - If no run-loop is present, it creates a new one. If a run loop is - present it will queue itself to run on the existing run-loops action - queue. - - Please note: This is not for normal usage, and should be used sparingly. - - If invoked when not within a run loop: - - ```javascript - import { join } from '@ember/runloop'; - - join(function() { - // creates a new run-loop - }); - ``` - - Alternatively, if called within an existing run loop: - - ```javascript - import { run, join } from '@ember/runloop'; - - run(function() { - // creates a new run-loop - - join(function() { - // joins with the existing run-loop, and queues for invocation on - // the existing run-loops action queue. - }); - }); - ``` + Runs the passed function immediately, joining any conceptual ongoing + work. With no run loop, this is a plain call. @method join - @static @for @ember/runloop - @param {Object} [target] target of method to call - @param {Function|String} method Method to invoke. - May be a function or a string. If you pass a string - then it will be looked up on the passed target. - @param {Object} [args*] Any additional arguments you wish to pass to the method. - @return {Object} Return value from invoking the passed function. Please note, - when called within an existing loop, no return value is possible. + @static @public */ export function join(method: F, ...args: Parameters): ReturnType | void; @@ -205,70 +124,18 @@ export function join( method: U, ...args: T[U] extends AnyFn ? Parameters : [] ): T[U] extends AnyFn ? ReturnType | void : void; -export function join(methodOrTarget: any, methodOrArg?: any, ...additionalArgs: any[]): any { - return _backburner.join(methodOrTarget, methodOrArg, ...additionalArgs); +export function join(...args: unknown[]): unknown { + return invoke(resolveInvocation(args)); } /** - Allows you to specify which context to call the specified function in while - adding the execution of that function to the Ember run loop. This ability - makes this method a great way to asynchronously integrate third-party libraries - into your Ember application. - - `bind` takes two main arguments, the desired context and the function to - invoke in that context. Any additional arguments will be supplied as arguments - to the function that is passed in. - - Let's use the creation of a TinyMCE component as an example. Currently, - TinyMCE provides a setup configuration option we can use to do some processing - after the TinyMCE instance is initialized but before it is actually rendered. - We can use that setup option to do some additional setup for our component. - The component itself could look something like the following: - - ```app/components/rich-text-editor.js - import Component from '@ember/component'; - import { on } from '@ember/object/evented'; - import { bind } from '@ember/runloop'; - - export default Component.extend({ - initializeTinyMCE: on('didInsertElement', function() { - tinymce.init({ - selector: '#' + this.$().prop('id'), - setup: bind(this, this.setupEditor) - }); - }), - - didInsertElement() { - tinymce.init({ - selector: '#' + this.$().prop('id'), - setup: bind(this, this.setupEditor) - }); - } - - setupEditor(editor) { - this.set('editor', editor); - - editor.on('change', function() { - console.log('content changed!'); - }); - } - }); - ``` - - In this example, we use `bind` to bind the setupEditor method to the - context of the RichTextEditor component and to have the invocation of that - method be safely handled and executed by the Ember run loop. + Returns a function bound to the given target and arguments. With no + run loop there is nothing to wrap; this is `Function#bind` with + string-method resolution. @method bind - @static @for @ember/runloop - @param {Object} [target] target of method to call - @param {Function|String} method Method to invoke. - May be a function or a string. If you pass a string - then it will be looked up on the passed target. - @param {Object} [args*] Any additional arguments you wish to pass to the method. - @return {Function} returns a new function that will always have a particular context - @since 1.4.0 + @static @public */ export function bind< @@ -303,119 +170,50 @@ export function bind( methodName: M, ...args: any[] ): (...args: any[]) => unknown; -export function bind(...curried: any[]): any { - assert( - 'could not find a suitable method to bind', - (function (methodOrTarget, methodOrArg) { - // Applies the same logic as backburner parseArgs for detecting if a method - // is actually being passed. - let length = arguments.length; - - if (length === 0) { - return false; - } else if (length === 1) { - return typeof methodOrTarget === 'function'; - } else { - return ( - typeof methodOrArg === 'function' || // second argument is a function - (methodOrTarget !== null && - typeof methodOrArg === 'string' && - methodOrArg in methodOrTarget) || // second argument is the name of a method in first argument - typeof methodOrTarget === 'function' //first argument is a function - ); - } - // @ts-expect-error TS doesn't like our spread args - })(...curried) - ); - // @ts-expect-error TS doesn't like our spread args - return (...args: any[]) => join(...curried.concat(args)); +export function bind(...curried: unknown[]): AnyFn { + return (...invocation: unknown[]) => invoke(resolveInvocation(curried.concat(invocation))); } /** - Begins a new RunLoop. Any deferred actions invoked after the begin will - be buffered until you invoke a matching call to `end()`. This is - a lower-level way to use a RunLoop instead of using `run()`. - - ```javascript - import { begin, end } from '@ember/runloop'; - - begin(); - // code to be executed within a RunLoop - end(); - ``` + Begins a run loop. With no run loop, this is a no-op. @method begin - @static @for @ember/runloop - @return {void} + @static @public */ -export function begin() { - _backburner.begin(); -} +export function begin(): void {} /** - Ends a RunLoop. This must be called sometime after you call - `begin()` to flush any deferred actions. This is a lower-level way - to use a RunLoop instead of using `run()`. - - ```javascript - import { begin, end } from '@ember/runloop'; - - begin(); - // code to be executed within a RunLoop - end(); - ``` + Ends a run loop. With no run loop, this is a no-op. @method end - @static @for @ember/runloop - @return {void} + @static @public */ -export function end() { - _backburner.end(); -} - -/** - Adds the passed target/method and any optional arguments to the named - queue to be executed at the end of the RunLoop. If you have not already - started a RunLoop when calling this method one will be started for you - automatically. - - At the end of a RunLoop, any methods scheduled in this way will be invoked. - Methods will be invoked in an order matching the named queues defined in - the `queues` property. +export function end(): void {} - ```javascript - import { schedule } from '@ember/runloop'; +function scheduleInvocation(invocation: TargetAndMethod): Microtask { + const token: Microtask = { kind: 'microtask', cancelled: false }; - schedule('afterRender', this, function() { - // this will be executed in the 'afterRender' queue - console.log('scheduled on afterRender queue'); + queueMicrotask(() => { + if (!token.cancelled) { + invoke(invocation); + } }); - schedule('actions', this, function() { - // this will be executed in the 'actions' queue - console.log('scheduled on actions queue'); - }); + return token; +} - // Note the functions will be run in order based on the run queues order. - // Output would be: - // scheduled on actions queue - // scheduled on afterRender queue - ``` +/** + Schedules work onto a queue. Queues collapse to the microtask queue: + work runs after the current synchronous execution, in scheduling + order. @method schedule - @static @for @ember/runloop - @param {String} queue The name of the queue to schedule against. Default queues is 'actions' - @param {Object} [target] target object to use as the context when invoking a method. - @param {String|Function} method The method to invoke. If you pass a string it - will be resolved on the target object at the time the scheduled item is - invoked allowing you to change the target function. - @param {Object} [arguments*] Optional arguments to be passed to the queued method. - @return {*} Timer information for use in canceling, see `cancel`. + @static @public */ export function schedule( @@ -435,262 +233,102 @@ export function schedule( method: U, ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; -export function schedule(...args: any[]): Timer { - // @ts-expect-error TS doesn't like the rest args here - return _backburner.schedule(...args); -} - -// Used by global test teardown -export function _hasScheduledTimers() { - return _backburner.hasTimers(); +export function schedule(_queue: string, ...args: unknown[]): Timer { + return scheduleInvocation(resolveInvocation(args)); } -// Used by global test teardown -export function _cancelTimers() { - _backburner.cancelTimers(); -} +const ONCE_KEYS = new WeakMap>(); +const ONCE_ANONYMOUS: object = {}; /** - Invokes the passed target/method and optional arguments after a specified - period of time. The last parameter of this method must always be a number - of milliseconds. - - You should use this method whenever you need to run some action after a - period of time instead of using `setTimeout()`. This method will ensure that - items that expire during the same script execution cycle all execute - together, which is often more efficient than using a real setTimeout. - - ```javascript - import { later } from '@ember/runloop'; + Schedules work onto a queue, coalescing repeat requests for the same + target and method until the scheduled microtask runs. - later(myContext, function() { - // code here will execute within a RunLoop in about 500ms with this == myContext - }, 500); - ``` - - @method later - @static + @method scheduleOnce @for @ember/runloop - @param {Object} [target] target of method to invoke - @param {Function|String} method The method to invoke. - If you pass a string it will be resolved on the - target at the time the method is invoked. - @param {Object} [args*] Optional arguments to pass to the timeout. - @param {Number} wait Number of milliseconds to wait. - @return {*} Timer information for use in canceling, see `cancel`. + @static @public */ -export function later any>( - target: T, - method: F, - ...args: [...args: Parameters, wait: string | number] -): Timer; -export function later( +export function scheduleOnce( + queueName: string, method: F, - ...args: [...args: Parameters, wait: string | number] -): Timer; -export function later( - target: T, - method: U, - ...args: [...args: T[U] extends AnyFn ? Parameters : [], wait: string | number] + ...args: Parameters ): Timer; -export function later(...args: any): Timer { - return _backburner.later(...args); -} - -/** - Schedule a function to run one time during the current RunLoop. This is equivalent - to calling `scheduleOnce` with the "actions" queue. - - @method once - @static - @for @ember/runloop - @param {Object} [target] The target of the method to invoke. - @param {Function|String} method The method to invoke. - If you pass a string it will be resolved on the - target at the time the method is invoked. - @param {Object} [args*] Optional arguments to pass to the timeout. - @return {Object} Timer information for use in canceling, see `cancel`. - @public -*/ -export function once(method: F, ...args: Parameters): Timer; -export function once any>( +export function scheduleOnce any>( + queueName: string, target: T, method: F, ...args: Parameters ): Timer; -export function once( +export function scheduleOnce( + queueName: string, target: T, method: U, ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; -export function once(...args: any[]): Timer { - // @ts-expect-error TS doesn't like the rest args here - return _backburner.scheduleOnce('actions', ...args); +export function scheduleOnce(_queue: string, ...args: unknown[]): Timer { + return scheduleOnceInvocation(resolveInvocation(args)); } -/** - Schedules a function to run one time in a given queue of the current RunLoop. - Calling this method with the same queue/target/method combination will have - no effect (past the initial call). - - Note that although you can pass optional arguments these will not be - considered when looking for duplicates. New arguments will replace previous - calls. +function scheduleOnceInvocation(invocation: TargetAndMethod): Timer { + const dedupeTarget = invocation.target ?? ONCE_ANONYMOUS; - ```javascript - import { run, scheduleOnce } from '@ember/runloop'; + let keys = ONCE_KEYS.get(dedupeTarget); - function sayHi() { - console.log('hi'); + if (keys === undefined) { + keys = new Set(); + ONCE_KEYS.set(dedupeTarget, keys); } - run(function() { - scheduleOnce('afterRender', myContext, sayHi); - scheduleOnce('afterRender', myContext, sayHi); - // sayHi will only be executed once, in the afterRender queue of the RunLoop - }); - ``` - - Also note that for `scheduleOnce` to prevent additional calls, you need to - pass the same function instance. The following case works as expected: + const token: Microtask = { kind: 'microtask', cancelled: false }; - ```javascript - function log() { - console.log('Logging only once'); + if (keys.has(invocation.method)) { + return token; } - function scheduleIt() { - scheduleOnce('actions', myContext, log); - } - - scheduleIt(); - scheduleIt(); - ``` + keys.add(invocation.method); - But this other case will schedule the function multiple times: + queueMicrotask(() => { + keys.delete(invocation.method); - ```javascript - import { scheduleOnce } from '@ember/runloop'; - - function scheduleIt() { - scheduleOnce('actions', myContext, function() { - console.log('Closure'); - }); - } - - scheduleIt(); - scheduleIt(); + if (!token.cancelled) { + invoke(invocation); + } + }); - // "Closure" will print twice, even though we're using `scheduleOnce`, - // because the function we pass to it won't match the - // previously scheduled operation. - ``` + return token; +} - Available queues, and their order, can be found at `queues` +/** + Schedules work to run once, coalescing repeat requests for the same + target and method. - @method scheduleOnce - @static + @method once @for @ember/runloop - @param {String} [queue] The name of the queue to schedule against. Default queues is 'actions'. - @param {Object} [target] The target of the method to invoke. - @param {Function|String} method The method to invoke. - If you pass a string it will be resolved on the - target at the time the method is invoked. - @param {Object} [args*] Optional arguments to pass to the timeout. - @return {Object} Timer information for use in canceling, see `cancel`. + @static @public */ -export function scheduleOnce( - queueName: string, - method: F, - ...args: Parameters -): Timer; -export function scheduleOnce any>( - queueName: string, +export function once(method: F, ...args: Parameters): Timer; +export function once any>( target: T, method: F, ...args: Parameters ): Timer; -export function scheduleOnce( - queueName: string, +export function once( target: T, method: U, ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; -export function scheduleOnce(...args: any[]): Timer { - // @ts-expect-error TS doesn't like the rest args here - return _backburner.scheduleOnce(...args); +export function once(...args: unknown[]): Timer { + return scheduleOnceInvocation(resolveInvocation(args)); } /** - Schedules an item to run from within a separate run loop, after - control has been returned to the system. This is equivalent to calling - `later` with a wait time of 1ms. - - ```javascript - import { next } from '@ember/runloop'; - - next(myContext, function() { - // code to be executed in the next run loop, - // which will be scheduled after the current one - }); - ``` - - Multiple operations scheduled with `next` will coalesce - into the same later run loop, along with any other operations - scheduled by `later` that expire right around the same - time that `next` operations will fire. - - Note that there are often alternatives to using `next`. - For instance, if you'd like to schedule an operation to happen - after all DOM element operations have completed within the current - run loop, you can make use of the `afterRender` run loop queue (added - by the `ember-views` package, along with the preceding `render` queue - where all the DOM element operations happen). - - Example: - - ```app/components/my-component.js - import Component from '@ember/component'; - import { scheduleOnce } from '@ember/runloop'; - - export default class MyComponent extends Component { - didInsertElement() { - super.didInsertElement(); - scheduleOnce('afterRender', this, 'processChildElements'); - }, - - processChildElements() { - // ... do something with component's child component - // elements after they've finished rendering, which - // can't be done within this component's - // `didInsertElement` hook because that gets run - // before the child elements have been added to the DOM. - } - } - ``` - - One benefit of the above approach compared to using `next` is - that you will be able to perform DOM/CSS operations before unprocessed - elements are rendered to the screen, which may prevent flickering or - other artifacts caused by delaying processing until after rendering. - - The other major benefit to the above approach is that `next` - introduces an element of non-determinism, which can make things much - harder to test, due to its reliance on `setTimeout`; it's much harder - to guarantee the order of scheduled operations when they are scheduled - outside of the current run loop, i.e. with `next`. + Runs the passed function in the next task. @method next - @static @for @ember/runloop - @param {Object} [target] target of method to invoke - @param {Function|String} method The method to invoke. - If you pass a string it will be resolved on the - target at the time the method is invoked. - @param {Object} [args*] Optional arguments to pass to the timeout. - @return {Object} Timer information for use in canceling, see `cancel`. + @static @public */ export function next(method: F, ...args: Parameters): Timer; @@ -704,153 +342,55 @@ export function next( method: U, ...args: T[U] extends AnyFn ? Parameters : [] ): Timer; -export function next(...args: any[]) { - return _backburner.later(...args, 1); +export function next(...args: unknown[]): Timer { + const invocation = resolveInvocation(args); + + return { kind: 'timeout', id: setTimeout(() => invoke(invocation), 0) }; } /** - Cancels a scheduled item. Must be a value returned by `later()`, - `once()`, `scheduleOnce()`, `next()`, `debounce()`, or - `throttle()`. - - ```javascript - import { - next, - cancel, - later, - scheduleOnce, - once, - throttle, - debounce - } from '@ember/runloop'; - - let runNext = next(myContext, function() { - // will not be executed - }); - - cancel(runNext); - - let runLater = later(myContext, function() { - // will not be executed - }, 500); - - cancel(runLater); - - let runScheduleOnce = scheduleOnce('afterRender', myContext, function() { - // will not be executed - }); - - cancel(runScheduleOnce); - - let runOnce = once(myContext, function() { - // will not be executed - }); - - cancel(runOnce); - - let throttle = throttle(myContext, function() { - // will not be executed - }, 1, false); - - cancel(throttle); - - let debounce = debounce(myContext, function() { - // will not be executed - }, 1); - - cancel(debounce); - - let debounceImmediate = debounce(myContext, function() { - // will be executed since we passed in true (immediate) - }, 100, true); - - // the 100ms delay until this method can be called again will be canceled - cancel(debounceImmediate); - ``` + Runs the passed function after the given number of milliseconds. - @method cancel - @static + @method later @for @ember/runloop - @param {Object} [timer] Timer object to cancel - @return {Boolean} true if canceled or false/undefined if it wasn't found + @static @public */ -export function cancel(timer?: Timer): boolean { - return _backburner.cancel(timer); -} - -/** - Delay calling the target method until the debounce period has elapsed - with no additional debounce calls. If `debounce` is called again before - the specified time has elapsed, the timer is reset and the entire period - must pass again before the target method is called. - - This method should be used when an event may be called multiple times - but the action should only be called once when the event is done firing. - A common example is for scroll events where you only want updates to - happen once scrolling has ceased. - - ```javascript - import { debounce } from '@ember/runloop'; - - function whoRan() { - console.log(this.name + ' ran.'); - } - - let myContext = { name: 'debounce' }; - - debounce(myContext, whoRan, 150); - - // less than 150ms passes - debounce(myContext, whoRan, 150); - - // 150ms passes - // whoRan is invoked with context myContext - // console logs 'debounce ran.' one time. - ``` - - Immediate allows you to run the function immediately, but debounce - other calls for this function until the wait time has elapsed. If - `debounce` is called again before the specified time has elapsed, - the timer is reset and the entire period must pass again before - the method can be called again. - - ```javascript - import { debounce } from '@ember/runloop'; +export function later any>( + target: T, + method: F, + ...args: [...args: Parameters, wait: string | number] +): Timer; +export function later( + method: F, + ...args: [...args: Parameters, wait: string | number] +): Timer; +export function later( + target: T, + method: U, + ...args: [...args: T[U] extends AnyFn ? Parameters : [], wait: string | number] +): Timer; +export function later(...args: unknown[]): Timer { + let wait = 0; - function whoRan() { - console.log(this.name + ' ran.'); + if (typeof args[args.length - 1] === 'number') { + wait = args.pop() as number; } - let myContext = { name: 'debounce' }; - - debounce(myContext, whoRan, 150, true); + const invocation = resolveInvocation(args); - // console logs 'debounce ran.' one time immediately. - // 100ms passes - debounce(myContext, whoRan, 150, true); + return { kind: 'timeout', id: setTimeout(() => invoke(invocation), wait) }; +} - // 150ms passes and nothing else is logged to the console and - // the debouncee is no longer being watched - debounce(myContext, whoRan, 150, true); +const DEBOUNCED = new WeakMap>>(); +const DEBOUNCE_ANONYMOUS: object = {}; - // console logs 'debounce ran.' one time immediately. - // 150ms passes and nothing else is logged to the console and - // the debouncee is no longer being watched - ``` +/** + Debounces the passed function by the given number of milliseconds. @method debounce - @static @for @ember/runloop - @param {Object} [target] target of method to invoke - @param {Function|String} method The method to invoke. - May be a function or a string. If you pass a string - then it will be looked up on the passed target. - @param {Object} [args*] Optional arguments to pass to the timeout. - @param {Number} wait Number of milliseconds to wait. - @param {Boolean} immediate Trigger the function on the leading instead - of the trailing edge of the wait interval. Defaults to false. - @return {Array} Timer information for use in canceling, see `cancel`. + @static @public */ export function debounce( @@ -871,52 +411,60 @@ export function debounce( immediate?: boolean, ] ): Timer; -export function debounce(...args: any[]) { - // @ts-expect-error TS doesn't like the rest args here - return _backburner.debounce(...args); -} +export function debounce(...args: unknown[]): Timer { + let immediate = false; -/** - Ensure that the target method is never called more frequently than - the specified spacing period. The target method is called immediately. + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; + } + + let wait = 0; + + if (typeof args[args.length - 1] === 'number') { + wait = args.pop() as number; + } - ```javascript - import { throttle } from '@ember/runloop'; + const invocation = resolveInvocation(args); + const dedupeTarget = invocation.target ?? DEBOUNCE_ANONYMOUS; - function whoRan() { - console.log(this.name + ' ran.'); + let timers = DEBOUNCED.get(dedupeTarget); + + if (timers === undefined) { + timers = new Map(); + DEBOUNCED.set(dedupeTarget, timers); } - let myContext = { name: 'throttle' }; + const existing = timers.get(invocation.method); + const isPending = existing !== undefined; - throttle(myContext, whoRan, 150); - // whoRan is invoked with context myContext - // console logs 'throttle ran.' + if (existing !== undefined) { + clearTimeout(existing); + } - // 50ms passes - throttle(myContext, whoRan, 150); + if (immediate && !isPending) { + invoke(invocation); + } - // 50ms passes - throttle(myContext, whoRan, 150); + const id = setTimeout(() => { + timers.delete(invocation.method); - // 150ms passes - throttle(myContext, whoRan, 150); - // whoRan is invoked with context myContext - // console logs 'throttle ran.' - ``` + if (!immediate) { + invoke(invocation); + } + }, wait); + + timers.set(invocation.method, id); + + return { kind: 'timeout', id }; +} + +/** + Throttles the passed function to at most once per the given number of + milliseconds. @method throttle - @static @for @ember/runloop - @param {Object} [target] target of method to invoke - @param {Function|String} method The method to invoke. - May be a function or a string. If you pass a string - then it will be looked up on the passed target. - @param {Object} [args*] Optional arguments to pass to the timeout. - @param {Number} spacing Number of milliseconds to space out requests. - @param {Boolean} immediate Trigger the function on the leading instead - of the trailing edge of the wait interval. Defaults to true. - @return {Array} Timer information for use in canceling, see `cancel`. + @static @public */ export function throttle( @@ -937,7 +485,87 @@ export function throttle( immediate?: boolean, ] ): Timer; -export function throttle(...args: any[]): Timer { - // @ts-expect-error TS doesn't like the rest args here - return _backburner.throttle(...args); +export function throttle(...args: unknown[]): Timer { + let immediate = true; + + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; + } + + let wait = 0; + + if (typeof args[args.length - 1] === 'number') { + wait = args.pop() as number; + } + + const invocation = resolveInvocation(args); + const dedupeTarget = invocation.target ?? DEBOUNCE_ANONYMOUS; + + let timers = DEBOUNCED.get(dedupeTarget); + + if (timers === undefined) { + timers = new Map(); + DEBOUNCED.set(dedupeTarget, timers); + } + + if (timers.has(invocation.method)) { + return { kind: 'microtask', cancelled: true }; + } + + if (immediate) { + invoke(invocation); + } + + const id = setTimeout(() => { + timers.delete(invocation.method); + + if (!immediate) { + invoke(invocation); + } + }, wait); + + timers.set(invocation.method, id); + + return { kind: 'timeout', id }; } + +/** + Cancels a timer returned from `later`, `next`, `once`, `schedule`, + `scheduleOnce`, `debounce`, or `throttle`. + + @method cancel + @for @ember/runloop + @static + @public +*/ +export function cancel(timer?: Timer): boolean { + if (timer === undefined) { + return false; + } + + if (timer.kind === 'timeout') { + clearTimeout(timer.id); + return true; + } + + timer.cancelled = true; + return true; +} + +// With no run loop there is never a current one, scheduled timers are +// native and unobservable, and there is nothing to flush or cancel in +// bulk. These remain only so test infrastructure keeps resolving. + +export function _getCurrentRunLoop(): null { + return null; +} + +export function _hasScheduledTimers(): boolean { + return false; +} + +export function _cancelTimers(): void {} + +// There is deliberately no `_backburner` export: backburner is gone, +// not stubbed. Test infrastructure that imported it to ask "is work +// pending?" should use `isRenderPending` from '@ember/renderer'. diff --git a/packages/@ember/runloop/package.json b/packages/@ember/runloop/package.json index e0b194a2af7..01027dfae62 100644 --- a/packages/@ember/runloop/package.json +++ b/packages/@ember/runloop/package.json @@ -15,8 +15,7 @@ "@ember/utils": "workspace:*", "@glimmer/env": "workspace:*", "@glimmer/validator": "workspace:*", - "backburner.js": "^2.7.0", "expect-type": "^0.15.0", "internal-test-helpers": "workspace:*" } -} +} \ No newline at end of file diff --git a/packages/@ember/runloop/tests/debounce_test.js b/packages/@ember/runloop/tests/debounce_test.js deleted file mode 100644 index 8de36aa9396..00000000000 --- a/packages/@ember/runloop/tests/debounce_test.js +++ /dev/null @@ -1,87 +0,0 @@ -import { debounce } from '..'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'debounce', - class extends AbstractTestCase { - ['@test debounce - with target, with method, without args'](assert) { - let done = assert.async(); - - let calledWith = []; - let target = { - someFunc(...args) { - calledWith.push(args); - }, - }; - - debounce(target, target.someFunc, 10); - debounce(target, target.someFunc, 10); - debounce(target, target.someFunc, 10); - - setTimeout(() => { - assert.deepEqual(calledWith, [[]], 'someFunc called once with correct arguments'); - done(); - }, 20); - } - - ['@test debounce - with target, with method name, without args'](assert) { - let done = assert.async(); - - let calledWith = []; - let target = { - someFunc(...args) { - calledWith.push(args); - }, - }; - - debounce(target, 'someFunc', 10); - debounce(target, 'someFunc', 10); - debounce(target, 'someFunc', 10); - - setTimeout(() => { - assert.deepEqual(calledWith, [[]], 'someFunc called once with correct arguments'); - done(); - }, 20); - } - - ['@test debounce - without target, without args'](assert) { - let done = assert.async(); - - let calledWith = []; - function someFunc(...args) { - calledWith.push(args); - } - - debounce(someFunc, 10); - debounce(someFunc, 10); - debounce(someFunc, 10); - - setTimeout(() => { - assert.deepEqual(calledWith, [[]], 'someFunc called once with correct arguments'); - done(); - }, 20); - } - - ['@test debounce - without target, with args'](assert) { - let done = assert.async(); - - let calledWith = []; - function someFunc(...args) { - calledWith.push(args); - } - - debounce(someFunc, { isFoo: true }, 10); - debounce(someFunc, { isBar: true }, 10); - debounce(someFunc, { isBaz: true }, 10); - - setTimeout(() => { - assert.deepEqual( - calledWith, - [[{ isBaz: true }]], - 'someFunc called once with correct arguments' - ); - done(); - }, 20); - } - } -); diff --git a/packages/@ember/runloop/tests/later_test.js b/packages/@ember/runloop/tests/later_test.js deleted file mode 100644 index 357b3fe0735..00000000000 --- a/packages/@ember/runloop/tests/later_test.js +++ /dev/null @@ -1,261 +0,0 @@ -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; -import { run, later, _backburner, _hasScheduledTimers, _getCurrentRunLoop } from '..'; - -const originalSetTimeout = window.setTimeout; -const originalDateValueOf = Date.prototype.valueOf; -const originalPlatform = _backburner._platform; - -function wait(callback, maxWaitCount = 100) { - originalSetTimeout(() => { - if (maxWaitCount > 0 && (_hasScheduledTimers() || _getCurrentRunLoop())) { - wait(callback, maxWaitCount - 1); - - return; - } - - callback(); - }, 10); -} - -// Synchronous "sleep". This simulates work being done -// after later was called but before the run loop -// has flushed. In previous versions, this would have -// caused the later callback to have run from -// within the run loop flush, since by the time the -// run loop has to flush, it would have considered -// the timer already expired. -function pauseUntil(time) { - while (Date.now() < time) { - /* do nothing - sleeping */ - } -} - -moduleFor( - 'runloop `later`', - class extends AbstractTestCase { - teardown() { - _backburner._platform = originalPlatform; - window.setTimeout = originalSetTimeout; - Date.prototype.valueOf = originalDateValueOf; - } - - ['@test should invoke after specified period of time - function only'](assert) { - let done = assert.async(); - let invoked = false; - - run(() => { - later(() => (invoked = true), 100); - }); - - wait(() => { - assert.equal(invoked, true, 'should have invoked later item'); - done(); - }); - } - - ['@test should invoke after specified period of time - target/method'](assert) { - let done = assert.async(); - let obj = { invoked: false }; - - run(() => { - later( - obj, - function () { - this.invoked = true; - }, - 100 - ); - }); - - wait(() => { - assert.equal(obj.invoked, true, 'should have invoked later item'); - done(); - }); - } - - ['@test should invoke after specified period of time - target/method/args'](assert) { - let done = assert.async(); - let obj = { invoked: 0 }; - - run(() => { - later( - obj, - function (amt) { - this.invoked += amt; - }, - 10, - 100 - ); - }); - - wait(() => { - assert.equal(obj.invoked, 10, 'should have invoked later item'); - done(); - }); - } - - ['@test should always invoke within a separate runloop'](assert) { - let done = assert.async(); - let obj = { invoked: 0 }; - let firstRunLoop, secondRunLoop; - - run(() => { - firstRunLoop = _getCurrentRunLoop(); - - later( - obj, - function (amt) { - this.invoked += amt; - secondRunLoop = _getCurrentRunLoop(); - }, - 10, - 1 - ); - - pauseUntil(Date.now() + 100); - }); - - assert.ok(firstRunLoop, 'first run loop captured'); - assert.ok(!_getCurrentRunLoop(), "shouldn't be in a run loop after flush"); - assert.equal(obj.invoked, 0, "shouldn't have invoked later item yet"); - - wait(() => { - assert.equal(obj.invoked, 10, 'should have invoked later item'); - assert.ok(secondRunLoop, 'second run loop took place'); - assert.ok(secondRunLoop !== firstRunLoop, 'two different run loops took place'); - done(); - }); - } - - // Our current implementation doesn't allow us to correctly enforce this ordering. - // We should probably implement a queue to provide this guarantee. - // See https://github.com/emberjs/ember.js/issues/3526 for more information. - - // asyncTest('callback order', function() { - // let array = []; - // function fn(val) { array.push(val); } - - // run(function() { - // later(this, fn, 4, 5); - // later(this, fn, 1, 1); - // later(this, fn, 5, 10); - // later(this, fn, 2, 3); - // later(this, fn, 3, 3); - // }); - - // deepEqual(array, []); - - // wait(function() { - // QUnit.start(); - // deepEqual(array, [1,2,3,4,5], 'callbacks were called in expected order'); - // }); - // }); - - // Out current implementation doesn't allow us to properly enforce what is tested here. - // We should probably fix it, but it's not technically a bug right now. - // See https://github.com/emberjs/ember.js/issues/3522 for more information. - - // asyncTest('callbacks coalesce into same run loop if expiring at the same time', function() { - // let array = []; - // function fn(val) { array.push(getCurrentRunLoop()); } - - // run(function() { - - // // Force +new Date to return the same result while scheduling - // // later timers. Otherwise: non-determinism! - // let now = +new Date(); - // Date.prototype.valueOf = function() { return now; }; - - // later(this, fn, 10); - // later(this, fn, 200); - // later(this, fn, 200); - - // Date.prototype.valueOf = originalDateValueOf; - // }); - - // deepEqual(array, []); - - // wait(function() { - // QUnit.start(); - // equal(array.length, 3, 'all callbacks called'); - // ok(array[0] !== array[1], 'first two callbacks have different run loops'); - // ok(array[0], 'first runloop present'); - // ok(array[1], 'second runloop present'); - // equal(array[1], array[2], 'last two callbacks got the same run loop'); - // }); - // }); - - ['@test inception calls to later should run callbacks in separate run loops'](assert) { - let done = assert.async(); - let runLoop, finished; - - run(() => { - runLoop = _getCurrentRunLoop(); - assert.ok(runLoop); - - later(() => { - assert.ok( - _getCurrentRunLoop() && _getCurrentRunLoop() !== runLoop, - 'first later callback has own run loop' - ); - runLoop = _getCurrentRunLoop(); - - later(() => { - assert.ok( - _getCurrentRunLoop() && _getCurrentRunLoop() !== runLoop, - 'second later callback has own run loop' - ); - finished = true; - }, 40); - }, 40); - }); - - wait(() => { - assert.ok(finished, 'all .later callbacks run'); - done(); - }); - } - - ['@test setTimeout should never run with a negative wait'](assert) { - let done = assert.async(); - // Rationale: The old run loop code was susceptible to an occasional - // bug where invokeLaterTimers would be scheduled with a setTimeout - // with a negative wait. Browsers normalize this to 0, but we verify - // that the wait is always a non-negative number. - let newSetTimeoutUsed; - _backburner._platform = Object.assign({}, originalPlatform, { - setTimeout() { - let wait = arguments[arguments.length - 1]; - newSetTimeoutUsed = true; - assert.ok(!isNaN(wait) && wait >= 0, 'wait is a non-negative number'); - - return originalPlatform.setTimeout.apply(originalPlatform, arguments); - }, - }); - - let count = 0; - run(() => { - later(() => { - count++; - - // This will get run first. Waste some time. - // This is intended to break invokeLaterTimers code by taking a - // long enough time that other timers should technically expire. It's - // fine that they're not called in this run loop; just need to - // make sure that invokeLaterTimers doesn't end up scheduling - // a negative setTimeout. - pauseUntil(Date.now() + 60); - }, 1); - - later(() => { - assert.equal(count, 1, 'callbacks called in order'); - }, 50); - }); - - wait(() => { - assert.ok(newSetTimeoutUsed, 'stub setTimeout was used'); - done(); - }); - } - } -); diff --git a/packages/@ember/runloop/tests/next_test.js b/packages/@ember/runloop/tests/next_test.js deleted file mode 100644 index 7c608eaafdd..00000000000 --- a/packages/@ember/runloop/tests/next_test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { run, next, _getCurrentRunLoop } from '..'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'runloop `next`', - class extends AbstractTestCase { - ['@test should invoke immediately on next timeout'](assert) { - let done = assert.async(); - let invoked = false; - - run(() => next(() => (invoked = true))); - - assert.equal(invoked, false, 'should not have invoked yet'); - - setTimeout(() => { - assert.equal(invoked, true, 'should have invoked later item'); - done(); - }, 20); - } - - ['@test callback should be called from within separate loop'](assert) { - let done = assert.async(); - let firstRunLoop, secondRunLoop; - run(() => { - firstRunLoop = _getCurrentRunLoop(); - next(() => (secondRunLoop = _getCurrentRunLoop())); - }); - - setTimeout(() => { - assert.ok(secondRunLoop, 'callback was called from within run loop'); - assert.ok( - firstRunLoop && secondRunLoop !== firstRunLoop, - 'two separate run loops were invoked' - ); - done(); - }, 20); - } - - ['@test multiple calls to next share coalesce callbacks into same run loop'](assert) { - let done = assert.async(); - let secondRunLoop, thirdRunLoop; - run(() => { - next(() => (secondRunLoop = _getCurrentRunLoop())); - next(() => (thirdRunLoop = _getCurrentRunLoop())); - }); - - setTimeout(() => { - assert.ok( - secondRunLoop && secondRunLoop === thirdRunLoop, - 'callbacks coalesced into same run loop' - ); - done(); - }, 20); - } - } -); diff --git a/packages/@ember/runloop/tests/once_test.js b/packages/@ember/runloop/tests/once_test.js deleted file mode 100644 index 484e182b94a..00000000000 --- a/packages/@ember/runloop/tests/once_test.js +++ /dev/null @@ -1,62 +0,0 @@ -import { run, _getCurrentRunLoop, once } from '..'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'system/run_loop/once_test', - class extends AbstractTestCase { - ['@test calling invokeOnce more than once invokes only once'](assert) { - let count = 0; - run(() => { - function F() { - count++; - } - once(F); - once(F); - once(F); - }); - - assert.equal(count, 1, 'should have invoked once'); - } - - ['@test should differentiate based on target'](assert) { - let A = { count: 0 }; - let B = { count: 0 }; - run(() => { - function F() { - this.count++; - } - once(A, F); - once(B, F); - once(A, F); - once(B, F); - }); - - assert.equal(A.count, 1, 'should have invoked once on A'); - assert.equal(B.count, 1, 'should have invoked once on B'); - } - - ['@test should ignore other arguments - replacing previous ones'](assert) { - let A = { count: 0 }; - let B = { count: 0 }; - - run(() => { - function F(amt) { - this.count += amt; - } - once(A, F, 10); - once(B, F, 20); - once(A, F, 30); - once(B, F, 40); - }); - - assert.equal(A.count, 30, 'should have invoked once on A'); - assert.equal(B.count, 40, 'should have invoked once on B'); - } - - ['@test should be inside of a runloop when running'](assert) { - run(() => { - once(() => assert.ok(Boolean(_getCurrentRunLoop()), 'should have a runloop')); - }); - } - } -); diff --git a/packages/@ember/runloop/tests/onerror_test.js b/packages/@ember/runloop/tests/onerror_test.js deleted file mode 100644 index 5bde8671809..00000000000 --- a/packages/@ember/runloop/tests/onerror_test.js +++ /dev/null @@ -1,59 +0,0 @@ -import { run } from '..'; -import { - getDispatchOverride, - getOnerror, - setDispatchOverride, - setOnerror, -} from '@ember/-internals/error-handling'; -import { isTesting, setTesting } from '@ember/debug'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'system/run_loop/onerror_test', - class extends AbstractTestCase { - ['@test With Ember.onerror undefined, errors in run are thrown'](assert) { - let thrown = new Error('Boom!'); - let original = getOnerror(); - - let caught; - setOnerror(undefined); - try { - run(() => { - throw thrown; - }); - } catch (error) { - caught = error; - } finally { - setOnerror(original); - } - - assert.deepEqual(caught, thrown); - } - - ['@test With Ember.onerror set, errors in run are caught'](assert) { - let thrown = new Error('Boom!'); - let original = getOnerror(); - let originalDispatchOverride = getDispatchOverride(); - let originalIsTesting = isTesting(); - - let caught; - setOnerror((error) => { - caught = error; - }); - setDispatchOverride(null); - setTesting(false); - - try { - run(() => { - throw thrown; - }); - } finally { - setOnerror(original); - setDispatchOverride(originalDispatchOverride); - setTesting(originalIsTesting); - } - - assert.deepEqual(caught, thrown); - } - } -); diff --git a/packages/@ember/runloop/tests/run_bind_test.js b/packages/@ember/runloop/tests/run_bind_test.js deleted file mode 100644 index 62af01434e2..00000000000 --- a/packages/@ember/runloop/tests/run_bind_test.js +++ /dev/null @@ -1,59 +0,0 @@ -import { bind, _getCurrentRunLoop } from '..'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'system/run_loop/run_bind_test', - class extends AbstractTestCase { - ['@test bind builds a run-loop wrapped callback handler'](assert) { - assert.expect(3); - - let obj = { - value: 0, - increment(increment) { - assert.ok(_getCurrentRunLoop(), 'expected a run-loop'); - return (this.value += increment); - }, - }; - - let proxiedFunction = bind(obj, obj.increment, 1); - assert.equal(proxiedFunction(), 1); - assert.equal(obj.value, 1); - } - - ['@test bind keeps the async callback arguments'](assert) { - assert.expect(4); - - function asyncCallback(increment, increment2, increment3) { - assert.ok(_getCurrentRunLoop(), 'expected a run-loop'); - assert.equal(increment, 1); - assert.equal(increment2, 2); - assert.equal(increment3, 3); - } - - function asyncFunction(fn) { - fn(2, 3); - } - - asyncFunction(bind(asyncCallback, asyncCallback, 1)); - } - - ['@test [GH#16652] bind throws an error if callback is undefined']() { - let assertBindThrows = (msg, ...args) => { - expectAssertion( - function () { - bind(...args); - }, - /could not find a suitable method to bind/, - msg - ); - }; - assertBindThrows('without arguments'); - assertBindThrows('with one arguments that is not a function', 'myMethod'); - assertBindThrows( - 'if second parameter is not a function and not a property in first parameter', - Object.create(null), - 'myMethod' - ); - } - } -); diff --git a/packages/@ember/runloop/tests/run_test.js b/packages/@ember/runloop/tests/run_test.js deleted file mode 100644 index d7c490cf006..00000000000 --- a/packages/@ember/runloop/tests/run_test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { run } from '..'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'system/run_loop/run_test', - class extends AbstractTestCase { - ['@test run invokes passed function, returning value'](assert) { - let obj = { - foo() { - return [this.bar, 'FOO']; - }, - bar: 'BAR', - checkArgs(arg1, arg2) { - return [arg1, this.bar, arg2]; - }, - }; - - assert.equal( - run(() => 'FOO'), - 'FOO', - 'pass function only' - ); - assert.deepEqual(run(obj, obj.foo), ['BAR', 'FOO'], 'pass obj and obj.method'); - assert.deepEqual(run(obj, 'foo'), ['BAR', 'FOO'], 'pass obj and "method"'); - assert.deepEqual( - run(obj, obj.checkArgs, 'hello', 'world'), - ['hello', 'BAR', 'world'], - 'pass obj, obj.method, and extra arguments' - ); - } - } -); diff --git a/packages/@ember/runloop/tests/schedule_test.js b/packages/@ember/runloop/tests/schedule_test.js deleted file mode 100644 index a712263b3b3..00000000000 --- a/packages/@ember/runloop/tests/schedule_test.js +++ /dev/null @@ -1,82 +0,0 @@ -import { run, cancel, schedule, _getCurrentRunLoop } from '..'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'system/run_loop/schedule_test', - class extends AbstractTestCase { - ['@test scheduling item in queue should defer until finished'](assert) { - let cnt = 0; - - run(() => { - schedule('actions', () => cnt++); - schedule('actions', () => cnt++); - assert.equal(cnt, 0, 'should not run action yet'); - }); - - assert.equal(cnt, 2, 'should flush actions now'); - } - - ['@test a scheduled item can be canceled'](assert) { - let hasRan = false; - - run(() => { - let cancelId = schedule('actions', () => (hasRan = true)); - cancel(cancelId); - }); - - assert.notOk(hasRan, 'should not have ran callback run'); - } - - ['@test nested runs should queue each phase independently'](assert) { - let cnt = 0; - - run(() => { - schedule('actions', () => cnt++); - assert.equal(cnt, 0, 'should not run action yet'); - - run(() => { - schedule('actions', () => cnt++); - }); - assert.equal(cnt, 1, 'should not run action yet'); - }); - - assert.equal(cnt, 2, 'should flush actions now'); - } - - ['@test prior queues should be flushed before moving on to next queue'](assert) { - let order = []; - - run(() => { - let runLoop = _getCurrentRunLoop(); - assert.ok(runLoop, 'run loop present'); - - schedule('actions', () => { - order.push('actions'); - assert.equal(runLoop, _getCurrentRunLoop(), 'same run loop used'); - }); - - schedule('afterRender', () => { - order.push('afterRender'); - assert.equal(runLoop, _getCurrentRunLoop(), 'same run loop used'); - - schedule('afterRender', () => { - order.push('afterRender'); - assert.equal(runLoop, _getCurrentRunLoop(), 'same run loop used'); - }); - - schedule('actions', () => { - order.push('actions'); - assert.equal(runLoop, _getCurrentRunLoop(), 'same run loop used'); - }); - }); - - schedule('destroy', () => { - order.push('destroy'); - assert.equal(runLoop, _getCurrentRunLoop(), 'same run loop used'); - }); - }); - - assert.deepEqual(order, ['actions', 'afterRender', 'actions', 'afterRender', 'destroy']); - } - } -); diff --git a/packages/@ember/runloop/tests/unwind_test.js b/packages/@ember/runloop/tests/unwind_test.js deleted file mode 100644 index 8a01ce128d9..00000000000 --- a/packages/@ember/runloop/tests/unwind_test.js +++ /dev/null @@ -1,53 +0,0 @@ -import { run, schedule, _getCurrentRunLoop } from '..'; -import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; - -moduleFor( - 'system/run_loop/unwind_test', - class extends AbstractTestCase { - ['@test RunLoop unwinds despite unhandled exception'](assert) { - let initialRunLoop = _getCurrentRunLoop(); - - assert.throws( - () => { - run(() => { - schedule('actions', function () { - throw new Error('boom!'); - }); - }); - }, - Error, - 'boom!' - ); - - // The real danger at this point is that calls to autorun will stick - // tasks into the already-dead runloop, which will never get - // flushed. I can't easily demonstrate this in a unit test because - // autorun explicitly doesn't work in test mode. - ef4 - assert.equal( - _getCurrentRunLoop(), - initialRunLoop, - 'Previous run loop should be cleaned up despite exception' - ); - } - - ['@test run unwinds despite unhandled exception'](assert) { - let initialRunLoop = _getCurrentRunLoop(); - - assert.throws( - () => { - run(function () { - throw new Error('boom!'); - }); - }, - Error, - 'boom!' - ); - - assert.equal( - _getCurrentRunLoop(), - initialRunLoop, - 'Previous run loop should be cleaned up despite exception' - ); - } - } -); diff --git a/packages/@ember/runloop/type-tests/begin-end.test.ts b/packages/@ember/runloop/type-tests/begin-end.test.ts deleted file mode 100644 index fc890450b8e..00000000000 --- a/packages/@ember/runloop/type-tests/begin-end.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { begin, end } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -expectTypeOf(begin()).toEqualTypeOf(); - -// code to be executed within a RunLoop -expectTypeOf(end()).toEqualTypeOf(); diff --git a/packages/@ember/runloop/type-tests/bind.test.ts b/packages/@ember/runloop/type-tests/bind.test.ts deleted file mode 100644 index 9ef518bafa7..00000000000 --- a/packages/@ember/runloop/type-tests/bind.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { bind } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - bind((_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }) -).toEqualTypeOf<(foo: number, bar: boolean, baz?: string) => number | void>(); - -expectTypeOf( - bind((_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, 1) -).toEqualTypeOf<(bar: boolean, baz?: string) => number | void>(); - -expectTypeOf( - bind( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true - ) -).toEqualTypeOf<(baz?: string) => number | void>(); - -expectTypeOf( - bind( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'baz' - ) -).toEqualTypeOf<() => number | void>(); - -expectTypeOf( - bind( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined - ) -).toEqualTypeOf<() => number | void>(); - -bind((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - bind(foo, function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }) -).toEqualTypeOf<(foo: number, bar: boolean, baz?: string) => number | void>(); - -expectTypeOf( - bind( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1 - ) -).toEqualTypeOf<(bar: boolean, baz?: string) => number | void>(); - -expectTypeOf( - bind( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true - ) -).toEqualTypeOf<(baz?: string) => number | void>(); - -expectTypeOf( - bind( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'baz' - ) -).toEqualTypeOf<() => number | void>(); - -expectTypeOf( - bind( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - undefined - ) -).toEqualTypeOf<() => number | void>(); - -// @ts-expect-error Invalid args -bind( - foo, - function (this: Foo, _foo: number): number { - return 1; - }, - 'string' -); - -// With function string reference -expectTypeOf(bind(foo, 'test')).toEqualTypeOf< - (foo: number, bar: boolean, baz?: string) => number | void ->(); - -expectTypeOf(bind(foo, 'test', 1)).toEqualTypeOf<(bar: boolean, baz?: string) => number | void>(); - -expectTypeOf(bind(foo, 'test', 1, true)).toEqualTypeOf<(baz?: string) => number | void>(); - -expectTypeOf(bind(foo, 'test', 1, true, 'baz')).toEqualTypeOf<() => number | void>(); - -expectTypeOf(bind(foo, 'test', 1, true, undefined)).toEqualTypeOf<() => number | void>(); - -// @ts-expect-error Invalid args -bind(foo, foo.test, 'string'); -// We would like to catch this, but it ends up in the fallback path. This type -// test simply asserts as much, so that if we change that behavior we will know -// about it. -expectTypeOf(bind(foo, 'test', 'string')).toEqualTypeOf<(...args: any[]) => unknown>(); diff --git a/packages/@ember/runloop/type-tests/cancel.test.ts b/packages/@ember/runloop/type-tests/cancel.test.ts deleted file mode 100644 index a02c22132b2..00000000000 --- a/packages/@ember/runloop/type-tests/cancel.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { cancel, next } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -let runNext = next(null, () => { - // will not be executed -}); - -expectTypeOf(cancel(runNext)).toEqualTypeOf(); diff --git a/packages/@ember/runloop/type-tests/debounce.test.ts b/packages/@ember/runloop/type-tests/debounce.test.ts deleted file mode 100644 index be0e290798f..00000000000 --- a/packages/@ember/runloop/type-tests/debounce.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { debounce } from '@ember/runloop'; -import type { Timer } from 'backburner.js'; -import { expectTypeOf } from 'expect-type'; - -// From Docs - -function whoRan() { - // Do stuff -} - -let myContext = { name: 'debounce', test(_foo: number, _baz?: boolean): void {} }; - -debounce(myContext, whoRan, 150); - -// less than 150ms passes -debounce(myContext, whoRan, 150); - -debounce(myContext, whoRan, 150, true); - -// console logs 'debounce ran.' one time immediately. -// 100ms passes -debounce(myContext, whoRan, 150, true); - -// 150ms passes and nothing else is logged to the console and -// the debouncee is no longer being watched -debounce(myContext, whoRan, 150, true); - -// console logs 'debounce ran.' one time immediately. -// 150ms passes and nothing else is logged to the console and -// the debouncee is no longer being watched - -// Method only -expectTypeOf( - debounce((_foo: number, _baz?: boolean): void => {}, 1, undefined, 1) -).toEqualTypeOf(); - -// @ts-expect-error Requires wait -debounce((_foo: number, _baz?: boolean): void => {}, 1, true); - -// @ts-expect-error Requires all args -debounce((_foo: number, _baz?: boolean): void => {}, 1, 1); - -// Can set immediate -debounce((_foo: number, _baz?: boolean): void => {}, 1, true, 1, true); - -// With target -debounce( - myContext, - function (_foo: number, _baz?: boolean): void { - expectTypeOf(this).toEqualTypeOf(myContext); - }, - 1, - true, - 1, - true -); - -// With key -debounce(myContext, 'test', 1, true, 1, true); - -// @ts-expect-error invalid key -debounce(myContext, 'invalid'); - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - debounce( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined, - 1 - ) -).toEqualTypeOf(); - -expectTypeOf( - debounce( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string', - 1 - ) -).toEqualTypeOf(); - -debounce((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - debounce( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true, - undefined, - 1 - ) -).toEqualTypeOf(); - -expectTypeOf( - debounce( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string', - 1 - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -debounce( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true, - 1 -); - -// With function string reference -expectTypeOf(debounce(foo, 'test', 1, true, 'string', 1)).toEqualTypeOf(); - -expectTypeOf(debounce(foo, 'test', 1, true, undefined, 1)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -debounce(foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/join.test.ts b/packages/@ember/runloop/type-tests/join.test.ts deleted file mode 100644 index a867cde90d7..00000000000 --- a/packages/@ember/runloop/type-tests/join.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { join } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - join( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true - ) -).toEqualTypeOf(); - -expectTypeOf( - join( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -join((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - join( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true - ) -).toEqualTypeOf(); - -expectTypeOf( - join( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -join( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string' -); - -// With function string reference -expectTypeOf(join(foo, 'test', 1, true)).toEqualTypeOf(); - -expectTypeOf(join(foo, 'test', 1, true, 'string')).toEqualTypeOf(); - -// @ts-expect-error Invalid args -join(foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/later.test.ts b/packages/@ember/runloop/type-tests/later.test.ts deleted file mode 100644 index bcd218a19b2..00000000000 --- a/packages/@ember/runloop/type-tests/later.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Timer } from '@ember/runloop'; -import { later } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - later( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined, - 1 - ) -).toEqualTypeOf(); - -expectTypeOf( - later( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string', - 1 - ) -).toEqualTypeOf(); - -later((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - later( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true, - undefined, - 1 - ) -).toEqualTypeOf(); - -expectTypeOf( - later( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string', - 1 - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -later( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true, - 1 -); - -// With function string reference -expectTypeOf(later(foo, 'test', 1, true, 'string', 1)).toEqualTypeOf(); - -expectTypeOf(later(foo, 'test', 1, true, undefined, 1)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -later(foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/next.test.ts b/packages/@ember/runloop/type-tests/next.test.ts deleted file mode 100644 index 8cccad4614c..00000000000 --- a/packages/@ember/runloop/type-tests/next.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Timer } from '@ember/runloop'; -import { next } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - next( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined - ) -).toEqualTypeOf(); - -expectTypeOf( - next( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -next((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - next( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true - ) -).toEqualTypeOf(); - -expectTypeOf( - next( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -next( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true -); - -// With function string reference -expectTypeOf(next(foo, 'test', 1, true, 'string')).toEqualTypeOf(); - -expectTypeOf(next(foo, 'test', 1, true)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -next(foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/once.test.ts b/packages/@ember/runloop/type-tests/once.test.ts deleted file mode 100644 index e55124820ca..00000000000 --- a/packages/@ember/runloop/type-tests/once.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Timer } from '@ember/runloop'; -import { once } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - once( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined - ) -).toEqualTypeOf(); - -expectTypeOf( - once( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -once((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - once( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true - ) -).toEqualTypeOf(); - -expectTypeOf( - once( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -once( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true -); - -// With function string reference -expectTypeOf(once(foo, 'test', 1, true, 'string')).toEqualTypeOf(); - -expectTypeOf(once(foo, 'test', 1, true)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -once(foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/run.test.ts b/packages/@ember/runloop/type-tests/run.test.ts deleted file mode 100644 index a52ad56bc5b..00000000000 --- a/packages/@ember/runloop/type-tests/run.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { run } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - run( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined - ) -).toEqualTypeOf(); - -expectTypeOf( - run( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -run((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - run( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true - ) -).toEqualTypeOf(); - -expectTypeOf( - run( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -run( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true -); - -// With function string reference -expectTypeOf(run(foo, 'test', 1, true, 'string')).toEqualTypeOf(); - -expectTypeOf(run(foo, 'test', 1, true)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -run(foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/schedule-once.test.ts b/packages/@ember/runloop/type-tests/schedule-once.test.ts deleted file mode 100644 index 091869b1a14..00000000000 --- a/packages/@ember/runloop/type-tests/schedule-once.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { Timer } from '@ember/runloop'; -import { scheduleOnce } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - scheduleOnce( - 'my-queue', - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined - ) -).toEqualTypeOf(); - -expectTypeOf( - scheduleOnce( - 'my-queue', - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -scheduleOnce( - 'my-queue', - (_foo: number): number => { - return 1; - }, - // @ts-expect-error invalid argument - 'string' -); - -// With target and function -expectTypeOf( - scheduleOnce( - 'my-queue', - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true - ) -).toEqualTypeOf(); - -expectTypeOf( - scheduleOnce( - 'my-queue', - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -scheduleOnce( - 'my-queue', - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true -); - -// With function string reference -expectTypeOf(scheduleOnce('my-queue', foo, 'test', 1, true, 'string')).toEqualTypeOf(); - -expectTypeOf(scheduleOnce('my-queue', foo, 'test', 1, true)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -scheduleOnce('my-queue', foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/schedule.test.ts b/packages/@ember/runloop/type-tests/schedule.test.ts deleted file mode 100644 index 7960fd8f736..00000000000 --- a/packages/@ember/runloop/type-tests/schedule.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { Timer } from '@ember/runloop'; -import { schedule } from '@ember/runloop'; -import { expectTypeOf } from 'expect-type'; - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - schedule( - 'my-queue', - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined - ) -).toEqualTypeOf(); - -expectTypeOf( - schedule( - 'my-queue', - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -schedule( - 'my-queue', - (_foo: number): number => { - return 1; - }, - // @ts-expect-error invalid argument - 'string' -); - -// With target and function -expectTypeOf( - schedule( - 'my-queue', - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true - ) -).toEqualTypeOf(); - -expectTypeOf( - schedule( - 'my-queue', - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string' - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -schedule( - 'my-queue', - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true -); - -// With function string reference -expectTypeOf(schedule('my-queue', foo, 'test', 1, true, 'string')).toEqualTypeOf(); - -expectTypeOf(schedule('my-queue', foo, 'test', 1, true)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -schedule('my-queue', foo, 'test', 'string'); diff --git a/packages/@ember/runloop/type-tests/throttle.test.ts b/packages/@ember/runloop/type-tests/throttle.test.ts deleted file mode 100644 index 92e1b7ab165..00000000000 --- a/packages/@ember/runloop/type-tests/throttle.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { throttle } from '@ember/runloop'; -import type { Timer } from 'backburner.js'; -import { expectTypeOf } from 'expect-type'; - -// From Docs - -function whoRan() { - // Do stuff -} - -let myContext = { name: 'throttle', test(_foo: number, _baz?: boolean): void {} }; - -throttle(myContext, whoRan, 150); - -// less than 150ms passes -throttle(myContext, whoRan, 150); - -throttle(myContext, whoRan, 150, true); - -// console logs 'throttle ran.' one time immediately. -// 100ms passes -throttle(myContext, whoRan, 150, true); - -// 150ms passes and nothing else is logged to the console and -// the throttlee is no longer being watched -throttle(myContext, whoRan, 150, true); - -// console logs 'throttle ran.' one time immediately. -// 150ms passes and nothing else is logged to the console and -// the throttlee is no longer being watched - -// Method only -expectTypeOf( - throttle((_foo: number, _baz?: boolean): void => {}, 1, undefined, 1) -).toEqualTypeOf(); - -// Wait is optional -throttle((_foo: number, _baz?: boolean): void => {}, 1, true); - -// @ts-expect-error Requires all args -throttle((_foo: number, _baz?: boolean): void => {}, 1, 1); - -// Can set immediate -throttle((_foo: number, _baz?: boolean): void => {}, 1, true, 1, true); - -// With target -throttle( - myContext, - function (_foo: number, _baz?: boolean): void { - expectTypeOf(this).toEqualTypeOf(myContext); - }, - 1, - true, - 1, - true -); - -// With key -throttle(myContext, 'test', 1, true, 1, true); - -// @ts-expect-error invalid key -throttle(myContext, 'invalid'); - -class Foo { - test(_foo: number, _bar: boolean, _baz?: string): number { - return 1; - } -} - -let foo = new Foo(); - -// With only function -expectTypeOf( - throttle( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - undefined, - 1 - ) -).toEqualTypeOf(); - -expectTypeOf( - throttle( - (_foo: number, _bar: boolean, _baz?: string): number => { - return 1; - }, - 1, - true, - 'string', - 1 - ) -).toEqualTypeOf(); - -throttle((_foo: number): number => { - return 1; - // @ts-expect-error invalid argument -}, 'string'); - -// With target and function -expectTypeOf( - throttle( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - expectTypeOf(this).toEqualTypeOf(); - return 1; - }, - 1, - true, - undefined, - 1 - ) -).toEqualTypeOf(); - -expectTypeOf( - throttle( - foo, - function (_foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - true, - 'string', - 1 - ) -).toEqualTypeOf(); - -// @ts-expect-error invalid args -throttle( - foo, - function (this: Foo, _foo: number, _bar: boolean, _baz?: string): number { - return 1; - }, - 1, - 'string', - true, - 1 -); - -// With function string reference -expectTypeOf(throttle(foo, 'test', 1, true, 'string', 1)).toEqualTypeOf(); - -expectTypeOf(throttle(foo, 'test', 1, true, undefined, 1)).toEqualTypeOf(); - -// @ts-expect-error Invalid args -throttle(foo, 'test', 'string'); diff --git a/packages/@ember/scheduler/index.ts b/packages/@ember/scheduler/index.ts new file mode 100644 index 00000000000..4ecced77b24 --- /dev/null +++ b/packages/@ember/scheduler/index.ts @@ -0,0 +1,258 @@ +import { assert } from '@ember/debug'; +import defaultStrategy from '@ember/scheduler/strategy'; + +/** + 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. + + By default the phase functions delegate to the renderer-clock strategy + from `@ember/scheduler/strategy` -- no registration is required. + Registering exists to swap in an alternative implementation, at most + once, when defining the Application: + + ```js + import { registerStrategy } from '@ember/scheduler'; + + registerStrategy(myAlternativeStrategy); + ``` + + 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(): Strategy { + // the renderer-clock strategy is the ambient default; registration + // exists to swap in an alternative implementation + return registeredStrategy ?? defaultStrategy; +} + +/** + 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(); +} + +/** + 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(); +} + +/** + 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(); +} + +/** + 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(); +} + +/** + 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(); +} 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..4389217c74b --- /dev/null +++ b/packages/@ember/scheduler/strategy.ts @@ -0,0 +1,234 @@ +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). + + This strategy IS the renderer's clock. The renderer schedules its own + ticks (microtask-speed for render-coupled continuations, frame-paced + for streams); each tick that leaves the renderer valid drives this + strategy's phase windows, so `await render()` resolves against the + tick that actually updated the DOM -- there is exactly one clock. + + - `render` resolves immediately after a tick's revalidation, before + the next paint when the tick rode the frame + - `layout` and `composite` resolve in subsequent microtask + checkpoints of the same tick, so each phase's awaiters run before + the next window opens + - scheduling into `render` while the render window is flushing + resolves within the current window (recursive render) + - scheduling into a phase whose window has already flushed this tick + resolves in the next tick + - `next()` resolves in a task after the tick completes; `idle()` uses + `requestIdleCallback` where available + + Awaiting a phase when the renderer has no pending work requests a + tick, so the promise always resolves; environments with no renderer + at all (unit tests, workers) fall back to a self-driven tick. + + @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 there, so the self-driven fallback +// degrades to a timer. +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; + }); + } +} + +export class RenderClockStrategy implements Strategy { + /** lazily-created pending windows for the upcoming tick */ + #render: Deferred | null = null; + #layout: Deferred | null = null; + #composite: Deferred | null = null; + #complete: Deferred | null = null; + + /** the phase window currently being flushed, if any */ + #flushing: FramePhase | null = null; + + /** bumped per driven tick; lets the self-driven fallback stand down */ + #tickCount = 0; + + /** + * Injected by the renderer: ensures a tick is scheduled even when no + * reactive state is dirty, so awaited phases always resolve. + */ + #requestTick: (() => void) | null = null; + + /** @internal wired up by the renderer at module initialization */ + _setTickRequester(requestTick: () => void): void { + this.#requestTick = requestTick; + } + + #ensureTick(): void { + if (this.#requestTick !== null) { + this.#requestTick(); + } + + // With no renderer connected (unit tests, workers, pre-boot), or a + // connected renderer with no roots to tick, self-drive: fire the + // windows at the next frame opportunity unless a real tick beat us + // to it. + const tickAtArm = this.#tickCount; + onFrameTask(() => { + if (this.#tickCount === tickAtArm) { + this._onRendererTick(); + } + }); + } + + /** + * Drives the phase windows. Called by the renderer at the end of + * every tick that leaves it valid; a no-op unless something awaited + * a phase, so ticks with no scheduled work pay one null check. + * + * @internal + */ + _onRendererTick(): void { + this.#tickCount++; + + if ( + this.#render === null && + this.#layout === null && + this.#composite === null && + this.#complete === null + ) { + return; + } + + // Each window resolves in its own microtask checkpoint so one + // phase's awaiters observe their window before the next opens -- + // all within the tick's task, before the next paint when the tick + // rode the frame. + this.#openWindow('render'); + queueMicrotask(() => { + this.#openWindow('layout'); + queueMicrotask(() => { + this.#openWindow('composite'); + queueMicrotask(() => { + this.#flushing = null; + const complete = this.#complete; + this.#complete = null; + complete?.resolve(); + }); + }); + }); + } + + #openWindow(phase: FramePhase): void { + this.#flushing = phase; + + let deferred: Deferred | null; + + if (phase === 'render') { + deferred = this.#render; + this.#render = null; + } else if (phase === 'layout') { + deferred = this.#layout; + this.#layout = null; + } else { + deferred = this.#composite; + this.#composite = null; + } + + deferred?.resolve(); + } + + #phase(name: FramePhase): Promise { + const flushing = this.#flushing; + + if (flushing === 'render' && name === 'render') { + // recursive scheduling into `render` resolves within the current + // render window + return Promise.resolve(); + } + + // Scheduling into a phase the current tick's cascade has not yet + // reached joins this tick just-in-time; a phase at or behind the + // window being flushed gets a fresh deferred, which the NEXT tick's + // cascade resolves. Either way the bookkeeping is the same: take or + // create the pending deferred and make sure a tick is coming. + let deferred: Deferred; + + if (name === 'render') { + deferred = this.#render ??= new Deferred(); + } else if (name === 'layout') { + deferred = this.#layout ??= new Deferred(); + } else { + deferred = this.#composite ??= new Deferred(); + } + + // a phase still ahead of the running cascade resolves within it; + // anything else needs a tick to be coming + if (flushing === null || PHASE_ORDER[name] <= PHASE_ORDER[flushing]) { + this.#ensureTick(); + } + + return deferred.promise; + } + + render(): Promise { + return this.#phase('render'); + } + + layout(): Promise { + return this.#phase('layout'); + } + + composite(): Promise { + return this.#phase('composite'); + } + + next(): Promise { + const complete = (this.#complete ??= new Deferred()); + + if (this.#flushing === null) { + this.#ensureTick(); + } + + // the tick's windows all flush before the paint when riding the + // frame; a timer scheduled from `complete` lands after it + return complete.promise.then(() => new Promise((resolve) => setTimeout(resolve, 0))); + } + + idle(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === 'function') { + // 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); + } + }); + } +} + +const strategy: RenderClockStrategy = new RenderClockStrategy(); + +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..17a61afb3d9 --- /dev/null +++ b/packages/@ember/scheduler/tests/scheduler_test.js @@ -0,0 +1,105 @@ +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(); + } + + async ['@test phase functions fall back to the default renderer-clock strategy'](assert) { + // no registerStrategy call: the ambient default handles phases + let order = []; + + await Promise.all([ + composite().then(() => order.push('composite')), + render().then(() => order.push('render')), + layout().then(() => order.push('layout')), + ]); + + assert.deepEqual(order, ['render', 'layout', 'composite']); + } + + ['@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..16a022b08fa --- /dev/null +++ b/packages/@ember/scheduler/tests/strategy_test.js @@ -0,0 +1,155 @@ +/* globals requestAnimationFrame: false, queueMicrotask: false */ +import defaultStrategy, { RenderClockStrategy } from '../strategy'; +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; + +// Drives a strategy the way the renderer does: each requested tick +// arrives asynchronously (here on a timer), then _onRendererTick fires +// the phase windows. +function connectTestClock(strategy) { + let ticks = 0; + strategy._setTickRequester(() => { + ticks++; + setTimeout(() => strategy._onRendererTick(), 0); + }); + return () => ticks; +} + +moduleFor( + '@ember/scheduler/strategy', + class extends AbstractTestCase { + ['@test the default export is a RenderClockStrategy'](assert) { + assert.ok(defaultStrategy instanceof RenderClockStrategy); + } + + async ['@test phases resolve in order within a single tick'](assert) { + let strategy = new RenderClockStrategy(); + connectTestClock(strategy); + 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 tick']( + assert + ) { + let strategy = new RenderClockStrategy(); + connectTestClock(strategy); + 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 tick']( + assert + ) { + let strategy = new RenderClockStrategy(); + let tickCount = connectTestClock(strategy); + + await strategy.render(); + + let ticksAfterRender = tickCount(); + let order = []; + let layoutPromise = strategy.layout().then(() => order.push('layout')); + let compositePromise = strategy.composite().then(() => order.push('composite')); + + await Promise.all([layoutPromise, compositePromise]); + + assert.deepEqual(order, ['layout', 'composite']); + assert.strictEqual( + tickCount(), + ticksAfterRender, + 'phases ahead of the cascade joined the current tick without requesting another' + ); + } + + async ['@test scheduling into an already-flushed phase resolves in the next tick'](assert) { + let strategy = new RenderClockStrategy(); + connectTestClock(strategy); + let order = []; + + // wait until the layout window of the first tick + await strategy.layout(); + + await Promise.all([ + strategy.composite().then(() => order.push('composite (this tick)')), + strategy.render().then(() => order.push('render (next tick)')), + strategy.layout().then(() => order.push('layout (next tick)')), + ]); + + assert.deepEqual(order, [ + 'composite (this tick)', + 'render (next tick)', + 'layout (next tick)', + ]); + } + + async ['@test work can be scheduled again after a tick completes'](assert) { + let strategy = new RenderClockStrategy(); + connectTestClock(strategy); + + await strategy.next(); + await strategy.render(); + await strategy.next(); + + assert.ok(true, 'phases continue to resolve in subsequent ticks'); + } + + async ['@test phases resolve without a connected renderer via the self-driven fallback']( + assert + ) { + let strategy = new RenderClockStrategy(); + let order = []; + + await Promise.all([ + strategy.composite().then(() => order.push('composite')), + strategy.render().then(() => order.push('render')), + strategy.layout().then(() => order.push('layout')), + ]); + + assert.deepEqual(order, ['render', 'layout', 'composite']); + } + + async ['@test a driven tick pre-empts the self-driven fallback'](assert) { + let strategy = new RenderClockStrategy(); + let requested = 0; + strategy._setTickRequester(() => { + requested++; + // resolve faster than the fallback's frame task + queueMicrotask(() => strategy._onRendererTick()); + }); + + await strategy.render(); + assert.strictEqual(requested, 1, 'the connected clock was asked for a tick'); + + // give the fallback's frame task a chance to fire; a double-drive + // would reject in #openWindow by double-resolving into fresh state + await new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); + assert.ok(true, 'fallback stood down after the driven tick'); + } + + async ['@test idle resolves'](assert) { + let strategy = new RenderClockStrategy(); + + await strategy.idle(); + + assert.ok(true, 'idle resolved'); + } + } +); diff --git a/packages/@glimmer/component/package.json b/packages/@glimmer/component/package.json index db983a89ee3..f622efedd15 100644 --- a/packages/@glimmer/component/package.json +++ b/packages/@glimmer/component/package.json @@ -33,9 +33,9 @@ "@ember/component": "workspace:*", "@ember/destroyable": "workspace:*", "@ember/owner": "workspace:*", - "@ember/runloop": "workspace:*", "@glimmer/interfaces": "workspace:*", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "@glimmer/global-context": "workspace:*" }, "engines": { "node": ">= 18" @@ -52,4 +52,4 @@ "version": 2, "main": "addon-main.cjs" } -} +} \ No newline at end of file diff --git a/packages/@glimmer/component/src/-private/ember-component-manager.ts b/packages/@glimmer/component/src/-private/ember-component-manager.ts index 9a39e6afdf9..b2c6d068cfb 100644 --- a/packages/@glimmer/component/src/-private/ember-component-manager.ts +++ b/packages/@glimmer/component/src/-private/ember-component-manager.ts @@ -1,6 +1,6 @@ import { destroy } from '@ember/destroyable'; import { capabilities } from '@ember/component'; -import { schedule } from '@ember/runloop'; +import { scheduleDestroy, scheduleDestroyed } from '@glimmer/global-context'; import BaseComponentManager from './base-component-manager'; import { type default as GlimmerComponent, IS_DESTROYING_KEY, IS_DESTROYED_KEY } from './component'; @@ -12,6 +12,10 @@ const CAPABILITIES = capabilities('3.13', { updateHook: false, }); +function invokeWillDestroy(component: GlimmerComponent): void { + component.willDestroy(); +} + function scheduledDestroyComponent(component: GlimmerComponent): void { if (component.isDestroyed) { return; @@ -37,8 +41,10 @@ class EmberGlimmerComponentManager extends BaseComponentManager { + scheduledDestroyComponent(component); + }); } } diff --git a/packages/ember-testing/tests/adapters_test.js b/packages/ember-testing/tests/adapters_test.js index 2f31657fdb8..4b3961d0bfb 100644 --- a/packages/ember-testing/tests/adapters_test.js +++ b/packages/ember-testing/tests/adapters_test.js @@ -4,7 +4,7 @@ import Adapter from '../lib/adapters/adapter'; import { getAdapter, setAdapter } from '../lib/test/adapter'; import EmberApplication from '@ember/application'; import { moduleFor, ModuleBasedTestResolver, AbstractTestCase } from 'internal-test-helpers'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import { getDebugFunction, setDebugFunction } from '@ember/debug'; const HAS_UNHANDLED_REJECTION_HANDLER = 'onunhandledrejection' in window; diff --git a/packages/ember/tests/routing/query_params_test.js b/packages/ember/tests/routing/query_params_test.js index 76a0214c0ed..b02162dca8d 100644 --- a/packages/ember/tests/routing/query_params_test.js +++ b/packages/ember/tests/routing/query_params_test.js @@ -1,7 +1,7 @@ import Controller from '@ember/controller'; import { dasherize } from '@ember/-internals/string'; import EmberObject, { action, get, computed } from '@ember/object'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import { A as emberA } from '@ember/array'; import { run } from '@ember/runloop'; import { peekMeta } from '@ember/-internals/meta'; diff --git a/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js b/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js index 6cc1dcaf51a..fc3b1cc242e 100644 --- a/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js +++ b/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js @@ -1,4 +1,4 @@ -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import { QueryParamTestCase, moduleFor } from 'internal-test-helpers'; diff --git a/packages/ember/tests/routing/router_service_test/currenturl_lifecycle_test.js b/packages/ember/tests/routing/router_service_test/currenturl_lifecycle_test.js index 05f80311eb4..b2e56bf0130 100644 --- a/packages/ember/tests/routing/router_service_test/currenturl_lifecycle_test.js +++ b/packages/ember/tests/routing/router_service_test/currenturl_lifecycle_test.js @@ -5,7 +5,7 @@ import Component from '@glimmer/component'; import Route from '@ember/routing/route'; import { get } from '@ember/object'; import { RouterTestCase, moduleFor } from 'internal-test-helpers'; -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; diff --git a/packages/ember/tests/routing/substates_test.js b/packages/ember/tests/routing/substates_test.js index 5bdbe9b3441..97587018f87 100644 --- a/packages/ember/tests/routing/substates_test.js +++ b/packages/ember/tests/routing/substates_test.js @@ -1,4 +1,4 @@ -import { RSVP } from '@ember/-internals/runtime'; +import * as RSVP from 'rsvp'; import Route from '@ember/routing/route'; import Controller from '@ember/controller'; import { action } from '@ember/object'; diff --git a/packages/router_js/lib/route-info.ts b/packages/router_js/lib/route-info.ts index 3c4a116fa70..52eec261e9b 100644 --- a/packages/router_js/lib/route-info.ts +++ b/packages/router_js/lib/route-info.ts @@ -1,5 +1,4 @@ /* eslint-disable no-prototype-builtins */ -import { Promise } from 'rsvp'; import type { Dict, Option } from './core'; import type { SerializerFunc } from './router'; import type Router from './router'; diff --git a/packages/router_js/lib/router.ts b/packages/router_js/lib/router.ts index 1e1e27cdf22..2633de1f0b1 100644 --- a/packages/router_js/lib/router.ts +++ b/packages/router_js/lib/router.ts @@ -1,7 +1,6 @@ /* eslint-disable no-prototype-builtins */ import type { MatchCallback, Params, QueryParams } from 'route-recognizer'; import RouteRecognizer from 'route-recognizer'; -import { Promise } from 'rsvp'; import type { Dict, Maybe, Option } from './core'; import type { RouteInfo, RouteInfoWithAttributes } from './route-info'; import type InternalRouteInfo from './route-info'; @@ -22,7 +21,7 @@ import URLTransitionIntent from './transition-intent/url-transition-intent'; import type { TransitionError } from './transition-state'; import TransitionState from './transition-state'; import type { ChangeList, ModelsAndQueryParams } from './utils'; -import { extractQueryParams, forEach, getChangelist, log, merge, promiseLabel } from './utils'; +import { extractQueryParams, forEach, getChangelist, log, merge } from './utils'; export interface SerializerFunc { (model: T, params: string[]): Dict; @@ -427,8 +426,7 @@ export default abstract class Router { } return result; }, - null, - promiseLabel('Transition complete') + null ); return newTransition; @@ -565,13 +563,9 @@ export default abstract class Router { // Transition promises by default resolve with resolved state. // For our purposes, swap out the promise to resolve // after the transition has been finalized. - newTransition.promise = newTransition.promise!.then( - (result: TransitionState) => { - return this.onTransitionSettled(newTransition, result); - }, - null, - promiseLabel('Settle transition promise when transition is finalized') - ); + newTransition.promise = newTransition.promise!.then((result: TransitionState) => { + return this.onTransitionSettled(newTransition, result); + }, null); if (!wasTransitioning) { this.notifyExistingHandlers(newState, newTransition); diff --git a/packages/router_js/lib/transition-state.ts b/packages/router_js/lib/transition-state.ts index e0be6739be2..96c62a6f146 100644 --- a/packages/router_js/lib/transition-state.ts +++ b/packages/router_js/lib/transition-state.ts @@ -1,4 +1,3 @@ -import { Promise } from 'rsvp'; import type { Dict } from './core'; import type { ResolvedRouteInfo, RouteInfo } from './route-info'; import type InternalRouteInfo from './route-info'; @@ -48,7 +47,7 @@ function resolveOneRouteInfo( let callback = (resolvedRouteInfo: ResolvedRouteInfo) => proceed(currentState, transition, resolvedRouteInfo); - return routeInfo.resolve(transition).then(callback, null, currentState.promiseLabel('Proceed')); + return routeInfo.resolve(transition).then(callback, null); } function proceed( @@ -122,9 +121,9 @@ export default class TransitionState { let errorHandler = (error: Error) => handleError(this, transition, error); // The prelude RSVP.resolve() async moves us into the promise land. - return Promise.resolve(null, this.promiseLabel('Start transition')) - .then(callback, null, this.promiseLabel('Resolve route')) - .catch(errorHandler, this.promiseLabel('Handle error')) + return Promise.resolve(null) + .then(callback, null) + .catch(errorHandler) .then(() => this); } } diff --git a/packages/router_js/lib/transition.ts b/packages/router_js/lib/transition.ts index 9e2362d0658..8f2b62060f2 100644 --- a/packages/router_js/lib/transition.ts +++ b/packages/router_js/lib/transition.ts @@ -1,4 +1,3 @@ -import { Promise } from 'rsvp'; import type { Dict, Maybe, Option } from './core'; import type { RouteInfo, RouteInfoWithAttributes } from './route-info'; import type InternalRouteInfo from './route-info'; @@ -8,7 +7,7 @@ import { buildTransitionAborted } from './transition-aborted-error'; import type { OpaqueIntent } from './transition-intent'; import type { TransitionError } from './transition-state'; import type TransitionState from './transition-state'; -import { log, promiseLabel } from './utils'; +import { log } from './utils'; import { DEBUG } from '@glimmer/env'; export type OnFulfilled = @@ -187,7 +186,7 @@ export default class Transition implements Partial let error = this.router.transitionDidError(result, this); throw error; - }, promiseLabel('Handle Abort')); + }); } else { this.promise = Promise.resolve(this[STATE_SYMBOL]!); this[PARAMS_SYMBOL] = {}; @@ -238,9 +237,9 @@ export default class Transition implements Partial then( onFulfilled?: ((value: unknown) => TResult1 | PromiseLike) | undefined | null, onRejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, - label?: string + _label?: string ): Promise { - return this.promise!.then(onFulfilled, onRejected, label); + return this.promise!.then(onFulfilled, onRejected); } /** @@ -256,8 +255,8 @@ export default class Transition implements Partial @return {Promise} @public */ - catch(onRejection?: OnRejected, T>, label?: string) { - return this.promise!.catch(onRejection, label); + catch(onRejection?: OnRejected, T>, _label?: string) { + return this.promise!.catch(onRejection); } /** @@ -273,9 +272,8 @@ export default class Transition implements Partial @return {Promise} @public */ - finally(callback?: T | undefined, label?: string) { - // @ts-expect-error @types/rsvp doesn't have the correct signature for RSVP.Promise.finally - return this.promise!.finally(callback, label); + finally(callback?: T | undefined, _label?: string) { + return this.promise!.finally(callback as (() => void) | undefined); } /** diff --git a/packages/router_js/lib/utils.ts b/packages/router_js/lib/utils.ts index 38912f6c240..609ec6ca21e 100644 --- a/packages/router_js/lib/utils.ts +++ b/packages/router_js/lib/utils.ts @@ -1,5 +1,4 @@ import type { QueryParams } from 'route-recognizer'; -import type { Promise } from 'rsvp'; import type { Dict } from './core'; import type Router from './router'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73af5a19048..87e7c7b45bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,9 +33,6 @@ importers: '@simple-dom/interface': specifier: ^1.4.0 version: 1.4.0 - backburner.js: - specifier: ^2.8.0 - version: 2.8.0 broccoli-file-creator: specifier: ^2.1.1 version: 2.1.1 @@ -316,6 +313,9 @@ importers: '@ember/runloop': specifier: workspace:* version: link:../runloop + '@ember/scheduler': + specifier: workspace:* + version: link:../scheduler '@ember/service': specifier: workspace:* version: link:../service @@ -1069,9 +1069,6 @@ importers: '@glimmer/validator': specifier: workspace:* version: link:../../@glimmer/validator - backburner.js: - specifier: ^2.7.0 - version: 2.8.0 expect-type: specifier: ^0.15.0 version: 0.15.0 @@ -1079,6 +1076,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': @@ -1585,9 +1591,9 @@ importers: '@ember/owner': specifier: workspace:* version: link:../../@ember/owner - '@ember/runloop': + '@glimmer/global-context': specifier: workspace:* - version: link:../../@ember/runloop + version: link:../global-context '@glimmer/interfaces': specifier: workspace:* version: link:../interfaces diff --git a/rollup.config.mjs b/rollup.config.mjs index 4491f00f19c..7306267aba1 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -274,8 +274,6 @@ function rolledUpPackages() { // these that we publish. export function exposedDependencies() { return { - 'backburner.js': require.resolve('backburner.js/dist/es6/backburner.js'), - rsvp: require.resolve('rsvp/lib/rsvp.js'), 'dag-map': require.resolve('dag-map/dag-map.js'), router_js: require.resolve('router_js'), 'route-recognizer': require.resolve('route-recognizer/dist/route-recognizer.es.js'), @@ -306,6 +304,7 @@ export function hiddenDependencies() { findFromProject('@glimmer/syntax', 'simple-html-tokenizer'), 'module' ).path, + rsvp: resolve(findFromProject('rsvp').root, 'dist/es6/rsvp.es.js'), '@handlebars/parser': resolve(packageCache.appRoot, 'packages/@handlebars/parser/lib/index.js'), ...walkGlimmerDeps(['@glimmer/compiler']), ...(process.env.VITE_STABLE_DECORATORS diff --git a/testem-filter.cjs b/testem-filter.cjs new file mode 100644 index 00000000000..89d6e5f4f21 --- /dev/null +++ b/testem-filter.cjs @@ -0,0 +1,29 @@ +const FailureOnlyReporter = require('testem-failure-only-reporter'); + +const filter = encodeURIComponent(process.env.TEST_FILTER || 'strict mode'); + +module.exports = { + test_page: `index.html/?filter=${filter}&`, + cwd: 'dist', + timeout: 540, + parallel: 1, + reporter: FailureOnlyReporter, + disable_watching: true, + launch_in_ci: ['Chrome'], + launch_in_dev: ['Chrome'], + browser_start_timeout: 120, + browser_disconnect_timeout: 1200, + browser_args: { + Chrome: { + ci: [ + process.env.CI ? '--no-sandbox' : null, + '--headless', + '--disable-dev-shm-usage', + '--disable-software-rasterizer', + '--mute-audio', + '--remote-debugging-port=0', + '--window-size=1440,900', + ].filter(Boolean), + }, + }, +}; diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index d57649b8575..55c10adb023 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -119,6 +119,7 @@ module.exports = { 'component', 'compute', 'computed', + 'composite', 'concat', 'concatenatedProperties', 'container', @@ -248,6 +249,7 @@ module.exports = { 'helper', 'htmlSafe', 'trustHTML', + 'idle', 'if', 'in-element', 'includes', @@ -396,6 +398,7 @@ module.exports = { 'registeredOptionsForType', 'registerOptions', 'registerOptionsForType', + 'registerStrategy', 'registerWaiter', 'registerWarnHandler', 'registrations', @@ -408,6 +411,7 @@ module.exports = { 'removeObject', 'removeObjects', 'removeObserver', + 'render', 'renderComponent', 'renderSettled', 'reopen', @@ -624,6 +628,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/routing-test/router-service.ts b/type-tests/@ember/routing-test/router-service.ts index 0944a281c4e..9086bda7ea9 100644 --- a/type-tests/@ember/routing-test/router-service.ts +++ b/type-tests/@ember/routing-test/router-service.ts @@ -3,7 +3,6 @@ import type RouteInfo from '@ember/routing/route-info'; import RouterService from '@ember/routing/router-service'; import type Transition from '@ember/routing/transition'; import { expectTypeOf } from 'expect-type'; -import type { Promise as RSVPPromise } from 'rsvp'; declare let router: RouterService; @@ -28,21 +27,21 @@ const transition = router.transitionTo('someRoute'); expectTypeOf(transition.abort()).toEqualTypeOf(); -expectTypeOf(transition.catch()).toEqualTypeOf>(); +expectTypeOf(transition.catch()).toEqualTypeOf>(); transition.catch((err) => console.log(err), 'label'); -expectTypeOf(transition.finally()).toEqualTypeOf>(); +expectTypeOf(transition.finally()).toEqualTypeOf>(); transition.finally(() => console.log('finally!')); transition.finally(() => console.log('finally!'), 'label'); -expectTypeOf(transition.followRedirects()).toEqualTypeOf>(); +expectTypeOf(transition.followRedirects()).toEqualTypeOf>(); expectTypeOf(transition.method('refresh')).toEqualTypeOf(); transition.method('replace'); expectTypeOf(transition.retry()).toEqualTypeOf(); -expectTypeOf(transition.then()).toEqualTypeOf>(); +expectTypeOf(transition.then()).toEqualTypeOf>(); transition.then( (result) => console.log(result), (err) => console.log(err), @@ -59,7 +58,7 @@ expectTypeOf(transition.from).toEqualTypeOf | undefined>(); +expectTypeOf(transition.promise).toEqualTypeOf | undefined>(); // @ts-expect-error transition.promise = 'promise'; diff --git a/type-tests/@ember/runloop-tests.ts b/type-tests/@ember/runloop-tests.ts index 6346746e6a4..b2338a9db90 100644 --- a/type-tests/@ember/runloop-tests.ts +++ b/type-tests/@ember/runloop-tests.ts @@ -12,8 +12,6 @@ import { schedule, scheduleOnce, throttle, - // private, supported via `declare module` below - _backburner, } from '@ember/runloop'; import EmberObject, { action } from '@ember/object'; import type { AnyFn, MethodsOf } from '@ember/-internals/utility-types'; diff --git a/type-tests/@ember/scheduler-test.ts b/type-tests/@ember/scheduler-test.ts new file mode 100644 index 00000000000..427259b8ec1 --- /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, { RenderClockStrategy } 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 RenderClockStrategy()).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() });