diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts
index b04ed6bb24..009299fff8 100644
--- a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts
+++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts
@@ -196,7 +196,7 @@ describe('DocViewerComponent', () => {
const DOC_WITH_HIDDEN_H1_CONTENT = '
linkFeatures
Some content';
const tryDoc = (contents: string, docId = '') => {
- docViewerEl.innerHTML = contents;
+ docViewer.currViewContainer.innerHTML = contents;
docViewer.addTitleAndToc(docId);
};
@@ -229,9 +229,10 @@ describe('DocViewerComponent', () => {
});
it('should fall back to `textContent` if `innerText` is not available', () => {
- const querySelector_ = docViewerEl.querySelector;
- spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => {
- const elem = querySelector_.call(docViewerEl, selector);
+ const viewContainer = docViewer.currViewContainer;
+ const querySelector_ = viewContainer.querySelector;
+ spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => {
+ const elem = querySelector_.call(viewContainer, selector);
return Object.defineProperties(elem, {
innerText: {value: undefined},
textContent: {value: 'Text Content'},
@@ -244,9 +245,10 @@ describe('DocViewerComponent', () => {
});
it('should still use `innerText` if available but empty', () => {
- const querySelector_ = docViewerEl.querySelector;
- spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => {
- const elem = querySelector_.call(docViewerEl, selector);
+ const viewContainer = docViewer.currViewContainer;
+ const querySelector_ = viewContainer.querySelector;
+ spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => {
+ const elem = querySelector_.call(viewContainer, selector);
return Object.defineProperties(elem, {
innerText: { value: '' },
textContent: { value: 'Text Content' }
@@ -273,7 +275,7 @@ describe('DocViewerComponent', () => {
expect(tocEl).toBeTruthy();
expect(tocEl.classList.contains('embedded')).toBe(true);
expect(tocService.genToc).toHaveBeenCalledTimes(1);
- expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo');
+ expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo');
});
it('should have no ToC if there is a `.no-toc` `` heading', () => {
@@ -301,7 +303,7 @@ describe('DocViewerComponent', () => {
tryDoc(DOC_WITH_H1, 'foo');
expect(tocService.reset).toHaveBeenCalledTimes(1);
expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc);
- expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo');
+ expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo');
tocService.genToc.calls.reset();
tryDoc(DOC_WITH_NO_TOC_H1, 'bar');
@@ -351,6 +353,7 @@ describe('DocViewerComponent', () => {
describe('#render()', () => {
let addTitleAndTocSpy: jasmine.Spy;
let embedIntoSpy: jasmine.Spy;
+ let swapViewsSpy: jasmine.Spy;
const doRender = (contents: string | null, id = 'foo') =>
new Promise((resolve, reject) =>
@@ -361,6 +364,7 @@ describe('DocViewerComponent', () => {
addTitleAndTocSpy = spyOn(docViewer, 'addTitleAndToc');
embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([]));
+ swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined));
});
it('should return an `Observable`', () => {
@@ -368,40 +372,47 @@ describe('DocViewerComponent', () => {
});
describe('(contents, title, ToC)', () => {
+ beforeEach(() => swapViewsSpy.and.callThrough());
+
it('should display the document contents', async () => {
const contents = 'Hello,
world!
';
await doRender(contents);
- expect(docViewerEl.innerHTML).toBe(contents);
+ expect(docViewerEl.innerHTML).toContain(contents);
+ expect(docViewerEl.textContent).toBe('Hello, world!');
});
it('should display nothing if the document has no contents', async () => {
- docViewerEl.innerHTML = 'Test';
- await doRender('');
- expect(docViewerEl.innerHTML).toBe('');
+ docViewer.currViewContainer.innerHTML = 'Test';
+ expect(docViewerEl.textContent).toBe('Test');
+
+ await doRender('');
+ expect(docViewerEl.textContent).toBe('');
+
+ docViewer.currViewContainer.innerHTML = 'Test';
+ expect(docViewerEl.textContent).toBe('Test');
- docViewerEl.innerHTML = 'Test';
await doRender(null);
- expect(docViewerEl.innerHTML).toBe('');
+ expect(docViewerEl.textContent).toBe('');
});
it('should set the title and ToC (after the content has been set)', async () => {
- addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Foo content'));
+ addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content'));
await doRender('Foo content', 'foo');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('foo');
- addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Bar content'));
+ addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content'));
await doRender('Bar content', 'bar');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('bar');
- addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe(''));
+ addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe(''));
await doRender('', 'baz');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('baz');
- addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Qux content'));
+ addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content'));
await doRender('Qux content', 'qux');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('qux');
@@ -412,7 +423,7 @@ describe('DocViewerComponent', () => {
it('should embed components', async () => {
await doRender('Some content');
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
- expect(embedIntoSpy).toHaveBeenCalledWith(docViewerEl);
+ expect(embedIntoSpy).toHaveBeenCalledWith(docViewer.nextViewContainer);
});
it('should attempt to embed components even if the document is empty', async () => {
@@ -420,8 +431,8 @@ describe('DocViewerComponent', () => {
await doRender(null);
expect(embedIntoSpy).toHaveBeenCalledTimes(2);
- expect(embedIntoSpy.calls.argsFor(0)).toEqual([docViewerEl]);
- expect(embedIntoSpy.calls.argsFor(1)).toEqual([docViewerEl]);
+ expect(embedIntoSpy.calls.argsFor(0)).toEqual([docViewer.nextViewContainer]);
+ expect(embedIntoSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]);
});
it('should store the embedded components', async () => {
@@ -450,36 +461,149 @@ describe('DocViewerComponent', () => {
});
});
- describe('(on error) should log the error and recover', () => {
+ describe('(swapping views)', () => {
+ it('should swap the views after creating embedded components', async () => {
+ await doRender('');
+
+ expect(swapViewsSpy).toHaveBeenCalledTimes(1);
+ expect(embedIntoSpy).toHaveBeenCalledBefore(swapViewsSpy);
+ });
+
+ it('should still swap the views if the document is empty', async () => {
+ await doRender('');
+ expect(swapViewsSpy).toHaveBeenCalledTimes(1);
+
+ await doRender(null);
+ expect(swapViewsSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => {
+ const obs = new ObservableWithSubscriptionSpies();
+ swapViewsSpy.and.returnValue(obs);
+
+ const renderObservable = docViewer.render({contents: 'Hello, world!', id: 'foo'});
+ const subscription = renderObservable.subscribe();
+
+ expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
+ expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
+
+ subscription.unsubscribe();
+
+ expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
+ expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('(on error) should clean up, log the error and recover', () => {
let logger: MockLogger;
beforeEach(() => logger = TestBed.get(Logger));
- it('when `addTitleAndToc()` fails', async () => {
- const error = Error('Typical `addTitleAndToc()` error');
- addTitleAndTocSpy.and.callFake(() => { throw error; });
+ it('when `EmbedComponentsService.embedInto()` fails', async () => {
+ const error = Error('Typical `embedInto()` error');
+ embedIntoSpy.and.callFake(() => {
+ expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
+ throw error;
+ });
await doRender('Some content', 'foo');
- expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
- expect(embedIntoSpy).not.toHaveBeenCalled();
+ expect(embedIntoSpy).toHaveBeenCalledTimes(1);
+ expect(swapViewsSpy).not.toHaveBeenCalled();
+ expect(addTitleAndTocSpy).not.toHaveBeenCalled();
+ expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'foo\'.', error],
]);
});
- it('when `EmbedComponentsService#embedInto()` fails', async () => {
- const error = Error('Typical `embedInto()` error');
- embedIntoSpy.and.callFake(() => { throw error; });
+ it('when `swapViews()` fails', async () => {
+ const error = Error('Typical `swapViews()` error');
+ swapViewsSpy.and.callFake(() => {
+ expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
+ throw error;
+ });
await doRender('Some content', 'bar');
- expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
+ expect(swapViewsSpy).toHaveBeenCalledTimes(1);
+ expect(addTitleAndTocSpy).not.toHaveBeenCalled();
+ expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'bar\'.', error],
]);
});
+
+ it('when `addTitleAndTocSpy()` fails', async () => {
+ const error = Error('Typical `addTitleAndToc()` error');
+ addTitleAndTocSpy.and.callFake(() => {
+ expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
+ throw error;
+ });
+
+ await doRender('Some content', 'baz');
+
+ expect(embedIntoSpy).toHaveBeenCalledTimes(1);
+ expect(swapViewsSpy).toHaveBeenCalledTimes(1);
+ expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
+ expect(docViewer.nextViewContainer.innerHTML).toBe('');
+ expect(logger.output.error).toEqual([
+ ['[DocViewer]: Error preparing document \'baz\'.', error],
+ ]);
+ });
+ });
+ });
+
+ describe('#swapViews()', () => {
+ let oldCurrViewContainer: HTMLElement;
+ let oldNextViewContainer: HTMLElement;
+
+ const doSwapViews = () => new Promise((resolve, reject) =>
+ docViewer.swapViews().subscribe(resolve, reject));
+
+ beforeEach(() => {
+ oldCurrViewContainer = docViewer.currViewContainer;
+ oldNextViewContainer = docViewer.nextViewContainer;
+
+ oldCurrViewContainer.innerHTML = 'Current view';
+ oldNextViewContainer.innerHTML = 'Next view';
+
+ expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true);
+ expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
+ });
+
+ it('should return an observable', done => {
+ docViewer.swapViews().subscribe(done, done.fail);
+ });
+
+ it('should swap the views', async () => {
+ await doSwapViews();
+
+ expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
+ expect(docViewerEl.contains(oldNextViewContainer)).toBe(true);
+ expect(docViewer.currViewContainer).toBe(oldNextViewContainer);
+ expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer);
+
+ await doSwapViews();
+
+ expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true);
+ expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
+ expect(docViewer.currViewContainer).toBe(oldCurrViewContainer);
+ expect(docViewer.nextViewContainer).toBe(oldNextViewContainer);
+ });
+
+ it('should empty the previous view', async () => {
+ await doSwapViews();
+
+ expect(docViewer.currViewContainer.innerHTML).toBe('Next view');
+ expect(docViewer.nextViewContainer.innerHTML).toBe('');
+
+ docViewer.nextViewContainer.innerHTML = 'Next view 2';
+ await doSwapViews();
+
+ expect(docViewer.currViewContainer.innerHTML).toBe('Next view 2');
+ expect(docViewer.nextViewContainer.innerHTML).toBe('');
});
});
});
diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts
index 6de60a853e..0130ebd2a6 100644
--- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts
+++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts
@@ -33,6 +33,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
private docContents$ = new EventEmitter();
protected embeddedComponentRefs: ComponentRef[] = [];
+ protected currViewContainer: HTMLElement = document.createElement('div');
+ protected nextViewContainer: HTMLElement = document.createElement('div');
@Input()
set doc(newDoc: DocumentContents) {
@@ -57,6 +59,12 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
this.hostElement.innerHTML = initialDocViewerContent;
+ if (this.hostElement.firstElementChild) {
+ this.currViewContainer = this.hostElement.firstElementChild as HTMLElement;
+ } else {
+ this.hostElement.appendChild(this.currViewContainer);
+ }
+
this.onDestroy$.subscribe(() => this.destroyEmbeddedComponents());
this.docContents$
.do(() => this.destroyEmbeddedComponents())
@@ -79,7 +87,7 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
*/
protected addTitleAndToc(docId: string): void {
this.tocService.reset();
- const titleEl = this.hostElement.querySelector('h1');
+ const titleEl = this.currViewContainer.querySelector('h1');
let title = '';
// Only create TOC for docs with an title
@@ -87,7 +95,7 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
if (titleEl) {
title = (typeof titleEl.innerText === 'string') ? titleEl.innerText : titleEl.textContent;
if (!/(no-toc|notoc)/i.test(titleEl.className)) {
- this.tocService.genToc(this.hostElement, docId);
+ this.tocService.genToc(this.currViewContainer, docId);
titleEl.insertAdjacentHTML('afterend', '');
}
}
@@ -111,15 +119,48 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
.do(() => {
// Security: `doc.contents` is always authored by the documentation team
// and is considered to be safe.
- this.hostElement.innerHTML = doc.contents || '';
- this.addTitleAndToc(doc.id);
+ this.nextViewContainer.innerHTML = doc.contents || '';
})
- .switchMap(() => this.embedComponentsService.embedInto(this.hostElement))
+ .switchMap(() => this.embedComponentsService.embedInto(this.nextViewContainer))
.do(componentRefs => this.embeddedComponentRefs = componentRefs)
- .switchMap(() => this.void$)
+ .switchMap(() => this.swapViews())
+ .do(() => this.addTitleAndToc(doc.id))
.catch(err => {
+ this.nextViewContainer.innerHTML = '';
this.logger.error(`[DocViewer]: Error preparing document '${doc.id}'.`, err);
return this.void$;
});
}
+
+ /**
+ * Swap the views, removing `currViewContainer` and inserting `nextViewContainer`.
+ * (At this point all content should be ready, including having loaded and instantiated embedded
+ * components.)
+ */
+ protected swapViews(): Observable {
+ // Placeholders for actual animations.
+ const animateLeave = (elem: HTMLElement) => this.void$;
+ const animateEnter = (elem: HTMLElement) => this.void$;
+
+ let done$ = this.void$;
+
+ if (this.currViewContainer.parentElement) {
+ done$ = done$
+ // Remove the current view from the viewer.
+ .switchMap(() => animateLeave(this.currViewContainer))
+ .do(() => this.currViewContainer.parentElement.removeChild(this.currViewContainer));
+ }
+
+ return done$
+ // Insert the next view into the viewer.
+ .do(() => this.hostElement.appendChild(this.nextViewContainer))
+ .switchMap(() => animateEnter(this.nextViewContainer))
+ // Update the view references and clean up unused nodes.
+ .do(() => {
+ const prevViewContainer = this.currViewContainer;
+ this.currViewContainer = this.nextViewContainer;
+ this.nextViewContainer = prevViewContainer;
+ this.nextViewContainer.innerHTML = ''; // Empty to release memory.
+ });
+ }
}
diff --git a/aio/src/testing/doc-viewer-utils.ts b/aio/src/testing/doc-viewer-utils.ts
index 3c4c289bdb..1240b489ac 100644
--- a/aio/src/testing/doc-viewer-utils.ts
+++ b/aio/src/testing/doc-viewer-utils.ts
@@ -18,10 +18,13 @@ import { MockLogger } from 'testing/logger.service';
export class TestDocViewerComponent extends DocViewerComponent {
embeddedComponentRefs: ComponentRef[];
+ currViewContainer: HTMLElement;
+ nextViewContainer: HTMLElement;
addTitleAndToc(docId: string): void { return null as any; }
destroyEmbeddedComponents(): void { return null as any; }
render(doc: DocumentContents): Observable { return null as any; }
+ swapViews(): Observable { return null as any; }
}