diff --git a/e2e/vue-start/server-routes/.gitignore b/e2e/vue-start/server-routes/.gitignore new file mode 100644 index 00000000000..a79d5cf1299 --- /dev/null +++ b/e2e/vue-start/server-routes/.gitignore @@ -0,0 +1,20 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.vercel +.output + +/build/ +/api/ +/server/build +/public/build +# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/vue-start/server-routes/.prettierignore b/e2e/vue-start/server-routes/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/vue-start/server-routes/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/e2e/vue-start/server-routes/package.json b/e2e/vue-start/server-routes/package.json new file mode 100644 index 00000000000..31a1444bccb --- /dev/null +++ b/e2e/vue-start/server-routes/package.json @@ -0,0 +1,42 @@ +{ + "name": "tanstack-vue-start-e2e-server-routes", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/vue-query": "^5.90.9", + "@tanstack/vue-router": "workspace:^", + "@tanstack/vue-router-devtools": "workspace:^", + "@tanstack/vue-router-ssr-query": "workspace:^", + "@tanstack/vue-start": "workspace:^", + "js-cookie": "^3.0.5", + "redaxios": "^0.5.1", + "tailwind-merge": "^2.6.0", + "vite": "^7.1.7", + "vue": "^3.5.25", + "zod": "^3.24.2" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.10.2", + "combinate": "^1.1.11", + "postcss": "^8.5.1", + "srvx": "^0.8.6", + "tailwindcss": "^4.1.17", + "typescript": "^5.7.2", + "@vitejs/plugin-vue": "^6.0.3", + "@vitejs/plugin-vue-jsx": "^5.1.2", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/vue-start/server-routes/playwright.config.ts b/e2e/vue-start/server-routes/playwright.config.ts new file mode 100644 index 00000000000..cb1da03b942 --- /dev/null +++ b/e2e/vue-start/server-routes/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +export const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `pnpm build && VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/vue-start/server-routes/postcss.config.mjs b/e2e/vue-start/server-routes/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/vue-start/server-routes/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/vue-start/server-routes/public/favicon.ico b/e2e/vue-start/server-routes/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/vue-start/server-routes/public/favicon.ico differ diff --git a/e2e/vue-start/server-routes/public/favicon.png b/e2e/vue-start/server-routes/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/vue-start/server-routes/public/favicon.png differ diff --git a/e2e/vue-start/server-routes/src/components/DefaultCatchBoundary.tsx b/e2e/vue-start/server-routes/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..b1f818dd747 --- /dev/null +++ b/e2e/vue-start/server-routes/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/vue-router' +import type { ErrorComponentProps } from '@tanstack/vue-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot.value ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/e2e/vue-start/server-routes/src/components/NotFound.tsx b/e2e/vue-start/server-routes/src/components/NotFound.tsx new file mode 100644 index 00000000000..944e35c12c6 --- /dev/null +++ b/e2e/vue-start/server-routes/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/vue-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/e2e/vue-start/server-routes/src/routeTree.gen.ts b/e2e/vue-start/server-routes/src/routeTree.gen.ts new file mode 100644 index 00000000000..7d9d463772e --- /dev/null +++ b/e2e/vue-start/server-routes/src/routeTree.gen.ts @@ -0,0 +1,257 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// 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 MergeMiddlewareContextRouteImport } from './routes/merge-middleware-context' +import { Route as MethodsRouteRouteImport } from './routes/methods/route' +import { Route as IndexRouteImport } from './routes/index' +import { Route as MethodsIndexRouteImport } from './routes/methods/index' +import { Route as MethodsOnlyAnyRouteImport } from './routes/methods/only-any' +import { Route as ApiOnlyAnyRouteImport } from './routes/api/only-any' +import { Route as ApiMiddlewareContextRouteImport } from './routes/api/middleware-context' +import { Route as ApiParamsFooRouteRouteImport } from './routes/api/params/$foo/route' +import { Route as ApiParamsFooBarRouteImport } from './routes/api/params/$foo/$bar' + +const MergeMiddlewareContextRoute = MergeMiddlewareContextRouteImport.update({ + id: '/merge-middleware-context', + path: '/merge-middleware-context', + getParentRoute: () => rootRouteImport, +} as any) +const MethodsRouteRoute = MethodsRouteRouteImport.update({ + id: '/methods', + path: '/methods', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const MethodsIndexRoute = MethodsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => MethodsRouteRoute, +} as any) +const MethodsOnlyAnyRoute = MethodsOnlyAnyRouteImport.update({ + id: '/only-any', + path: '/only-any', + getParentRoute: () => MethodsRouteRoute, +} as any) +const ApiOnlyAnyRoute = ApiOnlyAnyRouteImport.update({ + id: '/api/only-any', + path: '/api/only-any', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMiddlewareContextRoute = ApiMiddlewareContextRouteImport.update({ + id: '/api/middleware-context', + path: '/api/middleware-context', + getParentRoute: () => rootRouteImport, +} as any) +const ApiParamsFooRouteRoute = ApiParamsFooRouteRouteImport.update({ + id: '/api/params/$foo', + path: '/api/params/$foo', + getParentRoute: () => rootRouteImport, +} as any) +const ApiParamsFooBarRoute = ApiParamsFooBarRouteImport.update({ + id: '/$bar', + path: '/$bar', + getParentRoute: () => ApiParamsFooRouteRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/methods': typeof MethodsRouteRouteWithChildren + '/merge-middleware-context': typeof MergeMiddlewareContextRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute + '/methods/only-any': typeof MethodsOnlyAnyRoute + '/methods/': typeof MethodsIndexRoute + '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren + '/api/params/$foo/$bar': typeof ApiParamsFooBarRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/merge-middleware-context': typeof MergeMiddlewareContextRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute + '/methods/only-any': typeof MethodsOnlyAnyRoute + '/methods': typeof MethodsIndexRoute + '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren + '/api/params/$foo/$bar': typeof ApiParamsFooBarRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/methods': typeof MethodsRouteRouteWithChildren + '/merge-middleware-context': typeof MergeMiddlewareContextRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute + '/methods/only-any': typeof MethodsOnlyAnyRoute + '/methods/': typeof MethodsIndexRoute + '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren + '/api/params/$foo/$bar': typeof ApiParamsFooBarRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/methods' + | '/merge-middleware-context' + | '/api/middleware-context' + | '/api/only-any' + | '/methods/only-any' + | '/methods/' + | '/api/params/$foo' + | '/api/params/$foo/$bar' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/merge-middleware-context' + | '/api/middleware-context' + | '/api/only-any' + | '/methods/only-any' + | '/methods' + | '/api/params/$foo' + | '/api/params/$foo/$bar' + id: + | '__root__' + | '/' + | '/methods' + | '/merge-middleware-context' + | '/api/middleware-context' + | '/api/only-any' + | '/methods/only-any' + | '/methods/' + | '/api/params/$foo' + | '/api/params/$foo/$bar' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + MethodsRouteRoute: typeof MethodsRouteRouteWithChildren + MergeMiddlewareContextRoute: typeof MergeMiddlewareContextRoute + ApiMiddlewareContextRoute: typeof ApiMiddlewareContextRoute + ApiOnlyAnyRoute: typeof ApiOnlyAnyRoute + ApiParamsFooRouteRoute: typeof ApiParamsFooRouteRouteWithChildren +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/merge-middleware-context': { + id: '/merge-middleware-context' + path: '/merge-middleware-context' + fullPath: '/merge-middleware-context' + preLoaderRoute: typeof MergeMiddlewareContextRouteImport + parentRoute: typeof rootRouteImport + } + '/methods': { + id: '/methods' + path: '/methods' + fullPath: '/methods' + preLoaderRoute: typeof MethodsRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/methods/': { + id: '/methods/' + path: '/' + fullPath: '/methods/' + preLoaderRoute: typeof MethodsIndexRouteImport + parentRoute: typeof MethodsRouteRoute + } + '/methods/only-any': { + id: '/methods/only-any' + path: '/only-any' + fullPath: '/methods/only-any' + preLoaderRoute: typeof MethodsOnlyAnyRouteImport + parentRoute: typeof MethodsRouteRoute + } + '/api/only-any': { + id: '/api/only-any' + path: '/api/only-any' + fullPath: '/api/only-any' + preLoaderRoute: typeof ApiOnlyAnyRouteImport + parentRoute: typeof rootRouteImport + } + '/api/middleware-context': { + id: '/api/middleware-context' + path: '/api/middleware-context' + fullPath: '/api/middleware-context' + preLoaderRoute: typeof ApiMiddlewareContextRouteImport + parentRoute: typeof rootRouteImport + } + '/api/params/$foo': { + id: '/api/params/$foo' + path: '/api/params/$foo' + fullPath: '/api/params/$foo' + preLoaderRoute: typeof ApiParamsFooRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/api/params/$foo/$bar': { + id: '/api/params/$foo/$bar' + path: '/$bar' + fullPath: '/api/params/$foo/$bar' + preLoaderRoute: typeof ApiParamsFooBarRouteImport + parentRoute: typeof ApiParamsFooRouteRoute + } + } +} + +interface MethodsRouteRouteChildren { + MethodsOnlyAnyRoute: typeof MethodsOnlyAnyRoute + MethodsIndexRoute: typeof MethodsIndexRoute +} + +const MethodsRouteRouteChildren: MethodsRouteRouteChildren = { + MethodsOnlyAnyRoute: MethodsOnlyAnyRoute, + MethodsIndexRoute: MethodsIndexRoute, +} + +const MethodsRouteRouteWithChildren = MethodsRouteRoute._addFileChildren( + MethodsRouteRouteChildren, +) + +interface ApiParamsFooRouteRouteChildren { + ApiParamsFooBarRoute: typeof ApiParamsFooBarRoute +} + +const ApiParamsFooRouteRouteChildren: ApiParamsFooRouteRouteChildren = { + ApiParamsFooBarRoute: ApiParamsFooBarRoute, +} + +const ApiParamsFooRouteRouteWithChildren = + ApiParamsFooRouteRoute._addFileChildren(ApiParamsFooRouteRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + MethodsRouteRoute: MethodsRouteRouteWithChildren, + MergeMiddlewareContextRoute: MergeMiddlewareContextRoute, + ApiMiddlewareContextRoute: ApiMiddlewareContextRoute, + ApiOnlyAnyRoute: ApiOnlyAnyRoute, + ApiParamsFooRouteRoute: ApiParamsFooRouteRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/vue-start' +declare module '@tanstack/vue-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/vue-start/server-routes/src/router.tsx b/e2e/vue-start/server-routes/src/router.tsx new file mode 100644 index 00000000000..683d7776dd8 --- /dev/null +++ b/e2e/vue-start/server-routes/src/router.tsx @@ -0,0 +1,20 @@ +import { createRouter } from '@tanstack/vue-router' +import { setupRouterSsrQueryIntegration } from '@tanstack/vue-router-ssr-query' +import { QueryClient } from '@tanstack/vue-query' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const queryClient = new QueryClient() + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + setupRouterSsrQueryIntegration({ router, queryClient }) + + return router +} diff --git a/e2e/vue-start/server-routes/src/routes/__root.tsx b/e2e/vue-start/server-routes/src/routes/__root.tsx new file mode 100644 index 00000000000..999d80fff1f --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/__root.tsx @@ -0,0 +1,47 @@ +import { + Body, + HeadContent, + Html, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/vue-router' + +import { TanStackRouterDevtools } from '@tanstack/vue-router-devtools' +import { NotFound } from '~/components/NotFound' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + errorComponent: (props) => { + return

{props.error.stack}

+ }, + notFoundComponent: () => , + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + + ) +} diff --git a/e2e/vue-start/server-routes/src/routes/api/middleware-context.ts b/e2e/vue-start/server-routes/src/routes/api/middleware-context.ts new file mode 100644 index 00000000000..d780d2ebb7b --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/api/middleware-context.ts @@ -0,0 +1,29 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createMiddleware, json } from '@tanstack/vue-start' + +const testParentMiddleware = createMiddleware().server(async ({ next }) => { + const result = await next({ context: { testParent: true } }) + return result +}) + +const testMiddleware = createMiddleware() + .middleware([testParentMiddleware]) + .server(async ({ next }) => { + const result = await next({ context: { test: true } }) + return result + }) + +export const Route = createFileRoute('/api/middleware-context')({ + server: { + middleware: [testMiddleware], + handlers: { + GET: ({ request, context }) => { + return json({ + url: request.url, + context: context, + expectedContext: { testParent: true, test: true }, + }) + }, + }, + }, +}) diff --git a/e2e/vue-start/server-routes/src/routes/api/only-any.ts b/e2e/vue-start/server-routes/src/routes/api/only-any.ts new file mode 100644 index 00000000000..ded160acc18 --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/api/only-any.ts @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { json } from '@tanstack/vue-start' + +export const Route = createFileRoute('/api/only-any')({ + server: { + handlers: { + ANY: ({ request }) => { + return json( + { + handler: 'ANY', + method: request.method, + }, + { headers: { 'X-HANDLER': 'ANY', 'X-METHOD': request.method } }, + ) + }, + }, + }, +}) diff --git a/e2e/vue-start/server-routes/src/routes/api/params/$foo/$bar.ts b/e2e/vue-start/server-routes/src/routes/api/params/$foo/$bar.ts new file mode 100644 index 00000000000..3f11e8287d8 --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/api/params/$foo/$bar.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/api/params/$foo/$bar')({ + server: { + handlers: { + GET: ({ params }) => { + return new Response('hello, ' + params.foo + ' and ' + params.bar) + }, + }, + }, +}) diff --git a/e2e/vue-start/server-routes/src/routes/api/params/$foo/route.ts b/e2e/vue-start/server-routes/src/routes/api/params/$foo/route.ts new file mode 100644 index 00000000000..771e53025e9 --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/api/params/$foo/route.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/api/params/$foo')({ + server: { + handlers: { + GET: ({ params }) => { + return new Response('hello, ' + params.foo) + }, + }, + }, +}) diff --git a/e2e/vue-start/server-routes/src/routes/index.tsx b/e2e/vue-start/server-routes/src/routes/index.tsx new file mode 100644 index 00000000000..78d831d3250 --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/index.tsx @@ -0,0 +1,23 @@ +import { Link, createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Server routes E2E tests

+
    +
  • + + server route middleware context is merged correctly + +
  • +
  • + server route methods +
  • +
+
+ ) +} diff --git a/e2e/vue-start/server-routes/src/routes/merge-middleware-context.tsx b/e2e/vue-start/server-routes/src/routes/merge-middleware-context.tsx new file mode 100644 index 00000000000..c0f655a4dad --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/merge-middleware-context.tsx @@ -0,0 +1,75 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { defineComponent, ref } from 'vue' + +const MergeMiddlewareContext = defineComponent({ + name: 'MergeMiddlewareContext', + setup() { + const apiResponse = ref(null) + + const fetchMiddlewareContext = async () => { + try { + const response = await fetch('/api/middleware-context') + const data = await response.json() + apiResponse.value = data + } catch (error) { + console.error('Error fetching middleware context:', error) + apiResponse.value = { error: 'Failed to fetch' } + } + } + + return () => ( +
+

Merge Server Route Middleware Context Test

+
+ + + {apiResponse.value ? ( +
+

API Response:

+
+                {JSON.stringify(apiResponse.value, null, 2)}
+              
+ +
+

Context Verification:

+
+ {JSON.stringify(apiResponse.value?.context, null, 2)} +
+ +
+ Has testParent:{' '} + {apiResponse.value?.context?.testParent ? 'true' : 'false'} +
+ +
+ Has test:{' '} + {apiResponse.value?.context?.test ? 'true' : 'false'} +
+
+
+ ) : null} +
+
+ ) + }, +}) + +export const Route = createFileRoute('/merge-middleware-context')({ + component: MergeMiddlewareContext, +}) diff --git a/e2e/vue-start/server-routes/src/routes/methods/index.tsx b/e2e/vue-start/server-routes/src/routes/methods/index.tsx new file mode 100644 index 00000000000..a46a0e66ad0 --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/methods/index.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/methods/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
    +
  • + + Server Route only has ANY handler + +
  • +
+
+ ) +} diff --git a/e2e/vue-start/server-routes/src/routes/methods/only-any.tsx b/e2e/vue-start/server-routes/src/routes/methods/only-any.tsx new file mode 100644 index 00000000000..7c468e5ee98 --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/methods/only-any.tsx @@ -0,0 +1,80 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { useQuery } from '@tanstack/vue-query' +import { defineComponent } from 'vue' + +export const Route = createFileRoute('/methods/only-any')({ + ssr: false, + component: RouteComponent, +}) + +const HttpMethods = [ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'OPTIONS', + 'HEAD', +] as const +type HttpMethods = (typeof HttpMethods)[number] + +type OnlyAnyApiResponse = { + method: HttpMethods + handler: 'ANY' +} + +const Test = defineComponent({ + name: 'OnlyAnyTest', + props: { + method: { + type: String, + required: true, + }, + }, + setup(props) { + const query = useQuery(() => ({ + queryKey: ['only-any', props.method], + queryFn: async () => { + const method = props.method as HttpMethods + const response = await fetch(`/api/only-any`, { method }) + + try { + return (await response.json()) as OnlyAnyApiResponse + } catch { + // handle HEAD and OPTIONS that have no body + return { + handler: (response.headers.get('x-handler') ?? + 'ANY') as OnlyAnyApiResponse['handler'], + method: (response.headers.get('x-method') ?? + method) as OnlyAnyApiResponse['method'], + } + } + }, + })) + + return () => ( +
+

method={props.method}

+

expected

+
{props.method}
+

result

+ {query.data.value ? ( +
+ {query.data.value.method} +
+ ) : null} +
+ ) + }, +}) + +function RouteComponent() { + return ( +
+

Server Route has only ANY handler

+ {HttpMethods.map((method) => ( + + ))} +
+ ) +} diff --git a/e2e/vue-start/server-routes/src/routes/methods/route.tsx b/e2e/vue-start/server-routes/src/routes/methods/route.tsx new file mode 100644 index 00000000000..e39dc52bd8d --- /dev/null +++ b/e2e/vue-start/server-routes/src/routes/methods/route.tsx @@ -0,0 +1,14 @@ +import { Outlet, createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/methods')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Server Routes Methods E2E tests

+ +
+ ) +} diff --git a/e2e/vue-start/server-routes/src/styles/app.css b/e2e/vue-start/server-routes/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/vue-start/server-routes/src/styles/app.css @@ -0,0 +1,30 @@ +@import 'tailwindcss'; + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +@layer base { + html { + color-scheme: light dark; + } + + * { + @apply border-gray-200 dark:border-gray-800; + } + + html, + body { + @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; + } + + .using-mouse * { + outline: none !important; + } +} diff --git a/e2e/vue-start/server-routes/src/vite-env.d.ts b/e2e/vue-start/server-routes/src/vite-env.d.ts new file mode 100644 index 00000000000..0b2af560d60 --- /dev/null +++ b/e2e/vue-start/server-routes/src/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*?url' { + const url: string + export default url +} diff --git a/e2e/vue-start/server-routes/tests/server-routes.spec.ts b/e2e/vue-start/server-routes/tests/server-routes.spec.ts new file mode 100644 index 00000000000..f3a80a5a469 --- /dev/null +++ b/e2e/vue-start/server-routes/tests/server-routes.spec.ts @@ -0,0 +1,47 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('merge-middleware-context', async ({ page }) => { + await page.goto('/merge-middleware-context') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-middleware-context-btn').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('has-test-parent')).toContainText('true') + await expect(page.getByTestId('has-test')).toContainText('true') + + const contextResult = await page.getByTestId('context-result').textContent() + expect(contextResult).toContain('testParent') + expect(contextResult).toContain('test') +}) + +test.describe('methods', () => { + test('only ANY', async ({ page }) => { + await page.goto('/methods/only-any') + + // wait for page to be loaded by waiting for the route component to be rendered + await expect(page.getByTestId('route-component')).toBeInViewport() + + const testCases = await page + .locator('[data-testid^="expected-"]') + .elementHandles() + expect(testCases.length).not.toBe(0) + for (const testCase of testCases) { + const testId = await testCase.getAttribute('data-testid') + + if (!testId) { + throw new Error('testcase is missing data-testid') + } + + const suffix = testId.replace('expected-', '') + + const expected = + (await page.getByTestId(`expected-${suffix}`).textContent()) || '' + expect(expected).not.toBe('') + + await expect(page.getByTestId(`result-${suffix}`)).toContainText(expected) + } + }) +}) diff --git a/e2e/vue-start/server-routes/tsconfig.json b/e2e/vue-start/server-routes/tsconfig.json new file mode 100644 index 00000000000..a5ae5ae7e47 --- /dev/null +++ b/e2e/vue-start/server-routes/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx", "public/script*.js"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "vue", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/vue-start/server-routes/vite.config.ts b/e2e/vue-start/server-routes/vite.config.ts new file mode 100644 index 00000000000..79edfc05c26 --- /dev/null +++ b/e2e/vue-start/server-routes/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/vue-start/plugin/vite' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + vue(), + vueJsx(), + ], +}) diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 459d50f82c3..3e6b1615161 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -219,6 +219,7 @@ export type { ActiveLinkOptions, LinkProps, LinkComponent, + LinkComponentRoute, LinkComponentProps, CreateLinkProps, } from './link' diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 45ec178cbda..806fee28d52 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -630,6 +630,26 @@ export type LinkComponent = < props: LinkComponentProps, ) => Vue.VNode +export interface LinkComponentRoute< + in out TDefaultFrom extends string = string, +> { + defaultFrom: TDefaultFrom + < + TRouter extends AnyRouter = RegisteredRouter, + const TTo extends string | undefined = undefined, + const TMaskTo extends string = '', + >( + props: LinkComponentProps< + 'a', + TRouter, + this['defaultFrom'], + TTo, + this['defaultFrom'], + TMaskTo + >, + ): Vue.VNode +} + export function createLink( Comp: Constrain Vue.VNode>, ): LinkComponent { diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts index 458d70659d9..95295eceb00 100644 --- a/packages/vue-router/src/route.ts +++ b/packages/vue-router/src/route.ts @@ -4,6 +4,8 @@ import { BaseRouteApi, notFound, } from '@tanstack/router-core' +import * as Vue from 'vue' +import { Link } from './link' import { useLoaderData } from './useLoaderData' import { useLoaderDeps } from './useLoaderDeps' import { useParams } from './useParams' @@ -42,8 +44,8 @@ import type { UseMatchRoute } from './useMatch' import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseParamsRoute } from './useParams' import type { UseSearchRoute } from './useSearch' -import type * as Vue from 'vue' import type { UseRouteContextRoute } from './useRouteContext' +import type { LinkComponentRoute } from './link' // Structural type for Vue SFC components (.vue files) // Uses structural matching to accept Vue components without breaking @@ -73,6 +75,7 @@ declare module '@tanstack/router-core' { useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute useNavigate: () => UseNavigateResult + Link: LinkComponentRoute } } @@ -140,6 +143,19 @@ export class RouteApi< notFound = (opts?: NotFoundError) => { return notFound({ routeId: this.id as string, ...opts }) } + + Link: LinkComponentRoute['fullPath']> = (( + props, + ctx?: Vue.SetupContext, + ) => { + const router = useRouter() + const fullPath = router.routesById[this.id as string].fullPath + return Vue.h( + Link as any, + { from: fullPath as never, ...(props as any) }, + ctx?.slots, + ) + }) as LinkComponentRoute['fullPath']> } export class Route< @@ -277,6 +293,14 @@ export class Route< useNavigate = (): UseNavigateResult => { return useNavigate({ from: this.fullPath }) } + + Link: LinkComponentRoute = ((props, ctx?: Vue.SetupContext) => { + return Vue.h( + Link as any, + { from: this.fullPath as never, ...(props as any) }, + ctx?.slots, + ) + }) as LinkComponentRoute } export function createRoute< @@ -515,6 +539,14 @@ export class RootRoute< useNavigate = (): UseNavigateResult<'/'> => { return useNavigate({ from: this.fullPath }) } + + Link: LinkComponentRoute<'/'> = ((props, ctx?: Vue.SetupContext) => { + return Vue.h( + Link as any, + { from: this.fullPath as never, ...(props as any) }, + ctx?.slots, + ) + }) as LinkComponentRoute<'/'> } export function createRouteMask< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b41ca8d2f5..b6ff0805244 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4965,6 +4965,82 @@ importers: specifier: ^3.1.8 version: 3.1.8(typescript@5.9.2) + e2e/vue-start/server-routes: + dependencies: + '@tanstack/vue-query': + specifier: ^5.90.9 + version: 5.92.0(vue@3.5.25(typescript@5.9.2)) + '@tanstack/vue-router': + specifier: workspace:* + version: link:../../../packages/vue-router + '@tanstack/vue-router-devtools': + specifier: workspace:* + version: link:../../../packages/vue-router-devtools + '@tanstack/vue-router-ssr-query': + specifier: workspace:* + version: link:../../../packages/vue-router-ssr-query + '@tanstack/vue-start': + specifier: workspace:* + version: link:../../../packages/vue-start + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vue: + specifier: ^3.5.25 + version: 3.5.25(typescript@5.9.2) + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.15 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@vitejs/plugin-vue': + specifier: ^6.0.3 + version: 6.0.3(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2)) + '@vitejs/plugin-vue-jsx': + specifier: ^5.1.2 + version: 5.1.2(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2)) + combinate: + specifier: ^1.1.11 + version: 1.1.11 + postcss: + specifier: ^8.5.1 + version: 8.5.6 + srvx: + specifier: ^0.8.6 + version: 0.8.15 + tailwindcss: + specifier: ^4.1.17 + version: 4.1.17 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + examples/react/authenticated-routes: dependencies: '@tailwindcss/postcss':