From 6693943e6c4be4e370f74f3a03ae23614d1cd562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B7=E9=85=B7=E7=89=9B=E5=A5=B6?= Date: Tue, 30 Sep 2025 16:29:03 +0800 Subject: [PATCH 1/6] fix(docs): fix mermaid syntax error --- arch-doc/architecture-overview.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/arch-doc/architecture-overview.md b/arch-doc/architecture-overview.md index 22179e3d33e..90381b575a2 100644 --- a/arch-doc/architecture-overview.md +++ b/arch-doc/architecture-overview.md @@ -231,10 +231,10 @@ flowchart LR end subgraph "Core APIs" - LoadRemote[loadRemote()] - LoadShare[loadShare()] - Init[init()] - RegisterRemotes[registerRemotes()] + LoadRemote["loadRemote()"] + LoadShare["loadShare()"] + Init["init()"] + RegisterRemotes["registerRemotes()"] end GlobalAPI --> LoadRemote @@ -1175,4 +1175,4 @@ For detailed implementation guidance, see: - Study `@module-federation/enhanced` for webpack build-time integration patterns - Examine `@module-federation/runtime-core` for bundler-agnostic runtime logic - Check `@module-federation/sdk` for available utilities and type definitions -- Look at `@module-federation/webpack-bundler-runtime` for bundler bridge patterns \ No newline at end of file +- Look at `@module-federation/webpack-bundler-runtime` for bundler bridge patterns From df351a3899b16641220a8af3fbb091f214ef0e5c Mon Sep 17 00:00:00 2001 From: willxywang Date: Sat, 4 Oct 2025 20:24:37 +0800 Subject: [PATCH 2/6] feat(vue3-bridge): construct nested runtime route --- .../vue3-bridge/__tests__/routeUtils.test.ts | 225 +++++++++++++++ packages/bridge/vue3-bridge/package.json | 3 +- .../vue3-bridge/src/pathBasedRouteUtils.ts | 261 ++++++++++++++++++ packages/bridge/vue3-bridge/src/provider.ts | 24 +- packages/bridge/vue3-bridge/src/routeUtils.ts | 85 ++++++ packages/bridge/vue3-bridge/vitest.config.ts | 14 + 6 files changed, 595 insertions(+), 17 deletions(-) create mode 100644 packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts create mode 100644 packages/bridge/vue3-bridge/src/pathBasedRouteUtils.ts create mode 100644 packages/bridge/vue3-bridge/src/routeUtils.ts create mode 100644 packages/bridge/vue3-bridge/vitest.config.ts 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..a9413f5d8a6 --- /dev/null +++ b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createRouter, createWebHistory } from 'vue-router'; +import { + processRoutesWithPathAnalysis, + type RouteProcessingOptions, +} from '../src/routeUtils'; +import { reconstructRoutesByPath } from '../src/pathBasedRouteUtils'; + +// 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 = processRoutesWithPathAnalysis(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 = processRoutesWithPathAnalysis(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 = processRoutesWithPathAnalysis(options); + + const dashboardRoute = result.routes.find( + (route) => route.name === 'Dashboard', + ); + expect( + dashboardRoute?.component || dashboardRoute?.components, + ).toBeDefined(); + + // test child component + const profileRoute = dashboardRoute?.children?.find( + (child) => child.name === 'Profile', + ); + expect(profileRoute?.component || profileRoute?.components).toBeDefined(); + }); + }); + + describe('reconstructRoutesByPath', () => { + it('should analyze path hierarchy correctly', () => { + const flatRoutes = router.getRoutes(); + const staticRoutes = router.options.routes; + const result = reconstructRoutesByPath(flatRoutes, staticRoutes); + + expect(result.length).toBe(2); // Home + Dashboard + + // test nested structure + const dashboardRoute = result.find((route) => route.name === 'Dashboard'); + if (dashboardRoute) { + expect(dashboardRoute.children).toBeDefined(); + expect(dashboardRoute.children!.length).toBeGreaterThan(0); + } + }); + + it('should calculate relative paths correctly', () => { + const flatRoutes = router.getRoutes(); + const staticRoutes = router.options.routes; + const result = reconstructRoutesByPath(flatRoutes, staticRoutes); + + const dashboardRoute = result.find((route) => route.name === 'Dashboard'); + if (dashboardRoute && dashboardRoute.children) { + const settingsRoute = dashboardRoute.children.find( + (child) => child.name === 'Settings', + ); + if (settingsRoute) { + // Settings 路由的相对路径应该是 'settings',而不是 '/dashboard/settings' + expect(settingsRoute.path).toBe('settings'); + + // 检查 Settings 的子路由 + if (settingsRoute.children) { + const accountRoute = settingsRoute.children.find( + (child) => child.name === 'Account', + ); + if (accountRoute) { + expect(accountRoute.path).toBe('account'); + } + } + } + } + }); + + it('should process complex path hierarchy correctly', () => { + // Create a more complex route structure for testing + const complexPaths = [ + '/', + '/dashboard', + '/dashboard/profile', + '/dashboard/settings', + '/dashboard/settings/account', + '/dashboard/settings/account/security', + '/dashboard/settings/notifications', + '/admin', + '/admin/users', + '/admin/system', + ]; + + const mockFlatRoutes = complexPaths.map((path, index) => ({ + path, + name: path.split('/').pop() || 'Root', + components: { default: { template: `
${path}
` } }, + meta: {}, + props: {}, + })) as any; + + const result = reconstructRoutesByPath(mockFlatRoutes, []); + + // should have 3 top-level routes: /, /dashboard, /admin + expect(result.length).toBe(3); + + // test deep nested structure + const dashboardRoute = result.find((route) => route.name === 'dashboard'); + if (dashboardRoute && dashboardRoute.children) { + const settingsRoute = dashboardRoute.children.find( + (child) => child.name === 'settings', + ); + if (settingsRoute && settingsRoute.children) { + const accountRoute = settingsRoute.children.find( + (child) => child.name === 'account', + ); + if (accountRoute && accountRoute.children) { + const securityRoute = accountRoute.children.find( + (child) => child.name === 'security', + ); + expect(securityRoute).toBeDefined(); + expect(securityRoute?.path).toBe('security'); + } + } + } + }); + }); +}); diff --git a/packages/bridge/vue3-bridge/package.json b/packages/bridge/vue3-bridge/package.json index b69a53f2784..be6232c990d 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/pathBasedRouteUtils.ts b/packages/bridge/vue3-bridge/src/pathBasedRouteUtils.ts new file mode 100644 index 00000000000..6ce94b3d707 --- /dev/null +++ b/packages/bridge/vue3-bridge/src/pathBasedRouteUtils.ts @@ -0,0 +1,261 @@ +import * as VueRouter from 'vue-router'; + +/** + * Path information + */ +interface PathInfo { + fullPath: string; // full path, for example: /dashboard/account + relativePath: string; // relative path, for example: account + depth: number; // path depth + children: PathInfo[]; // child path info +} + +/** + * Reconstruct nested routes from flat route array + * Prioritize static route structure, enrich with runtime data + * @param flatRoutes - flat route array from getRoutes() + * @param staticRoutes - original static route configuration + * @returns nested route array + */ +export function reconstructRoutesByPath( + flatRoutes: VueRouter.RouteRecordNormalized[], + staticRoutes: readonly VueRouter.RouteRecordRaw[] = [], +): VueRouter.RouteRecordRaw[] { + // If we have static routes, use them as the primary structure + if (staticRoutes && staticRoutes.length > 0) { + return reconstructByStaticRoutes(flatRoutes, staticRoutes); + } + + // Fallback to path analysis if no static routes available + return reconstructByPathAnalysis(flatRoutes); +} + +/** + * Reconstruct routes using static route structure as primary, enriched with runtime data + */ +function reconstructByStaticRoutes( + flatRoutes: VueRouter.RouteRecordNormalized[], + staticRoutes: readonly VueRouter.RouteRecordRaw[], +): VueRouter.RouteRecordNormalized[] { + // Create map of runtime routes for quick lookup + const runtimeRouteMap = new Map(); + flatRoutes.forEach((route) => { + runtimeRouteMap.set(route.path, route); + }); + + // Recursively process static routes and find corresponding flat routes + function constructNestedNormalizedRoute( + staticRoute: VueRouter.RouteRecordRaw, + parentPath = '', + ): VueRouter.RouteRecordNormalized | null { + const fullPath = calculateFullPath(staticRoute, parentPath); + const flatRoute = runtimeRouteMap.get(fullPath); + + // If no corresponding flat route found, skip this static route + if (!flatRoute) { + return null; + } + + // Start with the flat route (which is already normalized) + const normalizedRoute: VueRouter.RouteRecordNormalized = { + ...flatRoute, + }; + + // Process children recursively + if (staticRoute.children && staticRoute.children.length > 0) { + const childRoutes: VueRouter.RouteRecordNormalized[] = []; + + staticRoute.children.forEach((childStatic) => { + const childNormalized = constructNestedNormalizedRoute( + childStatic, + fullPath, + ); + if (childNormalized) { + // For child routes, update the path to be relative + const relativePath = calculateRelativePath( + childNormalized.path, + fullPath, + ); + childNormalized.path = relativePath; + childRoutes.push(childNormalized); + } + }); + + if (childRoutes.length > 0) { + normalizedRoute.children = childRoutes; + } + } + + return normalizedRoute; + } + + // Process all static routes and filter out null results + const results: VueRouter.RouteRecordNormalized[] = []; + + staticRoutes.forEach((staticRoute) => { + const normalizedRoute = constructNestedNormalizedRoute(staticRoute); + if (normalizedRoute) { + results.push(normalizedRoute); + } + }); + + return results; +} + +/** + * Calculate full path for a static route (considering parent paths) + */ +function calculateFullPath( + route: VueRouter.RouteRecordRaw, + parentPath = '', +): string { + let fullPath = route.path; + + // Handle relative paths + if (!fullPath.startsWith('/')) { + fullPath = + parentPath === '/' ? '/' + fullPath : parentPath + '/' + fullPath; + } + + // Normalize path (remove double slashes, etc.) + return fullPath.replace(/\/+/g, '/').replace(/\/$/, '') || '/'; +} + +/** + * Reconstruct routes by path analysis when no static routes available. + * Assume that parent path is the prefix of child path. + */ +function reconstructByPathAnalysis( + flatRoutes: VueRouter.RouteRecordNormalized[], +): VueRouter.RouteRecordRaw[] { + const routeMap = new Map(); + flatRoutes.forEach((route) => { + routeMap.set(route.path, route); + }); + + // Analyze path hierarchy + const pathHierarchy = buildPathHierarchy(flatRoutes.map((r) => r.path)); + + // Reconstruct nested routes based on hierarchy + function buildNestedRoute( + path: string, + pathInfo: PathInfo, + ): VueRouter.RouteRecordRaw { + const route = routeMap.get(path)!; + + const nestedRoute = { + ...route, + // Use relative path for nested routes + path: pathInfo.relativePath, + }; + + // recursively build children + if (pathInfo.children.length > 0) { + nestedRoute.children = pathInfo.children.map((childInfo) => + buildNestedRoute(childInfo.fullPath, childInfo), + ); + } + + return nestedRoute; + } + + // Build top-level routes + return pathHierarchy.map((pathInfo) => + buildNestedRoute(pathInfo.fullPath, pathInfo), + ); +} + +/** + * Construct path hierarchy from flat paths + * + * @param paths - All paths array + * @returns Path hierarchy + */ +function buildPathHierarchy(paths: string[]): PathInfo[] { + // Make sure parent paths are processed before child paths + const sortedPaths = paths + .filter((path) => path !== '') + .sort((a, b) => { + const depthA = a.split('/').length; + const depthB = b.split('/').length; + if (depthA !== depthB) { + return depthA - depthB; + } + return a.localeCompare(b); + }); + + const pathInfoMap = new Map(); + const rootPaths: PathInfo[] = []; + + sortedPaths.forEach((path) => { + const segments = path.split('/').filter((s) => s); // Remove empty segments + const isRoot = path === '/'; + const depth = isRoot ? 0 : segments.length; + + const pathInfo: PathInfo = { + fullPath: path, + relativePath: isRoot ? '/' : segments[segments.length - 1] || path, + depth, + children: [], + }; + + pathInfoMap.set(path, pathInfo); + + if (isRoot || depth === 1) { + // Top-level path (root or single-level like /dashboard, /admin) + rootPaths.push(pathInfo); + } else { + const parentPath = findParentPath(path, pathInfoMap); + if (parentPath) { + parentPath.children.push(pathInfo); + // For child routes, the relative path should be the part after the parent path prefix + pathInfo.relativePath = calculateRelativePath( + path, + parentPath.fullPath, + ); + } else { + // If parent path not found, treat as top-level path + rootPaths.push(pathInfo); + } + } + }); + + return rootPaths; +} + +function findParentPath( + childPath: string, + pathInfoMap: Map, +): PathInfo | null { + const segments = childPath.split('/').filter((s) => s); // Remove empty segments + + // For single-level paths like '/dashboard', don't assign root as parent + if (segments.length === 1) { + return null; // Treat as top-level route + } + + // Look for actual parent path by progressively removing segments + for (let i = segments.length - 1; i > 0; i--) { + const possibleParentPath = '/' + segments.slice(0, i).join('/'); + if (pathInfoMap.has(possibleParentPath)) { + return pathInfoMap.get(possibleParentPath)!; + } + } + + // Only use root as parent if it's the actual parent (like /dashboard/profile where /dashboard exists) + // Don't automatically assign root as parent for top-level paths + return null; +} + +function calculateRelativePath(childPath: string, parentPath: string): string { + if (parentPath === '/') { + return childPath.substring(1); + } + + if (childPath.startsWith(parentPath + '/')) { + return childPath.substring(parentPath.length + 1); + } + + const segments = childPath.split('/'); + return segments[segments.length - 1] || childPath; +} diff --git a/packages/bridge/vue3-bridge/src/provider.ts b/packages/bridge/vue3-bridge/src/provider.ts index d8b08df50ce..e9bc0f29929 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 { processRoutesWithPathAnalysis } from './routeUtils'; declare const __APP_VERSION__: string; @@ -58,22 +59,13 @@ 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); - } + // 使用新的路由处理函数,修复嵌套路由扁平化问题 (Issue #3897) + const { history, routes } = processRoutesWithPathAnalysis({ + 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..22ff872c3bd --- /dev/null +++ b/packages/bridge/vue3-bridge/src/routeUtils.ts @@ -0,0 +1,85 @@ +import * as VueRouter from 'vue-router'; +import { reconstructRoutesByPath } from './pathBasedRouteUtils'; + +export interface RouteProcessingOptions { + router: VueRouter.Router; + basename?: string; + memoryRoute?: boolean | { entryPath: string }; + hashRoute?: boolean; +} + +export interface RouteProcessingResult { + history: VueRouter.RouterHistory; + routes: VueRouter.RouteRecordRaw[]; +} + +/** + * 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.RouteRecordRaw[], + basename: string, +): VueRouter.RouteRecordRaw[] { + return routes.map((route) => { + const updatedRoute = { + ...route, + path: basename + route.path, + }; + + // Recursively process child routes + if (route.children && route.children.length > 0) { + updatedRoute.children = addBasenameToNestedRoutes( + route.children, + basename, + ); + } + + return updatedRoute; + }); +} + +/** + * Route processing solution based on path analysis + * + * @param options - route processing options + * @returns processed history and routes + */ +export function processRoutesWithPathAnalysis( + options: RouteProcessingOptions, +): RouteProcessingResult { + const { router, basename, memoryRoute, hashRoute } = options; + + let history: VueRouter.RouterHistory; + + // Get flat runtime routes + const flatRoutes = router.getRoutes(); + const staticRoutes = router.options.routes; + + // Reconstruct nested structure + let routes: VueRouter.RouteRecordRaw[] = reconstructRoutesByPath( + flatRoutes, + staticRoutes, + ); + + 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'), + }, + }, +}); From 5d3acd2484bef1b29259858531554e83d51bae82 Mon Sep 17 00:00:00 2001 From: willxywang Date: Sun, 5 Oct 2025 00:05:00 +0800 Subject: [PATCH 3/6] refactor(vue3-bridge): use VueRouter.RouteRecordNormalized re-construct nested routes --- .../vue3-bridge/__tests__/routeUtils.test.ts | 118 ++++---- .../vue3-bridge/src/pathBasedRouteUtils.ts | 261 ------------------ packages/bridge/vue3-bridge/src/provider.ts | 4 +- packages/bridge/vue3-bridge/src/routeUtils.ts | 84 +++++- 4 files changed, 120 insertions(+), 347 deletions(-) delete mode 100644 packages/bridge/vue3-bridge/src/pathBasedRouteUtils.ts diff --git a/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts index a9413f5d8a6..1506075aa3b 100644 --- a/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts +++ b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRouter, createWebHistory } from 'vue-router'; -import { - processRoutesWithPathAnalysis, - type RouteProcessingOptions, -} from '../src/routeUtils'; -import { reconstructRoutesByPath } from '../src/pathBasedRouteUtils'; +import { processRoutes, type RouteProcessingOptions } from '../src/routeUtils'; // Mock components for testing const HomeComponent = { template: '
Home
' }; @@ -71,7 +67,7 @@ describe('routeUtils', () => { describe('processRoutesWithPathAnalysis', () => { it('should process routes correctly', () => { - const result = processRoutesWithPathAnalysis(options); + const result = processRoutes(options); expect(result.history).toBeDefined(); expect(result.routes).toBeDefined(); @@ -105,7 +101,7 @@ describe('routeUtils', () => { }); it('should process root path correctly', () => { - const result = processRoutesWithPathAnalysis(options); + const result = processRoutes(options); const homeRoute = result.routes.find((route) => route.name === 'Home'); expect(homeRoute).toBeDefined(); @@ -113,45 +109,28 @@ describe('routeUtils', () => { }); it('should preserve component information at runtime', () => { - const result = processRoutesWithPathAnalysis(options); + const result = processRoutes(options); const dashboardRoute = result.routes.find( (route) => route.name === 'Dashboard', ); - expect( - dashboardRoute?.component || dashboardRoute?.components, - ).toBeDefined(); + expect(dashboardRoute?.components).toBeDefined(); // test child component const profileRoute = dashboardRoute?.children?.find( (child) => child.name === 'Profile', ); - expect(profileRoute?.component || profileRoute?.components).toBeDefined(); + expect(profileRoute?.components).toBeDefined(); }); }); - describe('reconstructRoutesByPath', () => { - it('should analyze path hierarchy correctly', () => { - const flatRoutes = router.getRoutes(); - const staticRoutes = router.options.routes; - const result = reconstructRoutesByPath(flatRoutes, staticRoutes); + describe('processRoutes with relative paths', () => { + it('should use relative paths for nested routes', () => { + const result = processRoutes(options); - expect(result.length).toBe(2); // Home + Dashboard - - // test nested structure - const dashboardRoute = result.find((route) => route.name === 'Dashboard'); - if (dashboardRoute) { - expect(dashboardRoute.children).toBeDefined(); - expect(dashboardRoute.children!.length).toBeGreaterThan(0); - } - }); - - it('should calculate relative paths correctly', () => { - const flatRoutes = router.getRoutes(); - const staticRoutes = router.options.routes; - const result = reconstructRoutesByPath(flatRoutes, staticRoutes); - - const dashboardRoute = result.find((route) => route.name === 'Dashboard'); + const dashboardRoute = result.routes.find( + (route) => route.name === 'Dashboard', + ); if (dashboardRoute && dashboardRoute.children) { const settingsRoute = dashboardRoute.children.find( (child) => child.name === 'Settings', @@ -173,51 +152,48 @@ describe('routeUtils', () => { } }); - it('should process complex path hierarchy correctly', () => { - // Create a more complex route structure for testing - const complexPaths = [ - '/', - '/dashboard', - '/dashboard/profile', - '/dashboard/settings', - '/dashboard/settings/account', - '/dashboard/settings/account/security', - '/dashboard/settings/notifications', - '/admin', - '/admin/users', - '/admin/system', - ]; - - const mockFlatRoutes = complexPaths.map((path, index) => ({ - path, - name: path.split('/').pop() || 'Root', - components: { default: { template: `
${path}
` } }, - meta: {}, - props: {}, - })) as any; - - const result = reconstructRoutesByPath(mockFlatRoutes, []); - - // should have 3 top-level routes: /, /dashboard, /admin - expect(result.length).toBe(3); + 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 - // test deep nested structure - const dashboardRoute = result.find((route) => route.name === 'dashboard'); 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', + (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', + (child) => child.name === 'Account', ); - if (accountRoute && accountRoute.children) { - const securityRoute = accountRoute.children.find( - (child) => child.name === 'security', - ); - expect(securityRoute).toBeDefined(); - expect(securityRoute?.path).toBe('security'); - } + 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 } } }); diff --git a/packages/bridge/vue3-bridge/src/pathBasedRouteUtils.ts b/packages/bridge/vue3-bridge/src/pathBasedRouteUtils.ts deleted file mode 100644 index 6ce94b3d707..00000000000 --- a/packages/bridge/vue3-bridge/src/pathBasedRouteUtils.ts +++ /dev/null @@ -1,261 +0,0 @@ -import * as VueRouter from 'vue-router'; - -/** - * Path information - */ -interface PathInfo { - fullPath: string; // full path, for example: /dashboard/account - relativePath: string; // relative path, for example: account - depth: number; // path depth - children: PathInfo[]; // child path info -} - -/** - * Reconstruct nested routes from flat route array - * Prioritize static route structure, enrich with runtime data - * @param flatRoutes - flat route array from getRoutes() - * @param staticRoutes - original static route configuration - * @returns nested route array - */ -export function reconstructRoutesByPath( - flatRoutes: VueRouter.RouteRecordNormalized[], - staticRoutes: readonly VueRouter.RouteRecordRaw[] = [], -): VueRouter.RouteRecordRaw[] { - // If we have static routes, use them as the primary structure - if (staticRoutes && staticRoutes.length > 0) { - return reconstructByStaticRoutes(flatRoutes, staticRoutes); - } - - // Fallback to path analysis if no static routes available - return reconstructByPathAnalysis(flatRoutes); -} - -/** - * Reconstruct routes using static route structure as primary, enriched with runtime data - */ -function reconstructByStaticRoutes( - flatRoutes: VueRouter.RouteRecordNormalized[], - staticRoutes: readonly VueRouter.RouteRecordRaw[], -): VueRouter.RouteRecordNormalized[] { - // Create map of runtime routes for quick lookup - const runtimeRouteMap = new Map(); - flatRoutes.forEach((route) => { - runtimeRouteMap.set(route.path, route); - }); - - // Recursively process static routes and find corresponding flat routes - function constructNestedNormalizedRoute( - staticRoute: VueRouter.RouteRecordRaw, - parentPath = '', - ): VueRouter.RouteRecordNormalized | null { - const fullPath = calculateFullPath(staticRoute, parentPath); - const flatRoute = runtimeRouteMap.get(fullPath); - - // If no corresponding flat route found, skip this static route - if (!flatRoute) { - return null; - } - - // Start with the flat route (which is already normalized) - const normalizedRoute: VueRouter.RouteRecordNormalized = { - ...flatRoute, - }; - - // Process children recursively - if (staticRoute.children && staticRoute.children.length > 0) { - const childRoutes: VueRouter.RouteRecordNormalized[] = []; - - staticRoute.children.forEach((childStatic) => { - const childNormalized = constructNestedNormalizedRoute( - childStatic, - fullPath, - ); - if (childNormalized) { - // For child routes, update the path to be relative - const relativePath = calculateRelativePath( - childNormalized.path, - fullPath, - ); - childNormalized.path = relativePath; - childRoutes.push(childNormalized); - } - }); - - if (childRoutes.length > 0) { - normalizedRoute.children = childRoutes; - } - } - - return normalizedRoute; - } - - // Process all static routes and filter out null results - const results: VueRouter.RouteRecordNormalized[] = []; - - staticRoutes.forEach((staticRoute) => { - const normalizedRoute = constructNestedNormalizedRoute(staticRoute); - if (normalizedRoute) { - results.push(normalizedRoute); - } - }); - - return results; -} - -/** - * Calculate full path for a static route (considering parent paths) - */ -function calculateFullPath( - route: VueRouter.RouteRecordRaw, - parentPath = '', -): string { - let fullPath = route.path; - - // Handle relative paths - if (!fullPath.startsWith('/')) { - fullPath = - parentPath === '/' ? '/' + fullPath : parentPath + '/' + fullPath; - } - - // Normalize path (remove double slashes, etc.) - return fullPath.replace(/\/+/g, '/').replace(/\/$/, '') || '/'; -} - -/** - * Reconstruct routes by path analysis when no static routes available. - * Assume that parent path is the prefix of child path. - */ -function reconstructByPathAnalysis( - flatRoutes: VueRouter.RouteRecordNormalized[], -): VueRouter.RouteRecordRaw[] { - const routeMap = new Map(); - flatRoutes.forEach((route) => { - routeMap.set(route.path, route); - }); - - // Analyze path hierarchy - const pathHierarchy = buildPathHierarchy(flatRoutes.map((r) => r.path)); - - // Reconstruct nested routes based on hierarchy - function buildNestedRoute( - path: string, - pathInfo: PathInfo, - ): VueRouter.RouteRecordRaw { - const route = routeMap.get(path)!; - - const nestedRoute = { - ...route, - // Use relative path for nested routes - path: pathInfo.relativePath, - }; - - // recursively build children - if (pathInfo.children.length > 0) { - nestedRoute.children = pathInfo.children.map((childInfo) => - buildNestedRoute(childInfo.fullPath, childInfo), - ); - } - - return nestedRoute; - } - - // Build top-level routes - return pathHierarchy.map((pathInfo) => - buildNestedRoute(pathInfo.fullPath, pathInfo), - ); -} - -/** - * Construct path hierarchy from flat paths - * - * @param paths - All paths array - * @returns Path hierarchy - */ -function buildPathHierarchy(paths: string[]): PathInfo[] { - // Make sure parent paths are processed before child paths - const sortedPaths = paths - .filter((path) => path !== '') - .sort((a, b) => { - const depthA = a.split('/').length; - const depthB = b.split('/').length; - if (depthA !== depthB) { - return depthA - depthB; - } - return a.localeCompare(b); - }); - - const pathInfoMap = new Map(); - const rootPaths: PathInfo[] = []; - - sortedPaths.forEach((path) => { - const segments = path.split('/').filter((s) => s); // Remove empty segments - const isRoot = path === '/'; - const depth = isRoot ? 0 : segments.length; - - const pathInfo: PathInfo = { - fullPath: path, - relativePath: isRoot ? '/' : segments[segments.length - 1] || path, - depth, - children: [], - }; - - pathInfoMap.set(path, pathInfo); - - if (isRoot || depth === 1) { - // Top-level path (root or single-level like /dashboard, /admin) - rootPaths.push(pathInfo); - } else { - const parentPath = findParentPath(path, pathInfoMap); - if (parentPath) { - parentPath.children.push(pathInfo); - // For child routes, the relative path should be the part after the parent path prefix - pathInfo.relativePath = calculateRelativePath( - path, - parentPath.fullPath, - ); - } else { - // If parent path not found, treat as top-level path - rootPaths.push(pathInfo); - } - } - }); - - return rootPaths; -} - -function findParentPath( - childPath: string, - pathInfoMap: Map, -): PathInfo | null { - const segments = childPath.split('/').filter((s) => s); // Remove empty segments - - // For single-level paths like '/dashboard', don't assign root as parent - if (segments.length === 1) { - return null; // Treat as top-level route - } - - // Look for actual parent path by progressively removing segments - for (let i = segments.length - 1; i > 0; i--) { - const possibleParentPath = '/' + segments.slice(0, i).join('/'); - if (pathInfoMap.has(possibleParentPath)) { - return pathInfoMap.get(possibleParentPath)!; - } - } - - // Only use root as parent if it's the actual parent (like /dashboard/profile where /dashboard exists) - // Don't automatically assign root as parent for top-level paths - return null; -} - -function calculateRelativePath(childPath: string, parentPath: string): string { - if (parentPath === '/') { - return childPath.substring(1); - } - - if (childPath.startsWith(parentPath + '/')) { - return childPath.substring(parentPath.length + 1); - } - - const segments = childPath.split('/'); - return segments[segments.length - 1] || childPath; -} diff --git a/packages/bridge/vue3-bridge/src/provider.ts b/packages/bridge/vue3-bridge/src/provider.ts index e9bc0f29929..071a9331bc7 100644 --- a/packages/bridge/vue3-bridge/src/provider.ts +++ b/packages/bridge/vue3-bridge/src/provider.ts @@ -3,7 +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 { processRoutesWithPathAnalysis } from './routeUtils'; +import { processRoutes } from './routeUtils'; declare const __APP_VERSION__: string; @@ -60,7 +60,7 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) { }); if (bridgeOptions?.router) { // 使用新的路由处理函数,修复嵌套路由扁平化问题 (Issue #3897) - const { history, routes } = processRoutesWithPathAnalysis({ + const { history, routes } = processRoutes({ router: bridgeOptions.router, basename: info.basename, memoryRoute: info.memoryRoute, diff --git a/packages/bridge/vue3-bridge/src/routeUtils.ts b/packages/bridge/vue3-bridge/src/routeUtils.ts index 22ff872c3bd..8e03a827ce8 100644 --- a/packages/bridge/vue3-bridge/src/routeUtils.ts +++ b/packages/bridge/vue3-bridge/src/routeUtils.ts @@ -1,5 +1,4 @@ import * as VueRouter from 'vue-router'; -import { reconstructRoutesByPath } from './pathBasedRouteUtils'; export interface RouteProcessingOptions { router: VueRouter.Router; @@ -10,7 +9,7 @@ export interface RouteProcessingOptions { export interface RouteProcessingResult { history: VueRouter.RouterHistory; - routes: VueRouter.RouteRecordRaw[]; + routes: VueRouter.RouteRecordNormalized[]; } /** @@ -21,11 +20,11 @@ export interface RouteProcessingResult { * @returns processed route configuration */ function addBasenameToNestedRoutes( - routes: VueRouter.RouteRecordRaw[], + routes: VueRouter.RouteRecordNormalized[], basename: string, -): VueRouter.RouteRecordRaw[] { +): VueRouter.RouteRecordNormalized[] { return routes.map((route) => { - const updatedRoute = { + const updatedRoute: VueRouter.RouteRecordNormalized = { ...route, path: basename + route.path, }; @@ -33,7 +32,7 @@ function addBasenameToNestedRoutes( // Recursively process child routes if (route.children && route.children.length > 0) { updatedRoute.children = addBasenameToNestedRoutes( - route.children, + route.children as VueRouter.RouteRecordNormalized[], basename, ); } @@ -48,7 +47,7 @@ function addBasenameToNestedRoutes( * @param options - route processing options * @returns processed history and routes */ -export function processRoutesWithPathAnalysis( +export function processRoutes( options: RouteProcessingOptions, ): RouteProcessingResult { const { router, basename, memoryRoute, hashRoute } = options; @@ -56,14 +55,73 @@ export function processRoutesWithPathAnalysis( let history: VueRouter.RouterHistory; // Get flat runtime routes - const flatRoutes = router.getRoutes(); - const staticRoutes = router.options.routes; + // 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, + ); + // Make sure every route is processed + const processedTag = Array.from({ length: flatRoutes.length }, () => false); + // Construct map for fast query + const flatRoutesMap = new Map(); + flatRoutes.forEach((route) => { + flatRoutesMap.set(route.path, route); + }); + + const processChildren = ( + route: VueRouter.RouteRecordNormalized, + prefix = '', + ) => { + if (!route.children || route.children.length === 0) { + const idx = flatRoutes.findIndex((item) => item === route); + // mark as processed + if (idx !== -1) processedTag[idx] = true; + return route; + } + + for (let j = 0; j < route.children.length; j++) { + const child = route.children[j]; + // Theoretical childRoute is always defined, + // use no `!` for robustness. + const fullPath = prefix + '/' + child.path; + const childRoute = flatRoutesMap.get(fullPath); + if (childRoute) { + // Create a new 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.splice(j, 1, relativeChildRoute); + const idx = flatRoutes.findIndex((item) => item === childRoute); + // mark as processed + if (idx !== -1) processedTag[idx] = true; + // Use the full path for processing deeper children + processChildren(relativeChildRoute, fullPath); + } + } + + return route; + }; // Reconstruct nested structure - let routes: VueRouter.RouteRecordRaw[] = reconstructRoutesByPath( - flatRoutes, - staticRoutes, - ); + let routes: VueRouter.RouteRecordNormalized[] = []; + let i = 0; + while (i < flatRoutes.length) { + if (processedTag[i] === true) { + i++; + continue; + } + + const processedRoute = processChildren(flatRoutes[i], flatRoutes[i].path); + + routes.push(processedRoute); + processedTag[i] = true; + i++; + } if (memoryRoute) { // Memory route mode From 6f682082403c30fcc91a274aadb69c6a5c318689 Mon Sep 17 00:00:00 2001 From: willxywang Date: Sun, 5 Oct 2025 00:21:23 +0800 Subject: [PATCH 4/6] docs: change comment to ENG --- packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts index 1506075aa3b..d61cdf46235 100644 --- a/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts +++ b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts @@ -136,10 +136,10 @@ describe('routeUtils', () => { (child) => child.name === 'Settings', ); if (settingsRoute) { - // Settings 路由的相对路径应该是 'settings',而不是 '/dashboard/settings' + // Path of `Settings` should be 'settings',rather than '/dashboard/settings' expect(settingsRoute.path).toBe('settings'); - // 检查 Settings 的子路由 + // Check child routes of Settings if (settingsRoute.children) { const accountRoute = settingsRoute.children.find( (child) => child.name === 'Account', From 1e824e372397b41c1003d64709ba53288e6a63b8 Mon Sep 17 00:00:00 2001 From: willxywang Date: Sun, 12 Oct 2025 16:32:40 +0800 Subject: [PATCH 5/6] refactor(vue3-bridge): use a more efficient traversal strategy & edge test case about multiple slash --- .../vue3-bridge/__tests__/routeUtils.test.ts | 300 ++++++++++++++++++ packages/bridge/vue3-bridge/src/provider.ts | 1 - packages/bridge/vue3-bridge/src/routeUtils.ts | 58 ++-- 3 files changed, 327 insertions(+), 32 deletions(-) diff --git a/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts index d61cdf46235..d6474b09ab9 100644 --- a/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts +++ b/packages/bridge/vue3-bridge/__tests__/routeUtils.test.ts @@ -198,4 +198,304 @@ describe('routeUtils', () => { } }); }); + + 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/src/provider.ts b/packages/bridge/vue3-bridge/src/provider.ts index 071a9331bc7..bc612d6013d 100644 --- a/packages/bridge/vue3-bridge/src/provider.ts +++ b/packages/bridge/vue3-bridge/src/provider.ts @@ -59,7 +59,6 @@ export function createBridgeComponent(bridgeInfo: ProviderFnParams) { ...extraProps, }); if (bridgeOptions?.router) { - // 使用新的路由处理函数,修复嵌套路由扁平化问题 (Issue #3897) const { history, routes } = processRoutes({ router: bridgeOptions.router, basename: info.basename, diff --git a/packages/bridge/vue3-bridge/src/routeUtils.ts b/packages/bridge/vue3-bridge/src/routeUtils.ts index 8e03a827ce8..5d904f6c4cf 100644 --- a/packages/bridge/vue3-bridge/src/routeUtils.ts +++ b/packages/bridge/vue3-bridge/src/routeUtils.ts @@ -52,9 +52,6 @@ export function processRoutes( ): RouteProcessingResult { const { router, basename, memoryRoute, hashRoute } = options; - let history: VueRouter.RouterHistory; - - // Get flat runtime routes // Sort routes, try to process parent route first const flatRoutes = router .getRoutes() @@ -63,43 +60,46 @@ export function processRoutes( a.path.split('/').filter((p) => p).length - b.path.split('/').filter((p) => p).length, ); - // Make sure every route is processed - const processedTag = Array.from({ length: flatRoutes.length }, () => false); - // Construct map for fast query + + // 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) { - const idx = flatRoutes.findIndex((item) => item === route); - // mark as processed - if (idx !== -1) processedTag[idx] = true; return route; } for (let j = 0; j < route.children.length; j++) { const child = route.children[j]; - // Theoretical childRoute is always defined, - // use no `!` for robustness. - const fullPath = prefix + '/' + child.path; + const fullPath = normalizePath(prefix, child.path); const childRoute = flatRoutesMap.get(fullPath); - if (childRoute) { - // Create a new route object with relative path for nested routes + + 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.splice(j, 1, relativeChildRoute); - const idx = flatRoutes.findIndex((item) => item === childRoute); - // mark as processed - if (idx !== -1) processedTag[idx] = true; - // Use the full path for processing deeper children + route.children[j] = relativeChildRoute; + processedRoutes.add(childRoute); + processChildren(relativeChildRoute, fullPath); } } @@ -109,20 +109,16 @@ export function processRoutes( // Reconstruct nested structure let routes: VueRouter.RouteRecordNormalized[] = []; - let i = 0; - while (i < flatRoutes.length) { - if (processedTag[i] === true) { - i++; - continue; - } - const processedRoute = processChildren(flatRoutes[i], flatRoutes[i].path); - - routes.push(processedRoute); - processedTag[i] = true; - i++; + 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); From 08a9c4c263fd3a363d7ccc26b1a93291ceb27601 Mon Sep 17 00:00:00 2001 From: willxywang Date: Sun, 12 Oct 2025 17:08:49 +0800 Subject: [PATCH 6/6] test(vue3-bridge): integration testing for nested route --- .../router-remote3-2003/src/pages/Profile.vue | 6 ++++++ .../src/pages/Settings.vue | 6 ++++++ .../router-remote3-2003/src/router.ts | 19 +++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 apps/router-demo/router-remote3-2003/src/pages/Profile.vue create mode 100644 apps/router-demo/router-remote3-2003/src/pages/Settings.vue 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, + }, // 其他路由 ], });