Some refactoring of debouncing

This commit is contained in:
Gregory Schier 2016-11-10 13:03:12 -08:00
parent 494ce4b364
commit c08407eec6
11 changed files with 188 additions and 112 deletions

View File

@ -1,6 +1,6 @@
import path from 'path';
import fs from 'fs';
import LocalStorage from '../localstorage';
import LocalStorage from '../LocalStorage';
describe('LocalStorage()', () => {
beforeEach(() => {

View File

@ -1,8 +1,8 @@
import * as util from '../misc';
import * as misc from '../misc';
describe('getBasicAuthHeader()', () => {
it('succeed with username and password', () => {
const header = util.getBasicAuthHeader('user', 'password');
const header = misc.getBasicAuthHeader('user', 'password');
expect(header).toEqual({
name: 'Authorization',
value: 'Basic dXNlcjpwYXNzd29yZA=='
@ -10,7 +10,7 @@ describe('getBasicAuthHeader()', () => {
});
it('succeed with no username', () => {
const header = util.getBasicAuthHeader(null, 'password');
const header = misc.getBasicAuthHeader(null, 'password');
expect(header).toEqual({
name: 'Authorization',
value: 'Basic OnBhc3N3b3Jk'
@ -18,7 +18,7 @@ describe('getBasicAuthHeader()', () => {
});
it('succeed with username and empty password', () => {
const header = util.getBasicAuthHeader('user', '');
const header = misc.getBasicAuthHeader('user', '');
expect(header).toEqual({
name: 'Authorization',
value: 'Basic dXNlcjo='
@ -26,7 +26,7 @@ describe('getBasicAuthHeader()', () => {
});
it('succeed with username and null password', () => {
const header = util.getBasicAuthHeader('user', null);
const header = misc.getBasicAuthHeader('user', null);
expect(header).toEqual({
name: 'Authorization',
value: 'Basic dXNlcjo='
@ -36,7 +36,7 @@ describe('getBasicAuthHeader()', () => {
describe('hasAuthHeader()', () => {
it('finds valid header', () => {
const yes = util.hasAuthHeader([
const yes = misc.hasAuthHeader([
{name: 'foo', value: 'bar'},
{name: 'authorization', value: 'foo'}
]);
@ -45,7 +45,7 @@ describe('hasAuthHeader()', () => {
});
it('finds valid header case insensitive', () => {
const yes = util.hasAuthHeader([
const yes = misc.hasAuthHeader([
{name: 'foo', value: 'bar'},
{name: 'AuthOrizAtiOn', value: 'foo'}
]);
@ -56,89 +56,155 @@ describe('hasAuthHeader()', () => {
describe('generateId()', () => {
it('generates a valid ID', () => {
const id = util.generateId('foo');
const id = misc.generateId('foo');
expect(id).toMatch(/^foo_[a-z0-9]{32}$/);
});
it('generates without prefix', () => {
const id = util.generateId();
const id = misc.generateId();
expect(id).toMatch(/^[a-z0-9]{32}$/);
});
});
describe('setDefaultProtocol()', () => {
it('correctly sets protocol for empty', () => {
const url = util.setDefaultProtocol('google.com');
const url = misc.setDefaultProtocol('google.com');
expect(url).toBe('http://google.com');
});
it('does not set for valid url', () => {
const url = util.setDefaultProtocol('https://google.com');
const url = misc.setDefaultProtocol('https://google.com');
expect(url).toBe('https://google.com');
});
it('does not set for valid url', () => {
const url = util.setDefaultProtocol('http://google.com');
const url = misc.setDefaultProtocol('http://google.com');
expect(url).toBe('http://google.com');
});
it('does not set for invalid url', () => {
const url = util.setDefaultProtocol('httbad://google.com');
const url = misc.setDefaultProtocol('httbad://google.com');
expect(url).toBe('httbad://google.com');
});
});
describe('prepareUrlForSending()', () => {
it('does not touch normal url', () => {
const url = util.prepareUrlForSending('http://google.com');
const url = misc.prepareUrlForSending('http://google.com');
expect(url).toBe('http://google.com/');
});
it('works with no protocol', () => {
const url = util.prepareUrlForSending('google.com');
const url = misc.prepareUrlForSending('google.com');
expect(url).toBe('http://google.com/');
});
it('encodes pathname', () => {
const url = util.prepareUrlForSending('https://google.com/foo bar/100%/foo');
const url = misc.prepareUrlForSending('https://google.com/foo bar/100%/foo');
expect(url).toBe('https://google.com/foo%20bar/100%25/foo');
});
it('encodes pathname mixed encoding', () => {
const url = util.prepareUrlForSending('https://google.com/foo bar baz%20qux/100%/foo%25');
const url = misc.prepareUrlForSending('https://google.com/foo bar baz%20qux/100%/foo%25');
expect(url).toBe('https://google.com/foo%20bar%20baz%20qux/100%25/foo%25');
});
it('leaves already encoded pathname', () => {
const url = util.prepareUrlForSending('https://google.com/foo%20bar%20baz/100%25/foo');
const url = misc.prepareUrlForSending('https://google.com/foo%20bar%20baz/100%25/foo');
expect(url).toBe('https://google.com/foo%20bar%20baz/100%25/foo');
});
it('encodes querystring', () => {
const url = util.prepareUrlForSending('https://google.com?s=foo bar 100%&hi');
const url = misc.prepareUrlForSending('https://google.com?s=foo bar 100%&hi');
expect(url).toBe('https://google.com/?s=foo%20bar%20100%25&hi=');
});
it('encodes querystring with mixed spaces', () => {
const url = util.prepareUrlForSending('https://google.com?s=foo %20100%');
const url = misc.prepareUrlForSending('https://google.com?s=foo %20100%');
expect(url).toBe('https://google.com/?s=foo%20%20100%25');
});
it('encodes querystring with repeated keys', () => {
const url = util.prepareUrlForSending('https://google.com?s=foo&s=foo %20100%');
const url = misc.prepareUrlForSending('https://google.com?s=foo&s=foo %20100%');
expect(url).toBe('https://google.com/?s=foo&s=foo%20%20100%25');
});
});
describe('filterHeaders()', () => {
it('handles bad headers', () => {
expect(util.filterHeaders(null, null)).toEqual([]);
expect(util.filterHeaders([], null)).toEqual([]);
expect(util.filterHeaders(['bad'], null)).toEqual([]);
expect(util.filterHeaders(['bad'], 'good')).toEqual([]);
expect(util.filterHeaders(null, 'good')).toEqual([]);
expect(util.filterHeaders([{name: 'good', value: 'valid'}], null)).toEqual([]);
expect(util.filterHeaders([{name: 'good', value: 'valid'}], 'good'))
expect(misc.filterHeaders(null, null)).toEqual([]);
expect(misc.filterHeaders([], null)).toEqual([]);
expect(misc.filterHeaders(['bad'], null)).toEqual([]);
expect(misc.filterHeaders(['bad'], 'good')).toEqual([]);
expect(misc.filterHeaders(null, 'good')).toEqual([]);
expect(misc.filterHeaders([{name: 'good', value: 'valid'}], null)).toEqual([]);
expect(misc.filterHeaders([{name: 'good', value: 'valid'}], 'good'))
.toEqual([{name: 'good', value: 'valid'}]);
})
});
describe('keyedDebounce()', () => {
beforeEach(() => {
jest.useFakeTimers();
// There has to be a better way to reset this...
setTimeout.mock.calls = [];
});
afterEach(() => jest.clearAllTimers());
it('debounces correctly', () => {
const resultsList = [];
const fn = misc.keyedDebounce(results => {
resultsList.push(results);
}, 100);
fn('foo', 'bar');
fn('baz', 'bar');
fn('foo', 'bar2');
fn('foo', 'bar3');
fn('multi', 'foo', 'bar', 'baz');
expect(setTimeout.mock.calls.length).toBe(5);
expect(resultsList).toEqual([]);
jest.runAllTimers();
expect(resultsList).toEqual([{
foo: ['bar3'],
baz: ['bar'],
multi: ['foo', 'bar', 'baz']
}]);
})
});
describe('debounce()', () => {
beforeEach(() => {
jest.useFakeTimers();
// There has to be a better way to reset this...
setTimeout.mock.calls = [];
});
afterEach(() => jest.clearAllTimers());
it('debounces correctly', () => {
const resultList = [];
const fn = misc.debounce((...args) => {
resultList.push(args);
}, 100);
fn('foo');
fn('foo');
fn('multi', 'foo', 'bar', 'baz');
fn('baz', 'bar');
fn('foo', 'bar3');
expect(setTimeout.mock.calls.length).toBe(5);
expect(resultList).toEqual([]);
jest.runAllTimers();
expect(resultList).toEqual([['foo', 'bar3']]);
})
});

View File

@ -55,8 +55,9 @@ export async function initDB (types, config = {}, forceReset = false) {
db[modelType].persistence.setAutocompactionInterval(DB_PERSIST_INTERVAL);
console.log(`-- Initialized DB at ${filePath} --`);
}
console.log(`-- Initialized DB at ${getDBFilePath('$TYPE')} --`);
}

View File

@ -101,7 +101,7 @@ export function prepareUrlForSending (url) {
return urlFormat(parsedUrl);
}
export function delay (milliseconds) {
export function delay (milliseconds = DEBOUNCE_MILLIS) {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
@ -109,12 +109,28 @@ export function removeVowels (str) {
return str.replace(/[aeiouyAEIOUY]/g, '');
}
export function debounce (callback, millis = DEBOUNCE_MILLIS) {
export function keyedDebounce (callback, millis = DEBOUNCE_MILLIS) {
let timeout = null;
return function () {
let results = {};
return function (key, ...args) {
results[key] = args;
clearTimeout(timeout);
timeout = setTimeout(() => {
callback.apply(null, arguments)
if (!Object.keys(results).length) {
return;
}
callback(results);
results = {};
}, millis);
}
}
export function debounce (callback, millis = DEBOUNCE_MILLIS) {
// For regular debounce, just use a keyed debounce with a fixed key
return keyedDebounce(results => {
callback.apply(null, results['__key__'])
}, millis).bind(null, '__key__');
}

View File

@ -7,7 +7,7 @@ import request from 'request';
import path from 'path';
import electron from 'electron';
import * as packageJSON from './package.json';
import LocalStorage from './common/localstorage';
import LocalStorage from './common/LocalStorage';
// Some useful helpers
const IS_DEV = process.env.INSOMNIA_ENV === 'development';

View File

@ -5,6 +5,7 @@ import * as crypt from './crypt';
import * as session from './session';
import * as store from './storage';
import Logger from './logger';
import * as misc from '../common/misc';
export const FULL_SYNC_INTERVAL = 60E3;
export const QUEUE_DEBOUNCE_TIME = 1E3;
@ -29,17 +30,17 @@ const resourceGroupSymmetricKeysCache = {};
let isInitialized = false;
export async function initSync () {
const settings = await models.settings.getOrCreate();
if (!settings.optSyncBeta) {
logger.debug('Not enabled');
return;
}
if (isInitialized) {
logger.debug('Already enabled');
return;
}
const _queueChange = misc.keyedDebounce(results => {
for (const k of Object.keys(results)) {
_pushChange(...results[k]);
}
}, QUEUE_DEBOUNCE_TIME);
db.onChange(changes => {
for (const [event, doc, fromSync] of changes) {
if (!WHITE_LIST[doc.type]) {
@ -52,7 +53,8 @@ export async function initSync () {
}
// Make sure it happens async
process.nextTick(() => _queueChange(event, doc, fromSync));
const key = `${event}:${doc._id}`;
_queueChange(key, event, doc, Date.now());
}
});
@ -385,54 +387,28 @@ export async function resetRemoteData () {
// HELPERS //
// ~~~~~~~ //
let _queuedChanges = {};
let _queuedChangesTimeout = null;
let _pushChangesTimeout = null;
async function _pushChange (event, doc, timestamp) {
// Update the resource content and set dirty
// TODO: Remove one of these steps since it does encryption twice
// in the case where the resource does not exist yet
const resource = await getOrCreateResourceForDoc(doc);
async function _queueChange (event, doc) {
if (!session.isLoggedIn()) {
logger.warn('Not logged in');
return;
}
const updatedResource = await store.updateResource(resource, {
name: doc.name || 'n/a',
lastEdited: timestamp,
lastEditedBy: session.getAccountId(),
encContent: await _encryptDoc(resource.resourceGroupId, doc),
removed: event === db.CHANGE_REMOVE,
dirty: true
});
// How this works?
// First, debounce updates to Resources because they are heavy (encryption)
// Second, debounce pushes to the server, because they are slow (network)
// ... Using _queuedChanges as a map so that future changes to the same doc
// don't trigger more than 1 update.
// NOTE: Don't use doc.modified because that doesn't work for removal
_queuedChanges[doc._id + event] = [event, doc, Date.now()];
clearTimeout(_queuedChangesTimeout);
_queuedChangesTimeout = setTimeout(async () => {
const queuedChangesCopy = Object.assign({}, _queuedChanges);
_queuedChanges = {};
for (const k of Object.keys(queuedChangesCopy)) {
const [event, doc, timestamp] = queuedChangesCopy[k];
// Update the resource content and set dirty
// TODO: Remove one of these steps since it does encryption twice
// in the case where the resource does not exist yet
const resource = await getOrCreateResourceForDoc(doc);
const updatedResource = await store.updateResource(resource, {
name: doc.name || 'n/a',
lastEdited: timestamp,
lastEditedBy: session.getAccountId(),
encContent: await _encryptDoc(resource.resourceGroupId, doc),
removed: event === db.CHANGE_REMOVE,
dirty: true
});
// Debounce pushing of dirty resources
logger.debug(`Queue ${event} ${updatedResource.id}`);
clearTimeout(_pushChangesTimeout);
_pushChangesTimeout = setTimeout(() => pushActiveDirtyResources(), PUSH_DEBOUNCE_TIME);
}
}, QUEUE_DEBOUNCE_TIME);
// Debounce pushing of dirty resources
logger.debug(`Queue ${event} ${updatedResource.id}`);
clearTimeout(_pushChangesTimeout);
_pushChangesTimeout = setTimeout(() => {
pushActiveDirtyResources()
}, PUSH_DEBOUNCE_TIME);
}
/**

View File

@ -0,0 +1,26 @@
import React, {Component, PropTypes} from 'react';
import * as misc from '../../../common/misc';
class DebouncedInput extends Component {
constructor (props) {
super(props);
// NOTE: Cannot debounce props.onChange directly because react events aren't
// allowed to be used after a timeout :(
const debouncedOnChange = misc.debounce(value => this.props.onChange(value));
const onChangeFn = e => debouncedOnChange(e.target.value);
this._onChange = onChangeFn.bind(this);
}
render () {
// NOTE: Strip out onChange because we're overriding it
const {onChange, ...props} = this.props;
return <input {...props} onChange={this._onChange}/>
}
}
DebouncedInput.propTypes = {
onChange: PropTypes.func.isRequired,
};
export default DebouncedInput;

View File

@ -1,5 +1,5 @@
import React, {Component, PropTypes} from 'react';
import {DEBOUNCE_MILLIS} from '../../../common/constants';
import * as misc from '../../../common/misc';
class Editable extends Component {
constructor (props) {
@ -18,7 +18,7 @@ class Editable extends Component {
});
}
_handleEditEnd () {
async _handleEditEnd () {
const value = this._input.value.trim();
if (!value) {
@ -26,13 +26,12 @@ class Editable extends Component {
return;
}
this.props.onSubmit(value);
// This timeout prevents the UI from showing the old value after submit.
// It should give the UI enough time to redraw the new value.
setTimeout(() => {
this.setState({editing: false});
}, DEBOUNCE_MILLIS);
this.props.onSubmit(value);
await misc.delay(100);
this.setState({editing: false});
}
_handleEditKeyDown (e) {
@ -41,6 +40,8 @@ class Editable extends Component {
this._handleEditEnd();
} else if (e.keyCode === 27) {
// Pressed Escape
// NOTE: This blur causes a save because we save on blur
// TODO: Make escape blur without saving
this._input && this._input.blur();
}
}

View File

@ -42,7 +42,7 @@ import '../../css/components/editor.less';
import {showModal} from '../modals/index';
import AlertModal from '../modals/AlertModal';
import Link from '../base/Link';
import {DEBOUNCE_MILLIS} from '../../../common/constants';
import * as misc from '../../../common/misc';
const BASE_CODEMIRROR_OPTIONS = {
@ -122,8 +122,8 @@ class Editor extends Component {
const {value} = this.props;
this.codeMirror = CodeMirror.fromTextArea(textarea, BASE_CODEMIRROR_OPTIONS);
this.codeMirror.on('change', this._codemirrorValueChanged.bind(this));
this.codeMirror.on('paste', this._codemirrorValueChanged.bind(this));
this.codeMirror.on('change', misc.debounce(this._codemirrorValueChanged.bind(this)));
this.codeMirror.on('paste', misc.debounce(this._codemirrorValueChanged.bind(this)));
if (!this.codeMirror.getOption('indentWithTabs')) {
this.codeMirror.setOption('extraKeys', {
Tab: cm => {
@ -248,13 +248,8 @@ class Editor extends Component {
return;
}
// Debounce URL changes so we don't update the app so much
clearTimeout(this._askTimeout);
this._askTimeout = setTimeout(() => {
// Update our cached value
var newValue = doc.getValue();
this.props.onChange(newValue);
}, DEBOUNCE_MILLIS)
var newValue = doc.getValue();
this.props.onChange(newValue);
}
/**
@ -280,7 +275,7 @@ class Editor extends Component {
if (this.props.updateFilter) {
this.props.updateFilter(filter);
}
}, DEBOUNCE_MILLIS * 3);
}, 400);
}
_canPrettify () {

View File

@ -1,12 +1,10 @@
import React, {PropTypes, Component} from 'react';
import Modal from '../base/Modal';
import ModalBody from '../base/ModalBody';
import ModalHeader from '../base/ModalHeader';
import ModalFooter from '../base/ModalFooter';
import CookiesEditor from '../editors/CookiesEditor';
import * as models from '../../../models';
import {DEBOUNCE_MILLIS} from '../../../common/constants';
class CookiesModal extends Component {
constructor (props) {
@ -15,7 +13,7 @@ class CookiesModal extends Component {
cookieJar: null,
workspace: null,
filter: ''
}
};
}
async _saveChanges () {
@ -56,10 +54,7 @@ class CookiesModal extends Component {
}
_onFilterChange (filter) {
clearTimeout(this._askTimeout);
this._askTimeout = setTimeout(() => {
this.setState({filter});
}, DEBOUNCE_MILLIS);
this.setState({filter});
}
_getFilteredSortedCookies () {