Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bridge-react): enable custom createRoot to support React v19 in @module-federation/bridge-react #3551

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/chatty-days-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/bridge-react': patch
---

feat(bridge-react): enable custom createRoot in bridge-react
5 changes: 5 additions & 0 deletions .changeset/neat-types-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/bridge-react': patch
---

refactor(bridge-react): centralize type definitions into a single file for better maintainability and consistency
30 changes: 26 additions & 4 deletions apps/website-new/docs/zh/practice/bridge/react-bridge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,19 +182,34 @@ export declare function createBridgeComponent<T>(bridgeInfo: ProviderFnParams<T>
}): Promise<void>;
};

type ProviderFnParams<T> = {
interface ProviderFnParams<T> {
rootComponent: React.ComponentType<T>;
render?: (
App: React.ReactElement,
id?: HTMLElement | string,
) => RootType | Promise<RootType>;
createRoot?: (
container: Element | DocumentFragment,
options?: CreateRootOptions,
) => Root;
};

export declare interface RenderFnParams extends ProviderParams {
interface CreateRootOptions {
identifierPrefix?: string;
onRecoverableError?: (error: unknown) => void;
transitionCallbacks?: unknown;
}

interface Root {
render(children: React.ReactNode): void;
unmount(): void;
}

interface RenderFnParams extends ProviderParams {
dom: HTMLElement;
}

export declare interface ProviderParams {
interface ProviderParams {
moduleName?: string;
basename?: string;
memoryRoute?: {
Expand All @@ -209,16 +224,23 @@ export declare interface ProviderParams {
* `bridgeInfo`
* type:
```tsx
type ProviderFnParams<T> = {
interface ProviderFnParams<T> {
rootComponent: React.ComponentType<T>;
render?: (
App: React.ReactElement,
id?: HTMLElement | string,
) => RootType | Promise<RootType>;
createRoot?: (
container: Element | DocumentFragment,
options?: CreateRootOptions,
) => Root;
};
```

* 作用: 用于传递根组件
* `rootComponent`: 需要被远程加载的根组件
* `render`: 自定义渲染函数,用于自定义渲染逻辑
* `createRoot`: 自定义 createRoot 函数,用于自定义 React 根节点的创建方式,可用于兼容不同版本的 React 或实现特定的渲染行为
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be more advantageous to translate it into English, or keep both the code and comments in English? This way, we would reach a larger audience and also maintain a consistent language standard in the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@douglaszaltron Oh, I got what you mean. I think the thing is, I forgot the English document of this part! Yes you are right,you've made a very good point. We'll definitely add English docs for this part, and I think for english users we can just turn to the english version of the website and it will all be English documents. Thank again!

* ReturnType
* type:

Expand Down
32 changes: 32 additions & 0 deletions packages/bridge/bridge-react/__tests__/bridge.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,36 @@ describe('bridge', () => {
expect(getHtml(container)).toMatch('hello world');
expect(ref.current).not.toBeNull();
});

it('createRemoteComponent with custom createRoot prop', async () => {
const renderMock = vi.fn();

function Component({ props }: { props?: Record<string, any> }) {
return <div>life cycle render {props?.msg}</div>;
}
const BridgeComponent = createBridgeComponent({
rootComponent: Component,
createRoot: () => {
return {
render: renderMock,
unmount: vi.fn(),
};
},
});
const RemoteComponent = createRemoteComponent({
loader: async () => {
return {
default: BridgeComponent,
};
},
fallback: () => <div></div>,
loading: <div>loading</div>,
});

const { container } = render(<RemoteComponent />);
expect(getHtml(container)).toMatch('loading');

await sleep(200);
expect(renderMock).toHaveBeenCalledTimes(1);
});
});
10 changes: 9 additions & 1 deletion packages/bridge/bridge-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ export { createBridgeComponent } from './provider/create';
export type {
ProviderParams,
RenderFnParams,
} from '@module-federation/bridge-shared';
ProviderFnParams,
RootType,
Root,
CreateRootOptions,
DestroyParams,
RenderParams,
RemoteComponentParams,
RemoteComponentProps,
} from './types';
24 changes: 9 additions & 15 deletions packages/bridge/bridge-react/src/provider/compat.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import ReactDOM from 'react-dom';

interface CreateRootOptions {
identifierPrefix?: string;
onRecoverableError?: (error: unknown) => void;
transitionCallbacks?: unknown;
}

interface Root {
render(children: React.ReactNode): void;
unmount(): void;
}
import { CreateRootOptions, Root } from '../types';

const isReact18 = ReactDOM.version.startsWith('18');

Expand All @@ -29,7 +19,6 @@ export function createRoot(
// For React 16/17, simulate the new root API using render/unmountComponentAtNode
return {
render(children: React.ReactNode) {
// @ts-ignore - React 17's render method is deprecated but still functional
ReactDOM.render(children, container);
},
unmount() {
Expand All @@ -52,11 +41,16 @@ export function hydrateRoot(
return (ReactDOM as any).hydrateRoot(container, initialChildren, options);
}

// For React 16/17, simulate the new root API using hydrate
// For React 16/17, simulate the new root API using hydrate/unmountComponentAtNode
return {
render(children: React.ReactNode) {
// @ts-ignore - React 17's hydrate method is deprecated but still functional
ReactDOM.hydrate(children, container);
// For the initial render, use hydrate
if (children === initialChildren) {
ReactDOM.hydrate(children, container);
} else {
// For subsequent renders, use regular render
ReactDOM.render(children, container);
}
},
unmount() {
ReactDOM.unmountComponentAtNode(container);
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge/bridge-react/src/provider/context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import { ProviderParams } from '@module-federation/bridge-shared';
import { ProviderParams } from '../types';

export const RouterContext = React.createContext<ProviderParams | null>(null);
42 changes: 17 additions & 25 deletions packages/bridge/bridge-react/src/provider/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,21 @@ import ReactDOM from 'react-dom';
import type {
ProviderParams,
RenderFnParams,
} from '@module-federation/bridge-shared';
ProviderFnParams,
RootType,
DestroyParams,
RenderParams,
} from '../types';
import { ErrorBoundary } from 'react-error-boundary';
import { RouterContext } from './context';
import { LoggerInstance } from '../utils';
import { federationRuntime } from './plugin';
import { createRoot } from './compat';
import { createRoot as defaultCreateRoot } from './compat';

type RenderParams = RenderFnParams & {
[key: string]: unknown;
};
type DestroyParams = {
moduleName: string;
dom: HTMLElement;
};
type RootType = HTMLElement | ReturnType<typeof createRoot>;

export type ProviderFnParams<T> = {
rootComponent: React.ComponentType<T>;
render?: (
App: React.ReactElement,
id?: HTMLElement | string,
) => RootType | Promise<RootType>;
};

export function createBridgeComponent<T>(bridgeInfo: ProviderFnParams<T>) {
export function createBridgeComponent<T>({
createRoot = defaultCreateRoot,
...bridgeInfo
}: ProviderFnParams<T>) {
return () => {
const rootMap = new Map<any, RootType>();
const instance = federationRuntime.instance;
Expand Down Expand Up @@ -80,10 +70,9 @@ export function createBridgeComponent<T>(bridgeInfo: ProviderFnParams<T>) {
</ErrorBoundary>
);

if (bridgeInfo?.render) {
// in case bridgeInfo?.render is an async function, resolve this to promise
Promise.resolve(
bridgeInfo?.render(rootComponentWithErrorBoundary, dom),
if (bridgeInfo.render) {
await Promise.resolve(
bridgeInfo.render(rootComponentWithErrorBoundary, dom),
).then((root: RootType) => rootMap.set(info.dom, root));
} else {
let root = rootMap.get(info.dom);
Expand All @@ -92,7 +81,10 @@ export function createBridgeComponent<T>(bridgeInfo: ProviderFnParams<T>) {
root = createRoot(info.dom);
rootMap.set(info.dom, root);
}
root.render(rootComponentWithErrorBoundary);

if ('render' in root) {
root.render(rootComponentWithErrorBoundary);
}
}

instance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info) || {};
Expand Down
24 changes: 8 additions & 16 deletions packages/bridge/bridge-react/src/remote/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,30 @@ import React, {
forwardRef,
} from 'react';
import * as ReactRouterDOM from 'react-router-dom';
import type { ProviderParams } from '@module-federation/bridge-shared';
import { dispatchPopstateEnv } from '@module-federation/bridge-shared';
import { ErrorBoundaryPropsWithComponent } from 'react-error-boundary';
import { LoggerInstance, pathJoin, getRootDomDefaultClassName } from '../utils';
import { federationRuntime } from '../provider/plugin';
import { ProviderParams, RenderFnParams, RemoteComponentProps } from '../types';

declare const __APP_VERSION__: string;
export interface RenderFnParams extends ProviderParams {
dom?: any;

export interface RemoteAppParams extends ProviderParams {
moduleName: string;
providerInfo: NonNullable<RemoteModule['provider']>;
exportName: string | number | symbol;
fallback: ErrorBoundaryPropsWithComponent['FallbackComponent'];
}

interface RemoteModule {
provider: () => {
render: (
info: ProviderParams & {
dom: any;
},
) => void;
render: (info: RenderFnParams) => void;
destroy: (info: { dom: any }) => void;
};
}

interface RemoteAppParams {
moduleName: string;
providerInfo: NonNullable<RemoteModule['provider']>;
exportName: string | number | symbol;
fallback: ErrorBoundaryPropsWithComponent['FallbackComponent'];
}

const RemoteAppWrapper = forwardRef(function (
props: RemoteAppParams & RenderFnParams,
props: RemoteAppParams & RemoteComponentProps,
ref,
) {
const {
Expand Down
66 changes: 24 additions & 42 deletions packages/bridge/bridge-react/src/remote/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {
} from 'react-error-boundary';
import { LoggerInstance } from '../utils';
import RemoteApp from './component';
import type { ProviderParams } from '@module-federation/bridge-shared';

export interface RenderFnParams extends ProviderParams {
dom?: any;
}
import {
RemoteComponentParams,
RemoteComponentProps,
RenderFnParams,
} from '../types';

interface RemoteModule {
provider: () => {
Expand All @@ -18,16 +18,12 @@ interface RemoteModule {
};
}

type LazyRemoteComponentInfo<T, E extends keyof T> = {
loader: () => Promise<T>;
loading: React.ReactNode;
fallback: ErrorBoundaryPropsWithComponent['FallbackComponent'];
export?: E;
};
type LazyRemoteComponentInfo<T, E extends keyof T> = RemoteComponentParams<T>;

function createLazyRemoteComponent<T, E extends keyof T>(
info: LazyRemoteComponentInfo<T, E>,
) {
function createLazyRemoteComponent<
T = Record<string, unknown>,
E extends keyof T = keyof T,
>(info: LazyRemoteComponentInfo<T, E>) {
const exportName = info?.export || 'default';
return React.lazy(async () => {
LoggerInstance.debug(`createRemoteComponent LazyComponent create >>>`, {
Expand All @@ -49,10 +45,7 @@ function createLazyRemoteComponent<T, E extends keyof T>(
if (exportName in m && typeof exportFn === 'function') {
const RemoteAppComponent = forwardRef<
HTMLDivElement,
{
basename?: ProviderParams['basename'];
memoryRoute?: ProviderParams['memoryRoute'];
}
RemoteComponentProps
>((props, ref) => {
return (
<RemoteApp
Expand Down Expand Up @@ -87,29 +80,18 @@ function createLazyRemoteComponent<T, E extends keyof T>(
});
}

export function createRemoteComponent<T, E extends keyof T>(
info: LazyRemoteComponentInfo<T, E>,
) {
type ExportType = T[E] extends (...args: any) => any
? ReturnType<T[E]>
: never;

type RawComponentType = '__BRIDGE_FN__' extends keyof ExportType
? ExportType['__BRIDGE_FN__'] extends (...args: any) => any
? Parameters<ExportType['__BRIDGE_FN__']>[0]
: {}
: {};

export function createRemoteComponent<
T = Record<string, unknown>,
E extends keyof T = keyof T,
>(info: LazyRemoteComponentInfo<T, E>) {
const LazyComponent = createLazyRemoteComponent(info);
return forwardRef<HTMLDivElement, ProviderParams & RawComponentType>(
(props, ref) => {
return (
<ErrorBoundary FallbackComponent={info.fallback}>
<React.Suspense fallback={info.loading}>
<LazyComponent {...props} ref={ref} />
</React.Suspense>
</ErrorBoundary>
);
},
);
return forwardRef<HTMLDivElement, RemoteComponentProps>((props, ref) => {
return (
<ErrorBoundary FallbackComponent={info.fallback}>
<React.Suspense fallback={info.loading}>
<LazyComponent {...props} ref={ref} />
</React.Suspense>
</ErrorBoundary>
);
});
}
Loading