diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts index efb0b4ef6cc..a84b234638b 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -9,7 +9,6 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' import { Route as RemountDepsRouteImport } from './routes/remountDeps' import { Route as PostsRouteImport } from './routes/posts' import { Route as NotRemountDepsRouteImport } from './routes/notRemountDeps' @@ -18,6 +17,7 @@ import { Route as EditingARouteImport } from './routes/editing-a' import { Route as ComponentTypesTestRouteImport } from './routes/component-types-test' import { Route as AnchorRouteImport } from './routes/anchor' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as Char45824Char54620Char48124Char44397RouteRouteImport } from './routes/대한민국/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route' import { Route as IndexRouteImport } from './routes/index' @@ -45,6 +45,8 @@ import { Route as NonNestedNamedRouteRouteImport } from './routes/non-nested/nam import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' import { Route as ParamsPsWildcardIndexRouteImport } from './routes/params-ps/wildcard/index' import { Route as ParamsPsNamedIndexRouteImport } from './routes/params-ps/named/index' +import { Route as Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport } from './routes/대한민국/🚀.$id' +import { Route as Char45824Char54620Char48124Char44397WildcardSplatRouteImport } from './routes/대한민국/wildcard.$' import { Route as RelativeUseNavigateRelativeUseNavigateBRouteImport } from './routes/relative/useNavigate/relative-useNavigate-b' import { Route as RelativeUseNavigateRelativeUseNavigateARouteImport } from './routes/relative/useNavigate/relative-useNavigate-a' import { Route as RelativeLinkRelativeLinkBRouteImport } from './routes/relative/link/relative-link-b' @@ -99,12 +101,6 @@ import { Route as RelativeLinkPathPathIndexRouteImport } from './routes/relative import { Route as RelativeLinkNestedDeepIndexRouteImport } from './routes/relative/link/nested/deep/index' import { Route as ParamsPsNamedFooBarBazRouteImport } from './routes/params-ps/named/$foo/$bar.$baz' -const Char45824Char54620Char48124Char44397Route = - Char45824Char54620Char48124Char44397RouteImport.update({ - id: '/대한민국', - path: '/대한민국', - getParentRoute: () => rootRouteImport, - } as any) const RemountDepsRoute = RemountDepsRouteImport.update({ id: '/remountDeps', path: '/remountDeps', @@ -144,6 +140,12 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const Char45824Char54620Char48124Char44397RouteRoute = + Char45824Char54620Char48124Char44397RouteRouteImport.update({ + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => rootRouteImport, + } as any) const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ id: '/search-params', path: '/search-params', @@ -282,6 +284,18 @@ const ParamsPsNamedIndexRoute = ParamsPsNamedIndexRouteImport.update({ path: '/params-ps/named/', getParentRoute: () => rootRouteImport, } as any) +const Char45824Char54620Char48124Char44397Char55357Char56960IdRoute = + Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport.update({ + id: '/🚀/$id', + path: '/🚀/$id', + getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, + } as any) +const Char45824Char54620Char48124Char44397WildcardSplatRoute = + Char45824Char54620Char48124Char44397WildcardSplatRouteImport.update({ + id: '/wildcard/$', + path: '/wildcard/$', + getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, + } as any) const RelativeUseNavigateRelativeUseNavigateBRoute = RelativeUseNavigateRelativeUseNavigateBRouteImport.update({ id: '/relative-useNavigate-b', @@ -581,6 +595,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute @@ -588,7 +603,6 @@ export interface FileRoutesByFullPath { '/notRemountDeps': typeof NotRemountDepsRoute '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/non-nested/named': typeof NonNestedNamedRouteRouteWithChildren '/non-nested/path': typeof NonNestedPathRouteRouteWithChildren '/non-nested/prefix': typeof NonNestedPrefixRouteRouteWithChildren @@ -636,6 +650,8 @@ export interface FileRoutesByFullPath { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute + '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute '/params-ps/named': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard': typeof ParamsPsWildcardIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute @@ -668,13 +684,13 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute '/editing-b': typeof EditingBRoute '/notRemountDeps': typeof NotRemountDepsRoute '/remountDeps': typeof RemountDepsRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/non-nested/named': typeof NonNestedNamedRouteRouteWithChildren '/non-nested/path': typeof NonNestedPathRouteRouteWithChildren '/non-nested/prefix': typeof NonNestedPrefixRouteRouteWithChildren @@ -717,6 +733,8 @@ export interface FileRoutesByTo { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute + '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute '/params-ps/named': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard': typeof ParamsPsWildcardIndexRoute '/redirect/$target': typeof RedirectTargetIndexRoute @@ -751,6 +769,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute @@ -759,7 +778,6 @@ export interface FileRoutesById { '/notRemountDeps': typeof NotRemountDepsRoute '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/non-nested/named': typeof NonNestedNamedRouteRouteWithChildren '/non-nested/path': typeof NonNestedPathRouteRouteWithChildren '/non-nested/prefix': typeof NonNestedPrefixRouteRouteWithChildren @@ -809,6 +827,8 @@ export interface FileRoutesById { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute + '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute '/params-ps/named/': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard/': typeof ParamsPsWildcardIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute @@ -844,6 +864,7 @@ export interface FileRouteTypes { | '/' | '/non-nested' | '/search-params' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' @@ -851,7 +872,6 @@ export interface FileRouteTypes { | '/notRemountDeps' | '/posts' | '/remountDeps' - | '/대한민국' | '/non-nested/named' | '/non-nested/path' | '/non-nested/prefix' @@ -899,6 +919,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named' | '/params-ps/wildcard' | '/redirect/$target/' @@ -931,13 +953,13 @@ export interface FileRouteTypes { to: | '/' | '/non-nested' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' | '/editing-b' | '/notRemountDeps' | '/remountDeps' - | '/대한민국' | '/non-nested/named' | '/non-nested/path' | '/non-nested/prefix' @@ -980,6 +1002,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named' | '/params-ps/wildcard' | '/redirect/$target' @@ -1013,6 +1037,7 @@ export interface FileRouteTypes { | '/' | '/non-nested' | '/search-params' + | '/대한민국' | '/_layout' | '/anchor' | '/component-types-test' @@ -1021,7 +1046,6 @@ export interface FileRouteTypes { | '/notRemountDeps' | '/posts' | '/remountDeps' - | '/대한민국' | '/non-nested/named' | '/non-nested/path' | '/non-nested/prefix' @@ -1071,6 +1095,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named/' | '/params-ps/wildcard/' | '/redirect/$target/' @@ -1105,6 +1131,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute NonNestedRouteRoute: typeof NonNestedRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren + Char45824Char54620Char48124Char44397RouteRoute: typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren AnchorRoute: typeof AnchorRoute ComponentTypesTestRoute: typeof ComponentTypesTestRoute @@ -1113,7 +1140,6 @@ export interface RootRouteChildren { NotRemountDepsRoute: typeof NotRemountDepsRoute PostsRoute: typeof PostsRouteWithChildren RemountDepsRoute: typeof RemountDepsRoute - Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route ParamsPsNonNestedRouteRoute: typeof ParamsPsNonNestedRouteRouteWithChildren RelativeLinkRouteRoute: typeof RelativeLinkRouteRouteWithChildren RelativeUseNavigateRouteRoute: typeof RelativeUseNavigateRouteRouteWithChildren @@ -1146,13 +1172,6 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/대한민국': { - id: '/대한민국' - path: '/대한민국' - fullPath: '/대한민국' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteImport - parentRoute: typeof rootRouteImport - } '/remountDeps': { id: '/remountDeps' path: '/remountDeps' @@ -1209,6 +1228,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/대한민국': { + id: '/대한민국' + path: '/대한민국' + fullPath: '/대한민국' + preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteRouteImport + parentRoute: typeof rootRouteImport + } '/search-params': { id: '/search-params' path: '/search-params' @@ -1398,6 +1424,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ParamsPsNamedIndexRouteImport parentRoute: typeof rootRouteImport } + '/대한민국/🚀/$id': { + id: '/대한민국/🚀/$id' + path: '/🚀/$id' + fullPath: '/대한민국/🚀/$id' + preLoaderRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport + parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + } + '/대한민국/wildcard/$': { + id: '/대한민국/wildcard/$' + path: '/wildcard/$' + fullPath: '/대한민국/wildcard/$' + preLoaderRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRouteImport + parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + } '/relative/useNavigate/relative-useNavigate-b': { id: '/relative/useNavigate/relative-useNavigate-b' path: '/relative-useNavigate-b' @@ -1926,6 +1966,24 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface Char45824Char54620Char48124Char44397RouteRouteChildren { + Char45824Char54620Char48124Char44397WildcardSplatRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute +} + +const Char45824Char54620Char48124Char44397RouteRouteChildren: Char45824Char54620Char48124Char44397RouteRouteChildren = + { + Char45824Char54620Char48124Char44397WildcardSplatRoute: + Char45824Char54620Char48124Char44397WildcardSplatRoute, + Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: + Char45824Char54620Char48124Char44397Char55357Char56960IdRoute, + } + +const Char45824Char54620Char48124Char44397RouteRouteWithChildren = + Char45824Char54620Char48124Char44397RouteRoute._addFileChildren( + Char45824Char54620Char48124Char44397RouteRouteChildren, + ) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -2104,6 +2162,8 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NonNestedRouteRoute: NonNestedRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, + Char45824Char54620Char48124Char44397RouteRoute: + Char45824Char54620Char48124Char44397RouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, AnchorRoute: AnchorRoute, ComponentTypesTestRoute: ComponentTypesTestRoute, @@ -2112,8 +2172,6 @@ const rootRouteChildren: RootRouteChildren = { NotRemountDepsRoute: NotRemountDepsRoute, PostsRoute: PostsRouteWithChildren, RemountDepsRoute: RemountDepsRoute, - Char45824Char54620Char48124Char44397Route: - Char45824Char54620Char48124Char44397Route, ParamsPsNonNestedRouteRoute: ParamsPsNonNestedRouteRouteWithChildren, RelativeLinkRouteRoute: RelativeLinkRouteRouteWithChildren, RelativeUseNavigateRouteRoute: RelativeUseNavigateRouteRouteWithChildren, diff --git a/e2e/react-router/basic-file-based/src/routes/__root.tsx b/e2e/react-router/basic-file-based/src/routes/__root.tsx index 06734a3b954..26cd6a60f85 100644 --- a/e2e/react-router/basic-file-based/src/routes/__root.tsx +++ b/e2e/react-router/basic-file-based/src/routes/__root.tsx @@ -130,6 +130,14 @@ function RootComponent() { > relative routing {' '} + + unicode path + {' '} +
  • + + /params-ps/named/$foo - with special characters + +
  • +
  • + + /params-ps/wildcard/$ with encoded params + +
  • Bar2 + + Bar with special characters + ) diff --git a/e2e/react-router/basic-file-based/src/routes/search-params/index.tsx b/e2e/react-router/basic-file-based/src/routes/search-params/index.tsx index c0d4a55ac85..d56d5ddac14 100644 --- a/e2e/react-router/basic-file-based/src/routes/search-params/index.tsx +++ b/e2e/react-router/basic-file-based/src/routes/search-params/index.tsx @@ -21,6 +21,23 @@ function RouteComponent() { > go to /search-params/default?default=d2 +
    + + go to /search-params/default?default=🚀대한민국 + +
    + + go to + /search-params/default?default=%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD + ) } diff --git a/e2e/react-router/basic-file-based/src/routes/search-params/route.tsx b/e2e/react-router/basic-file-based/src/routes/search-params/route.tsx index 5adb281c41b..3f659c812d2 100644 --- a/e2e/react-router/basic-file-based/src/routes/search-params/route.tsx +++ b/e2e/react-router/basic-file-based/src/routes/search-params/route.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/search-params')({ beforeLoad: async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 100)) return { hello: 'world' as string } }, }) diff --git "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" deleted file mode 100644 index c70cb5096a9..00000000000 --- "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' - -export const Route = createFileRoute('/대한민국')({ - component: RouteComponent, -}) - -function RouteComponent() { - return
    Hello "/대한민국"!
    -} diff --git "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" new file mode 100644 index 00000000000..cff4d82723f --- /dev/null +++ "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" @@ -0,0 +1,63 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/대한민국')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
    +

    + Hello "/대한민국"! +

    +
      +
    • + + link to latin id + +
    • +
    • + + link to unicode id + +
    • +
    • + + link to foo/bar + +
    • +
    • + + link to foo%\/🚀대 + +
    • +
    +
    + +
    + ) +} diff --git "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" new file mode 100644 index 00000000000..5a0770344b9 --- /dev/null +++ "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/대한민국/wildcard/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return ( +
    +

    Unicode Wildcard Params

    +
    + Hello /대한민국/wildcard/ + {params._splat} +
    +
    + ) +} diff --git "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" new file mode 100644 index 00000000000..c8222486709 --- /dev/null +++ "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/대한민국/🚀/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return ( +
    +

    Unicode Named Params

    +
    + Hello /대한민국/🚀/ + {params.id} +
    +
    + ) +} diff --git a/e2e/react-router/basic-file-based/tests/app.spec.ts b/e2e/react-router/basic-file-based/tests/app.spec.ts index 46123e7d2eb..ab11a5fb1c6 100644 --- a/e2e/react-router/basic-file-based/tests/app.spec.ts +++ b/e2e/react-router/basic-file-based/tests/app.spec.ts @@ -316,3 +316,13 @@ test('Should remount deps when remountDeps does change ', async ({ page }) => { 'Page component mounts: 3', ) }) + +test.describe('Unicode route rendering', () => { + test('should render non-latin route correctly', async ({ page, baseURL }) => { + await page.goto('/대한민국') + + await expect(page.locator('body')).toContainText('Hello "/대한민국"!') + + expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) + }) +}) diff --git a/e2e/react-router/basic-file-based/tests/params.spec.ts b/e2e/react-router/basic-file-based/tests/params.spec.ts index 499c8b97e01..c05dae91e05 100644 --- a/e2e/react-router/basic-file-based/tests/params.spec.ts +++ b/e2e/react-router/basic-file-based/tests/params.spec.ts @@ -145,6 +145,12 @@ test.describe('params operations + prefix/suffix', () => { params: { foo: 'foo' }, destHeadingId: 'ParamsNamedFooSuffix', }, + { + id: 'l-to-named-foo-special-characters', + pathname: '/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80', + params: { foo: 'foo%\\/🚀대' }, + destHeadingId: 'ParamsNamedFoo', + }, ] satisfies Array<{ id: string pathname: string @@ -317,6 +323,16 @@ test.describe('params operations + prefix/suffix', () => { }, destHeadingId: 'ParamsWildcardSplatSuffix', }, + { + id: 'l-to-wildcard-encoded', + pathname: + '/params-ps/wildcard/%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + params: { + '*': '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + _splat: '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + }, + destHeadingId: 'ParamsWildcardSplat', + }, ] satisfies Array<{ id: string pathname: string @@ -368,12 +384,122 @@ test.describe('params operations + prefix/suffix', () => { }) }) -test.describe('Unicode route rendering', () => { - test('should render non-latin route correctly', async ({ page, baseURL }) => { - await page.goto('/대한민국') +test.describe('Unicode params', () => { + test('should render non-latin route correctly across multiple params', async ({ + page, + baseURL, + }) => { + await page.goto('/params-ps') + await page.waitForURL('/params-ps') + const fooLink = page.getByTestId('l-to-named-foo-special-characters') + + await fooLink.click() + await page.waitForURL('/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80') + + expect(page.url()).toBe( + `${baseURL}/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80`, + ) + + const headingEl = page.getByRole('heading', { name: 'ParamsNamedFoo' }) + await expect(headingEl).toBeVisible() + let paramsEl = page.getByTestId('params-output') + let paramsText = await paramsEl.innerText() + expect(paramsText).toEqual(JSON.stringify({ foo: 'foo%\\/🚀대' })) + + const barLink = page.getByTestId('params-foo-links-bar-special-characters') + + await barLink.click() + + await page.waitForURL( + '/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80/%F0%9F%9A%80%252F%2Fabc%EB%8C%80', + ) + + expect(page.url()).toBe( + `${baseURL}/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80/%F0%9F%9A%80%252F%2Fabc%EB%8C%80`, + ) + + paramsEl = page.getByTestId('foo-bar-value') + paramsText = await paramsEl.innerText() + expect(paramsText).toEqual('🚀%2F/abc대') + }) + + test.describe('should handle routes with non-latin paths and params correctly', () => { + const testCases = [ + { + name: 'named', + childPath: '🚀', + latinParams: 'foo', + unicodeParams: 'foo%\\/🚀대', + }, + { + name: 'wildcard', + childPath: 'wildcard', + latinParams: 'foo/bar', + unicodeParams: 'foo%\\/🚀대', + }, + ] + + testCases.forEach(({ name, childPath, latinParams, unicodeParams }) => { + test(`${name} params`, async ({ page, baseURL }) => { + const pascalCaseName = name.charAt(0).toUpperCase() + name.slice(1) + const routeParentPath = '/대한민국' + const encodedRouteParentPath = encodeURI(routeParentPath) + const childRoutePath = `${routeParentPath}/${childPath}` + const encodedChildRoutePath = encodeURI(childRoutePath) + + await page.goto(routeParentPath) + await page.waitForURL(encodedRouteParentPath) + + const headingRootEl = page.getByTestId('unicode-heading') + + expect(await headingRootEl.innerText()).toBe('Hello "/대한민국"!') + + const latinLink = page.getByTestId(`l-to-${name}-latin`) + const unicodeLink = page.getByTestId(`l-to-${name}-unicode`) + + await expect(latinLink).not.toContainClass('font-bold') + await expect(unicodeLink).not.toContainClass('font-bold') + + await latinLink.click() - await expect(page.locator('body')).toContainText('Hello "/대한민국"!') + await page.waitForURL(`${encodedChildRoutePath}/${latinParams}`) - expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) + expect(page.url()).toBe( + `${baseURL}${encodedChildRoutePath}/${latinParams}`, + ) + + await expect(latinLink).toContainClass('font-bold') + await expect(unicodeLink).not.toContainClass('font-bold') + + const headingEl = page.getByTestId(`unicode-${name}-heading`) + const paramsEl = page.getByTestId(`unicode-${name}-params`) + + expect(await headingEl.innerText()).toBe( + `Unicode ${pascalCaseName} Params`, + ) + expect(await paramsEl.innerText()).toBe(latinParams) + + await unicodeLink.click() + + const encodedParams = + name === 'wildcard' + ? encodeURI(unicodeParams) + : encodeURIComponent(unicodeParams) + + await page.waitForURL(`${encodedChildRoutePath}/${encodedParams}`) + + expect(page.url()).toBe( + `${baseURL}${encodedChildRoutePath}/${encodedParams}`, + ) + + await expect(latinLink).not.toContainClass('font-bold') + await expect(unicodeLink).toContainClass('font-bold') + + expect(await headingEl.innerText()).toBe( + `Unicode ${pascalCaseName} Params`, + ) + expect(await paramsEl.innerText()).toBe(unicodeParams) + }) + }) }) }) diff --git a/e2e/react-router/basic-file-based/tests/search-params.spec.ts b/e2e/react-router/basic-file-based/tests/search-params.spec.ts index 1fb0fc3034b..d6307cbcf8b 100644 --- a/e2e/react-router/basic-file-based/tests/search-params.spec.ts +++ b/e2e/react-router/basic-file-based/tests/search-params.spec.ts @@ -5,6 +5,7 @@ test.describe('/search-params/default', () => { page, }) => { await page.goto('/search-params/default') + await page.waitForURL('/search-params/default?default=d1') await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -17,6 +18,7 @@ test.describe('/search-params/default', () => { page, }) => { await page.goto('/search-params/default/?default=d2') + await page.waitForURL('/search-params/default?default=d2') await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -25,9 +27,54 @@ test.describe('/search-params/default', () => { ).toBeTruthy() }) + test('Directly visiting the route with special character search param set', async ({ + page, + }) => { + await page.goto('/search-params/default/?default=🚀대한민국') + await page.waitForURL( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + + await expect(page.getByTestId('search-default')).toContainText('🚀대한민국') + await expect(page.getByTestId('context-hello')).toContainText('world') + + expect( + page + .url() + .endsWith( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ), + ).toBeTruthy() + }) + + test('Directly visiting the route with encoded character search param set', async ({ + page, + }) => { + await page.goto( + '/search-params/default/?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ) + await page.waitForURL( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ) + + await expect(page.getByTestId('search-default')).toContainText( + '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + await expect(page.getByTestId('context-hello')).toContainText('world') + + expect( + page + .url() + .endsWith( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ), + ).toBeTruthy() + }) + test('navigating to the route without search param set', async ({ page }) => { await page.goto('/search-params/') await page.getByTestId('link-to-default-without-search').click() + await page.waitForURL('/search-params/default?default=d1') await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -39,6 +86,7 @@ test.describe('/search-params/default', () => { test('navigating to the route with search param set', async ({ page }) => { await page.goto('/search-params/') await page.getByTestId('link-to-default-with-search').click() + await page.waitForURL('/search-params/default?default=d2') await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -46,4 +94,50 @@ test.describe('/search-params/default', () => { page.url().endsWith('/search-params/default?default=d2'), ).toBeTruthy() }) + + test('navigating to the route with special character search param set', async ({ + page, + }) => { + await page.goto('/search-params/') + await page + .getByTestId('link-to-default-with-search-special-characters') + .click() + await page.waitForURL( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + + await expect(page.getByTestId('search-default')).toContainText('🚀대한민국') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page + .url() + .endsWith( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ), + ).toBeTruthy() + }) + + test('navigating to the route with encoded character search param set', async ({ + page, + }) => { + await page.goto('/search-params/') + await page + .getByTestId('link-to-default-with-search-encoded-characters') + .click() + await page.waitForURL( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ) + + await expect(page.getByTestId('search-default')).toContainText( + '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page + .url() + .endsWith( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ), + ).toBeTruthy() + }) }) diff --git a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts index 544ba70d76e..bfa9054cffe 100644 --- a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts @@ -9,7 +9,6 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' import { Route as RemountDepsRouteImport } from './routes/remountDeps' import { Route as PostsRouteImport } from './routes/posts' import { Route as NotRemountDepsRouteImport } from './routes/notRemountDeps' @@ -18,6 +17,7 @@ import { Route as EditingARouteImport } from './routes/editing-a' import { Route as ComponentTypesTestRouteImport } from './routes/component-types-test' import { Route as AnchorRouteImport } from './routes/anchor' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as Char45824Char54620Char48124Char44397RouteRouteImport } from './routes/대한민국/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route' import { Route as IndexRouteImport } from './routes/index' @@ -45,6 +45,8 @@ import { Route as NonNestedNamedRouteRouteImport } from './routes/non-nested/nam import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' import { Route as ParamsPsWildcardIndexRouteImport } from './routes/params-ps/wildcard/index' import { Route as ParamsPsNamedIndexRouteImport } from './routes/params-ps/named/index' +import { Route as Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport } from './routes/대한민국/🚀.$id' +import { Route as Char45824Char54620Char48124Char44397WildcardSplatRouteImport } from './routes/대한민국/wildcard.$' import { Route as RelativeUseNavigateRelativeUseNavigateBRouteImport } from './routes/relative/useNavigate/relative-useNavigate-b' import { Route as RelativeUseNavigateRelativeUseNavigateARouteImport } from './routes/relative/useNavigate/relative-useNavigate-a' import { Route as RelativeLinkRelativeLinkBRouteImport } from './routes/relative/link/relative-link-b' @@ -99,12 +101,6 @@ import { Route as RelativeLinkPathPathIndexRouteImport } from './routes/relative import { Route as RelativeLinkNestedDeepIndexRouteImport } from './routes/relative/link/nested/deep/index' import { Route as ParamsPsNamedFooBarBazRouteImport } from './routes/params-ps/named/$foo/$bar.$baz' -const Char45824Char54620Char48124Char44397Route = - Char45824Char54620Char48124Char44397RouteImport.update({ - id: '/대한민국', - path: '/대한민국', - getParentRoute: () => rootRouteImport, - } as any) const RemountDepsRoute = RemountDepsRouteImport.update({ id: '/remountDeps', path: '/remountDeps', @@ -144,6 +140,12 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const Char45824Char54620Char48124Char44397RouteRoute = + Char45824Char54620Char48124Char44397RouteRouteImport.update({ + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => rootRouteImport, + } as any) const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ id: '/search-params', path: '/search-params', @@ -281,6 +283,18 @@ const ParamsPsNamedIndexRoute = ParamsPsNamedIndexRouteImport.update({ path: '/params-ps/named/', getParentRoute: () => rootRouteImport, } as any) +const Char45824Char54620Char48124Char44397Char55357Char56960IdRoute = + Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport.update({ + id: '/🚀/$id', + path: '/🚀/$id', + getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, + } as any) +const Char45824Char54620Char48124Char44397WildcardSplatRoute = + Char45824Char54620Char48124Char44397WildcardSplatRouteImport.update({ + id: '/wildcard/$', + path: '/wildcard/$', + getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, + } as any) const RelativeUseNavigateRelativeUseNavigateBRoute = RelativeUseNavigateRelativeUseNavigateBRouteImport.update({ id: '/relative-useNavigate-b', @@ -580,6 +594,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute @@ -587,7 +602,6 @@ export interface FileRoutesByFullPath { '/notRemountDeps': typeof NotRemountDepsRoute '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/non-nested/named': typeof NonNestedNamedRouteRouteWithChildren '/non-nested/path': typeof NonNestedPathRouteRouteWithChildren '/non-nested/prefix': typeof NonNestedPrefixRouteRouteWithChildren @@ -635,6 +649,8 @@ export interface FileRoutesByFullPath { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute + '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute '/params-ps/named': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard': typeof ParamsPsWildcardIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute @@ -667,13 +683,13 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute '/editing-b': typeof EditingBRoute '/notRemountDeps': typeof NotRemountDepsRoute '/remountDeps': typeof RemountDepsRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/non-nested/named': typeof NonNestedNamedRouteRouteWithChildren '/non-nested/path': typeof NonNestedPathRouteRouteWithChildren '/non-nested/prefix': typeof NonNestedPrefixRouteRouteWithChildren @@ -716,6 +732,8 @@ export interface FileRoutesByTo { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute + '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute '/params-ps/named': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard': typeof ParamsPsWildcardIndexRoute '/redirect/$target': typeof RedirectTargetIndexRoute @@ -750,6 +768,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute @@ -758,7 +777,6 @@ export interface FileRoutesById { '/notRemountDeps': typeof NotRemountDepsRoute '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397Route '/non-nested/named': typeof NonNestedNamedRouteRouteWithChildren '/non-nested/path': typeof NonNestedPathRouteRouteWithChildren '/non-nested/prefix': typeof NonNestedPrefixRouteRouteWithChildren @@ -808,6 +826,8 @@ export interface FileRoutesById { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute + '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute '/params-ps/named/': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard/': typeof ParamsPsWildcardIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute @@ -843,6 +863,7 @@ export interface FileRouteTypes { | '/' | '/non-nested' | '/search-params' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' @@ -850,7 +871,6 @@ export interface FileRouteTypes { | '/notRemountDeps' | '/posts' | '/remountDeps' - | '/대한민국' | '/non-nested/named' | '/non-nested/path' | '/non-nested/prefix' @@ -898,6 +918,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named' | '/params-ps/wildcard' | '/redirect/$target/' @@ -930,13 +952,13 @@ export interface FileRouteTypes { to: | '/' | '/non-nested' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' | '/editing-b' | '/notRemountDeps' | '/remountDeps' - | '/대한민국' | '/non-nested/named' | '/non-nested/path' | '/non-nested/prefix' @@ -979,6 +1001,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named' | '/params-ps/wildcard' | '/redirect/$target' @@ -1012,6 +1036,7 @@ export interface FileRouteTypes { | '/' | '/non-nested' | '/search-params' + | '/대한민국' | '/_layout' | '/anchor' | '/component-types-test' @@ -1020,7 +1045,6 @@ export interface FileRouteTypes { | '/notRemountDeps' | '/posts' | '/remountDeps' - | '/대한민국' | '/non-nested/named' | '/non-nested/path' | '/non-nested/prefix' @@ -1070,6 +1094,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named/' | '/params-ps/wildcard/' | '/redirect/$target/' @@ -1104,6 +1130,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute NonNestedRouteRoute: typeof NonNestedRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren + Char45824Char54620Char48124Char44397RouteRoute: typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren AnchorRoute: typeof AnchorRoute ComponentTypesTestRoute: typeof ComponentTypesTestRoute @@ -1112,7 +1139,6 @@ export interface RootRouteChildren { NotRemountDepsRoute: typeof NotRemountDepsRoute PostsRoute: typeof PostsRouteWithChildren RemountDepsRoute: typeof RemountDepsRoute - Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route ParamsPsNonNestedRouteRoute: typeof ParamsPsNonNestedRouteRouteWithChildren RelativeLinkRouteRoute: typeof RelativeLinkRouteRouteWithChildren RelativeUseNavigateRouteRoute: typeof RelativeUseNavigateRouteRouteWithChildren @@ -1145,13 +1171,6 @@ export interface RootRouteChildren { declare module '@tanstack/solid-router' { interface FileRoutesByPath { - '/대한민국': { - id: '/대한민국' - path: '/대한민국' - fullPath: '/대한민국' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteImport - parentRoute: typeof rootRouteImport - } '/remountDeps': { id: '/remountDeps' path: '/remountDeps' @@ -1208,6 +1227,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/대한민국': { + id: '/대한민국' + path: '/대한민국' + fullPath: '/대한민국' + preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteRouteImport + parentRoute: typeof rootRouteImport + } '/search-params': { id: '/search-params' path: '/search-params' @@ -1397,6 +1423,20 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof ParamsPsNamedIndexRouteImport parentRoute: typeof rootRouteImport } + '/대한민국/🚀/$id': { + id: '/대한민국/🚀/$id' + path: '/🚀/$id' + fullPath: '/대한민국/🚀/$id' + preLoaderRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport + parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + } + '/대한민국/wildcard/$': { + id: '/대한민국/wildcard/$' + path: '/wildcard/$' + fullPath: '/대한민국/wildcard/$' + preLoaderRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRouteImport + parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + } '/relative/useNavigate/relative-useNavigate-b': { id: '/relative/useNavigate/relative-useNavigate-b' path: '/relative-useNavigate-b' @@ -1925,6 +1965,24 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) +interface Char45824Char54620Char48124Char44397RouteRouteChildren { + Char45824Char54620Char48124Char44397WildcardSplatRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRoute + Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute +} + +const Char45824Char54620Char48124Char44397RouteRouteChildren: Char45824Char54620Char48124Char44397RouteRouteChildren = + { + Char45824Char54620Char48124Char44397WildcardSplatRoute: + Char45824Char54620Char48124Char44397WildcardSplatRoute, + Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: + Char45824Char54620Char48124Char44397Char55357Char56960IdRoute, + } + +const Char45824Char54620Char48124Char44397RouteRouteWithChildren = + Char45824Char54620Char48124Char44397RouteRoute._addFileChildren( + Char45824Char54620Char48124Char44397RouteRouteChildren, + ) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -2103,6 +2161,8 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NonNestedRouteRoute: NonNestedRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, + Char45824Char54620Char48124Char44397RouteRoute: + Char45824Char54620Char48124Char44397RouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, AnchorRoute: AnchorRoute, ComponentTypesTestRoute: ComponentTypesTestRoute, @@ -2111,8 +2171,6 @@ const rootRouteChildren: RootRouteChildren = { NotRemountDepsRoute: NotRemountDepsRoute, PostsRoute: PostsRouteWithChildren, RemountDepsRoute: RemountDepsRoute, - Char45824Char54620Char48124Char44397Route: - Char45824Char54620Char48124Char44397Route, ParamsPsNonNestedRouteRoute: ParamsPsNonNestedRouteRouteWithChildren, RelativeLinkRouteRoute: RelativeLinkRouteRouteWithChildren, RelativeUseNavigateRouteRoute: RelativeUseNavigateRouteRouteWithChildren, diff --git a/e2e/solid-router/basic-file-based/src/routes/__root.tsx b/e2e/solid-router/basic-file-based/src/routes/__root.tsx index 0997c45b374..883fbd3406a 100644 --- a/e2e/solid-router/basic-file-based/src/routes/__root.tsx +++ b/e2e/solid-router/basic-file-based/src/routes/__root.tsx @@ -130,6 +130,14 @@ function RootComponent() { > relative routing {' '} + + unicode path + {' '}
  • +
  • + + /params-ps/named/$foo - with special characters + +
  • +
  • + + /params-ps/wildcard/$ with encoded params + +
  • Bar2 + + Bar with special characters + ) diff --git a/e2e/solid-router/basic-file-based/src/routes/search-params/index.tsx b/e2e/solid-router/basic-file-based/src/routes/search-params/index.tsx index a99d64cbf44..baeb5ea6ff1 100644 --- a/e2e/solid-router/basic-file-based/src/routes/search-params/index.tsx +++ b/e2e/solid-router/basic-file-based/src/routes/search-params/index.tsx @@ -21,6 +21,23 @@ function RouteComponent() { > go to /search-params/default?default=d2 +
    + + go to /search-params/default?default=🚀대한민국 + +
    + + go to + /search-params/default?default=%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD + ) } diff --git a/e2e/solid-router/basic-file-based/src/routes/search-params/route.tsx b/e2e/solid-router/basic-file-based/src/routes/search-params/route.tsx index 5d9fc675158..6efa7152b6a 100644 --- a/e2e/solid-router/basic-file-based/src/routes/search-params/route.tsx +++ b/e2e/solid-router/basic-file-based/src/routes/search-params/route.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/solid-router' export const Route = createFileRoute('/search-params')({ beforeLoad: async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 100)) return { hello: 'world' as string } }, }) diff --git "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" deleted file mode 100644 index 897c0576cc4..00000000000 --- "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from '@tanstack/solid-router' - -export const Route = createFileRoute('/대한민국')({ - component: RouteComponent, -}) - -function RouteComponent() { - return
    Hello "/대한민국"!
    -} diff --git "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" new file mode 100644 index 00000000000..e996d48c76b --- /dev/null +++ "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" @@ -0,0 +1,63 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/대한민국')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
    +

    + Hello "/대한민국"! +

    +
      +
    • + + link to latin id + +
    • +
    • + + link to unicode id + +
    • +
    • + + link to foo/bar + +
    • +
    • + + link to foo%\/🚀대 + +
    • +
    +
    + +
    + ) +} diff --git "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" new file mode 100644 index 00000000000..d849f5675cb --- /dev/null +++ "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/대한민국/wildcard/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return ( +
    +

    Unicode Wildcard Params

    +
    + Hello /대한민국/wildcard/ + {params()._splat} +
    +
    + ) +} diff --git "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" new file mode 100644 index 00000000000..93bb5e561c9 --- /dev/null +++ "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/대한민국/🚀/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return ( +
    +

    Unicode Named Params

    +
    + Hello /대한민국/🚀/ + {params().id} +
    +
    + ) +} diff --git a/e2e/solid-router/basic-file-based/tests/app.spec.ts b/e2e/solid-router/basic-file-based/tests/app.spec.ts index 398021a7b94..f535fdd78ce 100644 --- a/e2e/solid-router/basic-file-based/tests/app.spec.ts +++ b/e2e/solid-router/basic-file-based/tests/app.spec.ts @@ -304,3 +304,13 @@ test('Should remount deps when remountDeps does change ', async ({ page }) => { 'Page component mounts: 3', ) }) + +test.describe('Unicode route rendering', () => { + test('should render non-latin route correctly', async ({ page, baseURL }) => { + await page.goto('/대한민국') + + await expect(page.locator('body')).toContainText('Hello "/대한민국"!') + + expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) + }) +}) diff --git a/e2e/solid-router/basic-file-based/tests/params.spec.ts b/e2e/solid-router/basic-file-based/tests/params.spec.ts index 499c8b97e01..9d57f437a72 100644 --- a/e2e/solid-router/basic-file-based/tests/params.spec.ts +++ b/e2e/solid-router/basic-file-based/tests/params.spec.ts @@ -145,6 +145,12 @@ test.describe('params operations + prefix/suffix', () => { params: { foo: 'foo' }, destHeadingId: 'ParamsNamedFooSuffix', }, + { + id: 'l-to-named-foo-special-characters', + pathname: '/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80', + params: { foo: 'foo%\\/🚀대' }, + destHeadingId: 'ParamsNamedFoo', + }, ] satisfies Array<{ id: string pathname: string @@ -317,6 +323,16 @@ test.describe('params operations + prefix/suffix', () => { }, destHeadingId: 'ParamsWildcardSplatSuffix', }, + { + id: 'l-to-wildcard-encoded', + pathname: + '/params-ps/wildcard/%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + params: { + '*': '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + _splat: '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + }, + destHeadingId: 'ParamsWildcardSplat', + }, ] satisfies Array<{ id: string pathname: string @@ -368,12 +384,123 @@ test.describe('params operations + prefix/suffix', () => { }) }) -test.describe('Unicode route rendering', () => { - test('should render non-latin route correctly', async ({ page, baseURL }) => { - await page.goto('/대한민국') +test.describe('Unicode params', () => { + test('should render non-latin route correctly across multiple params', async ({ + page, + baseURL, + }) => { + await page.goto('/params-ps') + await page.waitForURL('/params-ps') + const fooLink = page.getByTestId('l-to-named-foo-special-characters') + + await fooLink.click() + await page.waitForURL('/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80') + + expect(page.url()).toBe( + `${baseURL}/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80`, + ) - await expect(page.locator('body')).toContainText('Hello "/대한민국"!') + const headingEl = page.getByRole('heading', { name: 'ParamsNamedFoo' }) + await expect(headingEl).toBeVisible() + let paramsEl = page.getByTestId('params-output') + let paramsText = await paramsEl.innerText() + expect(paramsText).toEqual(JSON.stringify({ foo: 'foo%\\/🚀대' })) - expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) + const barLink = page.getByTestId('params-foo-links-bar-special-characters') + + await barLink.click() + + await page.waitForURL( + '/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80/%F0%9F%9A%80%252F%2Fabc%EB%8C%80', + ) + + expect(page.url()).toBe( + `${baseURL}/params-ps/named/foo%25%5C%2F%F0%9F%9A%80%EB%8C%80/%F0%9F%9A%80%252F%2Fabc%EB%8C%80`, + ) + + paramsEl = page.getByTestId('foo-bar-value') + paramsText = await paramsEl.innerText() + expect(paramsText).toEqual('🚀%2F/abc대') + }) + + test.describe('should handle routes with non-latin paths and params correctly', () => { + const testCases = [ + { + name: 'named', + childPath: '🚀', + latinParams: 'foo', + unicodeParams: 'foo%\\/🚀대', + }, + { + name: 'wildcard', + childPath: 'wildcard', + latinParams: 'foo/bar', + unicodeParams: 'foo%\\/🚀대', + }, + ] + + testCases.forEach(({ name, childPath, latinParams, unicodeParams }) => { + test(`${name} params`, async ({ page, baseURL }) => { + const pascalCaseName = name.charAt(0).toUpperCase() + name.slice(1) + const routeParentPath = '/대한민국' + const encodedRouteParentPath = encodeURI(routeParentPath) + const childRoutePath = `${routeParentPath}/${childPath}` + const encodedChildRoutePath = encodeURI(childRoutePath) + + await page.goto(routeParentPath) + await page.waitForURL(encodedRouteParentPath) + + const headingRootEl = page.getByTestId('unicode-heading') + + expect(await headingRootEl.innerText()).toBe('Hello "/대한민국"!') + + const latinLink = page.getByTestId(`l-to-${name}-latin`) + const unicodeLink = page.getByTestId(`l-to-${name}-unicode`) + + await expect(latinLink).not.toContainClass('font-bold') + await expect(unicodeLink).not.toContainClass('font-bold') + + await latinLink.click() + + await page.waitForURL(`${encodedChildRoutePath}/${latinParams}`) + + expect(page.url()).toBe( + `${baseURL}${encodedChildRoutePath}/${latinParams}`, + ) + + await expect(latinLink).toContainClass('font-bold') + await expect(unicodeLink).not.toContainClass('font-bold') + + const headingEl = page.getByTestId(`unicode-${name}-heading`) + const paramsEl = page.getByTestId(`unicode-${name}-params`) + + expect(await headingEl.innerText()).toBe( + `Unicode ${pascalCaseName} Params`, + ) + + expect(await paramsEl.innerText()).toBe(latinParams) + + await unicodeLink.click() + + const encodedParams = + name === 'wildcard' + ? encodeURI(unicodeParams) + : encodeURIComponent(unicodeParams) + + await page.waitForURL(`${encodedChildRoutePath}/${encodedParams}`) + + expect(page.url()).toBe( + `${baseURL}${encodedChildRoutePath}/${encodedParams}`, + ) + + await expect(latinLink).not.toContainClass('font-bold') + await expect(unicodeLink).toContainClass('font-bold') + + expect(await headingEl.innerText()).toBe( + `Unicode ${pascalCaseName} Params`, + ) + expect(await paramsEl.innerText()).toBe(unicodeParams) + }) + }) }) }) diff --git a/e2e/solid-router/basic-file-based/tests/search-params.spec.ts b/e2e/solid-router/basic-file-based/tests/search-params.spec.ts index 1fb0fc3034b..d6307cbcf8b 100644 --- a/e2e/solid-router/basic-file-based/tests/search-params.spec.ts +++ b/e2e/solid-router/basic-file-based/tests/search-params.spec.ts @@ -5,6 +5,7 @@ test.describe('/search-params/default', () => { page, }) => { await page.goto('/search-params/default') + await page.waitForURL('/search-params/default?default=d1') await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -17,6 +18,7 @@ test.describe('/search-params/default', () => { page, }) => { await page.goto('/search-params/default/?default=d2') + await page.waitForURL('/search-params/default?default=d2') await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -25,9 +27,54 @@ test.describe('/search-params/default', () => { ).toBeTruthy() }) + test('Directly visiting the route with special character search param set', async ({ + page, + }) => { + await page.goto('/search-params/default/?default=🚀대한민국') + await page.waitForURL( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + + await expect(page.getByTestId('search-default')).toContainText('🚀대한민국') + await expect(page.getByTestId('context-hello')).toContainText('world') + + expect( + page + .url() + .endsWith( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ), + ).toBeTruthy() + }) + + test('Directly visiting the route with encoded character search param set', async ({ + page, + }) => { + await page.goto( + '/search-params/default/?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ) + await page.waitForURL( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ) + + await expect(page.getByTestId('search-default')).toContainText( + '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + await expect(page.getByTestId('context-hello')).toContainText('world') + + expect( + page + .url() + .endsWith( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ), + ).toBeTruthy() + }) + test('navigating to the route without search param set', async ({ page }) => { await page.goto('/search-params/') await page.getByTestId('link-to-default-without-search').click() + await page.waitForURL('/search-params/default?default=d1') await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -39,6 +86,7 @@ test.describe('/search-params/default', () => { test('navigating to the route with search param set', async ({ page }) => { await page.goto('/search-params/') await page.getByTestId('link-to-default-with-search').click() + await page.waitForURL('/search-params/default?default=d2') await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') @@ -46,4 +94,50 @@ test.describe('/search-params/default', () => { page.url().endsWith('/search-params/default?default=d2'), ).toBeTruthy() }) + + test('navigating to the route with special character search param set', async ({ + page, + }) => { + await page.goto('/search-params/') + await page + .getByTestId('link-to-default-with-search-special-characters') + .click() + await page.waitForURL( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + + await expect(page.getByTestId('search-default')).toContainText('🚀대한민국') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page + .url() + .endsWith( + '/search-params/default?default=%F0%9F%9A%80%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ), + ).toBeTruthy() + }) + + test('navigating to the route with encoded character search param set', async ({ + page, + }) => { + await page.goto('/search-params/') + await page + .getByTestId('link-to-default-with-search-encoded-characters') + .click() + await page.waitForURL( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ) + + await expect(page.getByTestId('search-default')).toContainText( + '%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD', + ) + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page + .url() + .endsWith( + '/search-params/default?default=%25EB%258C%2580%25ED%2595%259C%25EB%25AF%25BC%25EA%25B5%25AD', + ), + ).toBeTruthy() + }) }) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 292d40918c8..bbb9fd8f042 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -6498,31 +6498,31 @@ describe('encoded and unicode paths', () => { const testCases = [ { name: 'with prefix', - path: '/foo/prefix대{$}', - browsePath: '/foo/prefix대test[s%5C/.%5C/parameter%25!🚀]', + path: '/foo/prefix@대{$}', expectedPath: - '/foo/prefix%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + '/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]', + expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]', params: { - _splat: 'test[s\\/.\\/parameter%!🚀]', - '*': 'test[s\\/.\\/parameter%!🚀]', + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', }, }, { name: 'with suffix', - path: '/foo/{$}대suffix', - browsePath: '/foo/test[s%5C/.%5C/parameter%25!🚀]대suffix', + path: '/foo/{$}대suffix@', expectedPath: - '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]%EB%8C%80suffix', + '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@', params: { - _splat: 'test[s\\/.\\/parameter%!🚀]', - '*': 'test[s\\/.\\/parameter%!🚀]', + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', }, }, { name: 'with wildcard', path: '/foo/$', - browsePath: '/foo/test[s%5C/.%5C/parameter%25!🚀]', expectedPath: '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀]', params: { _splat: 'test[s\\/.\\/parameter%!🚀]', '*': 'test[s\\/.\\/parameter%!🚀]', @@ -6532,8 +6532,8 @@ describe('encoded and unicode paths', () => { { name: 'with path param', path: `/foo/$id`, - browsePath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', params: { id: 'test[s\\/.\\/parameter%!🚀]', }, @@ -6542,14 +6542,7 @@ describe('encoded and unicode paths', () => { test.each(testCases)( 'should handle encoded, decoded paths with unicode characters correctly - $name', - async ({ path, browsePath, expectedPath, params }) => { - async function validate() { - const paramsToValidate = await screen.findByTestId('params-to-validate') - - expect(window.location.pathname).toBe(expectedPath) - expect(paramsToValidate.textContent).toEqual(JSON.stringify(params)) - } - + async ({ path, expectedPath, expectedLocation, params }) => { const rootRoute = createRootRoute() const indexRoute = createRoute({ @@ -6595,19 +6588,18 @@ describe('encoded and unicode paths', () => { render() - await act(() => history.push(browsePath)) - - await validate() - - await act(() => history.push('/')) - const link = await screen.findByTestId('link-to-path') expect(link.getAttribute('href')).toBe(expectedPath) await act(() => fireEvent.click(link)) - await validate() + const paramsToValidate = await screen.findByTestId('params-to-validate') + + expect(window.location.pathname).toBe(expectedPath) + expect(router.latestLocation.pathname).toBe(expectedLocation) + + expect(paramsToValidate.textContent).toEqual(JSON.stringify(params)) }, ) }) diff --git a/packages/react-router/tests/navigate.test.tsx b/packages/react-router/tests/navigate.test.tsx index 574c9fe21fc..1e520c26211 100644 --- a/packages/react-router/tests/navigate.test.tsx +++ b/packages/react-router/tests/navigate.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, test, vi } from 'vitest' import { trailingSlashOptions } from '@tanstack/router-core' import { @@ -1293,3 +1293,79 @@ describe('splat routes with empty splat', () => { }, ) }) + +describe('encoded and unicode paths', () => { + const testCases = [ + { + name: 'with prefix', + path: '/foo/prefix@대{$}', + expectedPath: + '/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]', + expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with suffix', + path: '/foo/{$}대suffix@', + expectedPath: + '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with wildcard', + path: '/foo/$', + expectedPath: '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀]', + '*': 'test[s\\/.\\/parameter%!🚀]', + }, + }, + // '/' is left as is with splat params but encoded with normal params + { + name: 'with path param', + path: `/foo/$id`, + expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', + params: { + id: 'test[s\\/.\\/parameter%!🚀]', + }, + }, + ] + + test.each(testCases)( + 'should handle encoded, decoded paths with unicode characters correctly - $name', + async ({ path, expectedPath, expectedLocation, params }) => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const pathRoute = createRoute({ + getParentRoute: () => rootRoute, + path, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pathRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: path, params }) + await router.invalidate() + + expect(router.state.location.href).toBe(expectedPath) + expect(router.state.location.pathname).toBe(expectedLocation) + }, + ) +}) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 4f85a4688e2..cd566e804ff 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -48,6 +48,22 @@ afterEach(() => { const mockFn1 = vi.fn() +const URISyntaxCharacters = [ + [';', '%3B'], + [',', '%2C'], + ['/', '%2F'], + ['?', '%3F'], + [':', '%3A'], + ['@', '%40'], + ['&', '%26'], + ['=', '%3D'], + ['+', '%2B'], + ['$', '%24'], + ['#', '%23'], + ['\\', '%5C'], + ['%', '%25'], +] as const + export function validateSearchParams< TExpected extends Partial>, >(expected: TExpected, router: AnyRouter) { @@ -326,58 +342,125 @@ function createTestRouter( } } -describe('encoding: URL param segment for /posts/$slug', () => { - it('state.location.pathname, should have the params.slug value of "tanner"', async () => { +describe('encoding: path params', () => { + it('no decoding/encoding of path, url and href required', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/tanner'] }), }) await act(() => router.load()) + expect(router.state.location.url.endsWith('/posts/tanner')).toBe(true) + expect(router.state.location.href).toBe('/posts/tanner') expect(router.state.location.pathname).toBe('/posts/tanner') }) - it('state.location.pathname, should have the params.slug value of "🚀"', async () => { + it('should encode the url and href with unicode param', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/🚀'] }), }) await act(() => router.load()) - expect(router.state.location.pathname).toBe('/posts/%F0%9F%9A%80') + expect(router.state.location.url.endsWith('/posts/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.href).toBe('/posts/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/posts/🚀') }) - it('state.location.pathname, should have the params.slug value of "100%25"', async () => { + // the param that is passed in should be the param that is returned + it('should treat encoded params as decoded value when encoded params is specified', async () => { const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/posts/100%25'] }), + history: createMemoryHistory({ initialEntries: ['/'] }), }) await act(() => router.load()) + await act(() => + router.navigate({ + to: '/posts/$slug', + params: { slug: '100%25' }, + }), + ) - expect(router.state.location.pathname).toBe('/posts/100%25') + expect(router.state.location.url.endsWith('/posts/100%2525')).toBe(true) + expect(router.state.location.href).toBe('/posts/100%2525') + expect(router.state.location.pathname).toBe('/posts/100%2525') }) - it('state.location.pathname, should have the params.slug value of "100%26"', async () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/posts/100%26'] }), - }) + describe('pathname and URI syntax characters', () => { + it.each(URISyntaxCharacters)( + 'pathname should encode $0', + async (character, encodedValue) => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: [`/`], + }), + }) - await act(() => router.load()) + await act(() => router.load()) + + await act(() => + router.navigate({ + to: '/posts/$slug', + params: { slug: `${character}jane%` }, + }), + ) + + expect( + router.state.location.url.endsWith(`/posts/${encodedValue}jane%25`), + ).toBe(true) + expect(router.state.location.href).toBe(`/posts/${encodedValue}jane%25`) + expect(router.state.location.pathname).toBe( + `/posts/${encodedValue}jane%25`, + ) + }, + ) - expect(router.state.location.pathname).toBe('/posts/100%26') + it.each( + URISyntaxCharacters.filter((c) => { + const character = c[0] + + const strictlyNotAllowedCharacters = ['/', '?', '\\', '%', '#'] + + return !strictlyNotAllowedCharacters.includes(character) + }), + )('pathname should not encode $0 when allowed', async (character, _) => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: [`/`], + }), + pathParamsAllowedCharacters: [character] as any, + }) + + await act(() => router.load()) + + await act(() => + router.navigate({ + to: '/posts/$slug', + params: { slug: `${character}jane%` }, + }), + ) + + expect( + router.state.location.url.endsWith(`/posts/${character}jane%25`), + ).toBe(true) + expect(router.state.location.href).toBe(`/posts/${character}jane%25`) + expect(router.state.location.pathname).toBe(`/posts/${character}jane%25`) + }) }) - it('state.location.pathname, should have the params.slug value of "%F0%9F%9A%80"', async () => { + it('pathname should decode encoded unicode param', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/%F0%9F%9A%80'] }), }) await act(() => router.load()) - expect(router.state.location.pathname).toBe('/posts/%F0%9F%9A%80') + expect(router.state.location.url.endsWith('/posts/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.href).toBe('/posts/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/posts/🚀') }) - it('state.location.pathname, should have the params.slug value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + it('pathname params should decode combination of encoded characters', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: [ @@ -388,12 +471,20 @@ describe('encoding: URL param segment for /posts/$slug', () => { await act(() => router.load()) - expect(router.state.location.pathname).toBe( + expect( + router.state.location.url.endsWith( + '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', + ), + ).toBe(true) + expect(router.state.location.href).toBe( '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ) + expect(router.state.location.pathname).toBe( + '/posts/framework%2Freact%2Fguide%2Ffile-based-routing tanstack', + ) }) - it('params.slug for the matched route, should be "tanner"', async () => { + it('path params no encoding/decoding required', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/tanner'] }), }) @@ -411,7 +502,7 @@ describe('encoding: URL param segment for /posts/$slug', () => { expect((match.params as unknown as any).slug).toBe('tanner') }) - it('params.slug for the matched route, should be "🚀"', async () => { + it('path params should keep unicode characters', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/🚀'] }), }) @@ -429,7 +520,7 @@ describe('encoding: URL param segment for /posts/$slug', () => { expect((match.params as unknown as any).slug).toBe('🚀') }) - it('params.slug for the matched route, should be "🚀" instead of it being "%F0%9F%9A%80"', async () => { + it('path params should decode encoded unicode characters', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/%F0%9F%9A%80'] }), }) @@ -447,31 +538,18 @@ describe('encoding: URL param segment for /posts/$slug', () => { expect((match.params as unknown as any).slug).toBe('🚀') }) - it('params.slug for the matched route, should be "100%"', async () => { + // the param that is passed in should be the param that is returned + it('path params should not decode encoded characters when passed as param', async () => { const { router, routes } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/posts/100%25'] }), + history: createMemoryHistory({ initialEntries: ['/'] }), }) await act(() => router.load()) - const match = router.state.matches.find( - (r) => r.routeId === routes.postIdRoute.id, + await act(() => + router.navigate({ to: '/posts/$slug', params: { slug: '100%25' } }), ) - if (!match) { - throw new Error('No match found') - } - - expect((match.params as unknown as any).slug).toBe('100%') - }) - - it('params.slug for the matched route, should be "100&"', async () => { - const { router, routes } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/posts/100%26'] }), - }) - - await act(() => router.load()) - const match = router.state.matches.find( (r) => r.routeId === routes.postIdRoute.id, ) @@ -480,46 +558,35 @@ describe('encoding: URL param segment for /posts/$slug', () => { throw new Error('No match found') } - expect((match.params as unknown as any).slug).toBe('100&') + expect((match.params as unknown as any).slug).toBe('100%25') }) - it('params.slug for the matched route, should be "100%100"', async () => { - const { router, routes } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/posts/100%25100'] }), - }) - - await act(() => router.load()) - - const match = router.state.matches.find( - (r) => r.routeId === routes.postIdRoute.id, - ) - - if (!match) { - throw new Error('No match found') - } + describe('path params should decode encoded URI syntax characters', () => { + it.each(URISyntaxCharacters)( + '$1 => $0 - should be decoded', + async (character, encodedValue) => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: [`/posts/100${encodedValue}100`], + }), + }) - expect((match.params as unknown as any).slug).toBe('100%100') - }) + await act(() => router.load()) - it('params.slug for the matched route, should be "100&100"', async () => { - const { router, routes } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/posts/100%26100'] }), - }) + const match = router.state.matches.find( + (r) => r.routeId === routes.postIdRoute.id, + ) - await act(() => router.load()) + if (!match) { + throw new Error('No match found') + } - const match = router.state.matches.find( - (r) => r.routeId === routes.postIdRoute.id, + expect((match.params as unknown as any).slug).toBe(`100${character}100`) + }, ) - - if (!match) { - throw new Error('No match found') - } - - expect((match.params as unknown as any).slug).toBe('100&100') }) - it('params.slug for the matched route, should be "framework/react/guide/file-based-routing tanstack" instead of it being "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + it('path params should decode combination of encoded characters', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: [ @@ -542,91 +609,67 @@ describe('encoding: URL param segment for /posts/$slug', () => { 'framework/react/guide/file-based-routing tanstack', ) }) - - it('params.slug should be encoded in the final URL', async () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - await router.load() - render() - - await act(() => - router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }), - ) - - expect(router.state.location.pathname).toBe('/posts/%40jane') - }) - - it('params.slug should be encoded in the final URL except characters in pathParamsAllowedCharacters', async () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/'] }), - pathParamsAllowedCharacters: ['@'], - }) - - await router.load() - render() - - await act(() => - router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }), - ) - - expect(router.state.location.pathname).toBe('/posts/@jane') - }) }) -describe('encoding: URL splat segment for /$', () => { - it('state.location.pathname, should have the params._splat value of "tanner"', async () => { +describe('encoding/decoding: wildcard routes/params', () => { + it('no decoding/encoding of path, url and href required', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/tanner'] }), }) await router.load() + expect(router.state.location.url.endsWith('/tanner')).toBe(true) + expect(router.state.location.href).toBe('/tanner') expect(router.state.location.pathname).toBe('/tanner') }) - it('state.location.pathname, should have the params._splat value of "🚀"', async () => { + it('should encode the url and href with unicode param', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/🚀'] }), }) await router.load() - expect(router.state.location.pathname).toBe('/%F0%9F%9A%80') + expect(router.state.location.url.endsWith('/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.href).toBe('/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/🚀') }) - it('state.location.pathname, should have the params._splat value of "100%25"', async () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/100%25'] }), - }) - - await router.load() - - expect(router.state.location.pathname).toBe('/100%25') - }) - - it('state.location.pathname, should have the params._splat value of "100%26"', async () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/100%26'] }), - }) + describe('pathname and URI syntax characters', () => { + it.each(URISyntaxCharacters)( + '$1 should not be decoded for pathname', + async (character, encodedValue) => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: [`/100${encodedValue}100`], + }), + }) - await router.load() + await router.load() - expect(router.state.location.pathname).toBe('/100%26') + expect( + router.state.location.url.endsWith(`/100${encodedValue}100`), + ).toBe(true) + expect(router.state.location.href).toBe(`/100${encodedValue}100`) + expect(router.state.location.pathname).toBe(`/100${encodedValue}100`) + }, + ) }) - it('state.location.pathname, should have the params._splat value of "%F0%9F%9A%80"', async () => { + it('pathname should decode encoded unicode path', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/%F0%9F%9A%80'] }), }) await router.load() - expect(router.state.location.pathname).toBe('/%F0%9F%9A%80') + expect(router.state.location.url.endsWith('/%F0%9F%9A%80')).toBe(true) + expect(router.state.location.href).toBe('/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/🚀') }) - it('state.location.pathname, should have the params._splat value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + it('decode only non URI syntax characters in path', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: [ @@ -637,12 +680,20 @@ describe('encoding: URL splat segment for /$', () => { await router.load() + expect( + router.state.location.url.endsWith( + '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', + ), + ).toBe(true) expect(router.state.location.href).toBe( '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ) + expect(router.state.location.pathname).toBe( + '/framework%2Freact%2Fguide%2Ffile-based-routing tanstack', + ) }) - it('state.location.pathname, should have the params._splat value of "framework/react/guide/file-based-routing tanstack"', async () => { + it('"/" should not be encoded in splat param paths', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/framework/react/guide/file-based-routing tanstack'], @@ -651,12 +702,22 @@ describe('encoding: URL splat segment for /$', () => { await router.load() + expect( + router.state.location.url.endsWith( + '/framework/react/guide/file-based-routing%20tanstack', + ), + ).toBe(true) + expect(router.state.location.href).toBe( '/framework/react/guide/file-based-routing%20tanstack', ) + + expect(router.state.location.pathname).toBe( + '/framework/react/guide/file-based-routing tanstack', + ) }) - it('params._splat for the matched route, should be "tanner"', async () => { + it('no encoding/decoding of param required', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/tanner'] }), }) @@ -674,7 +735,7 @@ describe('encoding: URL splat segment for /$', () => { expect((match.params as unknown as any)._splat).toBe('tanner') }) - it('params._splat for the matched route, should be "🚀"', async () => { + it('param should keep decoded unicode param', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/🚀'] }), }) @@ -692,7 +753,7 @@ describe('encoding: URL splat segment for /$', () => { expect((match.params as unknown as any)._splat).toBe('🚀') }) - it('params._splat for the matched route, should be "🚀" instead of it being "%F0%9F%9A%80"', async () => { + it('param should be decoded for encoded unicode param', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/%F0%9F%9A%80'] }), }) @@ -710,7 +771,7 @@ describe('encoding: URL splat segment for /$', () => { expect((match.params as unknown as any)._splat).toBe('🚀') }) - it('params._splat for the matched route, should be "framework/react/guide/file-based-routing tanstack"', async () => { + it('"/" should not be encoded in splat params', async () => { const { router, routes } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/framework/react/guide/file-based-routing tanstack'], @@ -733,65 +794,58 @@ describe('encoding: URL splat segment for /$', () => { }) }) -describe('encoding: URL path segment', () => { +describe('encoding/decoding: URL path segment', () => { it.each([ { + test: 'should decode the pathname', input: '/path-segment/%C3%A9', - output: '/path-segment/%C3%A9', - }, - { - input: '/path-segment/é', - output: '/path-segment/%C3%A9', - type: 'not encoded', + path: '/path-segment/é', + url: '/path-segment/%C3%A9', }, { + test: 'should not decode excluded characters', input: '/path-segment/100%25', // `%25` = `%` - output: '/path-segment/100%25', - type: 'not encoded', + path: '/path-segment/100%25', + url: '/path-segment/100%25', }, { + test: 'should not decode multiple excluded characters', input: '/path-segment/100%25%25', - output: '/path-segment/100%25%25', - type: 'not encoded', + path: '/path-segment/100%25%25', + url: '/path-segment/100%25%25', }, { + test: 'should not decode characters that are part of the URI syntax', input: '/path-segment/100%26', // `%26` = `&` - output: '/path-segment/100%26', - type: 'not encoded', + path: '/path-segment/100%26', + url: '/path-segment/100%26', }, { + test: 'should decode unicode characters', input: '/path-segment/%F0%9F%9A%80', - output: '/path-segment/%F0%9F%9A%80', + path: '/path-segment/🚀', + url: '/path-segment/%F0%9F%9A%80', }, { - input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', + test: 'should encode unicode characters in the url and href', + input: '/path-segment/🚀é', + path: '/path-segment/🚀é', + url: '/path-segment/%F0%9F%9A%80%C3%A9', }, { - input: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', - }, - { - input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', - output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', + test: 'should encode unicode characters in the url and href and maintain already encoded characters', + input: '/path-segment/🚀to%2Fthe%2Fmoon', + path: '/path-segment/🚀to%2Fthe%2Fmoon', + url: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', }, { + test: 'should decode/encode combination of excluded, URI syntax and unicode characters correctly in the path, url and href', input: - '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: - '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', - }, - { - input: '/path-segment/🚀', - output: '/path-segment/%F0%9F%9A%80', - type: 'not encoded', - }, - { - input: '/path-segment/🚀to%2Fthe%2Fmoon', - output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', - type: 'not encoded', + '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon%25', + path: '/path-segment/🚀to%2Fthe%2Fmoon%25🚀to%2Fthe%2Fmoon%25', + url: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon%25', }, - ])('should resolve $input to $output', async ({ input, output }) => { + ])('$test', async ({ input, path, url }) => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: [input] }), }) @@ -799,7 +853,9 @@ describe('encoding: URL path segment', () => { render() await act(() => router.load()) - expect(new URL(router.state.location.url).pathname).toBe(output) + expect(router.state.location.pathname).toBe(path) + expect(router.state.location.href).toBe(url) + expect(new URL(router.state.location.url).pathname).toBe(url) }) }) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 539ab312c69..e3475c2e274 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -2660,3 +2660,118 @@ describe('splat routes with empty splat', () => { }, ) }) + +describe('encoded and unicode paths', () => { + const testCases = [ + { + name: 'with prefix', + path: '/foo/prefix@대{$}', + expectedPath: + '/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]', + expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with suffix', + path: '/foo/{$}대suffix@', + expectedPath: + '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with wildcard', + path: '/foo/$', + expectedPath: '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀]', + '*': 'test[s\\/.\\/parameter%!🚀]', + }, + }, + // '/' is left as is with splat params but encoded with normal params + { + name: 'with path param', + path: `/foo/$id`, + expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', + params: { + id: 'test[s\\/.\\/parameter%!🚀]', + }, + }, + ] + + test.each(testCases)( + 'should handle encoded, decoded paths with unicode characters correctly - $name', + async ({ path, expectedPath, expectedLocation, params }) => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + function IndexComponent() { + const navigate = useNavigate() + + return ( + <> +

    Index Route

    + + + ) + } + + const pathRoute = createRoute({ + getParentRoute: () => rootRoute, + path, + component: PathRouteComponent, + }) + + function PathRouteComponent() { + const params = pathRoute.useParams() + return ( +
    +

    Path Route

    +

    + params:{' '} + + {JSON.stringify(params)} + +

    +
    + ) + } + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pathRoute]), + history, + }) + + render() + + const link = await screen.findByTestId('btn-to-path') + + await act(() => fireEvent.click(link)) + + const paramsToValidate = await screen.findByTestId('params-to-validate') + + expect(window.location.pathname).toBe(expectedPath) + expect(router.latestLocation.pathname).toBe(expectedLocation) + + expect(paramsToValidate.textContent).toEqual(JSON.stringify(params)) + }, + ) +}) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 98baed5f55b..b86a1ed67f5 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -278,6 +278,7 @@ export { deepEqual, createControlledPromise, isModuleNotFoundError, + decodePath, } from './utils' export type { NoInfer, diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index eac8940ae5e..6b97f944bfa 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,4 +1,4 @@ -import { decodePathSegment, last } from './utils' +import { last } from './utils' import type { LRUCache } from './lru-cache' import type { MatchLocation } from './RouterProvider' import type { AnyPathParams } from './route' @@ -358,7 +358,7 @@ function baseParsePathname(pathname: string): ReadonlyArray { // Handle regular pathname segment return { type: SEGMENT_TYPE_PATHNAME, - value: decodePathSegment(part), + value: part, } }), ) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index bd4d22860d3..b6a700e8e6d 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2,7 +2,7 @@ import { Store, batch } from '@tanstack/store' import { createBrowserHistory, parseHref } from '@tanstack/history' import { createControlledPromise, - decodePathSegment, + decodePath, deepEqual, findLast, functionalUpdate, @@ -1173,7 +1173,7 @@ export class RouterCore< href: fullPath, publicHref: href, url: url.href, - pathname, + pathname: decodePath(pathname), searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, hash: hash.split('#').reverse()[0] ?? '', @@ -1671,7 +1671,7 @@ export class RouterCore< } } - const nextPathname = decodePathSegment( + const nextPathname = decodePath( interpolatePath({ // Use the original template path for interpolation // This preserves the original parameter syntax including optional parameters diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 2012d259166..ccc4654dbe4 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -493,31 +493,53 @@ const DECODE_IGNORE_LIST = Array.from( new Map([ ['%', '%25'], ['\\', '%5C'], - ['/', '%2F'], - [';', '%3B'], - [':', '%3A'], - ['@', '%40'], - ['&', '%26'], - ['=', '%3D'], - ['+', '%2B'], - ['$', '%24'], - [',', '%2C'], ]).values(), ) -export function decodePathSegment( +export function decodePath( part: string, decodeIgnore: Array = DECODE_IGNORE_LIST, - startIndex = 0, ): string { - function decode(part: string): string { + function splitAndDecode( + part: string, + decodeIgnore: Array, + startIndex = 0, + ): string { + // decode the path / path segment by splitting it into parts defined by the ignore list. + // once these pieces have been decoded, join them back together to form the final decoded path segment with the ignored character in place. + // we walk through the ignore list linearly, breaking the segment up into pieces and decoding each piece individually. + // use index traversal to avoid making unnecessary copies of the array. + for (let i = startIndex; i < decodeIgnore.length; i++) { + const char = decodeIgnore[i]!.toUpperCase() + + // check if the part includes the current ignore character + // if it doesn't continue to the next ignore character + if (part.includes(char)) { + // split the part into pieces that needs to be checked and decoded + const partsToDecode = part.split(char) + const partsToJoin: Array = [] + + // now check and decode each piece individually taking into consideration the remaining ignored characters. + // since we are walking through the list linearly, we only need to consider ignore items not yet traversed. + for (const partToDecode of partsToDecode) { + // once we have traversed the entire ignore list, each decoded part is returned. + partsToJoin.push(splitAndDecode(partToDecode, decodeIgnore, i + 1)) + } + + // and join them back together to form the final decoded path segment with the ignored character in place. + return partsToJoin.join(char) + } + } + + // once we have reached the end of the ignore list, we start walking back returning each decoded part. + // should there be no matching characters, the path segment as a whole will be decoded. try { - return decodeURIComponent(part) + return decodeURI(part) } catch { // if the decoding fails, try to decode the various parts leaving the malformed tags in place - return part.replaceAll(/%[0-9A-Fa-f]{2}/g, (match) => { + return part.replaceAll(/%[0-9A-F]{2}/g, (match) => { try { - return decodeURIComponent(match) + return decodeURI(match) } catch { return match } @@ -526,35 +548,12 @@ export function decodePathSegment( } // if the path segment does not contain any encoded uri components return the path as is - if (part === '' || !part.match(/%[0-9A-Fa-f]{2}/g)) return part - - // decode the path / path segment by splitting it into parts defined by the ignore list. - // once these pieces have been decoded, join them back together to form the final decoded path segment with the ignored character in place. - // we walk through the ignore list linearly, breaking the segment up into pieces and decoding each piece individually. - // use index traversal to avoid making unnecessary copies of the array. - for (let i = startIndex; i < decodeIgnore.length; i++) { - const char = decodeIgnore[i] - - // check if the part includes the current ignore character - // if it doesn't continue to the next ignore character - if (char && part.includes(char)) { - // split the part into pieces that needs to be checked and decoded - const partsToDecode = part.split(char) - const partsToJoin: Array = [] - - // now check and decode each piece individually taking into consideration the remaining ignored characters. - // since we are walking through the list linearly, we only need to consider ignore items not yet traversed. - for (const partToDecode of partsToDecode) { - // once we have traversed the entire ignore list, each decoded part is returned. - partsToJoin.push(decodePathSegment(partToDecode, decodeIgnore, i + 1)) - } + if (part === '' || !/%[0-9A-Fa-f]{2}/g.test(part)) return part - // and join them back together to form the final decoded path segment with the ignored character in place. - return partsToJoin.join(char) - } - } + // ensure all encoded characters are uppercase + const normalizedPart = part.replaceAll(/%[0-9a-f]{2}/g, (match) => + match.toUpperCase(), + ) - // once we have reached the end of the ignore list, we start walking back returning each decoded part. - // should there be no matching characters, the path segment as a whole will be decoded. - return decode(part) + return splitAndDecode(normalizedPart, decodeIgnore) } diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index a98f6d5f433..20eefe3d6cc 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -832,14 +832,6 @@ describe('parsePathname', () => { { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, - { - name: 'should preserve backslashes and percentage signs in path parameters', - to: '/%25%D1%88%D0%B5%5C%D0%BB%D0%BB%D1%8B', - expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_PATHNAME, value: '%25ше%5Cллы' }, - ], - }, ] satisfies ParsePathnameTestScheme)('$name', ({ to, expected }) => { const result = parsePathname(to) expect(result).toEqual(expected) diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index d00a17b4d52..4dec592a57c 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { - decodePathSegment, + decodePath, deepEqual, isPlainArray, replaceEqualDeep, @@ -498,14 +498,14 @@ describe('deepEqual', () => { }) }) -describe('decodePathSegment', () => { +describe('decodePath', () => { it('should decode a path segment with no ignored items existing', () => { const itemsToCheck = ['%25', '%5C'] const stringToCheck = 'https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B' const expectedResult = 'https://mozilla.org/?x=шеллы' - const result = decodePathSegment(stringToCheck, itemsToCheck) + const result = decodePath(stringToCheck, itemsToCheck) expect(result).toBe(expectedResult) }) @@ -516,7 +516,7 @@ describe('decodePathSegment', () => { 'https://mozilla.org/?x=%25%D1%88%D0%B5%5C%D0%BB%D0%BB%D1%8B' const expectedResult = 'https://mozilla.org/?x=%25ше\\ллы' - const result = decodePathSegment(stringToCheck, itemsToCheck) + const result = decodePath(stringToCheck, itemsToCheck) expect(result).toBe(expectedResult) }) @@ -527,14 +527,14 @@ describe('decodePathSegment', () => { 'https://mozilla.org/?x=%25%D1%88%D0%B5%D0%BB%D0%BB%D1%8B' let expectedResult = 'https://mozilla.org/?x=%25шеллы' - let result = decodePathSegment(stringToCheck, itemsToCheck) + let result = decodePath(stringToCheck, itemsToCheck) expect(result).toBe(expectedResult) stringToCheck = 'https://mozilla.org/?x=%D1%88%D0%B5%5C%D0%BB%D0%BB%D1%8B' expectedResult = 'https://mozilla.org/?x=ше%5Cллы' - result = decodePathSegment(stringToCheck, itemsToCheck) + result = decodePath(stringToCheck, itemsToCheck) expect(result).toBe(expectedResult) }) @@ -545,14 +545,14 @@ describe('decodePathSegment', () => { 'https://mozilla.org/?x=%5C%D1%88%D0%B5%D0%BB%D0%BB%D1%8B' let expectedResult = 'https://mozilla.org/?x=%5Cшеллы' - let result = decodePathSegment(stringToCheck, itemsToCheck) + let result = decodePath(stringToCheck, itemsToCheck) expect(result).toBe(expectedResult) stringToCheck = 'https://mozilla.org/?x=%D1%88%D0%B5%5C%D0%BB%D0%BB%D1%8B' expectedResult = 'https://mozilla.org/?x=ше%5Cллы' - result = decodePathSegment(stringToCheck, itemsToCheck) + result = decodePath(stringToCheck, itemsToCheck) expect(result).toBe(expectedResult) }) @@ -563,7 +563,7 @@ describe('decodePathSegment', () => { 'https://mozilla.org/?x=%25%D1%88%D0%B5%5C%D0%BB%D0%BB%D1%8B' const expectedResult = 'https://mozilla.org/?x=%25ше%5Cллы' - const result = decodePathSegment(stringToCheck, itemsToCheck) + const result = decodePath(stringToCheck, itemsToCheck) expect(result).toBe(expectedResult) }) @@ -573,7 +573,7 @@ describe('decodePathSegment', () => { 'https://mozilla.org/?x=%25%D1%88%D0%B5%5C%D0%BB%D0%BB%D1%8B%2F' const expectedResult = 'https://mozilla.org/?x=%25ше%5Cллы%2F' - const result = decodePathSegment(stringToCheck) + const result = decodePath(stringToCheck) expect(result).toBe(expectedResult) }) @@ -581,22 +581,35 @@ describe('decodePathSegment', () => { it('should handle malformed percent-encodings gracefully', () => { const stringToCheck = 'path%ZZ%D1%88test%5C%C3%A9' // Malformed sequences should remain as-is, valid ones decoded - const result = decodePathSegment(stringToCheck) + const result = decodePath(stringToCheck) expect(result).toBe(`path%ZZ%D1%88test%5Cé`) }) it('should return empty string unchanged', () => { - expect(decodePathSegment('')).toBe('') + expect(decodePath('')).toBe('') }) it('should return strings without encoding unchanged', () => { const stringToCheck = 'plain-text-path' - expect(decodePathSegment(stringToCheck)).toBe(stringToCheck) + expect(decodePath(stringToCheck)).toBe(stringToCheck) }) it('should handle consecutive ignored characters', () => { const stringToCheck = 'test%25%25end' const expectedResult = 'test%25%25end' - expect(decodePathSegment(stringToCheck)).toBe(expectedResult) + expect(decodePath(stringToCheck)).toBe(expectedResult) + }) + + it('should handle multiple ignored items of the same type with varying case', () => { + const stringToCheck = '/params-ps/named/foo%2Fabc/c%2Fh' + const expectedResult = '/params-ps/named/foo%2Fabc/c%2Fh' + expect(decodePath(stringToCheck)).toBe(expectedResult) + + const stringToCheckWithLowerCase = '/params-ps/named/foo%2Fabc/c%5C%2f%5cAh' + const expectedResultWithLowerCase = + '/params-ps/named/foo%2Fabc/c%5C%2F%5CAh' + expect(decodePath(stringToCheckWithLowerCase)).toBe( + expectedResultWithLowerCase, + ) }) }) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 8d6efa6812c..8d9f36374d8 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -6494,31 +6494,31 @@ describe('encoded and unicode paths', () => { const testCases = [ { name: 'with prefix', - path: '/foo/prefix대{$}', - browsePath: '/foo/prefix대test[s%5C/.%5C/parameter%25!🚀]', + path: '/foo/prefix@대{$}', expectedPath: - '/foo/prefix%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + '/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]', + expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]', params: { - _splat: 'test[s\\/.\\/parameter%!🚀]', - '*': 'test[s\\/.\\/parameter%!🚀]', + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', }, }, { name: 'with suffix', - path: '/foo/{$}대suffix', - browsePath: '/foo/test[s%5C/.%5C/parameter%25!🚀]대suffix', + path: '/foo/{$}대suffix@', expectedPath: - '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]%EB%8C%80suffix', + '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@', params: { - _splat: 'test[s\\/.\\/parameter%!🚀]', - '*': 'test[s\\/.\\/parameter%!🚀]', + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', }, }, { name: 'with wildcard', path: '/foo/$', - browsePath: '/foo/test[s%5C/.%5C/parameter%25!🚀]', expectedPath: '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀]', params: { _splat: 'test[s\\/.\\/parameter%!🚀]', '*': 'test[s\\/.\\/parameter%!🚀]', @@ -6528,8 +6528,8 @@ describe('encoded and unicode paths', () => { { name: 'with path param', path: `/foo/$id`, - browsePath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', params: { id: 'test[s\\/.\\/parameter%!🚀]', }, @@ -6538,16 +6538,7 @@ describe('encoded and unicode paths', () => { test.each(testCases)( 'should handle encoded, decoded paths with unicode characters correctly - $name', - async ({ path, browsePath, expectedPath, params }) => { - async function validate() { - const paramsToValidate = await screen.findByTestId('params-to-validate') - - await waitFor(() => expect(window.location.pathname).toBe(expectedPath)) - await waitFor(() => - expect(paramsToValidate.textContent).toEqual(JSON.stringify(params)), - ) - } - + async ({ path, expectedPath, expectedLocation, params }) => { const rootRoute = createRootRoute() const indexRoute = createRoute({ @@ -6593,19 +6584,18 @@ describe('encoded and unicode paths', () => { render(() => ) - history.push(browsePath) - - await validate() - - history.push('/') - const link = await screen.findByTestId('link-to-path') expect(link.getAttribute('href')).toBe(expectedPath) fireEvent.click(link) - await validate() + const paramsToValidate = await screen.findByTestId('params-to-validate') + + expect(window.location.pathname).toBe(expectedPath) + expect(router.latestLocation.pathname).toBe(expectedLocation) + + expect(paramsToValidate.textContent).toEqual(JSON.stringify(params)) }, ) }) diff --git a/packages/solid-router/tests/navigate.test.tsx b/packages/solid-router/tests/navigate.test.tsx index 7fa8fa26d0f..8c5ac5ade46 100644 --- a/packages/solid-router/tests/navigate.test.tsx +++ b/packages/solid-router/tests/navigate.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, test, vi } from 'vitest' import { trailingSlashOptions } from '@tanstack/router-core' import { waitFor } from '@solidjs/testing-library' @@ -1263,3 +1263,79 @@ describe('router.navigate navigation using optional path parameters - edge cases expect(router.state.location.pathname).toBe('/files/prefix.txt') }) }) + +describe('encoded and unicode paths', () => { + const testCases = [ + { + name: 'with prefix', + path: '/foo/prefix@대{$}', + expectedPath: + '/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]', + expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with suffix', + path: '/foo/{$}대suffix@', + expectedPath: + '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with wildcard', + path: '/foo/$', + expectedPath: '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀]', + '*': 'test[s\\/.\\/parameter%!🚀]', + }, + }, + // '/' is left as is with splat params but encoded with normal params + { + name: 'with path param', + path: `/foo/$id`, + expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', + params: { + id: 'test[s\\/.\\/parameter%!🚀]', + }, + }, + ] + + test.each(testCases)( + 'should handle encoded, decoded paths with unicode characters correctly - $name', + async ({ path, expectedPath, expectedLocation, params }) => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const pathRoute = createRoute({ + getParentRoute: () => rootRoute, + path, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pathRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: path, params }) + await router.invalidate() + + expect(router.state.location.href).toBe(expectedPath) + expect(router.state.location.pathname).toBe(expectedLocation) + }, + ) +}) diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index fdfcd91e4f1..aa30b2a000a 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -334,7 +334,7 @@ describe('encoding: URL param segment for /posts/$slug', () => { await router.load() - expect(router.state.location.pathname).toBe('/posts/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/posts/🚀') }) it('state.location.pathname, should have the params.slug value of "100%25"', async () => { @@ -364,7 +364,7 @@ describe('encoding: URL param segment for /posts/$slug', () => { await router.load() - expect(router.state.location.pathname).toBe('/posts/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/posts/🚀') }) it('state.location.pathname, should have the params.slug value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { @@ -378,9 +378,13 @@ describe('encoding: URL param segment for /posts/$slug', () => { await router.load() - expect(router.state.location.pathname).toBe( + expect(router.state.location.href).toBe( '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ) + + expect(router.state.location.pathname).toBe( + '/posts/framework%2Freact%2Fguide%2Ffile-based-routing tanstack', + ) }) it('params.slug for the matched route, should be "tanner"', async () => { @@ -579,7 +583,8 @@ describe('encoding: URL splat segment for /$', () => { await router.load() - expect(router.state.location.pathname).toBe('/%F0%9F%9A%80') + expect(router.state.location.href).toBe('/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/🚀') }) it('state.location.pathname, should have the params._splat value of "100%25"', async () => { @@ -609,7 +614,8 @@ describe('encoding: URL splat segment for /$', () => { await router.load() - expect(router.state.location.pathname).toBe('/%F0%9F%9A%80') + expect(router.state.location.href).toBe('/%F0%9F%9A%80') + expect(router.state.location.pathname).toBe('/🚀') }) it('state.location.pathname, should have the params._splat value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { @@ -623,9 +629,12 @@ describe('encoding: URL splat segment for /$', () => { await router.load() - expect(router.state.location.pathname).toBe( + expect(router.state.location.href).toBe( '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ) + expect(router.state.location.pathname).toBe( + '/framework%2Freact%2Fguide%2Ffile-based-routing tanstack', + ) }) it('state.location.pathname, should have the params._splat value of "framework/react/guide/file-based-routing tanstack"', async () => { @@ -637,9 +646,13 @@ describe('encoding: URL splat segment for /$', () => { await router.load() - expect(router.state.location.pathname).toBe( + expect(router.state.location.href).toBe( '/framework/react/guide/file-based-routing%20tanstack', ) + + expect(router.state.location.pathname).toBe( + '/framework/react/guide/file-based-routing tanstack', + ) }) it('params._splat for the matched route, should be "tanner"', async () => { @@ -723,55 +736,66 @@ describe('encoding: URL path segment', () => { it.each([ { input: '/path-segment/%C3%A9', - output: '/path-segment/%C3%A9', + path: '/path-segment/é', + url: '/path-segment/%C3%A9', }, { input: '/path-segment/é', - output: '/path-segment/%C3%A9', + path: '/path-segment/é', + url: '/path-segment/%C3%A9', }, { input: '/path-segment/100%25', // `%25` = `%` - output: '/path-segment/100%25', + path: '/path-segment/100%25', + url: '/path-segment/100%25', }, { input: '/path-segment/100%25%25', - output: '/path-segment/100%25%25', + path: '/path-segment/100%25%25', + url: '/path-segment/100%25%25', }, { input: '/path-segment/100%26', // `%26` = `&` - output: '/path-segment/100%26', + path: '/path-segment/100%26', + url: '/path-segment/100%26', }, { input: '/path-segment/%F0%9F%9A%80', - output: '/path-segment/%F0%9F%9A%80', + path: '/path-segment/🚀', + url: '/path-segment/%F0%9F%9A%80', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', + path: '/path-segment/🚀to%2Fthe%2Fmoon', + url: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', + path: '/path-segment/%25🚀to%2Fthe%2Fmoon', + url: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', - output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', + path: '/path-segment/🚀to%2Fthe%2Fmoon%25', + url: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: - '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', + path: '/path-segment/🚀to%2Fthe%2Fmoon%25🚀to%2Fthe%2Fmoon', + url: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/🚀', - output: '/path-segment/%F0%9F%9A%80', + path: '/path-segment/🚀', + url: '/path-segment/%F0%9F%9A%80', }, { input: '/path-segment/🚀to%2Fthe%2Fmoon', - output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', + path: '/path-segment/🚀to%2Fthe%2Fmoon', + url: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', }, - ])('should resolve $input to $output', async ({ input, output }) => { + ])('should resolve $input to $output', async ({ input, path, url }) => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: [input] }), }) @@ -779,7 +803,8 @@ describe('encoding: URL path segment', () => { render(() => ) await router.load() - expect(router.state.location.pathname).toBe(output) + expect(router.state.location.pathname).toBe(path) + expect(new URL(router.state.location.url).pathname).toBe(url) }) }) diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index 6acbf30f2b0..c660f6d64f8 100644 --- a/packages/solid-router/tests/useNavigate.test.tsx +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -2641,3 +2641,119 @@ describe('splat routes with empty splat', () => { }, ) }) + +describe('encoded and unicode paths', () => { + const testCases = [ + { + name: 'with prefix', + path: '/foo/prefix@대{$}', + expectedPath: + '/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]', + expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with suffix', + path: '/foo/{$}대suffix@', + expectedPath: + '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀@]', + '*': 'test[s\\/.\\/parameter%!🚀@]', + }, + }, + { + name: 'with wildcard', + path: '/foo/$', + expectedPath: '/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀]', + params: { + _splat: 'test[s\\/.\\/parameter%!🚀]', + '*': 'test[s\\/.\\/parameter%!🚀]', + }, + }, + // '/' is left as is with splat params but encoded with normal params + { + name: 'with path param', + path: `/foo/$id`, + expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]', + expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]', + params: { + id: 'test[s\\/.\\/parameter%!🚀]', + }, + }, + ] + + test.each(testCases)( + 'should handle encoded, decoded paths with unicode characters correctly - $name', + async ({ path, expectedPath, expectedLocation, params }) => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + function IndexComponent() { + const navigate = useNavigate() + + return ( + <> +

    Index Route

    + + + ) + } + + const pathRoute = createRoute({ + getParentRoute: () => rootRoute, + path, + component: PathRouteComponent, + }) + + function PathRouteComponent() { + const params = pathRoute.useParams() + return ( +
    +

    Path Route

    +

    + params:{' '} + + {JSON.stringify(params())} + +

    +
    + ) + } + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pathRoute]), + history, + }) + + render(() => ) + + const link = await screen.findByTestId('btn-to-path') + + fireEvent.click(link) + + await waitFor(async () => { + const paramsToValidate = await screen.findByTestId('params-to-validate') + + expect(window.location.pathname).toBe(expectedPath) + expect(router.latestLocation.pathname).toBe(expectedLocation) + expect(paramsToValidate.textContent).toEqual(JSON.stringify(params)) + }) + }, + ) +}) diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 8c22633b374..97c7b2a5dd3 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -178,10 +178,9 @@ export async function prerender({ const retries = retriesByPath.get(page.path) || 0 try { // Fetch the route - const encodedRoute = encodeURI(page.path) const res = await localFetch( - withBase(encodedRoute, routerBasePath), + withBase(page.path, routerBasePath), { headers: { ...(prerenderOptions.headers ?? {}),