From 474a591b7cc4a295d8aca129a20d29ec24045800 Mon Sep 17 00:00:00 2001 From: miinhho Date: Thu, 13 Aug 2026 16:21:48 +0900 Subject: [PATCH] [ZEPPELIN-6565] Re-enter Angular zone for React remote callbacks --- .../react-mount/react-mount.directive.spec.ts | 58 ++++++++++++++++++- .../react-mount/react-mount.directive.ts | 25 +++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.spec.ts b/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.spec.ts index 7a9862707f2..71c5e64e30b 100644 --- a/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.spec.ts +++ b/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.spec.ts @@ -14,7 +14,7 @@ import { ElementRef, NgZone, SimpleChange } from '@angular/core'; import { describe, expect, it, vi } from 'vitest'; import { ReactRemoteLoaderService } from './react-remote-loader.service'; -import { ReactExposedModule, ReactMountHandle, ReactProps } from './react-mount-handle'; +import { ReactExposedModule, ReactHostCallbacks, ReactMountHandle, ReactProps } from './react-mount-handle'; import { ReactMountDirective } from './react-mount.directive'; describe('ReactMountDirective', () => { @@ -62,4 +62,60 @@ describe('ReactMountDirective', () => { expect(unmount).toHaveBeenCalledOnce(); }); + + it('re-enters the Angular zone for callbacks invoked by the React remote', async () => { + const host = new ElementRef(document.createElement('div')); + const ngZone = new NgZone({}); + let mountedProps: (ReactProps & ReactHostCallbacks) | undefined; + let updatedProps: (ReactProps & ReactHostCallbacks) | undefined; + const update = vi.fn((props: ReactProps & ReactHostCallbacks) => { + updatedProps = props; + }); + const mountHandle: ReactMountHandle = { + update, + unmount: vi.fn() + }; + const remote: ReactExposedModule = { + mount: (_element: HTMLElement, props: ReactProps & ReactHostCallbacks) => { + mountedProps = props; + return mountHandle; + } + }; + const loadModule = vi.fn(async (): Promise => remote as T); + const loader = { loadModule } as Pick; + const zoneStates: boolean[] = []; + const onMountError = vi.fn(() => { + zoneStates.push(NgZone.isInAngularZone()); + }); + const onUpdateError = vi.fn(() => { + zoneStates.push(NgZone.isInAngularZone()); + }); + const directive = new ReactMountDirective(host, ngZone, loader as ReactRemoteLoaderService); + + directive.module = 'paragraph-footer'; + directive.reactProps = { onError: onMountError }; + directive.ngOnChanges({ + module: new SimpleChange(undefined, directive.module, true), + reactProps: new SimpleChange(undefined, directive.reactProps, true) + }); + await vi.waitFor(() => expect(mountedProps).toBeDefined()); + + ngZone.runOutsideAngular(() => { + mountedProps!.onError!(new Error('mount remote failed')); + }); + + directive.reactProps = { onError: onUpdateError }; + directive.ngOnChanges({ + reactProps: new SimpleChange({ onError: onMountError }, directive.reactProps, false) + }); + + ngZone.runOutsideAngular(() => { + updatedProps!.onError!(new Error('update remote failed')); + }); + + expect(onMountError).toHaveBeenCalledOnce(); + expect(onUpdateError).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledOnce(); + expect(zoneStates).toEqual([true, true]); + }); }); diff --git a/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.ts b/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.ts index c93c168001b..a28a575b7ef 100644 --- a/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.ts +++ b/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.ts @@ -40,6 +40,7 @@ export class ReactMountDirective implements OnChanges, OnDestroy { @Input('zeppelin-react-mount') module!: string; @Input() reactProps: ReactProps & ReactHostCallbacks = {}; + private latestRawProps: ReactProps & ReactHostCallbacks = {}; private latestProps: ReactProps & ReactHostCallbacks = {}; private destroyed = false; private loading = false; @@ -53,7 +54,8 @@ export class ReactMountDirective implements OnChanges, OnDestroy { ) {} ngOnChanges(changes: SimpleChanges): void { - this.latestProps = this.reactProps ?? {}; + this.latestRawProps = this.reactProps ?? {}; + this.latestProps = this.withHostCallbacks(this.latestRawProps); if (changes.module && !changes.module.firstChange && this.mountedModule) { // Module swap after first mount is unsupported. Report via onError @@ -128,7 +130,7 @@ export class ReactMountDirective implements OnChanges, OnDestroy { } private reportError(error: unknown): void { - const onError = this.latestProps.onError; + const onError = this.latestRawProps.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 @@ -146,4 +148,23 @@ export class ReactMountDirective implements OnChanges, OnDestroy { console.error('[ReactMountDirective]', error); } } + + private withHostCallbacks(props: ReactProps & ReactHostCallbacks): ReactProps & ReactHostCallbacks { + const onError = props.onError; + if (typeof onError !== 'function') { + return props; + } + return { + ...props, + onError: (error: unknown): void => { + this.ngZone.run(() => { + try { + onError(error); + } catch { + /* swallow callback errors; they shouldn't loop */ + } + }); + } + }; + } }