You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@zeppelin.apache.org by yo...@apache.org on 2026/08/12 16:06:01 UTC

(zeppelin) branch master updated: [ZEPPELIN-6638] Time out the React remote entry load

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

voidmatcha 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 c199b40742 [ZEPPELIN-6638] Time out the React remote entry load
c199b40742 is described below

commit c199b4074278316c99e5cc53304aeca3992b5f73
Author: 김예나 <10...@users.noreply.github.com>
AuthorDate: Thu Aug 13 01:05:56 2026 +0900

    [ZEPPELIN-6638] Time out the React remote entry load
    
    ### What is this PR for?
    
    `ReactRemoteLoaderService` settles `loadContainer()` only from the script tag's `onload` and `onerror`. Neither fires while a request is merely pending, and there is no timer. A `remoteEntry.js` request that the server accepts and never answers therefore leaves the promise pending for as long as the browser keeps the connection open, which is minutes.
    
    `onError` is never called, so the hosts that depend on it never fall back. The paragraph footer keeps an empty mount div instead of restoring `zeppelin-notebook-paragraph-footer`, and the published paragraph renders nothing. Both look like a slow page rather than a failed load.
    
    This bounds the script load with `environment.reactRemoteLoadTimeoutMs` (10 s, or 0 to disable) and reuses the existing `fail()` path on expiry, which removes the tag and leaves the caches drained so a later mount can retry.
    
    The chunks that `container.get()` pulls are left alone. They are fetched by the remote's own webpack runtime, which already bounds them with `output.chunkLoadTimeout` (120 s by default). A second, shorter timer over that path would cut off a multi-megabyte chunk on a slow connection, which is a worse failure than the one being fixed.
    
    ### What type of PR is it?
    
    Bug Fix
    
    ### Todos
    
    None
    
    ### What is the Jira issue?
    
    https://issues.apache.org/jira/browse/ZEPPELIN-6638
    
    ### How should this be tested?
    
    * Two new Playwright cases in `e2e/tests/notebook/paragraph/react-footer.spec.ts`: one holds `remoteEntry.js` open without ever answering and asserts the Angular footer comes back, one answers after a delay well inside the budget and asserts the React footer still renders. The first fails on master and passes here; verified by setting the budget to 0, which reproduces the current behaviour and makes it fail.
    * The existing abort and delay cases in the same suite, and `e2e/tests/notebook/published/published-paragraph.spec.ts`, for regressions. Chromium, all green.
    * A production build (`npm run build`).
    * A unit spec for the service would be the better home for the timer, but the Angular shell has no test harness on master yet (ZEPPELIN-6566, ZEPPELIN-6567, ZEPPELIN-6637) and this file carries an `<at>Injectable` decorator, which the current setup cannot compile. Left to a follow-up once that lands.
    
    ### Screenshots (if appropriate)
    
    No
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? Yes, the loader section of `projects/zeppelin-react/README.md` is updated in this PR
    
    Closes #5409 from kimyenac/ZEPPELIN-6638.
    
    Signed-off-by: YONGJAE LEE <de...@gmail.com>
---
 .../tests/notebook/paragraph/react-footer.spec.ts  | 42 ++++++++++++++++++++++
 .../projects/zeppelin-react/README.md              |  4 ++-
 .../react-mount/react-remote-loader.service.ts     | 20 +++++++++--
 .../src/environments/environment.prod.ts           |  3 +-
 .../src/environments/environment.ts                |  5 ++-
 5 files changed, 69 insertions(+), 5 deletions(-)

diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/react-footer.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/react-footer.spec.ts
index fd102b36d4..b82065f9bc 100644
--- a/zeppelin-web-angular/e2e/tests/notebook/paragraph/react-footer.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/react-footer.spec.ts
@@ -83,6 +83,48 @@ test.describe('React Paragraph Footer', () => {
     await expect(page.locator('[data-testid="react-paragraph-footer"]')).toHaveCount(0);
   });
 
+  test('when the remote never answers, paragraphs fall back to the Angular footer', async ({ page }) => {
+    const { noteId } = testNotebook;
+
+    await test.step('Given a remote that accepts the request and never answers', async () => {
+      // The handler settles nothing on purpose: the request is left open.
+      await page.route('**/remoteEntry.js', () => {});
+    });
+
+    await test.step('When the notebook opens with the React footer enabled', async () => {
+      await page.goto(`/#/notebook/${noteId}?reactFooter=true`);
+      await waitForZeppelinReady(page);
+    });
+
+    await test.step('Then the Angular footer takes over once the load budget expires', async () => {
+      await expect(page.locator('[data-testid="angular-paragraph-footer"]').first()).toBeAttached({ timeout: 30000 });
+      await expect(page.locator('[data-testid="react-paragraph-footer"]')).toHaveCount(0);
+    });
+  });
+
+  test('a remote that answers within the budget still renders the React footer', async ({ page }) => {
+    const { noteId } = testNotebook;
+
+    await test.step('Given a remote that answers slowly but well inside the budget', async () => {
+      await page.route('**/remoteEntry.js', async route => {
+        await new Promise(r => setTimeout(r, 2000));
+        await route.continue();
+      });
+    });
+
+    await test.step('When the notebook opens with the React footer enabled', async () => {
+      await page.goto(`/#/notebook/${noteId}?reactFooter=true`);
+      await waitForZeppelinReady(page);
+    });
+
+    await test.step('Then the React footer renders and no fallback happens', async () => {
+      await expect(page.locator('[data-testid="react-paragraph-footer-content"]').first()).toBeAttached({
+        timeout: 20000
+      });
+      await expect(page.locator('[data-testid="angular-paragraph-footer"]')).toHaveCount(0);
+    });
+  });
+
   test('navigating away during remoteEntry load does not throw', async ({ page }) => {
     const { noteId } = testNotebook;
 
diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md b/zeppelin-web-angular/projects/zeppelin-react/README.md
index f452ee1455..a32e90643f 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/README.md
+++ b/zeppelin-web-angular/projects/zeppelin-react/README.md
@@ -21,7 +21,9 @@ React micro-frontend that runs alongside the Angular host via [Webpack Module Fe
 The Angular host's `src/app/share/react-mount/` exports two pieces:
 
 - `ReactRemoteLoaderService` — loads `remoteEntry.js` once per page,
-  caches per-module promises, evicts on error.
+  caches per-module promises, evicts on error. The load is bounded by
+  `environment.reactRemoteLoadTimeoutMs`, so a remote that stalls instead
+  of failing still reaches the host's `onError` and its fallback.
 - `ReactMountDirective` — owns the host element, mounts outside the
   Angular zone, forwards `[reactProps]` changes through
   `handle.update(...)`, and unmounts on destroy. Re-checks `destroyed`
diff --git a/zeppelin-web-angular/src/app/share/react-mount/react-remote-loader.service.ts b/zeppelin-web-angular/src/app/share/react-mount/react-remote-loader.service.ts
index c3f45911ea..2c903f529a 100644
--- a/zeppelin-web-angular/src/app/share/react-mount/react-remote-loader.service.ts
+++ b/zeppelin-web-angular/src/app/share/react-mount/react-remote-loader.service.ts
@@ -45,14 +45,20 @@ export class ReactRemoteLoaderService {
       script.src = environment.reactRemoteEntryUrl;
       script.async = true;
 
-      // Remove the tag on *any* failure (network error or loaded-but-unregistered):
-      // containerPromise resets on rejection, so each retry would otherwise leak a tag.
+      const timeoutMs = environment.reactRemoteLoadTimeoutMs;
+      let timer: ReturnType<typeof setTimeout> | undefined;
+
+      // Remove the tag on *any* failure (network error, timeout, or
+      // loaded-but-unregistered): containerPromise resets on rejection, so each
+      // retry would otherwise leak a tag.
       const fail = (message: string) => {
+        clearTimeout(timer);
         script.remove();
         reject(new Error(message));
       };
 
       script.onload = () => {
+        clearTimeout(timer);
         if (!window.reactApp) {
           fail('window.reactApp not registered after script load');
           return;
@@ -60,6 +66,16 @@ export class ReactRemoteLoaderService {
         resolve(window.reactApp);
       };
       script.onerror = () => fail(`Failed to load React remote at ${script.src}`);
+
+      // A request the server accepts but never answers fires neither onload nor
+      // onerror, so without this the promise stays pending for minutes.
+      if (timeoutMs > 0) {
+        timer = setTimeout(
+          () => fail(`Timed out after ${timeoutMs} ms loading the React remote at ${script.src}`),
+          timeoutMs
+        );
+      }
+
       document.head.appendChild(script);
     });
 
diff --git a/zeppelin-web-angular/src/environments/environment.prod.ts b/zeppelin-web-angular/src/environments/environment.prod.ts
index 8613a332bc..606214bc64 100644
--- a/zeppelin-web-angular/src/environments/environment.prod.ts
+++ b/zeppelin-web-angular/src/environments/environment.prod.ts
@@ -12,5 +12,6 @@
 
 export const environment = {
   production: true,
-  reactRemoteEntryUrl: '/assets/react/remoteEntry.js'
+  reactRemoteEntryUrl: '/assets/react/remoteEntry.js',
+  reactRemoteLoadTimeoutMs: 10000
 };
diff --git a/zeppelin-web-angular/src/environments/environment.ts b/zeppelin-web-angular/src/environments/environment.ts
index c20bf371d2..aab3beca1b 100644
--- a/zeppelin-web-angular/src/environments/environment.ts
+++ b/zeppelin-web-angular/src/environments/environment.ts
@@ -16,7 +16,10 @@
 
 export const environment = {
   production: false,
-  reactRemoteEntryUrl: 'http://localhost:3001/remoteEntry.js'
+  reactRemoteEntryUrl: 'http://localhost:3001/remoteEntry.js',
+  // Budget for fetching remoteEntry.js, after which the host falls back.
+  // Set to 0 to disable the timer.
+  reactRemoteLoadTimeoutMs: 10000
 };
 
 /*