Skip to content

Commit

Permalink
Update IdleQueue with yield thresholds
Browse files Browse the repository at this point in the history
  • Loading branch information
philipwalton committed Aug 23, 2018
1 parent 2f18a38 commit 8bd569f
Show file tree
Hide file tree
Showing 9 changed files with 385 additions and 111 deletions.
79 changes: 42 additions & 37 deletions lib/idle-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

import {cIC, isSafari, now, queueMicrotask, rIC} from './utilities';


const DEFAULT_MIN_TASK_TIME = 0;

/**
* A class wraps a queue of requestIdleCallback functions for two reasons:
* 1. So other callers can know whether or not the queue is empty.
Expand All @@ -26,16 +29,18 @@ export default class IdleQueue {
/**
* Creates the IdleQueue instance and adds lifecycle event listeners to
* run the queue if the page is hidden (with fallback behavior for Safari).
* @param {!{defaultMinTaskTime: (number|undefined)}=} param1
*/
constructor() {
constructor({defaultMinTaskTime} = {}) {
this.idleCallbackHandle_ = null;
this.taskQueue_ = [];
this.isProcessing_ = false;
this.defaultMinTaskTime_ = defaultMinTaskTime || DEFAULT_MIN_TASK_TIME;

// Bind methods
this.onVisibilityChange_ = this.onVisibilityChange_.bind(this);
this.onBeforeUnload_ = this.onBeforeUnload_.bind(this);
this.processTasksImmediately = this.processTasksImmediately.bind(this);
this.processTasks_ = this.processTasks_.bind(this);
this.onVisibilityChange_ = this.onVisibilityChange_.bind(this);

addEventListener('visibilitychange', this.onVisibilityChange_, true);

Expand All @@ -47,33 +52,43 @@ export default class IdleQueue {
// NOTE: we only add this to Safari because adding it to Firefox would
// prevent the page from being eligible for bfcache.
if (isSafari()) {
addEventListener('beforeunload', this.onBeforeUnload_, true);
addEventListener('beforeunload', this.processTasksImmediately, true);
}
}

/**
* @param {!Array<!Function>|!Function} tasks
* @param {!Function} task
* @return {!IdleQueue}
*/
add(tasks) {
// Support single functions or arrays of functions.
if (typeof tasks === 'function') tasks = [tasks];

add(task, {minTaskTime = this.defaultMinTaskTime_} = {}) {
const state = {
time: now(),
visibilityState: document.visibilityState,
};

for (const task of tasks) {
this.taskQueue_.push({state, task});
}
this.taskQueue_.push({state, task, minTaskTime});

this.scheduleTaskProcessing_();

// For chaining.
return this;
}

/**
* Processes all scheduled tasks synchronously.
*/
processTasksImmediately() {
// By not passing a deadline, all tasks will be processed sync.
this.processTasks_();
}

/**
* @return {boolean}
*/
hasPendingTasks() {
return this.taskQueue_.length > 0;
}

/**
* Destroys the instance by unregistering all added event listeners and
* removing any overridden methods.
Expand All @@ -93,7 +108,7 @@ export default class IdleQueue {
// prevent the page from being eligible for bfcache.
if (isSafari()) {
removeEventListener(
'beforeunload', this.onBeforeUnload_, true);
'beforeunload', this.processTasksImmediately, true);
}
}

Expand Down Expand Up @@ -127,19 +142,18 @@ export default class IdleQueue {
if (!this.isProcessing_) {
this.isProcessing_ = true;

// Process tasks until there's none left or we need to yield to input.
while (this.taskQueue_.length > 0 && !shouldYield(deadline)) {
// Process tasks until there's no time left or we need to yield to input.
while (this.hasPendingTasks() &&
!shouldYield(deadline, this.taskQueue_[0].minTaskTime)) {
const {task, state} = this.taskQueue_.shift();

// Expose the current state to external code.
// this.state_ = this;

task(state);
}
// this.state_ = null;

this.isProcessing_ = false;

if (this.taskQueue_.length > 0) {
if (this.hasPendingTasks()) {
// Schedule the rest of the tasks for the next idle time.
this.scheduleTaskProcessing_();
}
}
Expand All @@ -159,30 +173,21 @@ export default class IdleQueue {
*/
onVisibilityChange_() {
if (document.visibilityState === 'hidden') {
this.processTasks_();
this.processTasksImmediately();
}
}

/**
* A callback for the `beforeunload` event than runs all pending callbacks
* immediately. The reason this is used instead of adding `processTasks_`
* directly is we can't invoke `processTasks_` with an `Event` object.
*/
onBeforeUnload_() {
this.processTasks_();
}
}

/**
* Returns true if the IdleDealine object exists and shows no time remaining.
* Otherwise returns false.
* Returns true if the IdleDealine object exists and the remaining time is
* less or equal to than the minTaskTime. Otherwise returns false.
* @param {IdleDeadline|undefined} deadline
* @param {number} minTaskTime
* @return {boolean}
*/
const shouldYield = (deadline) => {
if (!deadline) {
return false;
} else {
return deadline.timeRemaining() === 0;
const shouldYield = (deadline, minTaskTime) => {
if (deadline && deadline.timeRemaining() <= minTaskTime) {
return true;
}
return false;
};
9 changes: 1 addition & 8 deletions lib/plugins/max-scroll-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ class MaxScrollTracker {
this.tracker = tracker;

// Binds methods to `this`.
this.init = this.init.bind(this);
this.handleScroll = debounce(this.handleScroll.bind(this), 500);
this.trackerSetOverride = this.trackerSetOverride.bind(this);

Expand All @@ -70,17 +69,11 @@ class MaxScrollTracker {
tracker, this.opts.sessionTimeout, this.opts.timeZone);

// Queue the rest of the initialization of the plugin idly.
this.queue = TrackerQueue.getOrCreate(trackingId).add(this.init);
}
this.queue = TrackerQueue.getOrCreate(trackingId);

/**
* Idly initializes the rest of the plugin instance initialization logic.
*/
init() {
this.listenForMaxScrollChanges();
}


/**
* Adds a scroll event listener if the max scroll percentage for the
* current page isn't already at 100%.
Expand Down
42 changes: 31 additions & 11 deletions lib/plugins/url-change-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import MethodChain from '../method-chain';
import provide from '../provide';
import TrackerQueue from '../tracker-queue';
import {plugins, trackUsage} from '../usage';
import {assign, createFieldsObj, now} from '../utilities';
import {assign, createFieldsObj, now, queueMicrotask} from '../utilities';


/**
Expand Down Expand Up @@ -112,31 +112,51 @@ class UrlChangeTracker {
handleUrlChange(historyDidUpdate) {
// Call the update logic asychronously to help ensure that app logic
// responding to the URL change happens prior to this.
this.queue.add(({time}) => {
queueMicrotask(() => {
const oldPath = this.path;
const newPath = getPath();

if (oldPath != newPath &&
this.opts.shouldTrackUrlChange.call(this, newPath, oldPath)) {
this.path = newPath;
this.tracker.set({

/** @type {FieldsObj} */
const newFields = {
page: newPath,
title: document.title,
});
};

this.tracker.set(newFields);

if (historyDidUpdate || this.opts.trackReplaceState) {
/** @type {FieldsObj} */
const defaultFields = {
transport: 'beacon',
queueTime: now() - time,
};
this.tracker.send('pageview', createFieldsObj(defaultFields,
this.opts.fieldsObj, this.tracker, this.opts.hitFilter));
// Pass the new fields here in addition to setting them above
// on the off-chance that another URL change happens before this
// one gets sent.
this.sendPageview(newFields);
}
}
});
}

/**
* Sends a pageview hit when idle.
* @param {!FieldsObj} fieldsObj
*/
sendPageview(fieldsObj) {
this.queue.add(({time}) => {
/** @type {FieldsObj} */
const defaultFields = {
transport: 'beacon',
page: fieldsObj.page,
title: fieldsObj.title,
queueTime: now() - time,
};

this.tracker.send('pageview', createFieldsObj(defaultFields,
this.opts.fieldsObj, this.tracker, this.opts.hitFilter));
});
}

/**
* Determines whether or not the tracker should send a hit with the new page
* data. This default implementation can be overrided in the config options.
Expand Down
11 changes: 10 additions & 1 deletion lib/tracker-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,16 @@ export default class TrackerQueue extends IdleQueue {
* @param {string} trackingId
*/
constructor(trackingId) {
super();
// If an idle callback is being run in between frame rendering, it'll
// have an initial `timeRemaining()` value <= 16ms. If it's run when
// no frames are being rendered, it'll have an initial
// `timeRemaining()` <= 50ms. Since all the tasks queued by autotrack
// are non-critial and non-UI-related, we do not want our tasks to
// interfere with frame rendering, and therefore by default we pick a
// `defaultMinTaskTime` value > 16ms, so tasks are always processed
// outside of frame rendering.
super({defaultMinTaskTime: 25});

this.trackingId_ = trackingId;
}

Expand Down
29 changes: 22 additions & 7 deletions lib/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,26 @@ export function isSafari() {
return isSafari_;
}


/**
* A minimal shim of the native IdleDeadline class.
*/
class IdleDealine {
/** @param {number} initTime */
constructor(initTime) {
this.initTime_ = initTime;
}
/** @return {boolean} */
get didTimeout() {
return false;
}
/** @return {number} */
timeRemaining() {
return Math.max(0, 50 - (now() - this.initTime_));
}
}


/**
* A minimal shim for the requestIdleCallback function. This accepts a
* callback function and runs it at the next idle period, passing in an
Expand All @@ -265,13 +285,8 @@ export function isSafari() {
* @return {number}
*/
const requestIdleCallbackShim = (callback) => {
let startTime = +new Date;
return setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (new Date - startTime)),
});
}, 0);
const deadline = new IdleDealine(now());
return setTimeout(() => callback(deadline), 0);
};


Expand Down
35 changes: 29 additions & 6 deletions test/e2e/max-scroll-tracker-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,12 @@ describe('maxScrollTracker', function() {

corruptSession();

// Scrolling here should clear the corrupt data but not send a hit.
// Scrolling here should reset the session data and send a scroll event
// as if this were a new session.
browser.scroll(0, (PAGE_HEIGHT - WINDOW_HEIGHT) * .5);
browser.pause(DEBOUNCE_TIMEOUT);
browser.waitUntil(log.hitCountEquals(2));

browser.scroll(0, (PAGE_HEIGHT - WINDOW_HEIGHT) * .75);
browser.pause(DEBOUNCE_TIMEOUT);

browser.waitUntil(log.hitCountEquals(3));

const hits = log.getHits();
Expand Down Expand Up @@ -363,9 +362,8 @@ function expireSession() {
* store data gets out of sync with the session store.
*/
function corruptSession() {
setStoreData('autotrack:UA-12345-1:session', {
updateStoreData('autotrack:UA-12345-1:session', {
id: 'new-id',
hitTime: +new Date,
isExpired: false,
});
}
Expand Down Expand Up @@ -394,3 +392,28 @@ function setStoreData(key, value) {
}
}, key, value);
}


/**
* Merges an object with the data in an existing store.
* @param {string} key
* @param {!Object} value
*/
function updateStoreData(key, value) {
browser.execute((key, value) => {
const oldValue = window.localStorage.getItem(key);
const newValue = JSON.stringify(Object.assign(JSON.parse(oldValue), value));

// IE11 doesn't support event constructors.
try {
// Set the value on localStorage so it triggers the storage event in
// other tabs. Also, manually dispatch the event in this tab since just
// writing to localStorage won't update the locally cached values.
window.localStorage.setItem(key, newValue);
window.dispatchEvent(
new StorageEvent('storage', {key, oldValue, newValue}));
} catch (err) {
// Do nothing
}
}, key, value);
}
6 changes: 6 additions & 0 deletions test/unit/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ export const when = async (fn, intervalMillis = 100, retries = 20) => {

export const nextMicroTask = () => new Promise((res) => queueMicrotask(res));
export const nextIdleCallback = () => new Promise((res) => rIC(res));

export const getIdleDeadlinePrototype = async () => {
return await new Promise((resolve) => {
rIC((deadline) => resolve(deadline.constructor.prototype));
});
};
Loading

0 comments on commit 8bd569f

Please sign in to comment.