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) => {
|
||||
fixture.detectChanges(); // triggers ngOnInit
|
||||
expect(searchService.initWorker).toHaveBeenCalled();
|
||||
expect(searchService.loadIndex).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
@ -111,8 +111,8 @@ export class AppComponent implements OnInit {
|
|||
ngOnInit() {
|
||||
// Do not initialize the search on browsers that lack web worker support
|
||||
if ('Worker' in window) {
|
||||
this.searchService.initWorker('app/search/search-worker.js');
|
||||
this.searchService.loadIndex();
|
||||
// Delay initialization by up to 2 seconds
|
||||
this.searchService.initWorker('app/search/search-worker.js', 2000);
|
||||
}
|
||||
|
||||
this.onResize(window.innerWidth);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { SearchBoxComponent } from './search-box.component';
|
||||
import { MockSearchService } from 'testing/search.service';
|
||||
|
@ -36,30 +36,67 @@ describe('SearchBoxComponent', () => {
|
|||
});
|
||||
|
||||
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' });
|
||||
component.ngOnInit();
|
||||
expect(location.search).toHaveBeenCalled();
|
||||
tick(300);
|
||||
expect(host.searchHandler).toHaveBeenCalledWith('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', () => {
|
||||
it('should trigger the onSearch event', () => {
|
||||
it('should trigger a search', () => {
|
||||
const input = fixture.debugElement.query(By.css('input'));
|
||||
input.nativeElement.value = 'some query (input)';
|
||||
spyOn(component, 'doSearch');
|
||||
input.triggerEventHandler('input', { });
|
||||
expect(host.searchHandler).toHaveBeenCalledWith('some query (input)');
|
||||
expect(component.doSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('on keyup', () => {
|
||||
it('should trigger the onSearch event', () => {
|
||||
it('should trigger a search', () => {
|
||||
const input = fixture.debugElement.query(By.css('input'));
|
||||
input.nativeElement.value = 'some query (keyup)';
|
||||
spyOn(component, 'doSearch');
|
||||
input.triggerEventHandler('keyup', { });
|
||||
expect(host.searchHandler).toHaveBeenCalledWith('some query (keyup)');
|
||||
expect(component.doSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -73,28 +110,11 @@ describe('SearchBoxComponent', () => {
|
|||
});
|
||||
|
||||
describe('on click', () => {
|
||||
it('should trigger the search event', () => {
|
||||
it('should trigger a search', () => {
|
||||
const input = fixture.debugElement.query(By.css('input'));
|
||||
input.nativeElement.value = 'some query (click)';
|
||||
spyOn(component, 'doSearch');
|
||||
input.triggerEventHandler('click', { });
|
||||
expect(host.searchHandler).toHaveBeenCalledWith('some query (click)');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(component.doSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -26,10 +26,11 @@ import 'rxjs/add/operator/distinctUntilChanged';
|
|||
})
|
||||
export class SearchBoxComponent implements OnInit {
|
||||
|
||||
private searchDebounce = 300;
|
||||
private searchSubject = new Subject<string>();
|
||||
|
||||
@ViewChild('searchBox') searchBox: ElementRef;
|
||||
@Output() onSearch = this.searchSubject.distinctUntilChanged();
|
||||
@Output() onSearch = this.searchSubject.distinctUntilChanged().debounceTime(this.searchDebounce);
|
||||
@Output() onFocus = new EventEmitter<string>();
|
||||
|
||||
constructor(private locationService: LocationService) { }
|
||||
|
|
|
@ -1,24 +1,62 @@
|
|||
import { ReflectiveInjector, NgZone } from '@angular/core';
|
||||
import { fakeAsync, tick } from '@angular/core/testing';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchService } from './search.service';
|
||||
import { WebWorkerClient } from 'app/shared/web-worker';
|
||||
|
||||
describe('SearchService', () => {
|
||||
|
||||
let injector: ReflectiveInjector;
|
||||
let service: SearchService;
|
||||
let sendMessageSpy: jasmine.Spy;
|
||||
let mockWorker: WebWorkerClient;
|
||||
|
||||
beforeEach(() => {
|
||||
sendMessageSpy = jasmine.createSpy('sendMessage').and.returnValue(Observable.of({}));
|
||||
mockWorker = { sendMessage: sendMessageSpy } as any;
|
||||
spyOn(WebWorkerClient, 'create').and.returnValue(mockWorker);
|
||||
|
||||
injector = ReflectiveInjector.resolveAndCreate([
|
||||
SearchService,
|
||||
{ provide: NgZone, useFactory: () => new NgZone({ enableLongStackTrace: false }) }
|
||||
]);
|
||||
service = injector.get(SearchService);
|
||||
});
|
||||
|
||||
describe('loadIndex', () => {
|
||||
it('should send a "load-index" message to the worker');
|
||||
it('should connect the `ready` property to the response to the "load-index" message');
|
||||
describe('initWorker', () => {
|
||||
it('should create the worker and load the index after the specified delay', fakeAsync(() => {
|
||||
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', () => {
|
||||
it('should send a "query-index" message to the worker');
|
||||
it('should push the response to the `searchResults` observable');
|
||||
beforeEach(() => {
|
||||
// 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 { Observable } from 'rxjs/Observable';
|
||||
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/publish';
|
||||
import { WebWorkerClient } from 'app/shared/web-worker';
|
||||
|
||||
export interface SearchResults {
|
||||
|
@ -27,26 +29,50 @@ export interface SearchResult {
|
|||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
private worker: WebWorkerClient;
|
||||
private ready: Observable<boolean>;
|
||||
private resultsSubject = new ReplaySubject<SearchResults>(1);
|
||||
readonly searchResults = this.resultsSubject.asObservable();
|
||||
private searchesSubject = new ReplaySubject<string>(1);
|
||||
searchResults: Observable<SearchResults>;
|
||||
|
||||
constructor(private zone: NgZone) {}
|
||||
|
||||
initWorker(workerUrl) {
|
||||
this.worker = new WebWorkerClient(new Worker(workerUrl), this.zone);
|
||||
}
|
||||
|
||||
loadIndex() {
|
||||
const ready = this.ready = this.worker.sendMessage<boolean>('load-index').publishLast();
|
||||
// trigger the index to be loaded immediately
|
||||
ready.connect();
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param workerUrl the url of the WebWorker script that runs the searches
|
||||
* @param initDelay the number of milliseconds to wait before we load the WebWorker and generate the search index
|
||||
*/
|
||||
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) {
|
||||
this.ready.concatMap(ready => {
|
||||
return this.worker.sendMessage('query-index', query) as Observable<SearchResults>;
|
||||
}).subscribe(results => this.resultsSubject.next(results));
|
||||
this.searchesSubject.next(query);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,11 @@ export interface WebWorkerMessage {
|
|||
export class WebWorkerClient {
|
||||
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> {
|
||||
|
|
Loading…
Reference in New Issue