diff --git a/e2e/vue-start/selective-ssr/.gitignore b/e2e/vue-start/selective-ssr/.gitignore new file mode 100644 index 00000000000..08eba9e7065 --- /dev/null +++ b/e2e/vue-start/selective-ssr/.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/ + +count.txt diff --git a/e2e/vue-start/selective-ssr/.prettierignore b/e2e/vue-start/selective-ssr/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/vue-start/selective-ssr/.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/selective-ssr/package.json b/e2e/vue-start/selective-ssr/package.json new file mode 100644 index 00000000000..0fa084d6d55 --- /dev/null +++ b/e2e/vue-start/selective-ssr/package.json @@ -0,0 +1,33 @@ +{ + "name": "tanstack-vue-start-e2e-selective-ssr", + "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-router": "workspace:^", + "@tanstack/vue-start": "workspace:^", + "vue": "^3.5.25", + "zod": "^3.24.2" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "postcss": "^8.5.1", + "srvx": "^0.8.6", + "tailwindcss": "^4.1.17", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "@vitejs/plugin-vue": "^6.0.3", + "@vitejs/plugin-vue-jsx": "^5.1.2", + "vite-tsconfig-paths": "^5.1.4", + "vue-tsc": "^3.1.8" + } +} diff --git a/e2e/vue-start/selective-ssr/playwright.config.ts b/e2e/vue-start/selective-ssr/playwright.config.ts new file mode 100644 index 00000000000..badd6db0eb3 --- /dev/null +++ b/e2e/vue-start/selective-ssr/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +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: `VITE_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_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/selective-ssr/postcss.config.mjs b/e2e/vue-start/selective-ssr/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/vue-start/selective-ssr/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/vue-start/selective-ssr/src/routeTree.gen.ts b/e2e/vue-start/selective-ssr/src/routeTree.gen.ts new file mode 100644 index 00000000000..474705b7e72 --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/routeTree.gen.ts @@ -0,0 +1,112 @@ +/* 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 PostsRouteImport } from './routes/posts' +import { Route as IndexRouteImport } from './routes/index' +import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' + +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const PostsPostIdRoute = PostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => PostsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/posts': typeof PostsRouteWithChildren + '/posts/$postId': typeof PostsPostIdRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/posts': typeof PostsRouteWithChildren + '/posts/$postId': typeof PostsPostIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/posts': typeof PostsRouteWithChildren + '/posts/$postId': typeof PostsPostIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/posts' | '/posts/$postId' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/posts' | '/posts/$postId' + id: '__root__' | '/' | '/posts' | '/posts/$postId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PostsRoute: typeof PostsRouteWithChildren +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/$postId': { + id: '/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof PostsPostIdRouteImport + parentRoute: typeof PostsRoute + } + } +} + +interface PostsRouteChildren { + PostsPostIdRoute: typeof PostsPostIdRoute +} + +const PostsRouteChildren: PostsRouteChildren = { + PostsPostIdRoute: PostsPostIdRoute, +} + +const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PostsRoute: PostsRouteWithChildren, +} +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/selective-ssr/src/router.tsx b/e2e/vue-start/selective-ssr/src/router.tsx new file mode 100644 index 00000000000..0c61f2eb9a4 --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/router.tsx @@ -0,0 +1,17 @@ +import { createRouter } from '@tanstack/vue-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + }) + + return router +} + +declare module '@tanstack/vue-router' { + interface Register { + router: ReturnType + } +} diff --git a/e2e/vue-start/selective-ssr/src/routes/__root.tsx b/e2e/vue-start/selective-ssr/src/routes/__root.tsx new file mode 100644 index 00000000000..a952abd4e02 --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/routes/__root.tsx @@ -0,0 +1,157 @@ +/// +import { + Body, + ClientOnly, + HeadContent, + Html, + Link, + Outlet, + Scripts, + createRootRoute, + useRouterState, +} from '@tanstack/vue-router' +import { z } from 'zod' +import { ssrSchema } from '~/search' +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', + }, + { + title: 'Selective SSR E2E Test', + }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + validateSearch: z.object({ root: ssrSchema }), + ssr: ({ search }) => { + if (typeof window !== 'undefined') { + const error = `ssr() for ${Route.id} should not be called on the client` + console.error(error) + throw new Error(error) + } + if (search.status === 'success') { + return search.value.root?.ssr + } + }, + beforeLoad: ({ search }) => { + console.log( + `beforeLoad for ${Route.id} called on the ${typeof window !== 'undefined' ? 'client' : 'server'}`, + ) + if ( + search.root?.expected?.data === 'client' && + typeof window === 'undefined' + ) { + const error = `Expected beforeLoad for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return { + root: typeof window === 'undefined' ? 'server' : 'client', + search, + } + }, + loader: ({ context }) => { + console.log( + `loader for ${Route.id} called on the ${typeof window !== 'undefined' ? 'client' : 'server'}`, + ) + + if ( + context.search.root?.expected?.data === 'client' && + typeof window === 'undefined' + ) { + const error = `Expected loader for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return { root: typeof window === 'undefined' ? 'server' : 'client' } + }, + shellComponent: RootDocument, + component: () => { + const search = Route.useSearch() + if ( + typeof window === 'undefined' && + search.value.root?.expected?.render === 'client-only' + ) { + const error = `Expected component for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + const loaderData = Route.useLoaderData() + const context = Route.useRouteContext() + return ( +
+

root

+
+ ssr: {JSON.stringify(search.value.root?.ssr ?? 'undefined')} +
+
+ expected data location execution:{' '} + + {search.value.root?.expected?.data} + +
+
+ loader: {loaderData.value.root} +
+
+ context: {context.value.root} +
+
+ +
+ ) + }, +}) + +function RootDocument(_: unknown, { slots }: { slots: any }) { + const routerState = useRouterState({ + select: (state) => ({ + isLoading: state.isLoading, + status: state.status, + }), + }) + return ( + + + + + +
+

Selective SSR E2E Test

+ + Home + +
+
+ +
+ router isLoading:{' '} + + {routerState.value.isLoading ? 'true' : 'false'} + +
+
+ router status:{' '} + {routerState.value.status} +
+
+
+ {slots.default?.()} + + + + ) +} diff --git a/e2e/vue-start/selective-ssr/src/routes/index.tsx b/e2e/vue-start/selective-ssr/src/routes/index.tsx new file mode 100644 index 00000000000..92fc305b631 --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/routes/index.tsx @@ -0,0 +1,180 @@ +import { Link, createFileRoute, linkOptions } from '@tanstack/vue-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +const baseTestCase = linkOptions({ + to: '/posts/$postId', + params: { postId: '1' }, +}) + +const testCases = [ + { + link: linkOptions({ + ...baseTestCase, + search: { + root: { + ssr: undefined, + expected: { data: 'server', render: 'server-and-client' }, + }, + posts: { + ssr: undefined, + + expected: { data: 'server', render: 'server-and-client' }, + }, + postId: { + ssr: undefined, + + expected: { data: 'server', render: 'server-and-client' }, + }, + }, + }), + }, + { + link: linkOptions({ + ...baseTestCase, + search: { + root: { + ssr: false, + expected: { data: 'client', render: 'client-only' }, + }, + posts: { + ssr: undefined, + + expected: { data: 'client', render: 'client-only' }, + }, + postId: { + ssr: undefined, + + expected: { data: 'client', render: 'client-only' }, + }, + }, + }), + }, + { + link: linkOptions({ + ...baseTestCase, + search: { + root: { + ssr: false, + expected: { data: 'client', render: 'client-only' }, + }, + posts: { + ssr: false, + expected: { data: 'client', render: 'client-only' }, + }, + postId: { + ssr: true, + expected: { data: 'client', render: 'client-only' }, + }, + }, + }), + }, + { + link: linkOptions({ + ...baseTestCase, + search: { + root: { + ssr: true, + expected: { data: 'server', render: 'server-and-client' }, + }, + posts: { + ssr: false, + expected: { data: 'client', render: 'client-only' }, + }, + postId: { + ssr: undefined, + + expected: { data: 'client', render: 'client-only' }, + }, + }, + }), + }, + { + link: linkOptions({ + ...baseTestCase, + search: { + root: { + ssr: true, + expected: { data: 'server', render: 'server-and-client' }, + }, + posts: { + ssr: 'data-only', + expected: { data: 'server', render: 'client-only' }, + }, + postId: { + ssr: undefined, + + expected: { data: 'server', render: 'client-only' }, + }, + }, + }), + }, + { + link: linkOptions({ + ...baseTestCase, + search: { + root: { + ssr: 'data-only', + expected: { data: 'server', render: 'client-only' }, + }, + posts: { + ssr: true, + expected: { data: 'server', render: 'client-only' }, + }, + postId: { + ssr: undefined, + + expected: { data: 'server', render: 'client-only' }, + }, + }, + }), + }, + { + link: linkOptions({ + ...baseTestCase, + search: { + root: { + ssr: true, + expected: { data: 'server', render: 'server-and-client' }, + }, + posts: { + ssr: true, + expected: { data: 'server', render: 'server-and-client' }, + }, + postId: { + ssr: false, + expected: { data: 'client', render: 'client-only' }, + }, + }, + }), + }, +] + +function Home() { + const links = testCases.map((t, index) => { + const key = `testcase-${index}-link` + + return ( +
+ + root: {JSON.stringify(t.link.search.root.ssr ?? 'undefined')} posts:{' '} + {JSON.stringify(t.link.search.posts.ssr ?? 'undefined')} $postId:{' '} + {JSON.stringify(t.link.search.postId.ssr ?? 'undefined')} + +
+
+
+ ) + }) + + return ( + <> +
+ test count: {links.length} +
+
{links}
+ + ) +} diff --git a/e2e/vue-start/selective-ssr/src/routes/posts.$postId.tsx b/e2e/vue-start/selective-ssr/src/routes/posts.$postId.tsx new file mode 100644 index 00000000000..4e7065cc48f --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/routes/posts.$postId.tsx @@ -0,0 +1,82 @@ +import { createFileRoute } from '@tanstack/vue-router' +import z from 'zod' +import { ssrSchema } from '~/search' + +export const Route = createFileRoute('/posts/$postId')({ + validateSearch: z.object({ postId: ssrSchema }), + ssr: ({ search }) => { + if (typeof window !== 'undefined') { + const error = `ssr() for ${Route.id} should not be called on the client` + console.error(error) + throw new Error(error) + } + if (search.status === 'success') { + return search.value.postId?.ssr + } + }, + beforeLoad: ({ search }) => { + console.log( + `beforeLoad for ${Route.id} called on the ${typeof window !== 'undefined' ? 'client' : 'server'}`, + ) + if ( + search.postId?.expected?.data === 'client' && + typeof window === 'undefined' + ) { + const error = `Expected beforeLoad for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return { + postId: typeof window === 'undefined' ? 'server' : 'client', + search, + } + }, + loader: ({ context }) => { + console.log( + `loader for ${Route.id} called on the ${typeof window !== 'undefined' ? 'client' : 'server'}`, + ) + + if ( + context.search.postId?.expected?.data === 'client' && + typeof window === 'undefined' + ) { + const error = `Expected loader for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return { postId: typeof window === 'undefined' ? 'server' : 'client' } + }, + component: () => { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + const context = Route.useRouteContext() + if ( + typeof window === 'undefined' && + search.value.postId?.expected?.render === 'client-only' + ) { + const error = `Expected component for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return ( +
+

postId

+
+ ssr: {JSON.stringify(search.value.postId?.ssr ?? 'undefined')} +
+
+ expected data location execution:{' '} + + {search.value.postId?.expected?.data} + +
+
+ loader: {loaderData.value.postId} +
+
+ context: {context.value.postId} +
+
+ ) + }, +}) diff --git a/e2e/vue-start/selective-ssr/src/routes/posts.tsx b/e2e/vue-start/selective-ssr/src/routes/posts.tsx new file mode 100644 index 00000000000..6c4e490dde7 --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/routes/posts.tsx @@ -0,0 +1,84 @@ +import { Outlet, createFileRoute } from '@tanstack/vue-router' +import z from 'zod' +import { ssrSchema } from '~/search' + +export const Route = createFileRoute('/posts')({ + validateSearch: z.object({ posts: ssrSchema }), + ssr: ({ search }) => { + if (typeof window !== 'undefined') { + const error = `ssr() for ${Route.id} should not be called on the client` + console.error(error) + throw new Error(error) + } + if (search.status === 'success') { + return search.value.posts?.ssr + } + }, + beforeLoad: ({ search }) => { + console.log( + `beforeLoad for ${Route.id} called on the ${typeof window !== 'undefined' ? 'client' : 'server'}`, + ) + if ( + search.posts?.expected?.data === 'client' && + typeof window === 'undefined' + ) { + const error = `Expected beforeLoad for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return { + posts: typeof window === 'undefined' ? 'server' : 'client', + search, + } + }, + loader: ({ context }) => { + console.log( + `loader for ${Route.id} called on the ${typeof window !== 'undefined' ? 'client' : 'server'}`, + ) + + if ( + context.search.posts?.expected?.data === 'client' && + typeof window === 'undefined' + ) { + const error = `Expected loader for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return { posts: typeof window === 'undefined' ? 'server' : 'client' } + }, + component: () => { + const search = Route.useSearch() + const loaderData = Route.useLoaderData() + const context = Route.useRouteContext() + if ( + typeof window === 'undefined' && + search.value.posts?.expected?.render === 'client-only' + ) { + const error = `Expected component for ${Route.id} to be executed on the client, but it is running on the server` + console.error(error) + throw new Error(error) + } + return ( +
+

posts

+
+ ssr: {JSON.stringify(search.value.posts?.ssr ?? 'undefined')} +
+
+ expected data location execution:{' '} + + {search.value.posts?.expected?.data} + +
+
+ loader: {loaderData.value.posts} +
+
+ context: {context.value.posts} +
+
+ +
+ ) + }, +}) diff --git a/e2e/vue-start/selective-ssr/src/search.ts b/e2e/vue-start/selective-ssr/src/search.ts new file mode 100644 index 00000000000..5df18b7d6b9 --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/search.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const ssrSchema = z + .object({ + ssr: z.union([z.literal('data-only'), z.boolean()]).optional(), + expected: z + .object({ + data: z.union([z.literal('client'), z.literal('server')]), + render: z.union([ + z.literal('client-only'), + z.literal('server-and-client'), + ]), + }) + .optional(), + }) + .optional() diff --git a/e2e/vue-start/selective-ssr/src/styles/app.css b/e2e/vue-start/selective-ssr/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/vue-start/selective-ssr/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/selective-ssr/tests/app.spec.ts b/e2e/vue-start/selective-ssr/tests/app.spec.ts new file mode 100644 index 00000000000..aea216d5065 --- /dev/null +++ b/e2e/vue-start/selective-ssr/tests/app.spec.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +const testCount = 7 + +test.describe('selective ssr', () => { + test('testcount matches', async ({ page }) => { + await page.goto('/') + + await expect(page.getByTestId('test-count')).toHaveText(`${testCount}`) + }) + + for (let i = 0; i < testCount; i++) { + test(`run test ${i}`, async ({ page }) => { + await page.goto('/') + const testId = `testcase-${i}-link` + await page.getByTestId(testId).click() + + // wait for page to be loaded by waiting for the leaf route to be rendered + await expect(page.getByTestId('postId-heading')).toContainText('postId') + + // check expectations + await Promise.all( + ['root', 'posts', 'postId'].map(async (route) => { + const expectedData = await page + .getByTestId(`${route}-data-expected`) + .textContent() + expect(expectedData).not.toBeNull() + await expect(page.getByTestId(`${route}-loader`)).toContainText( + expectedData!, + ) + await expect(page.getByTestId(`${route}-context`)).toContainText( + expectedData!, + ) + }), + ) + await expect(page.getByTestId('router-isLoading')).toContainText('false') + await expect(page.getByTestId('router-status')).toContainText('idle') + }) + } +}) diff --git a/e2e/vue-start/selective-ssr/tsconfig.json b/e2e/vue-start/selective-ssr/tsconfig.json new file mode 100644 index 00000000000..f9f5aab3dae --- /dev/null +++ b/e2e/vue-start/selective-ssr/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "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/selective-ssr/vite.config.ts b/e2e/vue-start/selective-ssr/vite.config.ts new file mode 100644 index 00000000000..79edfc05c26 --- /dev/null +++ b/e2e/vue-start/selective-ssr/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/vue-start/plugin/vite' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + vue(), + vueJsx(), + ], +}) diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 11db7a33e90..c737f446b9c 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -9,6 +9,7 @@ import { rootRouteId, } from '@tanstack/router-core' import { CatchBoundary, ErrorComponent } from './CatchBoundary' +import { ClientOnly } from './ClientOnly' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' import { CatchNotFound } from './not-found' @@ -16,7 +17,7 @@ import { matchContext } from './matchContext' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import type { VNode } from 'vue' -import type { AnyRoute } from '@tanstack/router-core' +import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' export const Match = Vue.defineComponent({ name: 'Match', @@ -67,6 +68,8 @@ export const Match = Vue.defineComponent({ routeId, parentRouteId, loadedAt: s.loadedAt, + ssr: match.ssr, + _displayPending: match._displayPending, } }, }) @@ -86,6 +89,10 @@ export const Match = Vue.defineComponent({ router?.options?.defaultPendingComponent, ) + const pendingElement = Vue.computed(() => + PendingComponent.value ? Vue.h(PendingComponent.value) : undefined, + ) + const routeErrorComponent = Vue.computed( () => route.value?.options?.errorComponent ?? @@ -104,6 +111,17 @@ export const Match = Vue.defineComponent({ : route.value?.options?.notFoundComponent, ) + const hasShellComponent = Vue.computed(() => { + if (!route.value?.isRoot) return false + return !!(route.value.options as RootRouteOptions).shellComponent + }) + + const ShellComponent = Vue.computed(() => + hasShellComponent.value + ? ((route.value!.options as RootRouteOptions).shellComponent as any) + : null, + ) + // Create a ref for the current matchId that we provide to child components // This ref is updated to the ACTUAL matchId found (which may differ from props during transitions) const matchIdRef = Vue.ref(matchData.value?.matchId ?? props.matchId) @@ -127,82 +145,89 @@ export const Match = Vue.defineComponent({ // Use the actual matchId from matchData, not props (which may be stale) const actualMatchId = matchData.value?.matchId ?? props.matchId - // Determine which components to render - let content: VNode = Vue.h(MatchInner, { matchId: actualMatchId }) - - // Wrap in NotFound boundary if needed - if (routeNotFoundComponent.value) { - content = Vue.h(CatchNotFound, { - fallback: (error: any) => { - // If the current not found handler doesn't exist or it has a - // route ID which doesn't match the current route, rethrow the error - if ( - !routeNotFoundComponent.value || - (error.routeId && error.routeId !== matchData.value?.routeId) || - (!error.routeId && route.value && !route.value.isRoot) + const resolvedNoSsr = + matchData.value?.ssr === false || matchData.value?.ssr === 'data-only' + const shouldClientOnly = + resolvedNoSsr || !!matchData.value?._displayPending + + const renderMatchContent = (): VNode => { + const matchInner = Vue.h(MatchInner, { matchId: actualMatchId }) + + let content: VNode = shouldClientOnly + ? Vue.h( + ClientOnly, + { + fallback: pendingElement.value, + }, + { + default: () => matchInner, + }, ) - throw error - - return Vue.h(routeNotFoundComponent.value, error) - }, - children: content, - }) - } + : matchInner + + // Wrap in NotFound boundary if needed + if (routeNotFoundComponent.value) { + content = Vue.h(CatchNotFound, { + fallback: (error: any) => { + // If the current not found handler doesn't exist or it has a + // route ID which doesn't match the current route, rethrow the error + if ( + !routeNotFoundComponent.value || + (error.routeId && error.routeId !== matchData.value?.routeId) || + (!error.routeId && route.value && !route.value.isRoot) + ) + throw error + + return Vue.h(routeNotFoundComponent.value, error) + }, + children: content, + }) + } - // Wrap in error boundary if needed - if (routeErrorComponent.value) { - content = CatchBoundary({ - getResetKey: () => matchData.value?.loadedAt ?? 0, - errorComponent: routeErrorComponent.value || ErrorComponent, - onCatch: (error: Error) => { - // Forward not found errors (we don't want to show the error component for these) - if (isNotFound(error)) throw error - warning(false, `Error in route match: ${actualMatchId}`) - routeOnCatch.value?.(error) - }, - children: content, - }) - } + // Wrap in error boundary if needed + if (routeErrorComponent.value) { + content = CatchBoundary({ + getResetKey: () => matchData.value?.loadedAt ?? 0, + errorComponent: routeErrorComponent.value || ErrorComponent, + onCatch: (error: Error) => { + // Forward not found errors (we don't want to show the error component for these) + if (isNotFound(error)) throw error + warning(false, `Error in route match: ${actualMatchId}`) + routeOnCatch.value?.(error) + }, + children: content, + }) + } - // Wrap in suspense if needed - // Root routes should also wrap in Suspense if they have a pendingComponent - const needsSuspense = - route.value && - (route.value?.options?.wrapInSuspense ?? - PendingComponent.value ?? - false) + // Add scroll restoration if needed + const withScrollRestoration: Array = [ + content, + matchData.value?.parentRouteId === rootRouteId && + router.options.scrollRestoration + ? Vue.h(Vue.Fragment, null, [ + Vue.h(OnRendered), + Vue.h(ScrollRestoration), + ]) + : null, + ].filter(Boolean) as Array + + // Return single child directly to avoid Fragment wrapper that causes hydration mismatch + if (withScrollRestoration.length === 1) { + return withScrollRestoration[0]! + } - if (needsSuspense) { - content = Vue.h( - Vue.Suspense, - { - fallback: PendingComponent.value - ? Vue.h(PendingComponent.value) - : null, - }, - { - default: () => content, - }, - ) + return Vue.h(Vue.Fragment, null, withScrollRestoration) } - // Add scroll restoration if needed - const withScrollRestoration: Array = [ - content, - matchData.value?.parentRouteId === rootRouteId && - router.options.scrollRestoration - ? Vue.h(Vue.Fragment, null, [ - Vue.h(OnRendered), - Vue.h(ScrollRestoration), - ]) - : null, - ].filter(Boolean) as Array - - // Return single child directly to avoid Fragment wrapper that causes hydration mismatch - if (withScrollRestoration.length === 1) { - return withScrollRestoration[0]! + if (!hasShellComponent.value) { + return renderMatchContent() } - return Vue.h(Vue.Fragment, null, withScrollRestoration) + + return Vue.h(ShellComponent.value, null, { + // Important: return a fresh VNode on each slot invocation so that shell + // components can re-render without reusing a cached VNode instance. + default: () => renderMatchContent(), + }) } }, }) @@ -304,6 +329,9 @@ export const MatchInner = Vue.defineComponent({ id: match.id, status: match.status, error: match.error, + ssr: match.ssr, + _forcePending: match._forcePending, + _displayPending: match._displayPending, }, remountKey, } @@ -320,11 +348,25 @@ export const MatchInner = Vue.defineComponent({ return (): VNode | null => { // If match doesn't exist, return null (component is being unmounted or not ready) - if (!combinedState.value || !match.value || !route.value) { - return null - } + if (!combinedState.value || !match.value || !route.value) return null // Handle different match statuses + if (match.value._displayPending) { + const PendingComponent = + route.value.options.pendingComponent ?? + router.options.defaultPendingComponent + + return PendingComponent ? Vue.h(PendingComponent) : null + } + + if (match.value._forcePending) { + const PendingComponent = + route.value.options.pendingComponent ?? + router.options.defaultPendingComponent + + return PendingComponent ? Vue.h(PendingComponent) : null + } + if (match.value.status === 'notFound') { invariant(isNotFound(match.value.error), 'Expected a notFound error') return renderRouteNotFound(router, route.value, match.value.error) diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index b35ba9b0534..743ac6f7b87 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -136,6 +136,13 @@ export function useTransitionerSetup() { Vue.onMounted(() => { isMounted.value = true + if (!isAnyPending.value) { + router.__store.setState((s) => + s.status === 'pending' + ? { ...s, status: 'idle', resolvedLocation: s.location } + : s, + ) + } }) Vue.onUnmounted(() => { @@ -201,6 +208,14 @@ export function useTransitionerSetup() { Vue.watch(isAnyPending, (newValue) => { if (!isMounted.value) return try { + if (!newValue && router.__store.state.status === 'pending') { + router.__store.setState((s) => ({ + ...s, + status: 'idle', + resolvedLocation: s.location, + })) + } + // The router was pending and now it's not if (previousIsAnyPending.value.previous && !newValue) { const changeInfo = getLocationChangeInfo(router.state) @@ -209,12 +224,6 @@ export function useTransitionerSetup() { ...changeInfo, }) - router.__store.setState((s) => ({ - ...s, - status: 'idle', - resolvedLocation: s.location, - })) - if (changeInfo.hrefChanged) { handleHashScroll(router) } diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 3a57254687e..459d50f82c3 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -346,3 +346,4 @@ export type { LocationRewrite, LocationRewriteFunction, } from '@tanstack/router-core' +export { ClientOnly } from './ClientOnly' diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index d0c4cafea25..b8c040cf77a 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -1,5 +1,6 @@ import * as Vue from 'vue' import { Outlet } from './Match' +import { ClientOnly } from './ClientOnly' import type { AsyncRouteComponent } from './route' // If the load fails due to module not found, it may mean a new version of @@ -19,34 +20,6 @@ function isModuleNotFoundError(error: any): boolean { ) } -export function ClientOnly(props: { children?: any; fallback?: Vue.VNode }) { - const hydrated = useHydrated() - - return () => { - if (hydrated.value) { - return props.children - } - return props.fallback || null - } -} - -export function useHydrated() { - // Only hydrate on client-side, never on server - const hydrated = Vue.ref(false) - - // If on server, return false - if (typeof window === 'undefined') { - return Vue.computed(() => false) - } - - // On client, set to true once mounted - Vue.onMounted(() => { - hydrated.value = true - }) - - return hydrated -} - export function lazyRouteComponent< T extends Record, TKey extends keyof T = 'default', @@ -156,10 +129,15 @@ export function lazyRouteComponent< // If SSR is disabled for this component if (ssr?.() === false) { - return Vue.h(ClientOnly, { - fallback: Vue.h(Outlet), - children: Vue.h(component.value, props), - }) + return Vue.h( + ClientOnly, + { + fallback: Vue.h(Outlet), + }, + { + default: () => Vue.h(component.value, props), + }, + ) } // Regular render with the loaded component diff --git a/packages/vue-router/tests/shellComponent.test.tsx b/packages/vue-router/tests/shellComponent.test.tsx new file mode 100644 index 00000000000..d889429243f --- /dev/null +++ b/packages/vue-router/tests/shellComponent.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/vue' +import { createRootRoute, createRouter } from '../src' +import { RouterProvider } from '../src/RouterProvider' + +describe('shellComponent', () => { + it('should wrap the root route with shellComponent', async () => { + const Shell = (_: unknown, { slots }: { slots: any }) => ( +
{slots.default?.()}
+ ) + + const rootRoute = createRootRoute({ + shellComponent: Shell, + component: () =>
child
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([]), + }) + + const app = render() + + const shell = await app.findByTestId('shell') + expect(shell).toBeInTheDocument() + expect(shell).toContainHTML('child') + }) +}) diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx index 58943882524..1b0745522bc 100644 --- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx @@ -301,6 +301,6 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(7) + expect(updates).toBe(6) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 075353d1626..c50f959e8d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4864,6 +4864,55 @@ importers: 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/selective-ssr: + dependencies: + '@tanstack/vue-router': + specifier: workspace:* + version: link:../../../packages/vue-router + '@tanstack/vue-start': + specifier: workspace:* + version: link:../../../packages/vue-start + vue: + specifier: ^3.5.25 + version: 3.5.25(typescript@5.9.2) + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.15 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@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)) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + srvx: + specifier: ^0.8.6 + version: 0.8.15 + tailwindcss: + specifier: ^4.1.17 + version: 4.1.17 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + 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) + 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)) + vue-tsc: + specifier: ^3.1.8 + version: 3.1.8(typescript@5.9.2) + examples/react/authenticated-routes: dependencies: '@tailwindcss/postcss': @@ -15204,6 +15253,12 @@ packages: '@rolldown/pluginutils@1.0.0-beta.43': resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@rolldown/pluginutils@1.0.0-beta.54': + resolution: {integrity: sha512-AHgcZ+w7RIRZ65ihSQL8YuoKcpD9Scew4sEeP1BBUT9QdTo6KjwHrZZXjID6nL10fhKessCH6OPany2QKwAwTQ==} + '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} engines: {node: '>=14.0.0'} @@ -16987,6 +17042,13 @@ packages: vite: ^7.1.7 vue: ^3.0.0 + '@vitejs/plugin-vue-jsx@5.1.2': + resolution: {integrity: sha512-3a2BOryRjG/Iih87x87YXz5c8nw27eSlHytvSKYfp8ZIsp5+FgFQoKeA7k2PnqWpjJrv6AoVTMnvmuKUXb771A==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^7.1.7 + vue: ^3.0.0 + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -16994,6 +17056,13 @@ packages: vite: ^7.1.7 vue: ^3.2.25 + '@vitejs/plugin-vue@6.0.3': + resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^7.1.7 + vue: ^3.2.25 + '@vitest/browser@3.0.6': resolution: {integrity: sha512-FqKwCAkALZfNzGNx4YvRJa6HCWM2USWTjOdNO2egI/s6+3WkIl4xAlYISOARLJLDAI3yCXcpTtuUUF39K8TQgw==} peerDependencies: @@ -17069,21 +17138,33 @@ packages: '@volar/language-core@2.4.23': resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} + '@volar/language-core@2.4.26': + resolution: {integrity: sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==} + '@volar/source-map@2.4.11': resolution: {integrity: sha512-ZQpmafIGvaZMn/8iuvCFGrW3smeqkq/IIh9F1SdSx9aUl0J4Iurzd6/FhmjNO5g2ejF3rT45dKskgXWiofqlZQ==} '@volar/source-map@2.4.23': resolution: {integrity: sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==} + '@volar/source-map@2.4.26': + resolution: {integrity: sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==} + '@volar/typescript@2.4.11': resolution: {integrity: sha512-2DT+Tdh88Spp5PyPbqhyoYavYCPDsqbHLFwcUI9K1NlY1YgUJvujGdrqUp0zWxnW7KWNTr3xSpMuv2WnaTKDAw==} '@volar/typescript@2.4.23': resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==} + '@volar/typescript@2.4.26': + resolution: {integrity: sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==} + '@vue/babel-helper-vue-transform-on@1.5.0': resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==} + '@vue/babel-helper-vue-transform-on@2.0.1': + resolution: {integrity: sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==} + '@vue/babel-plugin-jsx@1.5.0': resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==} peerDependencies: @@ -17092,11 +17173,24 @@ packages: '@babel/core': optional: true + '@vue/babel-plugin-jsx@2.0.1': + resolution: {integrity: sha512-a8CaLQjD/s4PVdhrLD/zT574ZNPnZBOY+IhdtKWRB4HRZ0I2tXBi5ne7d9eCfaYwp5gU5+4KIyFTV1W1YL9xZA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + '@vue/babel-plugin-resolve-type@1.5.0': resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==} peerDependencies: '@babel/core': ^7.0.0-0 + '@vue/babel-plugin-resolve-type@2.0.1': + resolution: {integrity: sha512-ybwgIuRGRRBhOU37GImDoWQoz+TlSqap65qVI6iwg/J7FfLTLmMf97TS7xQH9I7Qtr/gp161kYVdhr1ZMraSYQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@vue/compiler-core@3.5.14': resolution: {integrity: sha512-k7qMHMbKvoCXIxPhquKQVw3Twid3Kg4s7+oYURxLGRd56LiuHJVrvFKI4fm2AM3c8apqODPfVJGoh8nePbXMRA==} @@ -17137,6 +17231,14 @@ packages: typescript: optional: true + '@vue/language-core@3.1.8': + resolution: {integrity: sha512-PfwAW7BLopqaJbneChNL6cUOTL3GL+0l8paYP5shhgY5toBNidWnMXWM+qDwL7MC9+zDtzCF2enT8r6VPu64iw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@vue/reactivity@3.5.25': resolution: {integrity: sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==} @@ -23102,6 +23204,12 @@ packages: peerDependencies: typescript: '>=5.0.0' + vue-tsc@3.1.8: + resolution: {integrity: sha512-deKgwx6exIHeZwF601P1ktZKNF0bepaSN4jBU3AsbldPx9gylUc1JDxYppl82yxgkAgaz0Y0LCLOi+cXe9HMYA==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + vue@3.5.25: resolution: {integrity: sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==} peerDependencies: @@ -27360,6 +27468,10 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.43': {} + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@rolldown/pluginutils@1.0.0-beta.54': {} + '@rollup/plugin-alias@5.1.1(rollup@4.52.2)': optionalDependencies: rollup: 4.52.2 @@ -29393,6 +29505,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue-jsx@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))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.54 + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.28.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) + vue: 3.5.25(typescript@5.9.2) + transitivePeerDependencies: + - supports-color + '@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) @@ -29403,6 +29527,12 @@ snapshots: 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@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))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.53 + 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) + '@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)': dependencies: '@testing-library/dom': 10.4.1 @@ -29509,10 +29639,16 @@ snapshots: dependencies: '@volar/source-map': 2.4.23 + '@volar/language-core@2.4.26': + dependencies: + '@volar/source-map': 2.4.26 + '@volar/source-map@2.4.11': {} '@volar/source-map@2.4.23': {} + '@volar/source-map@2.4.26': {} + '@volar/typescript@2.4.11': dependencies: '@volar/language-core': 2.4.11 @@ -29525,8 +29661,16 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.0.8 + '@volar/typescript@2.4.26': + dependencies: + '@volar/language-core': 2.4.26 + path-browserify: 1.0.1 + vscode-uri: 3.0.8 + '@vue/babel-helper-vue-transform-on@1.5.0': {} + '@vue/babel-helper-vue-transform-on@2.0.1': {} + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.28.5)': dependencies: '@babel/helper-module-imports': 7.27.1 @@ -29543,6 +29687,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.28.5)': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@vue/babel-helper-vue-transform-on': 2.0.1 + '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.28.5) + '@vue/shared': 3.5.25 + optionalDependencies: + '@babel/core': 7.28.5 + transitivePeerDependencies: + - supports-color + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.28.5)': dependencies: '@babel/code-frame': 7.27.1 @@ -29554,6 +29714,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.28.5)': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/parser': 7.28.5 + '@vue/compiler-sfc': 3.5.25 + transitivePeerDependencies: + - supports-color + '@vue/compiler-core@3.5.14': dependencies: '@babel/parser': 7.28.5 @@ -29654,6 +29825,18 @@ snapshots: optionalDependencies: typescript: 5.9.2 + '@vue/language-core@3.1.8(typescript@5.9.2)': + dependencies: + '@volar/language-core': 2.4.26 + '@vue/compiler-dom': 3.5.25 + '@vue/shared': 3.5.25 + alien-signals: 3.1.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.3 + optionalDependencies: + typescript: 5.9.2 + '@vue/reactivity@3.5.25': dependencies: '@vue/shared': 3.5.25 @@ -36374,6 +36557,12 @@ snapshots: '@vue/language-core': 3.1.5(typescript@5.9.2) typescript: 5.9.2 + vue-tsc@3.1.8(typescript@5.9.2): + dependencies: + '@volar/typescript': 2.4.26 + '@vue/language-core': 3.1.8(typescript@5.9.2) + typescript: 5.9.2 + vue@3.5.25(typescript@5.8.3): dependencies: '@vue/compiler-dom': 3.5.25