Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f0133da
[ZEPPELIN-6428] Add date-fns to zeppelin-react
May 27, 2026
f95ffea
[ZEPPELIN-6428] Add React mount handle type contract
May 27, 2026
f5e4196
[ZEPPELIN-6428] Add ReactRemoteLoaderService
May 27, 2026
c49ab0d
[ZEPPELIN-6428] Add ReactMountDirective
May 27, 2026
aee65da
[ZEPPELIN-6428] Wire ReactMountDirective into ShareModule
May 27, 2026
8689c35
[ZEPPELIN-6428] Add ReactErrorBoundary
May 27, 2026
05a6835
[ZEPPELIN-6428] Add ParagraphFooter styles
May 27, 2026
553d269
[ZEPPELIN-6428] Add React ParagraphFooter component
May 27, 2026
81d2926
[ZEPPELIN-6428] Re-export ParagraphFooter from React app
May 27, 2026
0636ec7
[ZEPPELIN-6428] Expose ParagraphFooter via Module Federation
May 27, 2026
3ada308
[ZEPPELIN-6428] Gate notebook paragraph footer behind ?reactFooter=true
May 27, 2026
4ef90b1
[ZEPPELIN-6428] Type window.reactApp.get<T>() call in PublishedParagraph
May 27, 2026
81c2de4
[ZEPPELIN-6428] Add E2E coverage for React paragraph footer
May 27, 2026
97422b5
[ZEPPELIN-6428] Update zeppelin-react README for new mount contract
May 27, 2026
e879b8e
[ZEPPELIN-6428] Re-enter Angular zone for ReactMountDirective onError…
May 28, 2026
92b0aa3
[ZEPPELIN-6428] Memoize reactFooterProps for stable object identity
tbonelee Jun 3, 2026
820f999
[ZEPPELIN-6428] Align react-mount barrel with Import Path Rules
tbonelee Jun 3, 2026
b0484aa
[ZEPPELIN-6428] Add E2E for remote load failure falling back to Angul…
tbonelee Jun 3, 2026
6eda88a
[ZEPPELIN-6428] Add vitest harness and unit tests for zeppelin-react
tbonelee Jun 3, 2026
960f27e
[ZEPPELIN-6428] Bump zeppelin-react typescript to 5.9.3 and ts-loader…
tbonelee Jun 3, 2026
1a408a7
[ZEPPELIN-6428] Bump zeppelin-react @types/node to 22
tbonelee Jun 3, 2026
a3c24fa
[ZEPPELIN-6428] Bump vitest to 4.1.8 to fix critical audit advisory
tbonelee Jun 3, 2026
9e0eed4
[ZEPPELIN-6428] Regenerate zeppelin-react lockfile so npm ci validate…
tbonelee Jun 3, 2026
57fc05d
[ZEPPELIN-6428] Generate zeppelin-react lockfile with npm 11 for npm …
tbonelee Jun 3, 2026
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
104 changes: 104 additions & 0 deletions zeppelin-web-angular/e2e/tests/notebook/paragraph/react-footer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, test } from '@playwright/test';
import {
addPageAnnotationBeforeEach,
createTestNotebook,
PAGES,
performLoginIfRequired,
waitForNotebookLinks,
waitForZeppelinReady
} from '../../../utils';

test.describe('React Paragraph Footer', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK);

let testNotebook: { noteId: string; paragraphId: string };

test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
await performLoginIfRequired(page);
await waitForNotebookLinks(page);
testNotebook = await createTestNotebook(page);
});

test('without reactFooter flag, Angular footer renders', async ({ page }) => {
const { noteId } = testNotebook;

await page.goto(`/#/notebook/${noteId}`);
await waitForZeppelinReady(page);

await expect(page.locator('[data-testid="angular-paragraph-footer"]').first()).toBeAttached({ timeout: 15000 });
await expect(page.locator('[data-testid="react-paragraph-footer"]')).toHaveCount(0);
});

test('with reactFooter=true, React footer renders', async ({ page }) => {
const { noteId } = testNotebook;

await page.goto(`/#/notebook/${noteId}?reactFooter=true`);
await waitForZeppelinReady(page);

await expect(page.locator('[data-testid="react-paragraph-footer"]').first()).toBeAttached({ timeout: 15000 });
await expect(page.locator('[data-testid="angular-paragraph-footer"]')).toHaveCount(0);
});

test('reactFooter=true preserves the paragraph query param', async ({ page }) => {
const { noteId, paragraphId } = testNotebook;

await page.goto(`/#/notebook/${noteId}?paragraph=${paragraphId}&reactFooter=true`);
await waitForZeppelinReady(page);

await expect(page).toHaveURL(/reactFooter=true/);
await expect(page).toHaveURL(new RegExp(`paragraph=${paragraphId}`));
await expect(page.locator('[data-testid="react-paragraph-footer"]').first()).toBeAttached({ timeout: 15000 });
});

test('when the remote fails to load, paragraphs fall back to the Angular footer', async ({ page }) => {
const { noteId } = testNotebook;

// Simulate a dead remote: every remoteEntry.js request fails
await page.route('**/remoteEntry.js', route => route.abort());

await page.goto(`/#/notebook/${noteId}?reactFooter=true`);
await waitForZeppelinReady(page);

// The loader rejection reaches each paragraph's onError, which flips
// reactFooterFailed and re-renders the Angular footer
await expect(page.locator('[data-testid="angular-paragraph-footer"]').first()).toBeAttached({ timeout: 15000 });
await expect(page.locator('[data-testid="react-paragraph-footer"]')).toHaveCount(0);
});

test('navigating away during remoteEntry load does not throw', async ({ page }) => {
const { noteId } = testNotebook;

// Delay remoteEntry.js to widen the destroy-while-loading window
await page.route('**/remoteEntry.js', async route => {
await new Promise(r => setTimeout(r, 1500));
await route.continue();
});

const consoleErrors: string[] = [];
page.on('pageerror', err => consoleErrors.push(err.message));

await page.goto(`/#/notebook/${noteId}?reactFooter=true`);
// Navigate away before the remote can possibly mount
await page.waitForTimeout(100);
await page.goto('/#/');
await waitForZeppelinReady(page);

await page.waitForTimeout(2500);

expect(consoleErrors).toEqual([]);
});
});
65 changes: 54 additions & 11 deletions zeppelin-web-angular/projects/zeppelin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ React micro-frontend that runs alongside the Angular host via [Webpack Module Fe

- Design Document: [Micro Frontend Migration (Angular to React) Proposal](https://cwiki.apache.org/confluence/display/ZEPPELIN/Micro+Frontend+Migration%28Angular+to+React%29+Proposal)

## React mount infrastructure (Angular side)

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.
- `ReactMountDirective` — owns the host element, mounts outside the
Angular zone, forwards `[reactProps]` changes through
`handle.update(...)`, and unmounts on destroy. Re-checks `destroyed`
after the async load so a navigation during load does not leak a mount.

Both are exported from `ShareModule`. Any notebook or interpreter
template can use the directive without additional wiring. The selector
is kebab-case (`zeppelin-react-mount`) per project ESLint convention.

## Migration roadmap

| Phase | Scope | Status |
Expand Down Expand Up @@ -78,23 +93,51 @@ src/

## Adding a new React module

1. Create a component (e.g. `src/pages/ExampleFeature.tsx`).
2. Export a `mount(element, props)` function that creates a React root and renders the component.
3. Register in `webpack.config.js` under `exposes`:
The Angular host loads each exposed module through the
`ReactMountDirective` (see `src/app/share/react-mount/`). The contract:

```ts
export interface ReactMountHandle {
update: (props: Props & { onError?: (e: unknown) => void }) => void;
unmount: () => void;
}

export function mount(element: HTMLElement, props: Props): ReactMountHandle;
```

1. Create a component (e.g. `src/components/<area>/ExampleFeature.tsx`).
2. Wrap its render tree in `<ReactErrorBoundary onError={props.onError}>`.
3. Export a `mount(element, props)` function that:
- Creates a single `Root` via `createRoot(element)`.
- Calls `root.render(<Wrapped {...props}/>)` on initial mount AND on
every `update(newProps)` call. React's reconciler preserves state.
- Returns `{ update, unmount }`. `unmount` calls `root.unmount()`.
4. Register in `webpack.config.js` under `exposes`:
```js
exposes: {
'./PublishedParagraph': './src/pages/PublishedParagraph',
'./ExampleFeature': './src/pages/ExampleFeature'
'./ParagraphFooter': './src/components/paragraph/ParagraphFooter',
'./ExampleFeature': './src/components/<area>/ExampleFeature'
}
```
4. Re-export from `main.ts`:
5. Re-export from `main.ts`:
```ts
export { ExampleFeature, mount as mountExampleFeature } from './pages/ExampleFeature';
export {
ExampleFeature,
mount as mountExampleFeature
} from './components/<area>/ExampleFeature';
```
5. Load from Angular (same pattern as `paragraph.component.ts`):
```ts
const factory = await container.get('./ExampleFeature');
const { mount } = factory();
mount(hostElement, props);
6. Use from Angular by adding the directive to your template:
```html
<div
zeppelin-react-mount="./ExampleFeature"
[reactProps]="exampleFeatureProps"
></div>
```
`exampleFeatureProps` should be a getter on the host component (not
an inline object literal) so identity is stable when nothing changed.

The legacy `./PublishedParagraph` module returns a bare unmount fn from
`mount`. The directive tolerates that shape, but new modules should use
the handle contract.

Loading
Loading