blob: c93c168001b10d1538a801cd58266dd7d45295e4 [file]
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Directive, ElementRef, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { ReactRemoteLoaderService } from './react-remote-loader.service';
import {
AnyExposedModule,
ReactExposedModule,
ReactHostCallbacks,
ReactMountHandle,
ReactProps
} from './react-mount-handle';
const isLegacyModule = (mod: AnyExposedModule, handleOrUnmount: unknown): handleOrUnmount is () => void => {
void mod;
return typeof handleOrUnmount === 'function';
};
const wrapLegacyHandle = (unmount: () => void): ReactMountHandle => ({
update: () => {
/* legacy modules don't support updates; no-op */
},
unmount
});
@Directive({
selector: '[zeppelin-react-mount]',
standalone: false
})
export class ReactMountDirective implements OnChanges, OnDestroy {
@Input('zeppelin-react-mount') module!: string;
@Input() reactProps: ReactProps & ReactHostCallbacks = {};
private latestProps: ReactProps & ReactHostCallbacks = {};
private destroyed = false;
private loading = false;
private handle: ReactMountHandle | null = null;
private mountedModule: string | null = null;
constructor(
private readonly host: ElementRef<HTMLElement>,
private readonly ngZone: NgZone,
private readonly loader: ReactRemoteLoaderService
) {}
ngOnChanges(changes: SimpleChanges): void {
this.latestProps = this.reactProps ?? {};
if (changes.module && !changes.module.firstChange && this.mountedModule) {
// Module swap after first mount is unsupported. Report via onError
// and otherwise leave the existing handle in place.
this.reportError(
new Error(
`ReactMountDirective: module input changed after mount ` +
`(from "${this.mountedModule}" to "${this.module}") — unsupported`
)
);
return;
}
if (this.handle) {
this.ngZone.runOutsideAngular(() => {
try {
this.handle!.update(this.latestProps);
} catch (err) {
this.reportError(err);
}
});
return;
}
if (!this.loading && !this.destroyed && this.module) {
void this.startLoad();
}
}
ngOnDestroy(): void {
this.destroyed = true;
if (this.handle) {
try {
this.handle.unmount();
} catch (err) {
this.reportError(err);
}
this.handle = null;
}
}
private async startLoad(): Promise<void> {
this.loading = true;
const moduleKey = this.module;
try {
const mod = await this.loader.loadModule<AnyExposedModule>(moduleKey);
if (this.destroyed) {
return;
}
this.ngZone.runOutsideAngular(() => {
try {
const returned = (mod as ReactExposedModule).mount(this.host.nativeElement, this.latestProps);
if (isLegacyModule(mod, returned)) {
this.handle = wrapLegacyHandle(returned as unknown as () => void);
} else {
this.handle = returned as ReactMountHandle;
}
this.mountedModule = moduleKey;
} catch (err) {
this.handle = null;
this.reportError(err);
}
});
} catch (err) {
// Mirror the success-path guard: don't report a load that failed after destroy.
if (!this.destroyed) {
this.reportError(err);
}
} finally {
this.loading = false;
}
}
private reportError(error: unknown): void {
const onError = this.latestProps.onError;
if (typeof onError === 'function') {
// Re-enter the Angular zone so onError handlers can safely mutate
// host state and trigger change detection. React lifecycle callbacks
// (e.g. error boundaries) run outside the zone because we mounted
// there; calling back into the host without ngZone.run would leave
// markForCheck() with nothing to flush.
this.ngZone.run(() => {
try {
onError(error);
} catch {
/* swallow callback errors; they shouldn't loop */
}
});
} else {
console.error('[ReactMountDirective]', error);
}
}
}