Skip to content

Commit 0677f82

Browse files
committed
feat: handle url changes for the axLazyElement directive
1 parent 97cfb76 commit 0677f82

File tree

2 files changed

+116
-56
lines changed

2 files changed

+116
-56
lines changed

projects/elements/src/lib/lazy-elements/lazy-element/lazy-element.directive.spec.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import { Component, CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
2-
import {
3-
ComponentFixture,
4-
fakeAsync,
5-
flushMicrotasks,
6-
TestBed,
7-
waitForAsync,
8-
} from '@angular/core/testing';
2+
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
93

104
import { LazyElementsModule } from '../lazy-elements.module';
5+
import { LazyElementsLoaderService } from '../lazy-elements-loader.service';
116

127
@Component({
138
template: ` <p class="loading">Spinner...</p> `,
@@ -74,6 +69,9 @@ class TestModule {}
7469
<div *ngIf="useElementConfig">
7570
<some-configured-element *axLazyElement></some-configured-element>
7671
</div>
72+
<div *ngIf="useUrlBinding">
73+
<some-configured-element *axLazyElement="url"></some-configured-element>
74+
</div>
7775
`,
7876
})
7977
class TestHostComponent {
@@ -84,13 +82,17 @@ class TestHostComponent {
8482
useModule = false;
8583
useImportMap = false;
8684
useElementConfig = false;
85+
86+
useUrlBinding = false;
87+
url: string | null = null;
8788
}
8889

8990
describe('LazyElementDirective', () => {
9091
let testHostComponent: TestHostComponent;
9192
let fixture: ComponentFixture<TestHostComponent>;
9293
let appendChildSpy: jest.SpyInstance;
9394
let whenDefinedSpy: jest.SpyInstance;
95+
let requestAnimationFrameSpy: jest.SpyInstance;
9496

9597
function getAppendChildFirstScript(): HTMLScriptElement {
9698
return appendChildSpy.mock.calls[0][0];
@@ -128,12 +130,20 @@ describe('LazyElementDirective', () => {
128130
whenDefinedSpy = jest
129131
.spyOn(customElements, 'whenDefined')
130132
.mockReturnValue(Promise.resolve());
133+
requestAnimationFrameSpy = jest
134+
.spyOn(window, 'requestAnimationFrame')
135+
.mockImplementation((callback) => {
136+
const time = 0;
137+
callback(time);
138+
return time;
139+
});
131140
fixture.detectChanges();
132141
});
133142

134143
afterEach(() => {
135144
appendChildSpy.mockRestore();
136145
whenDefinedSpy.mockRestore();
146+
requestAnimationFrameSpy.mockRestore();
137147
});
138148

139149
it('should create', () => {
@@ -269,4 +279,29 @@ describe('LazyElementDirective', () => {
269279

270280
expect(document.querySelector('.loading').textContent).toBe('Spinner...');
271281
});
282+
283+
it('should load another element when the `url` binding changes', () => {
284+
// Arrange
285+
const elementsLoaderService = TestBed.inject(LazyElementsLoaderService);
286+
const loadElementSpy = jest.spyOn(elementsLoaderService, 'loadElement');
287+
288+
// Act
289+
testHostComponent.useUrlBinding = true;
290+
testHostComponent.url =
291+
'http://elements.com/some-configured-element-module';
292+
fixture.detectChanges();
293+
294+
testHostComponent.url =
295+
'http://elements.com/some-configured-element-module-es2015';
296+
fixture.detectChanges();
297+
298+
// Assert
299+
expect(loadElementSpy).toHaveBeenCalledTimes(2);
300+
expect(loadElementSpy.mock.calls[0][0]).toBe(
301+
'http://elements.com/some-configured-element-module'
302+
);
303+
expect(loadElementSpy.mock.calls[1][0]).toBe(
304+
'http://elements.com/some-configured-element-module-es2015'
305+
);
306+
});
272307
});

projects/elements/src/lib/lazy-elements/lazy-element/lazy-element.directive.ts

Lines changed: 74 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@ import {
1212
ViewContainerRef,
1313
} from '@angular/core';
1414
import { isPlatformServer } from '@angular/common';
15-
import { from, Subscription } from 'rxjs';
16-
import { mergeMap } from 'rxjs/operators';
15+
import {
16+
animationFrameScheduler,
17+
BehaviorSubject,
18+
EMPTY,
19+
from,
20+
Subscription,
21+
} from 'rxjs';
22+
import { catchError, debounceTime, mergeMap, switchMap } from 'rxjs/operators';
1723

1824
import {
1925
ElementConfig,
@@ -26,14 +32,19 @@ const LOG_PREFIX = '@angular-extensions/elements';
2632
selector: '[axLazyElement]',
2733
})
2834
export class LazyElementDirective implements OnInit, OnDestroy {
29-
@Input('axLazyElement') url: string;
35+
@Input('axLazyElement')
36+
set url(url: string) {
37+
this.url$.next(url);
38+
}
39+
3040
@Input('axLazyElementLoadingTemplate') loadingTemplateRef: TemplateRef<any>; // eslint-disable-line @angular-eslint/no-input-rename
3141
@Input('axLazyElementErrorTemplate') errorTemplateRef: TemplateRef<any>; // eslint-disable-line @angular-eslint/no-input-rename
3242
@Input('axLazyElementModule') isModule: boolean | undefined; // eslint-disable-line @angular-eslint/no-input-rename
3343
@Input('axLazyElementImportMap') importMap: boolean | undefined; // eslint-disable-line @angular-eslint/no-input-rename
3444

3545
private viewRef: EmbeddedViewRef<any> = null;
3646
private subscription = Subscription.EMPTY;
47+
private url$ = new BehaviorSubject<string | null>(null);
3748

3849
constructor(
3950
@Inject(PLATFORM_ID) private platformId: string,
@@ -53,6 +64,22 @@ export class LazyElementDirective implements OnInit, OnDestroy {
5364
return;
5465
}
5566

67+
this.setupUrlListener();
68+
}
69+
70+
ngOnDestroy(): void {
71+
this.subscription.unsubscribe();
72+
}
73+
74+
destroyEmbeddedView() {
75+
if (this.viewRef && !this.viewRef.destroyed) {
76+
this.viewRef.detach();
77+
this.viewRef.destroy();
78+
this.viewRef = null;
79+
}
80+
}
81+
82+
private setupUrlListener(): void {
5683
const tpl = this.template as any;
5784
const elementTag = tpl._declarationTContainer
5885
? tpl._declarationTContainer.tagName || tpl._declarationTContainer.value
@@ -65,60 +92,58 @@ export class LazyElementDirective implements OnInit, OnDestroy {
6592
const loadingComponent =
6693
elementConfig.loadingComponent || options.loadingComponent;
6794

68-
if (this.loadingTemplateRef) {
69-
this.vcr.createEmbeddedView(this.loadingTemplateRef);
70-
} else if (loadingComponent) {
71-
const factory = this.cfr.resolveComponentFactory(loadingComponent);
72-
this.vcr.createComponent(factory);
73-
}
95+
this.subscription = this.url$
96+
.pipe(
97+
// This is used to coalesce changes since the `url$` subject might emit multiple values initially, e.g.
98+
// `null` (initial value) and the url itself (when the `url` binding is provided).
99+
// The `animationFrameScheduler` is used to prevent the frame drop.
100+
debounceTime(0, animationFrameScheduler),
101+
switchMap((url) => {
102+
if (this.loadingTemplateRef) {
103+
this.vcr.createEmbeddedView(this.loadingTemplateRef);
104+
} else if (loadingComponent) {
105+
const factory = this.cfr.resolveComponentFactory(loadingComponent);
106+
this.vcr.createComponent(factory);
107+
}
74108

75-
const loadElement$ = from(
76-
this.elementsLoaderService.loadElement(
77-
this.url,
78-
elementTag,
79-
this.isModule,
80-
this.importMap,
81-
elementConfig?.hooks
109+
return from(
110+
this.elementsLoaderService.loadElement(
111+
url,
112+
elementTag,
113+
this.isModule,
114+
this.importMap,
115+
elementConfig?.hooks
116+
)
117+
).pipe(
118+
catchError(() => {
119+
this.vcr.clear();
120+
const errorComponent =
121+
elementConfig.errorComponent || options.errorComponent;
122+
if (this.errorTemplateRef) {
123+
this.vcr.createEmbeddedView(this.errorTemplateRef);
124+
this.cdr.markForCheck();
125+
} else if (errorComponent) {
126+
const factory =
127+
this.cfr.resolveComponentFactory(errorComponent);
128+
this.vcr.createComponent(factory);
129+
this.cdr.markForCheck();
130+
} else if (ngDevMode) {
131+
console.error(
132+
`${LOG_PREFIX} - Loading of element <${elementTag}> failed, please provide <ng-template #error>Loading failed...</ng-template> and reference it in *axLazyElement="errorTemplate: error" to display customized error message in place of element`
133+
);
134+
}
135+
return EMPTY;
136+
})
137+
);
138+
}),
139+
mergeMap(() => customElements.whenDefined(elementTag))
82140
)
83-
);
84-
85-
this.subscription = loadElement$
86-
.pipe(mergeMap(() => customElements.whenDefined(elementTag)))
87141
.subscribe({
88142
next: () => {
89143
this.vcr.clear();
90144
this.viewRef = this.vcr.createEmbeddedView(this.template);
91145
this.cdr.markForCheck();
92146
},
93-
error: () => {
94-
this.vcr.clear();
95-
const errorComponent =
96-
elementConfig.errorComponent || options.errorComponent;
97-
if (this.errorTemplateRef) {
98-
this.vcr.createEmbeddedView(this.errorTemplateRef);
99-
this.cdr.markForCheck();
100-
} else if (errorComponent) {
101-
const factory = this.cfr.resolveComponentFactory(errorComponent);
102-
this.vcr.createComponent(factory);
103-
this.cdr.markForCheck();
104-
} else if (ngDevMode) {
105-
console.error(
106-
`${LOG_PREFIX} - Loading of element <${elementTag}> failed, please provide <ng-template #error>Loading failed...</ng-template> and reference it in *axLazyElement="errorTemplate: error" to display customized error message in place of element`
107-
);
108-
}
109-
},
110147
});
111148
}
112-
113-
ngOnDestroy(): void {
114-
this.subscription.unsubscribe();
115-
}
116-
117-
destroyEmbeddedView() {
118-
if (this.viewRef && !this.viewRef.destroyed) {
119-
this.viewRef.detach();
120-
this.viewRef.destroy();
121-
this.viewRef = null;
122-
}
123-
}
124149
}

0 commit comments

Comments
 (0)