diff --git a/build-metadata.json b/build-metadata.json new file mode 100644 index 00000000000..315ddd351dd --- /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" +} \ No newline at end of file diff --git a/package.json b/package.json index 83ba3b57cab..5bc307c40b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ember-source", - "version": "7.3.0-alpha.1", + "version": "7.3.0-alpha.1.all-optimizations+60f0fc94", "description": "A JavaScript framework for creating ambitious web applications", "type": "module", "sideEffects": [ @@ -69,7 +69,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", @@ -190,7 +189,6 @@ "@ember/-internals/owner/index.js": "ember-source/@ember/-internals/owner/index.js", "@ember/-internals/routing/index.js": "ember-source/@ember/-internals/routing/index.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", @@ -283,6 +281,8 @@ "@ember/routing/router-service.js": "ember-source/@ember/routing/router-service.js", "@ember/routing/router.js": "ember-source/@ember/routing/router.js", "@ember/runloop/index.js": "ember-source/@ember/runloop/index.js", + "@ember/scheduler/index.js": "ember-source/@ember/scheduler/index.js", + "@ember/scheduler/strategy.js": "ember-source/@ember/scheduler/strategy.js", "@ember/service/index.js": "ember-source/@ember/service/index.js", "@ember/template-compilation/index.js": "ember-source/@ember/template-compilation/index.js", "@ember/template-compiler/-internal-primitives.js": "ember-source/@ember/template-compiler/-internal-primitives.js", @@ -342,7 +342,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", @@ -354,8 +353,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" } }, "typesVersions": { @@ -368,5 +366,10 @@ ] } }, - "packageManager": "pnpm@10.33.2" + "packageManager": "pnpm@10.33.2", + "_originalVersion": "7.3.0-alpha.1", + "_versionPreviouslyCalculated": true, + "publishConfig": { + "tag": "old" + } } diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 955511c4389..4c82429fa16 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -1,7 +1,5 @@ -import { ENV } from '@ember/-internals/environment/lib/env'; import type { InternalOwner } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; -import { _backburner, _getCurrentRunLoop } from '@ember/runloop'; import { associateDestroyableChild, destroy, @@ -28,14 +26,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, + _resetInvalidationNotified, + _setDestroyQueueObserver, +} from './environment'; import ResolverImpl from './resolver'; +import schedulerStrategy from '@ember/scheduler/strategy'; 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. @@ -143,6 +149,8 @@ export function _resetRenderers() { function register(renderer: BaseRenderer): void { assert('Cannot register the same renderer twice', renderers.indexOf(renderer) === -1); renderers.push(renderer); + // a suppressed notification cannot have reached this renderer + _resetInvalidationNotified(); } function deregister(renderer: BaseRenderer): void { @@ -151,12 +159,65 @@ 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(() => { + if (renderers.length === 0) return false; + for (let renderer of renderers) { renderer.rerender(); } + + // first dirt after a flush (the notify latch dedupes the rest) -- + // the pending edge for the settledness observer + sampleSettledState(); + + return true; +}); + +// 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; @@ -166,8 +227,9 @@ let renderSettledDeferred: RenderSettledDeferred | null = null; /* Returns a promise which will resolve when rendering has settled. Settled in this context is defined as when all of the tags in use are "current" (e.g. - `renderers.every(r => r._isValid())`). When this is checked at the _end_ of - the run loop, this essentially guarantees that all rendering is completed. + `renderers.every(r => r._isValid())`). Resolution is attempted at the end + of every scheduler flush; if nothing is dirty when this is called, it + settles on a microtask. @method renderSettled @returns {Promise} a promise which fulfills when rendering has settled @@ -177,46 +239,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; +function resolveRenderPromiseIfSettled() { + if (renderSettledDeferred === null) return; - _backburner.join(null, resolve); - } -} - -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; @@ -305,6 +358,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 { @@ -315,6 +369,7 @@ export class RendererState { this.#lastRevision = valueForTag(CURRENT_TAG); } this.#inRenderTransaction = false; + _endRenderTransaction(); } } @@ -368,8 +423,189 @@ 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()); + } + + // dirt from here on is new information again -- the next set must + // notify the scheduler. Reset at the END of the tick so dirt that + // arrived during revalidation (which latched the flag but was + // absorbed by this tick or its settle rounds) can't leave it stuck. + _resetInvalidationNotified(); + + // 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..69595cd200e 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,120 @@ 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: () => boolean = () => false; + +export function _setNotifyRevalidate(fn: () => boolean): void { + notifyRevalidate = fn; +} + +// Dirtying is much hotter than ticking: a 100k-set loop notifies once +// and then pays a single boolean check per set, instead of walking the +// notify chain per dirty tag. The renderer re-arms this at the start of +// every tick. +let invalidationNotified = false; + +export function _resetInvalidationNotified(): void { + invalidationNotified = false; +} + +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(); + if (invalidationNotified) return; + // only latch when a renderer actually heard the notification -- + // latching against an empty renderer list (dirt during app boot) + // would permanently swallow all future invalidations + if (notifyRevalidate()) { + invalidationNotified = true; + } }, toBool, @@ -33,11 +141,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 f86d93f0685..777e4f05a11 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/lib/views/outlet.ts b/packages/@ember/-internals/glimmer/lib/views/outlet.ts index 00d0828a8e3..c27b61765c5 100644 --- a/packages/@ember/-internals/glimmer/lib/views/outlet.ts +++ b/packages/@ember/-internals/glimmer/lib/views/outlet.ts @@ -5,7 +5,6 @@ import { type InternalOwner, getOwner } from '@ember/-internals/owner'; import type { BootOptions } from '@ember/engine/instance'; import { assert } from '@ember/debug'; -import { schedule } from '@ember/runloop'; import type { Template, TemplateFactory } from '@glimmer/interfaces'; import type { Reference } from '@glimmer/reference/lib/reference'; import { createComputeRef, updateRef } from '@glimmer/reference/lib/reference'; @@ -107,7 +106,7 @@ export default class OutletView { // SAFETY: It's not clear that this cast is safe. // The types for appendOutletView may be incorrect or this is a potential bug. - schedule('render', renderer, 'appendOutletView', this, target as SimpleElement); + renderer.appendOutletView(this, target as SimpleElement); } rerender(): void { 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 24811748b85..93623915163 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 '@ember/-internals/glimmer'; 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 924bdef358c..c041cd3a06b 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 c1017feeaa1..46032932a28 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/syntax/each-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/syntax/each-test.js @@ -4,7 +4,7 @@ import { notifyPropertyChange, on } 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/metal/lib/property_get.ts b/packages/@ember/-internals/metal/lib/property_get.ts index e76c9c14064..7fee081e5b5 100644 --- a/packages/@ember/-internals/metal/lib/property_get.ts +++ b/packages/@ember/-internals/metal/lib/property_get.ts @@ -3,11 +3,9 @@ */ import type ProxyMixin from '@ember/-internals/runtime/lib/mixins/-proxy'; import { setProxy } from '@ember/-internals/utils/lib/is_proxy'; -import { isEmberArray } from '@ember/array/-internals'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; -import { consumeTag, isTracking, track } from '@glimmer/validator/lib/tracking'; -import { tagFor } from '@glimmer/validator/lib/meta'; +import { track } from '@glimmer/validator/lib/tracking'; import { isPath } from './path_cache'; export const PROXY_CONTENT = Symbol('PROXY_CONTENT'); @@ -111,24 +109,13 @@ export function _getProp(obj: unknown, keyName: string) { value = (obj as any)[keyName]; } - if ( - value === undefined && - typeof obj === 'object' && - !(keyName in obj) && - hasUnknownProperty(obj) - ) { - value = obj.unknownProperty(keyName); - } - - if (isTracking()) { - consumeTag(tagFor(obj, keyName)); - - if (Array.isArray(value) || isEmberArray(value)) { - // Add the tag of the returned value if it is an array, since arrays - // should always cause updates if they are consumed and then changed - consumeTag(tagFor(value, '[]')); - } - } + // SPIKE: deleted legacy read-path support: + // - unknownProperty (ObjectProxy / EmberObject) + // - per-(object, key) tag consumption on arbitrary objects, which + // existed so Ember.set() on POJOs invalidates renders + // - the '[]' EmberArray tag consume for array-valued reads + // Modern semantics: plain-data reads don't entangle; reactivity + // comes from @tracked, tracked collections, and value replacement. } else { // SAFETY: It should be ok to access properties on any non-nullish value value = (obj as any)[keyName]; diff --git a/packages/@ember/-internals/runtime/index.ts b/packages/@ember/-internals/runtime/index.ts index ec028a7a2da..399f3ac9a4a 100644 --- a/packages/@ember/-internals/runtime/index.ts +++ b/packages/@ember/-internals/runtime/index.ts @@ -6,4 +6,3 @@ export { default as _ProxyMixin, contentFor as _contentFor } from './lib/mixins/ 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 9b9a870a7c9..85b8b5f4b86 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 */ @@ -42,10 +43,16 @@ const ContainerProxyMixin = 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 99822d559fd..d70681fb957 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, AbstractTestCase } from 'internal-test-helpers'; 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..c7213e4a278 --- /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 & 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 aba69ba106b..7ee294c53e4 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'; export interface ViewState { @@ -49,7 +48,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(view, view.trigger, eventName, event); + return view.trigger(eventName, event); }); } else { return true; // continue event propagation diff --git a/packages/@ember/application/index.ts b/packages/@ember/application/index.ts index e4ac76f8de3..38299e22f17 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()); } /** diff --git a/packages/@ember/application/tests/visit_test.js b/packages/@ember/application/tests/visit_test.js index 2abc2d2cfc0..ae23a95feff 100644 --- a/packages/@ember/application/tests/visit_test.js +++ b/packages/@ember/application/tests/visit_test.js @@ -7,7 +7,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 050d27eb3b8..fd375348f22 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'; @@ -102,7 +101,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 @@ -122,7 +121,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 9571771a803..3d1d8211a4f 100644 --- a/packages/@ember/object/promise-proxy-mixin.ts +++ b/packages/@ember/object/promise-proxy-mixin.ts @@ -3,14 +3,13 @@ import setProperties from '@ember/-internals/metal/lib/set_properties'; import computed from '@ember/-internals/metal/lib/computed'; import Mixin from '@ember/object/mixin'; 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, 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 4e05176c34e..3d96d9f72a7 100644 --- a/packages/@ember/routing/route.ts +++ b/packages/@ember/routing/route.ts @@ -24,7 +24,7 @@ import { isTesting } from '@ember/debug/lib/testing'; import { assert, info } 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 { DEBUG } from '@glimmer/env'; import { hasInternalComponentManager } from '@glimmer/manager/lib/internal/api'; import type { RenderState } from '@ember/-internals/glimmer/lib/utils/outlet'; @@ -1473,7 +1473,7 @@ class Route extends EmberObject.extend(ActionHandler, Evented) */ [RENDER]() { this[RENDER_STATE] = buildRenderState(this); - once(this._router, '_setOutlets'); + scheduleMethodOnce(this._router, '_setOutlets'); } willDestroy() { @@ -1488,7 +1488,7 @@ class Route extends EmberObject.extend(ActionHandler, Evented) teardownViews() { if (this[RENDER_STATE]) { this[RENDER_STATE] = undefined; - once(this._router, '_setOutlets'); + scheduleMethodOnce(this._router, '_setOutlets'); } } diff --git a/packages/@ember/routing/router.ts b/packages/@ember/routing/router.ts index 7f6d760dd9d..8f745d276e1 100644 --- a/packages/@ember/routing/router.ts +++ b/packages/@ember/routing/router.ts @@ -31,7 +31,11 @@ import { A as emberA } from '@ember/array'; import typeOf from '@ember/utils/lib/type-of'; import Evented from '@ember/object/evented'; import { assert, info } from '@ember/debug'; -import { cancel, once, run, scheduleOnce } from '@ember/runloop'; +import { + scheduleMethodOnce, + scheduleCancelableMicrotask, +} from '@ember/-internals/utils/lib/microtask-scheduling'; +import type { CancelableMicrotask } from '@ember/-internals/utils/lib/microtask-scheduling'; import { DEBUG } from '@glimmer/env'; import { type QueryParamMeta, @@ -51,7 +55,6 @@ import type { TransitionState, } from 'router_js'; import Router, { logAbort, STATE_SYMBOL } from 'router_js'; -import type { Timer } from 'backburner.js'; import EngineInstance from '@ember/engine/instance'; import type { QueryParams } from 'route-recognizer'; import type { AnyFn, MethodNamesOf, OmitFirst } from '@ember/-internals/utility-types'; @@ -197,7 +200,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { _engineInfoByRoute = Object.create(null); _routerService: RouterService; - _slowTransitionTimer: Timer | null = null; + _slowTransitionTimer: CancelableMicrotask | null = null; private namespace: any; @@ -388,7 +391,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { } updateURL(path: string) { - once(() => { + queueMicrotask(() => { location.setURL(path); set(router, 'currentURL', path); }); @@ -439,7 +442,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { routeDidChange(transition: Transition) { router.set('currentRoute', transition.to); - once(() => { + queueMicrotask(() => { router.trigger('routeDidChange', transition); if (DEBUG) { @@ -480,7 +483,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { location.replaceURL!(url); set(router, 'currentURL', url); }; - once(doReplaceURL); + queueMicrotask(doReplaceURL); } else { this.updateURL(url); } @@ -851,7 +854,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { for (let id in instanceMap) { let instance: EngineInstance | undefined = instanceMap[id]; assert('has instance', instance); - run(instance, 'destroy'); + instance.destroy(); } } } @@ -863,7 +866,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { */ _activeQPChanged(queryParameterName: string, newValue: unknown) { this._queuedQPChanges[queryParameterName] = newValue; - once(this, this._fireQueryParamTransition); + scheduleMethodOnce(this, '_fireQueryParamTransition'); } // The queryParameterName is QueryParam['urlKey'] @@ -1320,13 +1323,9 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { _scheduleLoadingEvent(transition: Transition, originRoute: Route) { this._cancelSlowTransitionTimer(); - this._slowTransitionTimer = scheduleOnce( - 'routerTransitions', - this, - this._handleSlowTransition, - transition, - originRoute - ); + this._slowTransitionTimer = scheduleCancelableMicrotask(() => { + this._handleSlowTransition(transition, originRoute); + }); } currentState: null | RouterState = null; @@ -1350,7 +1349,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { _cancelSlowTransitionTimer() { if (this._slowTransitionTimer) { - cancel(this._slowTransitionTimer); + this._slowTransitionTimer.cancelled = true; } this._slowTransitionTimer = null; } 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..3bfa229ca35 100644 --- a/packages/@ember/runloop/index.ts +++ b/packages/@ember/runloop/index.ts @@ -1,943 +1,380 @@ -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'; - -export type { Timer }; - -// Partial types from https://medium.com/codex/currying-in-typescript-ca5226c85b85 - -type PartialParams

= P extends [infer First, ...infer Rest] - ? [] | [First] | [First, ...PartialParams] - : // This is necessary to handle optional tuple values - Required

extends [infer First, ...infer Rest] - ? [] | [First | undefined] | [First | undefined, ...PartialParams>] - : []; - -type RemainingParams = PartialParams extends [ - infer First, - ...infer Rest, -] - ? All extends [infer AllFirst, ...infer AllRest] - ? First extends AllFirst - ? RemainingParams - : never - : // This is necessary to handle optional tuple values - Required extends [infer AllFirst, ...infer AllRest] - ? First extends AllFirst | undefined - ? Partial> - : never - : never - : PartialParams extends [] - ? All - : never; - -let currentRunLoop: DeferredActionQueues | null = null; -export function _getCurrentRunLoop() { - return currentRunLoop; -} - -function onBegin(current: DeferredActionQueues) { - currentRunLoop = current; -} +/** + 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 +*/ -function onEnd(_current: DeferredActionQueues, next: DeferredActionQueues) { - currentRunLoop = next; +type AnyFn = (...args: unknown[]) => unknown; - flushAsyncObservers(schedule); +export interface Timeout { + kind: 'timeout'; + id: ReturnType; } -function flush(queueName: string, next: () => void) { - if (queueName === 'render' || queueName === _rsvpErrorQueue) { - flushAsyncObservers(schedule); - } - - next(); +export interface Microtask { + kind: 'microtask'; + cancelled: boolean; } -export const _rsvpErrorQueue = `${Math.random()}${Date.now()}`.replace('.', ''); +export type Timer = Timeout | Microtask; -/** - 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', +interface TargetAndMethod { + target: object | null; + method: AnyFn; + args: unknown[]; +} - // 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. - - 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. + Runs the passed function immediately. With no run loop, this is a + plain 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; -export function run(method: F, ...args: Parameters): ReturnType; -export function run any>( - target: T, - method: F, - ...args: Parameters -): ReturnType; -export function run( - target: T, - 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; -export function join any>( - target: T, - method: F, - ...args: Parameters -): ReturnType | void; -export function join( - target: T, - 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< - T, - F extends (this: T, ...args: any[]) => any, - A extends PartialParams>, ->( - target: T, - method: F, - ...args: A -): (...args: RemainingParams>) => ReturnType | void; -export function bind>>( - method: F, - ...args: A -): (...args: RemainingParams>) => ReturnType | void; -export function bind< - T, - U extends keyof T, - A extends T[U] extends AnyFn ? PartialParams> : [], ->( - target: T, - method: U, - ...args: A -): T[U] extends AnyFn - ? (...args: RemainingParams>) => ReturnType | void - : never; -// This final fallback is the equivalent of the (quite unsafe!) type for `bind` -// from TS' defs for `Function.prototype.bind`. In general, it means we have a -// loss of safety if we do not -export function bind( - target: T, - 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[]) => run(...curried, ...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. +export function end(): void {} - 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. +function scheduleInvocation(invocation: TargetAndMethod): Microtask { + const token: Microtask = { kind: 'microtask', cancelled: false }; - ```javascript - import { schedule } from '@ember/runloop'; - - 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( - queueName: string, - method: F, - ...args: Parameters -): Timer; -export function schedule any>( - queueName: string, - target: T, - method: F, - ...args: Parameters -): Timer; -export function schedule( - queueName: string, - target: T, - 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); +export function schedule(_queue: string, ...args: unknown[]): Timer { + return scheduleInvocation(resolveInvocation(args)); } -// Used by global test teardown -export function _hasScheduledTimers() { - return _backburner.hasTimers(); -} - -// 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. + Schedules work onto a queue, coalescing repeat requests for the same + target and method until the scheduled microtask runs. - 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'; - - 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`. - @public -*/ -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: 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>( - target: T, - method: F, - ...args: Parameters -): Timer; -export function once( - 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 { + const invocation = resolveInvocation(args); + const dedupeTarget = invocation.target ?? ONCE_ANONYMOUS; -/** - 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. - - ```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'; + if (!token.cancelled) { + invoke(invocation); + } + }); - function scheduleIt() { - scheduleOnce('actions', myContext, function() { - console.log('Closure'); - }); - } + return token; +} - scheduleIt(); - scheduleIt(); +/** + Schedules work to run once, coalescing repeat requests for the same + target and method. - // "Closure" will print twice, even though we're using `scheduleOnce`, - // because the function we pass to it won't match the - // previously scheduled operation. - ``` + @method once + @for @ember/runloop + @static + @public +*/ +export function once(...args: unknown[]): Timer { + return scheduleOnce('actions', ...args); +} - Available queues, and their order, can be found at `queues` +/** + Runs the passed function in the next task. - @method scheduleOnce - @static + @method next @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, - target: T, - method: F, - ...args: Parameters -): Timer; -export function scheduleOnce( - queueName: string, - 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 next(...args: unknown[]): Timer { + const invocation = resolveInvocation(args); + + return { kind: 'timeout', id: setTimeout(() => invoke(invocation), 0) }; } /** - 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. + Runs the passed function after the given number of milliseconds. - ```javascript - import { next } from '@ember/runloop'; + @method later + @for @ember/runloop + @static + @public +*/ +export function later(...args: unknown[]): Timer { + let wait = 0; - 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. - } + if (typeof args[args.length - 1] === 'number') { + wait = args.pop() as number; } - ``` - 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. + const invocation = resolveInvocation(args); - 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`. + return { kind: 'timeout', id: setTimeout(() => invoke(invocation), wait) }; +} - @method next - @static +const DEBOUNCED = new WeakMap>>(); +const DEBOUNCE_ANONYMOUS: object = {}; + +/** + Debounces the passed function by the given number of milliseconds. + + @method debounce @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; -export function next any>( - target: T, - method: F, - ...args: Parameters -): Timer; -export function next( - target: T, - method: U, - ...args: T[U] extends AnyFn ? Parameters : [] -): Timer; -export function next(...args: any[]) { - return _backburner.later(...args, 1); -} +export function debounce(...args: unknown[]): Timer { + let immediate = false; -/** - 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 - }); + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; + } - cancel(runNext); + let wait = 0; - let runLater = later(myContext, function() { - // will not be executed - }, 500); + if (typeof args[args.length - 1] === 'number') { + wait = args.pop() as number; + } - cancel(runLater); + const invocation = resolveInvocation(args); + const dedupeTarget = invocation.target ?? DEBOUNCE_ANONYMOUS; - let runScheduleOnce = scheduleOnce('afterRender', myContext, function() { - // will not be executed - }); + let timers = DEBOUNCED.get(dedupeTarget); - cancel(runScheduleOnce); + if (timers === undefined) { + timers = new Map(); + DEBOUNCED.set(dedupeTarget, timers); + } - let runOnce = once(myContext, function() { - // will not be executed - }); + const existing = timers.get(invocation.method); + const isPending = existing !== undefined; - cancel(runOnce); + if (existing !== undefined) { + clearTimeout(existing); + } - let throttle = throttle(myContext, function() { - // will not be executed - }, 1, false); + if (immediate && !isPending) { + invoke(invocation); + } - cancel(throttle); + const id = setTimeout(() => { + timers.delete(invocation.method); - let debounce = debounce(myContext, function() { - // will not be executed - }, 1); + if (!immediate) { + invoke(invocation); + } + }, wait); - cancel(debounce); + timers.set(invocation.method, id); - let debounceImmediate = debounce(myContext, function() { - // will be executed since we passed in true (immediate) - }, 100, true); + return { kind: 'timeout', id }; +} - // the 100ms delay until this method can be called again will be canceled - cancel(debounceImmediate); - ``` +/** + Throttles the passed function to at most once per the given number of + milliseconds. - @method cancel - @static + @method throttle @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'; +export function throttle(...args: unknown[]): Timer { + let immediate = true; - function whoRan() { - console.log(this.name + ' ran.'); + if (typeof args[args.length - 1] === 'boolean') { + immediate = args.pop() as boolean; } - let myContext = { name: 'debounce' }; + let wait = 0; - debounce(myContext, whoRan, 150); + if (typeof args[args.length - 1] === 'number') { + wait = args.pop() as number; + } - // less than 150ms passes - debounce(myContext, whoRan, 150); + const invocation = resolveInvocation(args); + const dedupeTarget = invocation.target ?? DEBOUNCE_ANONYMOUS; - // 150ms passes - // whoRan is invoked with context myContext - // console logs 'debounce ran.' one time. - ``` + let timers = DEBOUNCED.get(dedupeTarget); - 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. + if (timers === undefined) { + timers = new Map(); + DEBOUNCED.set(dedupeTarget, timers); + } - ```javascript - import { debounce } from '@ember/runloop'; + if (timers.has(invocation.method)) { + return { kind: 'microtask', cancelled: true }; + } - function whoRan() { - console.log(this.name + ' ran.'); + if (immediate) { + invoke(invocation); } - let myContext = { name: 'debounce' }; + const id = setTimeout(() => { + timers.delete(invocation.method); - debounce(myContext, whoRan, 150, true); + if (!immediate) { + invoke(invocation); + } + }, wait); - // console logs 'debounce ran.' one time immediately. - // 100ms passes - debounce(myContext, whoRan, 150, true); + timers.set(invocation.method, id); - // 150ms passes and nothing else is logged to the console and - // the debouncee is no longer being watched - debounce(myContext, whoRan, 150, true); + return { kind: 'timeout', id }; +} - // 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 - ``` +/** + Cancels a timer returned from `later`, `next`, `once`, `schedule`, + `scheduleOnce`, `debounce`, or `throttle`. - @method debounce - @static + @method cancel @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( - method: F, - ...args: [...args: Parameters, wait: string | number, immediate?: boolean] -): Timer; -export function debounce any>( - target: T, - method: F, - ...args: [...args: Parameters, wait: string | number, immediate?: boolean] -): Timer; -export function debounce( - target: T, - method: U, - ...args: [ - ...args: T[U] extends AnyFn ? Parameters : [], - wait: string | number, - immediate?: boolean, - ] -): Timer; -export function debounce(...args: any[]) { - // @ts-expect-error TS doesn't like the rest args here - return _backburner.debounce(...args); -} - -/** - Ensure that the target method is never called more frequently than - the specified spacing period. The target method is called immediately. - - ```javascript - import { throttle } from '@ember/runloop'; +export function cancel(timer?: Timer): boolean { + if (timer === undefined) { + return false; + } - function whoRan() { - console.log(this.name + ' ran.'); + if (timer.kind === 'timeout') { + clearTimeout(timer.id); + return true; } - let myContext = { name: 'throttle' }; + timer.cancelled = true; + return true; +} - throttle(myContext, whoRan, 150); - // whoRan is invoked with context myContext - // console logs 'throttle ran.' +// 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. - // 50ms passes - throttle(myContext, whoRan, 150); +export function _getCurrentRunLoop(): null { + return null; +} - // 50ms passes - throttle(myContext, whoRan, 150); +export function _hasScheduledTimers(): boolean { + return false; +} - // 150ms passes - throttle(myContext, whoRan, 150); - // whoRan is invoked with context myContext - // console logs 'throttle ran.' - ``` +export function _cancelTimers(): void {} - @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`. - @public -*/ -export function throttle( - method: F, - ...args: [...args: Parameters, wait?: string | number, immediate?: boolean] -): Timer; -export function throttle any>( - target: T, - method: F, - ...args: [...args: Parameters, wait?: string | number, immediate?: boolean] -): Timer; -export function throttle( - target: T, - method: U, - ...args: [ - ...args: T[U] extends AnyFn ? Parameters : [], - wait?: string | number, - immediate?: boolean, - ] -): Timer; -export function throttle(...args: any[]): Timer { - // @ts-expect-error TS doesn't like the rest args here - return _backburner.throttle(...args); -} +// 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 24e58c7f907..87db3851705 100644 --- a/packages/@glimmer/component/package.json +++ b/packages/@glimmer/component/package.json @@ -25,9 +25,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" @@ -44,4 +44,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 23433cdd724..0d272c5d847 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, setDestroyed, setDestroying } 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,12 @@ class EmberGlimmerComponentManager extends BaseComponentManager { + scheduledDestroyComponent(component); + }); } } diff --git a/packages/@glimmer/reference/lib/iterable.ts b/packages/@glimmer/reference/lib/iterable.ts index 71134eb5c2b..120d18114a7 100644 --- a/packages/@glimmer/reference/lib/iterable.ts +++ b/packages/@glimmer/reference/lib/iterable.ts @@ -19,6 +19,12 @@ export interface IterationItem { export interface AbstractIterator> { isEmpty(): boolean; next(): Nullable; + /** + * SPIKE: allocation-free iteration -- writes into `target` and returns + * it, instead of allocating a fresh item per step. Optional; callers + * must not retain the returned object across steps. + */ + nextInto?(target: V): Nullable; } export type OpaqueIterationItem = IterationItem; @@ -263,4 +269,24 @@ class ArrayIterator implements OpaqueIterator { return { key, value, memo }; } + + nextInto(target: IterationItem): Nullable> { + let value: unknown; + + let current = this.current; + if (current.kind === 'first') { + this.current = { kind: 'progress' }; + value = current.value; + } else if (this.pos >= this.iterator.length - 1) { + return null; + } else { + value = this.iterator[++this.pos]; + } + + target.key = this.keyFor(value, this.pos); + target.value = value; + target.memo = this.pos; + + return target; + } } diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index c7232d0469a..bdaa77b8524 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -15,6 +15,7 @@ import { expect } from '@glimmer/debug-util/lib/platform-utils'; import { getProp, setProp } from '@glimmer/global-context'; import { isDict } from '@glimmer/util/lib/collections'; import { CONSTANT_TAG, INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; +import { peekTagFor } from '@glimmer/validator/lib/meta'; import { consumeTag, track } from '@glimmer/validator/lib/tracking'; export const REFERENCE: ReferenceSymbol = Symbol('REFERENCE') as ReferenceSymbol; @@ -45,6 +46,17 @@ class ReferenceImpl implements Reference { public compute: Nullable<() => T> = null; public update: Nullable<(val: T) => void> = null; + /** + * Proven plain tracked-field read: the first framed compute consumed + * exactly the property's canonical cell tag, so the consumed set can + * never change and recomputes skip frame machinery entirely. + */ + public knownTag = false; + + /** pending known-tag candidacy; checked once after the first compute */ + public pathParent: Nullable = null; + public pathKey: Nullable = null; + public debugLabel?: string; constructor(type: ReferenceType) { @@ -163,6 +175,16 @@ export function valueForRef(_ref: Reference): T { if (tag === null || !validateTag(tag, lastRevision)) { const { compute } = ref; + if (ref.knownTag) { + // the getter's own consumeTag lands in the ambient frame, which + // is exactly what the framed path's trailing consumeTag achieved + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- knownTag implies compute + lastValue = ref.lastValue = compute!(); + ref.lastRevision = valueForTag(tag); + + return lastValue; + } + const newTag = track(() => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme lastValue = ref.lastValue = compute!(); @@ -171,6 +193,10 @@ export function valueForRef(_ref: Reference): T { tag = ref.tag = newTag; ref.lastRevision = valueForTag(newTag); + + if (ref.pathParent !== null) { + maybeLockKnownTag(ref, newTag); + } } else { lastValue = ref.lastValue; } @@ -180,6 +206,28 @@ export function valueForRef(_ref: Reference): T { return lastValue as T; } +/** + * A child ref locks onto its property's canonical tag when its first + * framed compute consumed EXACTLY that tag: single tag means no + * branching getter (those consume different sets per run), and + * identity with the registry's cell tag means the read was the plain + * tracked-field getter on a parent that can never change (a mutable + * parent's tag would have been in the frame too). Checked once. + */ +function maybeLockKnownTag(ref: ReferenceImpl, tag: Tag): void { + const parentRef = ref.pathParent as ReferenceImpl; + const key = ref.pathKey as string; + + ref.pathParent = null; + ref.pathKey = null; + + const parent = parentRef.lastValue; + + if (isDict(parent) && peekTagFor(parent, key) === tag) { + ref.knownTag = true; + } +} + export function updateRef(_ref: Reference, value: unknown) { const ref = _ref as ReferenceImpl; @@ -233,6 +281,9 @@ export function childRefFor(_parentRef: Reference, path: string): Reference { } ); + (child as ReferenceImpl).pathParent = parentRef; + (child as ReferenceImpl).pathKey = path; + if (DEBUG) { child.debugLabel = `${parentRef.debugLabel}.${path}`; } diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 92981cc0531..364fabf9883 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -16,14 +16,15 @@ import type { } from '@glimmer/interfaces'; import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable'; import type { Reference } from '@glimmer/reference/lib/reference'; +import type { Revision, Tag } from '@glimmer/interfaces'; import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; -import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; +import { beginTrackFrame, consumeTag, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; +import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -36,7 +37,15 @@ export class UpdatingVM implements IUpdatingVM { public dom: GlimmerTreeChanges; public alwaysRevalidate: boolean; - private frameStack: Stack = new Stack(); + /** + * SPIKE: a flat frame stack (parallel arrays indexed by depth) + * instead of allocating an UpdatingVMFrame per block per render. + */ + #ops: UpdatingOpcode[][] = []; + #current: number[] = []; + #handlers: Nullable[] = []; + #finalizers: (((didError: boolean) => void) | undefined)[] = []; + #depth = -1; constructor(env: Environment, { alwaysRevalidate = false }) { this.env = env; @@ -69,40 +78,60 @@ export class UpdatingVM implements IUpdatingVM { } private _execute(opcodes: UpdatingOpcode[], handler: ExceptionHandler) { - let { frameStack } = this; - this.try(opcodes, handler); - while (!frameStack.isEmpty()) { - let opcode = this.frame.nextStatement(); + while (this.#depth >= 0) { + let depth = this.#depth; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked + let ops = this.#ops[depth]!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked + let index = this.#current[depth]!; - if (opcode === undefined) { - frameStack.pop(); + if (index >= ops.length) { + this.#pop(false); continue; } - opcode.evaluate(this); + this.#current[depth] = index + 1; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + ops[index]!.evaluate(this); } } - private get frame() { - return expect(this.frameStack.current, 'bug: expected a frame'); + #pop(didError: boolean) { + let depth = this.#depth; + let finalizer = this.#finalizers[depth]; + + // release references so retained arrays don't leak between renders + this.#ops[depth] = EMPTY_OPS; + this.#handlers[depth] = null; + this.#finalizers[depth] = undefined; + this.#depth = depth - 1; + + finalizer?.(didError); } goto(index: number) { - this.frame.goto(index); + this.#current[this.#depth] = index; } - try(ops: UpdatingOpcode[], handler: Nullable) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + try(ops: UpdatingOpcode[], handler: Nullable, finalizer?: (didError: boolean) => void) { + let depth = ++this.#depth; + + this.#ops[depth] = ops; + this.#current[depth] = 0; + this.#handlers[depth] = handler; + this.#finalizers[depth] = finalizer; } throw() { - this.frame.handleException(); - this.frameStack.pop(); + this.#handlers[this.#depth]?.handleException(); + this.#pop(true); } } +const EMPTY_OPS: UpdatingOpcode[] = []; + export interface VMState { readonly pc: number; readonly scope: Scope; @@ -178,6 +207,15 @@ export class ListItemOpcode extends TryOpcode { public retained = false; public index = -1; + /** + * Everything this item's subtree consumed during its last update, + * combined. When still valid, the whole subtree is skipped -- one tag + * validation instead of walking every opcode in the item. + */ + private subtreeTag: Nullable = null; + private subtreeRevision: Revision = INITIAL; + private isTrivial: boolean | null = null; + constructor( state: Closure, context: EvaluationContext, @@ -189,6 +227,52 @@ export class ListItemOpcode extends TryOpcode { super(state, context, bounds, []); } + override evaluate(vm: UpdatingVM) { + // Trivial items (a text node or two) can't win: validating their + // combined tag costs as much as just updating them, so collection + // would be pure overhead. Skipping only pays off for items with a + // real subtree -- more than a couple of opcodes, or any nested + // block (a nested block child means an arbitrarily large subtree + // hides behind a small top-level count). + if (this.isTrivial ?? (this.isTrivial = computeIsTrivial(this.children))) { + vm.try(this.children, this); + return; + } + + let { subtreeTag } = this; + + if ( + subtreeTag !== null && + !vm.alwaysRevalidate && + validateTag(subtreeTag, this.subtreeRevision) + ) { + // propagate this item's dependencies to any enclosing tracking + // frame, exactly as executing the children would have + consumeTag(subtreeTag); + return; + } + + beginTrackFrame(); + vm.try(this.children, this, (didError) => { + // always balance beginTrackFrame, even when unwinding + let tag = endTrackFrame(); + + if (didError) return; + + this.subtreeTag = tag; + this.subtreeRevision = valueForTag(tag); + consumeTag(tag); + }); + } + + override handleException() { + // children are about to be rebuilt; the collected tag and triviality + // no longer describe them + this.subtreeTag = null; + this.isTrivial = null; + super.handleException(); + } + shouldRemove(): boolean { return !this.retained; } @@ -198,6 +282,16 @@ export class ListItemOpcode extends TryOpcode { } } +function computeIsTrivial(children: UpdatingOpcode[]): boolean { + if (children.length > 2) return false; + + for (const child of children) { + if (child instanceof BlockOpcode) return false; + } + + return true; +} + export class ListBlockOpcode extends BlockOpcode { public type = 'list-block'; declare public children: ListItemOpcode[]; @@ -228,20 +322,30 @@ export class ListBlockOpcode extends BlockOpcode { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { - let { bounds } = this; - let { dom } = vm; + // SPIKE: deriving a fresh array from tracked state is the idiomatic + // pattern, so iterator identity changes every render even when the + // list's keys did not. When items match the existing children in + // order and count, just update the item refs -- no diff + // bookkeeping, no marker DOM, no children rebuild. + let buffered = this.tryFastSync(iterator); + + if (buffered !== null) { + let { bounds } = this; + let { dom } = vm; + + let marker = (this.marker = dom.createComment('')); + dom.insertAfter( + bounds.parentElement(), + marker, + expect(bounds.lastNode(), "can't insert after an empty bounds") + ); - let marker = (this.marker = dom.createComment('')); - dom.insertAfter( - bounds.parentElement(), - marker, - expect(bounds.lastNode(), "can't insert after an empty bounds") - ); + this.sync(new PrefixedIterator(buffered, iterator)); - this.sync(iterator); + this.parentElement().removeChild(marker); + this.marker = null; + } - this.parentElement().removeChild(marker); - this.marker = null; this.lastIterator = iterator; } @@ -249,6 +353,72 @@ export class ListBlockOpcode extends BlockOpcode { super.evaluate(vm); } + /** + * Streaming compare of the new iteration against existing children, + * applied as it matches: allocation-free on the happy path (a shared + * scratch item via nextInto). Returns null when everything matched in + * order; otherwise reconstructs the already-applied prefix (reading + * the just-updated refs back) plus the mismatched item, so the full + * sync can replay them. + */ + private tryFastSync(iterator: OpaqueIterator): Nullable { + let { children } = this; + let matched = 0; + + while (true) { + let item = + iterator.nextInto !== undefined ? iterator.nextInto(SCRATCH_ITEM) : iterator.next(); + + if (item === null) { + if (matched === children.length) return null; + + // the list shrank; replay the matched prefix through full sync + return this.reconstructPrefix(matched, null); + } + + let opcode = children[matched]; + + if (opcode === undefined || opcode.key !== item.key) { + return this.reconstructPrefix(matched, { + key: item.key, + value: item.value, + memo: item.memo, + }); + } + + updateRef(opcode.memo, item.memo); + updateRef(opcode.value, item.value); + matched++; + } + } + + /** + * The matched prefix was already applied to the item refs, so its + * items can be reconstructed from the opcodes themselves. + */ + private reconstructPrefix( + matched: number, + mismatch: Nullable + ): OpaqueIterationItem[] { + let { children } = this; + let prefix: OpaqueIterationItem[] = []; + + for (let i = 0; i < matched; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + let opcode = children[i]!; + + prefix.push({ + key: opcode.key, + value: valueForRef(opcode.value), + memo: valueForRef(opcode.memo), + }); + } + + if (mismatch !== null) prefix.push(mismatch); + + return prefix; + } + private sync(iterator: OpaqueIterator) { let { opcodeMap: itemMap, children } = this; @@ -423,25 +593,29 @@ export class ListBlockOpcode extends BlockOpcode { } } -class UpdatingVMFrame { - private current = 0; +/** Shared scratch for allocation-free fast-path iteration. */ +const SCRATCH_ITEM: OpaqueIterationItem = { key: null, value: null, memo: null }; + +/** Replays already-consumed items before draining the rest. */ +class PrefixedIterator implements OpaqueIterator { + private index = 0; constructor( - private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private prefix: OpaqueIterationItem[], + private inner: OpaqueIterator ) {} - goto(index: number) { - this.current = index; - } - - nextStatement(): UpdatingOpcode | undefined { - return this.ops[this.current++]; + isEmpty(): boolean { + return this.index >= this.prefix.length && this.inner.isEmpty(); } - handleException() { - if (this.exceptionHandler) { - this.exceptionHandler.handleException(); + next(): Nullable { + if (this.index < this.prefix.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + return this.prefix[this.index++]!; } + + return this.inner.next(); } } + diff --git a/packages/@glimmer/validator/lib/meta.ts b/packages/@glimmer/validator/lib/meta.ts index 56e30cc7be1..b6a869368db 100644 --- a/packages/@glimmer/validator/lib/meta.ts +++ b/packages/@glimmer/validator/lib/meta.ts @@ -17,6 +17,32 @@ export type TagMeta = Map; const TRACKED_TAGS = new WeakMap(); +/** + * Read-only registry lookup: the canonical tag for (obj, key) if one + * exists, with no create-on-miss allocation. + */ +export function peekTagFor(obj: object, key: PropertyKey): UpdatableTag | undefined { + return TRACKED_TAGS.get(obj)?.get(key); +} + +/** + * Adopts an externally-owned tag (e.g. a tracked field's inline cell + * tag) as THE tag for (obj, key) in the central registry, so + * `tagFor`/`dirtyTagFor` consumers -- notifyPropertyChange, computed + * property chains -- observe the same tag object the field itself + * consumes and dirties. + */ +export function registerTagFor(obj: object, key: PropertyKey, tag: UpdatableTag): void { + let tags = TRACKED_TAGS.get(obj); + + if (tags === undefined) { + tags = new Map(); + TRACKED_TAGS.set(obj, tags); + } + + tags.set(key, tag); +} + export function dirtyTagFor( obj: T, key: keyof T | string | symbol, diff --git a/packages/@glimmer/validator/lib/tracked-data.ts b/packages/@glimmer/validator/lib/tracked-data.ts index 560f6d71a54..d0c4eee3e93 100644 --- a/packages/@glimmer/validator/lib/tracked-data.ts +++ b/packages/@glimmer/validator/lib/tracked-data.ts @@ -1,36 +1,80 @@ -import { dirtyTagFor, tagFor } from './meta'; +import { DEBUG } from '@glimmer/env'; +import type { UpdatableTag } from '@glimmer/interfaces'; + +import { debug } from './debug'; +import { registerTagFor } from './meta'; import { consumeTag } from './tracking'; +import { unwrap } from './utils'; +import { createUpdatableTag, DIRTY_TAG } from './validators'; export type Getter = (self: T) => T[K] | undefined; export type Setter = (self: T, value: T[K]) => void; +/** + * Value and tag live in one cell per (field, instance): a read is one + * WeakMap hop + consumeTag, a write is one hop + DIRTY_TAG. The + * previous shape went through the central tag registry + * (`TRACKED_TAGS` WeakMap -> per-object Map) plus a separate values + * WeakMap -- three map hops on every tracked read and write, which is + * the hottest path in data-heavy rendering. + */ +interface TrackedCell { + value: V; + tag: UpdatableTag; + initialized: boolean; +} + export function trackedData( key: K, initializer?: (this: T) => T[K] ): { getter: Getter; setter: Setter } { - let values = new WeakMap(); + let cells = new WeakMap>(); let hasInitializer = typeof initializer === 'function'; + function cellFor(self: T): TrackedCell { + let cell = cells.get(self); + + if (cell === undefined) { + cell = { + value: undefined, + tag: createUpdatableTag(), + initialized: !hasInitializer, + }; + cells.set(self, cell); + // one-time bridge: notifyPropertyChange / computed chains resolve + // tags through the central registry; hand them this cell's tag so + // both worlds dirty and consume the same object + registerTagFor(self, key, cell.tag); + } + + return cell; + } + function getter(self: T) { - consumeTag(tagFor(self, key)); + const cell = cellFor(self); - let value; + consumeTag(cell.tag); // If the field has never been initialized, we should initialize it - if (hasInitializer && !values.has(self)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - value = initializer!.call(self); - values.set(self, value); - } else { - value = values.get(self); + if (!cell.initialized) { + cell.initialized = true; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by initialized + cell.value = initializer!.call(self); } - return value; + return cell.value; } function setter(self: T, value: T[K]): void { - dirtyTagFor(self, key); - values.set(self, value); + const cell = cellFor(self); + + if (DEBUG) { + unwrap(debug.assertTagNotConsumed)(cell.tag, self, key); + } + + DIRTY_TAG(cell.tag); + cell.initialized = true; + cell.value = value; } return { getter, setter }; diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..5273632e8b6 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -37,6 +37,7 @@ class Tracker { return combine(Array.from(this.tags)); } } + } /** diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index 66c7f1ac1cb..73e1da2d77e 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -99,8 +99,37 @@ class MonomorphicTagImpl { case 1: return tags[0] as Tag; default: { + // SPIKE: flatten nested combinators (and drop constants) so + // validating a combined tag is one flat loop instead of a + // pointer-chasing tree walk. Capped so pathological frames + // don't build giant arrays. + let flattened: Tag[] = []; + let budget = 64; + + for (const t of tags) { + const impl = t as MonomorphicTagImpl; + + if (impl === CONSTANT_TAG) continue; + + if ( + impl[TYPE] === COMBINATOR_TAG_ID && + Array.isArray(impl.subtag) && + impl.subtag.length <= budget + ) { + for (const sub of impl.subtag) { + if (sub !== CONSTANT_TAG) flattened.push(sub); + } + budget -= impl.subtag.length; + } else { + flattened.push(t); + } + } + + if (flattened.length === 0) return CONSTANT_TAG; + if (flattened.length === 1) return flattened[0] as Tag; + let tag: MonomorphicTagImpl = new MonomorphicTagImpl(COMBINATOR_TAG_ID); - tag.subtag = tags; + tag.subtag = flattened; return tag; } } 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 a95bdfe1bbc..e382739ea2d 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 fe1f30f626a..efae1d59e25 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,5 +1,5 @@ import { get } from '@ember/object'; -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 6581612b9a5..a703b47bf7c 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 '@ember/-internals/glimmer'; 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 fd0396596ec..2e267e6dfab 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 443a49e8105..db2229a03f7 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 89d05bcc43c..7daeb64ed98 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 { ModelFor, Route, RouteInfo, RouteInfoWithAttributes } from './route-info'; import type InternalRouteInfo from './route-info'; diff --git a/packages/router_js/lib/transition-state.ts b/packages/router_js/lib/transition-state.ts index e10c7c7361c..821e846bdcc 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 { Route, ResolvedRouteInfo } from './route-info'; import type InternalRouteInfo from './route-info'; @@ -47,7 +46,7 @@ function resolveOneRouteInfo( resolvedRouteInfo: ResolvedRouteInfo ) => void | Promise; - return routeInfo.resolve(transition).then(callback, null, currentState.promiseLabel('Proceed')); + return routeInfo.resolve(transition).then(callback, null); } function proceed( @@ -113,9 +112,9 @@ export default class TransitionState { let errorHandler = handleError.bind(null, this, transition); // 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 3bca750fca5..4a1aff8f32d 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 { ModelFor, Route, RouteInfo, RouteInfoWithAttributes } from './route-info'; import type InternalRouteInfo from './route-info'; @@ -230,9 +229,9 @@ export default class Transition implements Partial> then( onFulfilled?: ((value: R) => 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); } /** @@ -248,8 +247,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); } /** @@ -265,9 +264,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 d51382da07e..831042089a4 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 @@ -1063,9 +1060,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 @@ -1073,6 +1067,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': @@ -1582,9 +1585,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 37333ab65f9..059373c703b 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -265,8 +265,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'), 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 db1994ff30a..2d2855471ec 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -119,6 +119,7 @@ module.exports = { 'component', 'compute', 'computed', + 'composite', 'concat', 'concatenatedProperties', 'container', @@ -250,6 +251,7 @@ module.exports = { 'helper', 'htmlSafe', 'trustHTML', + 'idle', 'if', 'in-element', 'includes', @@ -400,6 +402,7 @@ module.exports = { 'registeredOptionsForType', 'registerOptions', 'registerOptionsForType', + 'registerStrategy', 'registerWaiter', 'registerWarnHandler', 'registrations', @@ -412,6 +415,7 @@ module.exports = { 'removeObject', 'removeObjects', 'removeObserver', + 'render', 'renderComponent', 'renderSettled', 'reopen', @@ -629,6 +633,8 @@ module.exports = { '@ember/routing/router-service', '@ember/routing/transition', '@ember/runloop', + '@ember/scheduler', + '@ember/scheduler/strategy', '@ember/service', '@ember/template', '@ember/test', diff --git a/type-tests/@ember/scheduler-test.ts b/type-tests/@ember/scheduler-test.ts new file mode 100644 index 00000000000..90a730cb473 --- /dev/null +++ b/type-tests/@ember/scheduler-test.ts @@ -0,0 +1,28 @@ +import { render, layout, composite, next, idle, registerStrategy } from '@ember/scheduler'; +import type { Strategy } from '@ember/scheduler'; +import strategy, { FrameStrategy } from '@ember/scheduler/strategy'; +import { expectTypeOf } from 'expect-type'; + +expectTypeOf(render()).toEqualTypeOf>(); +expectTypeOf(layout()).toEqualTypeOf>(); +expectTypeOf(composite()).toEqualTypeOf>(); +expectTypeOf(next()).toEqualTypeOf>(); +expectTypeOf(idle()).toEqualTypeOf>(); + +expectTypeOf(registerStrategy(strategy)).toEqualTypeOf(); +expectTypeOf(strategy).toMatchTypeOf(); +expectTypeOf(new FrameStrategy()).toMatchTypeOf(); + +// @ts-expect-error requires a strategy +registerStrategy(); + +registerStrategy({ + render: () => Promise.resolve(), + layout: () => Promise.resolve(), + composite: () => Promise.resolve(), + next: () => Promise.resolve(), + idle: () => Promise.resolve(), +}); + +// @ts-expect-error an incomplete strategy is rejected +registerStrategy({ render: () => Promise.resolve() });