From a9c839d65e6e60daad049c10ebfa82634a24b1e8 Mon Sep 17 00:00:00 2001 From: imsherr Date: Mon, 12 Jan 2026 11:53:04 -0500 Subject: [PATCH] Expose fullPath and to on RouteApi from getRouteApi Adds fullPath and to getters to RouteApi, enabling type-safe navigation patterns like . Uses the global router reference (window.__TSR_ROUTER__) to resolve the correct fullPath at runtime, correctly handling pathless/layout routes where id differs from fullPath. --- packages/react-router/tests/route.test.tsx | 36 +++++++++++++++ .../react-router/tests/routeApi.test-d.tsx | 44 +++++++++++++++++++ packages/router-core/src/route.ts | 22 ++++++++++ packages/solid-router/tests/route.test.tsx | 36 +++++++++++++++ .../solid-router/tests/routeApi.test-d.tsx | 38 ++++++++++++++++ packages/vue-router/tests/route.test.tsx | 36 +++++++++++++++ packages/vue-router/tests/routeApi.test-d.tsx | 38 ++++++++++++++++ 7 files changed, 250 insertions(+) diff --git a/packages/react-router/tests/route.test.tsx b/packages/react-router/tests/route.test.tsx index e425dfaaf85..c812612595a 100644 --- a/packages/react-router/tests/route.test.tsx +++ b/packages/react-router/tests/route.test.tsx @@ -62,6 +62,42 @@ describe('getRouteApi', () => { const api = getRouteApi('foo') expect(api.useNavigate).toBeDefined() }) + + it('should have the fullPath property', () => { + const api = getRouteApi('/posts') + expect(api.fullPath).toBe('/posts') + }) + + it('should have the to property', () => { + const api = getRouteApi('/posts') + expect(api.to).toBe('/posts') + }) + + it('fullPath should equal id for standard routes', () => { + const api = getRouteApi('/invoices/$invoiceId') + expect(api.fullPath).toBe('/invoices/$invoiceId') + expect(api.to).toBe('/invoices/$invoiceId') + expect(api.fullPath).toBe(api.id) + }) + + it('fullPath should differ from id for pathless layout routes', () => { + const rootRoute = createRootRoute() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + }) + const routeTree = rootRoute.addChildren([layoutRoute.addChildren([postsRoute])]) + createRouter({ routeTree, history }) + + const api = getRouteApi('/_layout/posts') + expect(api.id).toBe('/_layout/posts') + expect(api.fullPath).toBe('/posts') + expect(api.to).toBe('/posts') + }) }) describe('createRoute has the same hooks as getRouteApi', () => { diff --git a/packages/react-router/tests/routeApi.test-d.tsx b/packages/react-router/tests/routeApi.test-d.tsx index a51a50b10ff..c186f3b1943 100644 --- a/packages/react-router/tests/routeApi.test-d.tsx +++ b/packages/react-router/tests/routeApi.test-d.tsx @@ -29,8 +29,19 @@ const invoiceRoute = createRoute({ loader: () => ({ data: 0 }), }) +const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', +}) + +const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', +}) + const routeTree = rootRoute.addChildren([ invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + layoutRoute.addChildren([postsRoute]), indexRoute, ]) @@ -94,6 +105,39 @@ describe('getRouteApi', () => { LinkComponentRoute<'/invoices/$invoiceId'> >() }) + test('fullPath', () => { + expectTypeOf(invoiceRouteApi.fullPath).toEqualTypeOf<'/invoices/$invoiceId'>() + }) + test('to', () => { + expectTypeOf(invoiceRouteApi.to).toEqualTypeOf<'/invoices/$invoiceId'>() + }) + test('id', () => { + expectTypeOf(invoiceRouteApi.id).toEqualTypeOf<'/invoices/$invoiceId'>() + }) +}) + +describe('getRouteApi with pathless layout route', () => { + const postsRouteApi = getRouteApi<'/_layout/posts', DefaultRouter>( + '/_layout/posts', + ) + + test('id includes the layout segment', () => { + expectTypeOf(postsRouteApi.id).toEqualTypeOf<'/_layout/posts'>() + }) + + test('fullPath excludes the pathless layout segment', () => { + expectTypeOf(postsRouteApi.fullPath).toEqualTypeOf<'/posts'>() + }) + + test('to excludes the pathless layout segment', () => { + expectTypeOf(postsRouteApi.to).toEqualTypeOf<'/posts'>() + }) + + test('fullPath is a valid RoutePaths type for Link from prop', () => { + // Verify fullPath is assignable to RoutePaths (valid for Link's from prop) + type RoutePaths = '/posts' | '/invoices' | '/invoices/$invoiceId' | '/' + expectTypeOf(postsRouteApi.fullPath).toMatchTypeOf() + }) }) describe('createRoute', () => { diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 10ba75d682e..c16b026e39a 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1912,6 +1912,28 @@ export class BaseRouteApi { this.id = id as any } + /** + * The full path of the route, which can be used as the `from` parameter + * in navigation APIs like `` or `navigate({ from: routeApi.fullPath })`. + */ + get fullPath(): RouteTypesById['fullPath'] { + if (typeof window !== 'undefined' && window.__TSR_ROUTER__) { + const route = window.__TSR_ROUTER__.routesById[this.id as string] + if (route) { + return route.fullPath as RouteTypesById['fullPath'] + } + } + return this.id as RouteTypesById['fullPath'] + } + + /** + * The `to` path of the route, an alias for `fullPath` that can be used + * for navigation. This provides parity with the `Route.to` property. + */ + get to(): RouteTypesById['fullPath'] { + return this.fullPath + } + notFound = (opts?: NotFoundError) => { return notFound({ routeId: this.id as string, ...opts }) } diff --git a/packages/solid-router/tests/route.test.tsx b/packages/solid-router/tests/route.test.tsx index 12d86430c92..9d8d0fca88f 100644 --- a/packages/solid-router/tests/route.test.tsx +++ b/packages/solid-router/tests/route.test.tsx @@ -61,6 +61,42 @@ describe('getRouteApi', () => { const api = getRouteApi('foo') expect(api.useNavigate).toBeDefined() }) + + it('should have the fullPath property', () => { + const api = getRouteApi('/posts') + expect(api.fullPath).toBe('/posts') + }) + + it('should have the to property', () => { + const api = getRouteApi('/posts') + expect(api.to).toBe('/posts') + }) + + it('fullPath should equal id for standard routes', () => { + const api = getRouteApi('/invoices/$invoiceId') + expect(api.fullPath).toBe('/invoices/$invoiceId') + expect(api.to).toBe('/invoices/$invoiceId') + expect(api.fullPath).toBe(api.id) + }) + + it('fullPath should differ from id for pathless layout routes', () => { + const rootRoute = createRootRoute() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + }) + const routeTree = rootRoute.addChildren([layoutRoute.addChildren([postsRoute])]) + createRouter({ routeTree, history }) + + const api = getRouteApi('/_layout/posts') + expect(api.id).toBe('/_layout/posts') + expect(api.fullPath).toBe('/posts') + expect(api.to).toBe('/posts') + }) }) describe('createRoute has the same hooks as getRouteApi', () => { diff --git a/packages/solid-router/tests/routeApi.test-d.tsx b/packages/solid-router/tests/routeApi.test-d.tsx index 2a1095ccd45..15bd4d8b320 100644 --- a/packages/solid-router/tests/routeApi.test-d.tsx +++ b/packages/solid-router/tests/routeApi.test-d.tsx @@ -30,8 +30,19 @@ const invoiceRoute = createRoute({ loader: () => ({ data: 0 }), }) +const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', +}) + +const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', +}) + const routeTree = rootRoute.addChildren([ invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + layoutRoute.addChildren([postsRoute]), indexRoute, ]) @@ -105,6 +116,33 @@ describe('getRouteApi', () => { LinkComponentRoute<'/invoices/$invoiceId'> >() }) + test('fullPath', () => { + expectTypeOf(invoiceRouteApi.fullPath).toEqualTypeOf<'/invoices/$invoiceId'>() + }) + test('to', () => { + expectTypeOf(invoiceRouteApi.to).toEqualTypeOf<'/invoices/$invoiceId'>() + }) + test('id', () => { + expectTypeOf(invoiceRouteApi.id).toEqualTypeOf<'/invoices/$invoiceId'>() + }) +}) + +describe('getRouteApi with pathless layout route', () => { + const postsRouteApi = getRouteApi<'/_layout/posts', DefaultRouter>( + '/_layout/posts', + ) + + test('id includes the layout segment', () => { + expectTypeOf(postsRouteApi.id).toEqualTypeOf<'/_layout/posts'>() + }) + + test('fullPath excludes the pathless layout segment', () => { + expectTypeOf(postsRouteApi.fullPath).toEqualTypeOf<'/posts'>() + }) + + test('to excludes the pathless layout segment', () => { + expectTypeOf(postsRouteApi.to).toEqualTypeOf<'/posts'>() + }) }) describe('createRoute', () => { diff --git a/packages/vue-router/tests/route.test.tsx b/packages/vue-router/tests/route.test.tsx index 70930e7ad40..62ce2cf3c28 100644 --- a/packages/vue-router/tests/route.test.tsx +++ b/packages/vue-router/tests/route.test.tsx @@ -61,6 +61,42 @@ describe('getRouteApi', () => { const api = getRouteApi('foo') expect(api.useNavigate).toBeDefined() }) + + it('should have the fullPath property', () => { + const api = getRouteApi('/posts') + expect(api.fullPath).toBe('/posts') + }) + + it('should have the to property', () => { + const api = getRouteApi('/posts') + expect(api.to).toBe('/posts') + }) + + it('fullPath should equal id for standard routes', () => { + const api = getRouteApi('/invoices/$invoiceId') + expect(api.fullPath).toBe('/invoices/$invoiceId') + expect(api.to).toBe('/invoices/$invoiceId') + expect(api.fullPath).toBe(api.id) + }) + + it('fullPath should differ from id for pathless layout routes', () => { + const rootRoute = createRootRoute() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + }) + const routeTree = rootRoute.addChildren([layoutRoute.addChildren([postsRoute])]) + createRouter({ routeTree, history }) + + const api = getRouteApi('/_layout/posts') + expect(api.id).toBe('/_layout/posts') + expect(api.fullPath).toBe('/posts') + expect(api.to).toBe('/posts') + }) }) describe('createRoute has the same hooks as getRouteApi', () => { diff --git a/packages/vue-router/tests/routeApi.test-d.tsx b/packages/vue-router/tests/routeApi.test-d.tsx index db97fe0bc0c..4815e5d5e24 100644 --- a/packages/vue-router/tests/routeApi.test-d.tsx +++ b/packages/vue-router/tests/routeApi.test-d.tsx @@ -29,8 +29,19 @@ const invoiceRoute = createRoute({ loader: () => ({ data: 0 }), }) +const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', +}) + +const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', +}) + const routeTree = rootRoute.addChildren([ invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + layoutRoute.addChildren([postsRoute]), indexRoute, ]) @@ -96,6 +107,33 @@ describe('getRouteApi', () => { Vue.Ref> >() }) + test('fullPath', () => { + expectTypeOf(invoiceRouteApi.fullPath).toEqualTypeOf<'/invoices/$invoiceId'>() + }) + test('to', () => { + expectTypeOf(invoiceRouteApi.to).toEqualTypeOf<'/invoices/$invoiceId'>() + }) + test('id', () => { + expectTypeOf(invoiceRouteApi.id).toEqualTypeOf<'/invoices/$invoiceId'>() + }) +}) + +describe('getRouteApi with pathless layout route', () => { + const postsRouteApi = getRouteApi<'/_layout/posts', DefaultRouter>( + '/_layout/posts', + ) + + test('id includes the layout segment', () => { + expectTypeOf(postsRouteApi.id).toEqualTypeOf<'/_layout/posts'>() + }) + + test('fullPath excludes the pathless layout segment', () => { + expectTypeOf(postsRouteApi.fullPath).toEqualTypeOf<'/posts'>() + }) + + test('to excludes the pathless layout segment', () => { + expectTypeOf(postsRouteApi.to).toEqualTypeOf<'/posts'>() + }) }) describe('createRoute', () => {