diff --git a/apps/router-demo/router-remote3-2003/src/pages/Profile.vue b/apps/router-demo/router-remote3-2003/src/pages/Profile.vue
new file mode 100644
index 00000000000..006ca92868a
--- /dev/null
+++ b/apps/router-demo/router-remote3-2003/src/pages/Profile.vue
@@ -0,0 +1,6 @@
+
+
+
Settings Page
+
This is the settings page for Router Remote 3.
+
+
diff --git a/apps/router-demo/router-remote3-2003/src/pages/Settings.vue b/apps/router-demo/router-remote3-2003/src/pages/Settings.vue
new file mode 100644
index 00000000000..006ca92868a
--- /dev/null
+++ b/apps/router-demo/router-remote3-2003/src/pages/Settings.vue
@@ -0,0 +1,6 @@
+
+
+
Settings Page
+
This is the settings page for Router Remote 3.
+
+
diff --git a/apps/router-demo/router-remote3-2003/src/router.ts b/apps/router-demo/router-remote3-2003/src/router.ts
index ed06fc0cb52..86448a96b7c 100644
--- a/apps/router-demo/router-remote3-2003/src/router.ts
+++ b/apps/router-demo/router-remote3-2003/src/router.ts
@@ -5,13 +5,28 @@ import {
} from 'vue-router';
import Home from '@/pages/Home.vue';
import Detail from '@/pages/Detail.vue';
+import Settings from '@/pages/Settings.vue';
+import Profile from '@/pages/Profile.vue';
const router = createRouter({
history: createWebHistory(),
routes: [
// 在这里定义你的路由
- { path: '/', component: Home },
- { path: '/detail', component: Detail },
+ {
+ path: '/',
+ component: Home,
+ children: [
+ {
+ path: 'profile',
+ component: Profile,
+ children: [{ path: 'settings', component: Settings }],
+ },
+ ],
+ },
+ {
+ path: '/detail',
+ component: Detail,
+ },
// 其他路由
],
});
diff --git a/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts
new file mode 100644
index 00000000000..d6474b09ab9
--- /dev/null
+++ b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts
@@ -0,0 +1,501 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { createRouter, createWebHistory } from 'vue-router';
+import { processRoutes, type RouteProcessingOptions } from '../src/routeUtils';
+
+// Mock components for testing
+const HomeComponent = { template: '
Home
' };
+const DashboardComponent = { template: 'Dashboard
' };
+const ProfileComponent = { template: 'Profile
' };
+const SettingsComponent = { template: 'Settings
' };
+const AccountComponent = { template: 'Account
' };
+const NotificationsComponent = { template: 'Notifications
' };
+
+// Create a nested route configuration for testing
+const createNestedRoutes = () => [
+ {
+ path: '/',
+ name: 'Home',
+ component: HomeComponent,
+ },
+ {
+ path: '/dashboard',
+ name: 'Dashboard',
+ component: DashboardComponent,
+ children: [
+ {
+ path: 'profile',
+ name: 'Profile',
+ component: ProfileComponent,
+ },
+ {
+ path: 'settings',
+ name: 'Settings',
+ component: SettingsComponent,
+ children: [
+ {
+ path: 'account',
+ name: 'Account',
+ component: AccountComponent,
+ },
+ {
+ path: 'notifications',
+ name: 'Notifications',
+ component: NotificationsComponent,
+ },
+ ],
+ },
+ ],
+ },
+];
+
+describe('routeUtils', () => {
+ let router: any;
+ let options: RouteProcessingOptions;
+
+ beforeEach(() => {
+ const routes = createNestedRoutes();
+ router = createRouter({
+ history: createWebHistory(),
+ routes,
+ });
+
+ options = {
+ router,
+ basename: '/app',
+ };
+ });
+
+ describe('processRoutesWithPathAnalysis', () => {
+ it('should process routes correctly', () => {
+ const result = processRoutes(options);
+
+ expect(result.history).toBeDefined();
+ expect(result.routes).toBeDefined();
+
+ expect(result.routes.length).toBe(2); // Home + Dashboard
+
+ // test nested structure
+ const dashboardRoute = result.routes.find(
+ (route) => route.name === 'Dashboard',
+ );
+ expect(dashboardRoute).toBeDefined();
+ expect(dashboardRoute?.children).toBeDefined();
+ expect(dashboardRoute?.children?.length).toBe(2); // Profile + Settings
+
+ // test deep nested structure
+ const settingsRoute = dashboardRoute?.children?.find(
+ (child) => child.name === 'Settings',
+ );
+ expect(settingsRoute).toBeDefined();
+ expect(settingsRoute?.children).toBeDefined();
+ expect(settingsRoute?.children?.length).toBe(2); // Account + Notifications
+
+ // test paths
+ expect(dashboardRoute?.path).toBe('/dashboard');
+ expect(settingsRoute?.path).toBe('settings'); // relative path
+
+ const accountRoute = settingsRoute?.children?.find(
+ (child) => child.name === 'Account',
+ );
+ expect(accountRoute?.path).toBe('account'); // relative path
+ });
+
+ it('should process root path correctly', () => {
+ const result = processRoutes(options);
+
+ const homeRoute = result.routes.find((route) => route.name === 'Home');
+ expect(homeRoute).toBeDefined();
+ expect(homeRoute?.path).toBe('/');
+ });
+
+ it('should preserve component information at runtime', () => {
+ const result = processRoutes(options);
+
+ const dashboardRoute = result.routes.find(
+ (route) => route.name === 'Dashboard',
+ );
+ expect(dashboardRoute?.components).toBeDefined();
+
+ // test child component
+ const profileRoute = dashboardRoute?.children?.find(
+ (child) => child.name === 'Profile',
+ );
+ expect(profileRoute?.components).toBeDefined();
+ });
+ });
+
+ describe('processRoutes with relative paths', () => {
+ it('should use relative paths for nested routes', () => {
+ const result = processRoutes(options);
+
+ const dashboardRoute = result.routes.find(
+ (route) => route.name === 'Dashboard',
+ );
+ if (dashboardRoute && dashboardRoute.children) {
+ const settingsRoute = dashboardRoute.children.find(
+ (child) => child.name === 'Settings',
+ );
+ if (settingsRoute) {
+ // Path of `Settings` should be 'settings',rather than '/dashboard/settings'
+ expect(settingsRoute.path).toBe('settings');
+
+ // Check child routes of Settings
+ if (settingsRoute.children) {
+ const accountRoute = settingsRoute.children.find(
+ (child) => child.name === 'Account',
+ );
+ if (accountRoute) {
+ expect(accountRoute.path).toBe('account');
+ }
+ }
+ }
+ }
+ });
+
+ it('should maintain top-level routes with absolute paths', () => {
+ const result = processRoutes(options);
+
+ const homeRoute = result.routes.find((route) => route.name === 'Home');
+ expect(homeRoute?.path).toBe('/');
+
+ const dashboardRoute = result.routes.find(
+ (route) => route.name === 'Dashboard',
+ );
+ expect(dashboardRoute?.path).toBe('/dashboard');
+ });
+
+ it('should handle deep nested routes with correct relative paths', () => {
+ const result = processRoutes(options);
+
+ const dashboardRoute = result.routes.find(
+ (route) => route.name === 'Dashboard',
+ );
+ expect(dashboardRoute?.path).toBe('/dashboard'); // Top-level should be absolute
+
+ if (dashboardRoute && dashboardRoute.children) {
+ const profileRoute = dashboardRoute.children.find(
+ (child) => child.name === 'Profile',
+ );
+ expect(profileRoute?.path).toBe('profile'); // Child should be relative
+
+ const settingsRoute = dashboardRoute.children.find(
+ (child) => child.name === 'Settings',
+ );
+ expect(settingsRoute?.path).toBe('settings'); // Child should be relative
+
+ // Test deep nested routes
+ if (settingsRoute && settingsRoute.children) {
+ const accountRoute = settingsRoute.children.find(
+ (child) => child.name === 'Account',
+ );
+ expect(accountRoute?.path).toBe('account'); // Deep child should be relative
+
+ const notificationsRoute = settingsRoute.children.find(
+ (child) => child.name === 'Notifications',
+ );
+ expect(notificationsRoute?.path).toBe('notifications'); // Deep child should be relative
+ }
+ }
+ });
+ });
+
+ describe('processRoutes path normalization', () => {
+ it('should handle root path children without double slashes', () => {
+ // Create a route structure with root children to test path normalization
+ const rootChildrenRoutes = [
+ {
+ path: '/',
+ name: 'Root',
+ component: HomeComponent,
+ children: [
+ {
+ path: 'about',
+ name: 'About',
+ component: { template: 'About
' },
+ },
+ {
+ path: 'contact',
+ name: 'Contact',
+ component: { template: 'Contact
' },
+ },
+ ],
+ },
+ ];
+
+ const rootRouter = createRouter({
+ history: createWebHistory(),
+ routes: rootChildrenRoutes,
+ });
+
+ const result = processRoutes({
+ router: rootRouter,
+ });
+
+ expect(result.routes.length).toBe(1);
+
+ const rootRoute = result.routes.find((route) => route.name === 'Root');
+ expect(rootRoute).toBeDefined();
+ expect(rootRoute?.path).toBe('/'); // Root should remain '/'
+
+ // Check that child routes have correct relative paths without double slashes
+ if (rootRoute && rootRoute.children) {
+ const aboutRoute = rootRoute.children.find(
+ (child) => child.name === 'About',
+ );
+ expect(aboutRoute?.path).toBe('about'); // Should be 'about', not '//about' or '/about'
+
+ const contactRoute = rootRoute.children.find(
+ (child) => child.name === 'Contact',
+ );
+ expect(contactRoute?.path).toBe('contact'); // Should be 'contact', not '//contact' or '/contact'
+ }
+ });
+
+ it('should normalize paths with trailing slashes correctly', () => {
+ // Create routes with potential trailing slash issues
+ const trailingSlashRoutes = [
+ {
+ path: '/dashboard/', // Note: trailing slash in parent
+ name: 'Dashboard',
+ component: DashboardComponent,
+ children: [
+ {
+ path: 'settings/', // Note: trailing slash in child
+ name: 'Settings',
+ component: SettingsComponent,
+ children: [
+ {
+ path: 'profile',
+ name: 'Profile',
+ component: ProfileComponent,
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const trailingSlashRouter = createRouter({
+ history: createWebHistory(),
+ routes: trailingSlashRoutes,
+ });
+
+ const result = processRoutes({
+ router: trailingSlashRouter,
+ });
+
+ const dashboardRoute = result.routes.find(
+ (route) => route.name === 'Dashboard',
+ );
+ expect(dashboardRoute).toBeDefined();
+
+ if (dashboardRoute && dashboardRoute.children) {
+ const settingsRoute = dashboardRoute.children.find(
+ (child) => child.name === 'Settings',
+ );
+ expect(settingsRoute?.path).toBe('settings/'); // Should preserve original relative path
+
+ if (settingsRoute && settingsRoute.children) {
+ const profileRoute = settingsRoute.children.find(
+ (child) => child.name === 'Profile',
+ );
+ expect(profileRoute?.path).toBe('profile'); // Should be clean relative path
+ }
+ }
+ });
+
+ it('should handle multiple consecutive slashes in path construction', () => {
+ // Create a scenario that could cause double slashes in internal path construction
+ const multiSlashRoutes = [
+ {
+ path: '/api/v1',
+ name: 'ApiV1',
+ component: { template: 'API v1
' },
+ children: [
+ {
+ path: 'users',
+ name: 'Users',
+ component: { template: 'Users
' },
+ children: [
+ {
+ path: 'profile',
+ name: 'UserProfile',
+ component: { template: 'User Profile
' },
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const multiSlashRouter = createRouter({
+ history: createWebHistory(),
+ routes: multiSlashRoutes,
+ });
+
+ const result = processRoutes({
+ router: multiSlashRouter,
+ });
+
+ const apiRoute = result.routes.find((route) => route.name === 'ApiV1');
+ expect(apiRoute?.path).toBe('/api/v1');
+
+ if (apiRoute && apiRoute.children) {
+ const usersRoute = apiRoute.children.find(
+ (child) => child.name === 'Users',
+ );
+ expect(usersRoute?.path).toBe('users'); // Should be relative without leading slash
+
+ if (usersRoute && usersRoute.children) {
+ const profileRoute = usersRoute.children.find(
+ (child) => child.name === 'UserProfile',
+ );
+ expect(profileRoute?.path).toBe('profile'); // Should be clean relative path
+ }
+ }
+
+ // Verify that the actual full paths in router.getRoutes() are correctly normalized
+ const flatRoutes = multiSlashRouter.getRoutes();
+ const userProfileRoute = flatRoutes.find(
+ (route) => route.name === 'UserProfile',
+ );
+ expect(userProfileRoute?.path).toBe('/api/v1/users/profile'); // Should not contain double slashes
+ });
+
+ it('should handle edge cases with empty and slash-only paths', () => {
+ // Test edge cases that could cause path normalization issues
+ const edgeCaseRoutes = [
+ {
+ path: '/',
+ name: 'Root',
+ component: HomeComponent,
+ children: [
+ {
+ path: '', // Empty path child
+ name: 'Index',
+ component: { template: 'Index
' },
+ },
+ {
+ path: '/', // Slash-only child (should be normalized)
+ name: 'SlashChild',
+ component: { template: 'Slash Child
' },
+ },
+ ],
+ },
+ ];
+
+ const edgeCaseRouter = createRouter({
+ history: createWebHistory(),
+ routes: edgeCaseRoutes,
+ });
+
+ const result = processRoutes({
+ router: edgeCaseRouter,
+ });
+
+ // Vue Router creates separate top-level routes for empty and slash paths under root
+ expect(result.routes.length).toBe(3); // Index, SlashChild, and Root all become top-level
+
+ const rootRoute = result.routes.find((route) => route.name === 'Root');
+ const indexRoute = result.routes.find((route) => route.name === 'Index');
+ const slashChildRoute = result.routes.find(
+ (route) => route.name === 'SlashChild',
+ );
+
+ // All should be top-level routes with '/' path (Vue Router normalization)
+ expect(rootRoute?.path).toBe('/');
+ expect(indexRoute?.path).toBe('/');
+ expect(slashChildRoute?.path).toBe('/');
+
+ // The original Root route should still have its children structure preserved
+ if (rootRoute && rootRoute.children) {
+ expect(rootRoute.children.length).toBe(2);
+
+ // But the children paths should be the original relative paths from static config
+ const staticIndexChild = rootRoute.children.find(
+ (child) => child.name === 'Index',
+ );
+ const staticSlashChild = rootRoute.children.find(
+ (child) => child.name === 'SlashChild',
+ );
+
+ if (staticIndexChild) {
+ expect(staticIndexChild.path).toBe(''); // Empty path preserved
+ }
+ if (staticSlashChild) {
+ expect(staticSlashChild.path).toBe('/'); // Slash path preserved
+ }
+ }
+
+ // Verify the flat routes structure
+ const flatRoutes = edgeCaseRouter.getRoutes();
+ const indexFlatRoute = flatRoutes.find((route) => route.name === 'Index');
+ const slashChildFlatRoute = flatRoutes.find(
+ (route) => route.name === 'SlashChild',
+ );
+
+ // Vue Router normalizes both empty and slash children of root to '/'
+ expect(indexFlatRoute?.path).toBe('/');
+ expect(slashChildFlatRoute?.path).toBe('/');
+ });
+
+ it('should verify normalizePath function handles double slashes correctly', () => {
+ // Since normalizePath is internal, we test it indirectly by creating scenarios
+ // that would cause double slashes and verifying they are handled correctly
+
+ const doubleSlashRoutes = [
+ {
+ path: '/api/', // trailing slash
+ name: 'Api',
+ component: { template: 'API
' },
+ children: [
+ {
+ path: '/users', // leading slash on child
+ name: 'Users',
+ component: { template: 'Users
' },
+ },
+ {
+ path: 'posts/', // trailing slash on child
+ name: 'Posts',
+ component: { template: 'Posts
' },
+ children: [
+ {
+ path: '/comments', // leading slash on grandchild
+ name: 'Comments',
+ component: { template: 'Comments
' },
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const doubleSlashRouter = createRouter({
+ history: createWebHistory(),
+ routes: doubleSlashRoutes,
+ });
+
+ // The key test: ensure our processRoutes doesn't break with these edge cases
+ const result = processRoutes({
+ router: doubleSlashRouter,
+ });
+
+ // Verify that no route has double slashes in its processing
+ const flatRoutes = doubleSlashRouter.getRoutes();
+
+ // Check that Vue Router itself handles the normalization correctly
+ const usersRoute = flatRoutes.find((r) => r.name === 'Users');
+ const postsRoute = flatRoutes.find((r) => r.name === 'Posts');
+ const commentsRoute = flatRoutes.find((r) => r.name === 'Comments');
+
+ // Vue Router treats children with leading slash as absolute paths
+ expect(usersRoute?.path).toBe('/users'); // Leading slash makes it absolute
+ expect(postsRoute?.path).toBe('/api/posts/'); // Relative path gets parent prefix
+ expect(commentsRoute?.path).toBe('/comments'); // Leading slash makes it absolute
+
+ // Verify our processRoutes handles these correctly without errors
+ expect(result.routes.length).toBeGreaterThan(0);
+ expect(() => processRoutes({ router: doubleSlashRouter })).not.toThrow();
+ });
+ });
+});
diff --git a/packages/bridge/vue3-bridge/package.json b/packages/bridge/vue3-bridge/package.json
index 28e6a0e9f54..f9caa2dffea 100644
--- a/packages/bridge/vue3-bridge/package.json
+++ b/packages/bridge/vue3-bridge/package.json
@@ -30,7 +30,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest"
},
"peerDependencies": {
"vue": "=3",
diff --git a/packages/bridge/vue3-bridge/src/provider.ts b/packages/bridge/vue3-bridge/src/provider.ts
index d8b08df50ce..bc612d6013d 100644
--- a/packages/bridge/vue3-bridge/src/provider.ts
+++ b/packages/bridge/vue3-bridge/src/provider.ts
@@ -3,6 +3,7 @@ import * as VueRouter from 'vue-router';
import { RenderFnParams } from '@module-federation/bridge-shared';
import { LoggerInstance } from './utils';
import { getInstance } from '@module-federation/runtime';
+import { processRoutes } from './routeUtils';
declare const __APP_VERSION__: string;
@@ -58,22 +59,12 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) {
...extraProps,
});
if (bridgeOptions?.router) {
- let history;
- let routes = bridgeOptions.router.getRoutes();
-
- if (info.memoryRoute) {
- history = VueRouter.createMemoryHistory(info.basename);
- } else if (info.hashRoute) {
- history = VueRouter.createWebHashHistory();
- routes = routes.map((route) => {
- return {
- ...route,
- path: info.basename + route.path,
- };
- });
- } else {
- history = VueRouter.createWebHistory(info.basename);
- }
+ const { history, routes } = processRoutes({
+ router: bridgeOptions.router,
+ basename: info.basename,
+ memoryRoute: info.memoryRoute,
+ hashRoute: info.hashRoute,
+ });
const router = VueRouter.createRouter({
...bridgeOptions.router.options,
diff --git a/packages/bridge/vue3-bridge/src/routeUtils.ts b/packages/bridge/vue3-bridge/src/routeUtils.ts
new file mode 100644
index 00000000000..5d904f6c4cf
--- /dev/null
+++ b/packages/bridge/vue3-bridge/src/routeUtils.ts
@@ -0,0 +1,139 @@
+import * as VueRouter from 'vue-router';
+
+export interface RouteProcessingOptions {
+ router: VueRouter.Router;
+ basename?: string;
+ memoryRoute?: boolean | { entryPath: string };
+ hashRoute?: boolean;
+}
+
+export interface RouteProcessingResult {
+ history: VueRouter.RouterHistory;
+ routes: VueRouter.RouteRecordNormalized[];
+}
+
+/**
+ * Add basename prefix to all nested routes recursively
+ *
+ * @param routes - route configuration array
+ * @param basename - base path prefix
+ * @returns processed route configuration
+ */
+function addBasenameToNestedRoutes(
+ routes: VueRouter.RouteRecordNormalized[],
+ basename: string,
+): VueRouter.RouteRecordNormalized[] {
+ return routes.map((route) => {
+ const updatedRoute: VueRouter.RouteRecordNormalized = {
+ ...route,
+ path: basename + route.path,
+ };
+
+ // Recursively process child routes
+ if (route.children && route.children.length > 0) {
+ updatedRoute.children = addBasenameToNestedRoutes(
+ route.children as VueRouter.RouteRecordNormalized[],
+ basename,
+ );
+ }
+
+ return updatedRoute;
+ });
+}
+
+/**
+ * Route processing solution based on path analysis
+ *
+ * @param options - route processing options
+ * @returns processed history and routes
+ */
+export function processRoutes(
+ options: RouteProcessingOptions,
+): RouteProcessingResult {
+ const { router, basename, memoryRoute, hashRoute } = options;
+
+ // Sort routes, try to process parent route first
+ const flatRoutes = router
+ .getRoutes()
+ .sort(
+ (a, b) =>
+ a.path.split('/').filter((p) => p).length -
+ b.path.split('/').filter((p) => p).length,
+ );
+
+ // Use Map/Set for O(1) lookup performance
+ const flatRoutesMap = new Map();
+ const processedRoutes = new Set();
+
+ flatRoutes.forEach((route) => {
+ flatRoutesMap.set(route.path, route);
+ });
+
+ /**
+ * Normalize path by removing double slashes and trailing slashes
+ */
+ const normalizePath = (prefix: string, childPath: string): string => {
+ const fullPath = `${prefix}/${childPath}`;
+ return fullPath.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
+ };
+
+ const processChildren = (
+ route: VueRouter.RouteRecordNormalized,
+ prefix = '',
+ ): VueRouter.RouteRecordNormalized => {
+ if (!route.children || route.children.length === 0) {
+ return route;
+ }
+
+ for (let j = 0; j < route.children.length; j++) {
+ const child = route.children[j];
+ const fullPath = normalizePath(prefix, child.path);
+ const childRoute = flatRoutesMap.get(fullPath);
+
+ if (childRoute && !processedRoutes.has(childRoute)) {
+ // Create a new optimized route object with relative path for nested routes
+ const relativeChildRoute: VueRouter.RouteRecordNormalized = {
+ ...childRoute,
+ path: child.path, // Keep the original relative path from static route
+ };
+
+ route.children[j] = relativeChildRoute;
+ processedRoutes.add(childRoute);
+
+ processChildren(relativeChildRoute, fullPath);
+ }
+ }
+
+ return route;
+ };
+
+ // Reconstruct nested structure
+ let routes: VueRouter.RouteRecordNormalized[] = [];
+
+ for (const route of flatRoutes) {
+ if (!processedRoutes.has(route)) {
+ const processedRoute = processChildren(route, route.path);
+ processedRoutes.add(route);
+ routes.push(processedRoute);
+ }
+ }
+
+ let history: VueRouter.RouterHistory;
+ if (memoryRoute) {
+ // Memory route mode
+ history = VueRouter.createMemoryHistory(basename);
+ } else if (hashRoute) {
+ // Hash route mode
+ history = VueRouter.createWebHashHistory();
+ // Recursively process nested routes and add basename prefix to all paths
+ if (basename) routes = addBasenameToNestedRoutes(routes, basename);
+ } else {
+ // Default Web History mode
+ history = VueRouter.createWebHistory(basename);
+ }
+
+ return {
+ history,
+ routes,
+ };
+}
diff --git a/packages/bridge/vue3-bridge/vitest.config.ts b/packages/bridge/vue3-bridge/vitest.config.ts
new file mode 100644
index 00000000000..5ee7a58c5d2
--- /dev/null
+++ b/packages/bridge/vue3-bridge/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vitest/config';
+import { resolve } from 'path';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, './src'),
+ },
+ },
+});