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: 김예나 <[email protected]>
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 <[email protected]>
---
.../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
};
/*