diff --git a/e2e/react-start/basic-nitro-spa/.gitignore b/e2e/react-start/basic-nitro-spa/.gitignore new file mode 100644 index 00000000000..114d10aa0e4 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/.gitignore @@ -0,0 +1,12 @@ +node_modules +.DS_Store +.cache +.env +dist +.output +.nitro + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/react-start/basic-nitro-spa/package.json b/e2e/react-start/basic-nitro-spa/package.json new file mode 100644 index 00000000000..6edf2933e0e --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/package.json @@ -0,0 +1,42 @@ +{ + "name": "tanstack-react-start-e2e-basic-nitro-spa", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev:v2": "vite dev -c vite.config.v2.ts --port 3000", + "dev:v3": "vite dev -c vite.config.v3.ts --port 3000", + "dev:e2e:v2": "vite dev -c vite.config.v2.ts", + "dev:e2e:v3": "vite dev -c vite.config.v3.ts", + "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit", + "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit", + "preview:v2": "vite preview -c vite.config.v2.ts", + "preview:v3": "vite preview -c vite.config.v3.ts", + "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared", + "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared", + "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@tanstack/nitro-v2-vite-plugin": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "nitro": "npm:nitro-nightly@latest", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.15", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/basic-nitro-spa/playwright.config.ts b/e2e/react-start/basic-nitro-spa/playwright.config.ts new file mode 100644 index 00000000000..b26f5493b47 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/playwright.config.ts @@ -0,0 +1,44 @@ +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}` +const nitroVariant = process.env.NITRO_VARIANT +if (nitroVariant !== 'v2' && nitroVariant !== 'v3') { + throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.') +} +const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3' +const buildCommand = `pnpm run ${buildScript}` + +/** + * 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: { + // Note: We run node directly instead of vite preview because Nitro's + // configurePreviewServer spawns on a random port. The prerendering during + // build uses vite.preview() correctly. + command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/basic-nitro-spa/postcss.config.mjs b/e2e/react-start/basic-nitro-spa/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/react-start/basic-nitro-spa/public/android-chrome-192x192.png b/e2e/react-start/basic-nitro-spa/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/android-chrome-192x192.png differ diff --git a/e2e/react-start/basic-nitro-spa/public/android-chrome-512x512.png b/e2e/react-start/basic-nitro-spa/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/android-chrome-512x512.png differ diff --git a/e2e/react-start/basic-nitro-spa/public/apple-touch-icon.png b/e2e/react-start/basic-nitro-spa/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/apple-touch-icon.png differ diff --git a/e2e/react-start/basic-nitro-spa/public/favicon-16x16.png b/e2e/react-start/basic-nitro-spa/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon-16x16.png differ diff --git a/e2e/react-start/basic-nitro-spa/public/favicon-32x32.png b/e2e/react-start/basic-nitro-spa/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon-32x32.png differ diff --git a/e2e/react-start/basic-nitro-spa/public/favicon.ico b/e2e/react-start/basic-nitro-spa/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon.ico differ diff --git a/e2e/react-start/basic-nitro-spa/public/favicon.png b/e2e/react-start/basic-nitro-spa/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/react-start/basic-nitro-spa/public/favicon.png differ diff --git a/e2e/react-start/basic-nitro-spa/public/site.webmanifest b/e2e/react-start/basic-nitro-spa/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/e2e/react-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..ef2daa1ea1d --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/e2e/react-start/basic-nitro-spa/src/components/NotFound.tsx b/e2e/react-start/basic-nitro-spa/src/components/NotFound.tsx new file mode 100644 index 00000000000..4e84e3f8e00 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/react-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/e2e/react-start/basic-nitro-spa/src/routeTree.gen.ts b/e2e/react-start/basic-nitro-spa/src/routeTree.gen.ts new file mode 100644 index 00000000000..4219501f5ef --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* 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 StaticRouteImport } from './routes/static' +import { Route as IndexRouteImport } from './routes/index' + +const StaticRoute = StaticRouteImport.update({ + id: '/static', + path: '/static', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/static' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/static' + id: '__root__' | '/' | '/static' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + StaticRoute: typeof StaticRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/static': { + id: '/static' + path: '/static' + fullPath: '/static' + preLoaderRoute: typeof StaticRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + StaticRoute: StaticRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/basic-nitro-spa/src/router.tsx b/e2e/react-start/basic-nitro-spa/src/router.tsx new file mode 100644 index 00000000000..1a1d8822d20 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/react-start/basic-nitro-spa/src/routes/__root.tsx b/e2e/react-start/basic-nitro-spa/src/routes/__root.tsx new file mode 100644 index 00000000000..5b62b589077 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/routes/__root.tsx @@ -0,0 +1,73 @@ +/// +import { + HeadContent, + Link, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import * as React from 'react' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: 'TanStack Start + Nitro E2E Test', + description: 'Testing nitro integration with TanStack Start', + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { rel: 'icon', href: '/favicon.ico' }, + ], + }), + errorComponent: DefaultCatchBoundary, + notFoundComponent: () => , + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + +
+ + Home + + + Static + +
+
+ {children} + + + + + ) +} diff --git a/e2e/react-start/basic-nitro-spa/src/routes/index.tsx b/e2e/react-start/basic-nitro-spa/src/routes/index.tsx new file mode 100644 index 00000000000..311e2cf3739 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/routes/index.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +export const Route = createFileRoute('/')({ + loader: () => getData(), + component: Home, +}) + +const getData = createServerFn().handler(() => { + return { + message: 'Hello from Nitro server!', + timestamp: new Date().toISOString(), + } +}) + +function Home() { + const data = Route.useLoaderData() + + return ( +
+

Welcome Home!

+

{data.message}

+

Loaded at: {data.timestamp}

+
+ ) +} diff --git a/e2e/react-start/basic-nitro-spa/src/routes/static.tsx b/e2e/react-start/basic-nitro-spa/src/routes/static.tsx new file mode 100644 index 00000000000..f018bf39649 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/routes/static.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +export const Route = createFileRoute('/static')({ + loader: () => getStaticData(), + component: StaticPage, +}) + +const getStaticData = createServerFn().handler(() => { + return { + content: 'This page was prerendered at build time', + buildTime: new Date().toISOString(), + } +}) + +function StaticPage() { + const data = Route.useLoaderData() + + return ( +
+

Static Page

+

{data.content}

+

Build time: {data.buildTime}

+
+ ) +} diff --git a/e2e/react-start/basic-nitro-spa/src/styles/app.css b/e2e/react-start/basic-nitro-spa/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/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/react-start/basic-nitro-spa/src/utils/seo.ts b/e2e/react-start/basic-nitro-spa/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/e2e/react-start/basic-nitro-spa/tests/app.spec.ts b/e2e/react-start/basic-nitro-spa/tests/app.spec.ts new file mode 100644 index 00000000000..b0f96e373af --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/tests/app.spec.ts @@ -0,0 +1,30 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { expect, test } from '@playwright/test' + +test('SPA shell is prerendered during build with nitro', async ({ page }) => { + const outputDir = join(process.cwd(), '.output', 'public') + expect(existsSync(join(outputDir, 'index.html'))).toBe(true) + + await page.goto('/') + await expect(page.getByTestId('home-heading')).toBeVisible() +}) + +test('server functions work with nitro', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('home-heading')).toHaveText('Welcome Home!') + await expect(page.getByTestId('message')).toHaveText( + 'Hello from Nitro server!', + ) +}) + +test('client-side navigation works in SPA mode', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('home-heading')).toBeVisible() + + await page.click('a[href="/static"]') + await expect(page.getByTestId('static-heading')).toBeVisible() + + await page.click('a[href="/"]') + await expect(page.getByTestId('home-heading')).toBeVisible() +}) diff --git a/e2e/react-start/basic-nitro-spa/tsconfig.json b/e2e/react-start/basic-nitro-spa/tsconfig.json new file mode 100644 index 00000000000..3a9fb7cd716 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "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/react-start/basic-nitro-spa/vite.config.v2.ts b/e2e/react-start/basic-nitro-spa/vite.config.v2.ts new file mode 100644 index 00000000000..4b6e6dec6ae --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/vite.config.v2.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + spa: { + enabled: true, + prerender: { + outputPath: 'index.html', + }, + }, + }), + nitroV2Plugin(), + ], +}) diff --git a/e2e/react-start/basic-nitro-spa/vite.config.v3.ts b/e2e/react-start/basic-nitro-spa/vite.config.v3.ts new file mode 100644 index 00000000000..37f603e3ba8 --- /dev/null +++ b/e2e/react-start/basic-nitro-spa/vite.config.v3.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { nitro } from 'nitro/vite' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + spa: { + enabled: true, + prerender: { + outputPath: 'index.html', + }, + }, + }), + nitro(), + ], +}) diff --git a/e2e/react-start/basic-nitro/.gitignore b/e2e/react-start/basic-nitro/.gitignore new file mode 100644 index 00000000000..cce09e5f653 --- /dev/null +++ b/e2e/react-start/basic-nitro/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +.cache +.env +dist +.output + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/react-start/basic-nitro/.prettierignore b/e2e/react-start/basic-nitro/.prettierignore new file mode 100644 index 00000000000..a16e01379d7 --- /dev/null +++ b/e2e/react-start/basic-nitro/.prettierignore @@ -0,0 +1 @@ +routeTree.gen.ts diff --git a/e2e/react-start/basic-nitro/package.json b/e2e/react-start/basic-nitro/package.json new file mode 100644 index 00000000000..109d3e2d33f --- /dev/null +++ b/e2e/react-start/basic-nitro/package.json @@ -0,0 +1,42 @@ +{ + "name": "tanstack-react-start-e2e-basic-nitro", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev:v2": "vite dev -c vite.config.v2.ts --port 3000", + "dev:v3": "vite dev -c vite.config.v3.ts --port 3000", + "dev:e2e:v2": "vite dev -c vite.config.v2.ts", + "dev:e2e:v3": "vite dev -c vite.config.v3.ts", + "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit", + "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit", + "preview:v2": "vite preview -c vite.config.v2.ts", + "preview:v3": "vite preview -c vite.config.v3.ts", + "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared", + "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared", + "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@tanstack/nitro-v2-vite-plugin": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "nitro": "npm:nitro-nightly@latest", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.15", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/basic-nitro/playwright.config.ts b/e2e/react-start/basic-nitro/playwright.config.ts new file mode 100644 index 00000000000..b26f5493b47 --- /dev/null +++ b/e2e/react-start/basic-nitro/playwright.config.ts @@ -0,0 +1,44 @@ +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}` +const nitroVariant = process.env.NITRO_VARIANT +if (nitroVariant !== 'v2' && nitroVariant !== 'v3') { + throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.') +} +const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3' +const buildCommand = `pnpm run ${buildScript}` + +/** + * 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: { + // Note: We run node directly instead of vite preview because Nitro's + // configurePreviewServer spawns on a random port. The prerendering during + // build uses vite.preview() correctly. + command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/basic-nitro/postcss.config.mjs b/e2e/react-start/basic-nitro/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/react-start/basic-nitro/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/react-start/basic-nitro/public/android-chrome-192x192.png b/e2e/react-start/basic-nitro/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/e2e/react-start/basic-nitro/public/android-chrome-192x192.png differ diff --git a/e2e/react-start/basic-nitro/public/android-chrome-512x512.png b/e2e/react-start/basic-nitro/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/e2e/react-start/basic-nitro/public/android-chrome-512x512.png differ diff --git a/e2e/react-start/basic-nitro/public/apple-touch-icon.png b/e2e/react-start/basic-nitro/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/e2e/react-start/basic-nitro/public/apple-touch-icon.png differ diff --git a/e2e/react-start/basic-nitro/public/favicon-16x16.png b/e2e/react-start/basic-nitro/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon-16x16.png differ diff --git a/e2e/react-start/basic-nitro/public/favicon-32x32.png b/e2e/react-start/basic-nitro/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon-32x32.png differ diff --git a/e2e/react-start/basic-nitro/public/favicon.ico b/e2e/react-start/basic-nitro/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon.ico differ diff --git a/e2e/react-start/basic-nitro/public/favicon.png b/e2e/react-start/basic-nitro/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/react-start/basic-nitro/public/favicon.png differ diff --git a/e2e/react-start/basic-nitro/public/site.webmanifest b/e2e/react-start/basic-nitro/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/e2e/react-start/basic-nitro/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/e2e/react-start/basic-nitro/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/basic-nitro/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..ef2daa1ea1d --- /dev/null +++ b/e2e/react-start/basic-nitro/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/e2e/react-start/basic-nitro/src/components/NotFound.tsx b/e2e/react-start/basic-nitro/src/components/NotFound.tsx new file mode 100644 index 00000000000..4e84e3f8e00 --- /dev/null +++ b/e2e/react-start/basic-nitro/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/react-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/e2e/react-start/basic-nitro/src/routeTree.gen.ts b/e2e/react-start/basic-nitro/src/routeTree.gen.ts new file mode 100644 index 00000000000..4219501f5ef --- /dev/null +++ b/e2e/react-start/basic-nitro/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* 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 StaticRouteImport } from './routes/static' +import { Route as IndexRouteImport } from './routes/index' + +const StaticRoute = StaticRouteImport.update({ + id: '/static', + path: '/static', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/static' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/static' + id: '__root__' | '/' | '/static' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + StaticRoute: typeof StaticRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/static': { + id: '/static' + path: '/static' + fullPath: '/static' + preLoaderRoute: typeof StaticRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + StaticRoute: StaticRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/basic-nitro/src/router.tsx b/e2e/react-start/basic-nitro/src/router.tsx new file mode 100644 index 00000000000..1a1d8822d20 --- /dev/null +++ b/e2e/react-start/basic-nitro/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/react-start/basic-nitro/src/routes/__root.tsx b/e2e/react-start/basic-nitro/src/routes/__root.tsx new file mode 100644 index 00000000000..17577a3905e --- /dev/null +++ b/e2e/react-start/basic-nitro/src/routes/__root.tsx @@ -0,0 +1,92 @@ +/// +import { + HeadContent, + Link, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import * as React from 'react' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: + 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework', + description: `TanStack Start is a type-safe, client-first, full-stack React framework. `, + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16.png', + }, + { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' }, + { rel: 'icon', href: '/favicon.ico' }, + ], + }), + errorComponent: DefaultCatchBoundary, + notFoundComponent: () => , + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + +
+ + Home + + + Static + +
+
+ {children} + + + + + ) +} diff --git a/e2e/react-start/basic-nitro/src/routes/index.tsx b/e2e/react-start/basic-nitro/src/routes/index.tsx new file mode 100644 index 00000000000..2b0878af588 --- /dev/null +++ b/e2e/react-start/basic-nitro/src/routes/index.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +export const Route = createFileRoute('/')({ + loader: () => getData(), + component: Home, +}) + +const getData = createServerFn().handler(() => { + return { + message: `Running in Node.js ${process.version}`, + runtime: 'Nitro', + } +}) + +function Home() { + const data = Route.useLoaderData() + + return ( +
+

Welcome Home!!!

+

{data.message}

+

{data.runtime}

+
+ ) +} diff --git a/e2e/react-start/basic-nitro/src/routes/static.tsx b/e2e/react-start/basic-nitro/src/routes/static.tsx new file mode 100644 index 00000000000..3333760d11a --- /dev/null +++ b/e2e/react-start/basic-nitro/src/routes/static.tsx @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +export const Route = createFileRoute('/static')({ + loader: () => getData(), + component: StaticPage, +}) + +const getData = createServerFn().handler(() => { + return { + generatedAt: new Date().toISOString(), + runtime: 'Nitro', + } +}) + +function StaticPage() { + const data = Route.useLoaderData() + + return ( +
+

Static Page

+

+ This page was prerendered with {data.runtime} +

+

Generated at: {data.generatedAt}

+
+ ) +} diff --git a/e2e/react-start/basic-nitro/src/styles/app.css b/e2e/react-start/basic-nitro/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/react-start/basic-nitro/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/react-start/basic-nitro/src/utils/seo.ts b/e2e/react-start/basic-nitro/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/e2e/react-start/basic-nitro/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/e2e/react-start/basic-nitro/tests/app.spec.ts b/e2e/react-start/basic-nitro/tests/app.spec.ts new file mode 100644 index 00000000000..31b1a7f6251 --- /dev/null +++ b/e2e/react-start/basic-nitro/tests/app.spec.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('returns correct runtime info', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('message')).toContainText('Running in Node.js') + await expect(page.getByTestId('runtime')).toHaveText('Nitro') +}) + +test('prerender with Nitro', async ({ page }) => { + const distDir = join(process.cwd(), '.output', 'public') + const staticHtmlPath = join(distDir, 'static', 'index.html') + expect(existsSync(staticHtmlPath)).toBe(true) + const staticHtml = readFileSync(staticHtmlPath, 'utf8') + const normalizedHtml = staticHtml.replace(//g, '') + expect(normalizedHtml).toContain('This page was prerendered with Nitro') + expect(normalizedHtml).toContain('Generated at:') + + await page.goto('/static') + await expect(page.getByTestId('static-heading')).toHaveText('Static Page') + await expect(page.getByTestId('static-content')).toHaveText( + 'This page was prerendered with Nitro', + ) + await expect(page.getByTestId('generated-at')).toContainText('Generated at:') +}) + +test('client-side navigation works', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('message')).toContainText('Running in Node.js') + + await page.getByRole('link', { name: 'Static' }).click() + await expect(page.getByTestId('static-heading')).toHaveText('Static Page') + + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByTestId('message')).toContainText('Running in Node.js') +}) diff --git a/e2e/react-start/basic-nitro/tests/setup/global.setup.ts b/e2e/react-start/basic-nitro/tests/setup/global.setup.ts new file mode 100644 index 00000000000..f54c01cad2c --- /dev/null +++ b/e2e/react-start/basic-nitro/tests/setup/global.setup.ts @@ -0,0 +1,3 @@ +export default async function setup() { + // No additional setup needed for Nitro +} diff --git a/e2e/react-start/basic-nitro/tests/setup/global.teardown.ts b/e2e/react-start/basic-nitro/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..ac5a84f5ea6 --- /dev/null +++ b/e2e/react-start/basic-nitro/tests/setup/global.teardown.ts @@ -0,0 +1,3 @@ +export default async function teardown() { + // No additional teardown needed for Nitro +} diff --git a/e2e/react-start/basic-nitro/tsconfig.json b/e2e/react-start/basic-nitro/tsconfig.json new file mode 100644 index 00000000000..3a9fb7cd716 --- /dev/null +++ b/e2e/react-start/basic-nitro/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "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/react-start/basic-nitro/vite.config.v2.ts b/e2e/react-start/basic-nitro/vite.config.v2.ts new file mode 100644 index 00000000000..a10eb758e6e --- /dev/null +++ b/e2e/react-start/basic-nitro/vite.config.v2.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + prerender: { + enabled: true, + filter: (page) => page.path === '/static', + }, + }), + nitroV2Plugin(), + ], +}) diff --git a/e2e/react-start/basic-nitro/vite.config.v3.ts b/e2e/react-start/basic-nitro/vite.config.v3.ts new file mode 100644 index 00000000000..eb7246636c4 --- /dev/null +++ b/e2e/react-start/basic-nitro/vite.config.v3.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { nitro } from 'nitro/vite' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + prerender: { + enabled: true, + filter: (page) => page.path === '/static', + }, + }), + nitro(), + ], +}) diff --git a/e2e/solid-start/basic-nitro-spa/.gitignore b/e2e/solid-start/basic-nitro-spa/.gitignore new file mode 100644 index 00000000000..114d10aa0e4 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/.gitignore @@ -0,0 +1,12 @@ +node_modules +.DS_Store +.cache +.env +dist +.output +.nitro + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/solid-start/basic-nitro-spa/package.json b/e2e/solid-start/basic-nitro-spa/package.json new file mode 100644 index 00000000000..b6bde8093c3 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/package.json @@ -0,0 +1,40 @@ +{ + "name": "tanstack-solid-start-e2e-basic-nitro-spa", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev:v2": "vite dev -c vite.config.v2.ts --port 3000", + "dev:v3": "vite dev -c vite.config.v3.ts --port 3000", + "dev:e2e:v2": "vite dev -c vite.config.v2.ts", + "dev:e2e:v3": "vite dev -c vite.config.v3.ts", + "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit", + "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit", + "preview:v2": "vite preview -c vite.config.v2.ts", + "preview:v3": "vite preview -c vite.config.v3.ts", + "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared", + "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared", + "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3" + }, + "dependencies": { + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-router-devtools": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "solid-js": "^1.9.10" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@tanstack/nitro-v2-vite-plugin": "workspace:^", + "@types/node": "^22.10.2", + "nitro": "npm:nitro-nightly@latest", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.15", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-plugin-solid": "^2.11.10", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/solid-start/basic-nitro-spa/playwright.config.ts b/e2e/solid-start/basic-nitro-spa/playwright.config.ts new file mode 100644 index 00000000000..b26f5493b47 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/playwright.config.ts @@ -0,0 +1,44 @@ +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}` +const nitroVariant = process.env.NITRO_VARIANT +if (nitroVariant !== 'v2' && nitroVariant !== 'v3') { + throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.') +} +const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3' +const buildCommand = `pnpm run ${buildScript}` + +/** + * 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: { + // Note: We run node directly instead of vite preview because Nitro's + // configurePreviewServer spawns on a random port. The prerendering during + // build uses vite.preview() correctly. + command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/basic-nitro-spa/postcss.config.mjs b/e2e/solid-start/basic-nitro-spa/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/solid-start/basic-nitro-spa/public/android-chrome-192x192.png b/e2e/solid-start/basic-nitro-spa/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/android-chrome-192x192.png differ diff --git a/e2e/solid-start/basic-nitro-spa/public/android-chrome-512x512.png b/e2e/solid-start/basic-nitro-spa/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/android-chrome-512x512.png differ diff --git a/e2e/solid-start/basic-nitro-spa/public/apple-touch-icon.png b/e2e/solid-start/basic-nitro-spa/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/apple-touch-icon.png differ diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon-16x16.png b/e2e/solid-start/basic-nitro-spa/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon-16x16.png differ diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon-32x32.png b/e2e/solid-start/basic-nitro-spa/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon-32x32.png differ diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon.ico b/e2e/solid-start/basic-nitro-spa/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon.ico differ diff --git a/e2e/solid-start/basic-nitro-spa/public/favicon.png b/e2e/solid-start/basic-nitro-spa/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/solid-start/basic-nitro-spa/public/favicon.png differ diff --git a/e2e/solid-start/basic-nitro-spa/public/site.webmanifest b/e2e/solid-start/basic-nitro-spa/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/e2e/solid-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx b/e2e/solid-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..2c0d464a066 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/solid-router' +import type { ErrorComponentProps } from '@tanstack/solid-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot() ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/e2e/solid-start/basic-nitro-spa/src/components/NotFound.tsx b/e2e/solid-start/basic-nitro-spa/src/components/NotFound.tsx new file mode 100644 index 00000000000..c48444862b5 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/solid-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/e2e/solid-start/basic-nitro-spa/src/routeTree.gen.ts b/e2e/solid-start/basic-nitro-spa/src/routeTree.gen.ts new file mode 100644 index 00000000000..2bd11546dd6 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* 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 StaticRouteImport } from './routes/static' +import { Route as IndexRouteImport } from './routes/index' + +const StaticRoute = StaticRouteImport.update({ + id: '/static', + path: '/static', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/static' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/static' + id: '__root__' | '/' | '/static' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + StaticRoute: typeof StaticRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/static': { + id: '/static' + path: '/static' + fullPath: '/static' + preLoaderRoute: typeof StaticRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + StaticRoute: StaticRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/solid-start/basic-nitro-spa/src/router.tsx b/e2e/solid-start/basic-nitro-spa/src/router.tsx new file mode 100644 index 00000000000..5da353c1ce2 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/solid-start/basic-nitro-spa/src/routes/__root.tsx b/e2e/solid-start/basic-nitro-spa/src/routes/__root.tsx new file mode 100644 index 00000000000..7020b288737 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/routes/__root.tsx @@ -0,0 +1,75 @@ +/// +import { + HeadContent, + Link, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' +import { TanStackRouterDevtools } from '@tanstack/solid-router-devtools' +import { HydrationScript } from 'solid-js/web' +import type * as Solid from 'solid-js' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charset: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: 'TanStack Start + Nitro SPA E2E Test', + description: 'Testing nitro SPA integration with TanStack Start', + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { rel: 'icon', href: '/favicon.ico' }, + ], + }), + errorComponent: DefaultCatchBoundary, + notFoundComponent: () => , + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: Solid.JSX.Element }) { + return ( + + + + + + +
+ + Home + + + Static + +
+
+ {children} + + + + + ) +} diff --git a/e2e/solid-start/basic-nitro-spa/src/routes/index.tsx b/e2e/solid-start/basic-nitro-spa/src/routes/index.tsx new file mode 100644 index 00000000000..fdcf05ce075 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/routes/index.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' + +export const Route = createFileRoute('/')({ + loader: () => getData(), + component: Home, +}) + +const getData = createServerFn().handler(() => { + return { + message: 'Hello from Nitro server!', + timestamp: new Date().toISOString(), + } +}) + +function Home() { + const data = Route.useLoaderData() + + return ( +
+

Welcome Home!

+

{data().message}

+

Loaded at: {data().timestamp}

+
+ ) +} diff --git a/e2e/solid-start/basic-nitro-spa/src/routes/static.tsx b/e2e/solid-start/basic-nitro-spa/src/routes/static.tsx new file mode 100644 index 00000000000..55c781f286c --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/routes/static.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' + +export const Route = createFileRoute('/static')({ + loader: () => getStaticData(), + component: StaticPage, +}) + +const getStaticData = createServerFn().handler(() => { + return { + content: 'This page was prerendered at build time', + buildTime: new Date().toISOString(), + } +}) + +function StaticPage() { + const data = Route.useLoaderData() + + return ( +
+

Static Page

+

{data().content}

+

Build time: {data().buildTime}

+
+ ) +} diff --git a/e2e/solid-start/basic-nitro-spa/src/styles/app.css b/e2e/solid-start/basic-nitro-spa/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/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/solid-start/basic-nitro-spa/src/utils/seo.ts b/e2e/solid-start/basic-nitro-spa/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/e2e/solid-start/basic-nitro-spa/tests/app.spec.ts b/e2e/solid-start/basic-nitro-spa/tests/app.spec.ts new file mode 100644 index 00000000000..b0f96e373af --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/tests/app.spec.ts @@ -0,0 +1,30 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { expect, test } from '@playwright/test' + +test('SPA shell is prerendered during build with nitro', async ({ page }) => { + const outputDir = join(process.cwd(), '.output', 'public') + expect(existsSync(join(outputDir, 'index.html'))).toBe(true) + + await page.goto('/') + await expect(page.getByTestId('home-heading')).toBeVisible() +}) + +test('server functions work with nitro', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('home-heading')).toHaveText('Welcome Home!') + await expect(page.getByTestId('message')).toHaveText( + 'Hello from Nitro server!', + ) +}) + +test('client-side navigation works in SPA mode', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('home-heading')).toBeVisible() + + await page.click('a[href="/static"]') + await expect(page.getByTestId('static-heading')).toBeVisible() + + await page.click('a[href="/"]') + await expect(page.getByTestId('home-heading')).toBeVisible() +}) diff --git a/e2e/solid-start/basic-nitro-spa/tsconfig.json b/e2e/solid-start/basic-nitro-spa/tsconfig.json new file mode 100644 index 00000000000..ed8b73fa2dd --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "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, + "types": ["vite/client"] + } +} diff --git a/e2e/solid-start/basic-nitro-spa/vite.config.v2.ts b/e2e/solid-start/basic-nitro-spa/vite.config.v2.ts new file mode 100644 index 00000000000..6677a660004 --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/vite.config.v2.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + spa: { + enabled: true, + prerender: { + outputPath: 'index.html', + }, + }, + }), + viteSolid({ ssr: true }), + nitroV2Plugin(), + ], +}) diff --git a/e2e/solid-start/basic-nitro-spa/vite.config.v3.ts b/e2e/solid-start/basic-nitro-spa/vite.config.v3.ts new file mode 100644 index 00000000000..2a9b082bf2e --- /dev/null +++ b/e2e/solid-start/basic-nitro-spa/vite.config.v3.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import { nitro } from 'nitro/vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + spa: { + enabled: true, + prerender: { + outputPath: 'index.html', + }, + }, + }), + viteSolid({ ssr: true }), + nitro(), + ], +}) diff --git a/e2e/solid-start/basic-nitro/.gitignore b/e2e/solid-start/basic-nitro/.gitignore new file mode 100644 index 00000000000..114d10aa0e4 --- /dev/null +++ b/e2e/solid-start/basic-nitro/.gitignore @@ -0,0 +1,12 @@ +node_modules +.DS_Store +.cache +.env +dist +.output +.nitro + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/solid-start/basic-nitro/.prettierignore b/e2e/solid-start/basic-nitro/.prettierignore new file mode 100644 index 00000000000..a16e01379d7 --- /dev/null +++ b/e2e/solid-start/basic-nitro/.prettierignore @@ -0,0 +1 @@ +routeTree.gen.ts diff --git a/e2e/solid-start/basic-nitro/package.json b/e2e/solid-start/basic-nitro/package.json new file mode 100644 index 00000000000..92363500e09 --- /dev/null +++ b/e2e/solid-start/basic-nitro/package.json @@ -0,0 +1,40 @@ +{ + "name": "tanstack-solid-start-e2e-basic-nitro", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev:v2": "vite dev -c vite.config.v2.ts --port 3000", + "dev:v3": "vite dev -c vite.config.v3.ts --port 3000", + "dev:e2e:v2": "vite dev -c vite.config.v2.ts", + "dev:e2e:v3": "vite dev -c vite.config.v3.ts", + "build:v2": "vite build -c vite.config.v2.ts && tsc --noEmit", + "build:v3": "vite build -c vite.config.v3.ts && tsc --noEmit", + "preview:v2": "vite preview -c vite.config.v2.ts", + "preview:v3": "vite preview -c vite.config.v3.ts", + "test:e2e:shared": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:v2": "rm -rf .output dist .nitro && NITRO_VARIANT=v2 pnpm run test:e2e:shared", + "test:e2e:v3": "rm -rf .output dist .nitro && NITRO_VARIANT=v3 pnpm run test:e2e:shared", + "test:e2e": "pnpm run test:e2e:v2 && pnpm run test:e2e:v3" + }, + "dependencies": { + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-router-devtools": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "solid-js": "^1.9.10" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@tanstack/nitro-v2-vite-plugin": "workspace:^", + "@types/node": "^22.10.2", + "nitro": "npm:nitro-nightly@latest", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.15", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-plugin-solid": "^2.11.10", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/solid-start/basic-nitro/playwright.config.ts b/e2e/solid-start/basic-nitro/playwright.config.ts new file mode 100644 index 00000000000..b26f5493b47 --- /dev/null +++ b/e2e/solid-start/basic-nitro/playwright.config.ts @@ -0,0 +1,44 @@ +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}` +const nitroVariant = process.env.NITRO_VARIANT +if (nitroVariant !== 'v2' && nitroVariant !== 'v3') { + throw new Error('Set NITRO_VARIANT to "v2" or "v3" for Nitro e2e tests.') +} +const buildScript = nitroVariant === 'v2' ? 'build:v2' : 'build:v3' +const buildCommand = `pnpm run ${buildScript}` + +/** + * 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: { + // Note: We run node directly instead of vite preview because Nitro's + // configurePreviewServer spawns on a random port. The prerendering during + // build uses vite.preview() correctly. + command: `${buildCommand} && PORT=${PORT} node .output/server/index.mjs`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/basic-nitro/postcss.config.mjs b/e2e/solid-start/basic-nitro/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/solid-start/basic-nitro/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/solid-start/basic-nitro/public/android-chrome-192x192.png b/e2e/solid-start/basic-nitro/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/android-chrome-192x192.png differ diff --git a/e2e/solid-start/basic-nitro/public/android-chrome-512x512.png b/e2e/solid-start/basic-nitro/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/android-chrome-512x512.png differ diff --git a/e2e/solid-start/basic-nitro/public/apple-touch-icon.png b/e2e/solid-start/basic-nitro/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/apple-touch-icon.png differ diff --git a/e2e/solid-start/basic-nitro/public/favicon-16x16.png b/e2e/solid-start/basic-nitro/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon-16x16.png differ diff --git a/e2e/solid-start/basic-nitro/public/favicon-32x32.png b/e2e/solid-start/basic-nitro/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon-32x32.png differ diff --git a/e2e/solid-start/basic-nitro/public/favicon.ico b/e2e/solid-start/basic-nitro/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon.ico differ diff --git a/e2e/solid-start/basic-nitro/public/favicon.png b/e2e/solid-start/basic-nitro/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/solid-start/basic-nitro/public/favicon.png differ diff --git a/e2e/solid-start/basic-nitro/public/site.webmanifest b/e2e/solid-start/basic-nitro/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/e2e/solid-start/basic-nitro/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/e2e/solid-start/basic-nitro/src/components/DefaultCatchBoundary.tsx b/e2e/solid-start/basic-nitro/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..2c0d464a066 --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/solid-router' +import type { ErrorComponentProps } from '@tanstack/solid-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot() ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/e2e/solid-start/basic-nitro/src/components/NotFound.tsx b/e2e/solid-start/basic-nitro/src/components/NotFound.tsx new file mode 100644 index 00000000000..c48444862b5 --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/solid-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/e2e/solid-start/basic-nitro/src/routeTree.gen.ts b/e2e/solid-start/basic-nitro/src/routeTree.gen.ts new file mode 100644 index 00000000000..2bd11546dd6 --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* 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 StaticRouteImport } from './routes/static' +import { Route as IndexRouteImport } from './routes/index' + +const StaticRoute = StaticRouteImport.update({ + id: '/static', + path: '/static', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/static': typeof StaticRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/static' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/static' + id: '__root__' | '/' | '/static' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + StaticRoute: typeof StaticRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/static': { + id: '/static' + path: '/static' + fullPath: '/static' + preLoaderRoute: typeof StaticRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + StaticRoute: StaticRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/solid-start/basic-nitro/src/router.tsx b/e2e/solid-start/basic-nitro/src/router.tsx new file mode 100644 index 00000000000..5da353c1ce2 --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/solid-start/basic-nitro/src/routes/__root.tsx b/e2e/solid-start/basic-nitro/src/routes/__root.tsx new file mode 100644 index 00000000000..9d2f56b6756 --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/routes/__root.tsx @@ -0,0 +1,94 @@ +/// +import { + HeadContent, + Link, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' +import { TanStackRouterDevtools } from '@tanstack/solid-router-devtools' +import { HydrationScript } from 'solid-js/web' +import type * as Solid from 'solid-js' +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charset: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: + 'TanStack Start | Type-Safe, Client-First, Full-Stack Solid Framework', + description: `TanStack Start is a type-safe, client-first, full-stack Solid framework. `, + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16.png', + }, + { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' }, + { rel: 'icon', href: '/favicon.ico' }, + ], + }), + errorComponent: DefaultCatchBoundary, + notFoundComponent: () => , + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: Solid.JSX.Element }) { + return ( + + + + + + +
+ + Home + + + Static + +
+
+ {children} + + + + + ) +} diff --git a/e2e/solid-start/basic-nitro/src/routes/index.tsx b/e2e/solid-start/basic-nitro/src/routes/index.tsx new file mode 100644 index 00000000000..681a335b355 --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/routes/index.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' + +export const Route = createFileRoute('/')({ + loader: () => getData(), + component: Home, +}) + +const getData = createServerFn().handler(() => { + return { + message: `Running in ${typeof navigator !== 'undefined' ? navigator.userAgent : 'Unknown'}`, + runtime: 'Nitro', + } +}) + +function Home() { + const data = Route.useLoaderData() + + return ( +
+

Welcome Home!!!

+

{data().message}

+

{data().runtime}

+
+ ) +} diff --git a/e2e/solid-start/basic-nitro/src/routes/static.tsx b/e2e/solid-start/basic-nitro/src/routes/static.tsx new file mode 100644 index 00000000000..5d7a478efd6 --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/routes/static.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/static')({ + component: StaticPage, +}) + +function StaticPage() { + return ( +
+

Static Page

+

This page was prerendered with Nitro

+
+ ) +} diff --git a/e2e/solid-start/basic-nitro/src/styles/app.css b/e2e/solid-start/basic-nitro/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/solid-start/basic-nitro/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/solid-start/basic-nitro/src/utils/seo.ts b/e2e/solid-start/basic-nitro/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/e2e/solid-start/basic-nitro/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/e2e/solid-start/basic-nitro/tests/app.spec.ts b/e2e/solid-start/basic-nitro/tests/app.spec.ts new file mode 100644 index 00000000000..c48116ea805 --- /dev/null +++ b/e2e/solid-start/basic-nitro/tests/app.spec.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('returns correct runtime info', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('message')).toContainText('Running in Node.js') + await expect(page.getByTestId('runtime')).toHaveText('Nitro') +}) + +test('prerender with Nitro', async ({ page }) => { + const distDir = join(process.cwd(), '.output', 'public') + const staticHtmlPath = join(distDir, 'static', 'index.html') + expect(existsSync(staticHtmlPath)).toBe(true) + const staticHtml = readFileSync(staticHtmlPath, 'utf8') + const normalizedHtml = staticHtml.replace(//g, '') + expect(normalizedHtml).toContain('This page was prerendered with Nitro') + + await page.goto('/static') + await expect(page.getByTestId('static-heading')).toHaveText('Static Page') + await expect(page.getByTestId('static-content')).toHaveText( + 'This page was prerendered with Nitro', + ) +}) + +test('client-side navigation works', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('message')).toContainText('Running in Node.js') + + await page.getByRole('link', { name: 'Static' }).click() + await expect(page.getByTestId('static-heading')).toHaveText('Static Page') + + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByTestId('message')).toContainText('Running in Node.js') +}) diff --git a/e2e/solid-start/basic-nitro/tsconfig.json b/e2e/solid-start/basic-nitro/tsconfig.json new file mode 100644 index 00000000000..ed8b73fa2dd --- /dev/null +++ b/e2e/solid-start/basic-nitro/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "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, + "types": ["vite/client"] + } +} diff --git a/e2e/solid-start/basic-nitro/vite.config.v2.ts b/e2e/solid-start/basic-nitro/vite.config.v2.ts new file mode 100644 index 00000000000..15ad2fabfc3 --- /dev/null +++ b/e2e/solid-start/basic-nitro/vite.config.v2.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + prerender: { + enabled: true, + filter: (page) => page.path === '/static', + }, + }), + viteSolid({ ssr: true }), + nitroV2Plugin(), + ], +}) diff --git a/e2e/solid-start/basic-nitro/vite.config.v3.ts b/e2e/solid-start/basic-nitro/vite.config.v3.ts new file mode 100644 index 00000000000..5f6a5f48e73 --- /dev/null +++ b/e2e/solid-start/basic-nitro/vite.config.v3.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import { nitro } from 'nitro/vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + prerender: { + enabled: true, + filter: (page) => page.path === '/static', + }, + }), + viteSolid({ ssr: true }), + nitro(), + ], +}) diff --git a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts index fccbfb09de0..3537a1eb0c3 100644 --- a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts @@ -101,7 +101,7 @@ export function devServerPlugin({ console.error(e) try { viteDevServer.ssrFixStacktrace(e as Error) - } catch (_e) {} + } catch {} if ( webReq.headers.get('content-type')?.includes('application/json') diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts index ba193cda0a2..725652de404 100644 --- a/packages/start-plugin-core/src/plugin.ts +++ b/packages/start-plugin-core/src/plugin.ts @@ -15,7 +15,7 @@ import { getClientOutputDirectory, getServerOutputDirectory, } from './output-directory' -import { postServerBuild } from './post-server-build' +import { postServerBuild, postServerBuildForNitro } from './post-server-build' import { startCompilerPlugin } from './start-compiler-plugin/plugin' import type { GetConfigFn, @@ -38,6 +38,37 @@ function isFullUrl(str: string): boolean { } } +type NitroPluginKind = 'v3' | 'v2' | null + +function getNitroPluginKind( + plugins: ReadonlyArray, +): NitroPluginKind { + let hasV3 = false + let hasV2 = false + const queue = [...plugins] + + while (queue.length) { + const plugin = queue.shift() + if (!plugin) continue + if (Array.isArray(plugin)) { + queue.push(...plugin) + continue + } + if (typeof plugin === 'object' && 'name' in plugin) { + const name = typeof plugin.name === 'string' ? plugin.name : '' + if (name.startsWith('nitro:')) { + hasV3 = true + } else if (name === 'tanstack-nitro-v2-vite-plugin') { + hasV2 = true + } + } + } + + if (hasV3) return 'v3' + if (hasV2) return 'v2' + return null +} + export function TanStackStartVitePluginCore( corePluginOpts: TanStackStartVitePluginCoreOptions, startPluginOpts: TanStackStartInputConfig, @@ -351,14 +382,74 @@ export function TanStackStartVitePluginCore( // would cause this hook to run before those others' buildApp, breaking prerendering. { name: 'tanstack-start-core:post-build', + configResolved(config) { + resolvedStartConfig.viteConfigFile = config.configFile || undefined + }, buildApp: { order: 'post', async handler(builder) { const { startConfig } = getConfig() - await postServerBuild({ builder, startConfig }) + const serverEnv = builder.environments[VITE_ENVIRONMENT_NAMES.server] + if (!serverEnv) { + throw new Error('SSR environment not found') + } + const nitroKindFromPlugins = getNitroPluginKind( + builder.config.plugins, + ) + const nitroKind = + nitroKindFromPlugins ?? + (serverEnv.config.build.write === false ? 'v2' : null) + + if (nitroKind === 'v3') { + return + } + + if (nitroKind === 'v2') { + await postServerBuildForNitro({ + startConfig, + mode: 'nitro-server', + nitro: { + options: { + rootDir: builder.config.root, + output: { + dir: '.output', + publicDir: '.output/public', + }, + }, + }, + }) + return + } + + await postServerBuild({ + builder, + startConfig, + }) }, }, }, + // Nitro module plugin - runs prerendering after Nitro build using vite preview + { + name: 'tanstack-start-core:nitro-prerender', + nitro: { + name: 'tanstack-start-prerender', + setup(nitro: any) { + nitro.hooks.hook('compiled', async () => { + const { startConfig, resolvedStartConfig } = getConfig() + if (!startConfig.prerender?.enabled && !startConfig.spa?.enabled) { + return + } + + await postServerBuildForNitro({ + startConfig, + nitro, + mode: 'vite-preview', + configFile: resolvedStartConfig.viteConfigFile, + }) + }) + }, + }, + } as PluginOption, // Server function plugin handles: // 1. Identifying createServerFn().handler() calls // 2. Extracting server functions to separate modules diff --git a/packages/start-plugin-core/src/post-server-build.ts b/packages/start-plugin-core/src/post-server-build.ts index 109b5a680c5..4459aaeb0f8 100644 --- a/packages/start-plugin-core/src/post-server-build.ts +++ b/packages/start-plugin-core/src/post-server-build.ts @@ -1,57 +1,23 @@ -import { HEADERS } from '@tanstack/start-server-core' import { buildSitemap } from './build-sitemap' import { VITE_ENVIRONMENT_NAMES } from './constants' +import { setupPrerenderConfig } from './prerender-config' import { prerender } from './prerender' +import { prerenderWithNitro, resolveNitroOutputPaths } from './prerender-nitro' import type { TanStackStartOutputConfig } from './schema' import type { ViteBuilder } from 'vite' export async function postServerBuild({ builder, startConfig, + skipPrerender = false, }: { builder: ViteBuilder startConfig: TanStackStartOutputConfig + skipPrerender?: boolean }) { - // If the user has not set a prerender option, we need to set it to true - // if the pages array is not empty and has sub options requiring for prerendering - // If the user has explicitly set prerender.enabled, this should be respected - if (startConfig.prerender?.enabled !== false) { - startConfig.prerender = { - ...startConfig.prerender, - enabled: - startConfig.prerender?.enabled ?? - startConfig.pages.some((d) => - typeof d === 'string' ? false : !!d.prerender?.enabled, - ), - } - } - - // Setup the options for prerendering the SPA shell (i.e `src/routes/__root.tsx`) - if (startConfig.spa?.enabled) { - startConfig.prerender = { - ...startConfig.prerender, - enabled: true, - } + setupPrerenderConfig(startConfig) - const maskUrl = new URL(startConfig.spa.maskPath, 'http://localhost') - - startConfig.pages.push({ - path: maskUrl.toString().replace('http://localhost', ''), - prerender: { - ...startConfig.spa.prerender, - headers: { - ...startConfig.spa.prerender.headers, - [HEADERS.TSS_SHELL]: 'true', - }, - }, - sitemap: { - exclude: true, - }, - }) - } - - // Run the prerendering process - if (startConfig.prerender.enabled) { + if (startConfig.prerender?.enabled && !skipPrerender) { await prerender({ startConfig, builder, @@ -68,3 +34,43 @@ export async function postServerBuild({ }) } } + +export async function postServerBuildForNitro({ + startConfig, + nitro, + mode, + configFile, +}: { + startConfig: TanStackStartOutputConfig + nitro: { + options: { + rootDir: string + output?: { + dir?: string + publicDir?: string + } + preset?: string + } + } + mode: 'vite-preview' | 'nitro-server' + configFile?: string +}) { + setupPrerenderConfig(startConfig) + + if (startConfig.prerender?.enabled) { + await prerenderWithNitro({ + startConfig, + nitro, + mode, + configFile, + }) + } + + if (startConfig.sitemap?.enabled && startConfig.pages.length) { + const { publicDir } = resolveNitroOutputPaths(nitro) + buildSitemap({ + startConfig, + publicDir, + }) + } +} diff --git a/packages/start-plugin-core/src/prerender-config.ts b/packages/start-plugin-core/src/prerender-config.ts new file mode 100644 index 00000000000..eb63abc1021 --- /dev/null +++ b/packages/start-plugin-core/src/prerender-config.ts @@ -0,0 +1,38 @@ +import { HEADERS } from '@tanstack/start-server-core' +import type { TanStackStartOutputConfig } from './schema' + +export function setupPrerenderConfig(startConfig: TanStackStartOutputConfig) { + if (startConfig.prerender?.enabled !== false) { + startConfig.prerender = { + ...startConfig.prerender, + enabled: + startConfig.prerender?.enabled ?? + startConfig.pages.some((d) => + typeof d === 'string' ? false : !!d.prerender?.enabled, + ), + } + } + + if (startConfig.spa?.enabled) { + startConfig.prerender = { + ...startConfig.prerender, + enabled: true, + } + + const maskUrl = new URL(startConfig.spa.maskPath, 'http://localhost') + + startConfig.pages.push({ + path: maskUrl.toString().replace('http://localhost', ''), + prerender: { + ...startConfig.spa.prerender, + headers: { + ...startConfig.spa.prerender.headers, + [HEADERS.TSS_SHELL]: 'true', + }, + }, + sitemap: { + exclude: true, + }, + }) + } +} diff --git a/packages/start-plugin-core/src/prerender-nitro.ts b/packages/start-plugin-core/src/prerender-nitro.ts new file mode 100644 index 00000000000..f9a146623cd --- /dev/null +++ b/packages/start-plugin-core/src/prerender-nitro.ts @@ -0,0 +1,98 @@ +import { existsSync, promises as fsp } from 'node:fs' +import path from 'pathe' +import { prerender } from './prerender' +import { createLogger } from './utils' +import type { TanStackStartOutputConfig } from './schema' + +type NitroLike = { + options: { + rootDir: string + output?: { + dir?: string + publicDir?: string + } + preset?: string + } +} + +type PrerenderMode = 'vite-preview' | 'nitro-server' + +export function resolveNitroOutputPaths(nitro: NitroLike) { + const rootDir = nitro.options.rootDir + const output = nitro.options.output ?? {} + const outputDir = resolveNitroPath(rootDir, output.dir ?? '.output') + const publicDir = resolveNitroPath( + rootDir, + output.publicDir ?? path.join(output.dir ?? '.output', 'public'), + ) + + return { outputDir, publicDir } +} + +export async function prerenderWithNitro({ + startConfig, + nitro, + mode, + configFile, +}: { + startConfig: TanStackStartOutputConfig + nitro: NitroLike + mode: PrerenderMode + configFile?: string +}) { + const logger = createLogger('prerender') + const { outputDir, publicDir } = resolveNitroOutputPaths(nitro) + + if (mode === 'vite-preview') { + const nitroJsonPath = path.join(outputDir, 'nitro.json') + if (!existsSync(nitroJsonPath)) { + await writeNitroBuildInfo({ + outputDir, + preset: nitro.options.preset, + }) + } + + logger.info('Prerendering pages using vite.preview()...') + await prerender({ + startConfig, + outputDir: publicDir, + configFile, + }) + return + } + + logger.info('Prerendering pages using Nitro server...') + await prerender({ + startConfig, + outputDir: publicDir, + nitroServerPath: path.join(outputDir, 'server/index.mjs'), + }) +} + +async function writeNitroBuildInfo({ + outputDir, + preset, +}: { + outputDir: string + preset?: string +}) { + const logger = createLogger('prerender') + logger.info('Writing nitro.json for vite.preview()...') + + const buildInfo = { + date: new Date().toJSON(), + preset, + framework: { name: 'tanstack-start' }, + versions: {}, + commands: { + preview: `node ${path.join(outputDir, 'server/index.mjs')}`, + }, + } + + const buildInfoPath = path.join(outputDir, 'nitro.json') + await fsp.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2)) +} + +function resolveNitroPath(rootDir: string, value: string) { + return path.isAbsolute(value) ? value : path.resolve(rootDir, value) +} diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index ea6368109e5..ee8b872a95a 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -1,19 +1,28 @@ import { promises as fsp } from 'node:fs' +import { spawn } from 'node:child_process' import os from 'node:os' import path from 'pathe' import { joinURL, withBase, withoutBase } from 'ufo' import { VITE_ENVIRONMENT_NAMES } from './constants' import { createLogger } from './utils' import { Queue } from './queue' -import type { PreviewServer, ResolvedConfig, ViteBuilder } from 'vite' +import type { ChildProcess } from 'node:child_process' +import type { PreviewServer, ViteBuilder } from 'vite' import type { Page, TanStackStartOutputConfig } from './schema' export async function prerender({ startConfig, builder, + outputDir: outputDirOverride, + configFile: configFileOverride, + nitroServerPath, }: { startConfig: TanStackStartOutputConfig - builder: ViteBuilder + builder?: ViteBuilder + outputDir?: string + configFile?: string + /** Path to Nitro's compiled server entry (e.g., .output/server/index.mjs) */ + nitroServerPath?: string }) { const logger = createLogger('prerender') logger.info('Prerendering pages...') @@ -40,38 +49,54 @@ export async function prerender({ startConfig.pages = pages } - const serverEnv = builder.environments[VITE_ENVIRONMENT_NAMES.server] + let outputDir: string - if (!serverEnv) { - throw new Error( - `Vite's "${VITE_ENVIRONMENT_NAMES.server}" environment not found`, - ) - } - - const clientEnv = builder.environments[VITE_ENVIRONMENT_NAMES.client] - if (!clientEnv) { - throw new Error( - `Vite's "${VITE_ENVIRONMENT_NAMES.client}" environment not found`, - ) + if (outputDirOverride) { + outputDir = outputDirOverride + } else if (builder) { + const clientEnv = builder.environments[VITE_ENVIRONMENT_NAMES.client] + if (!clientEnv) { + throw new Error( + `Vite's "${VITE_ENVIRONMENT_NAMES.client}" environment not found`, + ) + } + outputDir = clientEnv.config.build.outDir + } else { + throw new Error('Either builder or outputDir must be provided') } - const outputDir = clientEnv.config.build.outDir - process.env.TSS_PRERENDERING = 'true' - // Start Vite preview server instead of importing module - const previewServer = await startPreviewServer(serverEnv.config) - const baseUrl = getResolvedUrl(previewServer) + let cleanup: () => Promise + let baseUrl: URL + + if (nitroServerPath) { + // Start Nitro server as a subprocess + const { url, close } = await startNitroServer(nitroServerPath) + baseUrl = url + cleanup = close + } else { + // Start Vite preview server + const configFile = + configFileOverride ?? + builder?.environments[VITE_ENVIRONMENT_NAMES.server]?.config.configFile + const previewServer = await startPreviewServer(configFile) + baseUrl = getResolvedUrl(previewServer) + cleanup = async () => { + await previewServer.close() + } + } const isRedirectResponse = (res: Response) => { return res.status >= 300 && res.status < 400 && res.headers.get('location') } + async function localFetch( - path: string, + fetchPath: string, options?: RequestInit, maxRedirects: number = 5, ): Promise { - const url = new URL(path, baseUrl) + const url = new URL(fetchPath, baseUrl) const request = new Request(url, options) const response = await fetch(request) @@ -98,8 +123,9 @@ export async function prerender({ }) } catch (error) { logger.error(error) + throw error } finally { - await previewServer.close() + await cleanup() } function extractLinks(html: string): Array { @@ -166,9 +192,7 @@ export async function prerender({ const res = await localFetch( withBase(page.path, routerBasePath), { - headers: { - ...(prerenderOptions.headers ?? {}), - }, + headers: prerenderOptions.headers, }, prerenderOptions.maxRedirects, ) @@ -262,19 +286,81 @@ export async function prerender({ } } +async function startNitroServer( + serverPath: string, +): Promise<{ url: URL; close: () => Promise }> { + return new Promise((resolve, reject) => { + // Find a random port + const port = 3000 + Math.floor(Math.random() * 10000) + const env = { ...process.env, PORT: String(port) } + + const child: ChildProcess = spawn('node', [serverPath], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let resolved = false + const timeout = setTimeout(() => { + if (!resolved) { + child.kill() + reject(new Error('Nitro server startup timed out')) + } + }, 30000) + + const checkServer = async () => { + try { + const res = await fetch(`http://localhost:${port}/`) + if (res.ok || res.status < 500) { + resolved = true + clearTimeout(timeout) + resolve({ + url: new URL(`http://localhost:${port}`), + close: async () => { + child.kill('SIGTERM') + // Wait a bit for graceful shutdown + await new Promise((r) => setTimeout(r, 500)) + }, + }) + } + } catch { + // Server not ready yet, retry + if (!resolved) { + setTimeout(checkServer, 100) + } + } + } + + child.on('error', (err) => { + if (!resolved) { + clearTimeout(timeout) + reject(err) + } + }) + + child.stderr?.on('data', (data) => { + console.error('[nitro]', data.toString()) + }) + + // Start checking after a short delay + setTimeout(checkServer, 200) + }) +} + async function startPreviewServer( - viteConfig: ResolvedConfig, + configFile?: string | false, ): Promise { const vite = await import('vite') try { - return await vite.preview({ - configFile: viteConfig.configFile, + const server = await vite.preview({ + configFile, preview: { port: 0, open: false, }, }) + + return server } catch (error) { throw new Error( 'Failed to start the Vite preview server for prerendering', diff --git a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts index 973b2b192b6..eed8ce4628a 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts @@ -193,8 +193,6 @@ export function startCompilerPlugin( } let root = process.cwd() - let command: 'build' | 'serve' = 'build' - const resolvedResolverVirtualImportId = resolveViteId( VIRTUAL_MODULES.serverFnResolver, ) @@ -227,7 +225,6 @@ export function startCompilerPlugin( }, configResolved(config) { root = config.root - command = config.command }, transform: { filter: { @@ -373,7 +370,6 @@ export function startCompilerPlugin( }, configResolved(config) { root = config.root - command = config.command }, resolveId: { filter: { id: new RegExp(VIRTUAL_MODULES.serverFnResolver) }, diff --git a/packages/start-plugin-core/src/types.ts b/packages/start-plugin-core/src/types.ts index e2273d1a127..12d04f12c3b 100644 --- a/packages/start-plugin-core/src/types.ts +++ b/packages/start-plugin-core/src/types.ts @@ -23,6 +23,7 @@ export interface ResolvedStartConfig { routerFilePath: string srcDirectory: string viteAppBase: string + viteConfigFile?: string serverFnProviderEnv: string } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ab9c9061b6..0f20f545fee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1293,6 +1293,122 @@ importers: specifier: ^4.49.1 version: 4.49.1 + e2e/react-start/basic-nitro: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.18 + '@tanstack/nitro-v2-vite-plugin': + specifier: workspace:* + version: link:../../../packages/nitro-v2-vite-plugin + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + nitro: + specifier: npm:nitro-nightly@latest + version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(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)) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.15 + version: 4.1.18 + 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)) + + e2e/react-start/basic-nitro-spa: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.18 + '@tanstack/nitro-v2-vite-plugin': + specifier: workspace:* + version: link:../../../packages/nitro-v2-vite-plugin + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + nitro: + specifier: npm:nitro-nightly@latest + version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(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)) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.15 + version: 4.1.18 + 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)) + e2e/react-start/basic-react-query: dependencies: '@tanstack/react-query': @@ -3251,6 +3367,110 @@ importers: specifier: ^4.49.1 version: 4.49.1 + e2e/solid-start/basic-nitro: + dependencies: + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-router-devtools': + specifier: workspace:^ + version: link:../../../packages/solid-router-devtools + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + solid-js: + specifier: 1.9.10 + version: 1.9.10 + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.18 + '@tanstack/nitro-v2-vite-plugin': + specifier: workspace:* + version: link:../../../packages/nitro-v2-vite-plugin + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + nitro: + specifier: npm:nitro-nightly@latest + version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(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)) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.15 + version: 4.1.18 + 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-plugin-solid: + specifier: ^2.11.10 + 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)) + 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/solid-start/basic-nitro-spa: + dependencies: + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-router-devtools': + specifier: workspace:^ + version: link:../../../packages/solid-router-devtools + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + solid-js: + specifier: 1.9.10 + version: 1.9.10 + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.18 + '@tanstack/nitro-v2-vite-plugin': + specifier: workspace:* + version: link:../../../packages/nitro-v2-vite-plugin + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + nitro: + specifier: npm:nitro-nightly@latest + version: nitro-nightly@3.0.1-20251230-165713-6e801e22(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.8.0)(lru-cache@11.2.2)(mysql2@3.15.3)(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)) + postcss: + specifier: ^8.5.1 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.15 + version: 4.1.18 + 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-plugin-solid: + specifier: ^2.11.10 + 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)) + 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/solid-start/basic-solid-query: dependencies: '@tanstack/solid-query': @@ -11468,10 +11688,10 @@ importers: version: 2.0.0 seroval: specifier: ^1.4.1 - version: 1.4.1 + version: 1.4.2 seroval-plugins: specifier: ^1.4.0 - version: 1.4.0(seroval@1.4.1) + version: 1.4.0(seroval@1.4.2) tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -11903,7 +12123,7 @@ importers: version: link:../start-storage-context seroval: specifier: ^1.4.1 - version: 1.4.1 + version: 1.4.2 tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -12005,7 +12225,7 @@ importers: version: h3@2.0.1-rc.7(crossws@0.4.1(srvx@0.10.0)) seroval: specifier: ^1.4.1 - version: 1.4.1 + version: 1.4.2 tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -12033,7 +12253,7 @@ importers: version: link:../start-client-core seroval: specifier: ^1.4.1 - version: 1.4.1 + version: 1.4.2 packages/start-storage-context: dependencies: @@ -12426,6 +12646,11 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/parser@7.27.5': + resolution: {integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} @@ -14187,6 +14412,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -22404,8 +22632,8 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.4.1: - resolution: {integrity: sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg==} + seroval@1.4.2: + resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==} engines: {node: '>=10'} serve-index@1.9.1: @@ -24190,6 +24418,10 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.5 + '@babel/parser@7.27.5': + dependencies: + '@babel/types': 7.28.5 + '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 @@ -24327,7 +24559,7 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 + '@babel/parser': 7.27.5 '@babel/types': 7.28.5 '@babel/traverse@7.28.5': @@ -25724,7 +25956,7 @@ snapshots: '@img/sharp-wasm32@0.34.4': dependencies: - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 optional: true '@img/sharp-win32-arm64@0.34.4': @@ -25837,6 +26069,11 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -26200,8 +26437,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.1 optional: true @@ -29129,7 +29366,7 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.27.5 '@babel/types': 7.28.5 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -29141,7 +29378,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.27.5 '@babel/types': 7.28.5 '@types/babel__traverse@7.28.0': @@ -29204,7 +29441,7 @@ snapshots: '@types/eslint@9.6.1': dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 '@types/estree@1.0.7': {} @@ -32167,7 +32404,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 esutils@2.0.3: {} @@ -33126,7 +33363,7 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 is-regex@1.2.1: dependencies: @@ -35380,13 +35617,13 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.4.0(seroval@1.4.1): + seroval-plugins@1.4.0(seroval@1.4.2): dependencies: - seroval: 1.4.1 + seroval: 1.4.2 seroval@1.3.2: {} - seroval@1.4.1: {} + seroval@1.4.2: {} serve-index@1.9.1: dependencies: @@ -35845,7 +36082,7 @@ snapshots: terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.15(@swc/helpers@0.5.15))): dependencies: - '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 @@ -35856,7 +36093,7 @@ snapshots: terser-webpack-plugin@5.3.11(@swc/core@1.10.15(@swc/helpers@0.5.15))(webpack@5.97.1): dependencies: - '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2