Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2a9fc49
Fix path join for downloading remote types in FederatedTypesPlugin.ts
philip-lempke Oct 27, 2025
4f5ea10
Merge branch 'main' into patch-1
ScriptedAlchemy Oct 29, 2025
94ecab3
fix: add trailing comma to URL constructor
ScriptedAlchemy Oct 29, 2025
fa65f29
feat(bridge-react): add rerender option to createBridgeComponent
ScriptedAlchemy Oct 30, 2025
ddcf793
fix(bridge-react): properly implement shouldRecreate functionality
ScriptedAlchemy Oct 30, 2025
d5b4061
fix(bridge-react): properly implement shouldRecreate functionality
ScriptedAlchemy Oct 30, 2025
537182e
revert: restore FederatedTypesPlugin.ts to main version
ScriptedAlchemy Oct 30, 2025
88557b5
chore(bridge-react): changeset patch bump for rerender option (#4171)
ScriptedAlchemy Oct 30, 2025
e730e0d
Merge remote-tracking branch 'origin/main' into patch-1
ScriptedAlchemy Oct 30, 2025
a29162b
test(bridge-react): avoid direct jsdom import; fallback to global window
ScriptedAlchemy Nov 7, 2025
f053a00
fix(bridge-react): preserve component state on rerender
ScriptedAlchemy Nov 7, 2025
1a824ff
test(bridge-react): assert lifecycle destroy emits on recreation and …
ScriptedAlchemy Nov 7, 2025
996261f
test(bridge-react): assert state stability + extraProps injection
ScriptedAlchemy Nov 7, 2025
ba6ae5c
Merge branch 'main' into patch-1
ScriptedAlchemy Nov 7, 2025
29d94a9
fix(bridge-react): fallback to custom render on updates when returned…
ScriptedAlchemy Nov 7, 2025
2a4045b
test(bridge-react): stabilize fallback custom-render test
ScriptedAlchemy Nov 7, 2025
d48a8c1
test(bridge-react): reset mocked federationRuntime between tests to a…
ScriptedAlchemy Nov 7, 2025
22591c1
chore(bridge-react): format rerender-issue.spec.tsx
ScriptedAlchemy Nov 7, 2025
c86b975
test(bridge-react): add hydration and key-based remount coverage; ass…
ScriptedAlchemy Nov 7, 2025
18ea1a6
refactor(bridge-react): reduce usage; add type guards and precise ty…
ScriptedAlchemy Nov 7, 2025
7ec9ec5
style: fix formatting issues
ScriptedAlchemy Nov 7, 2025
3716f5e
fix: adjust bridge legacy react handling
ScriptedAlchemy Nov 8, 2025
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
15 changes: 15 additions & 0 deletions .changeset/rerender-functionality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@module-federation/bridge-react": patch
---

feat(bridge-react): add rerender option to createBridgeComponent

- Add rerender option to ProviderFnParams interface for custom rerender handling
- Update bridge-base implementation to support custom rerender logic with proper shouldRecreate functionality
- Add component state tracking to detect rerenders vs initial renders
- Properly unmount and recreate roots when shouldRecreate is true
- Preserve component state when shouldRecreate is false
- Maintain backward compatibility for existing code
- Add comprehensive test suite for rerender functionality

This addresses issue #4171 where remote apps were being recreated on every host rerender, causing loss of internal state.
327 changes: 327 additions & 0 deletions packages/bridge/bridge-react/__tests__/rerender-issue.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
import React, { useState, useRef } from 'react';
import { createBridgeComponent, createRemoteAppComponent } from '../src';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';

describe('Issue #4171: Rerender functionality', () => {
it('should call custom rerender function when provided', async () => {
const customRerenderSpy = jest.fn();
let instanceCounter = 0;

// Remote component that tracks instances
function RemoteApp({ props }: { props?: { count: number } }) {
const instanceId = useRef(++instanceCounter);

return (
<div>
<span data-testid="remote-count">Count: {props?.count}</span>
<span data-testid="instance-id">Instance: {instanceId.current}</span>
</div>
);
}

// Create bridge component with custom rerender function
const BridgeComponent = createBridgeComponent({
rootComponent: RemoteApp,
rerender: (props) => {
customRerenderSpy(props);
return { shouldRecreate: false };
},
});

const RemoteAppComponent = createRemoteAppComponent({
loader: async () => ({ default: BridgeComponent }),
loading: <div>Loading...</div>,
fallback: () => <div>Error</div>,
});

function HostApp() {
const [count, setCount] = useState(0);

return (
<div>
<button
data-testid="increment-btn"
onClick={() => setCount((c) => c + 1)}
>
Increment: {count}
</button>
<RemoteAppComponent props={{ count }} />
</div>
);
}

render(<HostApp />);

await waitFor(() => {
expect(screen.getByTestId('remote-count')).toBeInTheDocument();
});

// Clear spy to track only rerender calls
customRerenderSpy.mockClear();

// Trigger rerender
act(() => {
fireEvent.click(screen.getByTestId('increment-btn'));
});

await waitFor(() => {
expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
});

// Custom rerender function should have been called
expect(customRerenderSpy).toHaveBeenCalled();

// Verify the custom rerender function was called with props
const callArgs = customRerenderSpy.mock.calls[0][0];
expect(callArgs).toBeDefined();
expect(typeof callArgs).toBe('object');
});

it('should work without rerender option (backward compatibility)', async () => {
let instanceCounter = 0;

function RemoteApp({ props }: { props?: { count: number } }) {
const instanceId = useRef(++instanceCounter);

return (
<div>
<span data-testid="remote-count">Count: {props?.count}</span>
<span data-testid="instance-id">Instance: {instanceId.current}</span>
</div>
);
}

// Create bridge component without rerender option (existing behavior)
const BridgeComponent = createBridgeComponent({
rootComponent: RemoteApp,
});

const RemoteAppComponent = createRemoteAppComponent({
loader: async () => ({ default: BridgeComponent }),
loading: <div>Loading...</div>,
fallback: () => <div>Error</div>,
});

function HostApp() {
const [count, setCount] = useState(0);

return (
<div>
<button
data-testid="increment-btn"
onClick={() => setCount((c) => c + 1)}
>
Increment: {count}
</button>
<RemoteAppComponent props={{ count }} />
</div>
);
}

render(<HostApp />);

await waitFor(() => {
expect(screen.getByTestId('remote-count')).toBeInTheDocument();
});

// Should work without errors (backward compatibility)
act(() => {
fireEvent.click(screen.getByTestId('increment-btn'));
});

await waitFor(() => {
expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
});

// Component should still function correctly
expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
});

it('should support rerender function returning void', async () => {
const customRerenderSpy = jest.fn();

function RemoteApp({ props }: { props?: { count: number } }) {
return (
<div>
<span data-testid="remote-count">Count: {props?.count}</span>
</div>
);
}

// Create bridge component with rerender function that returns void
const BridgeComponent = createBridgeComponent({
rootComponent: RemoteApp,
rerender: (props) => {
customRerenderSpy(props);
// Return void (undefined)
},
});

const RemoteAppComponent = createRemoteAppComponent({
loader: async () => ({ default: BridgeComponent }),
loading: <div>Loading...</div>,
fallback: () => <div>Error</div>,
});

function HostApp() {
const [count, setCount] = useState(0);

return (
<div>
<button
data-testid="increment-btn"
onClick={() => setCount((c) => c + 1)}
>
Increment: {count}
</button>
<RemoteAppComponent props={{ count }} />
</div>
);
}

render(<HostApp />);

await waitFor(() => {
expect(screen.getByTestId('remote-count')).toBeInTheDocument();
});

customRerenderSpy.mockClear();

// Trigger rerender
act(() => {
fireEvent.click(screen.getByTestId('increment-btn'));
});

await waitFor(() => {
expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
});

// Custom rerender function should have been called even when returning void
expect(customRerenderSpy).toHaveBeenCalled();
});

it('should actually recreate component when shouldRecreate is true', async () => {
const mockUnmount = jest.fn();
const mockRender = jest.fn();
const createRootSpy = jest.fn();
let instanceCounter = 0;

// Remote component that tracks instances and has internal state
function RemoteApp({
props,
}: {
props?: { count: number; forceRecreate?: boolean };
}) {
const instanceId = useRef(++instanceCounter);
const [internalState, setInternalState] = useState(0);

return (
<div>
<span data-testid="remote-count">Count: {props?.count}</span>
<span data-testid="instance-id">Instance: {instanceId.current}</span>
<span data-testid="internal-state">Internal: {internalState}</span>
<button
data-testid="internal-btn"
onClick={() => setInternalState((s) => s + 1)}
>
Internal State
</button>
</div>
);
}

// Create bridge component with conditional recreation
const BridgeComponent = createBridgeComponent({
rootComponent: RemoteApp,
rerender: (info: any) => {
const shouldRecreate = info.props?.forceRecreate === true;
return { shouldRecreate };
},
createRoot: (container, options) => {
createRootSpy(container, options);
return {
render: mockRender,
unmount: mockUnmount,
};
},
});

const RemoteAppComponent = createRemoteAppComponent({
loader: async () => ({ default: BridgeComponent }),
loading: <div>Loading...</div>,
fallback: () => <div>Error</div>,
});

function HostApp() {
const [count, setCount] = useState(0);
const [forceRecreate, setForceRecreate] = useState(false);

return (
<div>
<button
data-testid="increment-btn"
onClick={() => setCount((c) => c + 1)}
>
Count: {count}
</button>
<button
data-testid="recreate-btn"
onClick={() => {
setForceRecreate(true);
setCount((c) => c + 1);
}}
>
Force Recreate
</button>
<RemoteAppComponent props={{ count, forceRecreate }} />
</div>
);
}

render(<HostApp />);

await waitFor(() => {
expect(mockRender).toHaveBeenCalledTimes(1);
});

// Clear mocks to track only recreation behavior
mockRender.mockClear();
mockUnmount.mockClear();
createRootSpy.mockClear();

// Normal rerender (should not recreate)
act(() => {
fireEvent.click(screen.getByTestId('increment-btn'));
});

await waitFor(() => {
expect(mockRender).toHaveBeenCalledTimes(1);
});

// Should not have unmounted or created new root
expect(mockUnmount).not.toHaveBeenCalled();
expect(createRootSpy).not.toHaveBeenCalled();

// Clear mocks again
mockRender.mockClear();

// Force recreation (should recreate)
act(() => {
fireEvent.click(screen.getByTestId('recreate-btn'));
});

await waitFor(() => {
expect(mockRender).toHaveBeenCalledTimes(1);
});

// Should have unmounted old root and created new one
expect(mockUnmount).toHaveBeenCalledTimes(1);
expect(createRootSpy).toHaveBeenCalledTimes(1);
});
});
Loading