diff --git a/e2e/vue-start/server-functions/.gitignore b/e2e/vue-start/server-functions/.gitignore new file mode 100644 index 00000000000..a79d5cf1299 --- /dev/null +++ b/e2e/vue-start/server-functions/.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-functions/.prettierignore b/e2e/vue-start/server-functions/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/vue-start/server-functions/.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-functions/package.json b/e2e/vue-start/server-functions/package.json new file mode 100644 index 00000000000..27faff7ff22 --- /dev/null +++ b/e2e/vue-start/server-functions/package.json @@ -0,0 +1,42 @@ +{ + "name": "tanstack-vue-start-e2e-server-functions", + "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": "rm -rf port*.txt; 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.9.8", + "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-functions/playwright.config.ts b/e2e/vue-start/server-functions/playwright.config.ts new file mode 100644 index 00000000000..cb1da03b942 --- /dev/null +++ b/e2e/vue-start/server-functions/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-functions/postcss.config.mjs b/e2e/vue-start/server-functions/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/vue-start/server-functions/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/vue-start/server-functions/public/favicon.ico b/e2e/vue-start/server-functions/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/vue-start/server-functions/public/favicon.ico differ diff --git a/e2e/vue-start/server-functions/public/favicon.png b/e2e/vue-start/server-functions/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/vue-start/server-functions/public/favicon.png differ diff --git a/e2e/vue-start/server-functions/src/components/DefaultCatchBoundary.tsx b/e2e/vue-start/server-functions/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..b1f818dd747 --- /dev/null +++ b/e2e/vue-start/server-functions/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-functions/src/components/NotFound.tsx b/e2e/vue-start/server-functions/src/components/NotFound.tsx new file mode 100644 index 00000000000..944e35c12c6 --- /dev/null +++ b/e2e/vue-start/server-functions/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-functions/src/routeTree.gen.ts b/e2e/vue-start/server-functions/src/routeTree.gen.ts new file mode 100644 index 00000000000..91633bf3c79 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routeTree.gen.ts @@ -0,0 +1,621 @@ +/* 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 SubmitPostFormdataRouteImport } from './routes/submit-post-formdata' +import { Route as StatusRouteImport } from './routes/status' +import { Route as SerializeFormDataRouteImport } from './routes/serialize-form-data' +import { Route as ReturnNullRouteImport } from './routes/return-null' +import { Route as RawResponseRouteImport } from './routes/raw-response' +import { Route as MultipartRouteImport } from './routes/multipart' +import { Route as IsomorphicFnsRouteImport } from './routes/isomorphic-fns' +import { Route as HeadersRouteImport } from './routes/headers' +import { Route as EnvOnlyRouteImport } from './routes/env-only' +import { Route as DeadCodePreserveRouteImport } from './routes/dead-code-preserve' +import { Route as ConsistentRouteImport } from './routes/consistent' +import { Route as AbortSignalRouteImport } from './routes/abort-signal' +import { Route as IndexRouteImport } from './routes/index' +import { Route as RedirectTestIndexRouteImport } from './routes/redirect-test/index' +import { Route as RedirectTestSsrIndexRouteImport } from './routes/redirect-test-ssr/index' +import { Route as PrimitivesIndexRouteImport } from './routes/primitives/index' +import { Route as MiddlewareIndexRouteImport } from './routes/middleware/index' +import { Route as FormdataRedirectIndexRouteImport } from './routes/formdata-redirect/index' +import { Route as FactoryIndexRouteImport } from './routes/factory/index' +import { Route as CookiesIndexRouteImport } from './routes/cookies/index' +import { Route as RedirectTestTargetRouteImport } from './routes/redirect-test/target' +import { Route as RedirectTestSsrTargetRouteImport } from './routes/redirect-test-ssr/target' +import { Route as MiddlewareSendServerFnRouteImport } from './routes/middleware/send-serverFn' +import { Route as MiddlewareRequestMiddlewareRouteImport } from './routes/middleware/request-middleware' +import { Route as MiddlewareClientMiddlewareRouterRouteImport } from './routes/middleware/client-middleware-router' +import { Route as CookiesSetRouteImport } from './routes/cookies/set' +import { Route as FormdataRedirectTargetNameRouteImport } from './routes/formdata-redirect/target.$name' + +const SubmitPostFormdataRoute = SubmitPostFormdataRouteImport.update({ + id: '/submit-post-formdata', + path: '/submit-post-formdata', + getParentRoute: () => rootRouteImport, +} as any) +const StatusRoute = StatusRouteImport.update({ + id: '/status', + path: '/status', + getParentRoute: () => rootRouteImport, +} as any) +const SerializeFormDataRoute = SerializeFormDataRouteImport.update({ + id: '/serialize-form-data', + path: '/serialize-form-data', + getParentRoute: () => rootRouteImport, +} as any) +const ReturnNullRoute = ReturnNullRouteImport.update({ + id: '/return-null', + path: '/return-null', + getParentRoute: () => rootRouteImport, +} as any) +const RawResponseRoute = RawResponseRouteImport.update({ + id: '/raw-response', + path: '/raw-response', + getParentRoute: () => rootRouteImport, +} as any) +const MultipartRoute = MultipartRouteImport.update({ + id: '/multipart', + path: '/multipart', + getParentRoute: () => rootRouteImport, +} as any) +const IsomorphicFnsRoute = IsomorphicFnsRouteImport.update({ + id: '/isomorphic-fns', + path: '/isomorphic-fns', + getParentRoute: () => rootRouteImport, +} as any) +const HeadersRoute = HeadersRouteImport.update({ + id: '/headers', + path: '/headers', + getParentRoute: () => rootRouteImport, +} as any) +const EnvOnlyRoute = EnvOnlyRouteImport.update({ + id: '/env-only', + path: '/env-only', + getParentRoute: () => rootRouteImport, +} as any) +const DeadCodePreserveRoute = DeadCodePreserveRouteImport.update({ + id: '/dead-code-preserve', + path: '/dead-code-preserve', + getParentRoute: () => rootRouteImport, +} as any) +const ConsistentRoute = ConsistentRouteImport.update({ + id: '/consistent', + path: '/consistent', + getParentRoute: () => rootRouteImport, +} as any) +const AbortSignalRoute = AbortSignalRouteImport.update({ + id: '/abort-signal', + path: '/abort-signal', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const RedirectTestIndexRoute = RedirectTestIndexRouteImport.update({ + id: '/redirect-test/', + path: '/redirect-test/', + getParentRoute: () => rootRouteImport, +} as any) +const RedirectTestSsrIndexRoute = RedirectTestSsrIndexRouteImport.update({ + id: '/redirect-test-ssr/', + path: '/redirect-test-ssr/', + getParentRoute: () => rootRouteImport, +} as any) +const PrimitivesIndexRoute = PrimitivesIndexRouteImport.update({ + id: '/primitives/', + path: '/primitives/', + getParentRoute: () => rootRouteImport, +} as any) +const MiddlewareIndexRoute = MiddlewareIndexRouteImport.update({ + id: '/middleware/', + path: '/middleware/', + getParentRoute: () => rootRouteImport, +} as any) +const FormdataRedirectIndexRoute = FormdataRedirectIndexRouteImport.update({ + id: '/formdata-redirect/', + path: '/formdata-redirect/', + getParentRoute: () => rootRouteImport, +} as any) +const FactoryIndexRoute = FactoryIndexRouteImport.update({ + id: '/factory/', + path: '/factory/', + getParentRoute: () => rootRouteImport, +} as any) +const CookiesIndexRoute = CookiesIndexRouteImport.update({ + id: '/cookies/', + path: '/cookies/', + getParentRoute: () => rootRouteImport, +} as any) +const RedirectTestTargetRoute = RedirectTestTargetRouteImport.update({ + id: '/redirect-test/target', + path: '/redirect-test/target', + getParentRoute: () => rootRouteImport, +} as any) +const RedirectTestSsrTargetRoute = RedirectTestSsrTargetRouteImport.update({ + id: '/redirect-test-ssr/target', + path: '/redirect-test-ssr/target', + getParentRoute: () => rootRouteImport, +} as any) +const MiddlewareSendServerFnRoute = MiddlewareSendServerFnRouteImport.update({ + id: '/middleware/send-serverFn', + path: '/middleware/send-serverFn', + getParentRoute: () => rootRouteImport, +} as any) +const MiddlewareRequestMiddlewareRoute = + MiddlewareRequestMiddlewareRouteImport.update({ + id: '/middleware/request-middleware', + path: '/middleware/request-middleware', + getParentRoute: () => rootRouteImport, + } as any) +const MiddlewareClientMiddlewareRouterRoute = + MiddlewareClientMiddlewareRouterRouteImport.update({ + id: '/middleware/client-middleware-router', + path: '/middleware/client-middleware-router', + getParentRoute: () => rootRouteImport, + } as any) +const CookiesSetRoute = CookiesSetRouteImport.update({ + id: '/cookies/set', + path: '/cookies/set', + getParentRoute: () => rootRouteImport, +} as any) +const FormdataRedirectTargetNameRoute = + FormdataRedirectTargetNameRouteImport.update({ + id: '/formdata-redirect/target/$name', + path: '/formdata-redirect/target/$name', + getParentRoute: () => rootRouteImport, + } as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/abort-signal': typeof AbortSignalRoute + '/consistent': typeof ConsistentRoute + '/dead-code-preserve': typeof DeadCodePreserveRoute + '/env-only': typeof EnvOnlyRoute + '/headers': typeof HeadersRoute + '/isomorphic-fns': typeof IsomorphicFnsRoute + '/multipart': typeof MultipartRoute + '/raw-response': typeof RawResponseRoute + '/return-null': typeof ReturnNullRoute + '/serialize-form-data': typeof SerializeFormDataRoute + '/status': typeof StatusRoute + '/submit-post-formdata': typeof SubmitPostFormdataRoute + '/cookies/set': typeof CookiesSetRoute + '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute + '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute + '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute + '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute + '/redirect-test/target': typeof RedirectTestTargetRoute + '/cookies': typeof CookiesIndexRoute + '/factory': typeof FactoryIndexRoute + '/formdata-redirect': typeof FormdataRedirectIndexRoute + '/middleware': typeof MiddlewareIndexRoute + '/primitives': typeof PrimitivesIndexRoute + '/redirect-test-ssr': typeof RedirectTestSsrIndexRoute + '/redirect-test': typeof RedirectTestIndexRoute + '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/abort-signal': typeof AbortSignalRoute + '/consistent': typeof ConsistentRoute + '/dead-code-preserve': typeof DeadCodePreserveRoute + '/env-only': typeof EnvOnlyRoute + '/headers': typeof HeadersRoute + '/isomorphic-fns': typeof IsomorphicFnsRoute + '/multipart': typeof MultipartRoute + '/raw-response': typeof RawResponseRoute + '/return-null': typeof ReturnNullRoute + '/serialize-form-data': typeof SerializeFormDataRoute + '/status': typeof StatusRoute + '/submit-post-formdata': typeof SubmitPostFormdataRoute + '/cookies/set': typeof CookiesSetRoute + '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute + '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute + '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute + '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute + '/redirect-test/target': typeof RedirectTestTargetRoute + '/cookies': typeof CookiesIndexRoute + '/factory': typeof FactoryIndexRoute + '/formdata-redirect': typeof FormdataRedirectIndexRoute + '/middleware': typeof MiddlewareIndexRoute + '/primitives': typeof PrimitivesIndexRoute + '/redirect-test-ssr': typeof RedirectTestSsrIndexRoute + '/redirect-test': typeof RedirectTestIndexRoute + '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/abort-signal': typeof AbortSignalRoute + '/consistent': typeof ConsistentRoute + '/dead-code-preserve': typeof DeadCodePreserveRoute + '/env-only': typeof EnvOnlyRoute + '/headers': typeof HeadersRoute + '/isomorphic-fns': typeof IsomorphicFnsRoute + '/multipart': typeof MultipartRoute + '/raw-response': typeof RawResponseRoute + '/return-null': typeof ReturnNullRoute + '/serialize-form-data': typeof SerializeFormDataRoute + '/status': typeof StatusRoute + '/submit-post-formdata': typeof SubmitPostFormdataRoute + '/cookies/set': typeof CookiesSetRoute + '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute + '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute + '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute + '/redirect-test-ssr/target': typeof RedirectTestSsrTargetRoute + '/redirect-test/target': typeof RedirectTestTargetRoute + '/cookies/': typeof CookiesIndexRoute + '/factory/': typeof FactoryIndexRoute + '/formdata-redirect/': typeof FormdataRedirectIndexRoute + '/middleware/': typeof MiddlewareIndexRoute + '/primitives/': typeof PrimitivesIndexRoute + '/redirect-test-ssr/': typeof RedirectTestSsrIndexRoute + '/redirect-test/': typeof RedirectTestIndexRoute + '/formdata-redirect/target/$name': typeof FormdataRedirectTargetNameRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/abort-signal' + | '/consistent' + | '/dead-code-preserve' + | '/env-only' + | '/headers' + | '/isomorphic-fns' + | '/multipart' + | '/raw-response' + | '/return-null' + | '/serialize-form-data' + | '/status' + | '/submit-post-formdata' + | '/cookies/set' + | '/middleware/client-middleware-router' + | '/middleware/request-middleware' + | '/middleware/send-serverFn' + | '/redirect-test-ssr/target' + | '/redirect-test/target' + | '/cookies' + | '/factory' + | '/formdata-redirect' + | '/middleware' + | '/primitives' + | '/redirect-test-ssr' + | '/redirect-test' + | '/formdata-redirect/target/$name' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/abort-signal' + | '/consistent' + | '/dead-code-preserve' + | '/env-only' + | '/headers' + | '/isomorphic-fns' + | '/multipart' + | '/raw-response' + | '/return-null' + | '/serialize-form-data' + | '/status' + | '/submit-post-formdata' + | '/cookies/set' + | '/middleware/client-middleware-router' + | '/middleware/request-middleware' + | '/middleware/send-serverFn' + | '/redirect-test-ssr/target' + | '/redirect-test/target' + | '/cookies' + | '/factory' + | '/formdata-redirect' + | '/middleware' + | '/primitives' + | '/redirect-test-ssr' + | '/redirect-test' + | '/formdata-redirect/target/$name' + id: + | '__root__' + | '/' + | '/abort-signal' + | '/consistent' + | '/dead-code-preserve' + | '/env-only' + | '/headers' + | '/isomorphic-fns' + | '/multipart' + | '/raw-response' + | '/return-null' + | '/serialize-form-data' + | '/status' + | '/submit-post-formdata' + | '/cookies/set' + | '/middleware/client-middleware-router' + | '/middleware/request-middleware' + | '/middleware/send-serverFn' + | '/redirect-test-ssr/target' + | '/redirect-test/target' + | '/cookies/' + | '/factory/' + | '/formdata-redirect/' + | '/middleware/' + | '/primitives/' + | '/redirect-test-ssr/' + | '/redirect-test/' + | '/formdata-redirect/target/$name' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AbortSignalRoute: typeof AbortSignalRoute + ConsistentRoute: typeof ConsistentRoute + DeadCodePreserveRoute: typeof DeadCodePreserveRoute + EnvOnlyRoute: typeof EnvOnlyRoute + HeadersRoute: typeof HeadersRoute + IsomorphicFnsRoute: typeof IsomorphicFnsRoute + MultipartRoute: typeof MultipartRoute + RawResponseRoute: typeof RawResponseRoute + ReturnNullRoute: typeof ReturnNullRoute + SerializeFormDataRoute: typeof SerializeFormDataRoute + StatusRoute: typeof StatusRoute + SubmitPostFormdataRoute: typeof SubmitPostFormdataRoute + CookiesSetRoute: typeof CookiesSetRoute + MiddlewareClientMiddlewareRouterRoute: typeof MiddlewareClientMiddlewareRouterRoute + MiddlewareRequestMiddlewareRoute: typeof MiddlewareRequestMiddlewareRoute + MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute + RedirectTestSsrTargetRoute: typeof RedirectTestSsrTargetRoute + RedirectTestTargetRoute: typeof RedirectTestTargetRoute + CookiesIndexRoute: typeof CookiesIndexRoute + FactoryIndexRoute: typeof FactoryIndexRoute + FormdataRedirectIndexRoute: typeof FormdataRedirectIndexRoute + MiddlewareIndexRoute: typeof MiddlewareIndexRoute + PrimitivesIndexRoute: typeof PrimitivesIndexRoute + RedirectTestSsrIndexRoute: typeof RedirectTestSsrIndexRoute + RedirectTestIndexRoute: typeof RedirectTestIndexRoute + FormdataRedirectTargetNameRoute: typeof FormdataRedirectTargetNameRoute +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/submit-post-formdata': { + id: '/submit-post-formdata' + path: '/submit-post-formdata' + fullPath: '/submit-post-formdata' + preLoaderRoute: typeof SubmitPostFormdataRouteImport + parentRoute: typeof rootRouteImport + } + '/status': { + id: '/status' + path: '/status' + fullPath: '/status' + preLoaderRoute: typeof StatusRouteImport + parentRoute: typeof rootRouteImport + } + '/serialize-form-data': { + id: '/serialize-form-data' + path: '/serialize-form-data' + fullPath: '/serialize-form-data' + preLoaderRoute: typeof SerializeFormDataRouteImport + parentRoute: typeof rootRouteImport + } + '/return-null': { + id: '/return-null' + path: '/return-null' + fullPath: '/return-null' + preLoaderRoute: typeof ReturnNullRouteImport + parentRoute: typeof rootRouteImport + } + '/raw-response': { + id: '/raw-response' + path: '/raw-response' + fullPath: '/raw-response' + preLoaderRoute: typeof RawResponseRouteImport + parentRoute: typeof rootRouteImport + } + '/multipart': { + id: '/multipart' + path: '/multipart' + fullPath: '/multipart' + preLoaderRoute: typeof MultipartRouteImport + parentRoute: typeof rootRouteImport + } + '/isomorphic-fns': { + id: '/isomorphic-fns' + path: '/isomorphic-fns' + fullPath: '/isomorphic-fns' + preLoaderRoute: typeof IsomorphicFnsRouteImport + parentRoute: typeof rootRouteImport + } + '/headers': { + id: '/headers' + path: '/headers' + fullPath: '/headers' + preLoaderRoute: typeof HeadersRouteImport + parentRoute: typeof rootRouteImport + } + '/env-only': { + id: '/env-only' + path: '/env-only' + fullPath: '/env-only' + preLoaderRoute: typeof EnvOnlyRouteImport + parentRoute: typeof rootRouteImport + } + '/dead-code-preserve': { + id: '/dead-code-preserve' + path: '/dead-code-preserve' + fullPath: '/dead-code-preserve' + preLoaderRoute: typeof DeadCodePreserveRouteImport + parentRoute: typeof rootRouteImport + } + '/consistent': { + id: '/consistent' + path: '/consistent' + fullPath: '/consistent' + preLoaderRoute: typeof ConsistentRouteImport + parentRoute: typeof rootRouteImport + } + '/abort-signal': { + id: '/abort-signal' + path: '/abort-signal' + fullPath: '/abort-signal' + preLoaderRoute: typeof AbortSignalRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/redirect-test/': { + id: '/redirect-test/' + path: '/redirect-test' + fullPath: '/redirect-test' + preLoaderRoute: typeof RedirectTestIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/redirect-test-ssr/': { + id: '/redirect-test-ssr/' + path: '/redirect-test-ssr' + fullPath: '/redirect-test-ssr' + preLoaderRoute: typeof RedirectTestSsrIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/primitives/': { + id: '/primitives/' + path: '/primitives' + fullPath: '/primitives' + preLoaderRoute: typeof PrimitivesIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/middleware/': { + id: '/middleware/' + path: '/middleware' + fullPath: '/middleware' + preLoaderRoute: typeof MiddlewareIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/formdata-redirect/': { + id: '/formdata-redirect/' + path: '/formdata-redirect' + fullPath: '/formdata-redirect' + preLoaderRoute: typeof FormdataRedirectIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/factory/': { + id: '/factory/' + path: '/factory' + fullPath: '/factory' + preLoaderRoute: typeof FactoryIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/cookies/': { + id: '/cookies/' + path: '/cookies' + fullPath: '/cookies' + preLoaderRoute: typeof CookiesIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/redirect-test/target': { + id: '/redirect-test/target' + path: '/redirect-test/target' + fullPath: '/redirect-test/target' + preLoaderRoute: typeof RedirectTestTargetRouteImport + parentRoute: typeof rootRouteImport + } + '/redirect-test-ssr/target': { + id: '/redirect-test-ssr/target' + path: '/redirect-test-ssr/target' + fullPath: '/redirect-test-ssr/target' + preLoaderRoute: typeof RedirectTestSsrTargetRouteImport + parentRoute: typeof rootRouteImport + } + '/middleware/send-serverFn': { + id: '/middleware/send-serverFn' + path: '/middleware/send-serverFn' + fullPath: '/middleware/send-serverFn' + preLoaderRoute: typeof MiddlewareSendServerFnRouteImport + parentRoute: typeof rootRouteImport + } + '/middleware/request-middleware': { + id: '/middleware/request-middleware' + path: '/middleware/request-middleware' + fullPath: '/middleware/request-middleware' + preLoaderRoute: typeof MiddlewareRequestMiddlewareRouteImport + parentRoute: typeof rootRouteImport + } + '/middleware/client-middleware-router': { + id: '/middleware/client-middleware-router' + path: '/middleware/client-middleware-router' + fullPath: '/middleware/client-middleware-router' + preLoaderRoute: typeof MiddlewareClientMiddlewareRouterRouteImport + parentRoute: typeof rootRouteImport + } + '/cookies/set': { + id: '/cookies/set' + path: '/cookies/set' + fullPath: '/cookies/set' + preLoaderRoute: typeof CookiesSetRouteImport + parentRoute: typeof rootRouteImport + } + '/formdata-redirect/target/$name': { + id: '/formdata-redirect/target/$name' + path: '/formdata-redirect/target/$name' + fullPath: '/formdata-redirect/target/$name' + preLoaderRoute: typeof FormdataRedirectTargetNameRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AbortSignalRoute: AbortSignalRoute, + ConsistentRoute: ConsistentRoute, + DeadCodePreserveRoute: DeadCodePreserveRoute, + EnvOnlyRoute: EnvOnlyRoute, + HeadersRoute: HeadersRoute, + IsomorphicFnsRoute: IsomorphicFnsRoute, + MultipartRoute: MultipartRoute, + RawResponseRoute: RawResponseRoute, + ReturnNullRoute: ReturnNullRoute, + SerializeFormDataRoute: SerializeFormDataRoute, + StatusRoute: StatusRoute, + SubmitPostFormdataRoute: SubmitPostFormdataRoute, + CookiesSetRoute: CookiesSetRoute, + MiddlewareClientMiddlewareRouterRoute: MiddlewareClientMiddlewareRouterRoute, + MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute, + MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, + RedirectTestSsrTargetRoute: RedirectTestSsrTargetRoute, + RedirectTestTargetRoute: RedirectTestTargetRoute, + CookiesIndexRoute: CookiesIndexRoute, + FactoryIndexRoute: FactoryIndexRoute, + FormdataRedirectIndexRoute: FormdataRedirectIndexRoute, + MiddlewareIndexRoute: MiddlewareIndexRoute, + PrimitivesIndexRoute: PrimitivesIndexRoute, + RedirectTestSsrIndexRoute: RedirectTestSsrIndexRoute, + RedirectTestIndexRoute: RedirectTestIndexRoute, + FormdataRedirectTargetNameRoute: FormdataRedirectTargetNameRoute, +} +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-functions/src/router.tsx b/e2e/vue-start/server-functions/src/router.tsx new file mode 100644 index 00000000000..11122f6b8a9 --- /dev/null +++ b/e2e/vue-start/server-functions/src/router.tsx @@ -0,0 +1,32 @@ +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, + context: { + foo: { + bar: 'baz', + }, + }, + }) + + setupRouterSsrQueryIntegration({ router, queryClient }) + + return router +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/e2e/vue-start/server-functions/src/routes/__root.tsx b/e2e/vue-start/server-functions/src/routes/__root.tsx new file mode 100644 index 00000000000..342f7a79b39 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/__root.tsx @@ -0,0 +1,47 @@ +import { + Body, + HeadContent, + Html, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/vue-router' + +import { TanStackRouterDevtoolsInProd } 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-functions/src/routes/abort-signal.tsx b/e2e/vue-start/server-functions/src/routes/abort-signal.tsx new file mode 100644 index 00000000000..2a3e08ae593 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/abort-signal.tsx @@ -0,0 +1,86 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const abortableServerFn = createServerFn().handler( + async ({ context, signal }) => { + console.log('server function started', { context, signal }) + return new Promise((resolve, reject) => { + if (signal.aborted) { + return reject(new Error('Aborted before start')) + } + const timerId = setTimeout(() => { + console.log('server function finished') + resolve('server function result') + }, 1000) + const onAbort = () => { + clearTimeout(timerId) + console.log('server function aborted') + reject(new Error('Aborted')) + } + signal.addEventListener('abort', onAbort, { once: true }) + }) + }, +) + +const RouteComponent = defineComponent({ + setup() { + const errorMessage = ref(undefined) + const result = ref(undefined) + + const reset = () => { + errorMessage.value = undefined + result.value = undefined + } + + return () => ( +
+ +
+ +
+ result:

{result.value ?? '$undefined'}

+
+
+ message:{' '} +

{errorMessage.value ?? '$undefined'}

+
+
+ ) + }, +}) + +export const Route = createFileRoute('/abort-signal')({ + component: RouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/consistent.tsx b/e2e/vue-start/server-functions/src/routes/consistent.tsx new file mode 100644 index 00000000000..5e6b0a7e08e --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/consistent.tsx @@ -0,0 +1,129 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +/** + * This checks whether the returned payloads from a + * server function are the same, regardless of whether the server function is + * called directly from the client or from within the server function. + * @link https://github.com/TanStack/router/issues/1866 + * @link https://github.com/TanStack/router/issues/2481 + */ + +const cons_getFn1 = createServerFn() + .inputValidator((d: { username: string }) => d) + .handler(({ data }) => { + return { payload: data } + }) + +const cons_serverGetFn1 = createServerFn() + .inputValidator((d: { username: string }) => d) + .handler(async ({ data }) => { + return cons_getFn1({ data }) + }) + +const cons_postFn1 = createServerFn({ method: 'POST' }) + .inputValidator((d: { username: string }) => d) + .handler(({ data }) => { + return { payload: data } + }) + +const cons_serverPostFn1 = createServerFn({ method: 'POST' }) + .inputValidator((d: { username: string }) => d) + .handler(({ data }) => { + return cons_postFn1({ data }) + }) + +const ConsistentServerFnCalls = defineComponent({ + setup() { + const getServerResult = ref({}) + const getDirectResult = ref({}) + + const postServerResult = ref({}) + const postDirectResult = ref({}) + + return () => ( +
+

Consistent Server Fn GET Calls

+

+ This component checks whether the returned payloads from server + function are the same, regardless of whether the server function is + called directly from the client or from within the server function. +

+
+ It should return{' '} + +
+              {JSON.stringify({ payload: { username: 'TEST' } })}
+            
+
+
+

+ {`GET: cons_getFn1 called from server cons_serverGetFn1 returns`} +
+ + {JSON.stringify(getServerResult.value)} + +

+

+ {`GET: cons_getFn1 called directly returns`} +
+ + {JSON.stringify(getDirectResult.value)} + +

+

+ {`POST: cons_postFn1 called from cons_serverPostFn1 returns`} +
+ + {JSON.stringify(postServerResult.value)} + +

+

+ {`POST: cons_postFn1 called directly returns`} +
+ + {JSON.stringify(postDirectResult.value)} + +

+ +
+ ) + }, +}) + +export const Route = createFileRoute('/consistent')({ + component: ConsistentServerFnCalls, + loader: async () => { + const data = await cons_serverGetFn1({ data: { username: 'TEST' } }) + console.log('cons_serverGetFn1', data) + return { data } + }, +}) diff --git a/e2e/vue-start/server-functions/src/routes/cookies/index.tsx b/e2e/vue-start/server-functions/src/routes/cookies/index.tsx new file mode 100644 index 00000000000..c47897c1096 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/cookies/index.tsx @@ -0,0 +1,24 @@ +import { Link, createFileRoute } from '@tanstack/vue-router' +import { z } from 'zod' + +const cookieSchema = z + .object({ value: z.string() }) + .catch(() => ({ value: `CLIENT-${Date.now()}` })) +export const Route = createFileRoute('/cookies/')({ + validateSearch: cookieSchema, + component: RouteComponent, +}) + +function RouteComponent() { + const search = Route.useSearch() + return ( + + got to route that sets the cookies with {JSON.stringify(search.value)} + + ) +} diff --git a/e2e/vue-start/server-functions/src/routes/cookies/set.tsx b/e2e/vue-start/server-functions/src/routes/cookies/set.tsx new file mode 100644 index 00000000000..39fd00b74e4 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/cookies/set.tsx @@ -0,0 +1,78 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { setCookie } from '@tanstack/vue-start/server' +import { z } from 'zod' +import Cookies from 'js-cookie' +import { defineComponent, ref, watch } from 'vue' + +const cookieSchema = z.object({ value: z.string() }) + +export const setCookieServerFn1 = createServerFn() + .inputValidator(cookieSchema) + .handler(({ data }) => { + setCookie(`cookie-1-${data.value}`, data.value) + setCookie(`cookie-2-${data.value}`, data.value) + }) + +export const setCookieServerFn2 = createServerFn() + .inputValidator(cookieSchema) + .handler(({ data }) => { + setCookie(`cookie-3-${data.value}`, data.value) + setCookie(`cookie-4-${data.value}`, data.value) + }) + +const RouteComponent = defineComponent({ + setup() { + const search = Route.useSearch() + const cookiesFromDocument = ref>({}) + + const updateCookies = () => { + const tempCookies: Record = {} + for (let i = 1; i <= 4; i++) { + const key = `cookie-${i}-${search.value.value}` + tempCookies[key] = Cookies.get(key) + } + cookiesFromDocument.value = tempCookies + } + + if (typeof window !== 'undefined') { + watch( + () => search.value.value, + () => { + updateCookies() + }, + { immediate: true }, + ) + } + + return () => ( +
+

cookies result

+ + + + + + + {Object.entries(cookiesFromDocument.value).map(([key, value]) => ( + + + + + ))} + +
cookievalue
{key}{value}
+
+ ) + }, +}) + +export const Route = createFileRoute('/cookies/set')({ + validateSearch: cookieSchema, + loaderDeps: ({ search }) => search, + loader: async ({ deps }) => { + await setCookieServerFn1({ data: deps }) + await setCookieServerFn2({ data: deps }) + }, + component: RouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/dead-code-preserve.tsx b/e2e/vue-start/server-functions/src/routes/dead-code-preserve.tsx new file mode 100644 index 00000000000..b80c6bf7701 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/dead-code-preserve.tsx @@ -0,0 +1,64 @@ +import { createFileRoute } from '@tanstack/vue-router' +import * as fs from 'node:fs' +import { createServerFn } from '@tanstack/vue-start' +import { getRequestHeader } from '@tanstack/vue-start/server' +import { defineComponent, ref } from 'vue' + +// by using this we make sure DCE still works - this errors when imported on the client + +const filePath = 'count-effect.txt' + +async function readCount() { + return parseInt( + await fs.promises.readFile(filePath, 'utf-8').catch(() => '0'), + ) +} + +async function updateCount() { + const count = await readCount() + await fs.promises.writeFile(filePath, `${count + 1}`) + return true +} + +const writeFileServerFn = createServerFn().handler(async () => { + // eslint-disable-next-line unused-imports/no-unused-vars + const test = await updateCount() + return getRequestHeader('X-Test') +}) + +const readFileServerFn = createServerFn().handler(async () => { + const data = await readCount() + return data +}) + +const RouteComponent = defineComponent({ + setup() { + const serverFnOutput = ref(undefined) + return () => ( +
+

Dead code test

+

+ This server function writes to a file as a side effect, then reads it. +

+ +

Server output

+
+          {serverFnOutput.value}
+        
+
+ ) + }, +}) + +export const Route = createFileRoute('/dead-code-preserve')({ + component: RouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/env-only.tsx b/e2e/vue-start/server-functions/src/routes/env-only.tsx new file mode 100644 index 00000000000..6b784089c79 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/env-only.tsx @@ -0,0 +1,87 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { + createClientOnlyFn, + createServerFn, + createServerOnlyFn, +} from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const serverEcho = createServerOnlyFn((input: string) => 'server got: ' + input) +const clientEcho = createClientOnlyFn((input: string) => 'client got: ' + input) + +const testOnServer = createServerFn().handler(() => { + const serverOnServer = serverEcho('hello') + let clientOnServer: string + try { + clientOnServer = clientEcho('hello') + } catch (e) { + clientOnServer = + 'clientEcho threw an error: ' + + (e instanceof Error ? e.message : String(e)) + } + return { serverOnServer, clientOnServer } +}) + +const RouteComponent = defineComponent({ + setup() { + const results = ref> | undefined>() + + async function handleClick() { + const { serverOnServer, clientOnServer } = await testOnServer() + const clientOnClient = clientEcho('hello') + let serverOnClient: string + try { + serverOnClient = serverEcho('hello') + } catch (e) { + serverOnClient = + 'serverEcho threw an error: ' + + (e instanceof Error ? e.message : String(e)) + } + results.value = { + serverOnServer, + clientOnServer, + clientOnClient, + serverOnClient, + } + } + + return () => ( +
+ + {!!results.value && ( +
+

+ serverEcho +

+ When we called the function on the server: +
+              {results.value.serverOnServer}
+            
+ When we called the function on the client: +
+              {results.value.serverOnClient}
+            
+
+

+ clientEcho +

+ When we called the function on the server: +
+              {results.value.clientOnServer}
+            
+ When we called the function on the client: +
+              {results.value.clientOnClient}
+            
+
+ )} +
+ ) + }, +}) + +export const Route = createFileRoute('/env-only')({ + component: RouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/factory/-functions/createBarServerFn.ts b/e2e/vue-start/server-functions/src/routes/factory/-functions/createBarServerFn.ts new file mode 100644 index 00000000000..bff66859175 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/factory/-functions/createBarServerFn.ts @@ -0,0 +1,22 @@ +import { createMiddleware } from '@tanstack/vue-start' +import { createFooServerFn } from './createFooServerFn' + +const barMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + console.log('Bar middleware triggered') + return next({ + context: { bar: 'bar' } as const, + }) + }, +) + +export const createBarServerFn = createFooServerFn().middleware([barMiddleware]) + +export const barFnInsideFactoryFile = createBarServerFn().handler( + ({ context }) => { + return { + name: 'barFnInsideFactoryFile', + context, + } + }, +) diff --git a/e2e/vue-start/server-functions/src/routes/factory/-functions/createFakeFn.ts b/e2e/vue-start/server-functions/src/routes/factory/-functions/createFakeFn.ts new file mode 100644 index 00000000000..1c727338850 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/factory/-functions/createFakeFn.ts @@ -0,0 +1,5 @@ +export function createFakeFn() { + return { + handler: (cb: () => Promise) => cb, + } +} diff --git a/e2e/vue-start/server-functions/src/routes/factory/-functions/createFooServerFn.ts b/e2e/vue-start/server-functions/src/routes/factory/-functions/createFooServerFn.ts new file mode 100644 index 00000000000..d75084b7ffb --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/factory/-functions/createFooServerFn.ts @@ -0,0 +1,24 @@ +import { createMiddleware, createServerFn } from '@tanstack/vue-start' +import { getRequest } from '@tanstack/vue-start/server' + +const fooMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + const request = getRequest() + console.log('Foo middleware triggered') + return next({ + context: { foo: 'foo', method: request.method } as const, + }) + }, +) + +export const createFooServerFn = createServerFn().middleware([fooMiddleware]) + +export const fooFnInsideFactoryFile = createFooServerFn().handler( + async ({ context }) => { + console.log('fooFnInsideFactoryFile handler triggered', context.method) + return { + name: 'fooFnInsideFactoryFile', + context, + } + }, +) diff --git a/e2e/vue-start/server-functions/src/routes/factory/-functions/functions.ts b/e2e/vue-start/server-functions/src/routes/factory/-functions/functions.ts new file mode 100644 index 00000000000..190791a5996 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/factory/-functions/functions.ts @@ -0,0 +1,93 @@ +import { createMiddleware, createServerFn } from '@tanstack/vue-start' +import { createBarServerFn } from './createBarServerFn' +import { createFooServerFn } from './createFooServerFn' +import { createFakeFn } from './createFakeFn' + +export const fooFn = createFooServerFn().handler(({ context }) => { + return { + name: 'fooFn', + context, + } +}) + +export const fooFnPOST = createFooServerFn({ method: 'POST' }).handler( + ({ context }) => { + return { + name: 'fooFnPOST', + context, + } + }, +) + +export const barFn = createBarServerFn().handler(({ context }) => { + return { + name: 'barFn', + context, + } +}) + +export const barFnPOST = createBarServerFn({ method: 'POST' }).handler( + ({ context }) => { + return { + name: 'barFnPOST', + context, + } + }, +) + +const localMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + console.log('local middleware triggered') + return next({ + context: { local: 'local' } as const, + }) + }, +) + +const localFnFactory = createBarServerFn.middleware([localMiddleware]) + +const anotherMiddleware = createMiddleware({ type: 'function' }).server( + ({ next }) => { + console.log('another middleware triggered') + return next({ + context: { another: 'another' } as const, + }) + }, +) + +export const localFn = localFnFactory() + .middleware([anotherMiddleware]) + .handler(({ context }) => { + return { + name: 'localFn', + context, + } + }) + +export const localFnPOST = localFnFactory({ method: 'POST' }) + .middleware([anotherMiddleware]) + .handler(({ context }) => { + return { + name: 'localFnPOST', + context, + } + }) + +export const fakeFn = createFakeFn().handler(async () => { + return { + name: 'fakeFn', + window, + } +}) + +export const composeFactory = createServerFn({ method: 'GET' }).middleware([ + createBarServerFn, +]) +export const composedFn = composeFactory() + .middleware([anotherMiddleware, localFnFactory]) + .handler(({ context }) => { + return { + name: 'composedFn', + context, + } + }) diff --git a/e2e/vue-start/server-functions/src/routes/factory/index.tsx b/e2e/vue-start/server-functions/src/routes/factory/index.tsx new file mode 100644 index 00000000000..8ddf7e6fbcf --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/factory/index.tsx @@ -0,0 +1,224 @@ +import { createFileRoute, deepEqual } from '@tanstack/vue-router' + +import { createServerFn } from '@tanstack/vue-start' +import { fooFnInsideFactoryFile } from './-functions/createFooServerFn' +import { + barFn, + barFnPOST, + composedFn, + fakeFn, + fooFn, + fooFnPOST, + localFn, + localFnPOST, +} from './-functions/functions' +import { computed, defineComponent, ref } from 'vue' +import type { PropType } from 'vue' + +const fnInsideRoute = createServerFn({ method: 'GET' }).handler(() => { + return { + name: 'fnInsideRoute', + } +}) + +const functions = { + fnInsideRoute: { + fn: fnInsideRoute, + type: 'serverFn', + expected: { + name: 'fnInsideRoute', + }, + }, + fooFnInsideFactoryFile: { + fn: fooFnInsideFactoryFile, + type: 'serverFn', + + expected: { + name: 'fooFnInsideFactoryFile', + context: { foo: 'foo', method: 'GET' }, + }, + }, + fooFn: { + fn: fooFn, + type: 'serverFn', + + expected: { + name: 'fooFn', + context: { foo: 'foo', method: 'GET' }, + }, + }, + fooFnPOST: { + fn: fooFnPOST, + type: 'serverFn', + + expected: { + name: 'fooFnPOST', + context: { foo: 'foo', method: 'POST' }, + }, + }, + barFn: { + fn: barFn, + type: 'serverFn', + + expected: { + name: 'barFn', + context: { foo: 'foo', method: 'GET', bar: 'bar' }, + }, + }, + barFnPOST: { + fn: barFnPOST, + type: 'serverFn', + + expected: { + name: 'barFnPOST', + context: { foo: 'foo', method: 'POST', bar: 'bar' }, + }, + }, + localFn: { + fn: localFn, + type: 'serverFn', + + expected: { + name: 'localFn', + context: { + foo: 'foo', + method: 'GET', + bar: 'bar', + local: 'local', + another: 'another', + }, + }, + }, + localFnPOST: { + fn: localFnPOST, + type: 'serverFn', + + expected: { + name: 'localFnPOST', + context: { + foo: 'foo', + method: 'POST', + bar: 'bar', + local: 'local', + another: 'another', + }, + }, + }, + composedFn: { + fn: composedFn, + type: 'serverFn', + expected: { + name: 'composedFn', + context: { + foo: 'foo', + method: 'GET', + bar: 'bar', + another: 'another', + local: 'local', + }, + }, + }, + fakeFn: { + fn: fakeFn, + type: 'localFn', + expected: { + name: 'fakeFn', + window, + }, + }, +} satisfies Record + +interface TestCase { + fn: () => Promise + expected: any + type: 'serverFn' | 'localFn' +} +const Test = defineComponent({ + props: { + fn: { + type: Function as PropType<() => Promise>, + required: true, + }, + expected: { + type: Object as PropType, + required: true, + }, + type: { + type: String as PropType, + required: true, + }, + }, + setup(props) { + const result = ref(null) + const comparison = computed(() => { + if (result.value) { + const isEqual = deepEqual(result.value, props.expected) + return isEqual ? 'equal' : 'not equal' + } + return 'Loading...' + }) + + return () => ( +
+

+
+ It should return{' '} + +
+              {props.type === 'serverFn'
+                ? JSON.stringify(props.expected)
+                : 'localFn'}
+            
+
+
+

+ fn returns: +
+ + {result.value + ? props.type === 'serverFn' + ? JSON.stringify(result.value) + : 'localFn' + : 'Loading...'} + {' '} + + {comparison.value} + +

+ +
+ ) + }, +}) + +const RouteComponent = defineComponent({ + setup() { + return () => ( +
+

Server functions middleware E2E tests

+ {Object.entries(functions).map(([name, testCase]) => ( + + ))} +
+ ) + }, +}) + +export const Route = createFileRoute('/factory/')({ + ssr: false, + component: RouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/formdata-redirect/index.tsx b/e2e/vue-start/server-functions/src/routes/formdata-redirect/index.tsx new file mode 100644 index 00000000000..d23eb342b3f --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/formdata-redirect/index.tsx @@ -0,0 +1,74 @@ +import { createFileRoute, redirect } from '@tanstack/vue-router' +import { createServerFn, useServerFn } from '@tanstack/vue-start' +import { z } from 'zod' + +export const Route = createFileRoute('/formdata-redirect/')({ + component: SubmitPostFormDataFn, + validateSearch: z.object({ + mode: z.union([z.literal('js'), z.literal('no-js')]).default('js'), + }), +}) + +const testValues = { + name: 'Sean', +} + +export const greetUser = createServerFn({ method: 'POST' }) + .inputValidator((data: FormData) => { + if (!(data instanceof FormData)) { + throw new Error('Invalid! FormData is required') + } + const name = data.get('name') + + if (!name) { + throw new Error('Name is required') + } + + return { + name: name.toString(), + } + }) + .handler(({ data: { name } }) => { + throw redirect({ to: '/formdata-redirect/target/$name', params: { name } }) + }) + +function SubmitPostFormDataFn() { + const mode = Route.useSearch({ select: (search) => search.mode }) + const greetUserFn = useServerFn(greetUser) + return ( +
+

Submit POST FormData Fn Call

+
+ It should return redirect to /formdata-redirect/target/{testValues.name}{' '} + and greet the user with their name: + +
+            {testValues.name}
+          
+
+
+
{ + if (mode.value === 'js') { + evt.preventDefault() + const data = new FormData(evt.currentTarget as HTMLFormElement) + await greetUserFn({ data }) + } + }} + > + + +
+
+ ) +} diff --git a/e2e/vue-start/server-functions/src/routes/formdata-redirect/target.$name.tsx b/e2e/vue-start/server-functions/src/routes/formdata-redirect/target.$name.tsx new file mode 100644 index 00000000000..958aeaf6177 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/formdata-redirect/target.$name.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/formdata-redirect/target/$name')({ + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + return ( +
+ Hello{' '} + + {params.value.name} + + ! +
+ ) +} diff --git a/e2e/vue-start/server-functions/src/routes/headers.tsx b/e2e/vue-start/server-functions/src/routes/headers.tsx new file mode 100644 index 00000000000..9cb3d7533c7 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/headers.tsx @@ -0,0 +1,77 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { + getRequestHeaders, + setResponseHeader, +} from '@tanstack/vue-start/server' +import type { RequestHeaderName } from '@tanstack/vue-start/server' +import { defineComponent, ref } from 'vue' + +export const getTestHeaders = createServerFn().handler(() => { + setResponseHeader('x-test-header', 'test-value') + const reqHeaders = Object.fromEntries(getRequestHeaders().entries()) + + return { + serverHeaders: reqHeaders, + headers: reqHeaders, + } +}) + +type TestHeadersResult = { + headers?: Partial> + serverHeaders?: Partial> +} + +const HeadersRouteComponent = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const testHeadersResult = ref(null) + + return () => ( +
+

Headers Test

+
{ + evt.preventDefault() + getTestHeaders().then((data) => { + testHeadersResult.value = data + }) + }} + > + +
+
+

Initial Headers:

+
+            {JSON.stringify(loaderData.value.testHeaders.headers, null, 2)}
+          
+ {testHeadersResult.value && ( + <> +

Updated Headers:

+
+                {JSON.stringify(testHeadersResult.value.headers, null, 2)}
+              
+ + )} +
+
+ ) + }, +}) + +export const Route = createFileRoute('/headers')({ + loader: async () => { + return { + testHeaders: await getTestHeaders(), + } + }, + component: HeadersRouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/index.tsx b/e2e/vue-start/server-functions/src/routes/index.tsx new file mode 100644 index 00000000000..155fb3dff1a --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/index.tsx @@ -0,0 +1,78 @@ +import { Link, createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Server functions E2E tests

+
    +
  • + + Consistent server function returns both on client and server for GET + and POST calls + +
  • +
  • + + submitting multipart/form-data as server function input + +
  • +
  • + + Server function can return null for GET and POST calls + +
  • +
  • + + Server function can correctly send and receive FormData + +
  • +
  • + + server function can correctly send and receive headers + +
  • +
  • + + Direct POST submitting FormData to a Server function returns the + correct message + +
  • +
  • + + invoking a server function with custom response status code + +
  • +
  • + + isomorphic functions can have different implementations on client + and server + +
  • +
  • + + env-only functions can only be called on the server or client + respectively + +
  • +
  • + server function sets cookies +
  • +
  • + + dead code elimation only affects code after transformation + +
  • +
  • + aborting a server function call +
  • +
  • + server function returns raw response +
  • +
+
+ ) +} diff --git a/e2e/vue-start/server-functions/src/routes/isomorphic-fns.tsx b/e2e/vue-start/server-functions/src/routes/isomorphic-fns.tsx new file mode 100644 index 00000000000..6d0679e34dc --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/isomorphic-fns.tsx @@ -0,0 +1,82 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createIsomorphicFn, createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const getEnv = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +const getServerEnv = createServerFn().handler(() => getEnv()) + +const getEcho = createIsomorphicFn() + .server((input: string) => 'server received ' + input) + .client((input) => 'client received ' + input) + +const getServerEcho = createServerFn() + .inputValidator((input: string) => input) + .handler(({ data }) => getEcho(data)) + +const RouteComponent = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const results = ref> | undefined>() + + async function handleClick() { + const envOnClick = getEnv() + const echo = getEcho('hello') + const [serverEnv, serverEcho] = await Promise.all([ + getServerEnv(), + getServerEcho({ data: 'hello' }), + ]) + results.value = { envOnClick, echo, serverEnv, serverEcho } + } + + return () => ( +
+ + {!!results.value && ( +
+

+ getEnv +

+ When we called the function on the server it returned: +
+              {JSON.stringify(results.value.serverEnv)}
+            
+ When we called the function on the client it returned: +
+              {JSON.stringify(results.value.envOnClick)}
+            
+ When we called the function during SSR it returned: +
+              {JSON.stringify(loaderData.value.envOnLoad)}
+            
+
+

+ echo +

+ When we called the function on the server it returned: +
+              {JSON.stringify(results.value.serverEcho)}
+            
+ When we called the function on the client it returned: +
+              {JSON.stringify(results.value.echo)}
+            
+
+ )} +
+ ) + }, +}) + +export const Route = createFileRoute('/isomorphic-fns')({ + component: RouteComponent, + loader() { + return { + envOnLoad: getEnv(), + } + }, +}) diff --git a/e2e/vue-start/server-functions/src/routes/middleware/client-middleware-router.tsx b/e2e/vue-start/server-functions/src/routes/middleware/client-middleware-router.tsx new file mode 100644 index 00000000000..b13fb972fa4 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/middleware/client-middleware-router.tsx @@ -0,0 +1,83 @@ +import { createFileRoute, useRouter } from '@tanstack/vue-router' +import { + createMiddleware, + createServerFn, + getRouterInstance, +} from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const middleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + const router = await getRouterInstance() + return next({ + sendContext: { + routerContext: router.options.context, + }, + }) + }, +) + +const serverFn = createServerFn() + .middleware([middleware]) + .handler(({ context }) => { + return context.routerContext + }) +const RouteComponent = defineComponent({ + setup() { + const serverFnClientResult = ref({}) + const loaderData = Route.useLoaderData() + const router = useRouter() + + return () => ( +
+

Client Middleware has access to router instance

+

+ This component checks that the client middleware has access to the + router instance and thus its context. +

+
+ It should return{' '} + +
+              {JSON.stringify(router.options.context)}
+            
+
+
+

+ serverFn when invoked in the loader returns: +
+ + {JSON.stringify(serverFnClientResult.value)} + +

+

+ serverFn when invoked on the client returns: +
+ + {JSON.stringify(loaderData.value.serverFnLoaderResult)} + +

+ +
+ ) + }, +}) + +export const Route = createFileRoute('/middleware/client-middleware-router')({ + component: RouteComponent, + loader: async () => ({ serverFnLoaderResult: await serverFn() }), +}) diff --git a/e2e/vue-start/server-functions/src/routes/middleware/index.tsx b/e2e/vue-start/server-functions/src/routes/middleware/index.tsx new file mode 100644 index 00000000000..45c68fd1b12 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/middleware/index.tsx @@ -0,0 +1,37 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/middleware/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Server functions middleware E2E tests

+
    +
  • + + Client Middleware has access to router instance + +
  • +
  • + + Client Middleware can send server function reference in context + +
  • +
  • + + Request Middleware in combination with server function + +
  • +
+
+ ) +} diff --git a/e2e/vue-start/server-functions/src/routes/middleware/request-middleware.tsx b/e2e/vue-start/server-functions/src/routes/middleware/request-middleware.tsx new file mode 100644 index 00000000000..63c0fe2b748 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/middleware/request-middleware.tsx @@ -0,0 +1,84 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createMiddleware, createServerFn } from '@tanstack/vue-start' +import { getRequest } from '@tanstack/vue-start/server' +import { defineComponent, ref } from 'vue' + +const requestMiddleware = createMiddleware({ type: 'request' }).server( + async ({ next, request }) => { + return next({ + context: { + requestParam: request.url, + requestFunc: getRequest().url, + }, + }) + }, +) + +const serverFn = createServerFn() + .middleware([requestMiddleware]) + .handler(async ({ context: { requestParam, requestFunc } }) => { + return { requestParam, requestFunc } + }) + +type ServerFnResult = Awaited> + +const RouteComponent = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const clientData = ref(null) + + return () => ( +
+

Request Middleware in combination with server function

+
+
+
+

Loader Data

Request Param: +
+ {loaderData.value.requestParam} +
+ Request Func: +
+ {loaderData.value.requestFunc} +
+
+
+
+ +
+
+
+

Client Data

+ {clientData.value ? ( +
+ Request Param: +
+ {clientData.value.requestParam} +
+ Request Func: +
+ {clientData.value.requestFunc} +
+
+ ) : ( + ' Loading ...' + )} +
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/middleware/request-middleware')({ + loader: () => serverFn(), + component: RouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/middleware/send-serverFn.tsx b/e2e/vue-start/server-functions/src/routes/middleware/send-serverFn.tsx new file mode 100644 index 00000000000..ff6ef46efb4 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/middleware/send-serverFn.tsx @@ -0,0 +1,82 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createMiddleware, createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const middleware = createMiddleware({ type: 'function' }).client( + async ({ next }) => { + return next({ + sendContext: { + serverFn: barFn, + }, + }) + }, +) + +const fooFn = createServerFn() + .middleware([middleware]) + .handler(({ context }) => { + return context.serverFn() + }) +const barFn = createServerFn().handler(() => { + return 'bar' +}) + +const RouteComponent = defineComponent({ + setup() { + const serverFnClientResult = ref({}) + const loaderData = Route.useLoaderData() + + return () => ( +
+

Send server function in context

+

+ This component checks that the client middleware can send a reference + to a server function in the context, which can then be invoked in the + server function handler. +

+
+ It should return{' '} + +
+              {JSON.stringify('bar')}
+            
+
+
+

+ serverFn when invoked in the loader returns: +
+ + {JSON.stringify(serverFnClientResult.value)} + +

+

+ serverFn when invoked on the client returns: +
+ + {JSON.stringify(loaderData.value.serverFnLoaderResult)} + +

+ +
+ ) + }, +}) + +export const Route = createFileRoute('/middleware/send-serverFn')({ + component: RouteComponent, + loader: async () => ({ serverFnLoaderResult: await fooFn() }), +}) diff --git a/e2e/vue-start/server-functions/src/routes/multipart.tsx b/e2e/vue-start/server-functions/src/routes/multipart.tsx new file mode 100644 index 00000000000..c41fd8e4e58 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/multipart.tsx @@ -0,0 +1,113 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const multipartFormDataServerFn = createServerFn({ method: 'POST' }) + .inputValidator((x: unknown) => { + if (!(x instanceof FormData)) { + throw new Error('Invalid form data') + } + + const value = x.get('input_field') + const file = x.get('input_file') + + if (typeof value !== 'string') { + throw new Error('Submitted value is not a string') + } + + if (!(file instanceof File)) { + throw new Error('File is required') + } + + return { + submittedValue: value, + file, + } + }) + .handler(async ({ data }) => { + const contents = await data.file.text() + return { + value: data.submittedValue, + file: { + name: data.file.name, + size: data.file.size, + contents: contents, + }, + } + }) + +const MultipartServerFnCall = defineComponent({ + setup() { + const formRef = ref(null) + const multipartResult = ref({}) + + const handleSubmit = (e: Event) => { + e.preventDefault() + + if (!formRef.value) { + return + } + + const formData = new FormData(formRef.value) + multipartFormDataServerFn({ data: formData }).then((data) => { + multipartResult.value = data + }) + } + + return () => ( +
+

Multipart Server Fn POST Call

+
+ It should return{' '} + +
+              {JSON.stringify({
+                value: 'test field value',
+                file: { name: 'my_file.txt', size: 9, contents: 'test data' },
+              })}
+            
+
+
+
{ + formRef.value = el as HTMLFormElement + }} + data-testid="multipart-form" + > + + + + +
+
+
+            {JSON.stringify(multipartResult.value)}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/multipart')({ + component: MultipartServerFnCall, +}) diff --git a/e2e/vue-start/server-functions/src/routes/primitives/index.tsx b/e2e/vue-start/server-functions/src/routes/primitives/index.tsx new file mode 100644 index 00000000000..561f7646eb4 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/primitives/index.tsx @@ -0,0 +1,146 @@ +import { useQuery } from '@tanstack/vue-query' +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { defineComponent } from 'vue' +import { z } from 'zod' + +function stringify(data: any) { + return JSON.stringify(data === undefined ? '$undefined' : data) +} + +const $stringPost = createServerFn({ method: 'POST' }) + .inputValidator(z.string()) + .handler((ctx) => ctx.data) + +const $stringGet = createServerFn({ method: 'GET' }) + .inputValidator(z.string()) + .handler((ctx) => ctx.data) + +const $undefinedPost = createServerFn({ method: 'POST' }) + .inputValidator(z.undefined()) + .handler((ctx) => ctx.data) + +const $undefinedGet = createServerFn({ method: 'GET' }) + .inputValidator(z.undefined()) + .handler((ctx) => ctx.data) + +const $nullPost = createServerFn({ method: 'POST' }) + .inputValidator(z.null()) + .handler((ctx) => ctx.data) + +const $nullGet = createServerFn({ method: 'GET' }) + .inputValidator(z.null()) + .handler((ctx) => ctx.data) + +interface PrimitiveComponentProps { + serverFn: { + get: (opts: { data: T }) => Promise + post: (opts: { data: T }) => Promise + } + data: { + value: T + type: string + } +} + +function makeTestCase(props: PrimitiveComponentProps) { + return props +} +const testCases = [ + makeTestCase({ + data: { + value: null, + type: 'null', + }, + serverFn: { + get: $nullGet, + post: $nullPost, + }, + }), + makeTestCase({ + data: { + value: undefined, + type: 'undefined', + }, + serverFn: { + get: $undefinedGet, + post: $undefinedPost, + }, + }), + makeTestCase({ + data: { + value: 'foo-bar', + type: 'string', + }, + serverFn: { + get: $stringGet, + post: $stringPost, + }, + }), +] as Array> + +type Method = 'get' | 'post' + +const RouteComponent = defineComponent({ + setup() { + const testQueries = testCases.map((testCase) => { + const makeQuery = (method: Method) => + useQuery(() => ({ + queryKey: [testCase.data.type, method], + queryFn: async () => { + const result = await testCase.serverFn[method]({ + data: testCase.data.value, + }) + if (result === undefined) { + return '$undefined' + } + return result + }, + })) + + return { + testCase, + queries: { + post: makeQuery('post'), + get: makeQuery('get'), + }, + } + }) + + return () => ( + <> + {testQueries.map(({ testCase, queries }) => ( +
+

data type: {testCase.data.type}

+ + {(['post', 'get'] as const).map((method) => { + const testId = `${method}-${testCase.data.type}` + const query = queries[method] + return ( +
+

serverFn method={method}

+

expected

+
+ {stringify(testCase.data.value)} +
+

result

+
+ {query.isSuccess.value ? stringify(query.data.value) : ''} +
+
+
+ ) + })} +
+
+
+ ))} + + ) + }, +}) + +export const Route = createFileRoute('/primitives/')({ + component: RouteComponent, + ssr: true, +}) diff --git a/e2e/vue-start/server-functions/src/routes/raw-response.tsx b/e2e/vue-start/server-functions/src/routes/raw-response.tsx new file mode 100644 index 00000000000..7c3b87d8633 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/raw-response.tsx @@ -0,0 +1,50 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const expectedValue = 'Hello from a server function!' +export const rawResponseFn = createServerFn().handler(() => { + return new Response(expectedValue) +}) + +const RouteComponent = defineComponent({ + setup() { + const formDataResult = ref('') + + return () => ( +
+

Raw Response

+
+ It should return{' '} + +
{expectedValue}
+
+
+ + + +
+
+            {JSON.stringify(formDataResult.value)}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/raw-response')({ + component: RouteComponent, +}) diff --git a/e2e/vue-start/server-functions/src/routes/redirect-test-ssr/index.tsx b/e2e/vue-start/server-functions/src/routes/redirect-test-ssr/index.tsx new file mode 100644 index 00000000000..46d6efcefd4 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/redirect-test-ssr/index.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/vue-query' +import { createFileRoute, redirect } from '@tanstack/vue-router' +import { createServerFn, useServerFn } from '@tanstack/vue-start' +import { Suspense, defineComponent } from 'vue' + +const $redirectServerFn = createServerFn({ method: 'GET' }).handler( + async () => { + throw redirect({ to: '/redirect-test-ssr/target' }) + }, +) + +const RouteComponent = defineComponent({ + setup() { + const redirectFn = useServerFn($redirectServerFn) + const query = useQuery(() => ({ + queryKey: ['redirect-test-ssr'], + queryFn: () => redirectFn(), + suspense: true, + })) + + return () => ( +
+

Redirect Source SSR

+ + {{ + default: () =>
{JSON.stringify(query.data.value)}
, + fallback: () =>
Loading...
, + }} +
+
+ ) + }, +}) + +export const Route = createFileRoute('/redirect-test-ssr/')({ + component: RouteComponent, + ssr: true, +}) diff --git a/e2e/vue-start/server-functions/src/routes/redirect-test-ssr/target.tsx b/e2e/vue-start/server-functions/src/routes/redirect-test-ssr/target.tsx new file mode 100644 index 00000000000..4f9584232ec --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/redirect-test-ssr/target.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/redirect-test-ssr/target')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Redirect Target SSR

+

Successfully redirected!

+
+ ) +} diff --git a/e2e/vue-start/server-functions/src/routes/redirect-test/index.tsx b/e2e/vue-start/server-functions/src/routes/redirect-test/index.tsx new file mode 100644 index 00000000000..87df7fa5b5e --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/redirect-test/index.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/vue-query' +import { createFileRoute, redirect } from '@tanstack/vue-router' +import { createServerFn, useServerFn } from '@tanstack/vue-start' +import { Suspense, defineComponent } from 'vue' + +const $redirectServerFn = createServerFn({ method: 'GET' }).handler( + async () => { + throw redirect({ to: '/redirect-test/target' }) + }, +) + +const RouteComponent = defineComponent({ + setup() { + const redirectFn = useServerFn($redirectServerFn) + const query = useQuery(() => ({ + queryKey: ['redirect-test'], + queryFn: () => redirectFn(), + suspense: true, + })) + + return () => ( +
+

Redirect Source

+ + {{ + default: () =>
{JSON.stringify(query.data.value)}
, + fallback: () =>
Loading...
, + }} +
+
+ ) + }, +}) + +export const Route = createFileRoute('/redirect-test/')({ + component: RouteComponent, + ssr: 'data-only', +}) diff --git a/e2e/vue-start/server-functions/src/routes/redirect-test/target.tsx b/e2e/vue-start/server-functions/src/routes/redirect-test/target.tsx new file mode 100644 index 00000000000..10b25f0a153 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/redirect-test/target.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/redirect-test/target')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+

Redirect Target

+

Successfully redirected!

+
+ ) +} diff --git a/e2e/vue-start/server-functions/src/routes/return-null.tsx b/e2e/vue-start/server-functions/src/routes/return-null.tsx new file mode 100644 index 00000000000..de88ad71138 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/return-null.tsx @@ -0,0 +1,74 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +/** + * This checks whether the server function can + * return null without throwing an error or returning something else. + * @link https://github.com/TanStack/router/issues/2776 + */ + +const $allow_return_null_getFn = createServerFn().handler(async () => { + return null +}) +const $allow_return_null_postFn = createServerFn({ method: 'POST' }).handler( + async () => { + return null + }, +) + +const AllowServerFnReturnNull = defineComponent({ + setup() { + const getServerResult = ref('-') + const postServerResult = ref('-') + + return () => ( +
+

Allow ServerFn to return `null`

+

+ This component checks whether the server function can return null + without throwing an error. +

+
+ It should return{' '} + +
{JSON.stringify(null)}
+
+
+

+ {`GET: $allow_return_null_getFn returns`} +
+ + {JSON.stringify(getServerResult.value)} + +

+

+ {`POST: $allow_return_null_postFn returns`} +
+ + {JSON.stringify(postServerResult.value)} + +

+ +
+ ) + }, +}) + +export const Route = createFileRoute('/return-null')({ + component: AllowServerFnReturnNull, +}) diff --git a/e2e/vue-start/server-functions/src/routes/serialize-form-data.tsx b/e2e/vue-start/server-functions/src/routes/serialize-form-data.tsx new file mode 100644 index 00000000000..e2b117a9ad1 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/serialize-form-data.tsx @@ -0,0 +1,88 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { defineComponent, ref } from 'vue' + +const testValues = { + name: 'Sean', + age: 25, + pet1: 'dog', + pet2: 'cat', + __adder: 1, +} + +export const greetUser = createServerFn({ method: 'POST' }) + .inputValidator((data: FormData) => { + if (!(data instanceof FormData)) { + throw new Error('Invalid! FormData is required') + } + const name = data.get('name') + const age = data.get('age') + const pets = data.getAll('pet') + + if (!name || !age || pets.length === 0) { + throw new Error('Name, age and pets are required') + } + + return { + name: name.toString(), + age: parseInt(age.toString(), 10), + pets: pets.map((pet) => pet.toString()), + } + }) + .handler(({ data: { name, age, pets } }) => { + return `Hello, ${name}! You are ${age + testValues.__adder} years old, and your favorite pets are ${pets.join(',')}.` + }) + +export const SerializeFormDataFnCall = defineComponent({ + setup() { + const formDataResult = ref('') + + return () => ( +
+

Serialize FormData Fn POST Call

+
+ It should return{' '} + +
+              Hello, {testValues.name}! You are{' '}
+              {testValues.age + testValues.__adder} years old, and your favorite
+              pets are {testValues.pet1},{testValues.pet2}.
+            
+
+
+
{ + evt.preventDefault() + const data = new FormData(evt.currentTarget as HTMLFormElement) + greetUser({ data }).then((result) => { + formDataResult.value = result + }) + }} + > + + + + + +
+
+
+            {JSON.stringify(formDataResult.value)}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/serialize-form-data')({ + component: SerializeFormDataFnCall, +}) diff --git a/e2e/vue-start/server-functions/src/routes/status.tsx b/e2e/vue-start/server-functions/src/routes/status.tsx new file mode 100644 index 00000000000..ba627bd3715 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/status.tsx @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn, useServerFn } from '@tanstack/vue-start' +import { setResponseStatus } from '@tanstack/vue-start/server' + +const helloFn = createServerFn().handler(() => { + setResponseStatus(225, `hello`) + return { + hello: 'world', + } +}) + +export const Route = createFileRoute('/status')({ + component: StatusComponent, +}) + +function StatusComponent() { + const hello = useServerFn(helloFn) + + return ( +
+ +
+ ) +} diff --git a/e2e/vue-start/server-functions/src/routes/submit-post-formdata.tsx b/e2e/vue-start/server-functions/src/routes/submit-post-formdata.tsx new file mode 100644 index 00000000000..35e083326d7 --- /dev/null +++ b/e2e/vue-start/server-functions/src/routes/submit-post-formdata.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' + +export const Route = createFileRoute('/submit-post-formdata')({ + component: SubmitPostFormDataFn, +}) + +const testValues = { + name: 'Sean', +} + +export const greetUser = createServerFn({ method: 'POST' }) + .inputValidator((data: FormData) => { + if (!(data instanceof FormData)) { + throw new Error('Invalid! FormData is required') + } + const name = data.get('name') + + if (!name) { + throw new Error('Name is required') + } + + return { + name: name.toString(), + } + }) + .handler(({ data: { name } }) => { + return new Response(`Hello, ${name}!`) + }) + +function SubmitPostFormDataFn() { + return ( +
+

Submit POST FormData Fn Call

+
+ It should return navigate and return{' '} + +
+            Hello, {testValues.name}!
+          
+
+
+
+ + +
+
+ ) +} diff --git a/e2e/vue-start/server-functions/src/styles/app.css b/e2e/vue-start/server-functions/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/vue-start/server-functions/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-functions/src/vite-env.d.ts b/e2e/vue-start/server-functions/src/vite-env.d.ts new file mode 100644 index 00000000000..0b2af560d60 --- /dev/null +++ b/e2e/vue-start/server-functions/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-functions/tests/server-functions.spec.ts b/e2e/vue-start/server-functions/tests/server-functions.spec.ts new file mode 100644 index 00000000000..3b32deb5bdd --- /dev/null +++ b/e2e/vue-start/server-functions/tests/server-functions.spec.ts @@ -0,0 +1,497 @@ +import * as fs from 'node:fs' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { PORT } from '../playwright.config' +import type { Page } from '@playwright/test' + +test('Server function URLs correctly include constant ids', async ({ + page, +}) => { + for (const currentPage of ['/submit-post-formdata', '/formdata-redirect']) { + await page.goto(currentPage) + await page.waitForLoadState('networkidle') + + const form = page.locator('form') + const actionUrl = await form.getAttribute('action') + + expect(actionUrl).toMatch(/^\/_serverFn\/constant_id/) + } +}) + +test('invoking a server function with custom response status code', async ({ + page, +}) => { + await page.goto('/status') + + await page.waitForLoadState('networkidle') + + const requestPromise = new Promise((resolve) => { + page.on('response', (response) => { + expect(response.status()).toBe(225) + expect(response.statusText()).toBe('hello') + expect(response.headers()['content-type']).toContain('application/json') + resolve() + }) + }) + await page.getByTestId('invoke-server-fn').click() + await requestPromise +}) + +test('Consistent server function returns both on client and server for GET and POST calls', async ({ + page, +}) => { + await page.goto('/consistent') + + await page.waitForLoadState('networkidle') + const expected = + (await page + .getByTestId('expected-consistent-server-fns-result') + .textContent()) || '' + expect(expected).not.toBe('') + + await page.getByTestId('test-consistent-server-fn-calls-btn').click() + await page.waitForLoadState('networkidle') + + // GET calls + await expect(page.getByTestId('cons_serverGetFn1-response')).toContainText( + expected, + ) + await expect(page.getByTestId('cons_getFn1-response')).toContainText(expected) + + // POST calls + await expect(page.getByTestId('cons_serverPostFn1-response')).toContainText( + expected, + ) + await expect(page.getByTestId('cons_postFn1-response')).toContainText( + expected, + ) +}) + +test('submitting multipart/form-data as server function input', async ({ + page, +}) => { + await page.goto('/multipart') + + await page.waitForLoadState('networkidle') + const expected = + (await page + .getByTestId('expected-multipart-server-fn-result') + .textContent()) || '' + expect(expected).not.toBe('') + + const fileChooserPromise = page.waitForEvent('filechooser') + await page.getByTestId('multipart-form-file-input').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles({ + name: 'my_file.txt', + mimeType: 'text/plain', + buffer: Buffer.from('test data', 'utf-8'), + }) + await page.getByText('Submit (onClick)').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('multipart-form-response')).toContainText( + expected, + ) +}) + +test('isomorphic functions can have different implementations on client and server', async ({ + page, +}) => { + await page.goto('/isomorphic-fns') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-isomorphic-results-btn').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('server-result')).toContainText('server') + await expect(page.getByTestId('client-result')).toContainText('client') + await expect(page.getByTestId('ssr-result')).toContainText('server') + + await expect(page.getByTestId('server-echo-result')).toContainText( + 'server received hello', + ) + await expect(page.getByTestId('client-echo-result')).toContainText( + 'client received hello', + ) +}) + +test('env-only functions can only be called on the server or client respectively', async ({ + page, +}) => { + await page.goto('/env-only') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('test-env-only-results-btn').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('server-on-server')).toContainText( + 'server got: hello', + ) + await expect(page.getByTestId('server-on-client')).toContainText( + 'serverEcho threw an error: createServerOnlyFn() functions can only be called on the server!', + ) + + await expect(page.getByTestId('client-on-server')).toContainText( + 'clientEcho threw an error: createClientOnlyFn() functions can only be called on the client!', + ) + await expect(page.getByTestId('client-on-client')).toContainText( + 'client got: hello', + ) +}) + +test('Server function can return null for GET and POST calls', async ({ + page, +}) => { + await page.goto('/return-null') + + await page.waitForLoadState('networkidle') + await page.getByTestId('test-allow-server-fn-return-null-btn').click() + await page.waitForLoadState('networkidle') + + // GET call + await expect( + page.getByTestId('allow_return_null_getFn-response'), + ).toContainText(JSON.stringify(null)) + + // POST call + await expect( + page.getByTestId('allow_return_null_postFn-response'), + ).toContainText(JSON.stringify(null)) +}) + +test('Server function can correctly send and receive FormData', async ({ + page, +}) => { + await page.goto('/serialize-form-data') + + await page.waitForLoadState('networkidle') + const expected = + (await page + .getByTestId('expected-serialize-formdata-server-fn-result') + .textContent()) || '' + expect(expected).not.toBe('') + + await page.getByTestId('test-serialize-formdata-fn-calls-btn').click() + await page.waitForLoadState('networkidle') + + await expect( + page.getByTestId('serialize-formdata-form-response'), + ).toContainText(expected) +}) + +test('server function can correctly send and receive headers', async ({ + page, +}) => { + await page.goto('/headers') + + await page.waitForLoadState('networkidle') + let headers = JSON.parse( + await page.getByTestId('initial-headers-result').innerText(), + ) + expect(headers['host']).toBe(`localhost:${PORT}`) + expect(headers['user-agent']).toContain('Mozilla/5.0') + expect(headers['sec-fetch-mode']).toBe('navigate') + + await page.getByTestId('test-headers-btn').click() + await page.waitForSelector('[data-testid="updated-headers-result"]') + + headers = JSON.parse( + await page.getByTestId('updated-headers-result').innerText(), + ) + + expect(headers['host']).toBe(`localhost:${PORT}`) + expect(headers['user-agent']).toContain('Mozilla/5.0') + expect(headers['sec-fetch-mode']).toBe('cors') + expect(headers['referer']).toBe(`http://localhost:${PORT}/headers`) +}) + +test('Direct POST submitting FormData to a Server function returns the correct message', async ({ + page, +}) => { + await page.goto('/submit-post-formdata') + + await page.waitForLoadState('networkidle') + + const expected = + (await page + .getByTestId('expected-submit-post-formdata-server-fn-result') + .textContent()) || '' + expect(expected).not.toBe('') + + await page.getByTestId('test-submit-post-formdata-fn-calls-btn').click() + await page.waitForLoadState('networkidle') + + const result = await page.innerText('body') + expect(result).toBe(expected) +}) + +test("server function's dead code is preserved if already there", async ({ + page, +}) => { + await page.goto('/dead-code-preserve') + + await page.waitForLoadState('networkidle') + await page.getByTestId('test-dead-code-fn-call-btn').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('dead-code-fn-call-response')).toContainText( + '1', + ) + + await fs.promises.rm('count-effect.txt') +}) + +test.describe('server function sets cookies', () => { + async function runCookieTest(page: Page, expectedCookieValue: string) { + for (let i = 1; i <= 4; i++) { + const key = `cookie-${i}-${expectedCookieValue}` + + const actualValue = await page.getByTestId(key).textContent() + expect(actualValue).toBe(expectedCookieValue) + } + } + test('SSR', async ({ page }) => { + const expectedCookieValue = `SSR-${Date.now()}` + await page.goto(`/cookies/set?value=${expectedCookieValue}`) + await runCookieTest(page, expectedCookieValue) + }) + + test('client side navigation', async ({ page }) => { + const expectedCookieValue = `CLIENT-${Date.now()}` + await page.goto(`/cookies?value=${expectedCookieValue}`) + await page.getByTestId('link-to-set').click() + await runCookieTest(page, expectedCookieValue) + }) +}) + +test.describe('aborting a server function call', () => { + test('without aborting', async ({ page }) => { + await page.goto('/abort-signal') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('run-without-abort-btn').click() + await page.waitForLoadState('networkidle') + await page.waitForSelector( + '[data-testid="result"]:has-text("server function result")', + ) + await page.waitForSelector( + '[data-testid="errorMessage"]:has-text("$undefined")', + ) + + const result = (await page.getByTestId('result').textContent()) || '' + expect(result).toBe('server function result') + + const errorMessage = + (await page.getByTestId('errorMessage').textContent()) || '' + expect(errorMessage).toBe('$undefined') + }) + + test('aborting', async ({ page }) => { + await page.goto('/abort-signal') + + await page.waitForLoadState('networkidle') + + await page.getByTestId('run-with-abort-btn').click() + await page.waitForLoadState('networkidle') + await page.waitForSelector('[data-testid="result"]:has-text("$undefined")') + await page.waitForSelector( + '[data-testid="errorMessage"]:has-text("aborted")', + ) + + const result = (await page.getByTestId('result').textContent()) || '' + expect(result).toBe('$undefined') + + const errorMessage = + (await page.getByTestId('errorMessage').textContent()) || '' + expect(errorMessage).toContain('abort') + }) +}) + +test('raw response', async ({ page }) => { + await page.goto('/raw-response') + + await page.waitForLoadState('networkidle') + + const expectedValue = (await page.getByTestId('expected').textContent()) || '' + expect(expectedValue).not.toBe('') + + await page.getByTestId('button').click() + await page.waitForLoadState('networkidle') + + await expect(page.getByTestId('response')).toContainText(expectedValue) +}) + +test.describe('formdata redirect modes', () => { + for (const mode of ['js', 'no-js']) { + test(`Server function can redirect when sending formdata: mode = ${mode}`, async ({ + page, + }) => { + await page.goto('/formdata-redirect?mode=' + mode) + + await page.waitForLoadState('networkidle') + const expected = + (await page + .getByTestId('expected-submit-post-formdata-server-fn-result') + .textContent()) || '' + expect(expected).not.toBe('') + + await page.getByTestId('test-submit-post-formdata-fn-calls-btn').click() + + await page.waitForLoadState('networkidle') + + await expect( + page.getByTestId('formdata-redirect-target-name'), + ).toContainText(expected) + + expect(page.url().endsWith(`/formdata-redirect/target/${expected}`)) + }) + } +}) + +test.describe('middleware', () => { + test.describe('client middleware should have access to router context via the router instance', () => { + async function runTest(page: Page) { + await page.waitForLoadState('networkidle') + + const expected = + (await page.getByTestId('expected-server-fn-result').textContent()) || + '' + expect(expected).not.toBe('') + + await page.getByTestId('btn-serverFn').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('serverFn-loader-result')).toContainText( + expected, + ) + await expect(page.getByTestId('serverFn-client-result')).toContainText( + expected, + ) + } + + test('direct visit', async ({ page }) => { + await page.goto('/middleware/client-middleware-router') + await runTest(page) + }) + + test('client navigation', async ({ page }) => { + await page.goto('/middleware') + await page.getByTestId('client-middleware-router-link').click() + await runTest(page) + }) + }) + + test('server function in combination with request middleware', async ({ + page, + }) => { + await page.goto('/middleware/request-middleware') + + await page.waitForLoadState('networkidle') + + async function checkEqual(prefix: string) { + const requestParam = await page + .getByTestId(`${prefix}-data-request-param`) + .textContent() + expect(requestParam).not.toBe('') + const requestFunc = await page + .getByTestId(`${prefix}-data-request-func`) + .textContent() + expect(requestParam).toBe(requestFunc) + } + + await checkEqual('loader') + + await page.getByTestId('client-call-button').click() + await page.waitForLoadState('networkidle') + + await checkEqual('client') + }) +}) + +test('factory', async ({ page }) => { + await page.goto('/factory') + + await expect(page.getByTestId('factory-route-component')).toBeInViewport() + + const buttons = await page + .locator('[data-testid^="btn-fn-"]') + .elementHandles() + for (const button of buttons) { + const testId = await button.getAttribute('data-testid') + + if (!testId) { + throw new Error('Button is missing data-testid') + } + + const suffix = testId.replace('btn-fn-', '') + + const expected = + (await page.getByTestId(`expected-fn-result-${suffix}`).textContent()) || + '' + expect(expected).not.toBe('') + + await button.click() + + await expect(page.getByTestId(`fn-result-${suffix}`)).toContainText( + expected, + ) + + await expect(page.getByTestId(`fn-comparison-${suffix}`)).toContainText( + 'equal', + ) + } +}) + +test('primitives', async ({ page }) => { + await page.goto('/primitives') + + await page.waitForLoadState('networkidle') + + // Wait for client-side hydration to complete + await expect(page.locator('[data-testid^="expected-"]').first()).toBeVisible() + + 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) + } +}) + +test('redirect in server function on direct navigation', async ({ page }) => { + // Test direct navigation to a route with a server function that redirects + await page.goto('/redirect-test') + + // Should redirect to target page + await expect(page.getByTestId('redirect-target')).toBeVisible() + expect(page.url()).toContain('/redirect-test/target') +}) + +test('redirect in server function called in query during SSR', async ({ + page, +}) => { + // Test direct navigation to a route with a server function that redirects + // when called inside a query with ssr: true + await page.goto('/redirect-test-ssr') + + // Should redirect to target page + await expect(page.getByTestId('redirect-target-ssr')).toBeVisible() + expect(page.url()).toContain('/redirect-test-ssr/target') +}) diff --git a/e2e/vue-start/server-functions/tsconfig.json b/e2e/vue-start/server-functions/tsconfig.json new file mode 100644 index 00000000000..a5ae5ae7e47 --- /dev/null +++ b/e2e/vue-start/server-functions/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-functions/vite.config.ts b/e2e/vue-start/server-functions/vite.config.ts new file mode 100644 index 00000000000..4c4af226a34 --- /dev/null +++ b/e2e/vue-start/server-functions/vite.config.ts @@ -0,0 +1,32 @@ +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' + +const FUNCTIONS_WITH_CONSTANT_ID = [ + 'src/routes/submit-post-formdata.tsx/greetUser_createServerFn_handler', + 'src/routes/formdata-redirect/index.tsx/greetUser_createServerFn_handler', +] + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + serverFns: { + generateFunctionId: (opts) => { + const id = `${opts.filename}/${opts.functionName}` + if (FUNCTIONS_WITH_CONSTANT_ID.includes(id)) return 'constant_id' + else return undefined + }, + }, + }), + vue(), + vueJsx(), + ], +}) diff --git a/e2e/vue-start/server-routes/package.json b/e2e/vue-start/server-routes/package.json index 31a1444bccb..680edbd581b 100644 --- a/e2e/vue-start/server-routes/package.json +++ b/e2e/vue-start/server-routes/package.json @@ -32,7 +32,7 @@ "@types/node": "^22.10.2", "combinate": "^1.1.11", "postcss": "^8.5.1", - "srvx": "^0.8.6", + "srvx": "^0.9.8", "tailwindcss": "^4.1.17", "typescript": "^5.7.2", "@vitejs/plugin-vue": "^6.0.3", diff --git a/packages/vue-router/README.md b/packages/vue-router/README.md index aef2489ce02..25c561a896e 100644 --- a/packages/vue-router/README.md +++ b/packages/vue-router/README.md @@ -2,7 +2,7 @@ # TanStack Vue Router -![TanStack Router Header](https://github.com/tanstack/router/raw/main/media/header.png) +![TanStack Router Header](https://github.com/tanstack/router/raw/main/media/header_router.png) 🤖 Type-safe router w/ built-in caching & URL state management for Vue! diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json index b708dafc044..f6b5df788ad 100644 --- a/packages/vue-router/package.json +++ b/packages/vue-router/package.json @@ -72,6 +72,7 @@ "@tanstack/history": "workspace:*", "@tanstack/router-core": "workspace:*", "@tanstack/vue-store": "^0.8.0", + "@vue/runtime-dom": "^3.5.25", "isbot": "^5.1.22", "jsesc": "^3.0.2", "tiny-invariant": "^1.3.3", diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 806fee28d52..998aecef2ad 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -19,39 +19,48 @@ import type { RegisteredRouter, RoutePaths, } from '@tanstack/router-core' +import type { AnchorHTMLAttributes, ReservedProps } from '@vue/runtime-dom' import type { ValidateLinkOptions, ValidateLinkOptionsArray, } from './typePrimitives' -// Type definitions to replace missing Vue JSX types type EventHandler = (e: TEvent) => void -interface HTMLAttributes { - class?: string - style?: Record - onClick?: EventHandler - onFocus?: EventHandler - // Vue 3's h() function expects lowercase event names after 'on' prefix - onMouseenter?: EventHandler - onMouseleave?: EventHandler - onMouseover?: EventHandler - onMouseout?: EventHandler - onTouchstart?: EventHandler - // Also accept the camelCase versions for external API compatibility - onMouseEnter?: EventHandler - onMouseLeave?: EventHandler - onMouseOver?: EventHandler - onMouseOut?: EventHandler - onTouchStart?: EventHandler - [key: string]: any + +type DataAttributes = { + [K in `data-${string}`]?: unknown } +type LinkHTMLAttributes = AnchorHTMLAttributes & + ReservedProps & + DataAttributes & { + // Vue's runtime-dom types use lowercase event names. + // Also accept camelCase versions for external API compatibility. + onMouseEnter?: EventHandler + onMouseLeave?: EventHandler + onMouseOver?: EventHandler + onMouseOut?: EventHandler + onTouchStart?: EventHandler + + // `disabled` is not a valid attribute, but is useful when using `asChild`. + disabled?: boolean + } + interface StyledProps { - class?: string - style?: Record - [key: string]: any + class?: LinkHTMLAttributes['class'] + style?: LinkHTMLAttributes['style'] + [key: string]: unknown } +type PropsOfComponent = + // Functional components + TComp extends (props: infer P, ...args: Array) => any + ? P + : // Vue components (defineComponent, class components, etc) + TComp extends Vue.Component + ? P + : Record + export function useLinkProps< TRouter extends AnyRouter = RegisteredRouter, TFrom extends RoutePaths | string = string, @@ -60,7 +69,7 @@ export function useLinkProps< TMaskTo extends string = '', >( options: UseLinkPropsOptions, -): HTMLAttributes { +): LinkHTMLAttributes { const router = useRouter() const isTransitioning = Vue.ref(false) let hasRenderFetched = false @@ -195,6 +204,7 @@ export function useLinkProps< // Create safe props that can be spread const getPropsSafeToSpread = () => { const result: Record = {} + const optionRecord = options as unknown as Record for (const key in options) { if ( ![ @@ -233,7 +243,7 @@ export function useLinkProps< 'additionalProps', ].includes(key) ) { - result[key] = options[key] + result[key] = optionRecord[key] } } return result @@ -241,7 +251,7 @@ export function useLinkProps< if (type.value === 'external') { // External links just have simple props - const externalProps: HTMLAttributes = { + const externalProps: Record = { ...getPropsSafeToSpread(), ref, href: options.to, @@ -265,11 +275,11 @@ export function useLinkProps< } }) - return externalProps + return externalProps as LinkHTMLAttributes } // The click handler - const handleClick = (e: MouseEvent): void => { + const handleClick = (e: PointerEvent): void => { // Check actual element's target attribute as fallback const elementTarget = ( e.currentTarget as HTMLAnchorElement | SVGAElement @@ -307,7 +317,7 @@ export function useLinkProps< startTransition: options.startTransition, viewTransition: options.viewTransition, ignoreBlocker: options.ignoreBlocker, - } as any) + }) } } @@ -448,7 +458,7 @@ export function useLinkProps< // Create static event handlers that don't change between renders const staticEventHandlers = { - onClick: composeEventHandlers([ + onClick: composeEventHandlers([ options.onClick, handleClick, ]) as any, @@ -480,8 +490,8 @@ export function useLinkProps< // Compute all props synchronously to avoid hydration mismatches // Using Vue.computed ensures props are calculated at render time, not after - const computedProps = Vue.computed(() => { - const result: HTMLAttributes = { + const computedProps = Vue.computed(() => { + const result: Record = { ...getPropsSafeToSpread(), href: href.value, ref, @@ -523,20 +533,20 @@ export function useLinkProps< for (const key of Object.keys(activeP)) { if (key !== 'class' && key !== 'style') { - result[key] = activeP[key] + result[key] = (activeP as any)[key] } } for (const key of Object.keys(inactiveP)) { if (key !== 'class' && key !== 'style') { - result[key] = inactiveP[key] + result[key] = (inactiveP as any)[key] } } - return result + return result as LinkHTMLAttributes }) // Return the computed ref itself - callers should access .value - return computedProps as unknown as HTMLAttributes + return computedProps as unknown as LinkHTMLAttributes } // Type definitions @@ -547,7 +557,7 @@ export type UseLinkPropsOptions< TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '.', > = ActiveLinkOptions<'a', TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & - HTMLAttributes + LinkHTMLAttributes export type ActiveLinkOptions< TComp = 'a', @@ -560,7 +570,9 @@ export type ActiveLinkOptions< ActiveLinkOptionProps type ActiveLinkProps = Partial< - HTMLAttributes & { + (TComp extends keyof HTMLElementTagNameMap + ? LinkHTMLAttributes + : PropsOfComponent) & { [key: `data-${string}`]: unknown } > @@ -591,15 +603,16 @@ export type LinkProps< export interface LinkPropsChildren { // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns children?: - | Vue.VNode - | ((state: { isActive: boolean; isTransitioning: boolean }) => Vue.VNode) + | Vue.VNodeChild + | ((state: { + isActive: boolean + isTransitioning: boolean + }) => Vue.VNodeChild) } type LinkComponentVueProps = TComp extends keyof HTMLElementTagNameMap - ? Omit - : TComp extends Vue.Component - ? Record - : Record + ? Omit + : Omit, keyof CreateLinkProps> export type LinkComponentProps< TComp = 'a', @@ -620,9 +633,12 @@ export type CreateLinkProps = LinkProps< string > -export type LinkComponent = < +export type LinkComponent< + in out TComp, + in out TDefaultFrom extends string = string, +> = < TRouter extends AnyRouter = RegisteredRouter, - const TFrom extends string = string, + const TFrom extends string = TDefaultFrom, const TTo extends string | undefined = undefined, const TMaskFrom extends string = TFrom, const TMaskTo extends string = '', @@ -657,7 +673,7 @@ export function createLink( name: 'CreatedLink', inheritAttrs: false, setup(_, { attrs, slots }) { - return () => Vue.h(Link, { ...attrs, _asChild: Comp }, slots) + return () => Vue.h(LinkImpl as any, { ...attrs, _asChild: Comp }, slots) }, }) as any } @@ -696,7 +712,7 @@ const LinkImpl = Vue.defineComponent({ const allProps = { ...props, ...attrs } const linkPropsComputed = useLinkProps( allProps as any, - ) as unknown as Vue.ComputedRef + ) as unknown as Vue.ComputedRef return () => { const Component = props._asChild || 'a' @@ -720,7 +736,7 @@ const LinkImpl = Vue.defineComponent({ if (Component === 'svg') { // Create props without class for svg link const svgLinkProps = { ...linkProps } - delete (svgLinkProps as any).class + delete svgLinkProps.class return Vue.h('svg', {}, [Vue.h('a', svgLinkProps, slotContent)]) } @@ -743,17 +759,9 @@ const LinkImpl = Vue.defineComponent({ /** * Link component with proper TypeScript generics support */ -export const Link = LinkImpl as unknown as { - < - TRouter extends AnyRouter = RegisteredRouter, - TFrom extends RoutePaths | string = string, - TTo extends string | undefined = '.', - TMaskFrom extends RoutePaths | string = TFrom, - TMaskTo extends string = '.', - >( - props: LinkComponentProps<'a', TRouter, TFrom, TTo, TMaskFrom, TMaskTo>, - ): Vue.VNode -} +export const Link = LinkImpl as unknown as Vue.Component & + Vue.Component & + LinkComponent<'a'> function isCtrlEvent(e: MouseEvent) { return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index ed2a5e6de32..d8649ff1bdf 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -14,7 +14,7 @@ function prependDoctype( let sentDoctype = false return new NodeReadableStream({ - async start(controller) { + start(controller) { const reader = readable.getReader() async function pump(): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 224c8db4305..bfee5c21683 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1458,7 +1458,7 @@ importers: version: 2.6.0 vite: specifier: ^7.1.7 - version: 7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + 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) devDependencies: '@playwright/test': specifier: ^1.56.1 @@ -1477,7 +1477,7 @@ importers: version: 19.2.2(@types/react@19.2.2) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 4.7.0(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)) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.5.6) @@ -1492,7 +1492,7 @@ importers: 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@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + 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)) e2e/react-start/custom-basepath: dependencies: @@ -4965,6 +4965,82 @@ importers: specifier: ^3.1.8 version: 3.1.8(typescript@5.9.2) + e2e/vue-start/server-functions: + 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.9.8 + version: 0.9.8 + 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)) + e2e/vue-start/server-routes: dependencies: '@tanstack/vue-query': @@ -5029,8 +5105,8 @@ importers: specifier: ^8.5.1 version: 8.5.6 srvx: - specifier: ^0.8.6 - version: 0.8.15 + specifier: ^0.9.8 + version: 0.9.8 tailwindcss: specifier: ^4.1.17 version: 4.1.17 @@ -7523,7 +7599,7 @@ importers: 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) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.56.1)(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))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -9930,7 +10006,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(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)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(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)) '@tailwindcss/postcss': specifier: ^4.1.15 version: 4.1.15 @@ -10168,7 +10244,7 @@ importers: version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.10)(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)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.56.1)(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))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -10659,16 +10735,16 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^5.2.3 - version: 5.2.4(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)) + version: 5.2.4(vite@7.1.7(@types/node@22.10.2)(jiti@1.21.7)(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: ^4.1.2 - version: 4.2.0(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)) + version: 4.2.0(vite@7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2)) typescript: specifier: ^5.7.2 version: 5.9.2 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) + version: 7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) vue-tsc: specifier: ^3.1.5 version: 3.1.5(typescript@5.9.2) @@ -11757,6 +11833,9 @@ importers: '@tanstack/vue-store': specifier: ^0.8.0 version: 0.8.0(vue@3.5.25(typescript@5.9.2)) + '@vue/runtime-dom': + specifier: ^3.5.25 + version: 3.5.25 isbot: specifier: ^5.1.22 version: 5.1.28 @@ -26191,13 +26270,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.52.5) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.52.5) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0) '@netlify/redirects': 3.1.0 @@ -26265,12 +26344,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.52.5)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.52.5) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.52.5) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -26360,9 +26439,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(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))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(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))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(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)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(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)) 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) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -26390,9 +26469,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(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))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(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))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) 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) @@ -26420,13 +26499,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.52.5)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.52.5) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.52.5) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -29502,7 +29581,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.52.5)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.52.5) @@ -29563,18 +29642,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': - dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@4.7.0(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))': dependencies: '@babel/core': 7.28.5 @@ -29611,6 +29678,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue-jsx@4.2.0(vite@7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.40 + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) + vite: 7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vue: 3.5.25(typescript@5.9.2) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue-jsx@4.2.0(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.8.3))': dependencies: '@babel/core': 7.28.5 @@ -29645,6 +29723,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@5.2.4(vite@7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.25(typescript@5.9.2))': + dependencies: + vite: 7.1.7(@types/node@22.10.2)(jiti@1.21.7)(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@5.2.4(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.8.3))': dependencies: 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) @@ -29908,7 +29991,7 @@ snapshots: '@volar/language-core': 2.4.11 '@vue/compiler-dom': 3.5.14 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.14 + '@vue/shared': 3.5.25 computeds: 0.0.1 minimatch: 9.0.5 muggle-string: 0.4.1 @@ -29921,7 +30004,7 @@ snapshots: '@volar/language-core': 2.4.11 '@vue/compiler-dom': 3.5.14 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.14 + '@vue/shared': 3.5.25 computeds: 0.0.1 minimatch: 9.0.5 muggle-string: 0.4.1 @@ -36477,17 +36560,6 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): - dependencies: - debug: 4.4.3 - globrex: 0.1.2 - tsconfck: 3.1.4(typescript@5.9.2) - optionalDependencies: - vite: 7.1.7(@types/node@22.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - - typescript - vite-tsconfig-paths@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)): dependencies: debug: 4.4.3 @@ -36537,7 +36609,7 @@ snapshots: optionalDependencies: 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) - vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1): + vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.56.1)(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))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -36566,7 +36638,7 @@ snapshots: '@types/node': 22.10.2 '@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.56.1)(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))(vitest@3.2.4) '@vitest/ui': 3.0.6(vitest@3.2.4) - jsdom: 25.0.1 + jsdom: 27.0.0(postcss@8.5.6) transitivePeerDependencies: - jiti - less @@ -36581,7 +36653,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1): + vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -36610,7 +36682,7 @@ snapshots: '@types/node': 22.10.2 '@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.56.1)(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))(vitest@3.2.4) '@vitest/ui': 3.0.6(vitest@3.2.4) - jsdom: 27.0.0(postcss@8.5.6) + jsdom: 25.0.1 transitivePeerDependencies: - jiti - less