diff --git a/apps/runtime-demo/3005-runtime-host/src/App.tsx b/apps/runtime-demo/3005-runtime-host/src/App.tsx index 2cf2933f74e..9d320c8cd2f 100644 --- a/apps/runtime-demo/3005-runtime-host/src/App.tsx +++ b/apps/runtime-demo/3005-runtime-host/src/App.tsx @@ -4,6 +4,7 @@ import { Link, Routes, Route, BrowserRouter } from 'react-router-dom'; import Root from './Root'; import Remote1 from './Remote1'; import Remote2 from './Remote2'; +import Remote3 from './Remote3'; const App = () => ( @@ -18,11 +19,15 @@ const App = () => (
  • remote2
  • +
  • + remote3 +
  • } /> } /> } /> + } />
    ); diff --git a/apps/runtime-demo/3005-runtime-host/src/Remote3.tsx b/apps/runtime-demo/3005-runtime-host/src/Remote3.tsx new file mode 100644 index 00000000000..2818f654496 --- /dev/null +++ b/apps/runtime-demo/3005-runtime-host/src/Remote3.tsx @@ -0,0 +1,28 @@ +import React, { Suspense, lazy } from 'react'; +import { createRoot } from 'react-dom/client'; +import { loadRemote } from '@module-federation/enhanced/runtime'; + +class CustomElement extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + async connectedCallback() { + if (!this.shadowRoot) return; + + const module = await loadRemote('dynamic-remote/ButtonOldAnt', { + //@ts-ignore + root: this.shadowRoot, + }); + //@ts-ignore + createRoot(this.shadowRoot).render(React.createElement(module.default)); + } +} + +customElements.define('custom-element', CustomElement); + +function DynamicRemoteButton() { + return React.createElement('custom-element'); +} + +export default DynamicRemoteButton; diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 6ed436b1bb1..34bab3f128b 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -247,7 +247,7 @@ export class FederationHost { // eslint-disable-next-line @typescript-eslint/member-ordering async loadRemote( id: string, - options?: { loadFactory?: boolean; from: CallFrom }, + options?: { loadFactory?: boolean; from?: CallFrom; root?: HTMLElement }, ): Promise { return this.remoteHandler.loadRemote(id, options); } diff --git a/packages/runtime-core/src/plugins/snapshot/index.ts b/packages/runtime-core/src/plugins/snapshot/index.ts index 990dd62e284..908bbec5816 100644 --- a/packages/runtime-core/src/plugins/snapshot/index.ts +++ b/packages/runtime-core/src/plugins/snapshot/index.ts @@ -41,7 +41,7 @@ export function snapshotPlugin(): FederationRuntimePlugin { return { name: 'snapshot-plugin', async afterResolve(args) { - const { remote, pkgNameOrAlias, expose, origin, remoteInfo } = args; + const { remote, pkgNameOrAlias, expose, origin, remoteInfo, root } = args; if (!isRemoteInfoWithEntry(remote) || !isPureRemoteEntry(remote)) { const { remoteSnapshot, globalSnapshot } = @@ -73,7 +73,7 @@ export function snapshotPlugin(): FederationRuntimePlugin { ); if (assets) { - preloadAssets(remoteInfo, origin, assets, false); + preloadAssets(remoteInfo, origin, assets, false, root); } return { diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index f51935c4440..ea73e941aa7 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -57,6 +57,7 @@ export interface LoadRemoteMatch { origin: FederationHost; remoteInfo: RemoteInfo; remoteSnapshot?: ModuleInfo; + root?: HTMLElement; } export class RemoteHandler { @@ -197,7 +198,7 @@ export class RemoteHandler { // eslint-disable-next-line @typescript-eslint/member-ordering async loadRemote( id: string, - options?: { loadFactory?: boolean; from: CallFrom }, + options?: { loadFactory?: boolean; from?: CallFrom; root?: HTMLElement }, ): Promise { const { host } = this; try { @@ -214,6 +215,7 @@ export class RemoteHandler { const { module, moduleOptions, remoteMatchInfo } = await this.getRemoteModuleAndOptions({ id, + root: options?.root, }); const { pkgNameOrAlias, @@ -314,7 +316,10 @@ export class RemoteHandler { }); } - async getRemoteModuleAndOptions(options: { id: string }): Promise<{ + async getRemoteModuleAndOptions(options: { + id: string; + root?: HTMLElement; + }): Promise<{ module: Module; moduleOptions: ModuleOptions; remoteMatchInfo: LoadRemoteMatch; @@ -371,6 +376,7 @@ export class RemoteHandler { options: host.options, origin: host, remoteInfo, + root: options.root, }); const { remote, expose } = matchInfo; diff --git a/packages/runtime-core/src/utils/preload.ts b/packages/runtime-core/src/utils/preload.ts index ed99ee6c3bf..7e0e162d869 100644 --- a/packages/runtime-core/src/utils/preload.ts +++ b/packages/runtime-core/src/utils/preload.ts @@ -70,6 +70,7 @@ export function preloadAssets( assets: PreloadAssets, // It is used to distinguish preload from load remote parallel loading useLinkPreload = true, + root: HTMLElement = document.head, ): void { const { cssAssets, jsAssetsWithoutEntry, entryAssets } = assets; @@ -99,6 +100,7 @@ export function preloadAssets( }; cssAssets.forEach((cssUrl) => { const { link: cssEl, needAttach } = createLink({ + root, url: cssUrl, cb: () => { // noop @@ -116,7 +118,7 @@ export function preloadAssets( }, }); - needAttach && document.head.appendChild(cssEl); + needAttach && root.appendChild(cssEl); }); } else { const defaultAttrs = { @@ -125,6 +127,7 @@ export function preloadAssets( }; cssAssets.forEach((cssUrl) => { const { link: cssEl, needAttach } = createLink({ + root, url: cssUrl, cb: () => { // noop @@ -143,7 +146,7 @@ export function preloadAssets( needDeleteLink: false, }); - needAttach && document.head.appendChild(cssEl); + needAttach && root.appendChild(cssEl); }); } @@ -154,6 +157,7 @@ export function preloadAssets( }; jsAssetsWithoutEntry.forEach((jsUrl) => { const { link: linkEl, needAttach } = createLink({ + root: document.head, url: jsUrl, cb: () => { // noop diff --git a/packages/runtime/__tests__/load-remote.spec.ts b/packages/runtime/__tests__/load-remote.spec.ts index b072baccd6d..a20c950986c 100644 --- a/packages/runtime/__tests__/load-remote.spec.ts +++ b/packages/runtime/__tests__/load-remote.spec.ts @@ -535,6 +535,14 @@ describe('lazy loadRemote and add remote into snapshot', () => { }); describe('loadRemote', () => { + beforeEach(() => { + document.querySelectorAll('script').forEach((script) => { + script.remove(); + }); + document.querySelectorAll('link').forEach((link) => { + link.remove(); + }); + }); it('loads remote synchronously', async () => { const jsSyncAssetPath = 'resources/load-remote/app2/say.sync.js'; const remotePublicPath = 'http://localhost:1111/'; @@ -600,7 +608,92 @@ describe('loadRemote', () => { const loadedSrcs = [...document.querySelectorAll('script')].map( (i) => (i as any).fakeSrc, ); + const loadedStyles = [...document.querySelectorAll('link')].map( + (link) => link.href, + ); + expect(loadedSrcs.includes(`${remotePublicPath}${jsSyncAssetPath}`)); + expect(loadedStyles.includes(`${remotePublicPath}sub2/say.sync.css`)); + + reset(); + }); + + it('loads remote synchronously in a custom root', async () => { + const jsSyncAssetPath = 'resources/load-remote/app2/say.sync.js'; + const remotePublicPath = 'http://localhost:1111/'; + const reset = addGlobalSnapshot({ + '@federation-test/globalinfo': { + globalName: '', + buildVersion: '', + publicPath: '', + remoteTypes: '', + shared: [], + remoteEntry: '', + remoteEntryType: 'global', + modules: [], + version: '0.0.1', + remotesInfo: { + '@federation-test/app2': { + matchedVersion: '0.0.1', + }, + }, + }, + '@federation-test/app2:0.0.1': { + globalName: '', + publicPath: remotePublicPath, + remoteTypes: '', + shared: [], + buildVersion: 'custom', + remotesInfo: {}, + remoteEntryType: 'global', + modules: [ + { + moduleName: 'say', + assets: { + css: { + sync: ['sub2/say.sync.css'], + async: ['sub2/say.async.css'], + }, + js: { + sync: [jsSyncAssetPath], + async: [], + }, + }, + }, + ], + version: '0.0.1', + remoteEntry: 'resources/app2/federation-remote-entry.js', + }, + }); + + const FederationInstance = new FederationHost({ + name: '@federation-test/globalinfo', + remotes: [ + { + name: '@federation-test/app2', + version: '*', + }, + ], + }); + + const root = document.createElement('div'); + await FederationInstance.loadRemote<() => string>( + '@federation-test/app2/say', + { root }, + ); + // @ts-ignore fakeSrc is local mock attr, which value is the same as src + const loadedSrcs = [...document.querySelectorAll('script')].map( + (i) => (i as any).fakeSrc, + ); + const loadedStyles = [...root.querySelectorAll('link')].map( + (link) => link.href, + ); + const documentStyles = [...document.head.querySelectorAll('link')].map( + (link) => link.href, + ); expect(loadedSrcs.includes(`${remotePublicPath}${jsSyncAssetPath}`)); + expect(loadedStyles.includes(`${remotePublicPath}sub2/say.sync.css`)); + expect(documentStyles).toEqual([]); + reset(); }); }); diff --git a/packages/sdk/__tests__/dom.spec.ts b/packages/sdk/__tests__/dom.spec.ts index 729991f75e8..5d732fb91b2 100644 --- a/packages/sdk/__tests__/dom.spec.ts +++ b/packages/sdk/__tests__/dom.spec.ts @@ -164,6 +164,7 @@ describe('createLink', () => { const url = 'https://example.com/script.js'; const cb = jest.fn(); const { link, needAttach } = createLink({ + root: document.head, url, cb, attrs: { as: 'script' }, @@ -181,6 +182,7 @@ describe('createLink', () => { document.head.innerHTML = ``; const { link, needAttach } = createLink({ url, + root: document.head, cb, attrs: { rel: 'preload', @@ -197,7 +199,7 @@ describe('createLink', () => { const url = 'https://example.com/script.js'; const cb = jest.fn(); const attrs = { rel: 'preload', as: 'script', 'data-test': 'test' }; - const { link } = createLink({ url, cb, attrs }); + const { link } = createLink({ url, cb, attrs, root: document.head }); expect(link.rel).toBe('preload'); expect(link.getAttribute('as')).toBe('script'); @@ -215,6 +217,7 @@ describe('createLink', () => { }; const { link } = createLink({ url, + root: document.head, cb, attrs, createLinkHook: (url) => { @@ -237,6 +240,7 @@ describe('createLink', () => { const cb = jest.fn(); const { link, needAttach } = createLink({ url, + root: document.head, cb, attrs: { as: 'script' }, }); @@ -255,6 +259,7 @@ describe('createLink', () => { const onErrorCallback = jest.fn(); const { link, needAttach } = createLink({ url, + root: document.head, cb, onErrorCallback, attrs: { as: 'script' }, @@ -277,6 +282,7 @@ describe('createLink', () => { const { link } = createLink({ url, cb, + root: document.head, attrs: {}, createLinkHook: () => customLink, }); diff --git a/packages/sdk/src/dom.ts b/packages/sdk/src/dom.ts index 5c478b13068..38d9d7af885 100644 --- a/packages/sdk/src/dom.ts +++ b/packages/sdk/src/dom.ts @@ -136,6 +136,7 @@ export function createScript(info: { } export function createLink(info: { + root: HTMLElement; url: string; cb?: (value: void | PromiseLike) => void; onErrorCallback?: (error: Error) => void; @@ -151,7 +152,7 @@ export function createLink(info: { // Retrieve the existing script element by its src attribute let link: HTMLLinkElement | null = null; let needAttach = true; - const links = document.getElementsByTagName('link'); + const links = info.root.querySelectorAll('link'); for (let i = 0; i < links.length; i++) { const l = links[i]; const linkHref = l.getAttribute('href');