diff --git a/.changeset/plain-tools-work.md b/.changeset/plain-tools-work.md new file mode 100644 index 0000000000..e9b5cbf3ac --- /dev/null +++ b/.changeset/plain-tools-work.md @@ -0,0 +1,12 @@ +--- +'@tanstack/octane-router': patch +'@tanstack/octane-start': patch +'@tanstack/octane-start-client': patch +'@tanstack/octane-start-server': patch +'@tanstack/router-cli': patch +'@tanstack/router-generator': patch +'@tanstack/router-plugin': patch +'@tanstack/start-plugin-core': patch +--- + +Add first-class Octane Router and TanStack Start support, including `.tsrx` file routes, typed router APIs, code splitting, native Octane streaming injection, deferred hydration, `ClientOnly` server isolation, and Vite integration. diff --git a/e2e/octane-start/basic/netlify.toml b/e2e/octane-start/basic/netlify.toml new file mode 100644 index 0000000000..c1e3d8492a --- /dev/null +++ b/e2e/octane-start/basic/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "pnpm run build:netlify" + publish = "dist/client" diff --git a/e2e/octane-start/basic/package.json b/e2e/octane-start/basic/package.json new file mode 100644 index 0000000000..3324c3d44d --- /dev/null +++ b/e2e/octane-start/basic/package.json @@ -0,0 +1,52 @@ +{ + "name": "tanstack-octane-start-e2e-basic", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "build:cloudflare": "vite build --config vite.config.cloudflare.ts", + "build:netlify": "vite build --config vite.config.netlify.ts", + "start": "node .output/server/index.mjs", + "test:platform-builds": "pnpm run build:cloudflare && pnpm run build:netlify", + "test:e2e:local": "pnpm build && playwright test --project=chromium", + "test:e2e:hmr:local": "E2E_DEV=true playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/octane-router": "workspace:^", + "@tanstack/octane-start": "workspace:^", + "octane": "catalog:" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.29.0", + "@netlify/vite-plugin-tanstack-start": "^1.1.4", + "@playwright/test": "^1.61.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@typescript/native": "npm:typescript@^7.0.2", + "nitro": "^3.0.260311-beta", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vite": "8.1.5", + "wrangler": "^4.74.0" + }, + "nx": { + "metadata": { + "playwrightModes": [ + { + "toolchain": "vite", + "mode": "ssr" + }, + { + "toolchain": "vite", + "mode": "ssr", + "name": "hmr", + "env": { + "E2E_DEV": "true" + } + } + ] + } + } +} diff --git a/e2e/octane-start/basic/playwright.config.ts b/e2e/octane-start/basic/playwright.config.ts new file mode 100644 index 0000000000..535cc3c910 --- /dev/null +++ b/e2e/octane-start/basic/playwright.config.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs' +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name +const distDir = process.env.E2E_DIST_DIR ?? '.output' +const isDev = process.env.E2E_DEV === 'true' + +if (process.env.TEST_WORKER_INDEX === undefined) { + fs.rmSync(`port-${e2ePortKey}.txt`, { force: true }) +} + +const port = await getTestServerPort(e2ePortKey) +const baseURL = `http://127.0.0.1:${port}` + +export default defineConfig({ + testDir: './tests', + testMatch: isDev ? 'hmr.spec.ts' : 'basic.spec.ts', + workers: 1, + reporter: [['line']], + use: { baseURL }, + webServer: { + command: isDev + ? `pnpm dev:e2e --host 127.0.0.1 --port ${port} --strictPort` + : `PORT=${port} node ${distDir}/server/index.mjs`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: isDev + ? { E2E_DEV: 'true' } + : { + NODE_ENV: 'production', + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/octane-start/basic/src/client-only-proof.client.tsrx b/e2e/octane-start/basic/src/client-only-proof.client.tsrx new file mode 100644 index 0000000000..87da63c787 --- /dev/null +++ b/e2e/octane-start/basic/src/client-only-proof.client.tsrx @@ -0,0 +1,3 @@ +export function ClientOnlyProof() @{ +

{'client-only module rendered'}

+} diff --git a/e2e/octane-start/basic/src/deferred-hydration-proof.css b/e2e/octane-start/basic/src/deferred-hydration-proof.css new file mode 100644 index 0000000000..81194cac33 --- /dev/null +++ b/e2e/octane-start/basic/src/deferred-hydration-proof.css @@ -0,0 +1,3 @@ +.octane-start-deferred-hydration-proof { + color: rgb(12, 34, 56); +} diff --git a/e2e/octane-start/basic/src/deferred-hydration-proof.tsrx b/e2e/octane-start/basic/src/deferred-hydration-proof.tsrx new file mode 100644 index 0000000000..0f72915dbb --- /dev/null +++ b/e2e/octane-start/basic/src/deferred-hydration-proof.tsrx @@ -0,0 +1,24 @@ +import { useState } from 'octane'; +import './deferred-hydration-proof.css'; + +const browserProof = globalThis as typeof globalThis & { + __octaneStartDeferredHydrationModuleLoaded?: boolean; +}; + +if (typeof document !== 'undefined') { + browserProof.__octaneStartDeferredHydrationModuleLoaded = true; +} + +export function DeferredHydrationProof() @{ + const [clicks, setClicks] = useState(0); + + +} diff --git a/e2e/octane-start/basic/src/routeTree.gen.ts b/e2e/octane-start/basic/src/routeTree.gen.ts new file mode 100644 index 0000000000..b3f86c5f7c --- /dev/null +++ b/e2e/octane-start/basic/src/routeTree.gen.ts @@ -0,0 +1,267 @@ +/* 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.tsrx' +import { Route as IndexRouteImport } from './routes/index.tsrx' +import { Route as AboutRouteImport } from './routes/about.tsrx' +import { Route as DeferredRouteImport } from './routes/deferred.tsrx' +import { Route as DeferredErrorRouteImport } from './routes/deferred-error.tsrx' +import { Route as HydrateRouteImport } from './routes/hydrate.tsrx' +import { Route as PostsRouteImport } from './routes/posts.tsrx' +import { Route as RedirectRouteImport } from './routes/redirect.tsrx' +import { Route as ServerFunctionRouteImport } from './routes/server-function.tsrx' +import { Route as PostsIndexRouteImport } from './routes/posts.index.tsrx' +import { Route as PostsPostIdRouteImport } from './routes/posts.$postId.tsrx' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRoute = DeferredRouteImport.update({ + id: '/deferred', + path: '/deferred', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredErrorRoute = DeferredErrorRouteImport.update({ + id: '/deferred-error', + path: '/deferred-error', + getParentRoute: () => rootRouteImport, +} as any) +const HydrateRoute = HydrateRouteImport.update({ + id: '/hydrate', + path: '/hydrate', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const RedirectRoute = RedirectRouteImport.update({ + id: '/redirect', + path: '/redirect', + getParentRoute: () => rootRouteImport, +} as any) +const ServerFunctionRoute = ServerFunctionRouteImport.update({ + id: '/server-function', + path: '/server-function', + getParentRoute: () => rootRouteImport, +} as any) +const PostsIndexRoute = PostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PostsRoute, +} as any) +const PostsPostIdRoute = PostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => PostsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/deferred': typeof DeferredRoute + '/deferred-error': typeof DeferredErrorRoute + '/hydrate': typeof HydrateRoute + '/posts': typeof PostsRouteWithChildren + '/redirect': typeof RedirectRoute + '/server-function': typeof ServerFunctionRoute + '/posts/$postId': typeof PostsPostIdRoute + '/posts/': typeof PostsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/deferred': typeof DeferredRoute + '/deferred-error': typeof DeferredErrorRoute + '/hydrate': typeof HydrateRoute + '/redirect': typeof RedirectRoute + '/server-function': typeof ServerFunctionRoute + '/posts/$postId': typeof PostsPostIdRoute + '/posts': typeof PostsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/deferred': typeof DeferredRoute + '/deferred-error': typeof DeferredErrorRoute + '/hydrate': typeof HydrateRoute + '/posts': typeof PostsRouteWithChildren + '/redirect': typeof RedirectRoute + '/server-function': typeof ServerFunctionRoute + '/posts/$postId': typeof PostsPostIdRoute + '/posts/': typeof PostsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/about' + | '/deferred' + | '/deferred-error' + | '/hydrate' + | '/posts' + | '/redirect' + | '/server-function' + | '/posts/$postId' + | '/posts/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/about' + | '/deferred' + | '/deferred-error' + | '/hydrate' + | '/redirect' + | '/server-function' + | '/posts/$postId' + | '/posts' + id: + | '__root__' + | '/' + | '/about' + | '/deferred' + | '/deferred-error' + | '/hydrate' + | '/posts' + | '/redirect' + | '/server-function' + | '/posts/$postId' + | '/posts/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute + DeferredRoute: typeof DeferredRoute + DeferredErrorRoute: typeof DeferredErrorRoute + HydrateRoute: typeof HydrateRoute + PostsRoute: typeof PostsRouteWithChildren + RedirectRoute: typeof RedirectRoute + ServerFunctionRoute: typeof ServerFunctionRoute +} + +declare module '@tanstack/octane-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred': { + id: '/deferred' + path: '/deferred' + fullPath: '/deferred' + preLoaderRoute: typeof DeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred-error': { + id: '/deferred-error' + path: '/deferred-error' + fullPath: '/deferred-error' + preLoaderRoute: typeof DeferredErrorRouteImport + parentRoute: typeof rootRouteImport + } + '/hydrate': { + id: '/hydrate' + path: '/hydrate' + fullPath: '/hydrate' + preLoaderRoute: typeof HydrateRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/redirect': { + id: '/redirect' + path: '/redirect' + fullPath: '/redirect' + preLoaderRoute: typeof RedirectRouteImport + parentRoute: typeof rootRouteImport + } + '/server-function': { + id: '/server-function' + path: '/server-function' + fullPath: '/server-function' + preLoaderRoute: typeof ServerFunctionRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/': { + id: '/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof PostsIndexRouteImport + parentRoute: typeof PostsRoute + } + '/posts/$postId': { + id: '/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof PostsPostIdRouteImport + parentRoute: typeof PostsRoute + } + } +} + +interface PostsRouteChildren { + PostsPostIdRoute: typeof PostsPostIdRoute + PostsIndexRoute: typeof PostsIndexRoute +} + +const PostsRouteChildren: PostsRouteChildren = { + PostsPostIdRoute: PostsPostIdRoute, + PostsIndexRoute: PostsIndexRoute, +} + +const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, + DeferredRoute: DeferredRoute, + DeferredErrorRoute: DeferredErrorRoute, + HydrateRoute: HydrateRoute, + PostsRoute: PostsRouteWithChildren, + RedirectRoute: RedirectRoute, + ServerFunctionRoute: ServerFunctionRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.ts' +import type { createStart } from '@tanstack/octane-start' +declare module '@tanstack/octane-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/octane-start/basic/src/router.ts b/e2e/octane-start/basic/src/router.ts new file mode 100644 index 0000000000..c47ed5df50 --- /dev/null +++ b/e2e/octane-start/basic/src/router.ts @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/octane-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/octane-start/basic/src/routes/__root.tsrx b/e2e/octane-start/basic/src/routes/__root.tsrx new file mode 100644 index 0000000000..af5baf6afe --- /dev/null +++ b/e2e/octane-start/basic/src/routes/__root.tsrx @@ -0,0 +1,63 @@ +import { + Body, + Head, + HeadContent, + Html, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/octane-router'; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Octane Start E2E' }, + ], + }), + shellComponent: RootDocument, + component: RootLayout, + errorComponent: RouteError, + notFoundComponent: NotFound, +}); + +function RootDocument(props: { children?: unknown }) @{ + + + + + + {props.children} + + + +} + +function RootLayout() @{ + <> + +
+ +
+ +} + +function RouteError(props) @{ +
+

{'Route error'}

+

{props.error instanceof Error ? props.error.message : String(props.error)}

+
+} + +function NotFound() @{ +
+

{'Not found'}

+
+} diff --git a/e2e/octane-start/basic/src/routes/about.tsrx b/e2e/octane-start/basic/src/routes/about.tsrx new file mode 100644 index 0000000000..74ae8da864 --- /dev/null +++ b/e2e/octane-start/basic/src/routes/about.tsrx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/octane-router'; + +export const Route = createFileRoute('/about')({ + component: About, +}); + +function About() @{ +
+

{'Client navigation works'}

+
+} diff --git a/e2e/octane-start/basic/src/routes/deferred-error.tsrx b/e2e/octane-start/basic/src/routes/deferred-error.tsrx new file mode 100644 index 0000000000..ad201e1605 --- /dev/null +++ b/e2e/octane-start/basic/src/routes/deferred-error.tsrx @@ -0,0 +1,21 @@ +import { Await, createFileRoute } from '@tanstack/octane-router'; + +export const Route = createFileRoute('/deferred-error')({ + loader: () => ({ + value: new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('deferred boom')), 50); + }), + }), + component: DeferredErrorPage, +}); + +function DeferredErrorPage() @{ + const data = Route.useLoaderData(); + +
+

{'Deferred rejection'}

+ {'Pending rejection'}

}> + {(value) =>

{String(value)}

} +
+
+} diff --git a/e2e/octane-start/basic/src/routes/deferred.tsrx b/e2e/octane-start/basic/src/routes/deferred.tsrx new file mode 100644 index 0000000000..e92891c09e --- /dev/null +++ b/e2e/octane-start/basic/src/routes/deferred.tsrx @@ -0,0 +1,53 @@ +import { Await, createFileRoute } from '@tanstack/octane-router'; +import { createServerFn } from '@tanstack/octane-start'; + +const immediatePerson = createServerFn({ method: 'GET' }) + .validator((name: string) => name) + .handler(async ({ data: name }: { data: string }) => ({ + name, + kind: 'immediate', + })); + +const deferredPerson = createServerFn({ method: 'GET' }) + .validator((name: string) => name) + .handler(async ({ data: name }: { data: string }) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { name, kind: 'deferred' }; + }); + +export const Route = createFileRoute('/deferred')({ + loader: async () => { + return { + person: await immediatePerson({ data: 'Octane' }), + deferredPerson: deferredPerson({ data: 'TanStack' }), + value: new Promise((resolve) => { + setTimeout(() => { + resolve({ + date: new Date('2026-07-16T22:00:00.000Z'), + map: new Map([['state', 'streamed']]), + }); + }, 750); + }), + }; + }, + component: DeferredPage, +}); + +function DeferredPage() @{ + const data = Route.useLoaderData(); + +
+

{'Deferred loader data'}

+

{data.person.name + ' ' + data.person.kind}

+ {'Loading person'}

}> + {(person) =>

+ {person.name + ' ' + person.kind} +

} +
+ {'Pending'}

}> + {(value) =>

+ {value.date.toISOString() + ' ' + value.map.get('state')} +

} +
+
+} diff --git a/e2e/octane-start/basic/src/routes/hydrate.tsrx b/e2e/octane-start/basic/src/routes/hydrate.tsrx new file mode 100644 index 0000000000..76d74d64dd --- /dev/null +++ b/e2e/octane-start/basic/src/routes/hydrate.tsrx @@ -0,0 +1,30 @@ +import { useEffect } from 'octane'; +import { Hydrate } from '@tanstack/octane-start'; +import { interaction } from '@tanstack/octane-start/hydration'; +import { ClientOnly, createFileRoute } from '@tanstack/octane-router'; +import { ClientOnlyProof } from '../client-only-proof.client.tsrx'; +import { DeferredHydrationProof } from '../deferred-hydration-proof.tsrx'; + +export const Route = createFileRoute('/hydrate')({ + component: HydrateRoute, +}); + +function HydrateRoute() @{ + useEffect(() => { + const browserProof = globalThis as typeof globalThis & { + __octaneStartHydrateRouteReady?: boolean; + }; + browserProof.__octaneStartHydrateRouteReady = true; + }, []); + +
+

{'Deferred hydration'}

+

{'eager route content'}

+ + + + {'server fallback'}

}> + +
+
+} diff --git a/e2e/octane-start/basic/src/routes/index.tsrx b/e2e/octane-start/basic/src/routes/index.tsrx new file mode 100644 index 0000000000..a51791b182 --- /dev/null +++ b/e2e/octane-start/basic/src/routes/index.tsrx @@ -0,0 +1,41 @@ +import { useState } from 'octane'; +import { createFileRoute } from '@tanstack/octane-router'; + +const loaderRuns = new Map(); +const loaderEnvironment = typeof document === 'undefined' ? 'server' : 'client'; + +function getHeading() { + return 'Octane + TanStack Start'; +} + +export const Route = createFileRoute('/')({ + loader: ({ location }) => { + const runs = (loaderRuns.get(location.href) ?? 0) + 1; + loaderRuns.set(location.href, runs); + + return { + date: new Date('2026-07-16T12:34:56.000Z'), + map: new Map([ + ['framework', 'octane'], + ['pipeline', 'start'], + ]), + run: `${loaderEnvironment} ${runs}`, + }; + }, + component: Home, +}); + +function Home() @{ + const data = Route.useLoaderData(); + const [count, setCount] = useState(0); + +
+

{getHeading()}

+

{'loader ran on ' + data.run}

+

{data.date.toISOString()}

+

{data.map.get('framework') + ' ' + data.map.get('pipeline')}

+ +
+} diff --git a/e2e/octane-start/basic/src/routes/posts.$postId.tsrx b/e2e/octane-start/basic/src/routes/posts.$postId.tsrx new file mode 100644 index 0000000000..31dbc8fbd9 --- /dev/null +++ b/e2e/octane-start/basic/src/routes/posts.$postId.tsrx @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/octane-router'; +import { fetchPost } from '../utils/posts.ts'; + +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params: { postId } }) => fetchPost({ data: postId }), + component: Post, + notFoundComponent: PostNotFound, +}); + +function Post() @{ + const post = Route.useLoaderData(); + +
+

{post.title}

+

{post.body}

+
+} + +function PostNotFound() @{ +
+

{'Post not found'}

+
+} diff --git a/e2e/octane-start/basic/src/routes/posts.index.tsrx b/e2e/octane-start/basic/src/routes/posts.index.tsrx new file mode 100644 index 0000000000..81e412cc81 --- /dev/null +++ b/e2e/octane-start/basic/src/routes/posts.index.tsrx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/octane-router'; + +export const Route = createFileRoute('/posts/')({ + component: PostsIndex, +}); + +function PostsIndex() @{ +

{'Select a post'}

+} diff --git a/e2e/octane-start/basic/src/routes/posts.tsrx b/e2e/octane-start/basic/src/routes/posts.tsrx new file mode 100644 index 0000000000..07830f73fc --- /dev/null +++ b/e2e/octane-start/basic/src/routes/posts.tsrx @@ -0,0 +1,39 @@ +import { useState } from 'octane'; +import { Link, Outlet, createFileRoute } from '@tanstack/octane-router'; +import { fetchPosts } from '../utils/posts.ts'; + +export const Route = createFileRoute('/posts')({ + loader: async () => fetchPosts(), + component: PostsLayout, +}); + +function PostsLayout() @{ + const posts = Route.useLoaderData(); + const [count, setCount] = useState(0); + +
+

{'Posts'}

+ +
    + @for (const post of posts; key post.id) { +
  • + + {post.title} + +
  • + } +
  • + + {'Missing post'} + +
  • +
+ +
+} diff --git a/e2e/octane-start/basic/src/routes/redirect.tsrx b/e2e/octane-start/basic/src/routes/redirect.tsrx new file mode 100644 index 0000000000..62384a7531 --- /dev/null +++ b/e2e/octane-start/basic/src/routes/redirect.tsrx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from '@tanstack/octane-router'; + +export const Route = createFileRoute('/redirect')({ + beforeLoad: () => { + throw redirect({ to: '/about' }); + }, +}); diff --git a/e2e/octane-start/basic/src/routes/server-function.tsrx b/e2e/octane-start/basic/src/routes/server-function.tsrx new file mode 100644 index 0000000000..eeff50cde1 --- /dev/null +++ b/e2e/octane-start/basic/src/routes/server-function.tsrx @@ -0,0 +1,39 @@ +import { useState } from 'octane'; +import { createFileRoute } from '@tanstack/octane-router'; +import { createServerFn } from '@tanstack/octane-start'; + +const roundTripServerValue = createServerFn({ method: 'POST' }) + .validator((data: { timestamp: Date; labels: Map }) => data) + .handler(async ({ data }) => ({ + timestamp: new Date(data.timestamp.getTime() + 1000), + labels: new Map([...data.labels, ['runtime', 'server']]), + })); + +export const Route = createFileRoute('/server-function')({ + component: ServerFunctionPage, +}); + +function ServerFunctionPage() @{ + const [result, setResult] = useState('idle'); + + const callServer = async () => { + const value = await roundTripServerValue({ + data: { + timestamp: new Date('2026-07-17T12:00:00.000Z'), + labels: new Map([['framework', 'octane']]), + }, + }); + + setResult( + `${value.timestamp.toISOString()} ${value.labels.get('framework')} ${value.labels.get('runtime')}`, + ); + }; + +
+

{'Server function'}

+ +

{result}

+
+} diff --git a/e2e/octane-start/basic/src/styles.d.ts b/e2e/octane-start/basic/src/styles.d.ts new file mode 100644 index 0000000000..5894ae0b6e --- /dev/null +++ b/e2e/octane-start/basic/src/styles.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/e2e/octane-start/basic/src/tsrx.d.ts b/e2e/octane-start/basic/src/tsrx.d.ts new file mode 100644 index 0000000000..c95fcf1499 --- /dev/null +++ b/e2e/octane-start/basic/src/tsrx.d.ts @@ -0,0 +1 @@ +declare module '*.tsrx' diff --git a/e2e/octane-start/basic/src/utils/posts.ts b/e2e/octane-start/basic/src/utils/posts.ts new file mode 100644 index 0000000000..d78a7d381e --- /dev/null +++ b/e2e/octane-start/basic/src/utils/posts.ts @@ -0,0 +1,36 @@ +import { notFound } from '@tanstack/octane-router' +import { createServerFn } from '@tanstack/octane-start' + +export type Post = { + id: string + title: string + body: string +} + +const posts: Array = [ + { + id: '1', + title: 'First post', + body: 'Body of the first post.', + }, + { + id: '2', + title: 'Second post', + body: 'Body of the second post.', + }, +] + +export const fetchPosts = createServerFn({ method: 'GET' }).handler( + async () => posts, +) + +export const fetchPost = createServerFn({ method: 'GET' }) + .validator((postId: string) => postId) + .handler(async ({ data: postId }: { data: string }) => { + const post = posts.find((candidate) => candidate.id === postId) + if (!post) { + throw notFound() + } + + return post + }) diff --git a/e2e/octane-start/basic/tests/basic.spec.ts b/e2e/octane-start/basic/tests/basic.spec.ts new file mode 100644 index 0000000000..fb6851669d --- /dev/null +++ b/e2e/octane-start/basic/tests/basic.spec.ts @@ -0,0 +1,369 @@ +import fs from 'node:fs' +import path from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +const fixtureRoot = path.resolve(import.meta.dirname, '..') + +test.use({ + whitelistErrors: [ + 'Failed to load resource: the server responded with a status of 404', + ], +}) + +function getOutputDir() { + return path.resolve(fixtureRoot, process.env.E2E_DIST_DIR ?? '.output') +} + +function findBuiltAsset(extension: string, sourceNeedle: string) { + const assetsDir = path.join(getOutputDir(), 'public', 'assets') + const matches = fs.readdirSync(assetsDir).filter((fileName) => { + return ( + fileName.endsWith(extension) && + fs + .readFileSync(path.join(assetsDir, fileName), 'utf8') + .includes(sourceNeedle) + ) + }) + + expect(matches).toHaveLength(1) + return matches[0]! +} + +function readStartManifest() { + const serverDir = path.join(getOutputDir(), 'server') + const matches = fs + .readdirSync(serverDir) + .filter((fileName) => fileName.startsWith('_tanstack-start-manifest_')) + + expect(matches).toHaveLength(1) + return fs.readFileSync(path.join(serverDir, matches[0]!), 'utf8') +} + +test('renders a full Start document and hydrates Octane', async ({ + page, + request, +}) => { + const hydrationErrors: Array = [] + page.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + hydrationErrors.push(message.text()) + } + }) + page.on('pageerror', (error) => hydrationErrors.push(error.message)) + + const response = await request.get('/?probe=document') + expect(response.status()).toBe(200) + const html = await response.text() + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('id="__app"') + expect(html).toContain('Octane + TanStack Start') + expect(html).toContain('loader ran on server') + + await page.goto('/?probe=hydrate') + await expect(page).toHaveTitle('Octane Start E2E') + await expect(page.locator('#__app')).toHaveCount(1) + await expect(page.locator('#__app nav')).toHaveCount(1) + await expect(page.locator('#__app main')).toHaveCount(1) + await expect(page.getByTestId('loader-run')).toHaveText( + 'loader ran on server 1', + ) + await expect(page.getByTestId('date-value')).toHaveText( + '2026-07-16T12:34:56.000Z', + ) + await expect(page.getByTestId('map-value')).toHaveText('octane start') + + await page.getByTestId('counter').click() + await expect(page.getByTestId('counter')).toHaveText('count 1') + expect(hydrationErrors).toEqual([]) +}) + +test('navigates without replacing the document', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'About' }).click() + await expect(page.getByTestId('about-heading')).toBeVisible() + await expect + .poll(() => + page.evaluate(() => performance.getEntriesByType('navigation').length), + ) + .toBe(1) +}) + +test('streams deferred values through the Start serializer', async ({ + page, +}) => { + await page.goto('/deferred') + await expect(page.getByTestId('deferred-value')).toHaveCount(1) + await expect(page.getByTestId('deferred-value')).toHaveText( + '2026-07-16T22:00:00.000Z streamed', + ) +}) + +test('loads GET server functions from loaders, including deferred results', async ({ + page, +}) => { + const serverFunctionRequests: Array = [] + page.on('request', (request) => { + if (request.url().includes('/_serverFn/')) { + serverFunctionRequests.push(request.method()) + } + }) + + await page.goto('/') + await page.getByRole('link', { name: 'Deferred' }).click() + + await expect(page.getByTestId('regular-person')).toHaveText( + 'Octane immediate', + ) + await expect(page.getByTestId('deferred-person')).toHaveText( + 'TanStack deferred', + ) + expect(serverFunctionRequests.length).toBeGreaterThanOrEqual(2) + expect(serverFunctionRequests.every((method) => method === 'GET')).toBe(true) +}) + +test('preserves parent route state while navigating between child routes', async ({ + page, +}) => { + await page.goto('/posts') + await expect(page.getByTestId('posts-index')).toBeVisible() + + await page.getByTestId('posts-parent-counter').click() + await expect(page.getByTestId('posts-parent-counter')).toHaveText( + 'Parent count 1', + ) + + await page.getByRole('link', { name: 'Second post' }).click() + await expect(page.getByTestId('post-detail')).toContainText( + 'Body of the second post.', + ) + await expect(page.getByTestId('posts-parent-counter')).toHaveText( + 'Parent count 1', + ) +}) + +test('routes notFound thrown by a loader server function to its boundary', async ({ + page, +}) => { + const response = await page.goto('/posts/missing') + + expect(response?.status()).toBe(404) + await expect(page.getByTestId('post-not-found')).toHaveText('Post not found') +}) + +test('round-trips typed values through a server function', async ({ page }) => { + await page.goto('/server-function') + + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/_serverFn/') && + response.request().method() === 'POST', + ) + await page.getByTestId('call-server-function').click() + + const response = await responsePromise + expect(response.status()).toBe(200) + await expect(page.getByTestId('server-function-result')).toHaveText( + '2026-07-17T12:00:01.000Z octane server', + ) +}) + +test('flushes the deferred fallback before the value resolves', async ({ + baseURL, +}) => { + const response = await fetch(`${baseURL}/deferred?probe=stream`, { + headers: { + 'user-agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/127.0.0.0 Safari/537.36', + }, + }) + expect(response.status).toBe(200) + expect(response.body).not.toBeNull() + + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let shell = '' + + const shellResult = await Promise.race([ + (async () => { + while (!shell.includes('deferred-pending')) { + const result = await reader.read() + if (result.done) { + throw new Error('The response ended before the deferred fallback') + } + shell += decoder.decode(result.value, { stream: true }) + } + return shell + })(), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('The deferred fallback was not flushed promptly')) + }, 500) + }), + ]) + + expect(shellResult).not.toContain('2026-07-16T22:00:00.000Z streamed') + + let tail = '' + while (true) { + const result = await reader.read() + if (result.done) { + tail += decoder.decode() + break + } + tail += decoder.decode(result.value, { stream: true }) + } + + expect(shellResult + tail).toContain('2026-07-16T22:00:00.000Z streamed') +}) + +test('routes deferred rejections through the route error boundary', async ({ + page, +}) => { + await page.goto('/deferred-error') + await expect(page.getByTestId('route-error')).toContainText('deferred boom') +}) + +test('preserves redirect and not-found response status', async ({ + request, +}) => { + const redirectResponse = await request.get('/redirect', { + maxRedirects: 0, + }) + expect([301, 302, 307, 308]).toContain(redirectResponse.status()) + expect(redirectResponse.headers().location).toBe('/about') + + const missingResponse = await request.get('/missing') + expect(missingResponse.status()).toBe(404) + expect(await missingResponse.text()).toContain('Not found') +}) + +test('adopts deferred Hydrate HTML while keeping its JavaScript lazy', async ({ + page, + request, +}) => { + const deferredJavaScript = findBuiltAsset( + '.js', + '__octaneStartDeferredHydrationModuleLoaded', + ) + const deferredCss = findBuiltAsset( + '.css', + '.octane-start-deferred-hydration-proof', + ) + const manifest = readStartManifest() + + const response = await request.get('/hydrate') + expect(response.status()).toBe(200) + const html = await response.text() + + expect(html).toContain('deferred hydration clicks 0') + expect(html).toContain('server fallback') + expect(html).not.toContain('client-only module rendered') + expect(html).toContain(`/assets/${deferredCss}`) + expect(html).not.toContain(`/assets/${deferredJavaScript}`) + expect(manifest).toContain(`/assets/${deferredCss}`) + expect(manifest).not.toContain(`/assets/${deferredJavaScript}`) + + const unrelatedResponse = await request.get('/about') + expect(unrelatedResponse.status()).toBe(200) + expect(await unrelatedResponse.text()).not.toContain(`/assets/${deferredCss}`) + + const browserErrors: Array = [] + const requestedScripts: Array = [] + page.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + browserErrors.push(message.text()) + } + }) + page.on('pageerror', (error) => browserErrors.push(error.message)) + page.on('request', (pageRequest) => { + if (pageRequest.resourceType() === 'script') { + requestedScripts.push(new URL(pageRequest.url()).pathname) + } + }) + + await page.addInitScript(() => { + const proof = window as typeof window & { + __octaneStartDeferredHydrationServerNode?: Element | null + } + const captureServerNode = () => { + const node = document.querySelector( + '.octane-start-deferred-hydration-proof', + ) + if (node !== null) { + proof.__octaneStartDeferredHydrationServerNode ??= node + observer.disconnect() + } + } + const observer = new MutationObserver(captureServerNode) + observer.observe(document, { childList: true, subtree: true }) + captureServerNode() + }) + + await page.goto('/hydrate') + await expect + .poll(() => + page.evaluate(() => { + return ( + window as typeof window & { + __octaneStartHydrateRouteReady?: boolean + } + ).__octaneStartHydrateRouteReady + }), + ) + .toBe(true) + await expect(page.getByTestId('hydrate-eager')).toHaveText( + 'eager route content', + ) + await expect(page.getByTestId('client-only-proof')).toHaveText( + 'client-only module rendered', + ) + await expect(page.getByTestId('client-only-fallback')).toHaveCount(0) + + const deferredProof = page.getByTestId('deferred-hydration-proof') + await expect(deferredProof).toHaveText('deferred hydration clicks 0') + await expect(deferredProof).toHaveCSS('color', 'rgb(12, 34, 56)') + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }), + ) + + const deferredScriptPath = `/assets/${deferredJavaScript}` + expect(requestedScripts).not.toContain(deferredScriptPath) + expect( + await deferredProof.evaluate((node) => { + const proof = window as typeof window & { + __octaneStartDeferredHydrationModuleLoaded?: boolean + __octaneStartDeferredHydrationServerNode?: Element | null + } + return { + dormant: node.parentElement?.getAttribute('data-octane-hydrate-when'), + moduleLoaded: proof.__octaneStartDeferredHydrationModuleLoaded === true, + sameNode: proof.__octaneStartDeferredHydrationServerNode === node, + } + }), + ).toEqual({ dormant: 'interaction', moduleLoaded: false, sameNode: true }) + + await deferredProof.click() + await expect(deferredProof).toHaveText('deferred hydration clicks 1') + await expect + .poll(() => requestedScripts.includes(deferredScriptPath)) + .toBe(true) + expect( + await deferredProof.evaluate((node) => { + const proof = window as typeof window & { + __octaneStartDeferredHydrationModuleLoaded?: boolean + __octaneStartDeferredHydrationServerNode?: Element | null + } + return { + dormant: node.parentElement?.hasAttribute('data-octane-hydrate-when'), + moduleLoaded: proof.__octaneStartDeferredHydrationModuleLoaded === true, + sameNode: proof.__octaneStartDeferredHydrationServerNode === node, + } + }), + ).toEqual({ dormant: false, moduleLoaded: true, sameNode: true }) + expect(browserErrors).toEqual([]) +}) diff --git a/e2e/octane-start/basic/tests/hmr.spec.ts b/e2e/octane-start/basic/tests/hmr.spec.ts new file mode 100644 index 0000000000..8439acfc30 --- /dev/null +++ b/e2e/octane-start/basic/tests/hmr.spec.ts @@ -0,0 +1,55 @@ +import path from 'node:path' +import { expect } from '@playwright/test' +import { createHmrFileEditor, test } from '@tanstack/router-e2e-utils' + +const baselineHeading = 'Octane + TanStack Start' +const updatedHeading = 'Octane + TanStack Start HMR' +const routeEditor = createHmrFileEditor({ + files: { + index: path.join(process.cwd(), 'src/routes/index.tsrx'), + }, + normalizeSource: (_fileKey, source) => + source.split(updatedHeading).join(baselineHeading), +}) + +test.afterAll(async () => { + await routeEditor.capturePromise + await routeEditor.restoreFiles() +}) + +test('hot updates a code-split tsrx route without losing state', async ({ + page, +}) => { + const hydrationErrors: Array = [] + page.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + hydrationErrors.push(message.text()) + } + }) + page.on('pageerror', (error) => hydrationErrors.push(error.message)) + + await routeEditor.capturePromise + await page.goto('/?probe=hmr') + await expect(page.getByTestId('home-heading')).toHaveText(baselineHeading) + + await page.getByTestId('counter').click() + await expect(page.getByTestId('counter')).toHaveText('count 1') + + try { + await routeEditor.replaceText('index', baselineHeading, updatedHeading) + await expect(page.getByTestId('home-heading')).toHaveText(updatedHeading, { + timeout: 20_000, + }) + await expect(page.getByTestId('counter')).toHaveText('count 1') + } finally { + await routeEditor.restoreFiles() + } + + await expect(page.getByTestId('home-heading')).toHaveText(baselineHeading, { + timeout: 20_000, + }) + await expect(page.getByTestId('counter')).toHaveText('count 1') + await expect(page.locator('#__app nav')).toHaveCount(1) + await expect(page.locator('#__app main')).toHaveCount(1) + expect(hydrationErrors).toEqual([]) +}) diff --git a/e2e/octane-start/basic/tsconfig.json b/e2e/octane-start/basic/tsconfig.json new file mode 100644 index 0000000000..e7e65f9b05 --- /dev/null +++ b/e2e/octane-start/basic/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["**/*.ts", "**/*.d.ts"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2024"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2024", + "types": ["node"], + "noEmit": true + } +} diff --git a/e2e/octane-start/basic/vite.config.cloudflare.ts b/e2e/octane-start/basic/vite.config.cloudflare.ts new file mode 100644 index 0000000000..a57522277c --- /dev/null +++ b/e2e/octane-start/basic/vite.config.cloudflare.ts @@ -0,0 +1,10 @@ +import { cloudflare } from '@cloudflare/vite-plugin' +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/octane-start/plugin/vite' + +export default defineConfig({ + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' }, inspectorPort: false }), + tanstackStart(), + ], +}) diff --git a/e2e/octane-start/basic/vite.config.netlify.ts b/e2e/octane-start/basic/vite.config.netlify.ts new file mode 100644 index 0000000000..a72352017e --- /dev/null +++ b/e2e/octane-start/basic/vite.config.netlify.ts @@ -0,0 +1,7 @@ +import netlify from '@netlify/vite-plugin-tanstack-start' +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/octane-start/plugin/vite' + +export default defineConfig({ + plugins: [netlify(), tanstackStart()], +}) diff --git a/e2e/octane-start/basic/vite.config.ts b/e2e/octane-start/basic/vite.config.ts new file mode 100644 index 0000000000..241a00f193 --- /dev/null +++ b/e2e/octane-start/basic/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import { nitro } from 'nitro/vite' +import { tanstackStart } from '@tanstack/octane-start/plugin/vite' + +const outDir = process.env.E2E_DIST_DIR + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tanstackStart(), + nitro( + outDir + ? { + output: { + dir: outDir, + serverDir: `${outDir}/server`, + publicDir: `${outDir}/public`, + }, + } + : {}, + ), + ], +}) diff --git a/e2e/octane-start/basic/wrangler.jsonc b/e2e/octane-start/basic/wrangler.jsonc new file mode 100644 index 0000000000..d78374a0d4 --- /dev/null +++ b/e2e/octane-start/basic/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "tanstack-octane-start-e2e-basic", + "compatibility_date": "2025-09-24", + "compatibility_flags": ["nodejs_compat"], + "main": "@tanstack/octane-start/server-entry", +} diff --git a/labeler-config.yml b/labeler-config.yml index c1ad49328e..2a8f354377 100644 --- a/labeler-config.yml +++ b/labeler-config.yml @@ -13,6 +13,18 @@ 'package: nitro-v2-vite-plugin': - changed-files: - any-glob-to-any-file: 'packages/nitro-v2-vite-plugin/**/*' +'package: octane-router': + - changed-files: + - any-glob-to-any-file: 'packages/octane-router/**/*' +'package: octane-start': + - changed-files: + - any-glob-to-any-file: 'packages/octane-start/**/*' +'package: octane-start-client': + - changed-files: + - any-glob-to-any-file: 'packages/octane-start-client/**/*' +'package: octane-start-server': + - changed-files: + - any-glob-to-any-file: 'packages/octane-start-server/**/*' 'package: react-router': - changed-files: - any-glob-to-any-file: 'packages/react-router/**/*' diff --git a/package.json b/package.json index 98682bac38..c69a188bc6 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,9 @@ "cleanup-empty-packages": "node scripts/cleanup-empty-packages.mjs", "test:docs": "node scripts/verify-links.ts", "ts:symbol-references": "node scripts/ts-symbol-references.mjs", - "vite-ecosystem-ci:build": "nx run-many --targets=build --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start,@tanstack/react-start-client,@tanstack/react-start-server --skipRemoteCache", + "vite-ecosystem-ci:build": "nx run-many --targets=build --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start,@tanstack/react-start-client,@tanstack/react-start-server,@tanstack/octane-router,@tanstack/octane-start,@tanstack/octane-start-client,@tanstack/octane-start-server --skipRemoteCache", "vite-ecosystem-ci:before-test": "pnpm exec playwright install chromium", - "vite-ecosystem-ci:test": "nx run-many --targets=test:unit --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start-client --skipRemoteCache && nx run-many --target=test:e2e --projects=tanstack-router-e2e-react-basic-file-based,tanstack-router-e2e-react-basic-file-based-code-splitting,tanstack-react-start-e2e-basic,tanstack-vue-start-e2e-basic,tanstack-solid-start-e2e-basic --skipRemoteCache" + "vite-ecosystem-ci:test": "nx run-many --targets=test:unit --projects=@tanstack/router-plugin,@tanstack/start-plugin-core,@tanstack/react-start-client,@tanstack/octane-router,@tanstack/octane-start,@tanstack/octane-start-client,@tanstack/octane-start-server --skipRemoteCache && nx run-many --target=test:e2e --projects=tanstack-router-e2e-react-basic-file-based,tanstack-router-e2e-react-basic-file-based-code-splitting,tanstack-react-start-e2e-basic,tanstack-vue-start-e2e-basic,tanstack-solid-start-e2e-basic,tanstack-octane-start-e2e-basic --skipRemoteCache && nx run tanstack-octane-start-e2e-basic:test:platform-builds --skipRemoteCache" }, "nx": { "includedScripts": [ diff --git a/packages/octane-router/CHANGELOG.md b/packages/octane-router/CHANGELOG.md new file mode 100644 index 0000000000..cd2e364d9e --- /dev/null +++ b/packages/octane-router/CHANGELOG.md @@ -0,0 +1,12 @@ +# @tanstack/octane-router + +## 1.170.18 + +### Patch Changes + +- Add the official TanStack Router binding for Octane, including typed code- and + file-based routes, client hydration, streaming SSR, head and script management, + and Octane route generation support. + +- Updated dependencies: + - @tanstack/router-core@1.171.15 diff --git a/packages/octane-router/README.md b/packages/octane-router/README.md new file mode 100644 index 0000000000..ac72b3e017 --- /dev/null +++ b/packages/octane-router/README.md @@ -0,0 +1,101 @@ +# TanStack Router for Octane + +Official TanStack Router bindings for the +[Octane](https://github.com/octanejs/octane) renderer. + +```sh +pnpm add @tanstack/octane-router octane +``` + +The package re-exports `@tanstack/router-core` and supplies the Octane-specific +components, hooks, hydration, document primitives, and streaming SSR handlers. +It is published as raw source so the Octane Vite compiler can produce the +correct client or server output for each build environment. + +## Router + +```ts +import { createRoot } from 'octane' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/octane-router' + +function RootLayout() @{ + + +} + +function Home() @{ +

Home

+} + +const rootRoute = createRootRoute({ component: RootLayout }) +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Home, +}) + +const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), +}) + +createRoot(document.getElementById('__app')!).render(RouterProvider, { router }) +``` + +Code-based and generated file-based routes share the same typed APIs as the +other TanStack Router bindings, including route-bound hooks, loaders, search +validation, navigation blocking, lazy routes, and link preloading. + +## Start and SSR + +TanStack Start uses the `@tanstack/octane-router/ssr/server` and +`@tanstack/octane-router/ssr/client` entries. The root route owns the document: + +```ts +import { + Body, + Head, + HeadContent, + Html, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/octane-router' + +function RootDocument(props: { children?: unknown }) @{ + + + + + + {props.children} + + + +} + +function RootLayout() @{ + +} + +export const Route = createRootRoute({ + shellComponent: RootDocument, + component: RootLayout, +}) +``` + +`Body` emits the server document's `#__app` hydration boundary. On the client, +`Html` and `Body` flatten into that existing boundary while `Head` portals its +children into `document.head`. + +Visit [tanstack.com/router](https://tanstack.com/router) for Router concepts and +API documentation. diff --git a/packages/octane-router/eslint.config.ts b/packages/octane-router/eslint.config.ts new file mode 100644 index 0000000000..6f42530e18 --- /dev/null +++ b/packages/octane-router/eslint.config.ts @@ -0,0 +1,19 @@ +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + ignores: [ + 'tests/_fixtures/**', + 'tests/conformance/**', + 'tests/_helpers.ts', + ], + }, + { + files: ['src/**/*.ts', 'tests/**/*.ts'], + rules: { + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/triple-slash-reference': 'off', + }, + }, +] diff --git a/packages/octane-router/package.json b/packages/octane-router/package.json new file mode 100644 index 0000000000..171749336e --- /dev/null +++ b/packages/octane-router/package.json @@ -0,0 +1,91 @@ +{ + "name": "@tanstack/octane-router", + "version": "1.170.18", + "description": "Modern and scalable routing for Octane applications", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/router.git", + "directory": "packages/octane-router" + }, + "homepage": "https://tanstack.com/router", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "octane", + "location", + "router", + "routing", + "async", + "async router", + "typescript" + ], + "scripts": { + "clean": "rimraf ./dist && rimraf ./coverage", + "test:eslint": "eslint", + "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --noEmit", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --noEmit", + "test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js --noEmit", + "test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js --noEmit", + "test:types:ts60": "tsc6 --noEmit", + "test:types:ts70": "tsc --noEmit", + "test:unit": "vitest", + "test:unit:dev": "pnpm run test:unit --watch --hideSkippedTests", + "test:build": "publint --strict && attw --pack . --profile esm-only --ignore-rules no-resolution internal-resolution-error", + "build": "tsc --noEmit -p tsconfig.build.json" + }, + "type": "module", + "types": "src/index.ts", + "main": "src/index.ts", + "module": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./history": "./src/history.ts", + "./ssr/server": { + "types": "./src/ssr/server.ts", + "default": "./src/ssr/server.ts" + }, + "./ssr/client": { + "types": "./src/ssr/client.ts", + "default": "./src/ssr/client.ts" + }, + "./generator-plugin": { + "types": "./src/generator-plugin.d.ts", + "default": "./src/generator-plugin.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "src", + "README.md" + ], + "engines": { + "node": ">=22" + }, + "octane": { + "hookSlots": { + "manual": [ + "src" + ] + } + }, + "dependencies": { + "@tanstack/history": "workspace:*", + "@tanstack/router-core": "workspace:*", + "@tanstack/store": "^0.9.3", + "isbot": "^5.1.22" + }, + "devDependencies": { + "@types/node": ">=22", + "octane": "catalog:", + "vite": "*" + }, + "peerDependencies": { + "octane": ">=0.1.13" + } +} diff --git a/packages/octane-router/src/Asset.tsrx b/packages/octane-router/src/Asset.tsrx new file mode 100644 index 0000000000..fe38830adb --- /dev/null +++ b/packages/octane-router/src/Asset.tsrx @@ -0,0 +1,117 @@ +import { createElement, useLayoutEffect } from 'octane'; +import { isServer } from '@tanstack/router-core/isServer'; +import { useRouter } from './context.ts'; +import { useHydrated } from './ClientOnly.tsrx'; + +const MANAGED_KEY_ATTR = 'data-tsr-managed-key'; + +function attributeName(name) { + switch (name) { + case 'charSet': + return 'charset'; + case 'className': + return 'class'; + case 'crossOrigin': + return 'crossorigin'; + case 'httpEquiv': + return 'http-equiv'; + default: + return name; + } +} + +function setAttributes(element, attrs) { + if (!attrs) { + return; + } + for (const [name, value] of Object.entries(attrs)) { + if ( + name === 'suppressHydrationWarning' || + value === undefined || + value === null || + value === false + ) { + continue; + } + element.setAttribute(attributeName(name), value === true ? '' : String(value)); + } +} + +function findManagedElement(parent, key) { + for (const element of parent.querySelectorAll(`[${MANAGED_KEY_ATTR}]`)) { + if (element.getAttribute(MANAGED_KEY_ATTR) === key) { + return element; + } + } + return undefined; +} + +function mountAsset(props) { + const parent = props.target === 'head' ? document.head : document.body; + const existing = findManagedElement(parent, props.assetKey); + if (existing) { + return () => existing.remove(); + } + + const element = document.createElement(props.tag); + element.setAttribute(MANAGED_KEY_ATTR, props.assetKey); + setAttributes(element, props.attrs); + if (typeof props.children === 'string') { + element.textContent = props.children; + } + parent.appendChild(element); + return () => element.remove(); +} + +export function Asset(props) @{ + const router = useRouter(); + const hydrated = useHydrated(); + + if (!(isServer ?? router.isServer)) { + useLayoutEffect(() => mountAsset(props), [ + props.assetKey, + props.attrs, + props.children, + props.tag, + props.target, + ]); + } + + @if ( + (isServer ?? router.isServer) || + (props.target === 'body' && !hydrated) + ) { + @switch (props.tag) { + @case 'title': { + + {(props.children ?? '') as string} + + } + @case 'meta': { + + } + @case 'link': { + + } + @case 'style': { + {createElement('style', { + ...props.attrs, + 'data-tsr-managed-key': props.assetKey, + dangerouslySetInnerHTML: { __html: props.children ?? '' }, + })} + } + @case 'script': { + ') + }) + + it('emits the pre-hydration scroll restoration script when enabled', async () => { + const router = makeSsrRouter({ scrollRestoration: true }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr.dehydrate() + const script = getScrollRestorationScriptForRouter(router) + + const response = await renderRouterToString({ + router, + responseHeaders: new Headers({ 'content-type': 'text/html' }), + App: RouterServer, + }) + + expect(script).toBeTruthy() + expect(await response.text()).toContain(script) + }) + + it.each([false, 'data-only'] as const)( + 'does not render route UI when ssr is %s', + async (routeSsr) => { + const router = makeSsrRouter({ routeSsr }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr.dehydrate() + + const response = await renderRouterToString({ + router, + responseHeaders: new Headers({ 'content-type': 'text/html' }), + App: RouterServer, + }) + + expect(await response.text()).not.toContain('Rendered on the server') + }, + ) + + it('places shell styles inside the route-owned head when streaming', async () => { + const router = makeSsrRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr.dehydrate() + + const result = await renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', + }, + }), + router, + responseHeaders: new Headers({ 'content-type': 'text/html' }), + App: RouterServer, + }) + expect(isSsrResponse(result)).toBe(true) + if (!isSsrResponse(result)) { + throw new Error('Expected the streaming SSR response contract') + } + expect(result.serverSsrCleanup).toBe('stream') + + const html = await result.response.text() + const doctype = html.indexOf('') + const document = html.indexOf('') + + expect(doctype).toBe(0) + expect(document).toBeGreaterThan(doctype) + expect(head).toBeGreaterThan(document) + expect(style).toBeGreaterThan(head) + expect(headClose).toBeGreaterThan(style) + expect(html.slice(0, document)).not.toContain('