Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion zeppelin-web-angular/projects/zeppelin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,37 @@ 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;
}
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);
});

Expand Down
3 changes: 2 additions & 1 deletion zeppelin-web-angular/src/environments/environment.prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@

export const environment = {
production: true,
reactRemoteEntryUrl: '/assets/react/remoteEntry.js'
reactRemoteEntryUrl: '/assets/react/remoteEntry.js',
reactRemoteLoadTimeoutMs: 10000
};
5 changes: 4 additions & 1 deletion zeppelin-web-angular/src/environments/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

/*
Expand Down
Loading