From 66b0d5ee62cc1e79c500d11194f5cfe8ac51fb3e Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Mon, 16 Jun 2025 19:25:34 +0200 Subject: [PATCH 1/2] fix: if validate search rewrites search params, issue a server-side redirect before, this was handled client-side also added e2e tests for redirection in start and regression test for #3578 --- .../basic-file-based/src/routeTree.gen.ts | 70 ++++++ .../src/routes/search-params/default.tsx | 28 +++ .../src/routes/search-params/index.tsx | 19 ++ .../src/routes/search-params/route.tsx | 8 + .../tests/search-params.spec.ts | 41 +++ e2e/react-start/basic/src/routeTree.gen.ts | 233 ++++++++++++++---- .../src/routes/search-params/default.tsx | 27 ++ .../basic/src/routes/search-params/index.tsx | 19 ++ .../loader-throws-redirect.tsx} | 21 +- .../basic/src/routes/search-params/route.tsx | 6 + .../basic/tests/search-params.spec.ts | 85 +++++-- .../basic-file-based/src/routeTree.gen.ts | 70 ++++++ .../src/routes/search-params/default.tsx | 28 +++ .../src/routes/search-params/index.tsx | 19 ++ .../src/routes/search-params/route.tsx | 8 + .../tests/search-params.spec.ts | 41 +++ e2e/solid-start/basic/src/routeTree.gen.ts | 113 +++++++-- .../src/routes/search-params/default.tsx | 28 +++ .../basic/src/routes/search-params/index.tsx | 19 ++ .../loader-throws-redirect.tsx} | 23 +- .../basic/src/routes/search-params/route.tsx | 7 + .../basic/tests/search-params.spec.ts | 85 +++++-- packages/router-core/src/redirect.ts | 3 + packages/router-core/src/router.ts | 16 +- 24 files changed, 899 insertions(+), 118 deletions(-) create mode 100644 e2e/react-router/basic-file-based/src/routes/search-params/default.tsx create mode 100644 e2e/react-router/basic-file-based/src/routes/search-params/index.tsx create mode 100644 e2e/react-router/basic-file-based/src/routes/search-params/route.tsx create mode 100644 e2e/react-router/basic-file-based/tests/search-params.spec.ts create mode 100644 e2e/react-start/basic/src/routes/search-params/default.tsx create mode 100644 e2e/react-start/basic/src/routes/search-params/index.tsx rename e2e/react-start/basic/src/routes/{search-params.tsx => search-params/loader-throws-redirect.tsx} (90%) create mode 100644 e2e/react-start/basic/src/routes/search-params/route.tsx create mode 100644 e2e/solid-router/basic-file-based/src/routes/search-params/default.tsx create mode 100644 e2e/solid-router/basic-file-based/src/routes/search-params/index.tsx create mode 100644 e2e/solid-router/basic-file-based/src/routes/search-params/route.tsx create mode 100644 e2e/solid-router/basic-file-based/tests/search-params.spec.ts create mode 100644 e2e/solid-start/basic/src/routes/search-params/default.tsx create mode 100644 e2e/solid-start/basic/src/routes/search-params/index.tsx rename e2e/solid-start/basic/src/routes/{search-params.tsx => search-params/loader-throws-redirect.tsx} (81%) create mode 100644 e2e/solid-start/basic/src/routes/search-params/route.tsx 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 4844229cd3a..4efc03e2827 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -16,11 +16,14 @@ import { Route as EditingBRouteImport } from './routes/editing-b' import { Route as EditingARouteImport } from './routes/editing-a' import { Route as AnchorRouteImport } from './routes/anchor' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' import { Route as RedirectIndexRouteImport } from './routes/redirect/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as ParamsPsIndexRouteImport } from './routes/params-ps/index' import { Route as StructuralSharingEnabledRouteImport } from './routes/structural-sharing.$enabled' +import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' @@ -79,11 +82,21 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ + id: '/search-params', + path: '/search-params', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectIndexRoute = RedirectIndexRouteImport.update({ id: '/redirect/', path: '/redirect/', @@ -105,6 +118,11 @@ const StructuralSharingEnabledRoute = path: '/structural-sharing/$enabled', getParentRoute: () => rootRouteImport, } as any) +const SearchParamsDefaultRoute = SearchParamsDefaultRouteImport.update({ + id: '/default', + path: '/default', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectTargetRoute = RedirectTargetRouteImport.update({ id: '/redirect/$target', path: '/redirect/$target', @@ -249,6 +267,7 @@ const groupLayoutInsidelayoutRoute = groupLayoutInsidelayoutRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof groupLayoutRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren '/anchor': typeof AnchorRoute '/editing-a': typeof EditingARoute '/editing-b': typeof EditingBRoute @@ -258,10 +277,12 @@ export interface FileRoutesByFullPath { '/lazyinside': typeof groupLazyinsideRoute '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute '/structural-sharing/$enabled': typeof StructuralSharingEnabledRoute '/params-ps': typeof ParamsPsIndexRoute '/posts/': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/insidelayout': typeof groupLayoutInsidelayoutRoute '/subfolder/inside': typeof groupSubfolderInsideRoute '/layout-a': typeof LayoutLayout2LayoutARoute @@ -292,10 +313,12 @@ export interface FileRoutesByTo { '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute '/posts/$postId': typeof PostsPostIdRoute + '/search-params/default': typeof SearchParamsDefaultRoute '/structural-sharing/$enabled': typeof StructuralSharingEnabledRoute '/params-ps': typeof ParamsPsIndexRoute '/posts': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params': typeof SearchParamsIndexRoute '/insidelayout': typeof groupLayoutInsidelayoutRoute '/subfolder/inside': typeof groupSubfolderInsideRoute '/layout-a': typeof LayoutLayout2LayoutARoute @@ -320,6 +343,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/search-params': typeof SearchParamsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/anchor': typeof AnchorRoute '/editing-a': typeof EditingARoute @@ -333,10 +357,12 @@ export interface FileRoutesById { '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute '/structural-sharing/$enabled': typeof StructuralSharingEnabledRoute '/params-ps/': typeof ParamsPsIndexRoute '/posts/': typeof PostsIndexRoute '/redirect/': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/(group)/_layout/insidelayout': typeof groupLayoutInsidelayoutRoute '/(group)/subfolder/inside': typeof groupSubfolderInsideRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute @@ -362,6 +388,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/search-params' | '/anchor' | '/editing-a' | '/editing-b' @@ -371,10 +398,12 @@ export interface FileRouteTypes { | '/lazyinside' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' | '/structural-sharing/$enabled' | '/params-ps' | '/posts/' | '/redirect' + | '/search-params/' | '/insidelayout' | '/subfolder/inside' | '/layout-a' @@ -405,10 +434,12 @@ export interface FileRouteTypes { | '/inside' | '/lazyinside' | '/posts/$postId' + | '/search-params/default' | '/structural-sharing/$enabled' | '/params-ps' | '/posts' | '/redirect' + | '/search-params' | '/insidelayout' | '/subfolder/inside' | '/layout-a' @@ -432,6 +463,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/search-params' | '/_layout' | '/anchor' | '/editing-a' @@ -445,10 +477,12 @@ export interface FileRouteTypes { | '/_layout/_layout-2' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' | '/structural-sharing/$enabled' | '/params-ps/' | '/posts/' | '/redirect/' + | '/search-params/' | '/(group)/_layout/insidelayout' | '/(group)/subfolder/inside' | '/_layout/_layout-2/layout-a' @@ -473,6 +507,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren AnchorRoute: typeof AnchorRoute EditingARoute: typeof EditingARoute @@ -543,6 +578,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/search-params': { + id: '/search-params' + path: '/search-params' + fullPath: '/search-params' + preLoaderRoute: typeof SearchParamsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -550,6 +592,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/search-params/': { + id: '/search-params/' + path: '/' + fullPath: '/search-params/' + preLoaderRoute: typeof SearchParamsIndexRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/redirect/': { id: '/redirect/' path: '/redirect' @@ -578,6 +627,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StructuralSharingEnabledRouteImport parentRoute: typeof rootRouteImport } + '/search-params/default': { + id: '/search-params/default' + path: '/default' + fullPath: '/search-params/default' + preLoaderRoute: typeof SearchParamsDefaultRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/redirect/$target': { id: '/redirect/$target' path: '/redirect/$target' @@ -770,6 +826,19 @@ declare module '@tanstack/react-router' { } } +interface SearchParamsRouteRouteChildren { + SearchParamsDefaultRoute: typeof SearchParamsDefaultRoute + SearchParamsIndexRoute: typeof SearchParamsIndexRoute +} + +const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { + SearchParamsDefaultRoute: SearchParamsDefaultRoute, + SearchParamsIndexRoute: SearchParamsIndexRoute, +} + +const SearchParamsRouteRouteWithChildren = + SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -853,6 +922,7 @@ const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, AnchorRoute: AnchorRoute, EditingARoute: EditingARoute, diff --git a/e2e/react-router/basic-file-based/src/routes/search-params/default.tsx b/e2e/react-router/basic-file-based/src/routes/search-params/default.tsx new file mode 100644 index 00000000000..19e1149b8d2 --- /dev/null +++ b/e2e/react-router/basic-file-based/src/routes/search-params/default.tsx @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' + +export const Route = createFileRoute('/search-params/default')({ + validateSearch: z.object({ + default: z.string().default('d1'), + }), + beforeLoad: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + loader: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + component: () => { + const search = Route.useSearch() + const context = Route.useRouteContext() + return ( + <> +
{search.default}
+
{context.hello}
+ + ) + }, +}) 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 new file mode 100644 index 00000000000..b406763706a --- /dev/null +++ b/e2e/react-router/basic-file-based/src/routes/search-params/index.tsx @@ -0,0 +1,19 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/search-params/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ + go to /search-params/default + +
+ + go to /search-params/default?default=d2 + +
+ ) +} 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 new file mode 100644 index 00000000000..5adb281c41b --- /dev/null +++ b/e2e/react-router/basic-file-based/src/routes/search-params/route.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/search-params')({ + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return { hello: 'world' as string } + }, +}) 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 new file mode 100644 index 00000000000..37048e1c54c --- /dev/null +++ b/e2e/react-router/basic-file-based/tests/search-params.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' + +test.describe('/search-params/default', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + await page.goto('/search-params/default') + + await expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + await page.goto('/search-params/default/?default=d2') + + await expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).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 expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + 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 expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + }) +}) diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 262b52bfde0..1eaa3c34e22 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -19,19 +19,22 @@ import { createServerRootRoute } from '@tanstack/react-start/server' import { Route as rootRouteImport } from './routes/__root' import { Route as UsersRouteImport } from './routes/users' import { Route as StreamRouteImport } from './routes/stream' -import { Route as SearchParamsRouteImport } from './routes/search-params' import { Route as ScriptsRouteImport } from './routes/scripts' import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' import { Route as IndexRouteImport } from './routes/index' import { Route as UsersIndexRouteImport } from './routes/users.index' +import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' import { Route as RedirectIndexRouteImport } from './routes/redirect/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' +import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' @@ -65,11 +68,6 @@ const StreamRoute = StreamRouteImport.update({ path: '/stream', getParentRoute: () => rootRouteImport, } as any) -const SearchParamsRoute = SearchParamsRouteImport.update({ - id: '/search-params', - path: '/search-params', - getParentRoute: () => rootRouteImport, -} as any) const ScriptsRoute = ScriptsRouteImport.update({ id: '/scripts', path: '/scripts', @@ -94,6 +92,11 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ + id: '/search-params', + path: '/search-params', + getParentRoute: () => rootRouteImport, +} as any) const NotFoundRouteRoute = NotFoundRouteRouteImport.update({ id: '/not-found', path: '/not-found', @@ -109,6 +112,11 @@ const UsersIndexRoute = UsersIndexRouteImport.update({ path: '/', getParentRoute: () => UsersRoute, } as any) +const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectIndexRoute = RedirectIndexRouteImport.update({ id: '/redirect/', path: '/redirect/', @@ -129,6 +137,17 @@ const UsersUserIdRoute = UsersUserIdRouteImport.update({ path: '/$userId', getParentRoute: () => UsersRoute, } as any) +const SearchParamsLoaderThrowsRedirectRoute = + SearchParamsLoaderThrowsRedirectRouteImport.update({ + id: '/loader-throws-redirect', + path: '/loader-throws-redirect', + getParentRoute: () => SearchParamsRouteRoute, + } as any) +const SearchParamsDefaultRoute = SearchParamsDefaultRouteImport.update({ + id: '/default', + path: '/default', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectTargetRoute = RedirectTargetRouteImport.update({ id: '/redirect/$target', path: '/redirect/$target', @@ -236,21 +255,24 @@ const ApiUsersIdServerRoute = ApiUsersIdServerRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren '/deferred': typeof DeferredRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute - '/search-params': typeof SearchParamsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute @@ -270,15 +292,17 @@ export interface FileRoutesByTo { '/deferred': typeof DeferredRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute - '/search-params': typeof SearchParamsRoute '/stream': typeof StreamRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/not-found': typeof NotFoundIndexRoute '/posts': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params': typeof SearchParamsIndexRoute '/users': typeof UsersIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute @@ -296,12 +320,12 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/deferred': typeof DeferredRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute - '/search-params': typeof SearchParamsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren @@ -309,10 +333,13 @@ export interface FileRoutesById { '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute '/redirect/': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute @@ -333,21 +360,24 @@ export interface FileRouteTypes { fullPaths: | '/' | '/not-found' + | '/search-params' | '/deferred' | '/links' | '/posts' | '/scripts' - | '/search-params' | '/stream' | '/users' | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' + | '/search-params/loader-throws-redirect' | '/users/$userId' | '/not-found/' | '/posts/' | '/redirect' + | '/search-params/' | '/users/' | '/layout-a' | '/layout-b' @@ -367,15 +397,17 @@ export interface FileRouteTypes { | '/deferred' | '/links' | '/scripts' - | '/search-params' | '/stream' | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/search-params/default' + | '/search-params/loader-throws-redirect' | '/users/$userId' | '/not-found' | '/posts' | '/redirect' + | '/search-params' | '/users' | '/layout-a' | '/layout-b' @@ -392,12 +424,12 @@ export interface FileRouteTypes { | '__root__' | '/' | '/not-found' + | '/search-params' | '/_layout' | '/deferred' | '/links' | '/posts' | '/scripts' - | '/search-params' | '/stream' | '/users' | '/_layout/_layout-2' @@ -405,10 +437,13 @@ export interface FileRouteTypes { | '/not-found/via-loader' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' + | '/search-params/loader-throws-redirect' | '/users/$userId' | '/not-found/' | '/posts/' | '/redirect/' + | '/search-params/' | '/users/' | '/_layout/_layout-2/layout-a' | '/_layout/_layout-2/layout-b' @@ -428,12 +463,12 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren + SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren DeferredRoute: typeof DeferredRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren ScriptsRoute: typeof ScriptsRoute - SearchParamsRoute: typeof SearchParamsRoute StreamRoute: typeof StreamRoute UsersRoute: typeof UsersRouteWithChildren RedirectTargetRoute: typeof RedirectTargetRouteWithChildren @@ -482,6 +517,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof NotFoundRouteRouteImport parentRoute: typeof rootRouteImport } + '/search-params': { + id: '/search-params' + path: '/search-params' + fullPath: '/search-params' + preLoaderRoute: typeof SearchParamsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/_layout': { id: '/_layout' path: '' @@ -517,13 +559,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ScriptsRouteImport parentRoute: typeof rootRouteImport } - '/search-params': { - id: '/search-params' - path: '/search-params' - fullPath: '/search-params' - preLoaderRoute: typeof SearchParamsRouteImport - parentRoute: typeof rootRouteImport - } '/stream': { id: '/stream' path: '/stream' @@ -580,6 +615,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RedirectTargetRouteImport parentRoute: typeof rootRouteImport } + '/search-params/default': { + id: '/search-params/default' + path: '/default' + fullPath: '/search-params/default' + preLoaderRoute: typeof SearchParamsDefaultRouteImport + parentRoute: typeof SearchParamsRouteRoute + } + '/search-params/loader-throws-redirect': { + id: '/search-params/loader-throws-redirect' + path: '/loader-throws-redirect' + fullPath: '/search-params/loader-throws-redirect' + preLoaderRoute: typeof SearchParamsLoaderThrowsRedirectRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/users/$userId': { id: '/users/$userId' path: '/$userId' @@ -608,6 +657,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RedirectIndexRouteImport parentRoute: typeof rootRouteImport } + '/search-params/': { + id: '/search-params/' + path: '/' + fullPath: '/search-params/' + preLoaderRoute: typeof SearchParamsIndexRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/users/': { id: '/users/' path: '/' @@ -731,6 +787,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: unknown parentRoute: typeof rootServerRouteImport } + '/search-params': { + id: '/search-params' + path: '/search-params' + fullPath: '/search-params' + preLoaderRoute: unknown + parentRoute: typeof rootServerRouteImport + } '/_layout': { id: '/_layout' path: '' @@ -766,13 +829,6 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: unknown parentRoute: typeof rootServerRouteImport } - '/search-params': { - id: '/search-params' - path: '/search-params' - fullPath: '/search-params' - preLoaderRoute: unknown - parentRoute: typeof rootServerRouteImport - } '/stream': { id: '/stream' path: '/stream' @@ -829,6 +885,20 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: unknown parentRoute: typeof rootServerRouteImport } + '/search-params/default': { + id: '/search-params/default' + path: '/default' + fullPath: '/search-params/default' + preLoaderRoute: unknown + parentRoute: typeof rootServerRouteImport + } + '/search-params/loader-throws-redirect': { + id: '/search-params/loader-throws-redirect' + path: '/loader-throws-redirect' + fullPath: '/search-params/loader-throws-redirect' + preLoaderRoute: unknown + parentRoute: typeof rootServerRouteImport + } '/users/$userId': { id: '/users/$userId' path: '/$userId' @@ -857,6 +927,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: unknown parentRoute: typeof rootServerRouteImport } + '/search-params/': { + id: '/search-params/' + path: '/' + fullPath: '/search-params/' + preLoaderRoute: unknown + parentRoute: typeof rootServerRouteImport + } '/users/': { id: '/users/' path: '/' @@ -992,6 +1069,23 @@ declare module './routes/not-found/route' { unknown > } +declare module './routes/search-params/route' { + const createFileRoute: CreateFileRoute< + '/search-params', + FileRoutesByPath['/search-params']['parentRoute'], + FileRoutesByPath['/search-params']['id'], + FileRoutesByPath['/search-params']['path'], + FileRoutesByPath['/search-params']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/search-params']['parentRoute'], + ServerFileRoutesByPath['/search-params']['id'], + ServerFileRoutesByPath['/search-params']['path'], + ServerFileRoutesByPath['/search-params']['fullPath'], + unknown + > +} declare module './routes/_layout' { const createFileRoute: CreateFileRoute< '/_layout', @@ -1077,23 +1171,6 @@ declare module './routes/scripts' { unknown > } -declare module './routes/search-params' { - const createFileRoute: CreateFileRoute< - '/search-params', - FileRoutesByPath['/search-params']['parentRoute'], - FileRoutesByPath['/search-params']['id'], - FileRoutesByPath['/search-params']['path'], - FileRoutesByPath['/search-params']['fullPath'] - > - - const createServerFileRoute: CreateServerFileRoute< - ServerFileRoutesByPath['/search-params']['parentRoute'], - ServerFileRoutesByPath['/search-params']['id'], - ServerFileRoutesByPath['/search-params']['path'], - ServerFileRoutesByPath['/search-params']['fullPath'], - unknown - > -} declare module './routes/stream' { const createFileRoute: CreateFileRoute< '/stream', @@ -1230,6 +1307,40 @@ declare module './routes/redirect/$target' { unknown > } +declare module './routes/search-params/default' { + const createFileRoute: CreateFileRoute< + '/search-params/default', + FileRoutesByPath['/search-params/default']['parentRoute'], + FileRoutesByPath['/search-params/default']['id'], + FileRoutesByPath['/search-params/default']['path'], + FileRoutesByPath['/search-params/default']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/search-params/default']['parentRoute'], + ServerFileRoutesByPath['/search-params/default']['id'], + ServerFileRoutesByPath['/search-params/default']['path'], + ServerFileRoutesByPath['/search-params/default']['fullPath'], + unknown + > +} +declare module './routes/search-params/loader-throws-redirect' { + const createFileRoute: CreateFileRoute< + '/search-params/loader-throws-redirect', + FileRoutesByPath['/search-params/loader-throws-redirect']['parentRoute'], + FileRoutesByPath['/search-params/loader-throws-redirect']['id'], + FileRoutesByPath['/search-params/loader-throws-redirect']['path'], + FileRoutesByPath['/search-params/loader-throws-redirect']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/search-params/loader-throws-redirect']['parentRoute'], + ServerFileRoutesByPath['/search-params/loader-throws-redirect']['id'], + ServerFileRoutesByPath['/search-params/loader-throws-redirect']['path'], + ServerFileRoutesByPath['/search-params/loader-throws-redirect']['fullPath'], + unknown + > +} declare module './routes/users.$userId' { const createFileRoute: CreateFileRoute< '/users/$userId', @@ -1298,6 +1409,23 @@ declare module './routes/redirect/index' { unknown > } +declare module './routes/search-params/index' { + const createFileRoute: CreateFileRoute< + '/search-params/', + FileRoutesByPath['/search-params/']['parentRoute'], + FileRoutesByPath['/search-params/']['id'], + FileRoutesByPath['/search-params/']['path'], + FileRoutesByPath['/search-params/']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/search-params/']['parentRoute'], + ServerFileRoutesByPath['/search-params/']['id'], + ServerFileRoutesByPath['/search-params/']['path'], + ServerFileRoutesByPath['/search-params/']['fullPath'], + unknown + > +} declare module './routes/users.index' { const createFileRoute: CreateFileRoute< '/users/', @@ -1553,6 +1681,21 @@ const NotFoundRouteRouteWithChildren = NotFoundRouteRoute._addFileChildren( NotFoundRouteRouteChildren, ) +interface SearchParamsRouteRouteChildren { + SearchParamsDefaultRoute: typeof SearchParamsDefaultRoute + SearchParamsLoaderThrowsRedirectRoute: typeof SearchParamsLoaderThrowsRedirectRoute + SearchParamsIndexRoute: typeof SearchParamsIndexRoute +} + +const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { + SearchParamsDefaultRoute: SearchParamsDefaultRoute, + SearchParamsLoaderThrowsRedirectRoute: SearchParamsLoaderThrowsRedirectRoute, + SearchParamsIndexRoute: SearchParamsIndexRoute, +} + +const SearchParamsRouteRouteWithChildren = + SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -1667,12 +1810,12 @@ const ApiUsersServerRouteWithChildren = ApiUsersServerRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NotFoundRouteRoute: NotFoundRouteRouteWithChildren, + SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, ScriptsRoute: ScriptsRoute, - SearchParamsRoute: SearchParamsRoute, StreamRoute: StreamRoute, UsersRoute: UsersRouteWithChildren, RedirectTargetRoute: RedirectTargetRouteWithChildren, diff --git a/e2e/react-start/basic/src/routes/search-params/default.tsx b/e2e/react-start/basic/src/routes/search-params/default.tsx new file mode 100644 index 00000000000..ebeb195c74a --- /dev/null +++ b/e2e/react-start/basic/src/routes/search-params/default.tsx @@ -0,0 +1,27 @@ +import { z } from 'zod' + +export const Route = createFileRoute({ + validateSearch: z.object({ + default: z.string().default('d1'), + }), + beforeLoad: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + loader: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + component: () => { + const search = Route.useSearch() + const context = Route.useRouteContext() + return ( + <> +
{search.default}
+
{context.hello}
+ + ) + }, +}) diff --git a/e2e/react-start/basic/src/routes/search-params/index.tsx b/e2e/react-start/basic/src/routes/search-params/index.tsx new file mode 100644 index 00000000000..8d417803add --- /dev/null +++ b/e2e/react-start/basic/src/routes/search-params/index.tsx @@ -0,0 +1,19 @@ +import { Link } from '@tanstack/react-router' + +export const Route = createFileRoute({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ + go to /search-params/default + +
+ + go to /search-params/default?default=d2 + +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/search-params.tsx b/e2e/react-start/basic/src/routes/search-params/loader-throws-redirect.tsx similarity index 90% rename from e2e/react-start/basic/src/routes/search-params.tsx rename to e2e/react-start/basic/src/routes/search-params/loader-throws-redirect.tsx index 5ddddc58f0b..f2baeb4e276 100644 --- a/e2e/react-start/basic/src/routes/search-params.tsx +++ b/e2e/react-start/basic/src/routes/search-params/loader-throws-redirect.tsx @@ -2,15 +2,6 @@ import { redirect } from '@tanstack/react-router' import { z } from 'zod' export const Route = createFileRoute({ - component: () => { - const search = Route.useSearch() - return ( -
-

SearchParams

-
{search.step}
-
- ) - }, validateSearch: z.object({ step: z.enum(['a', 'b', 'c']).optional(), }), @@ -18,10 +9,18 @@ export const Route = createFileRoute({ loader: ({ deps: { step } }) => { if (step === undefined) { throw redirect({ - to: '/search-params', - from: '/search-params', + to: '/search-params/loader-throws-redirect', search: { step: 'a' }, }) } }, + component: () => { + const search = Route.useSearch() + return ( +
+

SearchParams

+
{search.step}
+
+ ) + }, }) diff --git a/e2e/react-start/basic/src/routes/search-params/route.tsx b/e2e/react-start/basic/src/routes/search-params/route.tsx new file mode 100644 index 00000000000..a9d4a9f8bc2 --- /dev/null +++ b/e2e/react-start/basic/src/routes/search-params/route.tsx @@ -0,0 +1,6 @@ +export const Route = createFileRoute({ + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return { hello: 'world' as string } + }, +}) diff --git a/e2e/react-start/basic/tests/search-params.spec.ts b/e2e/react-start/basic/tests/search-params.spec.ts index 7d1ee6d3740..f7f66e99f99 100644 --- a/e2e/react-start/basic/tests/search-params.spec.ts +++ b/e2e/react-start/basic/tests/search-params.spec.ts @@ -1,22 +1,79 @@ import { expect } from '@playwright/test' import { test } from './fixture' +import type { Response } from '@playwright/test'; -test('Directly visiting the search-params route without search param set', async ({ - page, -}) => { - await page.goto('/search-params') +function expectRedirect(response: Response | null, endsWith: string, ) { + expect(response).not.toBeNull() + expect(response!.request().redirectedFrom()).not.toBeNull() + const redirectUrl = response!.request().redirectedFrom()!.redirectedTo()?.url() + expect(redirectUrl).toBeDefined() + expect(redirectUrl!.endsWith(endsWith)) +} - await new Promise((r) => setTimeout(r, 500)) - await expect(page.getByTestId('search-param')).toContainText('a') - expect(page.url().endsWith('/search-params?step=a')) +function expectNoRedirect(response: Response | null) { + expect(response).not.toBeNull() + const request = response!.request(); + expect(request.redirectedFrom()?.redirectedTo() === request).toBeTruthy +} + +test.describe('/search-params/loader-throws-redirect', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/loader-throws-redirect') + expectRedirect(response, '/search-params/loader-throws-redirect?step=a') + await expect(page.getByTestId('search-param')).toContainText('a') + expect(page.url().endsWith('/search-params/loader-throws-redirect?step=a')) + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/loader-throws-redirect?step=b') + expectNoRedirect(response) + await expect(page.getByTestId('search-param')).toContainText('b') + expect(page.url().endsWith('/search-params/loader-throws-redirect?step=b')) + }) }) -test('Directly visiting the search-params route with search param set', async ({ - page, -}) => { - await page.goto('/search-params?step=b') - await new Promise((r) => setTimeout(r, 500)) - await expect(page.getByTestId('search-param')).toContainText('b') - expect(page.url().endsWith('/search-params?step=b')) +test.describe('/search-params/default', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/default') + expectRedirect(response, '/search-params/default?default=d1') + await expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/default/?default=d2') + expectNoRedirect(response) + + await expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).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 expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + 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 expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).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 d6df88ccdf4..2c9b87f4ef9 100644 --- a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts @@ -16,9 +16,12 @@ import { Route as EditingBRouteImport } from './routes/editing-b' import { Route as EditingARouteImport } from './routes/editing-a' import { Route as AnchorRouteImport } from './routes/anchor' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' import { Route as RedirectIndexRouteImport } from './routes/redirect/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' +import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' @@ -69,11 +72,21 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ + id: '/search-params', + path: '/search-params', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectIndexRoute = RedirectIndexRouteImport.update({ id: '/redirect/', path: '/redirect/', @@ -84,6 +97,11 @@ const PostsIndexRoute = PostsIndexRouteImport.update({ path: '/', getParentRoute: () => PostsRoute, } as any) +const SearchParamsDefaultRoute = SearchParamsDefaultRouteImport.update({ + id: '/default', + path: '/default', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectTargetRoute = RedirectTargetRouteImport.update({ id: '/redirect/$target', path: '/redirect/$target', @@ -184,6 +202,7 @@ const groupLayoutInsidelayoutRoute = groupLayoutInsidelayoutRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof groupLayoutRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren '/anchor': typeof AnchorRoute '/editing-a': typeof EditingARoute '/editing-b': typeof EditingBRoute @@ -193,8 +212,10 @@ export interface FileRoutesByFullPath { '/lazyinside': typeof groupLazyinsideRoute '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute '/posts/': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/insidelayout': typeof groupLayoutInsidelayoutRoute '/subfolder/inside': typeof groupSubfolderInsideRoute '/layout-a': typeof LayoutLayout2LayoutARoute @@ -217,8 +238,10 @@ export interface FileRoutesByTo { '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute '/posts/$postId': typeof PostsPostIdRoute + '/search-params/default': typeof SearchParamsDefaultRoute '/posts': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params': typeof SearchParamsIndexRoute '/insidelayout': typeof groupLayoutInsidelayoutRoute '/subfolder/inside': typeof groupSubfolderInsideRoute '/layout-a': typeof LayoutLayout2LayoutARoute @@ -235,6 +258,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/search-params': typeof SearchParamsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/anchor': typeof AnchorRoute '/editing-a': typeof EditingARoute @@ -248,8 +272,10 @@ export interface FileRoutesById { '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute '/posts/': typeof PostsIndexRoute '/redirect/': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/(group)/_layout/insidelayout': typeof groupLayoutInsidelayoutRoute '/(group)/subfolder/inside': typeof groupSubfolderInsideRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute @@ -267,6 +293,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/search-params' | '/anchor' | '/editing-a' | '/editing-b' @@ -276,8 +303,10 @@ export interface FileRouteTypes { | '/lazyinside' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' | '/posts/' | '/redirect' + | '/search-params/' | '/insidelayout' | '/subfolder/inside' | '/layout-a' @@ -300,8 +329,10 @@ export interface FileRouteTypes { | '/inside' | '/lazyinside' | '/posts/$postId' + | '/search-params/default' | '/posts' | '/redirect' + | '/search-params' | '/insidelayout' | '/subfolder/inside' | '/layout-a' @@ -317,6 +348,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/search-params' | '/_layout' | '/anchor' | '/editing-a' @@ -330,8 +362,10 @@ export interface FileRouteTypes { | '/_layout/_layout-2' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' | '/posts/' | '/redirect/' + | '/search-params/' | '/(group)/_layout/insidelayout' | '/(group)/subfolder/inside' | '/_layout/_layout-2/layout-a' @@ -348,6 +382,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren AnchorRoute: typeof AnchorRoute EditingARoute: typeof EditingARoute @@ -408,6 +443,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/search-params': { + id: '/search-params' + path: '/search-params' + fullPath: '/search-params' + preLoaderRoute: typeof SearchParamsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -415,6 +457,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/search-params/': { + id: '/search-params/' + path: '/' + fullPath: '/search-params/' + preLoaderRoute: typeof SearchParamsIndexRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/redirect/': { id: '/redirect/' path: '/redirect' @@ -429,6 +478,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof PostsIndexRouteImport parentRoute: typeof PostsRoute } + '/search-params/default': { + id: '/search-params/default' + path: '/default' + fullPath: '/search-params/default' + preLoaderRoute: typeof SearchParamsDefaultRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/redirect/$target': { id: '/redirect/$target' path: '/redirect/$target' @@ -565,6 +621,19 @@ declare module '@tanstack/solid-router' { } } +interface SearchParamsRouteRouteChildren { + SearchParamsDefaultRoute: typeof SearchParamsDefaultRoute + SearchParamsIndexRoute: typeof SearchParamsIndexRoute +} + +const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { + SearchParamsDefaultRoute: SearchParamsDefaultRoute, + SearchParamsIndexRoute: SearchParamsIndexRoute, +} + +const SearchParamsRouteRouteWithChildren = + SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -648,6 +717,7 @@ const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, AnchorRoute: AnchorRoute, EditingARoute: EditingARoute, diff --git a/e2e/solid-router/basic-file-based/src/routes/search-params/default.tsx b/e2e/solid-router/basic-file-based/src/routes/search-params/default.tsx new file mode 100644 index 00000000000..36879466202 --- /dev/null +++ b/e2e/solid-router/basic-file-based/src/routes/search-params/default.tsx @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { z } from 'zod' + +export const Route = createFileRoute('/search-params/default')({ + validateSearch: z.object({ + default: z.string().default('d1'), + }), + beforeLoad: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + loader: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + component: () => { + const search = Route.useSearch() + const context = Route.useRouteContext() + return ( + <> +
{search().default}
+
{context().hello}
+ + ) + }, +}) 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 new file mode 100644 index 00000000000..ced8975530b --- /dev/null +++ b/e2e/solid-router/basic-file-based/src/routes/search-params/index.tsx @@ -0,0 +1,19 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/search-params/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ + go to /search-params/default + +
+ + go to /search-params/default?default=d2 + +
+ ) +} 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 new file mode 100644 index 00000000000..5d9fc675158 --- /dev/null +++ b/e2e/solid-router/basic-file-based/src/routes/search-params/route.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/search-params')({ + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return { hello: 'world' as string } + }, +}) 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 new file mode 100644 index 00000000000..37048e1c54c --- /dev/null +++ b/e2e/solid-router/basic-file-based/tests/search-params.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' + +test.describe('/search-params/default', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + await page.goto('/search-params/default') + + await expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + await page.goto('/search-params/default/?default=d2') + + await expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).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 expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + 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 expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + }) +}) diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index dd8551546ff..307552541dc 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -13,19 +13,22 @@ import { createServerRootRoute } from '@tanstack/solid-start/server' import { Route as rootRouteImport } from './routes/__root' import { Route as UsersRouteImport } from './routes/users' import { Route as StreamRouteImport } from './routes/stream' -import { Route as SearchParamsRouteImport } from './routes/search-params' import { Route as ScriptsRouteImport } from './routes/scripts' import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' import { Route as IndexRouteImport } from './routes/index' import { Route as UsersIndexRouteImport } from './routes/users.index' +import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' import { Route as RedirectIndexRouteImport } from './routes/redirect/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' +import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' @@ -56,11 +59,6 @@ const StreamRoute = StreamRouteImport.update({ path: '/stream', getParentRoute: () => rootRouteImport, } as any) -const SearchParamsRoute = SearchParamsRouteImport.update({ - id: '/search-params', - path: '/search-params', - getParentRoute: () => rootRouteImport, -} as any) const ScriptsRoute = ScriptsRouteImport.update({ id: '/scripts', path: '/scripts', @@ -85,6 +83,11 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) +const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ + id: '/search-params', + path: '/search-params', + getParentRoute: () => rootRouteImport, +} as any) const NotFoundRouteRoute = NotFoundRouteRouteImport.update({ id: '/not-found', path: '/not-found', @@ -100,6 +103,11 @@ const UsersIndexRoute = UsersIndexRouteImport.update({ path: '/', getParentRoute: () => UsersRoute, } as any) +const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectIndexRoute = RedirectIndexRouteImport.update({ id: '/redirect/', path: '/redirect/', @@ -120,6 +128,17 @@ const UsersUserIdRoute = UsersUserIdRouteImport.update({ path: '/$userId', getParentRoute: () => UsersRoute, } as any) +const SearchParamsLoaderThrowsRedirectRoute = + SearchParamsLoaderThrowsRedirectRouteImport.update({ + id: '/loader-throws-redirect', + path: '/loader-throws-redirect', + getParentRoute: () => SearchParamsRouteRoute, + } as any) +const SearchParamsDefaultRoute = SearchParamsDefaultRouteImport.update({ + id: '/default', + path: '/default', + getParentRoute: () => SearchParamsRouteRoute, +} as any) const RedirectTargetRoute = RedirectTargetRouteImport.update({ id: '/redirect/$target', path: '/redirect/$target', @@ -213,21 +232,24 @@ const ApiUsersUserIdServerRoute = ApiUsersUserIdServerRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren '/deferred': typeof DeferredRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute - '/search-params': typeof SearchParamsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute @@ -245,15 +267,17 @@ export interface FileRoutesByTo { '/deferred': typeof DeferredRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute - '/search-params': typeof SearchParamsRoute '/stream': typeof StreamRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/not-found': typeof NotFoundIndexRoute '/posts': typeof PostsIndexRoute '/redirect': typeof RedirectIndexRoute + '/search-params': typeof SearchParamsIndexRoute '/users': typeof UsersIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute @@ -270,12 +294,12 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/not-found': typeof NotFoundRouteRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/deferred': typeof DeferredRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute - '/search-params': typeof SearchParamsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren @@ -283,10 +307,13 @@ export interface FileRoutesById { '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute '/redirect/': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute @@ -304,21 +331,24 @@ export interface FileRouteTypes { fullPaths: | '/' | '/not-found' + | '/search-params' | '/deferred' | '/links' | '/posts' | '/scripts' - | '/search-params' | '/stream' | '/users' | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' + | '/search-params/loader-throws-redirect' | '/users/$userId' | '/not-found/' | '/posts/' | '/redirect' + | '/search-params/' | '/users/' | '/layout-a' | '/layout-b' @@ -336,15 +366,17 @@ export interface FileRouteTypes { | '/deferred' | '/links' | '/scripts' - | '/search-params' | '/stream' | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/search-params/default' + | '/search-params/loader-throws-redirect' | '/users/$userId' | '/not-found' | '/posts' | '/redirect' + | '/search-params' | '/users' | '/layout-a' | '/layout-b' @@ -360,12 +392,12 @@ export interface FileRouteTypes { | '__root__' | '/' | '/not-found' + | '/search-params' | '/_layout' | '/deferred' | '/links' | '/posts' | '/scripts' - | '/search-params' | '/stream' | '/users' | '/_layout/_layout-2' @@ -373,10 +405,13 @@ export interface FileRouteTypes { | '/not-found/via-loader' | '/posts/$postId' | '/redirect/$target' + | '/search-params/default' + | '/search-params/loader-throws-redirect' | '/users/$userId' | '/not-found/' | '/posts/' | '/redirect/' + | '/search-params/' | '/users/' | '/_layout/_layout-2/layout-a' | '/_layout/_layout-2/layout-b' @@ -393,12 +428,12 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren + SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren DeferredRoute: typeof DeferredRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren ScriptsRoute: typeof ScriptsRoute - SearchParamsRoute: typeof SearchParamsRoute StreamRoute: typeof StreamRoute UsersRoute: typeof UsersRouteWithChildren RedirectTargetRoute: typeof RedirectTargetRouteWithChildren @@ -446,13 +481,6 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof StreamRouteImport parentRoute: typeof rootRouteImport } - '/search-params': { - id: '/search-params' - path: '/search-params' - fullPath: '/search-params' - preLoaderRoute: typeof SearchParamsRouteImport - parentRoute: typeof rootRouteImport - } '/scripts': { id: '/scripts' path: '/scripts' @@ -488,6 +516,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } + '/search-params': { + id: '/search-params' + path: '/search-params' + fullPath: '/search-params' + preLoaderRoute: typeof SearchParamsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/not-found': { id: '/not-found' path: '/not-found' @@ -509,6 +544,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof UsersIndexRouteImport parentRoute: typeof UsersRoute } + '/search-params/': { + id: '/search-params/' + path: '/' + fullPath: '/search-params/' + preLoaderRoute: typeof SearchParamsIndexRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/redirect/': { id: '/redirect/' path: '/redirect' @@ -537,6 +579,20 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof UsersUserIdRouteImport parentRoute: typeof UsersRoute } + '/search-params/loader-throws-redirect': { + id: '/search-params/loader-throws-redirect' + path: '/loader-throws-redirect' + fullPath: '/search-params/loader-throws-redirect' + preLoaderRoute: typeof SearchParamsLoaderThrowsRedirectRouteImport + parentRoute: typeof SearchParamsRouteRoute + } + '/search-params/default': { + id: '/search-params/default' + path: '/default' + fullPath: '/search-params/default' + preLoaderRoute: typeof SearchParamsDefaultRouteImport + parentRoute: typeof SearchParamsRouteRoute + } '/redirect/$target': { id: '/redirect/$target' path: '/redirect/$target' @@ -679,6 +735,21 @@ const NotFoundRouteRouteWithChildren = NotFoundRouteRoute._addFileChildren( NotFoundRouteRouteChildren, ) +interface SearchParamsRouteRouteChildren { + SearchParamsDefaultRoute: typeof SearchParamsDefaultRoute + SearchParamsLoaderThrowsRedirectRoute: typeof SearchParamsLoaderThrowsRedirectRoute + SearchParamsIndexRoute: typeof SearchParamsIndexRoute +} + +const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { + SearchParamsDefaultRoute: SearchParamsDefaultRoute, + SearchParamsLoaderThrowsRedirectRoute: SearchParamsLoaderThrowsRedirectRoute, + SearchParamsIndexRoute: SearchParamsIndexRoute, +} + +const SearchParamsRouteRouteWithChildren = + SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) + interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute @@ -769,12 +840,12 @@ const ApiUsersServerRouteWithChildren = ApiUsersServerRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NotFoundRouteRoute: NotFoundRouteRouteWithChildren, + SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, ScriptsRoute: ScriptsRoute, - SearchParamsRoute: SearchParamsRoute, StreamRoute: StreamRoute, UsersRoute: UsersRouteWithChildren, RedirectTargetRoute: RedirectTargetRouteWithChildren, diff --git a/e2e/solid-start/basic/src/routes/search-params/default.tsx b/e2e/solid-start/basic/src/routes/search-params/default.tsx new file mode 100644 index 00000000000..36879466202 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/search-params/default.tsx @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { z } from 'zod' + +export const Route = createFileRoute('/search-params/default')({ + validateSearch: z.object({ + default: z.string().default('d1'), + }), + beforeLoad: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + loader: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + component: () => { + const search = Route.useSearch() + const context = Route.useRouteContext() + return ( + <> +
{search().default}
+
{context().hello}
+ + ) + }, +}) diff --git a/e2e/solid-start/basic/src/routes/search-params/index.tsx b/e2e/solid-start/basic/src/routes/search-params/index.tsx new file mode 100644 index 00000000000..ced8975530b --- /dev/null +++ b/e2e/solid-start/basic/src/routes/search-params/index.tsx @@ -0,0 +1,19 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/search-params/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ + go to /search-params/default + +
+ + go to /search-params/default?default=d2 + +
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/search-params.tsx b/e2e/solid-start/basic/src/routes/search-params/loader-throws-redirect.tsx similarity index 81% rename from e2e/solid-start/basic/src/routes/search-params.tsx rename to e2e/solid-start/basic/src/routes/search-params/loader-throws-redirect.tsx index d6b4ac8a696..80c5ea9ad7c 100644 --- a/e2e/solid-start/basic/src/routes/search-params.tsx +++ b/e2e/solid-start/basic/src/routes/search-params/loader-throws-redirect.tsx @@ -1,16 +1,7 @@ import { redirect, createFileRoute } from '@tanstack/solid-router' import { z } from 'zod' -export const Route = createFileRoute('/search-params')({ - component: () => { - const search = Route.useSearch() - return ( -
-

SearchParams

-
{search().step}
-
- ) - }, +export const Route = createFileRoute('/search-params/loader-throws-redirect')({ validateSearch: z.object({ step: z.enum(['a', 'b', 'c']).optional(), }), @@ -18,10 +9,18 @@ export const Route = createFileRoute('/search-params')({ loader: ({ deps: { step } }) => { if (step === undefined) { throw redirect({ - to: '/search-params', - from: '/search-params', + to: '/search-params/loader-throws-redirect', search: { step: 'a' }, }) } }, + component: () => { + const search = Route.useSearch() + return ( +
+

SearchParams

+
{search().step}
+
+ ) + }, }) diff --git a/e2e/solid-start/basic/src/routes/search-params/route.tsx b/e2e/solid-start/basic/src/routes/search-params/route.tsx new file mode 100644 index 00000000000..c324b10d65d --- /dev/null +++ b/e2e/solid-start/basic/src/routes/search-params/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/solid-router' +export const Route = createFileRoute('/search-params')({ + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return { hello: 'world' as string } + }, +}) diff --git a/e2e/solid-start/basic/tests/search-params.spec.ts b/e2e/solid-start/basic/tests/search-params.spec.ts index 7d1ee6d3740..f7f66e99f99 100644 --- a/e2e/solid-start/basic/tests/search-params.spec.ts +++ b/e2e/solid-start/basic/tests/search-params.spec.ts @@ -1,22 +1,79 @@ import { expect } from '@playwright/test' import { test } from './fixture' +import type { Response } from '@playwright/test'; -test('Directly visiting the search-params route without search param set', async ({ - page, -}) => { - await page.goto('/search-params') +function expectRedirect(response: Response | null, endsWith: string, ) { + expect(response).not.toBeNull() + expect(response!.request().redirectedFrom()).not.toBeNull() + const redirectUrl = response!.request().redirectedFrom()!.redirectedTo()?.url() + expect(redirectUrl).toBeDefined() + expect(redirectUrl!.endsWith(endsWith)) +} - await new Promise((r) => setTimeout(r, 500)) - await expect(page.getByTestId('search-param')).toContainText('a') - expect(page.url().endsWith('/search-params?step=a')) +function expectNoRedirect(response: Response | null) { + expect(response).not.toBeNull() + const request = response!.request(); + expect(request.redirectedFrom()?.redirectedTo() === request).toBeTruthy +} + +test.describe('/search-params/loader-throws-redirect', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/loader-throws-redirect') + expectRedirect(response, '/search-params/loader-throws-redirect?step=a') + await expect(page.getByTestId('search-param')).toContainText('a') + expect(page.url().endsWith('/search-params/loader-throws-redirect?step=a')) + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/loader-throws-redirect?step=b') + expectNoRedirect(response) + await expect(page.getByTestId('search-param')).toContainText('b') + expect(page.url().endsWith('/search-params/loader-throws-redirect?step=b')) + }) }) -test('Directly visiting the search-params route with search param set', async ({ - page, -}) => { - await page.goto('/search-params?step=b') - await new Promise((r) => setTimeout(r, 500)) - await expect(page.getByTestId('search-param')).toContainText('b') - expect(page.url().endsWith('/search-params?step=b')) +test.describe('/search-params/default', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/default') + expectRedirect(response, '/search-params/default?default=d1') + await expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/default/?default=d2') + expectNoRedirect(response) + + await expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).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 expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + }) + + 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 expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + }) }) diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts index 02e85221b96..813663e59ad 100644 --- a/packages/router-core/src/redirect.ts +++ b/packages/router-core/src/redirect.ts @@ -73,6 +73,9 @@ export function redirect< } const headers = new Headers(opts.headers || {}) + if (opts.href && headers.get('Location') === null) { + headers.set('Location', opts.href) + } const response = new Response(null, { status: opts.statusCode, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 4b7babe2fba..29c3cf2af5a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -28,7 +28,7 @@ import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' -import { isRedirect } from './redirect' +import { isRedirect, redirect } from './redirect' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { @@ -1762,6 +1762,20 @@ export class RouterCore< this.cancelMatches() this.latestLocation = this.parseLocation(this.latestLocation) + if (this.isServer) { + // for SPAs on the initial load, this is handled by the Transitioner + const nextLocation = this.buildLocation({ + to: this.latestLocation.pathname, + search: true, + params: true, + hash: true, + state: true, + _includeValidateSearch: true, + }) + if (trimPath(this.latestLocation.href) !== trimPath(nextLocation.href)) { + throw redirect({ href: nextLocation.href }) + } + } // Match the routes const pendingMatches = this.matchRoutes(this.latestLocation) From 88a62d026b7794a89966f79b70221d3f8c1c084d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 17:27:39 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- .../src/routes/search-params/index.tsx | 11 +++++-- .../tests/search-params.spec.ts | 16 ++++++--- .../basic/src/routes/search-params/index.tsx | 11 +++++-- .../basic/tests/search-params.spec.ts | 33 +++++++++++++------ .../src/routes/search-params/index.tsx | 11 +++++-- .../tests/search-params.spec.ts | 16 ++++++--- .../basic/src/routes/search-params/index.tsx | 11 +++++-- .../basic/tests/search-params.spec.ts | 33 +++++++++++++------ 8 files changed, 106 insertions(+), 36 deletions(-) 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 b406763706a..c0d4a55ac85 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 @@ -7,11 +7,18 @@ export const Route = createFileRoute('/search-params/')({ function RouteComponent() { return (
- + go to /search-params/default
- + go to /search-params/default?default=d2
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 37048e1c54c..1fb0fc3034b 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 @@ -8,7 +8,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('Directly visiting the route with search param set', async ({ @@ -18,7 +20,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) test('navigating to the route without search param set', async ({ page }) => { @@ -27,7 +31,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('navigating to the route with search param set', async ({ page }) => { @@ -36,6 +42,8 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) }) diff --git a/e2e/react-start/basic/src/routes/search-params/index.tsx b/e2e/react-start/basic/src/routes/search-params/index.tsx index 8d417803add..437b6a4122a 100644 --- a/e2e/react-start/basic/src/routes/search-params/index.tsx +++ b/e2e/react-start/basic/src/routes/search-params/index.tsx @@ -7,11 +7,18 @@ export const Route = createFileRoute({ function RouteComponent() { return (
- + go to /search-params/default
- + go to /search-params/default?default=d2
diff --git a/e2e/react-start/basic/tests/search-params.spec.ts b/e2e/react-start/basic/tests/search-params.spec.ts index f7f66e99f99..23cd97517d6 100644 --- a/e2e/react-start/basic/tests/search-params.spec.ts +++ b/e2e/react-start/basic/tests/search-params.spec.ts @@ -1,18 +1,22 @@ import { expect } from '@playwright/test' import { test } from './fixture' -import type { Response } from '@playwright/test'; +import type { Response } from '@playwright/test' -function expectRedirect(response: Response | null, endsWith: string, ) { +function expectRedirect(response: Response | null, endsWith: string) { expect(response).not.toBeNull() expect(response!.request().redirectedFrom()).not.toBeNull() - const redirectUrl = response!.request().redirectedFrom()!.redirectedTo()?.url() + const redirectUrl = response! + .request() + .redirectedFrom()! + .redirectedTo() + ?.url() expect(redirectUrl).toBeDefined() expect(redirectUrl!.endsWith(endsWith)) } function expectNoRedirect(response: Response | null) { expect(response).not.toBeNull() - const request = response!.request(); + const request = response!.request() expect(request.redirectedFrom()?.redirectedTo() === request).toBeTruthy } @@ -29,14 +33,15 @@ test.describe('/search-params/loader-throws-redirect', () => { test('Directly visiting the route with search param set', async ({ page, }) => { - const response = await page.goto('/search-params/loader-throws-redirect?step=b') + const response = await page.goto( + '/search-params/loader-throws-redirect?step=b', + ) expectNoRedirect(response) await expect(page.getByTestId('search-param')).toContainText('b') expect(page.url().endsWith('/search-params/loader-throws-redirect?step=b')) }) }) - test.describe('/search-params/default', () => { test('Directly visiting the route without search param set', async ({ page, @@ -45,7 +50,9 @@ test.describe('/search-params/default', () => { expectRedirect(response, '/search-params/default?default=d1') await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('Directly visiting the route with search param set', async ({ @@ -56,7 +63,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) test('navigating to the route without search param set', async ({ page }) => { @@ -65,7 +74,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('navigating to the route with search param set', async ({ page }) => { @@ -74,6 +85,8 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) }) 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 ced8975530b..a99d64cbf44 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 @@ -7,11 +7,18 @@ export const Route = createFileRoute('/search-params/')({ function RouteComponent() { return (
- + go to /search-params/default
- + go to /search-params/default?default=d2
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 37048e1c54c..1fb0fc3034b 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 @@ -8,7 +8,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('Directly visiting the route with search param set', async ({ @@ -18,7 +20,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) test('navigating to the route without search param set', async ({ page }) => { @@ -27,7 +31,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('navigating to the route with search param set', async ({ page }) => { @@ -36,6 +42,8 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) }) diff --git a/e2e/solid-start/basic/src/routes/search-params/index.tsx b/e2e/solid-start/basic/src/routes/search-params/index.tsx index ced8975530b..a99d64cbf44 100644 --- a/e2e/solid-start/basic/src/routes/search-params/index.tsx +++ b/e2e/solid-start/basic/src/routes/search-params/index.tsx @@ -7,11 +7,18 @@ export const Route = createFileRoute('/search-params/')({ function RouteComponent() { return (
- + go to /search-params/default
- + go to /search-params/default?default=d2
diff --git a/e2e/solid-start/basic/tests/search-params.spec.ts b/e2e/solid-start/basic/tests/search-params.spec.ts index f7f66e99f99..23cd97517d6 100644 --- a/e2e/solid-start/basic/tests/search-params.spec.ts +++ b/e2e/solid-start/basic/tests/search-params.spec.ts @@ -1,18 +1,22 @@ import { expect } from '@playwright/test' import { test } from './fixture' -import type { Response } from '@playwright/test'; +import type { Response } from '@playwright/test' -function expectRedirect(response: Response | null, endsWith: string, ) { +function expectRedirect(response: Response | null, endsWith: string) { expect(response).not.toBeNull() expect(response!.request().redirectedFrom()).not.toBeNull() - const redirectUrl = response!.request().redirectedFrom()!.redirectedTo()?.url() + const redirectUrl = response! + .request() + .redirectedFrom()! + .redirectedTo() + ?.url() expect(redirectUrl).toBeDefined() expect(redirectUrl!.endsWith(endsWith)) } function expectNoRedirect(response: Response | null) { expect(response).not.toBeNull() - const request = response!.request(); + const request = response!.request() expect(request.redirectedFrom()?.redirectedTo() === request).toBeTruthy } @@ -29,14 +33,15 @@ test.describe('/search-params/loader-throws-redirect', () => { test('Directly visiting the route with search param set', async ({ page, }) => { - const response = await page.goto('/search-params/loader-throws-redirect?step=b') + const response = await page.goto( + '/search-params/loader-throws-redirect?step=b', + ) expectNoRedirect(response) await expect(page.getByTestId('search-param')).toContainText('b') expect(page.url().endsWith('/search-params/loader-throws-redirect?step=b')) }) }) - test.describe('/search-params/default', () => { test('Directly visiting the route without search param set', async ({ page, @@ -45,7 +50,9 @@ test.describe('/search-params/default', () => { expectRedirect(response, '/search-params/default?default=d1') await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('Directly visiting the route with search param set', async ({ @@ -56,7 +63,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) test('navigating to the route without search param set', async ({ page }) => { @@ -65,7 +74,9 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d1') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d1')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() }) test('navigating to the route with search param set', async ({ page }) => { @@ -74,6 +85,8 @@ test.describe('/search-params/default', () => { await expect(page.getByTestId('search-default')).toContainText('d2') await expect(page.getByTestId('context-hello')).toContainText('world') - expect(page.url().endsWith('/search-params/default?default=d2')).toBeTruthy() + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() }) })