Skip to content

Commit 131c8ab

Browse files
gkalpakjasonaden
authored andcommitted
fix(aio): do not show new document until embedded components are ready (angular#18428)
Previously, the document was shown as soon as the HTML was received, but before the embedded components were ready (e.g. downloaded and instantiated). This caused FOUC (Flash Of Uninstantiated Components). This commit fixes it by preparing the new document in an off-DOM node and swapping the nodes when the embedded components are ready. PR Close angular#18428
1 parent 7d81309 commit 131c8ab

File tree

3 files changed

+205
-37
lines changed

3 files changed

+205
-37
lines changed

aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts

Lines changed: 155 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ describe('DocViewerComponent', () => {
196196
const DOC_WITH_HIDDEN_H1_CONTENT = '<h1><i style="visibility: hidden">link</i>Features</h1>Some content';
197197

198198
const tryDoc = (contents: string, docId = '') => {
199-
docViewerEl.innerHTML = contents;
199+
docViewer.currViewContainer.innerHTML = contents;
200200
docViewer.addTitleAndToc(docId);
201201
};
202202

@@ -229,9 +229,10 @@ describe('DocViewerComponent', () => {
229229
});
230230

231231
it('should fall back to `textContent` if `innerText` is not available', () => {
232-
const querySelector_ = docViewerEl.querySelector;
233-
spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => {
234-
const elem = querySelector_.call(docViewerEl, selector);
232+
const viewContainer = docViewer.currViewContainer;
233+
const querySelector_ = viewContainer.querySelector;
234+
spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => {
235+
const elem = querySelector_.call(viewContainer, selector);
235236
return Object.defineProperties(elem, {
236237
innerText: {value: undefined},
237238
textContent: {value: 'Text Content'},
@@ -244,9 +245,10 @@ describe('DocViewerComponent', () => {
244245
});
245246

246247
it('should still use `innerText` if available but empty', () => {
247-
const querySelector_ = docViewerEl.querySelector;
248-
spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => {
249-
const elem = querySelector_.call(docViewerEl, selector);
248+
const viewContainer = docViewer.currViewContainer;
249+
const querySelector_ = viewContainer.querySelector;
250+
spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => {
251+
const elem = querySelector_.call(viewContainer, selector);
250252
return Object.defineProperties(elem, {
251253
innerText: { value: '' },
252254
textContent: { value: 'Text Content' }
@@ -273,7 +275,7 @@ describe('DocViewerComponent', () => {
273275
expect(tocEl).toBeTruthy();
274276
expect(tocEl.classList.contains('embedded')).toBe(true);
275277
expect(tocService.genToc).toHaveBeenCalledTimes(1);
276-
expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo');
278+
expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo');
277279
});
278280

279281
it('should have no ToC if there is a `.no-toc` `<h1>` heading', () => {
@@ -301,7 +303,7 @@ describe('DocViewerComponent', () => {
301303
tryDoc(DOC_WITH_H1, 'foo');
302304
expect(tocService.reset).toHaveBeenCalledTimes(1);
303305
expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc);
304-
expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo');
306+
expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo');
305307

306308
tocService.genToc.calls.reset();
307309
tryDoc(DOC_WITH_NO_TOC_H1, 'bar');
@@ -351,6 +353,7 @@ describe('DocViewerComponent', () => {
351353
describe('#render()', () => {
352354
let addTitleAndTocSpy: jasmine.Spy;
353355
let embedIntoSpy: jasmine.Spy;
356+
let swapViewsSpy: jasmine.Spy;
354357

355358
const doRender = (contents: string | null, id = 'foo') =>
356359
new Promise<void>((resolve, reject) =>
@@ -361,47 +364,55 @@ describe('DocViewerComponent', () => {
361364

362365
addTitleAndTocSpy = spyOn(docViewer, 'addTitleAndToc');
363366
embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([]));
367+
swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined));
364368
});
365369

366370
it('should return an `Observable`', () => {
367371
expect(docViewer.render({contents: '', id: ''})).toEqual(jasmine.any(Observable));
368372
});
369373

370374
describe('(contents, title, ToC)', () => {
375+
beforeEach(() => swapViewsSpy.and.callThrough());
376+
371377
it('should display the document contents', async () => {
372378
const contents = '<h1>Hello,</h1> <div>world!</div>';
373379
await doRender(contents);
374380

375-
expect(docViewerEl.innerHTML).toBe(contents);
381+
expect(docViewerEl.innerHTML).toContain(contents);
382+
expect(docViewerEl.textContent).toBe('Hello, world!');
376383
});
377384

378385
it('should display nothing if the document has no contents', async () => {
379-
docViewerEl.innerHTML = 'Test';
386+
docViewer.currViewContainer.innerHTML = 'Test';
387+
expect(docViewerEl.textContent).toBe('Test');
388+
380389
await doRender('');
381-
expect(docViewerEl.innerHTML).toBe('');
390+
expect(docViewerEl.textContent).toBe('');
391+
392+
docViewer.currViewContainer.innerHTML = 'Test';
393+
expect(docViewerEl.textContent).toBe('Test');
382394

383-
docViewerEl.innerHTML = 'Test';
384395
await doRender(null);
385-
expect(docViewerEl.innerHTML).toBe('');
396+
expect(docViewerEl.textContent).toBe('');
386397
});
387398

388399
it('should set the title and ToC (after the content has been set)', async () => {
389-
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Foo content'));
400+
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content'));
390401
await doRender('Foo content', 'foo');
391402
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
392403
expect(addTitleAndTocSpy).toHaveBeenCalledWith('foo');
393404

394-
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Bar content'));
405+
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content'));
395406
await doRender('Bar content', 'bar');
396407
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2);
397408
expect(addTitleAndTocSpy).toHaveBeenCalledWith('bar');
398409

399-
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe(''));
410+
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe(''));
400411
await doRender('', 'baz');
401412
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3);
402413
expect(addTitleAndTocSpy).toHaveBeenCalledWith('baz');
403414

404-
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Qux content'));
415+
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content'));
405416
await doRender('Qux content', 'qux');
406417
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4);
407418
expect(addTitleAndTocSpy).toHaveBeenCalledWith('qux');
@@ -412,16 +423,16 @@ describe('DocViewerComponent', () => {
412423
it('should embed components', async () => {
413424
await doRender('Some content');
414425
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
415-
expect(embedIntoSpy).toHaveBeenCalledWith(docViewerEl);
426+
expect(embedIntoSpy).toHaveBeenCalledWith(docViewer.nextViewContainer);
416427
});
417428

418429
it('should attempt to embed components even if the document is empty', async () => {
419430
await doRender('');
420431
await doRender(null);
421432

422433
expect(embedIntoSpy).toHaveBeenCalledTimes(2);
423-
expect(embedIntoSpy.calls.argsFor(0)).toEqual([docViewerEl]);
424-
expect(embedIntoSpy.calls.argsFor(1)).toEqual([docViewerEl]);
434+
expect(embedIntoSpy.calls.argsFor(0)).toEqual([docViewer.nextViewContainer]);
435+
expect(embedIntoSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]);
425436
});
426437

427438
it('should store the embedded components', async () => {
@@ -450,36 +461,149 @@ describe('DocViewerComponent', () => {
450461
});
451462
});
452463

453-
describe('(on error) should log the error and recover', () => {
464+
describe('(swapping views)', () => {
465+
it('should swap the views after creating embedded components', async () => {
466+
await doRender('<div></div>');
467+
468+
expect(swapViewsSpy).toHaveBeenCalledTimes(1);
469+
expect(embedIntoSpy).toHaveBeenCalledBefore(swapViewsSpy);
470+
});
471+
472+
it('should still swap the views if the document is empty', async () => {
473+
await doRender('');
474+
expect(swapViewsSpy).toHaveBeenCalledTimes(1);
475+
476+
await doRender(null);
477+
expect(swapViewsSpy).toHaveBeenCalledTimes(2);
478+
});
479+
480+
it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => {
481+
const obs = new ObservableWithSubscriptionSpies();
482+
swapViewsSpy.and.returnValue(obs);
483+
484+
const renderObservable = docViewer.render({contents: 'Hello, world!', id: 'foo'});
485+
const subscription = renderObservable.subscribe();
486+
487+
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
488+
expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
489+
490+
subscription.unsubscribe();
491+
492+
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
493+
expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
494+
});
495+
});
496+
497+
describe('(on error) should clean up, log the error and recover', () => {
454498
let logger: MockLogger;
455499

456500
beforeEach(() => logger = TestBed.get(Logger));
457501

458-
it('when `addTitleAndToc()` fails', async () => {
459-
const error = Error('Typical `addTitleAndToc()` error');
460-
addTitleAndTocSpy.and.callFake(() => { throw error; });
502+
it('when `EmbedComponentsService.embedInto()` fails', async () => {
503+
const error = Error('Typical `embedInto()` error');
504+
embedIntoSpy.and.callFake(() => {
505+
expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
506+
throw error;
507+
});
461508

462509
await doRender('Some content', 'foo');
463510

464-
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
465-
expect(embedIntoSpy).not.toHaveBeenCalled();
511+
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
512+
expect(swapViewsSpy).not.toHaveBeenCalled();
513+
expect(addTitleAndTocSpy).not.toHaveBeenCalled();
514+
expect(docViewer.nextViewContainer.innerHTML).toBe('');
466515
expect(logger.output.error).toEqual([
467516
['[DocViewer]: Error preparing document \'foo\'.', error],
468517
]);
469518
});
470519

471-
it('when `EmbedComponentsService#embedInto()` fails', async () => {
472-
const error = Error('Typical `embedInto()` error');
473-
embedIntoSpy.and.callFake(() => { throw error; });
520+
it('when `swapViews()` fails', async () => {
521+
const error = Error('Typical `swapViews()` error');
522+
swapViewsSpy.and.callFake(() => {
523+
expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
524+
throw error;
525+
});
474526

475527
await doRender('Some content', 'bar');
476528

477-
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
478529
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
530+
expect(swapViewsSpy).toHaveBeenCalledTimes(1);
531+
expect(addTitleAndTocSpy).not.toHaveBeenCalled();
532+
expect(docViewer.nextViewContainer.innerHTML).toBe('');
479533
expect(logger.output.error).toEqual([
480534
['[DocViewer]: Error preparing document \'bar\'.', error],
481535
]);
482536
});
537+
538+
it('when `addTitleAndTocSpy()` fails', async () => {
539+
const error = Error('Typical `addTitleAndToc()` error');
540+
addTitleAndTocSpy.and.callFake(() => {
541+
expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
542+
throw error;
543+
});
544+
545+
await doRender('Some content', 'baz');
546+
547+
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
548+
expect(swapViewsSpy).toHaveBeenCalledTimes(1);
549+
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
550+
expect(docViewer.nextViewContainer.innerHTML).toBe('');
551+
expect(logger.output.error).toEqual([
552+
['[DocViewer]: Error preparing document \'baz\'.', error],
553+
]);
554+
});
555+
});
556+
});
557+
558+
describe('#swapViews()', () => {
559+
let oldCurrViewContainer: HTMLElement;
560+
let oldNextViewContainer: HTMLElement;
561+
562+
const doSwapViews = () => new Promise<void>((resolve, reject) =>
563+
docViewer.swapViews().subscribe(resolve, reject));
564+
565+
beforeEach(() => {
566+
oldCurrViewContainer = docViewer.currViewContainer;
567+
oldNextViewContainer = docViewer.nextViewContainer;
568+
569+
oldCurrViewContainer.innerHTML = 'Current view';
570+
oldNextViewContainer.innerHTML = 'Next view';
571+
572+
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true);
573+
expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
574+
});
575+
576+
it('should return an observable', done => {
577+
docViewer.swapViews().subscribe(done, done.fail);
578+
});
579+
580+
it('should swap the views', async () => {
581+
await doSwapViews();
582+
583+
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
584+
expect(docViewerEl.contains(oldNextViewContainer)).toBe(true);
585+
expect(docViewer.currViewContainer).toBe(oldNextViewContainer);
586+
expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer);
587+
588+
await doSwapViews();
589+
590+
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true);
591+
expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
592+
expect(docViewer.currViewContainer).toBe(oldCurrViewContainer);
593+
expect(docViewer.nextViewContainer).toBe(oldNextViewContainer);
594+
});
595+
596+
it('should empty the previous view', async () => {
597+
await doSwapViews();
598+
599+
expect(docViewer.currViewContainer.innerHTML).toBe('Next view');
600+
expect(docViewer.nextViewContainer.innerHTML).toBe('');
601+
602+
docViewer.nextViewContainer.innerHTML = 'Next view 2';
603+
await doSwapViews();
604+
605+
expect(docViewer.currViewContainer.innerHTML).toBe('Next view 2');
606+
expect(docViewer.nextViewContainer.innerHTML).toBe('');
483607
});
484608
});
485609
});

0 commit comments

Comments
 (0)