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 @@ + 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 @@ + 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'), + }, + }, +});