[IMP] mail: introduce Throttle model
Task-2831082 closes odoo/odoo#91502 Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear, insert, insertAndReplace, insertAndUnlink, link, replace, unlink } from '@mail/model/model_field_command';
|
||||
import { OnChange } from '@mail/model/model_onchange';
|
||||
import throttle from '@mail/utils/throttle';
|
||||
import { cleanSearchTerm } from '@mail/utils/utils';
|
||||
import * as mailUtils from '@mail/js/utils';
|
||||
|
||||
@@ -26,7 +25,6 @@ registerModel({
|
||||
identifyingFields: ['model', 'id'],
|
||||
lifecycleHooks: {
|
||||
_willDelete() {
|
||||
this.throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
if (this.isTemporary) {
|
||||
for (const message of this.messages) {
|
||||
message.delete();
|
||||
@@ -914,15 +912,17 @@ registerModel({
|
||||
});
|
||||
// Manage typing member relation.
|
||||
const currentPartner = this.messaging.currentPartner;
|
||||
const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
|
||||
.filter(localId => localId !== currentPartner.localId);
|
||||
newOrderedTypingMemberLocalIds.push(currentPartner.localId);
|
||||
const newOrderedTypingMembers = [
|
||||
...this.orderedTypingMembers.filter(member => member !== currentPartner),
|
||||
currentPartner,
|
||||
];
|
||||
this.update({
|
||||
orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
|
||||
isCurrentPartnerTyping: true,
|
||||
orderedTypingMembers: replace(newOrderedTypingMembers),
|
||||
typingMembers: link(currentPartner),
|
||||
});
|
||||
// Notify typing status to other members.
|
||||
await this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
|
||||
await this.throttleNotifyCurrentPartnerTypingStatus.do();
|
||||
},
|
||||
/**
|
||||
* Called to register a new other member partner that is typing
|
||||
@@ -932,11 +932,12 @@ registerModel({
|
||||
*/
|
||||
registerOtherMemberTypingMember(partner) {
|
||||
this.update({ otherMembersLongTypingTimers: insert({ partner: replace(partner) }) });
|
||||
const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
|
||||
.filter(localId => localId !== partner.localId);
|
||||
newOrderedTypingMemberLocalIds.push(partner.localId);
|
||||
const newOrderedTypingMembers = [
|
||||
...this.orderedTypingMembers.filter(member => member !== partner),
|
||||
partner,
|
||||
];
|
||||
this.update({
|
||||
orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
|
||||
orderedTypingMembers: replace(newOrderedTypingMembers),
|
||||
typingMembers: link(partner),
|
||||
});
|
||||
},
|
||||
@@ -1005,17 +1006,17 @@ registerModel({
|
||||
});
|
||||
// Manage typing member relation.
|
||||
const currentPartner = this.messaging.currentPartner;
|
||||
const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
|
||||
.filter(localId => localId !== currentPartner.localId);
|
||||
const newOrderedTypingMembers = this.orderedTypingMembers.filter(member => member !== currentPartner);
|
||||
this.update({
|
||||
orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
|
||||
isCurrentPartnerTyping: false,
|
||||
orderedTypingMembers: replace(newOrderedTypingMembers),
|
||||
typingMembers: unlink(currentPartner),
|
||||
});
|
||||
// Notify typing status to other members.
|
||||
if (immediateNotify) {
|
||||
this.throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
}
|
||||
this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: false });
|
||||
this.throttleNotifyCurrentPartnerTypingStatus.do();
|
||||
},
|
||||
/**
|
||||
* Called to unregister an other member partner that is no longer typing
|
||||
@@ -1025,10 +1026,9 @@ registerModel({
|
||||
*/
|
||||
unregisterOtherMemberTypingMember(partner) {
|
||||
this.update({ otherMembersLongTypingTimers: insertAndUnlink({ partner: replace(partner) }) });
|
||||
const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
|
||||
.filter(localId => localId !== partner.localId);
|
||||
const newOrderedTypingMembers = this.orderedTypingMembers.filter(member => member !== partner);
|
||||
this.update({
|
||||
orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
|
||||
orderedTypingMembers: replace(newOrderedTypingMembers),
|
||||
typingMembers: unlink(partner),
|
||||
});
|
||||
},
|
||||
@@ -1116,6 +1116,9 @@ registerModel({
|
||||
if (!this.isPinned) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.messaging.discuss) {
|
||||
return clear();
|
||||
}
|
||||
const discussSidebarCategory = this._getDiscussSidebarCategory();
|
||||
if (!discussSidebarCategory) {
|
||||
return clear();
|
||||
@@ -1508,18 +1511,6 @@ registerModel({
|
||||
member => member !== this.messaging.currentPartner
|
||||
));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Partner[]}
|
||||
*/
|
||||
_computeOrderedTypingMembers() {
|
||||
return [[
|
||||
'replace',
|
||||
this.orderedTypingMemberLocalIds
|
||||
.map(localId => this.messaging.models['Partner'].get(localId))
|
||||
.filter(member => !!member),
|
||||
]];
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Activity[]}
|
||||
@@ -1542,11 +1533,9 @@ registerModel({
|
||||
* @returns {Throttle}
|
||||
*/
|
||||
_computeThrottleNotifyCurrentPartnerTypingStatus() {
|
||||
return throttle(
|
||||
this.messaging,
|
||||
({ isTyping }) => this._notifyCurrentPartnerTypingStatus({ isTyping }),
|
||||
2.5 * 1000
|
||||
);
|
||||
return insertAndReplace({
|
||||
func: () => this._notifyCurrentPartnerTypingStatus(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -1627,31 +1616,29 @@ registerModel({
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {boolean} param0.isTyping
|
||||
*/
|
||||
async _notifyCurrentPartnerTypingStatus({ isTyping }) {
|
||||
async _notifyCurrentPartnerTypingStatus() {
|
||||
if (
|
||||
this.forceNotifyNextCurrentPartnerTypingStatus ||
|
||||
isTyping !== this.currentPartnerLastNotifiedIsTyping
|
||||
this.isCurrentPartnerTyping !== this.currentPartnerLastNotifiedIsTyping
|
||||
) {
|
||||
if (this.model === 'mail.channel') {
|
||||
await this.messaging.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'notify_typing',
|
||||
args: [this.id],
|
||||
kwargs: { is_typing: isTyping },
|
||||
kwargs: { is_typing: this.isCurrentPartnerTyping },
|
||||
}, { shadow: true });
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isTyping && this.currentPartnerLongTypingTimer) {
|
||||
if (this.isCurrentPartnerTyping && this.currentPartnerLongTypingTimer) {
|
||||
this.update({ currentPartnerLongTypingTimer: [clear(), insertAndReplace()] });
|
||||
}
|
||||
}
|
||||
this.update({
|
||||
currentPartnerLastNotifiedIsTyping: isTyping,
|
||||
currentPartnerLastNotifiedIsTyping: this.isCurrentPartnerTyping,
|
||||
forceNotifyNextCurrentPartnerTypingStatus: false,
|
||||
});
|
||||
},
|
||||
@@ -1795,9 +1782,12 @@ registerModel({
|
||||
* Immediately notify other members that he/she is still typing.
|
||||
*/
|
||||
async onCurrentPartnerLongTypingTimeout() {
|
||||
this.update({ forceNotifyNextCurrentPartnerTypingStatus: true });
|
||||
this.update({
|
||||
isCurrentPartnerTyping: true,
|
||||
forceNotifyNextCurrentPartnerTypingStatus: true,
|
||||
});
|
||||
this.throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
await this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
|
||||
await this.throttleNotifyCurrentPartnerTypingStatus.do();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
@@ -2052,6 +2042,9 @@ registerModel({
|
||||
compute: '_computeIsCurrentPartnerFollowing',
|
||||
default: false,
|
||||
}),
|
||||
isCurrentPartnerTyping: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States whether this thread description is editable by the current user.
|
||||
*/
|
||||
@@ -2248,19 +2241,9 @@ registerModel({
|
||||
compute: '_computeOrderedOtherTypingMembers',
|
||||
}),
|
||||
/**
|
||||
* Ordered typing members on this thread. Lower index means this member
|
||||
* is currently typing for the longest time. This list includes current
|
||||
* partner as typer.
|
||||
* Ordered list of typing members.
|
||||
*/
|
||||
orderedTypingMembers: many('Partner', {
|
||||
compute: '_computeOrderedTypingMembers',
|
||||
}),
|
||||
/**
|
||||
* Technical attribute to manage ordered list of typing members.
|
||||
*/
|
||||
orderedTypingMemberLocalIds: attr({
|
||||
default: [],
|
||||
}),
|
||||
orderedTypingMembers: many('Partner'),
|
||||
originThreadAttachments: many('Attachment', {
|
||||
inverse: 'originThread',
|
||||
isCausal: true,
|
||||
@@ -2405,8 +2388,10 @@ registerModel({
|
||||
*
|
||||
* @see _notifyCurrentPartnerTypingStatus
|
||||
*/
|
||||
throttleNotifyCurrentPartnerTypingStatus: attr({
|
||||
throttleNotifyCurrentPartnerTypingStatus: one('Throttle', {
|
||||
compute: '_computeThrottleNotifyCurrentPartnerTypingStatus',
|
||||
inverse: 'threadAsThrottleNotifyCurrentPartnerTypingStatus',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* States the `Activity` that belongs to `this` and that are due
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear, insertAndReplace } from '@mail/model/model_field_command';
|
||||
|
||||
/**
|
||||
* This model defines a "Throttle", which is an abstraction to throttle calls on a
|
||||
* provided function. Such throttled calls can be cleared, i.e it can simply stop
|
||||
* cooling down phase and clear pending func invocation. This allows having throttled
|
||||
* calls most of the time, and a few priviledged exception to immediately make the call
|
||||
* are re-trigger a cooldown like a fresh throttle call.
|
||||
*/
|
||||
registerModel({
|
||||
name: 'Throttle',
|
||||
identifyingFields: [[
|
||||
'threadAsThrottleNotifyCurrentPartnerTypingStatus',
|
||||
]],
|
||||
recordMethods: {
|
||||
/**
|
||||
* Clear any buffered function call and immediately terminates any cooling
|
||||
* down phase in progress.
|
||||
*/
|
||||
clear() {
|
||||
this.update({
|
||||
cooldownTimer: clear(),
|
||||
shouldInvoke: false,
|
||||
});
|
||||
},
|
||||
async do() {
|
||||
if (!this.cooldownTimer) {
|
||||
this.func();
|
||||
this.update({ cooldownTimer: insertAndReplace() });
|
||||
} else {
|
||||
this.update({ shouldInvoke: true });
|
||||
}
|
||||
},
|
||||
onTimeout() {
|
||||
if (this.shouldInvoke) {
|
||||
this.func();
|
||||
}
|
||||
this.update({ shouldInvoke: false });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {integer|FieldCommand}
|
||||
*/
|
||||
_computeDuration() {
|
||||
if (this.threadAsThrottleNotifyCurrentPartnerTypingStatus) {
|
||||
return 2.5 * 1000;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
cooldownTimer: one('Timer', {
|
||||
inverse: 'throttleOwner',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* Duration, in milliseconds, of the cool down phase.
|
||||
*/
|
||||
duration: attr({
|
||||
compute: '_computeDuration',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* Inner function to be invoked and throttled.
|
||||
*/
|
||||
func: attr(),
|
||||
shouldInvoke: attr({
|
||||
default: false,
|
||||
}),
|
||||
threadAsThrottleNotifyCurrentPartnerTypingStatus: one('Thread', {
|
||||
inverse: 'throttleNotifyCurrentPartnerTypingStatus',
|
||||
readonly: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,7 @@ registerModel({
|
||||
'otherMemberLongTypingInThreadTimerOwner',
|
||||
'threadAsCurrentPartnerInactiveTypingTimerOwner',
|
||||
'threadAsCurrentPartnerLongTypingTimerOwner',
|
||||
'throttleOwner',
|
||||
]],
|
||||
lifecycleHooks: {
|
||||
_willDelete() {
|
||||
@@ -31,6 +32,9 @@ registerModel({
|
||||
if (this.otherMemberLongTypingInThreadTimerOwner) {
|
||||
return 60 * 1000;
|
||||
}
|
||||
if (this.throttleOwner) {
|
||||
return this.throttleOwner.duration;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
@@ -71,6 +75,10 @@ registerModel({
|
||||
this.otherMemberLongTypingInThreadTimerOwner.onOtherMemberLongTypingTimeout();
|
||||
return;
|
||||
}
|
||||
if (this.throttleOwner) {
|
||||
this.throttleOwner.onTimeout();
|
||||
return;
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
@@ -96,6 +104,10 @@ registerModel({
|
||||
inverse: 'currentPartnerLongTypingTimer',
|
||||
readonly: true,
|
||||
}),
|
||||
throttleOwner: one('Throttle', {
|
||||
inverse: 'cooldownTimer',
|
||||
readonly: true,
|
||||
}),
|
||||
/**
|
||||
* Internal reference of `setTimeout()` that is used to invoke function
|
||||
* when timer times out. Useful to clear it when timer is cleared/reset.
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { makeDeferred } from '@mail/utils/deferred';
|
||||
|
||||
/**
|
||||
* 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 {Messaging} messaging the messaging record.
|
||||
* @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(messaging, func, duration) {
|
||||
/**
|
||||
* Reference to the messaging record. Useful to fine-tune control of
|
||||
* time flow in tests.
|
||||
* @see mail/static/src/utils/test_utils.js:start.hasTimeControl
|
||||
*/
|
||||
this.messaging = messaging;
|
||||
/**
|
||||
* 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.messaging.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.messaging.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 {Messaging} messaging the messaging record.
|
||||
* @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(
|
||||
messaging,
|
||||
func,
|
||||
duration,
|
||||
{ silentCancelationErrors = true } = {}
|
||||
) {
|
||||
const throttleObj = new Throttle(messaging, 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,
|
||||
});
|
||||
|
||||
|
||||
export default throttle;
|
||||
@@ -34,6 +34,14 @@ registerModel({
|
||||
inverse: 'qunitTest',
|
||||
isCausal: true,
|
||||
}),
|
||||
throttle1: one('Throttle', {
|
||||
inverse: 'qunitTestOwner1',
|
||||
isCausal: true,
|
||||
}),
|
||||
throttle2: one('Throttle', {
|
||||
inverse: 'qunitTestOwner2',
|
||||
isCausal: true,
|
||||
}),
|
||||
timer1: one('Timer', {
|
||||
inverse: 'qunitTestOwner1',
|
||||
isCausal: true,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { addFields, patchIdentifyingFields, patchRecordMethods } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
// ensure that the model definition is loaded before the patch
|
||||
import '@mail/models/throttle';
|
||||
|
||||
addFields('Throttle', {
|
||||
qunitTestOwner1: one('QUnitTest', {
|
||||
inverse: 'throttle1',
|
||||
readonly: true,
|
||||
}),
|
||||
qunitTestOwner2: one('QUnitTest', {
|
||||
inverse: 'throttle2',
|
||||
readonly: true,
|
||||
}),
|
||||
});
|
||||
|
||||
patchIdentifyingFields('Throttle', identifyingFields => {
|
||||
identifyingFields[0].push('qunitTestOwner1');
|
||||
identifyingFields[0].push('qunitTestOwner2');
|
||||
});
|
||||
|
||||
patchRecordMethods('Throttle', {
|
||||
_computeDuration() {
|
||||
if (this.qunitTestOwner1) {
|
||||
return 0;
|
||||
}
|
||||
if (this.qunitTestOwner2) {
|
||||
return 1000;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
});
|
||||
@@ -1,45 +1,25 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { start } from '@mail/../tests/helpers/test_utils';
|
||||
import throttle from '@mail/utils/throttle';
|
||||
import { insertAndReplace } from '@mail/model/model_field_command';
|
||||
import { nextTick } from '@mail/utils/utils';
|
||||
|
||||
const { ThrottleReinvokedError, ThrottleCanceledError } = throttle;
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
QUnit.module('utils', {}, function () {
|
||||
QUnit.module('throttle', {}, function () {
|
||||
QUnit.module('throttle_tests.js', {
|
||||
async beforeEach() {
|
||||
this.throttles = [];
|
||||
},
|
||||
afterEach() {
|
||||
// Important: tests should cleanly intercept cancelation errors that
|
||||
// may result from this teardown.
|
||||
for (const t of this.throttles) {
|
||||
t.clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
QUnit.module('throttle_tests.js', {});
|
||||
|
||||
QUnit.test('single call', async function (assert) {
|
||||
assert.expect(6);
|
||||
assert.expect(3);
|
||||
|
||||
const { advanceTime, messaging } = await start({
|
||||
hasTimeControl: true,
|
||||
});
|
||||
|
||||
let hasInvokedFunc = false;
|
||||
const throttledFunc = throttle(
|
||||
messaging,
|
||||
() => {
|
||||
hasInvokedFunc = true;
|
||||
return 'func_result';
|
||||
},
|
||||
0
|
||||
);
|
||||
this.throttles.push(throttledFunc);
|
||||
|
||||
const throttle = messaging.models['Throttle'].insert({
|
||||
func: () => hasInvokedFunc = true,
|
||||
qunitTestOwner1: insertAndReplace(),
|
||||
});
|
||||
assert.notOk(
|
||||
hasInvokedFunc,
|
||||
"func should not have been invoked on immediate throttle initialization"
|
||||
@@ -51,340 +31,141 @@ QUnit.test('single call', async function (assert) {
|
||||
"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"
|
||||
);
|
||||
});
|
||||
throttle.do();
|
||||
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);
|
||||
assert.expect(4);
|
||||
|
||||
const { advanceTime, messaging } = await start({
|
||||
hasTimeControl: true,
|
||||
});
|
||||
|
||||
let funcCalledAmount = 0;
|
||||
const throttledFunc = throttle(
|
||||
messaging,
|
||||
() => {
|
||||
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"
|
||||
);
|
||||
const throttle = messaging.models['Throttle'].insert({
|
||||
func: () => funcCalledAmount++,
|
||||
qunitTestOwner2: insertAndReplace(),
|
||||
});
|
||||
throttle.do();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
['throttle_observed_invoke_1'],
|
||||
"inner function of throttle should have been invoked on 1st call (immediate return)"
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"throttle call return should forward result of inner func 1"
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
throttle.do();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
[],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
|
||||
);
|
||||
|
||||
await advanceTime(999);
|
||||
assert.verifySteps(
|
||||
[],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
|
||||
);
|
||||
|
||||
await 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)"
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
2,
|
||||
"throttle call return should forward result of inner func 2"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('throttled call reinvocation', async function (assert) {
|
||||
assert.expect(11);
|
||||
assert.expect(4);
|
||||
|
||||
const { advanceTime, messaging } = await start({
|
||||
hasTimeControl: true,
|
||||
});
|
||||
|
||||
let funcCalledAmount = 0;
|
||||
const throttledFunc = throttle(
|
||||
messaging,
|
||||
() => {
|
||||
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"
|
||||
);
|
||||
const throttle = messaging.models['Throttle'].insert({
|
||||
func: () => funcCalledAmount++,
|
||||
qunitTestOwner2: insertAndReplace(),
|
||||
});
|
||||
throttle.do();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
['throttle_observed_invoke_1'],
|
||||
"inner function of throttle should have been invoked on 1st call (immediate return)"
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"throttle call return should forward result of inner func 1"
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
throttle.do();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
[],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
|
||||
);
|
||||
|
||||
await advanceTime(999);
|
||||
assert.verifySteps(
|
||||
[],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"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"
|
||||
);
|
||||
});
|
||||
throttle.do();
|
||||
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 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);
|
||||
|
||||
const { advanceTime, messaging } = await start({
|
||||
hasTimeControl: true,
|
||||
});
|
||||
|
||||
const throttledFunc = throttle(
|
||||
messaging,
|
||||
() => {},
|
||||
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 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 advanceTime(999);
|
||||
assert.verifySteps(
|
||||
[],
|
||||
"inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
|
||||
);
|
||||
|
||||
await 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);
|
||||
|
||||
const { advanceTime, messaging } = await start({
|
||||
hasTimeControl: true,
|
||||
});
|
||||
|
||||
const throttledFunc = throttle(
|
||||
messaging,
|
||||
() => {},
|
||||
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 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 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)"
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
2,
|
||||
"throttle call return should forward result of inner func 2"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('clear throttled call', async function (assert) {
|
||||
assert.expect(9);
|
||||
assert.expect(4);
|
||||
|
||||
const { advanceTime, messaging } = await start({
|
||||
hasTimeControl: true,
|
||||
});
|
||||
|
||||
const throttledFunc = throttle(
|
||||
messaging,
|
||||
() => {},
|
||||
1000,
|
||||
{ silentCancelationErrors: false }
|
||||
);
|
||||
this.throttles.push(throttledFunc);
|
||||
|
||||
throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
|
||||
let funcCalledAmount = 0;
|
||||
const throttle = messaging.models['Throttle'].insert({
|
||||
func: () => funcCalledAmount++,
|
||||
qunitTestOwner2: insertAndReplace(),
|
||||
});
|
||||
throttle.do();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
['throttle_observed_invoke_1'],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
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');
|
||||
});
|
||||
throttle.do();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
[],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
|
||||
);
|
||||
|
||||
await advanceTime(500);
|
||||
assert.verifySteps(
|
||||
[],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
1,
|
||||
"inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
|
||||
);
|
||||
|
||||
throttledFunc.clear();
|
||||
throttle.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'));
|
||||
throttle.do();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
['throttle_observed_invoke_3'],
|
||||
assert.strictEqual(
|
||||
funcCalledAmount,
|
||||
2,
|
||||
"3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user