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 ( +
+
    + {[...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( + (post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + }, + )} +
+ +
+ ) +} 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 ( +