Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/calm-vue-bridge-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@module-federation/bridge-vue3': patch
---

Fix Vue bridge lifecycle state so destroyed roots can be mounted again, avoid
sharing root state between provider instances, and allow `appOptions` to be
omitted as documented.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ __mocks__/
/.env

# Local AI/unpack cache (not part of source)
/graphify-out/
packages/enhanced/.unpack-cache/**
packages/enhanced/.codex/**
packages/enhanced/generated/**
Expand Down
100 changes: 99 additions & 1 deletion packages/bridge/vue3-bridge/__tests__/bridgeLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, rs } from '@rstest/core';
import { h, nextTick } from 'vue';
import { h, nextTick, onUnmounted } from 'vue';
import { createMemoryHistory, createRouter } from 'vue-router';
import { createBridgeComponent } from '../src/provider';

Expand Down Expand Up @@ -81,6 +81,104 @@ describe('Vue Bridge operation lifecycle', () => {
).toBe(1);
});

it('allows appOptions to be omitted', async () => {
const dom = document.createElement('div');
document.body.appendChild(dom);
const bridge = createBridgeComponent({
rootComponent: { render: () => h('div', 'committed') },
})();

await bridge.render({ dom, moduleName: 'remote/App' });

expect(dom.textContent).toBe('committed');
bridge.destroy({ dom });
});

it('releases the mounted app so a remount is a fresh render', async () => {
const dom = document.createElement('div');
document.body.appendChild(dom);
const bridge = createBridgeComponent({
rootComponent: { render: () => h('div', 'committed') },
appOptions: () => undefined,
})();

await bridge.render({ dom, moduleName: 'remote/App' });
bridge.destroy({ dom });
await bridge.render({ dom, moduleName: 'remote/App' });

const operations = lifecycleEvents
.filter(
(event) =>
event.lifecycle === 'beforeBridgeRender' &&
['render', 'update'].includes(getContext(event).operation),
)
.map((event) => getContext(event).operation);
expect(operations).toEqual(['render', 'render']);
bridge.destroy({ dom });
});

it('remounts the app for an update without leaving two roots mounted', async () => {
const dom = document.createElement('div');
document.body.appendChild(dom);
const onRootUnmounted = rs.fn();
const bridge = createBridgeComponent({
rootComponent: {
setup: () => {
onUnmounted(onRootUnmounted);
return () => h('div', 'committed');
},
},
appOptions: () => undefined,
})();

await bridge.render({ dom, moduleName: 'remote/App' });
await bridge.render({ dom, moduleName: 'remote/App' });

const operations = lifecycleEvents
.filter(
(event) =>
event.lifecycle === 'beforeBridgeRender' &&
['render', 'update'].includes(getContext(event).operation),
)
.map((event) => getContext(event).operation);
expect(operations).toEqual(['render', 'update']);
expect(dom.textContent).toBe('committed');
expect(onRootUnmounted).toHaveBeenCalledTimes(1);
bridge.destroy({ dom });
expect(onRootUnmounted).toHaveBeenCalledTimes(2);
});

it('does not retain a root when appOptions fails before mounting', async () => {
const dom = document.createElement('div');
document.body.appendChild(dom);
let shouldFail = true;
const bridge = createBridgeComponent({
rootComponent: { render: () => h('div', 'committed') },
appOptions: () => {
if (shouldFail) {
throw new Error('options failed');
}
return undefined;
},
})();

await expect(
bridge.render({ dom, moduleName: 'remote/App' }),
).rejects.toThrow('options failed');
shouldFail = false;
await bridge.render({ dom, moduleName: 'remote/App' });

const operations = lifecycleEvents
.filter(
(event) =>
event.lifecycle === 'beforeBridgeRender' &&
['render', 'update'].includes(getContext(event).operation),
)
.map((event) => getContext(event).operation);
expect(operations).toEqual(['render', 'render']);
bridge.destroy({ dom });
});

it('preserves render and destroy errors without reporting completion', async () => {
const renderDom = document.createElement('div');
const renderBridge = createBridgeComponent({
Expand Down
21 changes: 17 additions & 4 deletions packages/bridge/vue3-bridge/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type AddOptionsFnParams = {

export type ProviderFnParams = {
rootComponent: Vue.Component;
appOptions: (params: AddOptionsFnParams) => {
appOptions?: (params: AddOptionsFnParams) => {
router?: VueRouter.Router;
/** Called with the bridge's internal router after creation but before navigation.
* Use this to register global guards (beforeEach, afterEach, etc.) that would
Expand All @@ -29,9 +29,12 @@ export type ProviderFnParams = {
};

export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
const rootMap = new Map();
const instance = getInstance();
return () => {
// A provider instance owns the roots it mounts. Keeping this map here
// prevents roots from leaking across independently created providers.
const rootMap = new Map<HTMLElement, Vue.App<Vue.Component>>();

return {
__APP_VERSION__,
async render(info: RenderFnParams) {
Expand All @@ -52,7 +55,6 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
};

const app = Vue.createApp(bridgeInfo.rootComponent, propsInfo);
rootMap.set(dom, app);

const beforeBridgeRenderRes =
await instance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(
Expand All @@ -67,7 +69,7 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
? beforeBridgeRenderRes?.extraProps
: {};

const bridgeOptions = bridgeInfo.appOptions({
const bridgeOptions = bridgeInfo.appOptions?.({
app,
basename,
memoryRoute,
Expand Down Expand Up @@ -125,7 +127,15 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
app.use(router);
}

const previousApp = rootMap.get(dom);
if (previousApp) {
// Vue apps cannot be mounted twice. Recreate the app for updates,
// but release the previous root before mounting the replacement.
previousApp.unmount();
rootMap.delete(dom);
}
app.mount(dom);
rootMap.set(dom, app);
Comment thread
LuisDev2001 marked this conversation as resolved.
instance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info, {
context: operationContext,
});
Expand All @@ -145,6 +155,9 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
operationContext,
);
root?.unmount();
if (root) {
rootMap.delete(info.dom);
}
instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(info, {
context: operationContext,
});
Expand Down
Loading