diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 484f797307..775d68a96d 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -351,3 +351,29 @@ function encodePathParam(value: string, decodeCharMap?: Map) { } return encoded } + +/** + * Checks if a pathname is within a basepath scope. + * Used to determine if a router should process a location change. + * This enables MFE architectures where multiple routers coexist. + * + * Mirrors React Router's stripBasename behavior where paths outside + * the basename are silently ignored. + * + * @param pathname - The current pathname to check + * @param basepath - The router's configured basepath + * @returns true if pathname is within basepath scope, false otherwise + */ +export function isPathInScope(pathname: string, basepath: string): boolean { + if (basepath === '/') return true + + // Case-insensitive comparison (same as React Router) + if (!pathname.toLowerCase().startsWith(basepath.toLowerCase())) { + return false + } + + // Ensure basepath is followed by / or end of string + // This prevents /app from matching /application + const nextChar = pathname.charAt(basepath.length) + return !nextChar || nextChar === '/' +} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 19b7753129..54390bcb3b 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -19,6 +19,7 @@ import { import { cleanPath, interpolatePath, + isPathInScope, resolvePath, trimPath, trimPathRight, @@ -2098,6 +2099,28 @@ export class RouterCore< } load: LoadFn = async (opts?: { sync?: boolean }): Promise => { + // If this router has a basepath, only respond to paths within scope. + // This enables MFE architectures where multiple routers coexist. + if (this.basepath && this.basepath !== '/') { + // Get the current path from history + let pathToCheck = this.history.location.pathname + + // For hash history, extract path from the hash portion + const href = this.history.location.href + if (href.includes('#')) { + const hashPart = href.split('#')[1] + if (hashPart) { + // Remove query string and nested hash from path + pathToCheck = hashPart.split('?')[0]?.split('#')[0] || '/' + } + } + + // If path is outside this router's scope, silently ignore + if (!isPathInScope(pathToCheck, this.basepath)) { + return + } + } + let redirect: AnyRedirect | undefined let notFound: NotFoundError | undefined let loadPromise: Promise diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 6503eb0b75..a42ebd8f15 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { exactPathTest, interpolatePath, + isPathInScope, removeTrailingSlash, resolvePath, trimPathLeft, @@ -85,6 +86,60 @@ describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( }, ) +describe('isPathInScope', () => { + it('returns true for root basepath with any path', () => { + expect(isPathInScope('/anything', '/')).toBe(true) + expect(isPathInScope('/', '/')).toBe(true) + expect(isPathInScope('/deep/nested/path', '/')).toBe(true) + }) + + it('returns true when pathname matches basepath exactly', () => { + expect(isPathInScope('/app', '/app')).toBe(true) + expect(isPathInScope('/user-management', '/user-management')).toBe(true) + }) + + it('returns true when pathname starts with basepath followed by /', () => { + expect(isPathInScope('/app/page', '/app')).toBe(true) + expect(isPathInScope('/app/nested/deep', '/app')).toBe(true) + expect(isPathInScope('/user-management/users', '/user-management')).toBe( + true, + ) + }) + + it('returns false when pathname does not start with basepath', () => { + expect(isPathInScope('/other', '/app')).toBe(false) + expect(isPathInScope('/', '/app')).toBe(false) + expect(isPathInScope('/settings', '/app')).toBe(false) + expect(isPathInScope('/home', '/user-management')).toBe(false) + }) + + it('returns false when basepath is prefix but not at path boundary', () => { + // /app should not match /application + expect(isPathInScope('/application', '/app')).toBe(false) + expect(isPathInScope('/apps', '/app')).toBe(false) + expect(isPathInScope('/appstore/page', '/app')).toBe(false) + }) + + it('handles case-insensitive comparison', () => { + expect(isPathInScope('/APP/page', '/app')).toBe(true) + expect(isPathInScope('/App/Page', '/app')).toBe(true) + expect(isPathInScope('/app/page', '/APP')).toBe(true) + expect(isPathInScope('/USER-MANAGEMENT/users', '/user-management')).toBe( + true, + ) + }) + + it('handles trailing slashes correctly', () => { + expect(isPathInScope('/app/', '/app')).toBe(true) + expect(isPathInScope('/app/page/', '/app')).toBe(true) + }) + + it('handles edge cases', () => { + expect(isPathInScope('', '/')).toBe(true) + expect(isPathInScope('/', '/')).toBe(true) + }) +}) + describe('resolvePath', () => { describe.each([ ['/', '/', '/'],