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