From 4cd5ea754788878b9101428ebb3f0c410a19c107 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 22:27:44 -0700 Subject: [PATCH] Add TanStack Router + Start support to live mode Live mode had no TanStack coverage: a TanStack Start user hit disconnects and static previews because there is no static index.html to inject and no adapter for the SSR root document. - New tanstack-adapter.mjs, modeled on the SvelteKit/Nuxt adapters: detects a TanStack Start project (@tanstack/react-start + src/routes/__root.tsx) and patches the __root document to mount a generated dev-only React component (src/impeccable/ImpeccableLiveRoot) that appends the live bundle on the client after hydration, carrying the ?token= param via buildLiveScriptSrc. Patch/unpatch round-trips byte-for-byte and is idempotent; refuses to clobber an unmanaged file at the component path. - Wire detection into live-inject.mjs (insert + remove + gitignore), ordered so SvelteKit/Nuxt win and a plain TanStack Router SPA falls through to the baseline Vite index.html path. - tanstack-router-vite fixture (baseline, no adapter) and tanstack-start fixture (SSR adapter), both with runtime blocks. Both pass the full live-e2e cycle (handshake, steer, pick, Go, cycle, accept, carbonize, reloadProbe). - Unit tests for detection + patch round-trip + apply/remove; tanstack-start branches in framework-fixtures.test.mjs; live.md framework table + adapter note. Co-Authored-By: Claude Fable 5 --- skill/reference/live.md | 4 + skill/scripts/live-inject.mjs | 26 +- skill/scripts/live/tanstack-adapter.mjs | 280 ++++++++++++++++++ tests/framework-fixtures.test.mjs | 21 ++ tests/framework-fixtures/README.md | 2 + .../tanstack-router-vite/files/index.html | 11 + .../tanstack-router-vite/files/package.json | 20 ++ .../tanstack-router-vite/files/src/main.jsx | 37 +++ .../files/src/routes/About.jsx | 8 + .../files/src/routes/Home.jsx | 8 + .../tanstack-router-vite/files/src/styles.css | 6 + .../tanstack-router-vite/files/vite.config.js | 7 + .../tanstack-router-vite/fixture.json | 49 +++ .../tanstack-router-vite/gitignore.txt | 4 + .../tanstack-start/files/package.json | 21 ++ .../tanstack-start/files/src/router.tsx | 12 + .../files/src/routes/__root.tsx | 26 ++ .../tanstack-start/files/src/routes/index.tsx | 14 + .../tanstack-start/files/vite.config.js | 8 + .../tanstack-start/fixture.json | 40 +++ .../tanstack-start/gitignore.txt | 8 + tests/live-tanstack-adapter.test.mjs | 175 +++++++++++ 22 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 skill/scripts/live/tanstack-adapter.mjs create mode 100644 tests/framework-fixtures/tanstack-router-vite/files/index.html create mode 100644 tests/framework-fixtures/tanstack-router-vite/files/package.json create mode 100644 tests/framework-fixtures/tanstack-router-vite/files/src/main.jsx create mode 100644 tests/framework-fixtures/tanstack-router-vite/files/src/routes/About.jsx create mode 100644 tests/framework-fixtures/tanstack-router-vite/files/src/routes/Home.jsx create mode 100644 tests/framework-fixtures/tanstack-router-vite/files/src/styles.css create mode 100644 tests/framework-fixtures/tanstack-router-vite/files/vite.config.js create mode 100644 tests/framework-fixtures/tanstack-router-vite/fixture.json create mode 100644 tests/framework-fixtures/tanstack-router-vite/gitignore.txt create mode 100644 tests/framework-fixtures/tanstack-start/files/package.json create mode 100644 tests/framework-fixtures/tanstack-start/files/src/router.tsx create mode 100644 tests/framework-fixtures/tanstack-start/files/src/routes/__root.tsx create mode 100644 tests/framework-fixtures/tanstack-start/files/src/routes/index.tsx create mode 100644 tests/framework-fixtures/tanstack-start/files/vite.config.js create mode 100644 tests/framework-fixtures/tanstack-start/fixture.json create mode 100644 tests/framework-fixtures/tanstack-start/gitignore.txt create mode 100644 tests/live-tanstack-adapter.test.mjs diff --git a/skill/reference/live.md b/skill/reference/live.md index 6f84d88e5..23ef907c7 100644 --- a/skill/reference/live.md +++ b/skill/reference/live.md @@ -598,11 +598,15 @@ Schema: | Next.js (Pages) | `["pages/_document.tsx"]` | `` | `jsx` | | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | +| TanStack Router (SPA, Vite) | `["index.html"]` | `` | `html` | +| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `"]` | `` | `html` | | Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw ` + + diff --git a/tests/framework-fixtures/tanstack-router-vite/files/package.json b/tests/framework-fixtures/tanstack-router-vite/files/package.json new file mode 100644 index 000000000..95acee9ee --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/package.json @@ -0,0 +1,20 @@ +{ + "name": "tanstack-router-vite-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-router": "^1.132.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/main.jsx b/tests/framework-fixtures/tanstack-router-vite/files/src/main.jsx new file mode 100644 index 000000000..ae32f1b35 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/main.jsx @@ -0,0 +1,37 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + createRootRoute, + createRoute, + createRouter, + RouterProvider, + Outlet, + Link, +} from '@tanstack/react-router'; +import Home from './routes/Home.jsx'; +import About from './routes/About.jsx'; +import './styles.css'; + +const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), +}); + +const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Home }); +const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: '/about', component: About }); + +const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]); +const router = createRouter({ routeTree }); + +createRoot(document.getElementById('root')).render( + + + , +); diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/routes/About.jsx b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/About.jsx new file mode 100644 index 000000000..ae4c31e15 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/About.jsx @@ -0,0 +1,8 @@ +export default function About() { + return ( +
+

About Page Hero

+

Lives on the /about route — only mounts after navigation.

+
+ ); +} diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/routes/Home.jsx b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/Home.jsx new file mode 100644 index 000000000..3c022d03d --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/Home.jsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

Home

+

Welcome. The hero we'll edit lives on the About page.

+
+ ); +} diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/styles.css b/tests/framework-fixtures/tanstack-router-vite/files/src/styles.css new file mode 100644 index 000000000..7d2c6e91f --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/styles.css @@ -0,0 +1,6 @@ +body { margin: 0; font-family: system-ui, sans-serif; } +.nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #eee; } +.nav a { color: #111; text-decoration: none; } +.page { padding: 2rem; } +.hero-title { font-size: 2rem; margin: 0 0 0.5rem; } +.hero-hook { color: #555; } diff --git a/tests/framework-fixtures/tanstack-router-vite/files/vite.config.js b/tests/framework-fixtures/tanstack-router-vite/files/vite.config.js new file mode 100644 index 000000000..f7ad4b565 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { host: '127.0.0.1', strictPort: false }, +}); diff --git a/tests/framework-fixtures/tanstack-router-vite/fixture.json b/tests/framework-fixtures/tanstack-router-vite/fixture.json new file mode 100644 index 000000000..e5f96bd86 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/fixture.json @@ -0,0 +1,49 @@ +{ + "name": "Vite 8 + TanStack Router (code-based SPA)", + "config": { + "files": ["index.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": [ + "index.html", + "src/main.jsx", + "src/routes/Home.jsx", + "src/routes/About.jsx", + "src/styles.css", + "vite.config.js" + ], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps About hero in routes/About.jsx", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "src/routes/About.jsx" + } + ], + "runtime": { + "styling": "plain-css", + "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], + "devCommand": ["npx", "vite", "--host", "127.0.0.1"], + "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", + "readyTimeoutMs": 120000, + "preActions": [ + { "type": "click", "selector": "[data-testid='nav-about']" }, + { "type": "wait", "selector": "h1.hero-title" } + ], + "reloadProbe": { + "preActions": [ + { "type": "click", "selector": "[data-testid='nav-about']" }, + { "type": "wait", "selector": "h1.hero-title" } + ], + "expectSelector": "h1.hero-title" + }, + "probe": { + "expectLiveInit": true, + "expectConsoleClean": true + }, + "steer": { + "sourceFile": "src/routes/About.jsx" + } + } +} diff --git a/tests/framework-fixtures/tanstack-router-vite/gitignore.txt b/tests/framework-fixtures/tanstack-router-vite/gitignore.txt new file mode 100644 index 000000000..8cda9ad20 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/gitignore.txt @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +package-lock.json diff --git a/tests/framework-fixtures/tanstack-start/files/package.json b/tests/framework-fixtures/tanstack-start/files/package.json new file mode 100644 index 000000000..83f93d047 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/package.json @@ -0,0 +1,21 @@ +{ + "name": "tanstack-start-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --host 127.0.0.1", + "build": "vite build", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "@tanstack/react-router": "^1.132.0", + "@tanstack/react-start": "^1.132.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/tests/framework-fixtures/tanstack-start/files/src/router.tsx b/tests/framework-fixtures/tanstack-start/files/src/router.tsx new file mode 100644 index 000000000..9cd826476 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter as createTanStackRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export function getRouter() { + return createTanStackRouter({ routeTree, scrollRestoration: true }); +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType; + } +} diff --git a/tests/framework-fixtures/tanstack-start/files/src/routes/__root.tsx b/tests/framework-fixtures/tanstack-start/files/src/routes/__root.tsx new file mode 100644 index 000000000..c3c0f0413 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/src/routes/__root.tsx @@ -0,0 +1,26 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'TanStack Start Fixture' }, + ], + }), + shellComponent: RootDocument, +}); + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ); +} diff --git a/tests/framework-fixtures/tanstack-start/files/src/routes/index.tsx b/tests/framework-fixtures/tanstack-start/files/src/routes/index.tsx new file mode 100644 index 000000000..52a49652f --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/')({ + component: Home, +}); + +function Home() { + return ( +
+

Start Home Hero

+

Server-rendered by TanStack Start.

+
+ ); +} diff --git a/tests/framework-fixtures/tanstack-start/files/vite.config.js b/tests/framework-fixtures/tanstack-start/files/vite.config.js new file mode 100644 index 000000000..6479cd32e --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react'; + +export default defineConfig({ + server: { host: '127.0.0.1' }, + plugins: [tanstackStart(), viteReact()], +}); diff --git a/tests/framework-fixtures/tanstack-start/fixture.json b/tests/framework-fixtures/tanstack-start/fixture.json new file mode 100644 index 000000000..ca001aa30 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/fixture.json @@ -0,0 +1,40 @@ +{ + "name": "Vite 8 + TanStack Start (SSR, root-document adapter)", + "config": { + "files": ["src/routes/__root.tsx"], + "insertBefore": " + + + + + {children} + + + + ); +} +`; + +function scaffold(tmp, { ext = 'tsx', rootBody = ROOT_TSX, startPackage = '@tanstack/react-start' } = {}) { + mkdirSync(join(tmp, 'src', 'routes'), { recursive: true }); + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ + name: 'app', + dependencies: { '@tanstack/react-router': '^1', [startPackage]: '^1' }, + })); + writeFileSync(join(tmp, 'src', 'routes', `__root.${ext}`), rootBody); +} + +describe('tanstack-adapter — detection', () => { + let tmp; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('detects a TanStack Start project from package + root route', () => { + scaffold(tmp); + const project = detectTanStackStartProject(tmp); + assert.equal(project.rootRoute, 'src/routes/__root.tsx'); + assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.tsx'); + assert.equal(project.componentImport, '../impeccable/ImpeccableLiveRoot'); + }); + + it('mirrors the root-route extension for the mount component (jsx)', () => { + scaffold(tmp, { ext: 'jsx' }); + const project = detectTanStackStartProject(tmp); + assert.equal(project.rootRoute, 'src/routes/__root.jsx'); + assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.jsx'); + }); + + it('detects @tanstack/solid-start and @tanstack/start too', () => { + scaffold(tmp, { startPackage: '@tanstack/solid-start' }); + assert.ok(detectTanStackStartProject(tmp)); + }); + + it('returns null without the Start package (plain TanStack Router SPA)', () => { + mkdirSync(join(tmp, 'src', 'routes'), { recursive: true }); + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ + dependencies: { '@tanstack/react-router': '^1' }, + })); + writeFileSync(join(tmp, 'src', 'routes', '__root.tsx'), ROOT_TSX); + assert.equal(detectTanStackStartProject(tmp), null); + }); + + it('returns null without a root route file', () => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ + dependencies: { '@tanstack/react-start': '^1' }, + })); + assert.equal(detectTanStackStartProject(tmp), null); + }); +}); + +describe('tanstack-adapter — patch/unpatch round-trip', () => { + it('inserts the import + mount component before ', () => { + const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot'); + assert.match(patched, /import ImpeccableLiveRoot from '\.\.\/impeccable\/ImpeccableLiveRoot';/); + assert.match(patched, /\{\/\* impeccable-live-tanstack-start \*\/\}/); + assert.match(patched, //); + // component renders before + assert.ok(patched.indexOf('') < patched.indexOf('')); + }); + + it('round-trips byte-for-byte (patch then unpatch)', () => { + const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot'); + assert.notEqual(patched, ROOT_TSX); + assert.equal(unpatchTanStackRoot(patched), ROOT_TSX); + }); + + it('is idempotent (double patch adds one import + one mount)', () => { + const once = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot'); + const twice = patchTanStackRoot(once, '../impeccable/ImpeccableLiveRoot'); + assert.equal(twice, once); + assert.equal((twice.match(//g) || []).length, 1); + assert.equal((twice.match(/^import ImpeccableLiveRoot/gm) || []).length, 1); + }); + + it('falls back to when is absent', () => { + const noScripts = ROOT_TSX.replace(/\s*/, ''); + const patched = patchTanStackRoot(noScripts, '../impeccable/ImpeccableLiveRoot'); + assert.match(patched, //); + assert.ok(patched.indexOf('') < patched.indexOf('')); + assert.equal(unpatchTanStackRoot(patched), noScripts); + }); + + it('builds a client-only mount component carrying the token', () => { + const body = buildTanStackLiveRootComponent(8123, 'tok-xyz'); + assert.match(body, /http:\/\/localhost:8123\/live\.js\?token=tok-xyz/); + assert.match(body, /useEffect/); + assert.match(body, /typeof document === 'undefined'/); + assert.match(body, /data-impeccable-live-tanstack/); + }); +}); + +describe('tanstack-adapter — apply/remove on disk', () => { + let tmp; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('apply writes the component + patches root, remove restores byte-for-byte', () => { + scaffold(tmp); + const original = readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'); + + const applied = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' }); + assert.equal(applied.adapter, 'tanstack-start'); + assert.equal(applied.inserted, true); + assert.ok(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'))); + assert.match(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), /ImpeccableLiveRoot/); + assert.match( + readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'), + /localhost:9100\/live\.js\?token=T1/, + ); + + const removed = removeTanStackLiveAdapter({ cwd: tmp }); + assert.equal(removed.removed, true); + assert.equal(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx')), false); + assert.equal(existsSync(join(tmp, 'src/impeccable')), false, 'empty managed dir pruned'); + assert.equal(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), original); + }); + + it('refuses to clobber an unmanaged file at the component path', () => { + scaffold(tmp); + mkdirSync(join(tmp, 'src/impeccable'), { recursive: true }); + writeFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'export const mine = 1;\n'); + const result = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' }); + assert.equal(result.error, 'tanstack_component_conflict'); + // unmanaged file untouched + assert.equal( + readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'), + 'export const mine = 1;\n', + ); + }); +});