Skip to content

Commit d96beb2

Browse files
authored
Merge pull request #99 from angular-extensions/feat/url-changes
feat: handle url changes for the `axLazyElement` directive
2 parents 97cfb76 + de86e11 commit d96beb2

File tree

2 files changed

+120
-56
lines changed

2 files changed

+120
-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: 78 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,23 @@ import {
55
EmbeddedViewRef,
66
Inject,
77
Input,
8+
OnChanges,
89
OnDestroy,
910
OnInit,
1011
PLATFORM_ID,
12+
SimpleChanges,
1113
TemplateRef,
1214
ViewContainerRef,
1315
} from '@angular/core';
1416
import { isPlatformServer } from '@angular/common';
15-
import { from, Subscription } from 'rxjs';
16-
import { mergeMap } from 'rxjs/operators';
17+
import {
18+
animationFrameScheduler,
19+
BehaviorSubject,
20+
EMPTY,
21+
from,
22+
Subscription,
23+
} from 'rxjs';
24+
import { catchError, debounceTime, mergeMap, switchMap } from 'rxjs/operators';
1725

1826
import {
1927
ElementConfig,
@@ -25,7 +33,7 @@ const LOG_PREFIX = '@angular-extensions/elements';
2533
@Directive({
2634
selector: '[axLazyElement]',
2735
})
28-
export class LazyElementDirective implements OnInit, OnDestroy {
36+
export class LazyElementDirective implements OnChanges, OnInit, OnDestroy {
2937
@Input('axLazyElement') url: string;
3038
@Input('axLazyElementLoadingTemplate') loadingTemplateRef: TemplateRef<any>; // eslint-disable-line @angular-eslint/no-input-rename
3139
@Input('axLazyElementErrorTemplate') errorTemplateRef: TemplateRef<any>; // eslint-disable-line @angular-eslint/no-input-rename
@@ -34,6 +42,7 @@ export class LazyElementDirective implements OnInit, OnDestroy {
3442

3543
private viewRef: EmbeddedViewRef<any> = null;
3644
private subscription = Subscription.EMPTY;
45+
private url$ = new BehaviorSubject<string | null>(null);
3746

3847
constructor(
3948
@Inject(PLATFORM_ID) private platformId: string,
@@ -44,6 +53,12 @@ export class LazyElementDirective implements OnInit, OnDestroy {
4453
private cdr: ChangeDetectorRef
4554
) {}
4655

56+
ngOnChanges(changes: SimpleChanges): void {
57+
if (changes.url) {
58+
this.url$.next(this.url);
59+
}
60+
}
61+
4762
ngOnInit() {
4863
// There's no sense to execute the below logic on the Node.js side since the JavaScript
4964
// will not be loaded on the server-side (Angular will only append the script to body).
@@ -53,6 +68,22 @@ export class LazyElementDirective implements OnInit, OnDestroy {
5368
return;
5469
}
5570

71+
this.setupUrlListener();
72+
}
73+
74+
ngOnDestroy(): void {
75+
this.subscription.unsubscribe();
76+
}
77+
78+
destroyEmbeddedView() {
79+
if (this.viewRef && !this.viewRef.destroyed) {
80+
this.viewRef.detach();
81+
this.viewRef.destroy();
82+
this.viewRef = null;
83+
}
84+
}
85+
86+
private setupUrlListener(): void {
5687
const tpl = this.template as any;
5788
const elementTag = tpl._declarationTContainer
5889
? tpl._declarationTContainer.tagName || tpl._declarationTContainer.value
@@ -65,60 +96,58 @@ export class LazyElementDirective implements OnInit, OnDestroy {
6596
const loadingComponent =
6697
elementConfig.loadingComponent || options.loadingComponent;
6798

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-
}
99+
this.subscription = this.url$
100+
.pipe(
101+
// This is used to coalesce changes since the `url$` subject might emit multiple values initially, e.g.
102+
// `null` (initial value) and the url itself (when the `url` binding is provided).
103+
// The `animationFrameScheduler` is used to prevent the frame drop.
104+
debounceTime(0, animationFrameScheduler),
105+
switchMap((url) => {
106+
if (this.loadingTemplateRef) {
107+
this.vcr.createEmbeddedView(this.loadingTemplateRef);
108+
} else if (loadingComponent) {
109+
const factory = this.cfr.resolveComponentFactory(loadingComponent);
110+
this.vcr.createComponent(factory);
111+
}
74112

75-
const loadElement$ = from(
76-
this.elementsLoaderService.loadElement(
77-
this.url,
78-
elementTag,
79-
this.isModule,
80-
this.importMap,
81-
elementConfig?.hooks
113+
return from(
114+
this.elementsLoaderService.loadElement(
115+
url,
116+
elementTag,
117+
this.isModule,
118+
this.importMap,
119+
elementConfig?.hooks
120+
)
121+
).pipe(
122+
catchError(() => {
123+
this.vcr.clear();
124+
const errorComponent =
125+
elementConfig.errorComponent || options.errorComponent;
126+
if (this.errorTemplateRef) {
127+
this.vcr.createEmbeddedView(this.errorTemplateRef);
128+
this.cdr.markForCheck();
129+
} else if (errorComponent) {
130+
const factory =
131+
this.cfr.resolveComponentFactory(errorComponent);
132+
this.vcr.createComponent(factory);
133+
this.cdr.markForCheck();
134+
} else if (ngDevMode) {
135+
console.error(
136+
`${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`
137+
);
138+
}
139+
return EMPTY;
140+
})
141+
);
142+
}),
143+
mergeMap(() => customElements.whenDefined(elementTag))
82144
)
83-
);
84-
85-
this.subscription = loadElement$
86-
.pipe(mergeMap(() => customElements.whenDefined(elementTag)))
87145
.subscribe({
88146
next: () => {
89147
this.vcr.clear();
90148
this.viewRef = this.vcr.createEmbeddedView(this.template);
91149
this.cdr.markForCheck();
92150
},
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-
},
110151
});
111152
}
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-
}
124153
}

0 commit comments

Comments
 (0)