This is an automated email from the ASF dual-hosted git repository.

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new d64897a0ab [ZEPPELIN-6565] Re-enter Angular zone for React remote 
callbacks
d64897a0ab is described below

commit d64897a0abdb4c9f720cf26faf95861c0e117ef1
Author: Minho Jang <[email protected]>
AuthorDate: Sat Aug 15 17:58:32 2026 +0900

    [ZEPPELIN-6565] Re-enter Angular zone for React remote callbacks
    
    ### What is this PR for?
    This PR fixes `ReactMountDirective` so callbacks invoked by a mounted React 
remote re-enter the Angular zone before calling back into the Angular host.
    
    `ReactMountDirective` mounts and updates React remotes inside 
`ngZone.runOutsideAngular()`. The directive already used `ngZone.run()` for 
errors reported through its own `reportError()` path, but props were passed to 
the remote unchanged. As a result, a remote such as the React paragraph footer 
could call `props.onError()` directly from outside the Angular zone.
    
    For an Angular `OnPush` host component, that can mark the component dirty 
without scheduling change detection, delaying the fallback UI until some later 
zone-scheduled work occurs.
    
    This change wraps host callbacks as props enter `ReactMountDirective`, 
while keeping the original props (`latestRawProps`) available for 
directive-internal error reporting. The wrapped props (`latestProps`) are 
passed to both `mount()` and `update()`, so React remote callbacks consistently 
run through the Angular zone boundary.
    
    ### What type of PR is it?
    Bug Fix
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6565
    
    ### How should this be tested?
    Run the shell unit tests:
    ```sh
    cd zeppelin-web-angular
    npm run test:shell
    ```
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5417 from miinhho/fix/react-lifecycle-outside.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .../react-mount/react-mount.directive.spec.ts      | 58 +++++++++++++++++++++-
 .../app/share/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 7a9862707f..71c5e64e30 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<HTMLElement>(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 <T>(): Promise<T> => remote as T);
+    const loader = { loadModule } as Pick<ReactRemoteLoaderService, 
'loadModule'>;
+    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 c93c168001..a28a575b7e 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 */
+          }
+        });
+      }
+    };
+  }
 }

Reply via email to