summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/utils/throttle
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/utils/throttle')
-rw-r--r--addons/mail/static/src/utils/throttle/throttle.js382
-rw-r--r--addons/mail/static/src/utils/throttle/throttle_tests.js407
2 files changed, 789 insertions, 0 deletions
diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js
new file mode 100644
index 00000000..6b9ff008
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle.js
@@ -0,0 +1,382 @@
+odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+/**
+ * This module define an utility function that enables throttling calls on a
+ * provided function. Such throttled calls can be canceled, flushed and/or
+ * cleared:
+ *
+ * - cancel: Canceling a throttle function call means that if a function call is
+ * pending invocation, cancel removes this pending call invocation. It however
+ * preserves the internal timer of the cooling down phase of this throttle
+ * function, meaning that any following throttle function call will be pending
+ * and has to wait for the remaining time of the cooling down phase before
+ * being invoked.
+ *
+ * - flush: Flushing a throttle function call means that if a function call is
+ * pending invocation, flush immediately terminates the cooling down phase and
+ * the pending function call is immediately invoked. Flush also works without
+ * any pending function call: it just terminates the cooling down phase, so
+ * that a following function call is guaranteed to be immediately called.
+ *
+ * - clear: Clearing a throttle function combines canceling and flushing
+ * together.
+ */
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of internal and external Throttle errors.
+ * Internal errors are prefixed with `_`.
+ */
+
+ /**
+ * Error when throttle function has been canceled with `.cancel()`. Used to
+ * let the caller know of throttle function that the call has been canceled,
+ * which means the inner function will not be called. Usually caller should
+ * just accept it and kindly treat this error as a polite warning.
+ */
+class ThrottleCanceledError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleCanceledError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been reinvoked again. Used to let know
+ * caller of throttle function that the call has been canceled and replaced with
+ * another one, which means the (potentially) following inner function will be
+ * in the context of another call. Same as for `ThrottleCanceledError`, usually
+ * caller should just accept it and kindly treat this error as a polite
+ * warning.
+ */
+class ThrottleReinvokedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleReinvokedError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been flushed with `.flush()`. Used
+ * internally to immediately invoke pending inner functions, since a flush means
+ * the termination of cooling down phase.
+ *
+ * @private
+ */
+class _ThrottleFlushedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = '_ThrottleFlushedError';
+ this.throttleId = throttleId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class models the behaviour of the cancelable, flushable and clearable
+ * throttle version of a provided function. See definitions at the top of this
+ * file.
+ */
+class Throttle {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} func provided function for making throttled version.
+ * @param {integer} duration duration of the 'cool down' phase, i.e.
+ * the minimum duration between the most recent function call that has
+ * been made and the following function call (of course, assuming no flush
+ * in-between).
+ */
+ constructor(env, func, duration) {
+ /**
+ * Reference to the OWL envirionment. Useful to fine-tune control of
+ * time flow in tests.
+ * @see mail/static/src/utils/test_utils.js:start.hasTimeControl
+ */
+ this.env = env;
+ /**
+ * Unique id of this throttle function. Useful for the ThrottleError
+ * management, in order to determine whether these errors come from
+ * this throttle or from another one (e.g. inner function makes use of
+ * another throttle).
+ */
+ this.id = _.uniqueId('throttle_');
+ /**
+ * Deferred of current cooling down phase in progress. Defined only when
+ * there is a cooling down phase in progress. Resolved when cooling down
+ * phase terminates from timeout, and rejected if flushed.
+ *
+ * @see _ThrottleFlushedError for rejection of this deferred.
+ */
+ this._coolingDownDeferred = undefined;
+ /**
+ * Duration, in milliseconds, of the cool down phase.
+ */
+ this._duration = duration;
+ /**
+ * Inner function to be invoked and throttled.
+ */
+ this._function = func;
+ /**
+ * Determines whether the throttle function is currently in cool down
+ * phase. Cool down phase happens just after inner function has been
+ * invoked, and during this time any following function call are pending
+ * and will be invoked only after the end of the cool down phase (except
+ * if canceled).
+ */
+ this._isCoolingDown = false;
+ /**
+ * Deferred of a currently pending invocation to inner function. Defined
+ * only during a cooling down phase and just after when throttle
+ * function has been called during this cooling down phase. It is kept
+ * until cooling down phase ends (either from timeout or flushed
+ * throttle) or until throttle is canceled (i.e. removes pending invoke
+ * while keeping cooling down phase live on).
+ */
+ this._pendingInvokeDeferred = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancel any buffered function call while keeping the cooldown phase
+ * running.
+ */
+ cancel() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ if (!this._pendingInvokeDeferred) {
+ return;
+ }
+ this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id));
+ }
+
+ /**
+ * Clear any buffered function call and immediately terminates any cooling
+ * down phase in progress.
+ */
+ clear() {
+ this.cancel();
+ this.flush();
+ }
+
+ /**
+ * Called when there is a call to the function. This function is throttled,
+ * so the time it is called depends on whether the "cooldown stage" occurs
+ * or not:
+ *
+ * - no cooldown stage: function is called immediately, and it starts
+ * the cooldown stage when successful.
+ * - in cooldown stage: function is called when the cooldown stage has
+ * ended from timeout.
+ *
+ * Note that after the cooldown stage, only the last attempted function
+ * call will be considered.
+ *
+ * @param {...any} args
+ * @throws {ThrottleReinvokedError|ThrottleCanceledError}
+ * @returns {any} result of called function, if it's called.
+ */
+ async do(...args) {
+ if (!this._isCoolingDown) {
+ return this._invokeFunction(...args);
+ }
+ if (this._pendingInvokeDeferred) {
+ this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id));
+ }
+ try {
+ this._pendingInvokeDeferred = makeDeferred();
+ await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]);
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ throw error;
+ }
+ } finally {
+ this._pendingInvokeDeferred = undefined;
+ }
+ return this._invokeFunction(...args);
+ }
+
+ /**
+ * Flush the internal throttle timer, so that the following function call
+ * is immediate. For instance, if there is a cooldown stage, it is aborted.
+ */
+ flush() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ const coolingDownDeferred = this._coolingDownDeferred;
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ coolingDownDeferred.reject(new _ThrottleFlushedError(this.id));
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Invoke the inner function of this throttle and starts cooling down phase
+ * immediately after.
+ *
+ * @private
+ * @param {...any} args
+ */
+ _invokeFunction(...args) {
+ const res = this._function(...args);
+ this._startCoolingDown();
+ return res;
+ }
+
+ /**
+ * Called just when the inner function is being called. Starts the cooling
+ * down phase, which turn any call to this throttle function as pending
+ * inner function calls. This will be called after the end of cooling down
+ * phase (except if canceled).
+ */
+ async _startCoolingDown() {
+ if (this._coolingDownDeferred) {
+ throw new Error("Cannot start cooling down if there's already a cooling down in progress.");
+ }
+ // Keep local reference of cooling down deferred, because the one stored
+ // on `this` could be overwritten by another call to this throttle.
+ const coolingDownDeferred = makeDeferred();
+ this._coolingDownDeferred = coolingDownDeferred;
+ this._isCoolingDown = true;
+ const cooldownTimeoutId = this.env.browser.setTimeout(
+ () => coolingDownDeferred.resolve(),
+ this._duration
+ );
+ let unexpectedError;
+ try {
+ await coolingDownDeferred;
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ // This branching should never happen.
+ // Still defined in case of programming error.
+ unexpectedError = error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(cooldownTimeoutId);
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ }
+ if (unexpectedError) {
+ throw unexpectedError;
+ }
+ }
+
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * A function that creates a cancelable, flushable and clearable throttle
+ * version of a provided function. See definitions at the top of this file.
+ *
+ * This throttle mechanism allows calling a function at most once during a
+ * certain period:
+ *
+ * - When a function call is made, it enters a 'cooldown' phase, in which any
+ * attempt to call the function is buffered until the cooldown phase ends.
+ * - At most 1 function call can be buffered during the cooldown phase, and the
+ * latest one in this phase will be considered at its end.
+ * - When a cooldown phase ends, any buffered function call will be performed
+ * and another cooldown phase will follow up.
+ *
+ * @param {Object} env the OWL env
+ * @param {function} func the function to throttle.
+ * @param {integer} duration duration, in milliseconds, of the cooling down
+ * phase of the throttling.
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller
+ * of throttle function will observe some errors that come from current
+ * throttle call that has been canceled, such as when throttle function has
+ * been explicitly canceled with `.cancel()` or when another new throttle call
+ * has been registered.
+ * @see ThrottleCanceledError for when a call has been canceled from explicit
+ * call.
+ * @see ThrottleReinvokedError for when a call has been canceled from another
+ * new throttle call has been registered.
+ * @returns {function} the cancelable, flushable and clearable throttle version
+ * of the provided function.
+ */
+function throttle(
+ env,
+ func,
+ duration,
+ { silentCancelationErrors = true } = {}
+) {
+ const throttleObj = new Throttle(env, func, duration);
+ const callable = async (...args) => {
+ try {
+ // await is important, otherwise errors are not intercepted.
+ return await throttleObj.do(...args);
+ } catch (error) {
+ const isSelfReinvokedError = (
+ error instanceof ThrottleReinvokedError &&
+ error.throttleId === throttleObj.id
+ );
+ const isSelfCanceledError = (
+ error instanceof ThrottleCanceledError &&
+ error.throttleId === throttleObj.id
+ );
+
+ if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) {
+ // Silently ignore cancelation errors.
+ // Promise is indefinitely pending for async functions.
+ return new Promise(() => {});
+ } else {
+ throw error;
+ }
+ }
+ };
+ Object.assign(callable, {
+ cancel: () => throttleObj.cancel(),
+ clear: () => throttleObj.clear(),
+ flush: () => throttleObj.flush(),
+ });
+ return callable;
+}
+
+/**
+ * Make external throttle errors accessible from throttle function.
+ */
+Object.assign(throttle, {
+ ThrottleReinvokedError,
+ ThrottleCanceledError,
+});
+
+
+return throttle;
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js
new file mode 100644
index 00000000..d3e6ad66
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle_tests.js
@@ -0,0 +1,407 @@
+odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+
+const { ThrottleReinvokedError, ThrottleCanceledError } = throttle;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('throttle', {}, function () {
+QUnit.module('throttle_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+ this.throttles = [];
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ for (const t of this.throttles) {
+ t.clear();
+ }
+ afterEach(this);
+ },
+});
+
+QUnit.test('single call', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasInvokedFunc = false;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ hasInvokedFunc = true;
+ return 'func_result';
+ },
+ 0
+ );
+ this.throttles.push(throttledFunc);
+
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked on immediate throttle initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked from throttle initialization after 0ms"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke');
+ assert.strictEqual(
+ res,
+ 'func_result',
+ "throttle call return should forward result of inner func"
+ );
+ });
+ await nextTick();
+ assert.ok(
+ hasInvokedFunc,
+ "func should have been immediately invoked on first throttle call"
+ );
+ assert.verifySteps(
+ ['throttle_observed_invoke'],
+ "throttle should have observed invoked on first throttle call"
+ );
+});
+
+QUnit.test('2nd (throttled) call', async function (assert) {
+ assert.expect(8);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ res,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('throttled call reinvocation', async function (assert) {
+ assert.expect(11);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleReinvokedError,
+ "Should generate a Throttle reinvoked error (from another throttle function call)"
+ );
+ assert.step('throttle_reinvoked_1');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc()
+ .then(result => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ result,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_reinvoked_1'],
+ "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)"
+ );
+});
+
+QUnit.test('flush throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_2'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(10);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.flush();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked from 2nd call after flush"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('cancel throttled call', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.cancel()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.cancel();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)"
+ );
+});
+
+QUnit.test('clear throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.clear()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.clear();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled (from `.clear()`)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)"
+ );
+});
+
+});
+});
+});
+
+});