diff --git a/.changeset/rerender-functionality.md b/.changeset/rerender-functionality.md
new file mode 100644
index 00000000000..786612a4af1
--- /dev/null
+++ b/.changeset/rerender-functionality.md
@@ -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.
\ No newline at end of file
diff --git a/packages/bridge/bridge-react/__tests__/prefetch.spec.ts b/packages/bridge/bridge-react/__tests__/prefetch.spec.ts
index 5157ed4c440..dad918557e0 100644
--- a/packages/bridge/bridge-react/__tests__/prefetch.spec.ts
+++ b/packages/bridge/bridge-react/__tests__/prefetch.spec.ts
@@ -1,4 +1,5 @@
import { prefetch } from '../src/lazy/data-fetch/prefetch';
+import type { DataFetchParams } from '../src';
import * as utils from '../src/lazy/utils';
import logger from '../src/lazy/logger';
import helpers from '@module-federation/runtime/helpers';
@@ -113,7 +114,7 @@ describe('prefetch', () => {
await prefetch({
id: 'remote1/component1',
instance: mockInstance,
- dataFetchParams: { some: 'param', isDowngrade: false } as any,
+ dataFetchParams: { some: 'param', isDowngrade: false } as DataFetchParams,
preloadComponentResource: true,
});
diff --git a/packages/bridge/bridge-react/__tests__/rerender-issue.spec.tsx b/packages/bridge/bridge-react/__tests__/rerender-issue.spec.tsx
new file mode 100644
index 00000000000..7fc20f88850
--- /dev/null
+++ b/packages/bridge/bridge-react/__tests__/rerender-issue.spec.tsx
@@ -0,0 +1,947 @@
+import React, { useState, useRef } from 'react';
+import { createBridgeComponent, createRemoteAppComponent } from '../src';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { federationRuntime } from '../src/provider/plugin';
+import type { ModuleFederation } from '@module-federation/runtime';
+
+// Ensure tests do not leak mocked runtime across cases
+afterEach(() => {
+ federationRuntime.instance = null;
+});
+
+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 (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ // 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: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+
+
+
+ );
+ }
+
+ render();
+
+ 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',
+ );
+ },
+ { timeout: 3000 },
+ );
+
+ // Instance should be preserved (no remount)
+ expect(screen.getByTestId('instance-id')).toHaveTextContent('Instance: 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 (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ // Create bridge component without rerender option (existing behavior)
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+
+
+
+ );
+ }
+
+ render();
+
+ 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');
+ });
+
+ // Instance id should remain stable (no remount)
+ expect(screen.getByTestId('instance-id')).toHaveTextContent('Instance: 1');
+ });
+
+ it('should support rerender function returning void and preserve state', async () => {
+ const customRerenderSpy = jest.fn();
+
+ let instanceCounter = 0;
+ function RemoteApp({ props }: { props?: { count: number } }) {
+ const instanceId = useRef(++instanceCounter);
+ return (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ // 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: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+
+
+
+ );
+ }
+
+ render();
+
+ 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();
+ // Instance should be preserved
+ expect(screen.getByTestId('instance-id')).toHaveTextContent('Instance: 1');
+ });
+
+ 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 (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+ Internal: {internalState}
+
+
+ );
+ }
+
+ // 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: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+ const [forceRecreate, setForceRecreate] = useState(false);
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ render();
+
+ 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);
+ });
+
+ it('should preserve state with custom render implementation when shouldRecreate=false', async () => {
+ let instanceCounter = 0;
+
+ function RemoteApp({ props }: { props?: { count: number } }) {
+ const instanceId = useRef(++instanceCounter);
+ return (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ // exercise custom render: user handles createRoot and returns it
+ render: (App, container) => {
+ // use React 18 createRoot under the hood
+ // dynamic import to avoid ESM/CJS interop issues in ts-jest
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { createRoot } = require('react-dom/client');
+ const root = createRoot(container as HTMLElement);
+ root.render(App);
+ return root as any;
+ },
+ rerender: () => ({ shouldRecreate: false }),
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+ return (
+
+
+
+
+ );
+ }
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toBeInTheDocument();
+ });
+
+ // Trigger rerender
+ act(() => {
+ fireEvent.click(screen.getByTestId('increment-btn'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
+ });
+
+ // Instance id should remain stable (no remount)
+ expect(screen.getByTestId('instance-id')).toHaveTextContent('Instance: 1');
+ });
+
+ it('should emit lifecycle destroy hooks when recreating', async () => {
+ const beforeBridgeRender = jest.fn();
+ const afterBridgeRender = jest.fn();
+ const beforeBridgeDestroy = jest.fn();
+ const afterBridgeDestroy = jest.fn();
+
+ // Inject a mocked federation runtime instance to capture lifecycle emits
+ federationRuntime.instance = {
+ bridgeHook: {
+ lifecycle: {
+ beforeBridgeRender: { emit: beforeBridgeRender },
+ afterBridgeRender: { emit: afterBridgeRender },
+ beforeBridgeDestroy: { emit: beforeBridgeDestroy },
+ afterBridgeDestroy: { emit: afterBridgeDestroy },
+ },
+ },
+ } as unknown as ModuleFederation;
+
+ const mockUnmount = jest.fn();
+ const mockRender = jest.fn();
+ const createRootSpy = jest.fn(() => ({
+ render: mockRender,
+ unmount: mockUnmount,
+ }));
+
+ let instanceCounter = 0;
+ function RemoteApp({
+ props,
+ }: {
+ props?: { count: number; forceRecreate?: boolean };
+ }) {
+ const instanceId = useRef(++instanceCounter);
+ return (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ rerender: (info: any) => ({
+ shouldRecreate: info.props?.forceRecreate === true,
+ }),
+ // override root creation so we can assert unmount/recreation
+ createRoot: createRootSpy,
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+ const [forceRecreate, setForceRecreate] = useState(false);
+ return (
+
+
+ );
+ }
+
+ render();
+
+ // Initial render calls
+ await waitFor(() => {
+ expect(mockRender).toHaveBeenCalled();
+ });
+ expect(beforeBridgeRender).toHaveBeenCalled();
+ expect(afterBridgeRender).toHaveBeenCalled();
+
+ // Clear for rerender assertions
+ mockRender.mockClear();
+ beforeBridgeRender.mockClear();
+ afterBridgeRender.mockClear();
+ beforeBridgeDestroy.mockClear();
+ afterBridgeDestroy.mockClear();
+
+ // Normal rerender: should not destroy
+ act(() => {
+ fireEvent.click(screen.getByTestId('inc'));
+ });
+ await waitFor(() => expect(mockRender).toHaveBeenCalled());
+ expect(beforeBridgeDestroy).not.toHaveBeenCalled();
+ expect(afterBridgeDestroy).not.toHaveBeenCalled();
+ expect(beforeBridgeRender).toHaveBeenCalled();
+ expect(afterBridgeRender).toHaveBeenCalled();
+
+ // Clear and force recreation
+ mockRender.mockClear();
+ beforeBridgeRender.mockClear();
+ afterBridgeRender.mockClear();
+
+ act(() => {
+ fireEvent.click(screen.getByTestId('recreate'));
+ });
+
+ await waitFor(() => expect(mockRender).toHaveBeenCalled());
+ // Destroy hooks should have fired once
+ expect(beforeBridgeDestroy).toHaveBeenCalledTimes(1);
+ expect(afterBridgeDestroy).toHaveBeenCalledTimes(1);
+ // And a new root should have been created
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
+ expect(createRootSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should call custom render once on mount and again only when recreating', async () => {
+ const customRender = jest.fn();
+ let instanceCounter = 0;
+
+ function RemoteApp({
+ props,
+ }: {
+ props?: { count: number; forceRecreate?: boolean };
+ }) {
+ const instanceId = useRef(++instanceCounter);
+ return (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ render: (App, container) => {
+ customRender(App, container);
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { createRoot } = require('react-dom/client') as {
+ createRoot: (c: HTMLElement) => {
+ render: (n: React.ReactNode) => void;
+ unmount: () => void;
+ };
+ };
+ const r = createRoot(container as HTMLElement);
+ r.render(App);
+ // Wrap to align with expected Root shape without using any
+ return {
+ render: (n: React.ReactNode) => r.render(n),
+ unmount: () => r.unmount(),
+ };
+ },
+ rerender: (info: any) => ({
+ shouldRecreate: info.props?.forceRecreate === true,
+ }),
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+ const [forceRecreate, setForceRecreate] = useState(false);
+ return (
+
+ setCount((c) => c + 1)} />
+ {
+ setForceRecreate(true);
+ setCount((c) => c + 1);
+ }}
+ />
+
+
+ );
+ }
+
+ render();
+
+ // Initial mount calls custom render once
+ await waitFor(() => {
+ expect(customRender).toHaveBeenCalledTimes(1);
+ });
+
+ // Normal rerender: custom render should not be called again
+ act(() => {
+ fireEvent.click(screen.getByTestId('inc'));
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
+ });
+ expect(customRender).toHaveBeenCalledTimes(1);
+
+ // Force recreation: custom render should be called again
+ act(() => {
+ fireEvent.click(screen.getByTestId('recreate'));
+ });
+ await waitFor(() => {
+ expect(customRender).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('should recreate when host changes key (React key technique)', async () => {
+ let instanceCounter = 0;
+
+ function RemoteApp({ props }: { props?: { count: number } }) {
+ const instanceId = useRef(++instanceCounter);
+ return (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ // No rerender option provided; key change should force unmount/mount
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+ const [key, setKey] = useState('a');
+
+ return (
+
+ setCount((c) => c + 1)} />
+ setKey((k) => (k === 'a' ? 'b' : 'a'))}
+ />
+
+
+ );
+ }
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 0');
+ expect(screen.getByTestId('instance-id')).toHaveTextContent(
+ 'Instance: 1',
+ );
+ });
+
+ // Update props without key change — should preserve instance
+ act(() => {
+ fireEvent.click(screen.getByTestId('inc'));
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
+ expect(screen.getByTestId('instance-id')).toHaveTextContent(
+ 'Instance: 1',
+ );
+ });
+
+ // Change key — should remount and increment instance id
+ act(() => {
+ fireEvent.click(screen.getByTestId('swap-key'));
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
+ expect(screen.getByTestId('instance-id')).toHaveTextContent(
+ 'Instance: 2',
+ );
+ });
+ });
+
+ it('should hydrate with custom hydrateRoot and preserve state on update', async () => {
+ function RemoteApp({ props }: { props?: { count: number } }) {
+ return (
+
+ Count: {props?.count}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ render: (App, container) => {
+ // Hydrate existing SSR markup first, then reuse the returned root for updates
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { hydrateRoot } = require('react-dom/client') as {
+ hydrateRoot: (
+ c: HTMLElement,
+ initialChildren: React.ReactNode,
+ ) => { render: (n: React.ReactNode) => void; unmount: () => void };
+ };
+ // Pre-populate minimal SSR markup matching the structure (content may differ)
+ (container as HTMLElement).innerHTML =
+ 'Count: 0
';
+ const r = hydrateRoot(container as HTMLElement, App);
+ return {
+ render: (n: React.ReactNode) => r.render(n),
+ unmount: () => r.unmount(),
+ };
+ },
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+ return (
+
+ setCount((c) => c + 1)} />
+
+
+ );
+ }
+
+ render();
+
+ // Hydrated content should appear; then updates should modify text
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toBeInTheDocument();
+ });
+
+ act(() => {
+ fireEvent.click(screen.getByTestId('inc'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
+ });
+ });
+
+ it('should emit destroy hooks when host changes key (key-based remount)', async () => {
+ const beforeBridgeDestroy = jest.fn();
+ const afterBridgeDestroy = jest.fn();
+ federationRuntime.instance = {
+ bridgeHook: {
+ lifecycle: {
+ beforeBridgeRender: { emit: jest.fn() },
+ afterBridgeRender: { emit: jest.fn() },
+ beforeBridgeDestroy: { emit: beforeBridgeDestroy },
+ afterBridgeDestroy: { emit: afterBridgeDestroy },
+ },
+ },
+ } as unknown as ModuleFederation;
+
+ let instanceCounter = 0;
+ function RemoteApp({ props }: { props?: { count: number } }) {
+ const instanceId = useRef(++instanceCounter);
+ return (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({ rootComponent: RemoteApp });
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [key, setKey] = useState('a');
+ const [count, setCount] = useState(0);
+ return (
+
+ setCount((c) => c + 1)} />
+ setKey((k) => (k === 'a' ? 'b' : 'a'))}
+ />
+
+
+ );
+ }
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('instance-id')).toHaveTextContent(
+ 'Instance: 1',
+ );
+ });
+
+ // Trigger a key change to force remount
+ act(() => {
+ fireEvent.click(screen.getByTestId('swap-key'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('instance-id')).toHaveTextContent(
+ 'Instance: 2',
+ );
+ });
+
+ expect(beforeBridgeDestroy).toHaveBeenCalled();
+ expect(afterBridgeDestroy).toHaveBeenCalled();
+ });
+
+ it('should inject extra props from beforeBridgeRender into child props', async () => {
+ // Arrange federation runtime lifecycle hook to inject props
+ const beforeBridgeRender = jest.fn().mockReturnValue({
+ extraProps: { props: { injected: 'hello' } },
+ });
+ federationRuntime.instance = {
+ bridgeHook: {
+ lifecycle: {
+ beforeBridgeRender: { emit: beforeBridgeRender },
+ afterBridgeRender: { emit: jest.fn() },
+ beforeBridgeDestroy: { emit: jest.fn() },
+ afterBridgeDestroy: { emit: jest.fn() },
+ },
+ },
+ } as unknown as ModuleFederation;
+
+ function RemoteApp({ props }: { props?: { injected?: string } }) {
+ return (
+
+ {props?.injected ?? 'nope'}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('injected')).toHaveTextContent('hello');
+ });
+
+ expect(beforeBridgeRender).toHaveBeenCalled();
+ });
+
+ it('should fallback to custom render on update when returned root lacks render()', async () => {
+ const customRender = jest.fn();
+
+ // Track roots per container and intentionally return a handle without `render`
+ const roots = new Map();
+
+ let instanceCounter = 0;
+ function RemoteApp({ props }: { props?: { count: number } }) {
+ const instanceId = useRef(++instanceCounter);
+ return (
+
+ Count: {props?.count}
+ Instance: {instanceId.current}
+
+ );
+ }
+
+ const BridgeComponent = createBridgeComponent({
+ rootComponent: RemoteApp,
+ render: (App, container) => {
+ customRender(App, container);
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { createRoot } = require('react-dom/client') as {
+ createRoot: (c: HTMLElement) => {
+ render: (n: React.ReactNode) => void;
+ unmount: () => void;
+ };
+ };
+ let root = roots.get(container as Element);
+ if (!root) {
+ root = createRoot(container as HTMLElement);
+ roots.set(container as Element, root);
+ }
+ root.render(App);
+ // Return a handle without `render` to simulate implementations that don’t expose it
+ // Bind unmount to avoid teardown errors when called later
+ // Return a DOM element to simulate a non-root handle (no render method)
+ return container as HTMLElement;
+ },
+ // No rerender hook; fallback path should call custom render again
+ });
+
+ const RemoteAppComponent = createRemoteAppComponent({
+ loader: async () => ({ default: BridgeComponent }),
+ loading: Loading...
,
+ fallback: () => Error
,
+ });
+
+ function HostApp() {
+ const [count, setCount] = useState(0);
+ return (
+
+ setCount((c) => c + 1)} />
+
+
+ );
+ }
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toBeInTheDocument();
+ });
+ expect(customRender).toHaveBeenCalledTimes(1);
+
+ // Trigger update – fallback should call custom render again
+ act(() => {
+ fireEvent.click(screen.getByTestId('inc'));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('remote-count')).toHaveTextContent('Count: 1');
+ });
+ expect(customRender).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/bridge/bridge-react/__tests__/router.spec.tsx b/packages/bridge/bridge-react/__tests__/router.spec.tsx
index 3892a863965..1f42ae751c5 100644
--- a/packages/bridge/bridge-react/__tests__/router.spec.tsx
+++ b/packages/bridge/bridge-react/__tests__/router.spec.tsx
@@ -15,7 +15,7 @@ import { getHtml, getWindowImpl } from './util';
describe('react router proxy', () => {
it('BrowserRouter not wraper context', async () => {
let { container } = render(
-
+
-
@@ -73,7 +73,7 @@ describe('react router proxy', () => {
},
);
let { container } = render(
-
+
,
);
diff --git a/packages/bridge/bridge-react/__tests__/setupTests.ts b/packages/bridge/bridge-react/__tests__/setupTests.ts
index f14cea72de4..6e35f13bf9f 100644
--- a/packages/bridge/bridge-react/__tests__/setupTests.ts
+++ b/packages/bridge/bridge-react/__tests__/setupTests.ts
@@ -5,4 +5,5 @@ import '@testing-library/jest-dom';
// Fix TextEncoder/TextDecoder not defined in Node.js
import { TextEncoder, TextDecoder } from 'util';
global.TextEncoder = TextEncoder;
-global.TextDecoder = TextDecoder as any;
+(global as unknown as { TextDecoder: typeof TextDecoder }).TextDecoder =
+ TextDecoder;
diff --git a/packages/bridge/bridge-react/__tests__/util.ts b/packages/bridge/bridge-react/__tests__/util.ts
index 6e85359faa7..5deaa4fa359 100644
--- a/packages/bridge/bridge-react/__tests__/util.ts
+++ b/packages/bridge/bridge-react/__tests__/util.ts
@@ -1,4 +1,3 @@
-import { JSDOM } from 'jsdom';
import { prettyDOM } from '@testing-library/react';
export async function sleep(time: number) {
@@ -23,10 +22,26 @@ export function createContainer() {
}
export function getWindowImpl(initialUrl: string, isHash = false): Window {
- // Need to use our own custom DOM in order to get a working history
- const dom = new JSDOM(``, { url: 'http://localhost/' });
- dom.window.history.replaceState(null, '', (isHash ? '#' : '') + initialUrl);
- return dom.window as unknown as Window;
+ // Prefer creating an isolated JSDOM window when jsdom is available.
+ // When running in environments where `jsdom` is not hoisted, fall back to the global window.
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { JSDOM } = require('jsdom') as typeof import('jsdom');
+ const dom = new JSDOM(``, { url: 'http://localhost/' });
+ dom.window.history.replaceState(null, '', (isHash ? '#' : '') + initialUrl);
+ return dom.window as unknown as Window;
+ } catch {
+ // Fallback – rely on Jest's jsdom environment global window
+ const w = (globalThis as unknown as { window: Window }).window;
+ // Ensure URL base
+ try {
+ // Replace state to desired initial URL
+ w?.history?.replaceState?.(null, '', (isHash ? '#' : '') + initialUrl);
+ } catch {
+ // no-op: keep default URL if history isn't available
+ }
+ return w;
+ }
}
export function getHtml(container: HTMLElement) {
diff --git a/packages/bridge/bridge-react/src/provider/versions/bridge-base.tsx b/packages/bridge/bridge-react/src/provider/versions/bridge-base.tsx
index 0a7d43a4009..faa0e231186 100644
--- a/packages/bridge/bridge-react/src/provider/versions/bridge-base.tsx
+++ b/packages/bridge/bridge-react/src/provider/versions/bridge-base.tsx
@@ -19,10 +19,13 @@ import { federationRuntime } from '../plugin';
export function createBaseBridgeComponent({
createRoot,
defaultRootOptions,
+ rerender,
...bridgeInfo
}: ProviderFnParams) {
return () => {
const rootMap = new Map();
+ const componentStateMap = new Map();
+ const propsStateMap = new Map();
const instance = federationRuntime.instance;
LoggerInstance.debug(
`createBridgeComponent instance from props >>>`,
@@ -63,46 +66,242 @@ export function createBaseBridgeComponent({
const beforeBridgeRenderRes =
instance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(info) || {};
+ const getExtraProps = (
+ x: unknown,
+ ): Record | undefined => {
+ if (x && typeof x === 'object' && 'extraProps' in (x as object)) {
+ const { extraProps } = x as {
+ extraProps?: Record;
+ };
+ return extraProps;
+ }
+ return undefined;
+ };
- const BridgeWrapper = ({ basename }: { basename?: string }) => (
+ // Build a stable element tree using stable component types
+ const buildElement = (params: {
+ moduleName: typeof moduleName;
+ basename?: typeof basename;
+ memoryRoute: typeof memoryRoute;
+ fallback?: typeof fallback;
+ propsInfo: typeof propsInfo;
+ }) => (
}
+ FallbackComponent={
+ params.fallback as React.ComponentType
+ }
>
);
- const rootComponentWithErrorBoundary = (
-
- );
+ const rootComponentWithErrorBoundary = buildElement({
+ moduleName,
+ basename,
+ memoryRoute,
+ fallback,
+ propsInfo,
+ });
- if (bridgeInfo.render) {
- await Promise.resolve(
- bridgeInfo.render(rootComponentWithErrorBoundary, dom),
- ).then((root: RootType) => rootMap.set(dom, root));
- } else {
- let root = rootMap.get(dom);
- // Do not call createRoot multiple times
- if (!root && createRoot) {
- root = createRoot(dom, mergedRootOptions);
- rootMap.set(dom, root as any);
+ // Determine if we already have a root for this DOM node
+ let root = rootMap.get(dom);
+ const hasRender = (
+ r: RootType | undefined,
+ ): r is RootType & { render: (children: React.ReactNode) => void } =>
+ !!r && typeof (r as any).render === 'function';
+ const existingComponent = componentStateMap.get(dom);
+
+ if (!root) {
+ // Initial render: create or obtain a root once
+ if (bridgeInfo.render) {
+ root = (await Promise.resolve(
+ bridgeInfo.render(rootComponentWithErrorBoundary, dom),
+ )) as RootType;
+ rootMap.set(dom, root);
+ // If the custom render implementation already performed a render,
+ // do not call render again below when root lacks a render method.
+ if (hasRender(root)) {
+ root.render(rootComponentWithErrorBoundary);
+ }
+ } else {
+ if (!root && createRoot) {
+ root = createRoot(dom, mergedRootOptions);
+ rootMap.set(dom, root as any);
+ }
+
+ if (hasRender(root)) {
+ root.render(rootComponentWithErrorBoundary);
+ }
}
- if (root && 'render' in root) {
- root.render(rootComponentWithErrorBoundary);
+ // Store initial component and props for future rerender detection
+ componentStateMap.set(dom, rootComponentWithErrorBoundary);
+ propsStateMap.set(dom, info);
+ } else {
+ // Rerender path (we have an existing root)
+ // Check if we have a custom rerender function and this is a rerender (not initial render)
+ if (rerender && existingComponent && root) {
+ LoggerInstance.debug(
+ `createBridgeComponent custom rerender >>>`,
+ info,
+ );
+
+ // Call the custom rerender function
+ const rerenderResult = rerender(info);
+ const shouldRecreate = rerenderResult?.shouldRecreate ?? false;
+
+ if (!shouldRecreate) {
+ // Use custom rerender logic - update props without recreating the component tree
+ LoggerInstance.debug(
+ `createBridgeComponent preserving component state >>>`,
+ info,
+ );
+
+ // Build updated element with stable component identities
+ const {
+ moduleName: updatedModuleName,
+ basename: updatedBasename,
+ memoryRoute: updatedMemoryRoute,
+ fallback: updatedFallback,
+ ...updatedPropsInfo
+ } = info;
+
+ const updatedRootComponentWithErrorBoundary = buildElement({
+ moduleName: updatedModuleName,
+ basename: updatedBasename,
+ memoryRoute: updatedMemoryRoute,
+ fallback: updatedFallback,
+ propsInfo: updatedPropsInfo,
+ });
+
+ // Store the new props and updated component
+ propsStateMap.set(dom, info);
+ componentStateMap.set(dom, updatedRootComponentWithErrorBoundary);
+
+ // Update the React tree with new props.
+ // Prefer root.render when available; otherwise fall back to invoking custom render again
+ // to preserve compatibility with implementations that return a handle without `render`.
+ if (hasRender(root)) {
+ root.render(updatedRootComponentWithErrorBoundary);
+ } else if (bridgeInfo.render) {
+ const newRoot = (await Promise.resolve(
+ bridgeInfo.render(updatedRootComponentWithErrorBoundary, dom),
+ )) as RootType;
+ rootMap.set(dom, newRoot);
+ }
+ } else {
+ // Custom rerender function requested recreation - unmount and recreate
+ LoggerInstance.debug(
+ `createBridgeComponent recreating component due to shouldRecreate: true >>>`,
+ info,
+ );
+
+ // Emit destroy lifecycle hooks around recreation
+ try {
+ instance?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(
+ info,
+ );
+ } catch (e) {
+ LoggerInstance.warn('beforeBridgeDestroy hook failed', e);
+ }
+
+ // Unmount the existing root to reset all state
+ if (root && 'unmount' in root) {
+ root.unmount();
+ LoggerInstance.debug(
+ `createBridgeComponent unmounted existing root >>>`,
+ info,
+ );
+ }
+
+ try {
+ instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(info);
+ } catch (e) {
+ LoggerInstance.warn('afterBridgeDestroy hook failed', e);
+ }
+
+ // Remove the old root from the map
+ rootMap.delete(dom);
+
+ // Create a fresh root
+ let newRoot: RootType | null = null;
+ const {
+ moduleName: recreateModuleName,
+ basename: recreateBasename,
+ memoryRoute: recreateMemoryRoute,
+ fallback: recreateFallback,
+ ...recreatePropsInfo
+ } = info;
+
+ const recreateRootComponentWithErrorBoundary = buildElement({
+ moduleName: recreateModuleName,
+ basename: recreateBasename,
+ memoryRoute: recreateMemoryRoute,
+ fallback: recreateFallback,
+ propsInfo: recreatePropsInfo,
+ });
+
+ if (bridgeInfo.render) {
+ newRoot = (await Promise.resolve(
+ bridgeInfo.render(
+ recreateRootComponentWithErrorBoundary,
+ dom,
+ ),
+ )) as RootType;
+ rootMap.set(dom, newRoot);
+ LoggerInstance.debug(
+ `createBridgeComponent created fresh root via custom render >>>`,
+ info,
+ );
+ } else if (createRoot) {
+ newRoot = createRoot(dom, mergedRootOptions);
+ rootMap.set(dom, newRoot);
+ LoggerInstance.debug(
+ `createBridgeComponent created fresh root >>>`,
+ info,
+ );
+ }
+
+ // Render with the new root
+ if (hasRender(newRoot)) {
+ newRoot.render(recreateRootComponentWithErrorBoundary);
+ }
+
+ // Update state maps with new component
+ componentStateMap.set(
+ dom,
+ recreateRootComponentWithErrorBoundary,
+ );
+ propsStateMap.set(dom, info);
+ }
+ } else {
+ // No custom rerender provided; render into existing root or
+ // fall back to calling the custom render once more if the handle lacks `render`.
+ if (hasRender(root)) {
+ root.render(rootComponentWithErrorBoundary);
+ } else if (bridgeInfo.render) {
+ const refreshedRoot = (await Promise.resolve(
+ bridgeInfo.render(rootComponentWithErrorBoundary, dom),
+ )) as RootType;
+ rootMap.set(dom, refreshedRoot);
+ }
+
+ // Update component/props state
+ componentStateMap.set(dom, rootComponentWithErrorBoundary);
+ propsStateMap.set(dom, info);
}
}
instance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info) || {};
@@ -120,6 +319,9 @@ export function createBaseBridgeComponent({
}
rootMap.delete(dom);
}
+ // Clean up component state maps
+ componentStateMap.delete(dom);
+ propsStateMap.delete(dom);
instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(info);
},
};
diff --git a/packages/bridge/bridge-react/src/provider/versions/legacy.ts b/packages/bridge/bridge-react/src/provider/versions/legacy.ts
index de867c6b0ae..074276d16e6 100644
--- a/packages/bridge/bridge-react/src/provider/versions/legacy.ts
+++ b/packages/bridge/bridge-react/src/provider/versions/legacy.ts
@@ -29,43 +29,59 @@ export interface Root {
export function createReact16Or17Root(
container: Element | DocumentFragment,
): Root {
- return {
- render(children: React.ReactNode) {
- /**
- * Detect React version
- */
- const reactVersion = ReactDOM.version || '';
- const isReact18 = reactVersion.startsWith('18');
- const isReact19 = reactVersion.startsWith('19');
+ /**
+ * Detect React version upfront
+ */
+ const reactVersion = ReactDOM.version || '';
+ const isReact18 = reactVersion.startsWith('18');
+ const isReact19 = reactVersion.startsWith('19');
+
+ /**
+ * Throw error for React 19
+ *
+ * Note: Due to Module Federation sharing mechanism, the actual version detected here
+ * might be 18 or 19, even if the application itself uses React 16/17.
+ * This happens because in MF environments, different remote modules may share different React versions.
+ * The console may throw warnings about version and API mismatches. If you need to resolve these issues,
+ * consider disabling the shared configuration for React.
+ */
+ if (isReact19) {
+ throw new Error(
+ `React 19 detected in legacy mode. This is not supported. ` +
+ `Please use the version-specific import: ` +
+ `import { createBridgeComponent } from '@module-federation/bridge-react/v19'`,
+ );
+ }
- /**
- * Throw error for React 19
- *
- * Note: Due to Module Federation sharing mechanism, the actual version detected here
- * might be 18 or 19, even if the application itself uses React 16/17.
- * This happens because in MF environments, different remote modules may share different React versions.
- * The console may throw warnings about version and API mismatches. If you need to resolve these issues,
- * consider disabling the shared configuration for React.
- */
- if (isReact19) {
- throw new Error(
- `React 19 detected in legacy mode. This is not supported. ` +
- `Please use the version-specific import: ` +
- `import { createBridgeComponent } from '@module-federation/bridge-react/v19'`,
- );
- }
+ /**
+ * Provide warning for React 18
+ */
+ if (isReact18) {
+ LoggerInstance.warn(
+ `[Bridge-React] React 18 detected in legacy mode. ` +
+ `For better compatibility, please use the version-specific import: ` +
+ `import { createBridgeComponent } from '@module-federation/bridge-react/v18'`,
+ );
+ }
- /**
- * Provide warning for React 18
- */
- if (isReact18) {
- LoggerInstance.warn(
- `[Bridge-React] React 18 detected in legacy mode. ` +
- `For better compatibility, please use the version-specific import: ` +
- `import { createBridgeComponent } from '@module-federation/bridge-react/v18'`,
- );
- }
+ // For React 18+, use createRoot to avoid multiple root creation issues
+ if (isReact18) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const ReactDOMClient = require('react-dom/client');
+ const root = ReactDOMClient.createRoot(container);
+ return {
+ render(children: React.ReactNode) {
+ root.render(children);
+ },
+ unmount() {
+ root.unmount();
+ },
+ };
+ }
+ // For React 16/17, use legacy render API
+ return {
+ render(children: React.ReactNode) {
// @ts-ignore - React 17's render method is deprecated but still functional
ReactDOM.render(children, container);
},
diff --git a/packages/bridge/bridge-react/src/remote/component.tsx b/packages/bridge/bridge-react/src/remote/component.tsx
index a7ae62d841d..826f22b9d92 100644
--- a/packages/bridge/bridge-react/src/remote/component.tsx
+++ b/packages/bridge/bridge-react/src/remote/component.tsx
@@ -79,6 +79,9 @@ const RemoteAppWrapper = forwardRef(function (
};
}, [moduleName]);
+ // Serialize props once to use as stable dependency
+ const propsStr = JSON.stringify(props);
+
// trigger render after props updated
useEffect(() => {
if (!initialized || !providerInfoRef.current) return;
@@ -100,7 +103,8 @@ const RemoteAppWrapper = forwardRef(function (
renderProps = { ...renderProps, ...beforeBridgeRenderRes.extraProps };
providerInfoRef.current.render(renderProps);
instance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(renderProps);
- }, [initialized, ...Object.values(props)]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialized, propsStr]);
// bridge-remote-root
const rootComponentClassName = `${getRootDomDefaultClassName(moduleName)} ${className || ''}`;
diff --git a/packages/bridge/bridge-react/src/types.ts b/packages/bridge/bridge-react/src/types.ts
index eab85b7be60..b470ea51f79 100644
--- a/packages/bridge/bridge-react/src/types.ts
+++ b/packages/bridge/bridge-react/src/types.ts
@@ -100,6 +100,20 @@ export interface ProviderFnParams {
* }
*/
defaultRootOptions?: CreateRootOptions;
+ /**
+ * Custom rerender function to handle prop updates without recreating the entire component tree
+ * This function is called when the host component rerenders and passes new props to the remote app
+ * @param props - The new props being passed to the remote app
+ * @returns An object indicating how to handle the rerender, or void for default behavior
+ * @example
+ * {
+ * rerender: (props) => {
+ * // Custom logic to update component without full recreation
+ * return { shouldRecreate: false };
+ * }
+ * }
+ */
+ rerender?: (props: RenderParams) => { shouldRecreate?: boolean } | void;
}
/**