From dfdfc3f6eff04bd241921bd9831bbd0bc897b11b Mon Sep 17 00:00:00 2001 From: kimyenac Date: Mon, 10 Aug 2026 16:50:21 +0900 Subject: [PATCH] [ZEPPELIN-6638] Time out the React remote entry load ReactRemoteLoaderService settles loadContainer() only from the script tag's onload and onerror. Neither fires while a request is merely pending, so a remoteEntry.js request that is accepted and never answered leaves the promise pending for as long as the browser holds the connection. onError is never called, and the hosts that depend on it never fall back: the paragraph footer keeps an empty mount div instead of restoring the Angular footer, and the published paragraph renders nothing. Bound the script load with environment.reactRemoteLoadTimeoutMs and reuse the existing fail() path on expiry, which removes the tag and leaves the caches drained so a later mount can retry. Set it to 0 to disable the timer. 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, and a second shorter timer would cut off a multi-megabyte chunk on a slow connection. Covered by two Playwright cases: one that holds remoteEntry.js open without ever answering and asserts the Angular footer comes back, and one with a delayed but successful response that asserts the React footer still renders. --- .../notebook/paragraph/react-footer.spec.ts | 42 +++++++++++++++++++ .../projects/zeppelin-react/README.md | 4 +- .../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 fd102b36d4d..b82065f9bc9 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 f452ee1455a..a32e90643f3 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 c3f45911ea5..2c903f529af 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 | 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 8613a332bc6..606214bc641 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 c20bf371d28..aab3beca1be 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 }; /*