Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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, cancel asynchronous renders when
their host is destroyed, 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
166 changes: 165 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, onMounted, onUnmounted } from 'vue';
import { createMemoryHistory, createRouter } from 'vue-router';
import { createBridgeComponent } from '../src/provider';

Expand Down Expand Up @@ -81,6 +81,170 @@ 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('cancels a pending memory-route render when destroyed', async () => {
const dom = document.createElement('div');
document.body.appendChild(dom);
const onRootMounted = rs.fn();
let releaseNavigation!: () => void;
let markNavigationStarted!: () => void;
const navigationStarted = new Promise<void>((resolve) => {
markNavigationStarted = resolve;
});
const navigationReleased = new Promise<void>((resolve) => {
releaseNavigation = resolve;
});
const sourceRouter = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/start', component: { render: () => h('div', 'start') } },
],
});
const bridge = createBridgeComponent({
rootComponent: {
setup: () => {
onMounted(onRootMounted);
return () => h('div', 'committed');
},
},
appOptions: () => ({
router: sourceRouter,
afterRouterCreate(router) {
router.beforeEach(async () => {
markNavigationStarted();
await navigationReleased;
});
},
}),
})();

const renderPromise = bridge.render({
dom,
moduleName: 'remote/App',
memoryRoute: { entryPath: '/start' },
});
await navigationStarted;
bridge.destroy({ dom });
releaseNavigation();
await renderPromise;
await nextTick();

expect(onRootMounted).not.toHaveBeenCalled();
expect(dom.textContent).toBe('');
expect(
lifecycleEvents.some(
(event) =>
event.lifecycle === 'afterBridgeRender' &&
getContext(event).operation === 'render',
),
).toBe(false);

await bridge.render({
dom,
moduleName: 'remote/App',
memoryRoute: { entryPath: '/start' },
});
expect(onRootMounted).toHaveBeenCalledTimes(1);
bridge.destroy({ dom });
});

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
42 changes: 38 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,13 @@ 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>>();
const renderGenerations = new WeakMap<HTMLElement, number>();

return {
__APP_VERSION__,
async render(info: RenderFnParams) {
Expand All @@ -44,6 +48,10 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
hashRoute,
...propsInfo
} = info;
const renderGeneration = (renderGenerations.get(dom) ?? 0) + 1;
renderGenerations.set(dom, renderGeneration);
const isCurrentRender = () =>
renderGenerations.get(dom) === renderGeneration;
const operationContext: BridgeOperationContext = {
side: 'producer',
framework: 'vue',
Expand All @@ -52,22 +60,25 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
};

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

const beforeBridgeRenderRes =
await instance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(
info,
operationContext,
);

if (!isCurrentRender()) {
return;
}

const extraProps =
beforeBridgeRenderRes &&
typeof beforeBridgeRenderRes === 'object' &&
beforeBridgeRenderRes?.extraProps
? beforeBridgeRenderRes?.extraProps
: {};

const bridgeOptions = bridgeInfo.appOptions({
const bridgeOptions = bridgeInfo.appOptions?.({
app,
basename,
memoryRoute,
Expand Down Expand Up @@ -116,6 +127,9 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
route,
};
const result = await router.push(memoryRoute.entryPath);
if (!isCurrentRender()) {
return;
}
instance?.bridgeHook?.lifecycle?.afterBridgeRouteSync?.emit({
context: routeContext,
result,
Expand All @@ -125,13 +139,30 @@ 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);
}
if (!isCurrentRender()) {
return;
}
app.mount(dom);
rootMap.set(dom, app);
Comment thread
LuisDev2001 marked this conversation as resolved.
instance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info, {
context: operationContext,
});
},
destroy(info: { dom: HTMLElement; moduleName?: string }) {
LoggerInstance.debug(`createBridgeComponent destroy Info`, info);
if (info?.dom) {
renderGenerations.set(
info.dom,
(renderGenerations.get(info.dom) ?? 0) + 1,
);
}
const root = rootMap.get(info?.dom);
const operationContext: BridgeOperationContext = {
side: 'producer',
Expand All @@ -145,6 +176,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