aio: debounce search and delay index building (#18134)
* feat(aio): debounce search requests * feat(aio): delay loading search worker and index
This commit is contained in:
parent
72fe45db2b
commit
4cd4f7a208
|
@ -632,7 +632,6 @@ describe('AppComponent', () => {
|
||||||
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
|
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
|
||||||
fixture.detectChanges(); // triggers ngOnInit
|
fixture.detectChanges(); // triggers ngOnInit
|
||||||
expect(searchService.initWorker).toHaveBeenCalled();
|
expect(searchService.initWorker).toHaveBeenCalled();
|
||||||
expect(searchService.loadIndex).toHaveBeenCalled();
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -111,8 +111,8 @@ export class AppComponent implements OnInit {
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// Do not initialize the search on browsers that lack web worker support
|
// Do not initialize the search on browsers that lack web worker support
|
||||||
if ('Worker' in window) {
|
if ('Worker' in window) {
|
||||||
this.searchService.initWorker('app/search/search-worker.js');
|
// Delay initialization by up to 2 seconds
|
||||||
this.searchService.loadIndex();
|
this.searchService.initWorker('app/search/search-worker.js', 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onResize(window.innerWidth);
|
this.onResize(window.innerWidth);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing';
|
import { ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { SearchBoxComponent } from './search-box.component';
|
import { SearchBoxComponent } from './search-box.component';
|
||||||
import { MockSearchService } from 'testing/search.service';
|
import { MockSearchService } from 'testing/search.service';
|
||||||
|
@ -36,30 +36,67 @@ describe('SearchBoxComponent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initialisation', () => {
|
describe('initialisation', () => {
|
||||||
it('should get the current search query from the location service', inject([LocationService], (location: MockLocationService) => {
|
it('should get the current search query from the location service',
|
||||||
|
inject([LocationService], (location: MockLocationService) => fakeAsync(() => {
|
||||||
location.search.and.returnValue({ search: 'initial search' });
|
location.search.and.returnValue({ search: 'initial search' });
|
||||||
component.ngOnInit();
|
component.ngOnInit();
|
||||||
expect(location.search).toHaveBeenCalled();
|
expect(location.search).toHaveBeenCalled();
|
||||||
|
tick(300);
|
||||||
expect(host.searchHandler).toHaveBeenCalledWith('initial search');
|
expect(host.searchHandler).toHaveBeenCalledWith('initial search');
|
||||||
expect(component.searchBox.nativeElement.value).toEqual('initial search');
|
expect(component.searchBox.nativeElement.value).toEqual('initial search');
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onSearch', () => {
|
||||||
|
it('should debounce by 300ms', fakeAsync(() => {
|
||||||
|
component.doSearch();
|
||||||
|
expect(host.searchHandler).not.toHaveBeenCalled();
|
||||||
|
tick(300);
|
||||||
|
expect(host.searchHandler).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should pass through the value of the input box', fakeAsync(() => {
|
||||||
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
|
input.nativeElement.value = 'some query (input)';
|
||||||
|
component.doSearch();
|
||||||
|
tick(300);
|
||||||
|
expect(host.searchHandler).toHaveBeenCalledWith('some query (input)');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should only send events if the search value has changed', fakeAsync(() => {
|
||||||
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
|
|
||||||
|
input.nativeElement.value = 'some query';
|
||||||
|
component.doSearch();
|
||||||
|
tick(300);
|
||||||
|
expect(host.searchHandler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
component.doSearch();
|
||||||
|
tick(300);
|
||||||
|
expect(host.searchHandler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
input.nativeElement.value = 'some other query';
|
||||||
|
component.doSearch();
|
||||||
|
tick(300);
|
||||||
|
expect(host.searchHandler).toHaveBeenCalledTimes(2);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('on input', () => {
|
describe('on input', () => {
|
||||||
it('should trigger the onSearch event', () => {
|
it('should trigger a search', () => {
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
input.nativeElement.value = 'some query (input)';
|
spyOn(component, 'doSearch');
|
||||||
input.triggerEventHandler('input', { });
|
input.triggerEventHandler('input', { });
|
||||||
expect(host.searchHandler).toHaveBeenCalledWith('some query (input)');
|
expect(component.doSearch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('on keyup', () => {
|
describe('on keyup', () => {
|
||||||
it('should trigger the onSearch event', () => {
|
it('should trigger a search', () => {
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
input.nativeElement.value = 'some query (keyup)';
|
spyOn(component, 'doSearch');
|
||||||
input.triggerEventHandler('keyup', { });
|
input.triggerEventHandler('keyup', { });
|
||||||
expect(host.searchHandler).toHaveBeenCalledWith('some query (keyup)');
|
expect(component.doSearch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -73,28 +110,11 @@ describe('SearchBoxComponent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('on click', () => {
|
describe('on click', () => {
|
||||||
it('should trigger the search event', () => {
|
it('should trigger a search', () => {
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
input.nativeElement.value = 'some query (click)';
|
spyOn(component, 'doSearch');
|
||||||
input.triggerEventHandler('click', { });
|
input.triggerEventHandler('click', { });
|
||||||
expect(host.searchHandler).toHaveBeenCalledWith('some query (click)');
|
expect(component.doSearch).toHaveBeenCalled();
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('event filtering', () => {
|
|
||||||
it('should only send events if the search value has changed', () => {
|
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
|
||||||
|
|
||||||
input.nativeElement.value = 'some query';
|
|
||||||
input.triggerEventHandler('input', { });
|
|
||||||
expect(host.searchHandler).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
input.triggerEventHandler('input', { });
|
|
||||||
expect(host.searchHandler).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
input.nativeElement.value = 'some other query';
|
|
||||||
input.triggerEventHandler('input', { });
|
|
||||||
expect(host.searchHandler).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -26,10 +26,11 @@ import 'rxjs/add/operator/distinctUntilChanged';
|
||||||
})
|
})
|
||||||
export class SearchBoxComponent implements OnInit {
|
export class SearchBoxComponent implements OnInit {
|
||||||
|
|
||||||
|
private searchDebounce = 300;
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
|
|
||||||
@ViewChild('searchBox') searchBox: ElementRef;
|
@ViewChild('searchBox') searchBox: ElementRef;
|
||||||
@Output() onSearch = this.searchSubject.distinctUntilChanged();
|
@Output() onSearch = this.searchSubject.distinctUntilChanged().debounceTime(this.searchDebounce);
|
||||||
@Output() onFocus = new EventEmitter<string>();
|
@Output() onFocus = new EventEmitter<string>();
|
||||||
|
|
||||||
constructor(private locationService: LocationService) { }
|
constructor(private locationService: LocationService) { }
|
||||||
|
|
|
@ -1,24 +1,62 @@
|
||||||
import { ReflectiveInjector, NgZone } from '@angular/core';
|
import { ReflectiveInjector, NgZone } from '@angular/core';
|
||||||
|
import { fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { SearchService } from './search.service';
|
import { SearchService } from './search.service';
|
||||||
|
import { WebWorkerClient } from 'app/shared/web-worker';
|
||||||
|
|
||||||
describe('SearchService', () => {
|
describe('SearchService', () => {
|
||||||
|
|
||||||
let injector: ReflectiveInjector;
|
let injector: ReflectiveInjector;
|
||||||
|
let service: SearchService;
|
||||||
|
let sendMessageSpy: jasmine.Spy;
|
||||||
|
let mockWorker: WebWorkerClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
sendMessageSpy = jasmine.createSpy('sendMessage').and.returnValue(Observable.of({}));
|
||||||
|
mockWorker = { sendMessage: sendMessageSpy } as any;
|
||||||
|
spyOn(WebWorkerClient, 'create').and.returnValue(mockWorker);
|
||||||
|
|
||||||
injector = ReflectiveInjector.resolveAndCreate([
|
injector = ReflectiveInjector.resolveAndCreate([
|
||||||
SearchService,
|
SearchService,
|
||||||
{ provide: NgZone, useFactory: () => new NgZone({ enableLongStackTrace: false }) }
|
{ provide: NgZone, useFactory: () => new NgZone({ enableLongStackTrace: false }) }
|
||||||
]);
|
]);
|
||||||
|
service = injector.get(SearchService);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadIndex', () => {
|
describe('initWorker', () => {
|
||||||
it('should send a "load-index" message to the worker');
|
it('should create the worker and load the index after the specified delay', fakeAsync(() => {
|
||||||
it('should connect the `ready` property to the response to the "load-index" message');
|
service.initWorker('some/url', 100);
|
||||||
|
expect(WebWorkerClient.create).not.toHaveBeenCalled();
|
||||||
|
expect(mockWorker.sendMessage).not.toHaveBeenCalled();
|
||||||
|
tick(100);
|
||||||
|
expect(WebWorkerClient.create).toHaveBeenCalledWith('some/url', jasmine.any(NgZone));
|
||||||
|
expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index');
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
it('should send a "query-index" message to the worker');
|
beforeEach(() => {
|
||||||
it('should push the response to the `searchResults` observable');
|
// We must initialize the service before calling search
|
||||||
|
service.initWorker('some/url', 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger a `loadIndex` synchronously', () => {
|
||||||
|
service.search('some query');
|
||||||
|
expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send a "query-index" message to the worker', () => {
|
||||||
|
service.search('some query');
|
||||||
|
expect(mockWorker.sendMessage).toHaveBeenCalledWith('query-index', 'some query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should push the response to the `searchResults` observable', () => {
|
||||||
|
const mockSearchResults = { results: ['a', 'b'] };
|
||||||
|
(mockWorker.sendMessage as jasmine.Spy).and.returnValue(Observable.of(mockSearchResults));
|
||||||
|
let searchResults: any;
|
||||||
|
service.searchResults.subscribe(results => searchResults = results);
|
||||||
|
service.search('some query');
|
||||||
|
expect(searchResults).toEqual(mockSearchResults);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,8 +7,10 @@ can be found in the LICENSE file at http://angular.io/license
|
||||||
import { NgZone, Injectable, Type } from '@angular/core';
|
import { NgZone, Injectable, Type } from '@angular/core';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
||||||
import 'rxjs/add/operator/publishLast';
|
import 'rxjs/add/observable/race';
|
||||||
|
import 'rxjs/add/observable/timer';
|
||||||
import 'rxjs/add/operator/concatMap';
|
import 'rxjs/add/operator/concatMap';
|
||||||
|
import 'rxjs/add/operator/publish';
|
||||||
import { WebWorkerClient } from 'app/shared/web-worker';
|
import { WebWorkerClient } from 'app/shared/web-worker';
|
||||||
|
|
||||||
export interface SearchResults {
|
export interface SearchResults {
|
||||||
|
@ -27,26 +29,50 @@ export interface SearchResult {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
private worker: WebWorkerClient;
|
private searchesSubject = new ReplaySubject<string>(1);
|
||||||
private ready: Observable<boolean>;
|
searchResults: Observable<SearchResults>;
|
||||||
private resultsSubject = new ReplaySubject<SearchResults>(1);
|
|
||||||
readonly searchResults = this.resultsSubject.asObservable();
|
|
||||||
|
|
||||||
constructor(private zone: NgZone) {}
|
constructor(private zone: NgZone) {}
|
||||||
|
|
||||||
initWorker(workerUrl) {
|
/**
|
||||||
this.worker = new WebWorkerClient(new Worker(workerUrl), this.zone);
|
* Initialize the search engine. We offer an `initDelay` to prevent the search initialisation from delaying the
|
||||||
}
|
* initial rendering of the web page. Triggering a search will override this delay and cause the index to be
|
||||||
|
* loaded immediately.
|
||||||
loadIndex() {
|
*
|
||||||
const ready = this.ready = this.worker.sendMessage<boolean>('load-index').publishLast();
|
* @param workerUrl the url of the WebWorker script that runs the searches
|
||||||
// trigger the index to be loaded immediately
|
* @param initDelay the number of milliseconds to wait before we load the WebWorker and generate the search index
|
||||||
ready.connect();
|
*/
|
||||||
|
initWorker(workerUrl: string, initDelay: number) {
|
||||||
|
const searchResults = Observable
|
||||||
|
// Wait for the initDelay or the first search
|
||||||
|
.race(
|
||||||
|
Observable.timer(initDelay),
|
||||||
|
this.searchesSubject.first()
|
||||||
|
)
|
||||||
|
.concatMap(() => {
|
||||||
|
// Create the worker and load the index
|
||||||
|
const worker = WebWorkerClient.create(workerUrl, this.zone);
|
||||||
|
return worker.sendMessage('load-index').concatMap(() =>
|
||||||
|
// Once the index has loaded, switch to listening to the searches coming in
|
||||||
|
this.searchesSubject.switchMap((query) =>
|
||||||
|
// Each search gets switched to a web worker message, whose results are returned via an observable
|
||||||
|
worker.sendMessage<SearchResults>('query-index', query)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}).publish();
|
||||||
|
|
||||||
|
// Connect to the observable to kick off the timer
|
||||||
|
searchResults.connect();
|
||||||
|
|
||||||
|
// Expose the connected observable to the rest of the world
|
||||||
|
this.searchResults = searchResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a search query to the index.
|
||||||
|
* The results will appear on the `searchResults` observable.
|
||||||
|
*/
|
||||||
search(query: string) {
|
search(query: string) {
|
||||||
this.ready.concatMap(ready => {
|
this.searchesSubject.next(query);
|
||||||
return this.worker.sendMessage('query-index', query) as Observable<SearchResults>;
|
|
||||||
}).subscribe(results => this.resultsSubject.next(results));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,11 @@ export interface WebWorkerMessage {
|
||||||
export class WebWorkerClient {
|
export class WebWorkerClient {
|
||||||
private nextId = 0;
|
private nextId = 0;
|
||||||
|
|
||||||
constructor(private worker: Worker, private zone: NgZone) {
|
static create(workerUrl: string, zone: NgZone) {
|
||||||
|
return new WebWorkerClient(new Worker(workerUrl), zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(private worker: Worker, private zone: NgZone) {
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage<T>(type: string, payload?: any): Observable<T> {
|
sendMessage<T>(type: string, payload?: any): Observable<T> {
|
||||||
|
|
Loading…
Reference in New Issue