diff --git a/e2e/preact-router/basic/.gitignore b/e2e/preact-router/basic/.gitignore
new file mode 100644
index 00000000000..4d2da67b504
--- /dev/null
+++ b/e2e/preact-router/basic/.gitignore
@@ -0,0 +1,11 @@
+node_modules
+.DS_Store
+dist
+dist-hash
+dist-ssr
+*.local
+
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/preact-router/basic/index.html b/e2e/preact-router/basic/index.html
new file mode 100644
index 00000000000..a2252c34539
--- /dev/null
+++ b/e2e/preact-router/basic/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ TanStack Router Preact E2E
+
+
+
+
+
+
diff --git a/e2e/preact-router/basic/package.json b/e2e/preact-router/basic/package.json
new file mode 100644
index 00000000000..6ecfe3ed70a
--- /dev/null
+++ b/e2e/preact-router/basic/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "tanstack-router-e2e-preact-basic",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3000",
+ "dev:e2e": "vite",
+ "build": "vite build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "vite",
+ "test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/preact-router": "workspace:^",
+ "preact": "^10.25.0",
+ "preact-render-to-string": "^6.6.5"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@preact/preset-vite": "^2.9.4",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1"
+ }
+}
diff --git a/e2e/preact-router/basic/playwright.config.ts b/e2e/preact-router/basic/playwright.config.ts
new file mode 100644
index 00000000000..5ad36c825bd
--- /dev/null
+++ b/e2e/preact-router/basic/playwright.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from '@playwright/test'
+import {
+ getDummyServerPort,
+ getTestServerPort,
+} from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const EXTERNAL_PORT = await getDummyServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+ globalSetup: './tests/setup/global.setup.ts',
+ globalTeardown: './tests/setup/global.teardown.ts',
+ use: {
+ baseURL,
+ },
+ webServer: {
+ command: `VITE_NODE_ENV=\"test\" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && pnpm preview --port ${PORT}`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/preact-router/basic/src/main.tsx b/e2e/preact-router/basic/src/main.tsx
new file mode 100644
index 00000000000..44811cbc9af
--- /dev/null
+++ b/e2e/preact-router/basic/src/main.tsx
@@ -0,0 +1,67 @@
+import { render } from 'preact'
+import { useMemo } from 'preact/hooks'
+import {
+ Await,
+ Link,
+ Outlet,
+ RouterProvider,
+ Suspense,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '@tanstack/preact-router'
+
+const rootRoute = createRootRoute({
+ component: () => (
+
+
+
+
+ ),
+})
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+})
+
+const slowRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/slow',
+ component: SlowRouteComponent,
+})
+
+function SlowRouteComponent() {
+ const promise = useMemo(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve('Loaded!'), 200)
+ }),
+ [],
+ )
+
+ return (
+
+
Slow Route
+ Loading...}>
+
+ {(value) => {value}
}
+
+
+
+ )
+}
+
+const routeTree = rootRoute.addChildren([indexRoute, slowRoute])
+
+const router = createRouter({
+ routeTree,
+})
+
+render(, document.getElementById('app')!)
diff --git a/e2e/preact-router/basic/tests/app.spec.ts b/e2e/preact-router/basic/tests/app.spec.ts
new file mode 100644
index 00000000000..165037ec49d
--- /dev/null
+++ b/e2e/preact-router/basic/tests/app.spec.ts
@@ -0,0 +1,14 @@
+import { expect, test } from '@playwright/test'
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+})
+
+test('shows suspense fallback and then resolved content', async ({ page }) => {
+ await expect(page.getByTestId('home')).toBeVisible()
+
+ await page.getByRole('link', { name: 'Slow' }).click()
+ await expect(page.getByTestId('slow-title')).toBeVisible()
+ await expect(page.getByTestId('suspense-fallback')).toBeVisible()
+ await expect(page.getByTestId('suspense-content')).toHaveText('Loaded!')
+})
diff --git a/e2e/preact-router/basic/tests/setup/global.setup.ts b/e2e/preact-router/basic/tests/setup/global.setup.ts
new file mode 100644
index 00000000000..3593d10ab90
--- /dev/null
+++ b/e2e/preact-router/basic/tests/setup/global.setup.ts
@@ -0,0 +1,6 @@
+import { e2eStartDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function setup() {
+ await e2eStartDummyServer(packageJson.name)
+}
diff --git a/e2e/preact-router/basic/tests/setup/global.teardown.ts b/e2e/preact-router/basic/tests/setup/global.teardown.ts
new file mode 100644
index 00000000000..62fd79911cc
--- /dev/null
+++ b/e2e/preact-router/basic/tests/setup/global.teardown.ts
@@ -0,0 +1,6 @@
+import { e2eStopDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function teardown() {
+ await e2eStopDummyServer(packageJson.name)
+}
diff --git a/e2e/preact-router/basic/tsconfig.json b/e2e/preact-router/basic/tsconfig.json
new file mode 100644
index 00000000000..c74cfd97707
--- /dev/null
+++ b/e2e/preact-router/basic/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "target": "ESNext",
+ "moduleResolution": "Bundler",
+ "module": "ESNext",
+ "resolveJsonModule": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "types": ["vite/client"],
+ "paths": {
+ "@tanstack/router-e2e-utils": ["../../e2e-utils/src/index.ts"]
+ }
+ },
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/e2e/preact-router/basic/vite.config.js b/e2e/preact-router/basic/vite.config.js
new file mode 100644
index 00000000000..bfe110c0597
--- /dev/null
+++ b/e2e/preact-router/basic/vite.config.js
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import preact from '@preact/preset-vite'
+
+export default defineConfig({
+ plugins: [preact()],
+})
diff --git a/examples/preact/basic/.gitignore b/examples/preact/basic/.gitignore
new file mode 100644
index 00000000000..8354e4d50d5
--- /dev/null
+++ b/examples/preact/basic/.gitignore
@@ -0,0 +1,10 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
\ No newline at end of file
diff --git a/examples/preact/basic/.vscode/settings.json b/examples/preact/basic/.vscode/settings.json
new file mode 100644
index 00000000000..00b5278e580
--- /dev/null
+++ b/examples/preact/basic/.vscode/settings.json
@@ -0,0 +1,11 @@
+{
+ "files.watcherExclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "search.exclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "files.readonlyInclude": {
+ "**/routeTree.gen.ts": true
+ }
+}
diff --git a/examples/preact/basic/README.md b/examples/preact/basic/README.md
new file mode 100644
index 00000000000..a58d907bf87
--- /dev/null
+++ b/examples/preact/basic/README.md
@@ -0,0 +1,45 @@
+# TanStack Router - Basic Example
+
+A basic example demonstrating the fundamentals of TanStack Router.
+
+- [TanStack Router Docs](https://tanstack.com/router)
+
+## Start a new project based on this example
+
+To start a new project based on this example, run:
+
+```sh
+npx gitpick TanStack/router/tree/main/examples/react/basic basic
+```
+
+## Getting Started
+
+Install dependencies:
+
+```sh
+pnpm install
+```
+
+Start the development server:
+
+```sh
+pnpm dev
+```
+
+## Build
+
+Build for production:
+
+```sh
+pnpm build
+```
+
+## About This Example
+
+This example demonstrates:
+
+- Basic routing setup
+- Route configuration
+- Navigation
+- Route parameters
+- TanStack Router DevTools
diff --git a/examples/preact/basic/index.html b/examples/preact/basic/index.html
new file mode 100644
index 00000000000..9b6335c0ac1
--- /dev/null
+++ b/examples/preact/basic/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Vite App
+
+
+
+
+
+
diff --git a/examples/preact/basic/package.json b/examples/preact/basic/package.json
new file mode 100644
index 00000000000..8f2d540f9d2
--- /dev/null
+++ b/examples/preact/basic/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "tanstack-router-react-example-basic",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port=3000",
+ "build": "vite build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "vite"
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "@tanstack/preact-router": "^1.160.0",
+ "preact": "^10.25.0",
+ "preact-render-to-string": "^6.6.5",
+ "redaxios": "^0.5.1",
+ "tailwindcss": "^4.1.18"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.10.3",
+ "typescript": "^5.7.2",
+ "vite": "^7.3.1"
+ }
+}
diff --git a/examples/preact/basic/src/main.tsx b/examples/preact/basic/src/main.tsx
new file mode 100644
index 00000000000..870b82756b3
--- /dev/null
+++ b/examples/preact/basic/src/main.tsx
@@ -0,0 +1,230 @@
+import {render} from 'preact'
+import {
+ ErrorComponent,
+ Link,
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '@tanstack/preact-router'
+import { NotFoundError, fetchPost, fetchPosts } from './posts'
+import type { ErrorComponentProps } from '@tanstack/preact-router'
+import './styles.css'
+
+const rootRoute = createRootRoute({
+ component: RootComponent,
+ notFoundComponent: () => {
+ return (
+
+
This is the notFoundComponent configured on root route
+
Start Over
+
+ )
+ },
+})
+
+function RootComponent() {
+ return (
+ <>
+
+
+ Home
+ {' '}
+
+ Posts
+ {' '}
+
+ Pathless Layout
+ {' '}
+
+ This Route Does Not Exist
+
+
+
+ >
+ )
+}
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: IndexComponent,
+})
+
+function IndexComponent() {
+ return (
+
+
Welcome Home!
+
+ )
+}
+
+export const postsLayoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'posts',
+ loader: () => fetchPosts(),
+}).lazy(() => import('./posts.lazy').then((d) => d.Route))
+
+const postsIndexRoute = createRoute({
+ getParentRoute: () => postsLayoutRoute,
+ path: '/',
+ component: PostsIndexComponent,
+})
+
+function PostsIndexComponent() {
+ return Select a post.
+}
+
+const postRoute = createRoute({
+ getParentRoute: () => postsLayoutRoute,
+ path: '$postId',
+ errorComponent: PostErrorComponent,
+ loader: ({ params }) => fetchPost(params.postId),
+ component: PostComponent,
+})
+
+function PostErrorComponent({ error }: ErrorComponentProps) {
+ if (error instanceof NotFoundError) {
+ return {error.message}
+ }
+
+ return
+}
+
+function PostComponent() {
+ const post = postRoute.useLoaderData()
+
+ return (
+
+
{post.title}
+
+
{post.body}
+
+ )
+}
+
+const pathlessLayoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: '_pathlessLayout',
+ component: PathlessLayoutComponent,
+})
+
+function PathlessLayoutComponent() {
+ return (
+
+
I'm a pathless layout
+
+
+
+
+ )
+}
+
+const nestedPathlessLayout2Route = createRoute({
+ getParentRoute: () => pathlessLayoutRoute,
+ id: '_nestedPathlessLayout',
+ component: PathlessLayout2Component,
+})
+
+function PathlessLayout2Component() {
+ return (
+
+
I'm a nested pathless layout
+
+
+ Go to Route A
+
+
+ Go to Route B
+
+
+
+
+
+
+ )
+}
+
+const pathlessLayoutARoute = createRoute({
+ getParentRoute: () => nestedPathlessLayout2Route,
+ path: '/route-a',
+ component: PathlessLayoutAComponent,
+})
+
+function PathlessLayoutAComponent() {
+ return I'm route A!
+}
+
+const pathlessLayoutBRoute = createRoute({
+ getParentRoute: () => nestedPathlessLayout2Route,
+ path: '/route-b',
+ component: PathlessLayoutBComponent,
+})
+
+function PathlessLayoutBComponent() {
+ return I'm route B!
+}
+
+const routeTree = rootRoute.addChildren([
+ postsLayoutRoute.addChildren([postRoute, postsIndexRoute]),
+ pathlessLayoutRoute.addChildren([
+ nestedPathlessLayout2Route.addChildren([
+ pathlessLayoutARoute,
+ pathlessLayoutBRoute,
+ ]),
+ ]),
+ indexRoute,
+])
+
+// Set up a Router instance
+const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ defaultStaleTime: 5000,
+ scrollRestoration: true,
+})
+
+// Register things for typesafety
+declare module '@tanstack/preact-router' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+const rootElement = document.getElementById('app')!
+
+if (!rootElement.innerHTML) {
+ render(, rootElement)
+}
diff --git a/examples/preact/basic/src/posts.lazy.tsx b/examples/preact/basic/src/posts.lazy.tsx
new file mode 100644
index 00000000000..1e4a4daf6fa
--- /dev/null
+++ b/examples/preact/basic/src/posts.lazy.tsx
@@ -0,0 +1,35 @@
+import { Link, Outlet, createLazyRoute } from '@tanstack/preact-router'
+
+export const Route = createLazyRoute('/posts')({
+ component: PostsLayoutComponent,
+})
+
+function PostsLayoutComponent() {
+ const posts = Route.useLoaderData()
+
+ return (
+
+ )
+}
diff --git a/examples/preact/basic/src/posts.ts b/examples/preact/basic/src/posts.ts
new file mode 100644
index 00000000000..54d62e57886
--- /dev/null
+++ b/examples/preact/basic/src/posts.ts
@@ -0,0 +1,32 @@
+import axios from 'redaxios'
+
+export class NotFoundError extends Error {}
+
+type PostType = {
+ id: string
+ title: string
+ body: string
+}
+
+export const fetchPosts = async () => {
+ console.info('Fetching posts...')
+ await new Promise((r) => setTimeout(r, 500))
+ return axios
+ .get>('https://jsonplaceholder.typicode.com/posts')
+ .then((r) => r.data.slice(0, 10))
+}
+
+export const fetchPost = async (postId: string) => {
+ console.info(`Fetching post with id ${postId}...`)
+ await new Promise((r) => setTimeout(r, 500))
+ const post = await axios
+ .get(`https://jsonplaceholder.typicode.com/posts/${postId}`)
+ .then((r) => r.data)
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!post) {
+ throw new NotFoundError(`Post with id "${postId}" not found!`)
+ }
+
+ return post
+}
diff --git a/examples/preact/basic/src/styles.css b/examples/preact/basic/src/styles.css
new file mode 100644
index 00000000000..ce24a390c75
--- /dev/null
+++ b/examples/preact/basic/src/styles.css
@@ -0,0 +1,21 @@
+@import 'tailwindcss' source('../');
+
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
+
+html {
+ color-scheme: light dark;
+}
+* {
+ @apply border-gray-200 dark:border-gray-800;
+}
+body {
+ @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
+}
diff --git a/examples/preact/basic/tsconfig.json b/examples/preact/basic/tsconfig.json
new file mode 100644
index 00000000000..10dbda00f82
--- /dev/null
+++ b/examples/preact/basic/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "target": "ESNext",
+ "moduleResolution": "Bundler",
+ "module": "ESNext",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "skipLibCheck": true
+ }
+}
diff --git a/examples/preact/basic/vite.config.js b/examples/preact/basic/vite.config.js
new file mode 100644
index 00000000000..1b24376e976
--- /dev/null
+++ b/examples/preact/basic/vite.config.js
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import preact from '@preact/preset-vite'
+import tailwindcss from '@tailwindcss/vite'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [tailwindcss(), preact()],
+})
diff --git a/package.json b/package.json
index 15e059571c6..6a7c135d666 100644
--- a/package.json
+++ b/package.json
@@ -120,6 +120,7 @@
"@tanstack/start-client-core": "workspace:*",
"@tanstack/start-server-core": "workspace:*",
"@tanstack/start-storage-context": "workspace:*",
+ "@tanstack/preact-router": "workspace:*",
"@tanstack/vue-router": "workspace:*",
"@tanstack/vue-router-devtools": "workspace:*",
"@tanstack/eslint-plugin-router": "workspace:*",
diff --git a/packages/preact-router/eslint.config.ts b/packages/preact-router/eslint.config.ts
new file mode 100644
index 00000000000..108ae9cff1c
--- /dev/null
+++ b/packages/preact-router/eslint.config.ts
@@ -0,0 +1,14 @@
+import rootConfig from '../../eslint.config.js'
+
+export default [
+ ...rootConfig,
+ {
+ files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
+ },
+ {
+ rules: {
+ '@typescript-eslint/no-unnecessary-condition': 'off',
+ 'no-unused-vars': 'off',
+ },
+ },
+]
diff --git a/packages/preact-router/package.json b/packages/preact-router/package.json
new file mode 100644
index 00000000000..f875d8d2a65
--- /dev/null
+++ b/packages/preact-router/package.json
@@ -0,0 +1,88 @@
+{
+ "name": "@tanstack/preact-router",
+ "version": "1.160.0",
+ "description": "Modern and scalable routing for Preact applications",
+ "author": "Tanner Linsley",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/router.git",
+ "directory": "packages/preact-router"
+ },
+ "homepage": "https://tanstack.com/router",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "keywords": [
+ "preact",
+ "location",
+ "router",
+ "routing",
+ "async",
+ "async router",
+ "typescript"
+ ],
+ "scripts": {
+ "clean": "rimraf ./dist && rimraf ./coverage",
+ "test": "pnpm run test:unit",
+ "test:eslint": "eslint",
+ "test:types": "tsc -p tsconfig.legacy.json",
+ "test:unit": "vitest",
+ "test:unit:dev": "pnpm run test:unit --watch --hideSkippedTests",
+ "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .",
+ "build": "vite build"
+ },
+ "type": "module",
+ "types": "dist/esm/index.d.ts",
+ "module": "dist/esm/index.js",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/esm/index.d.ts",
+ "development": "./dist/esm/index.dev.js",
+ "default": "./dist/esm/index.js"
+ }
+ },
+ "./ssr/server": {
+ "import": {
+ "types": "./dist/esm/ssr/server.d.ts",
+ "default": "./dist/esm/ssr/server.js"
+ }
+ },
+ "./ssr/client": {
+ "import": {
+ "types": "./dist/esm/ssr/client.d.ts",
+ "default": "./dist/esm/ssr/client.js"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "src"
+ ],
+ "engines": {
+ "node": ">=12"
+ },
+ "dependencies": {
+ "@tanstack/history": "workspace:*",
+ "@tanstack/preact-store": "^0.10.2",
+ "@tanstack/router-core": "workspace:*",
+ "isbot": "^5.1.22",
+ "preact-render-to-string": "^6.6.5",
+ "preact-suspense": "^0.2.0",
+ "tiny-invariant": "^1.3.3",
+ "tiny-warning": "^1.0.3"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.9.4",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/preact": "^3.2.4",
+ "preact": "^10.25.0"
+ },
+ "peerDependencies": {
+ "preact": "^10.0.0"
+ }
+}
diff --git a/packages/preact-router/src/Asset.tsx b/packages/preact-router/src/Asset.tsx
new file mode 100644
index 00000000000..880d65276cb
--- /dev/null
+++ b/packages/preact-router/src/Asset.tsx
@@ -0,0 +1,188 @@
+import { useEffect } from 'preact/hooks'
+import { isServer } from '@tanstack/router-core/isServer'
+import { useRouter } from './useRouter'
+import type { VNode } from 'preact'
+import type { RouterManagedTag } from '@tanstack/router-core'
+
+interface ScriptAttrs {
+ [key: string]: string | boolean | undefined
+ src?: string
+}
+
+export function Asset({
+ tag,
+ attrs,
+ children,
+ nonce,
+}: RouterManagedTag & { nonce?: string }): VNode | null {
+ switch (tag) {
+ case 'title':
+ return (
+
+ {children}
+
+ )
+ case 'meta':
+ return
+ case 'link':
+ return
+ case 'style':
+ return (
+
+ )
+ case 'script':
+ return
+ default:
+ return null
+ }
+}
+
+function Script({
+ attrs,
+ children,
+}: {
+ attrs?: ScriptAttrs
+ children?: string
+}) {
+ const router = useRouter()
+ const dataScript =
+ typeof attrs?.type === 'string' &&
+ attrs.type !== '' &&
+ attrs.type !== 'text/javascript' &&
+ attrs.type !== 'module'
+
+ useEffect(() => {
+ if (dataScript) return
+
+ if (attrs?.src) {
+ const normSrc = (() => {
+ try {
+ const base = document.baseURI || window.location.href
+ return new URL(attrs.src, base).href
+ } catch {
+ return attrs.src
+ }
+ })()
+ const existingScript = Array.from(
+ document.querySelectorAll('script[src]'),
+ ).find((el) => (el as HTMLScriptElement).src === normSrc)
+
+ if (existingScript) {
+ return
+ }
+
+ const script = document.createElement('script')
+
+ for (const [key, value] of Object.entries(attrs)) {
+ if (
+ key !== 'suppressHydrationWarning' &&
+ value !== undefined &&
+ value !== false
+ ) {
+ script.setAttribute(
+ key,
+ typeof value === 'boolean' ? '' : String(value),
+ )
+ }
+ }
+
+ document.head.appendChild(script)
+
+ return () => {
+ if (script.parentNode) {
+ script.parentNode.removeChild(script)
+ }
+ }
+ }
+
+ if (typeof children === 'string') {
+ const typeAttr =
+ typeof attrs?.type === 'string' ? attrs.type : 'text/javascript'
+ const nonceAttr =
+ typeof attrs?.nonce === 'string' ? attrs.nonce : undefined
+ const existingScript = Array.from(
+ document.querySelectorAll('script:not([src])'),
+ ).find((el) => {
+ if (!(el instanceof HTMLScriptElement)) return false
+ const sType = el.getAttribute('type') ?? 'text/javascript'
+ const sNonce = el.getAttribute('nonce') ?? undefined
+ return (
+ el.textContent === children &&
+ sType === typeAttr &&
+ sNonce === nonceAttr
+ )
+ })
+
+ if (existingScript) {
+ return
+ }
+
+ const script = document.createElement('script')
+ script.textContent = children
+
+ if (attrs) {
+ for (const [key, value] of Object.entries(attrs)) {
+ if (
+ key !== 'suppressHydrationWarning' &&
+ value !== undefined &&
+ value !== false
+ ) {
+ script.setAttribute(
+ key,
+ typeof value === 'boolean' ? '' : String(value),
+ )
+ }
+ }
+ }
+
+ document.head.appendChild(script)
+
+ return () => {
+ if (script.parentNode) {
+ script.parentNode.removeChild(script)
+ }
+ }
+ }
+
+ return undefined
+ }, [attrs, children, dataScript])
+
+ if (!(isServer ?? router.isServer)) {
+ if (dataScript && typeof children === 'string') {
+ return (
+
+ )
+ }
+
+ const { src: _src, async: _async, defer: _defer, ...rest } = attrs || {}
+ // render an empty script on the client just to avoid hydration errors
+ return (
+
+ )
+ }
+
+ if (attrs?.src && typeof attrs.src === 'string') {
+ return
+ }
+
+ if (typeof children === 'string') {
+ return (
+
+ )
+ }
+
+ return null
+}
diff --git a/packages/preact-router/src/CatchBoundary.tsx b/packages/preact-router/src/CatchBoundary.tsx
new file mode 100644
index 00000000000..d785a1c270a
--- /dev/null
+++ b/packages/preact-router/src/CatchBoundary.tsx
@@ -0,0 +1,120 @@
+import { Component, h } from 'preact'
+import { useState } from 'preact/hooks'
+import type { ComponentChildren } from 'preact'
+import type { ErrorRouteComponent } from './route'
+
+export function CatchBoundary(props: {
+ getResetKey: () => number | string
+ children: ComponentChildren
+ errorComponent?: ErrorRouteComponent
+ onCatch?: (error: Error, errorInfo: { componentStack?: string }) => void
+}) {
+ const errorComponent = props.errorComponent ?? ErrorComponent
+
+ return (
+ {
+ if (error) {
+ return h(errorComponent, {
+ error,
+ reset,
+ })
+ }
+
+ return props.children
+ }}
+ />
+ )
+}
+
+class CatchBoundaryImpl extends Component<
+ {
+ getResetKey: () => number | string
+ children: (props: {
+ error: Error | null
+ reset: () => void
+ }) => ComponentChildren
+ onCatch?: (error: Error, errorInfo: { componentStack?: string }) => void
+ },
+ { error: Error | null; resetKey: string }
+> {
+ state = { error: null as Error | null, resetKey: '' }
+
+ static getDerivedStateFromProps(props: any) {
+ return { resetKey: props.getResetKey() }
+ }
+ static getDerivedStateFromError(error: Error) {
+ return { error }
+ }
+ reset() {
+ this.setState({ error: null })
+ }
+ componentDidUpdate(
+ prevProps: any,
+ prevState: any,
+ ): void {
+ if (prevState.error && prevState.resetKey !== this.state.resetKey) {
+ this.reset()
+ }
+ }
+ componentDidCatch(error: Error, errorInfo: any) {
+ if (this.props.onCatch) {
+ this.props.onCatch(error, errorInfo)
+ }
+ }
+ render() {
+ return this.props.children({
+ error:
+ this.state.resetKey !== this.props.getResetKey()
+ ? null
+ : this.state.error,
+ reset: () => {
+ this.reset()
+ },
+ })
+ }
+}
+
+export function ErrorComponent({ error }: { error: any }) {
+ const [show, setShow] = useState(process.env.NODE_ENV !== 'production')
+
+ return (
+
+
+ Something went wrong!
+
+
+
+ {show ? (
+
+
+ {error.message ? {error.message} : null}
+
+
+ ) : null}
+
+ )
+}
diff --git a/packages/preact-router/src/ClientOnly.tsx b/packages/preact-router/src/ClientOnly.tsx
new file mode 100644
index 00000000000..6ff72d28bca
--- /dev/null
+++ b/packages/preact-router/src/ClientOnly.tsx
@@ -0,0 +1,33 @@
+import { Fragment } from 'preact'
+import { useState, useEffect } from 'preact/hooks'
+import type { ComponentChildren } from 'preact'
+
+export interface ClientOnlyProps {
+ children: ComponentChildren
+ fallback?: ComponentChildren
+}
+
+/**
+ * Render the children only after the JS has loaded client-side.
+ */
+export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
+ return useHydrated() ? (
+ {children}
+ ) : (
+ {fallback}
+ )
+}
+
+/**
+ * Return a boolean indicating if the JS has been hydrated already.
+ * Uses useState + useEffect since Preact doesn't have useSyncExternalStore.
+ */
+export function useHydrated(): boolean {
+ const [hydrated, setHydrated] = useState(false)
+
+ useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return hydrated
+}
diff --git a/packages/preact-router/src/HeadContent.dev.tsx b/packages/preact-router/src/HeadContent.dev.tsx
new file mode 100644
index 00000000000..3d2fd323a49
--- /dev/null
+++ b/packages/preact-router/src/HeadContent.dev.tsx
@@ -0,0 +1,43 @@
+import { useEffect } from 'preact/hooks'
+import { Asset } from './Asset'
+import { useRouter } from './useRouter'
+import { useHydrated } from './ClientOnly'
+import { useTags } from './headContentUtils'
+
+const DEV_STYLES_ATTR = 'data-tanstack-router-dev-styles'
+
+/**
+ * Render route-managed head tags (title, meta, links, styles, head scripts).
+ * Place inside the document head of your app shell.
+ *
+ * Development version: filters out dev styles link after hydration and
+ * includes a fallback cleanup effect for hydration mismatch cases.
+ */
+export function HeadContent() {
+ const tags = useTags()
+ const router = useRouter()
+ const nonce = router.options.ssr?.nonce
+ const hydrated = useHydrated()
+
+ // Fallback cleanup for hydration mismatch cases
+ useEffect(() => {
+ if (hydrated) {
+ document
+ .querySelectorAll(`link[${DEV_STYLES_ATTR}]`)
+ .forEach((el) => el.remove())
+ }
+ }, [hydrated])
+
+ // Filter out dev styles after hydration
+ const filteredTags = hydrated
+ ? tags.filter((tag) => !tag.attrs?.[DEV_STYLES_ATTR])
+ : tags
+
+ return (
+ <>
+ {filteredTags.map((tag) => (
+
+ ))}
+ >
+ )
+}
diff --git a/packages/preact-router/src/HeadContent.tsx b/packages/preact-router/src/HeadContent.tsx
new file mode 100644
index 00000000000..e4d2b54e324
--- /dev/null
+++ b/packages/preact-router/src/HeadContent.tsx
@@ -0,0 +1,20 @@
+import { Asset } from './Asset'
+import { useRouter } from './useRouter'
+import { useTags } from './headContentUtils'
+
+/**
+ * Render route-managed head tags (title, meta, links, styles, head scripts).
+ * Place inside the document head of your app shell.
+ */
+export function HeadContent() {
+ const tags = useTags()
+ const router = useRouter()
+ const nonce = router.options.ssr?.nonce
+ return (
+ <>
+ {tags.map((tag) => (
+
+ ))}
+ >
+ )
+}
diff --git a/packages/preact-router/src/Match.tsx b/packages/preact-router/src/Match.tsx
new file mode 100644
index 00000000000..f5d101fe9e2
--- /dev/null
+++ b/packages/preact-router/src/Match.tsx
@@ -0,0 +1,327 @@
+import { h } from 'preact'
+import { useContext, useMemo, useRef } from 'preact/hooks'
+import invariant from 'tiny-invariant'
+import warning from 'tiny-warning'
+import {
+ createControlledPromise,
+ getLocationChangeInfo,
+ isNotFound,
+ isRedirect,
+ rootRouteId,
+} from '@tanstack/router-core'
+import { isServer } from '@tanstack/router-core/isServer'
+import { CatchBoundary, ErrorComponent } from './CatchBoundary'
+import { useRouterState } from './useRouterState'
+import { useRouter } from './useRouter'
+import { CatchNotFound } from './not-found'
+import { matchContext } from './matchContext'
+import { SafeFragment } from './SafeFragment'
+import { Suspense } from './Suspense'
+import { renderRouteNotFound } from './renderRouteNotFound'
+import { ScrollRestoration } from './scroll-restoration'
+import { ClientOnly } from './ClientOnly'
+import type {
+ AnyRoute,
+ ParsedLocation,
+ RootRouteOptions,
+} from '@tanstack/router-core'
+
+// No React.memo in Preact without compat - just use plain function components
+export function Match({ matchId }: { matchId: string }) {
+ const router = useRouter()
+ const matchState = useRouterState({
+ select: (s) => {
+ const matchIndex = s.matches.findIndex((d) => d.id === matchId)
+ const match = s.matches[matchIndex]
+ invariant(
+ match,
+ `Could not find match for matchId "${matchId}". Please file an issue!`,
+ )
+ return {
+ routeId: match.routeId,
+ ssr: match.ssr,
+ _displayPending: match._displayPending,
+ resetKey: s.loadedAt,
+ parentRouteId: s.matches[matchIndex - 1]?.routeId as string,
+ }
+ },
+ structuralSharing: true as any,
+ })
+
+ const route: AnyRoute = router.routesById[matchState.routeId]
+
+ const PendingComponent =
+ route.options.pendingComponent ?? router.options.defaultPendingComponent
+
+ const pendingElement = PendingComponent ? : null
+
+ const routeErrorComponent =
+ route.options.errorComponent ?? router.options.defaultErrorComponent
+
+ const routeOnCatch = route.options.onCatch ?? router.options.defaultOnCatch
+
+ const routeNotFoundComponent = route.isRoot
+ ? (route.options.notFoundComponent ??
+ router.options.notFoundRoute?.options.component)
+ : route.options.notFoundComponent
+
+ const resolvedNoSsr =
+ matchState.ssr === false || matchState.ssr === 'data-only'
+ const ResolvedSuspenseBoundary =
+ // If we're on the root route, allow forcefully wrapping in suspense
+ (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) &&
+ (route.options.wrapInSuspense ??
+ PendingComponent ??
+ ((route.options.errorComponent as any)?.preload || resolvedNoSsr))
+ ? Suspense
+ : SafeFragment
+
+ const ResolvedCatchBoundary = routeErrorComponent
+ ? CatchBoundary
+ : SafeFragment
+
+ const ResolvedNotFoundBoundary = routeNotFoundComponent
+ ? CatchNotFound
+ : SafeFragment
+
+ const ShellComponent = route.isRoot
+ ? ((route.options as RootRouteOptions).shellComponent ?? SafeFragment)
+ : SafeFragment
+
+ return (
+
+
+
+ matchState.resetKey}
+ errorComponent={routeErrorComponent || ErrorComponent}
+ onCatch={(error, errorInfo) => {
+ if (isNotFound(error)) throw error
+ warning(false, `Error in route match: ${matchId}`)
+ routeOnCatch?.(error, errorInfo)
+ }}
+ >
+ {
+ if (
+ !routeNotFoundComponent ||
+ (error.routeId && error.routeId !== matchState.routeId) ||
+ (!error.routeId && !route.isRoot)
+ )
+ throw error
+
+ return h(routeNotFoundComponent, error as any) as any
+ }}
+ >
+ {resolvedNoSsr || matchState._displayPending ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {matchState.parentRouteId === rootRouteId &&
+ router.options.scrollRestoration ? (
+ <>
+
+
+ >
+ ) : null}
+
+ )
+}
+
+function OnRendered() {
+ const router = useRouter()
+
+ const prevLocationRef = useRef>(
+ undefined,
+ )
+
+ return (
+ '),
+ },
+ } as any
+}
+
+describe('preact-router ssr', () => {
+ test('renderRouterToString returns html with doctype and buffered injections', async () => {
+ const router = createMockRouter()
+
+ const response = await renderRouterToString({
+ router,
+ responseHeaders: new Headers(),
+ children: (
+
+
+ Hello SSR
+
+
+ ),
+ })
+
+ const html = await response.text()
+
+ expect(response.status).toBe(200)
+ expect(html).toContain('')
+ expect(html).toContain('Hello SSR')
+ expect(html).toContain('')
+ expect(router.serverSsr.setRenderFinished).toHaveBeenCalledTimes(1)
+ expect(router.serverSsr.cleanup).toHaveBeenCalledTimes(1)
+ })
+
+ test('renderRouterToStream returns stream response with rendered html', async () => {
+ const router = createMockRouter()
+
+ const response = await renderRouterToStream({
+ request: new Request('http://localhost', {
+ headers: {
+ 'User-Agent': 'tanstack-test-agent',
+ },
+ }),
+ router,
+ responseHeaders: new Headers(),
+ children: (
+
+
+ Hello Stream SSR
+
+
+ ),
+ })
+
+ const html = await response.text()
+
+ expect(response.status).toBe(200)
+ expect(html).toContain('')
+ expect(html).toContain('Hello Stream SSR')
+ expect(html).toContain('')
+ expect(router.serverSsr.cleanup).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/preact-router/tests/useBlocker.test.tsx b/packages/preact-router/tests/useBlocker.test.tsx
new file mode 100644
index 00000000000..1ae23646921
--- /dev/null
+++ b/packages/preact-router/tests/useBlocker.test.tsx
@@ -0,0 +1,137 @@
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/preact'
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ useBlocker,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('useBlocker', () => {
+ test('does not block when condition is false', async () => {
+ function UnblockedComponent() {
+ useBlocker({ shouldBlockFn: () => false })
+
+ return (
+
+
Home
+ Go to About
+
+ )
+ }
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: UnblockedComponent,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => About
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('Go to About'))
+
+ expect(await screen.findByText('About')).toBeInTheDocument()
+ })
+
+ test('blocks navigation when shouldBlockFn returns true (non-resolver)', async () => {
+ function BlockedComponent() {
+ useBlocker({ shouldBlockFn: () => true })
+
+ return (
+
+
Home
+ Go to About
+
+ )
+ }
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: BlockedComponent,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => About
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('Go to About'))
+
+ // Should still be on home since navigation is blocked
+ expect(screen.getByText('Home')).toBeInTheDocument()
+ expect(screen.queryByText('About')).not.toBeInTheDocument()
+ })
+
+ test('does not block when disabled is true', async () => {
+ function BlockedComponent() {
+ useBlocker({ shouldBlockFn: () => true, disabled: true })
+
+ return (
+
+
Home
+ Go to About
+
+ )
+ }
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: BlockedComponent,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => About
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('Go to About'))
+
+ expect(await screen.findByText('About')).toBeInTheDocument()
+ })
+})
diff --git a/packages/preact-router/tests/useCanGoBack.test.tsx b/packages/preact-router/tests/useCanGoBack.test.tsx
new file mode 100644
index 00000000000..0f9b8aa59e3
--- /dev/null
+++ b/packages/preact-router/tests/useCanGoBack.test.tsx
@@ -0,0 +1,92 @@
+import { afterEach, describe, expect, test } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/preact'
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ useCanGoBack,
+ useLocation,
+ useRouter,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('useCanGoBack', () => {
+ function setup({
+ initialEntries = ['/'],
+ }: {
+ initialEntries?: Array
+ } = {}) {
+ function RootComponent() {
+ const router = useRouter()
+ const location = useLocation()
+ const canGoBack = useCanGoBack()
+
+ expect(canGoBack).toBe(location.pathname === '/' ? false : true)
+
+ return (
+ <>
+
+ Home
+ About
+
+ >
+ )
+ }
+
+ const rootRoute = createRootRoute({
+ component: RootComponent,
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => IndexTitle
,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => AboutTitle
,
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
+ history: createMemoryHistory({ initialEntries }),
+ })
+
+ return render()
+ }
+
+ test('when no location behind', async () => {
+ setup()
+
+ const indexTitle = await screen.findByText('IndexTitle')
+ expect(indexTitle).toBeInTheDocument()
+
+ const aboutLink = await screen.findByText('About')
+ fireEvent.click(aboutLink)
+
+ const aboutTitle = await screen.findByText('AboutTitle')
+ expect(aboutTitle).toBeInTheDocument()
+ })
+
+ test('when location behind', async () => {
+ setup({
+ initialEntries: ['/', '/about'],
+ })
+
+ const aboutTitle = await screen.findByText('AboutTitle')
+ expect(aboutTitle).toBeInTheDocument()
+
+ const backButton = await screen.findByText('Back')
+ fireEvent.click(backButton)
+
+ const indexTitle = await screen.findByText('IndexTitle')
+ expect(indexTitle).toBeInTheDocument()
+ })
+})
diff --git a/packages/preact-router/tests/useParams.test.tsx b/packages/preact-router/tests/useParams.test.tsx
new file mode 100644
index 00000000000..4bd8150113b
--- /dev/null
+++ b/packages/preact-router/tests/useParams.test.tsx
@@ -0,0 +1,103 @@
+import { afterEach, expect, test, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/preact'
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ useParams,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+})
+
+test('useParams returns params for the current route', async () => {
+ const rootRoute = createRootRoute()
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'posts',
+ component: () => (
+
+
Posts
+
+
+ ),
+ })
+ const postRoute = createRoute({
+ getParentRoute: () => postsRoute,
+ path: '$postId',
+ component: PostComponent,
+ })
+
+ function PostComponent() {
+ const params = useParams({ from: postRoute.fullPath })
+ return (
+
+ {params.postId}
+
+ )
+ }
+
+ const routeTree = rootRoute.addChildren([
+ postsRoute.addChildren([postRoute]),
+ ])
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/posts/123'] }),
+ })
+
+ render()
+
+ await act(() => router.load())
+
+ const postId = await screen.findByTestId('post-id')
+ expect(postId.textContent).toBe('123')
+})
+
+test('useParams with parsed params', async () => {
+ const rootRoute = createRootRoute()
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'posts',
+ component: () => (
+
+
+
+ ),
+ })
+ const postRoute = createRoute({
+ getParentRoute: () => postsRoute,
+ path: '$postId',
+ params: {
+ parse: (params) => ({
+ ...params,
+ postId: params.postId === 'one' ? '1' : params.postId,
+ }),
+ },
+ component: PostComponent,
+ })
+
+ function PostComponent() {
+ const params = useParams({ from: postRoute.fullPath })
+ return {params.postId}
+ }
+
+ const routeTree = rootRoute.addChildren([
+ postsRoute.addChildren([postRoute]),
+ ])
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/posts/one'] }),
+ })
+
+ render()
+
+ await act(() => router.load())
+
+ const postId = await screen.findByTestId('post-id')
+ expect(postId.textContent).toBe('1')
+})
diff --git a/packages/preact-router/tests/utils.ts b/packages/preact-router/tests/utils.ts
new file mode 100644
index 00000000000..cdc7ff82bd0
--- /dev/null
+++ b/packages/preact-router/tests/utils.ts
@@ -0,0 +1,56 @@
+import type { Mock } from 'vitest'
+
+export function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+export function createTimer() {
+ let time = Date.now()
+
+ return {
+ start: () => {
+ time = Date.now()
+ },
+ getTime: () => {
+ return Date.now() - time
+ },
+ }
+}
+
+export const getIntersectionObserverMock = ({
+ observe,
+ disconnect,
+}: {
+ observe: Mock
+ disconnect: Mock
+}) => {
+ return class IO implements IntersectionObserver {
+ root: Document | Element | null
+ rootMargin: string
+ thresholds: Array
+ constructor(
+ _cb: IntersectionObserverCallback,
+ options?: IntersectionObserverInit,
+ ) {
+ this.root = options?.root ?? null
+ this.rootMargin = options?.rootMargin ?? '0px'
+ this.thresholds = options?.threshold ?? ([0] as any)
+ }
+
+ takeRecords(): Array {
+ return []
+ }
+ unobserve(): void {}
+ observe(): void {
+ observe()
+ }
+ disconnect(): void {
+ disconnect()
+ }
+ }
+}
+
+export function getSearchParamsFromURI(uri: string) {
+ const [, paramString] = uri.split('?')
+ return new URLSearchParams(paramString)
+}
diff --git a/packages/preact-router/tsconfig.json b/packages/preact-router/tsconfig.json
new file mode 100644
index 00000000000..914f4e88c0a
--- /dev/null
+++ b/packages/preact-router/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact"
+ },
+ "include": ["src", "tests", "vite.config.ts", "eslint.config.ts"]
+}
diff --git a/packages/preact-router/tsconfig.legacy.json b/packages/preact-router/tsconfig.legacy.json
new file mode 100644
index 00000000000..b90fc83e04c
--- /dev/null
+++ b/packages/preact-router/tsconfig.legacy.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src"]
+}
diff --git a/packages/preact-router/vite.config.ts b/packages/preact-router/vite.config.ts
new file mode 100644
index 00000000000..467e0db13d1
--- /dev/null
+++ b/packages/preact-router/vite.config.ts
@@ -0,0 +1,37 @@
+import { defineConfig, mergeConfig } from 'vitest/config'
+import preact from '@preact/preset-vite'
+import { tanstackViteConfig } from '@tanstack/config/vite'
+import packageJson from './package.json'
+
+const config = defineConfig({
+ plugins: [preact()],
+ // Add 'development' condition for tests to resolve @tanstack/router-core/isServer
+ // to the development export (isServer = undefined) instead of node (isServer = true)
+ ...(process.env.VITEST && {
+ resolve: {
+ conditions: ['development'],
+ },
+ }),
+ test: {
+ name: packageJson.name,
+ dir: './tests',
+ watch: false,
+ environment: 'jsdom',
+ setupFiles: ['./tests/setupTests.tsx'],
+ typecheck: { enabled: true },
+ },
+})
+
+export default mergeConfig(
+ config,
+ tanstackViteConfig({
+ entry: [
+ './src/index.tsx',
+ './src/index.dev.tsx',
+ './src/ssr/client.ts',
+ './src/ssr/server.ts',
+ ],
+ srcDir: './src',
+ cjs: false,
+ }),
+)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24046b73ef3..d6cc45c5f70 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,7 @@ overrides:
'@tanstack/start-client-core': workspace:*
'@tanstack/start-server-core': workspace:*
'@tanstack/start-storage-context': workspace:*
+ '@tanstack/preact-router': workspace:*
'@tanstack/vue-router': workspace:*
'@tanstack/vue-router-devtools': workspace:*
'@tanstack/eslint-plugin-router': workspace:*
@@ -167,6 +168,34 @@ importers:
specifier: 4.0.3
version: 4.0.3(@types/node@25.0.9)(rollup@4.55.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ e2e/preact-router/basic:
+ dependencies:
+ '@tanstack/preact-router':
+ specifier: workspace:*
+ version: link:../../../packages/preact-router
+ preact:
+ specifier: ^10.25.0
+ version: 10.28.3
+ preact-render-to-string:
+ specifier: ^6.6.5
+ version: 6.6.5(preact@10.28.3)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.57.0
+ version: 1.57.0
+ '@preact/preset-vite':
+ specifier: ^2.9.4
+ version: 2.10.3(@babel/core@7.28.5)(preact@10.28.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.3.1
+ version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+
e2e/react-router/basepath-file-based:
dependencies:
'@tanstack/react-router':
@@ -6039,6 +6068,37 @@ importers:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.2)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ examples/preact/basic:
+ dependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.18
+ version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ '@tanstack/preact-router':
+ specifier: workspace:*
+ version: link:../../../packages/preact-router
+ preact:
+ specifier: ^10.25.0
+ version: 10.28.3
+ preact-render-to-string:
+ specifier: ^6.6.5
+ version: 6.6.5(preact@10.28.3)
+ redaxios:
+ specifier: ^0.5.1
+ version: 0.5.1
+ tailwindcss:
+ specifier: ^4.1.18
+ version: 4.1.18
+ devDependencies:
+ '@preact/preset-vite':
+ specifier: ^2.10.3
+ version: 2.10.3(@babel/core@7.28.5)(preact@10.28.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vite:
+ specifier: ^7.3.1
+ version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+
examples/react/authenticated-routes:
dependencies:
'@tailwindcss/vite':
@@ -11586,6 +11646,46 @@ importers:
specifier: ^7.3.1
version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ packages/preact-router:
+ dependencies:
+ '@tanstack/history':
+ specifier: workspace:*
+ version: link:../history
+ '@tanstack/preact-store':
+ specifier: ^0.10.2
+ version: 0.10.2(preact@10.28.3)
+ '@tanstack/router-core':
+ specifier: workspace:*
+ version: link:../router-core
+ isbot:
+ specifier: ^5.1.22
+ version: 5.1.28
+ preact-render-to-string:
+ specifier: ^6.6.5
+ version: 6.6.5(preact@10.28.3)
+ preact-suspense:
+ specifier: ^0.2.0
+ version: 0.2.0(preact@10.28.3)
+ tiny-invariant:
+ specifier: ^1.3.3
+ version: 1.3.3
+ tiny-warning:
+ specifier: ^1.0.3
+ version: 1.0.3
+ devDependencies:
+ '@preact/preset-vite':
+ specifier: ^2.9.4
+ version: 2.10.3(@babel/core@7.28.5)(preact@10.28.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ '@testing-library/jest-dom':
+ specifier: ^6.6.3
+ version: 6.6.3
+ '@testing-library/preact':
+ specifier: ^3.2.4
+ version: 3.2.4(preact@10.28.3)
+ preact:
+ specifier: ^10.25.0
+ version: 10.28.3
+
packages/react-router:
dependencies:
'@tanstack/history':
@@ -12684,6 +12784,10 @@ packages:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/compat-data@7.27.5':
resolution: {integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==}
engines: {node: '>=6.9.0'}
@@ -12696,6 +12800,10 @@ packages:
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==}
engines: {node: '>=6.9.0'}
+ '@babel/generator@7.29.1':
+ resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-annotate-as-pure@7.27.3':
resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
engines: {node: '>=6.9.0'}
@@ -12736,6 +12844,10 @@ packages:
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-module-transforms@7.28.3':
resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
engines: {node: '>=6.9.0'}
@@ -12750,6 +12862,10 @@ packages:
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-replace-supers@7.27.1':
resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==}
engines: {node: '>=6.9.0'}
@@ -12781,6 +12897,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/parser@7.29.0':
+ resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/plugin-proposal-decorators@7.25.9':
resolution: {integrity: sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==}
engines: {node: '>=6.9.0'}
@@ -12811,6 +12932,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-syntax-jsx@7.28.6':
+ resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-syntax-typescript@7.27.1':
resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==}
engines: {node: '>=6.9.0'}
@@ -12835,6 +12962,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-transform-react-jsx-development@7.27.1':
+ resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-transform-react-jsx-self@7.25.9':
resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==}
engines: {node: '>=6.9.0'}
@@ -12859,6 +12992,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-transform-react-jsx@7.28.6':
+ resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-transform-typescript@7.27.1':
resolution: {integrity: sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==}
engines: {node: '>=6.9.0'}
@@ -12891,10 +13030,18 @@ packages:
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+ engines: {node: '>=6.9.0'}
+
'@babel/traverse@7.28.5':
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==}
engines: {node: '>=6.9.0'}
+ '@babel/traverse@7.29.0':
+ resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
+ engines: {node: '>=6.9.0'}
+
'@babel/types@7.28.4':
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
engines: {node: '>=6.9.0'}
@@ -12903,6 +13050,10 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
+ engines: {node: '>=6.9.0'}
+
'@better-auth/core@1.3.27':
resolution: {integrity: sha512-3Sfdax6MQyronY+znx7bOsfQHI6m1SThvJWb0RDscFEAhfqLy95k1sl+/PgGyg0cwc2cUXoEiAOSqYdFYrg3vA==}
@@ -15680,6 +15831,29 @@ packages:
'@poppinss/exception@1.2.2':
resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==}
+ '@preact/preset-vite@2.10.3':
+ resolution: {integrity: sha512-1SiS+vFItpkNdBs7q585PSAIln0wBeBdcpJYbzPs1qipsb/FssnkUioNXuRsb8ZnU8YEQHr+3v8+/mzWSnTQmg==}
+ peerDependencies:
+ '@babel/core': 7.x
+ vite: ^7.3.1
+
+ '@prefresh/babel-plugin@0.5.2':
+ resolution: {integrity: sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==}
+
+ '@prefresh/core@1.5.9':
+ resolution: {integrity: sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==}
+ peerDependencies:
+ preact: ^10.0.0 || ^11.0.0-0
+
+ '@prefresh/utils@1.2.1':
+ resolution: {integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==}
+
+ '@prefresh/vite@2.4.11':
+ resolution: {integrity: sha512-/XjURQqdRiCG3NpMmWqE9kJwrg9IchIOWHzulCfqg2sRe/8oQ1g5De7xrk9lbqPIQLn7ntBkKdqWXIj4E9YXyg==}
+ peerDependencies:
+ preact: ^10.4.0 || ^11.0.0-0
+ vite: ^7.3.1
+
'@prisma/adapter-libsql@7.0.0':
resolution: {integrity: sha512-RspyVP5FgvNhsb4XdpbcUfiWWh/JXucTxzLUVKlrL/sLYQ50Mxx855cElncWjl9u1chaLC5AOLqYC4wNH8XOzA==}
@@ -16521,6 +16695,10 @@ packages:
rollup:
optional: true
+ '@rollup/pluginutils@4.2.1':
+ resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
+ engines: {node: '>= 8.0.0'}
+
'@rollup/pluginutils@5.1.4':
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
engines: {node: '>=14.0.0'}
@@ -17497,6 +17675,11 @@ packages:
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
+ '@tanstack/preact-store@0.10.2':
+ resolution: {integrity: sha512-fe2XUWlomNczbyMaOk4TtMRfIUq3Pn4S/jgGAS6jYOCMKGjHNrwhdTA4EtGeG86DMxPL7NyObOsNy/6rA4dqCw==}
+ peerDependencies:
+ preact: ^10.0.0
+
'@tanstack/publish-config@0.2.1':
resolution: {integrity: sha512-URVXmXwlZXL75AFyvyOORef1tv2f16dEaFntwLYnBHoKLQMxyWYRzQrnXooxO1xf+GidJuDSZSC6Rc9UX1aK7g==}
engines: {node: '>=18'}
@@ -17581,6 +17764,9 @@ packages:
'@tanstack/store@0.8.0':
resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==}
+ '@tanstack/store@0.8.1':
+ resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==}
+
'@tanstack/typedoc-config@0.3.0':
resolution: {integrity: sha512-g7sfxscIq0wYUGtOLegnTbiMTsNiAz6r28CDgdZqIIjI1naWZoIlABpWH2qdI3IIJUDWvhOaVwAo6sfqzm6GsQ==}
engines: {node: '>=18'}
@@ -17646,6 +17832,10 @@ packages:
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
+ '@testing-library/dom@8.20.1':
+ resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==}
+ engines: {node: '>=12'}
+
'@testing-library/dom@9.3.4':
resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==}
engines: {node: '>=14'}
@@ -17654,6 +17844,12 @@ packages:
resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+ '@testing-library/preact@3.2.4':
+ resolution: {integrity: sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==}
+ engines: {node: '>= 12'}
+ peerDependencies:
+ preact: '>=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0'
+
'@testing-library/react@16.2.0':
resolution: {integrity: sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==}
engines: {node: '>=18'}
@@ -18772,6 +18968,11 @@ packages:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
+ babel-plugin-transform-hook-names@1.0.2:
+ resolution: {integrity: sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==}
+ peerDependencies:
+ '@babel/core': ^7.12.10
+
babel-plugin-vue-jsx-hmr@1.0.0:
resolution: {integrity: sha512-XRq+XTD4bub6HkavELMhihvLX2++JkSBAxRXlqQK32b+Tb0S9PEqxrDSMpOEZ1iGyOaJZj9Y0uU/FzICdyL9MA==}
@@ -22043,6 +22244,9 @@ packages:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
+ node-html-parser@6.1.13:
+ resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
+
node-machine-id@1.1.12:
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
@@ -22520,9 +22724,22 @@ packages:
peerDependencies:
preact: '>=10'
+ preact-render-to-string@6.6.5:
+ resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==}
+ peerDependencies:
+ preact: '>=10 || >= 11.0.0-0'
+
+ preact-suspense@0.2.0:
+ resolution: {integrity: sha512-AyBb6lJa1+JbbDaGyBDxxJx+P/MG0nw3X/z2rZaF5h5qU6MX8TBGWxWOG/h50ob7fMkKX9+or2Qp8Mvlda3G9w==}
+ peerDependencies:
+ preact: ^10.0.0
+
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
+ preact@10.28.3:
+ resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==}
+
precinct@12.2.0:
resolution: {integrity: sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==}
engines: {node: '>=18'}
@@ -23371,6 +23588,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ simple-code-frame@1.3.0:
+ resolution: {integrity: sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==}
+
simple-git@3.28.0:
resolution: {integrity: sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==}
@@ -23496,6 +23716,10 @@ packages:
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
+ stack-trace@1.0.0-pre2:
+ resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==}
+ engines: {node: '>=16'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -24438,6 +24662,11 @@ packages:
'@testing-library/jest-dom':
optional: true
+ vite-prerender-plugin@0.5.12:
+ resolution: {integrity: sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==}
+ peerDependencies:
+ vite: ^7.3.1
+
vite-tsconfig-paths@5.1.4:
resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==}
peerDependencies:
@@ -25030,6 +25259,12 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
+ '@babel/code-frame@7.29.0':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
'@babel/compat-data@7.27.5': {}
'@babel/core@7.28.5':
@@ -25060,6 +25295,14 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
+ '@babel/generator@7.29.1':
+ dependencies:
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.28.5
@@ -25125,6 +25368,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -25140,6 +25390,8 @@ snapshots:
'@babel/helper-plugin-utils@7.27.1': {}
+ '@babel/helper-plugin-utils@7.28.6': {}
+
'@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -25171,6 +25423,10 @@ snapshots:
dependencies:
'@babel/types': 7.28.5
+ '@babel/parser@7.29.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
'@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -25204,6 +25460,11 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.27.1
+ '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-plugin-utils': 7.28.6
+
'@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -25233,6 +25494,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.28.5)
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -25253,6 +25521,17 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.27.1
+ '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-annotate-as-pure': 7.27.3
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5)
+ '@babel/types': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/plugin-transform-typescript@7.27.1(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -25307,6 +25586,12 @@ snapshots:
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
+ '@babel/template@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
+
'@babel/traverse@7.28.5':
dependencies:
'@babel/code-frame': 7.27.1
@@ -25319,6 +25604,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/traverse@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.29.0
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/types@7.28.4':
dependencies:
'@babel/helper-string-parser': 7.27.1
@@ -25329,6 +25626,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@babel/types@7.29.0':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
'@better-auth/core@1.3.27':
dependencies:
better-call: 1.0.19
@@ -28079,6 +28381,43 @@ snapshots:
'@poppinss/exception@1.2.2': {}
+ '@preact/preset-vite@2.10.3(@babel/core@7.28.5)(preact@10.28.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.28.5)
+ '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.5)
+ '@prefresh/vite': 2.4.11(preact@10.28.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ '@rollup/pluginutils': 5.1.4(rollup@4.55.3)
+ babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.5)
+ debug: 4.4.3
+ picocolors: 1.1.1
+ vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ vite-prerender-plugin: 0.5.12(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ transitivePeerDependencies:
+ - preact
+ - rollup
+ - supports-color
+
+ '@prefresh/babel-plugin@0.5.2': {}
+
+ '@prefresh/core@1.5.9(preact@10.28.3)':
+ dependencies:
+ preact: 10.28.3
+
+ '@prefresh/utils@1.2.1': {}
+
+ '@prefresh/vite@2.4.11(preact@10.28.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@prefresh/babel-plugin': 0.5.2
+ '@prefresh/core': 1.5.9(preact@10.28.3)
+ '@prefresh/utils': 1.2.1
+ '@rollup/pluginutils': 4.2.1
+ preact: 10.28.3
+ vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ transitivePeerDependencies:
+ - supports-color
+
'@prisma/adapter-libsql@7.0.0':
dependencies:
'@libsql/client': 0.8.1
@@ -29001,6 +29340,11 @@ snapshots:
optionalDependencies:
rollup: 4.55.3
+ '@rollup/pluginutils@4.2.1':
+ dependencies:
+ estree-walker: 2.0.2
+ picomatch: 2.3.1
+
'@rollup/pluginutils@5.1.4(rollup@4.55.3)':
dependencies:
'@types/estree': 1.0.8
@@ -29996,6 +30340,11 @@ snapshots:
dependencies:
remove-accents: 0.5.0
+ '@tanstack/preact-store@0.10.2(preact@10.28.3)':
+ dependencies:
+ '@tanstack/store': 0.8.1
+ preact: 10.28.3
+
'@tanstack/publish-config@0.2.1':
dependencies:
'@commitlint/parse': 19.8.1
@@ -30088,6 +30437,8 @@ snapshots:
'@tanstack/store@0.8.0': {}
+ '@tanstack/store@0.8.1': {}
+
'@tanstack/typedoc-config@0.3.0(typescript@5.9.3)':
dependencies:
typedoc: 0.28.14(typescript@5.9.3)
@@ -30193,6 +30544,17 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
+ '@testing-library/dom@8.20.1':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/runtime': 7.26.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.1.3
+ chalk: 4.1.2
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ pretty-format: 27.5.1
+
'@testing-library/dom@9.3.4':
dependencies:
'@babel/code-frame': 7.27.1
@@ -30214,6 +30576,11 @@ snapshots:
lodash: 4.17.21
redent: 3.0.0
+ '@testing-library/preact@3.2.4(preact@10.28.3)':
+ dependencies:
+ '@testing-library/dom': 8.20.1
+ preact: 10.28.3
+
'@testing-library/react@16.2.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.26.7
@@ -31851,6 +32218,10 @@ snapshots:
cosmiconfig: 7.1.0
resolve: 1.22.10
+ babel-plugin-transform-hook-names@1.0.2(@babel/core@7.28.5):
+ dependencies:
+ '@babel/core': 7.28.5
+
babel-plugin-vue-jsx-hmr@1.0.0:
dependencies:
'@babel/core': 7.28.5
@@ -35085,8 +35456,8 @@ snapshots:
magicast@0.3.5:
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
source-map-js: 1.2.1
optional: true
@@ -35669,6 +36040,11 @@ snapshots:
node-gyp-build@4.8.4: {}
+ node-html-parser@6.1.13:
+ dependencies:
+ css-select: 5.1.0
+ he: 1.2.0
+
node-machine-id@1.1.12: {}
node-mock-http@1.0.4: {}
@@ -36209,8 +36585,18 @@ snapshots:
dependencies:
preact: 10.24.3
+ preact-render-to-string@6.6.5(preact@10.28.3):
+ dependencies:
+ preact: 10.28.3
+
+ preact-suspense@0.2.0(preact@10.28.3):
+ dependencies:
+ preact: 10.28.3
+
preact@10.24.3: {}
+ preact@10.28.3: {}
+
precinct@12.2.0:
dependencies:
'@dependents/detective-less': 5.0.1
@@ -37250,6 +37636,10 @@ snapshots:
signal-exit@4.1.0: {}
+ simple-code-frame@1.3.0:
+ dependencies:
+ kolorist: 1.8.0
+
simple-git@3.28.0:
dependencies:
'@kwsites/file-exists': 1.1.1
@@ -37386,6 +37776,8 @@ snapshots:
stack-trace@0.0.10: {}
+ stack-trace@1.0.0-pre2: {}
+
stackback@0.0.2: {}
stackframe@1.3.4: {}
@@ -38269,6 +38661,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ vite-prerender-plugin@0.5.12(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)):
+ dependencies:
+ kolorist: 1.8.0
+ magic-string: 0.30.21
+ node-html-parser: 6.1.13
+ simple-code-frame: 1.3.0
+ source-map: 0.7.6
+ stack-trace: 1.0.0-pre2
+ vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+
vite-tsconfig-paths@5.1.4(typescript@5.8.2)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)):
dependencies:
debug: 4.4.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index da7d07317b4..d4c5075c129 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,6 +4,7 @@ preferWorkspacePackages: true
packages:
- 'packages/*'
+ - 'examples/preact/*'
- 'examples/react/*'
- 'examples/solid/*'
- 'examples/vue/*'
@@ -12,6 +13,7 @@ packages:
- 'examples/react/router-monorepo-simple-lazy/packages/*'
- 'e2e/e2e-utils'
- 'e2e/react-router/*'
+ - 'e2e/preact-router/*'
- 'e2e/solid-router/*'
- 'e2e/vue-router/*'
- 'e2e/react-start/*'