diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index cfd0d902e0..7dd6487b45 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -421,27 +421,6 @@ describe('AppComponent', () => { imageElement.click(); expect(location.handleAnchorClick).not.toHaveBeenCalled(); })); - - it('should intercept clicks not on the search elements and hide the search results', () => { - const searchResults: SearchResultsComponent = fixture.debugElement.query(By.directive(SearchResultsComponent)).componentInstance; - spyOn(searchResults, 'hideResults'); - // docViewer is a commonly-clicked, non-search element - docViewer.click(); - expect(searchResults.hideResults).toHaveBeenCalled(); - }); - - it('should not intercept clicks on any of the search elements', () => { - const searchResults = fixture.debugElement.query(By.directive(SearchResultsComponent)); - const searchResultsComponent: SearchResultsComponent = searchResults.componentInstance; - const searchBox = fixture.debugElement.query(By.directive(SearchBoxComponent)); - spyOn(searchResultsComponent, 'hideResults'); - - searchResults.nativeElement.click(); - expect(searchResultsComponent.hideResults).not.toHaveBeenCalled(); - - searchBox.nativeElement.click(); - expect(searchResultsComponent.hideResults).not.toHaveBeenCalled(); - }); }); describe('aio-toc', () => { @@ -456,7 +435,7 @@ describe('AppComponent', () => { it('should have a non-embedded `` element', () => { expect(tocDebugElement).toBeDefined(); - expect(tocDebugElement.classes.embedded).toBeFalsy(); + expect(tocDebugElement.classes['embedded']).toBeFalsy(); }); it('should update the TOC container\'s `maxHeight` based on `tocMaxHeight`', () => { @@ -476,6 +455,84 @@ describe('AppComponent', () => { }); }); + describe('search', () => { + describe('initialization', () => { + it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => { + fixture.detectChanges(); // triggers ngOnInit + expect(searchService.initWorker).toHaveBeenCalled(); + expect(searchService.loadIndex).toHaveBeenCalled(); + })); + }); + + describe('click handling', () => { + it('should intercept clicks not on the search elements and hide the search results', () => { + component.showSearchResults = true; + fixture.detectChanges(); + // docViewer is a commonly-clicked, non-search element + docViewer.click(); + expect(component.showSearchResults).toBe(false); + }); + + it('should not intercept clicks on the searchResults', () => { + component.showSearchResults = true; + fixture.detectChanges(); + + const searchResults = fixture.debugElement.query(By.directive(SearchResultsComponent)); + searchResults.nativeElement.click(); + fixture.detectChanges(); + + expect(component.showSearchResults).toBe(true); + }); + + it('should not intercept clicks om the searchBox', () => { + component.showSearchResults = true; + fixture.detectChanges(); + + const searchBox = fixture.debugElement.query(By.directive(SearchBoxComponent)); + searchBox.nativeElement.click(); + fixture.detectChanges(); + + expect(component.showSearchResults).toBe(true); + }); + }); + + describe('keyup handling', () => { + it('should grab focus when the / key is pressed', () => { + const searchBox: SearchBoxComponent = fixture.debugElement.query(By.directive(SearchBoxComponent)).componentInstance; + spyOn(searchBox, 'focus'); + window.document.dispatchEvent(new KeyboardEvent('keyup', { 'key': '/' })); + fixture.detectChanges(); + expect(searchBox.focus).toHaveBeenCalled(); + }); + }); + + describe('showing search results', () => { + it('should not display search results when query is empty', () => { + const searchService: MockSearchService = TestBed.get(SearchService); + searchService.searchResults.next({ query: '', results: [] }); + fixture.detectChanges(); + expect(component.showSearchResults).toBe(false); + }); + + it('should hide the results when a search result is selected', () => { + const searchService: MockSearchService = TestBed.get(SearchService); + + const results = [ + { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' } + ]; + + searchService.searchResults.next({ query: 'something', results: results }); + component.showSearchResults = true; + fixture.detectChanges(); + + const searchResultsComponent = fixture.debugElement.query(By.directive(SearchResultsComponent)); + searchResultsComponent.triggerEventHandler('resultSelected', {}); + fixture.detectChanges(); + expect(component.showSearchResults).toBe(false); + }); + }); + }); + }); //// test helpers //// diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 2a2d9c59f8..71efaa41df 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -9,6 +9,8 @@ import { LocationService } from 'app/shared/location.service'; import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component'; import { ScrollService } from 'app/shared/scroll.service'; import { SearchResultsComponent } from 'app/search/search-results/search-results.component'; +import { SearchBoxComponent } from 'app/search/search-box/search-box.component'; +import { SearchService } from 'app/search/search.service'; import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service'; const sideNavView = 'SideNav'; @@ -74,11 +76,14 @@ export class AppComponent implements OnInit { @ViewChild(DocViewerComponent, { read: ElementRef }) docViewer: ElementRef; + // Search related properties + showSearchResults = false; @ViewChildren('searchBox, searchResults', { read: ElementRef }) searchElements: QueryList; - @ViewChild(SearchResultsComponent) searchResults: SearchResultsComponent; + @ViewChild(SearchBoxComponent) + searchBox: SearchBoxComponent; @ViewChild(MdSidenav) sidenav: MdSidenav; @@ -89,10 +94,14 @@ export class AppComponent implements OnInit { private locationService: LocationService, private navigationService: NavigationService, private scrollService: ScrollService, + private searchService: SearchService, private swUpdateNotifications: SwUpdateNotificationsService ) { } ngOnInit() { + this.searchService.initWorker('app/search/search-worker.js'); + this.searchService.loadIndex(); + this.onResize(window.innerWidth); /* No need to unsubscribe because this root component never dies */ @@ -170,14 +179,12 @@ export class AppComponent implements OnInit { @HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey']) onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean { - // Hide the search results if we clicked outside both the search box and the search results - if (this.searchResults) { - const hits = this.searchElements.filter(element => element.nativeElement.contains(eventTarget)); - if (hits.length === 0) { - this.searchResults.hideResults(); - } + // Hide the search results if we clicked outside both the "search box" and the "search results" + if (!this.searchElements.some(element => element.nativeElement.contains(eventTarget))) { + this.hideSearchResults(); } + // Show developer source view if the footer is clicked while holding the meta and alt keys if (eventTarget.tagName === 'FOOTER' && metaKey && altKey) { this.dtOn = !this.dtOn; return false; @@ -191,6 +198,8 @@ export class AppComponent implements OnInit { if (target instanceof HTMLAnchorElement) { return this.locationService.handleAnchorClick(target, button, ctrlKey, metaKey); } + + // Allow the click to pass through return true; } @@ -230,4 +239,36 @@ export class AppComponent implements OnInit { this.tocMaxHeight = (document.body.scrollHeight - window.pageYOffset - this.tocMaxHeightOffset).toFixed(2); } + + + // Search related methods and handlers + + hideSearchResults() { + this.showSearchResults = false; + } + + focusSearchBox() { + if (this.searchBox) { + this.searchBox.focus(); + } + } + + doSearch(query) { + this.searchService.search(query); + this.showSearchResults = !!query; + } + + @HostListener('document:keyup', ['$event.key', '$event.which']) + onKeyUp(key: string, keyCode: number) { + // forward slash "/" + if (key === '/' || keyCode === 191) { + this.focusSearchBox(); + } + if (key === 'Escape' || keyCode === 27 ) { + // escape key + if (this.showSearchResults) { + this.hideSearchResults(); + } + } + } } diff --git a/aio/src/app/search/search-box/search-box.component.spec.ts b/aio/src/app/search/search-box/search-box.component.spec.ts index fcb60f3d1a..7efe4ede5f 100644 --- a/aio/src/app/search/search-box/search-box.component.spec.ts +++ b/aio/src/app/search/search-box/search-box.component.spec.ts @@ -1,20 +1,27 @@ +import { Component } from '@angular/core'; import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SearchBoxComponent } from './search-box.component'; -import { SearchService } from '../search.service'; import { MockSearchService } from 'testing/search.service'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; +@Component({ + template: '' +}) +class HostComponent { + doSearch = jasmine.createSpy('doSearch'); +} + describe('SearchBoxComponent', () => { let component: SearchBoxComponent; - let fixture: ComponentFixture; + let host: HostComponent; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ SearchBoxComponent ], + declarations: [ SearchBoxComponent, HostComponent ], providers: [ - { provide: SearchService, useFactory: () => new MockSearchService() }, { provide: LocationService, useFactory: () => new MockLocationService('') } ] }) @@ -22,61 +29,51 @@ describe('SearchBoxComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(SearchBoxComponent); - component = fixture.componentInstance; + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + component = fixture.debugElement.query(By.directive(SearchBoxComponent)).componentInstance; fixture.detectChanges(); }); describe('initialisation', () => { - it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => { - fixture.detectChanges(); // triggers ngOnInit - expect(searchService.initWorker).toHaveBeenCalled(); - expect(searchService.loadIndex).toHaveBeenCalled(); - })); - it('should get the current search query from the location service', inject([LocationService], (location: MockLocationService) => { location.search.and.returnValue({ search: 'initial search' }); - spyOn(component, 'onSearch'); component.ngOnInit(); expect(location.search).toHaveBeenCalled(); - expect(component.onSearch).toHaveBeenCalledWith('initial search'); + expect(host.doSearch).toHaveBeenCalledWith('initial search'); expect(component.searchBox.nativeElement.value).toEqual('initial search'); })); }); describe('on keyup', () => { - it('should call the search service, if it is not triggered by the ESC key', inject([SearchService], (search: MockSearchService) => { + it('should trigger the search event', () => { const input = fixture.debugElement.query(By.css('input')); input.triggerEventHandler('keyup', { target: { value: 'some query' } }); - expect(search.search).toHaveBeenCalledWith('some query'); - })); - - it('should not call the search service if it is triggered by the ESC key', inject([SearchService], (search: MockSearchService) => { - const input = fixture.debugElement.query(By.css('input')); - input.triggerEventHandler('keyup', { target: { value: 'some query' }, which: 27 }); - expect(search.search).not.toHaveBeenCalled(); - })); - - it('should grab focus when the / key is pressed', () => { - const input = fixture.debugElement.query(By.css('input')); - window.document.dispatchEvent(new KeyboardEvent('keyup', { 'key': '/' })); - expect(document.activeElement).toBe(input.nativeElement, 'Search box should be active element'); + expect(host.doSearch).toHaveBeenCalledWith('some query'); }); }); describe('on focus', () => { - it('should call the search service on focus', inject([SearchService], (search: SearchService) => { + it('should trigger the search event', () => { const input = fixture.debugElement.query(By.css('input')); input.triggerEventHandler('focus', { target: { value: 'some query' } }); - expect(search.search).toHaveBeenCalledWith('some query'); - })); + expect(host.doSearch).toHaveBeenCalledWith('some query'); + }); }); describe('on click', () => { - it('should call the search service on click', inject([SearchService], (search: SearchService) => { + it('should trigger the search event', () => { const input = fixture.debugElement.query(By.css('input')); input.triggerEventHandler('click', { target: { value: 'some query'}}); - expect(search.search).toHaveBeenCalledWith('some query'); - })); + expect(host.doSearch).toHaveBeenCalledWith('some query'); + }); + }); + + describe('focus', () => { + it('should set the focus to the input box', () => { + const input = fixture.debugElement.query(By.css('input')); + component.focus(); + expect(document.activeElement).toBe(input.nativeElement); + }); }); }); diff --git a/aio/src/app/search/search-box/search-box.component.ts b/aio/src/app/search/search-box/search-box.component.ts index 91299bb29e..00578803ab 100644 --- a/aio/src/app/search/search-box/search-box.component.ts +++ b/aio/src/app/search/search-box/search-box.component.ts @@ -1,13 +1,10 @@ -import { Component, OnInit, ViewChild, ElementRef, HostListener } from '@angular/core'; -import { SearchService } from 'app/search/search.service'; +import { Component, OnInit, ViewChild, ElementRef, EventEmitter, Output } from '@angular/core'; import { LocationService } from 'app/shared/location.service'; /** * This component provides a text box to type a search query that will be sent to the SearchService. * - * Whatever is typed in this box will be placed in the browser address bar as `?search=...`. - * - * When you arrive at a page containing this component, it will retrieve the query from the browser + * When you arrive at a page containing this component, it will retrieve the `query` from the browser * address bar. If there is a query then this will be made. * * Focussing on the input box will resend whatever query is there. This can be useful if the search @@ -19,20 +16,21 @@ import { LocationService } from 'app/shared/location.service'; template: `` }) export class SearchBoxComponent implements OnInit { @ViewChild('searchBox') searchBox: ElementRef; + @Output() search = new EventEmitter(); - constructor(private searchService: SearchService, private locationService: LocationService) { } + constructor(private locationService: LocationService) { } + /** + * When we first show this search box we trigger a search if there is a search query in the URL + */ ngOnInit() { - this.searchService.initWorker('app/search/search-worker.js'); - this.searchService.loadIndex(); - const query = this.locationService.search()['search']; if (query) { this.searchBox.nativeElement.value = query; @@ -40,17 +38,11 @@ export class SearchBoxComponent implements OnInit { } } - onSearch(query: string, keyCode?: number) { - if (keyCode === 27) { // ignore escape key - return; - } - this.searchService.search(query); + onSearch(query: string) { + this.search.emit(query); } - @HostListener('document:keyup', ['$event']) - onKeyUp($event: KeyboardEvent) { - if ($event.key === '/') { - this.searchBox.nativeElement.focus(); - } + focus() { + this.searchBox.nativeElement.focus(); } } diff --git a/aio/src/app/search/search-results/search-results.component.html b/aio/src/app/search/search-results/search-results.component.html index b9edc5c63a..4692a63758 100644 --- a/aio/src/app/search/search-results/search-results.component.html +++ b/aio/src/app/search/search-results/search-results.component.html @@ -1,6 +1,10 @@ -
+
+
+
+ +

Search Results

-
+

{{area.name}} ({{area.pages.length}})

  • @@ -18,7 +22,8 @@
-
-
+ + +

No results found.

-
+
diff --git a/aio/src/app/search/search-results/search-results.component.spec.ts b/aio/src/app/search/search-results/search-results.component.spec.ts index 3ecc276de8..d191efd890 100644 --- a/aio/src/app/search/search-results/search-results.component.spec.ts +++ b/aio/src/app/search/search-results/search-results.component.spec.ts @@ -9,9 +9,7 @@ import { MockSearchService } from 'testing/search.service'; describe('SearchResultsComponent', () => { let component: SearchResultsComponent; let fixture: ComponentFixture; - let searchService: SearchService; let searchResults: Subject; - let currentAreas: SearchArea[]; /** Get all text from component element */ function getText() { return fixture.debugElement.nativeElement.innerText; } @@ -48,17 +46,15 @@ describe('SearchResultsComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SearchResultsComponent); component = fixture.componentInstance; - searchService = fixture.debugElement.injector.get(SearchService); - searchResults = searchService.searchResults as Subject; + searchResults = TestBed.get(SearchService).searchResults; fixture.detectChanges(); - component.searchAreas.subscribe(areas => currentAreas = areas); }); it('should map the search results into groups based on their containing folder', () => { const results = getTestResults(3); searchResults.next({ query: '', results: results}); - expect(currentAreas).toEqual([ + expect(component.searchAreas).toEqual([ { name: 'api', pages: [ { path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' } ], priorityPages: [] }, @@ -74,7 +70,7 @@ describe('SearchResultsComponent', () => { { path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' }, { path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' }, ]}); - expect(currentAreas).toEqual([ + expect(component.searchAreas).toEqual([ { name: 'tutorial', pages: [ { path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' }, { path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' }, @@ -86,7 +82,7 @@ describe('SearchResultsComponent', () => { const results = getTestResults(5); searchResults.next({ query: '', results: results }); - expect(currentAreas).toEqual([ + expect(component.searchAreas).toEqual([ { name: 'api', pages: [ { path: 'api/c', title: 'API C', type: '', keywords: '', titleWords: '' }, { path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' }, @@ -116,7 +112,7 @@ describe('SearchResultsComponent', () => { ]; searchResults.next({ query: '', results: results }); - expect(currentAreas).toEqual(expected); + expect(component.searchAreas).toEqual(expected); }); it('should put search results with no containing folder into the default area (other)', () => { @@ -125,7 +121,7 @@ describe('SearchResultsComponent', () => { ]; searchResults.next({ query: '', results: results }); - expect(currentAreas).toEqual([ + expect(component.searchAreas).toEqual([ { name: 'other', pages: [ { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' } ], priorityPages: [] } @@ -137,73 +133,30 @@ describe('SearchResultsComponent', () => { { path: 'news', title: undefined, type: 'marketing', keywords: '', titleWords: '' } ]; - searchResults.next({ query: '', results: results }); - expect(currentAreas).toEqual([]); + searchResults.next({ query: 'something', results: results }); + expect(component.searchAreas).toEqual([]); }); - it('should emit an "resultSelected" event when a search result anchor is clicked', () => { - let selectedResult: SearchResult; - component.resultSelected.subscribe((result: SearchResult) => selectedResult = result); - const results = [ - { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' } - ]; + it('should emit a "resultSelected" event when a search result anchor is clicked', () => { + const searchResult = { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }; + let selected: SearchResult; + component.resultSelected.subscribe(result => selected = result); - searchResults.next({ query: '', results: results }); + searchResults.next({ query: 'something', results: [searchResult] }); fixture.detectChanges(); - const anchor = fixture.debugElement.query(By.css('a')); + expect(selected).toBeUndefined(); - anchor.triggerEventHandler('click', {}); - expect(selectedResult).toEqual({ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }); - }); - - it('should clear the results when a search result is clicked', () => { - const results = [ - { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' } - ]; - - searchResults.next({ query: '', results: results }); - fixture.detectChanges(); const anchor = fixture.debugElement.query(By.css('a')); anchor.triggerEventHandler('click', {}); - fixture.detectChanges(); - expect(fixture.debugElement.queryAll(By.css('a'))).toEqual([]); - }); - - describe('hideResults', () => { - it('should clear the results', () => { - const results = [ - { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' } - ]; - - searchResults.next({ query: '', results: results }); - fixture.detectChanges(); - component.hideResults(); - fixture.detectChanges(); - expect(getText()).toBe(''); - }); + expect(selected).toEqual(searchResult); }); describe('when no query results', () => { - it('should display "not found" message', () => { searchResults.next({ query: 'something', results: [] }); fixture.detectChanges(); expect(getText()).toContain('No results'); }); - - it('should not display "not found" message after hideResults()', () => { - searchResults.next({ query: 'something', results: [] }); - fixture.detectChanges(); - component.hideResults(); - fixture.detectChanges(); - expect(getText()).toBe(''); - }); - - it('should not display "not found" message when query is empty', () => { - searchResults.next({ query: '', results: [] }); - fixture.detectChanges(); - expect(getText()).toBe(''); - }); }); }); diff --git a/aio/src/app/search/search-results/search-results.component.ts b/aio/src/app/search/search-results/search-results.component.ts index b5e4fabaa2..6ff179bf82 100644 --- a/aio/src/app/search/search-results/search-results.component.ts +++ b/aio/src/app/search/search-results/search-results.component.ts @@ -1,5 +1,6 @@ -import { Component, ChangeDetectionStrategy, EventEmitter, HostListener, OnInit, Output } from '@angular/core'; -import { ReplaySubject } from 'rxjs/ReplaySubject'; +import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subscription } from 'rxjs/Subscription'; import { SearchResult, SearchResults, SearchService } from '../search.service'; @@ -15,50 +16,41 @@ export interface SearchArea { @Component({ selector: 'aio-search-results', templateUrl: './search-results.component.html', - changeDetection: ChangeDetectionStrategy.OnPush }) -export class SearchResultsComponent implements OnInit { +export class SearchResultsComponent implements OnInit, OnDestroy { + private resultsSubscription: Subscription; readonly defaultArea = 'other'; readonly topLevelFolders = ['guide', 'tutorial']; - notFound = false; - + /** + * Emitted when the user selects a search result + */ @Output() resultSelected = new EventEmitter(); /** * A mapping of the search results grouped into areas */ - searchAreas = new ReplaySubject(1); - hasAreas = this.searchAreas.map(areas => areas.length > 0); + searchAreas: SearchArea[] = []; constructor(private searchService: SearchService) {} ngOnInit() { - this.searchService.searchResults.subscribe(search => this.searchAreas.next(this.processSearchResults(search))); + this.resultsSubscription = this.searchService.searchResults + .subscribe(search => this.searchAreas = this.processSearchResults(search)); } - onResultSelected(result: SearchResult) { - this.resultSelected.emit(result); - this.hideResults(); + ngOnDestroy() { + this.resultsSubscription.unsubscribe(); } - @HostListener('document:keyup', ['$event.which']) - onKeyUp(keyCode: number) { - if (keyCode === 27) { - this.hideResults(); - } - } - - hideResults() { - this.searchAreas.next([]); - this.notFound = false; + onResultSelected(page: SearchResult) { + this.resultSelected.emit(page); } // Map the search results into groups by area private processSearchResults(search: SearchResults) { - this.notFound = search.query.trim() && search.results.length === 0; const searchAreaMap = {}; search.results.forEach(result => { if (!result.title) { return; } // bad data; should fix @@ -72,7 +64,7 @@ export class SearchResultsComponent implements OnInit { const priorityPages = pages.length > 10 ? searchAreaMap[name].slice(0, 5) : []; pages = pages.sort(compareResults); return { name, pages, priorityPages }; - }); + }); } // Split the search result path and use the top level folder, if there is one, as the area name. diff --git a/aio/src/app/search/search.service.ts b/aio/src/app/search/search.service.ts index f3840435e0..7e37b38521 100644 --- a/aio/src/app/search/search.service.ts +++ b/aio/src/app/search/search.service.ts @@ -6,7 +6,7 @@ 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 { Subject } from 'rxjs/Subject'; +import { ReplaySubject } from 'rxjs/ReplaySubject'; import 'rxjs/add/operator/publishLast'; import 'rxjs/add/operator/concatMap'; import { WebWorkerClient } from 'app/shared/web-worker'; @@ -29,7 +29,7 @@ export interface SearchResult { export class SearchService { private worker: WebWorkerClient; private ready: Observable; - private resultsSubject = new Subject(); + private resultsSubject = new ReplaySubject(1); readonly searchResults = this.resultsSubject.asObservable(); constructor(private zone: NgZone) {}