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 }; /*