From 471592506940b2a2dde7d4c57a1c32e6834e436a Mon Sep 17 00:00:00 2001 From: sanjibani <18418553+sanjibani@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:35:45 +0530 Subject: [PATCH] feat(link): accept external URLs in Link `to` prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime `useLinkProps` already detects absolute URLs in the `to` prop (it parses `to` with `new URL()` and renders it as an anchor's `href` when it looks external), but the type-level constraint from `ToPathOption` rejected any string with a scheme — so users got a TypeScript error for valid runtime usage: // TS error // TS error // TS error Widen `ToPathOption` to also accept the four protocols in the default protocol allowlist (http:, https:, mailto:, tel:). Internal route paths (`/dashboard`, `../profile`) continue to type-check unchanged — the union only widens what is accepted. Expose the new `ExternalUrl` template-literal type from router-core so apps that want to type their own Link-like components can reuse it. Fixes #4901 --- packages/router-core/src/index.ts | 1 + packages/router-core/src/link.ts | 37 +++++++++++---- .../router-core/tests/external-url.test-d.ts | 45 +++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 packages/router-core/tests/external-url.test-d.ts diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index fd673ca410..21b1c15f07 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -30,6 +30,7 @@ export type { SearchParamOptions, PathParamOptions, ToPathOption, + ExternalUrl, LinkOptions, MakeOptionalPathParams, FromPathOption, diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 55d5a79ce8..edcd9d633b 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -613,18 +613,39 @@ export type PathParamOptions = ? MakeOptionalPathParams : MakeRequiredPathParams +/** + * Absolute external URL strings accepted by the `to` prop on `Link`, + * `createLink`, and `linkOptions`. Matches the runtime `new URL(to)` check in + * `useLinkProps` and the default protocol allowlist (`http:`, `https:`, + * `mailto:`, `tel:`). When `to` matches one of these patterns the runtime + * short-circuits internal-route handling and renders the value as an + * anchor's `href` (still applying `isDangerousProtocol` filtering). + * + * Internal route paths (e.g. `/dashboard`, `../profile`) remain valid; this + * type only widens what is accepted, never narrows it. + * + * @see https://github.com/TanStack/router/issues/4901 + */ +export type ExternalUrl = + | `http://${string}` + | `https://${string}` + | `mailto:${string}` + | `tel:${string}` + export type ToPathOption< TRouter extends AnyRouter = AnyRouter, TFrom extends string = string, TTo extends string | undefined = string, -> = ConstrainLiteral< - TTo, - RelativeToPathAutoComplete< - TRouter, - NoInfer extends string ? NoInfer : '', - NoInfer & string - > -> +> = + | ConstrainLiteral< + TTo, + RelativeToPathAutoComplete< + TRouter, + NoInfer extends string ? NoInfer : '', + NoInfer & string + > + > + | ExternalUrl export type FromPathOption = ConstrainLiteral< TFrom, diff --git a/packages/router-core/tests/external-url.test-d.ts b/packages/router-core/tests/external-url.test-d.ts new file mode 100644 index 0000000000..8591fa730e --- /dev/null +++ b/packages/router-core/tests/external-url.test-d.ts @@ -0,0 +1,45 @@ +import { describe, expectTypeOf, test } from 'vitest' +import type { ExternalUrl, ToPathOption } from '../src/link' + +// Regression coverage for https://github.com/TanStack/router/issues/4901. +// `to` on `Link`/`createLink`/`linkOptions` should accept absolute external +// URLs (https, mailto, tel, http) in addition to internal route paths. +describe('ExternalUrl', () => { + test('matches https URLs', () => { + expectTypeOf<'https://example.com'>().toMatchTypeOf() + expectTypeOf<'https://example.com/path?query=1#hash'>().toMatchTypeOf() + }) + + test('matches http URLs', () => { + expectTypeOf<'http://example.com'>().toMatchTypeOf() + }) + + test('matches mailto: URLs', () => { + expectTypeOf<'mailto:user@example.com'>().toMatchTypeOf() + }) + + test('matches tel: URLs', () => { + expectTypeOf<'tel:+15551234567'>().toMatchTypeOf() + }) + + test('rejects internal route paths (no scheme)', () => { + // Without a scheme these are NOT external URLs. + expectTypeOf<'/dashboard'>().not.toMatchTypeOf() + expectTypeOf<'../profile'>().not.toMatchTypeOf() + }) +}) + +describe('ToPathOption', () => { + test('accepts absolute external URLs', () => { + // Default generics: no specific router/type constraints. + // `to` should now accept any ExternalUrl. + expectTypeOf<'https://example.com'>().toMatchTypeOf() + expectTypeOf<'mailto:user@example.com'>().toMatchTypeOf() + expectTypeOf<'tel:+15551234567'>().toMatchTypeOf() + }) + + test('still accepts internal route paths', () => { + // Backward compat: internal paths must remain assignable to `to`. + expectTypeOf().toMatchTypeOf() + }) +}) \ No newline at end of file