mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d4d02b69f2
commit
4cd5ea7547
@@ -598,11 +598,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` 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 `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -157,6 +157,19 @@ for (const name of listFixtures()) {
|
||||
assert.match(body, /localhost:9999\/live\.js/);
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'tanstack-start') {
|
||||
const adapterResult = result.results[0];
|
||||
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
|
||||
const component = readFileSync(join(tmp, adapterResult.componentFile), 'utf-8');
|
||||
assert.equal(adapterResult.inserted, true, 'TanStack Start root document was patched');
|
||||
assert.match(rootDoc, /impeccable-live-tanstack-start/, 'root document got the adapter marker');
|
||||
assert.match(rootDoc, /<ImpeccableLiveRoot \/>/, 'root document renders the mount component');
|
||||
assert.doesNotMatch(rootDoc, /impeccable-live-start/, 'root document must not get the raw script block');
|
||||
assert.doesNotMatch(rootDoc, /localhost:9999\/live\.js/, 'root document must not own live.js directly');
|
||||
assert.match(component, /localhost:9999\/live\.js/, 'mount component loads live.js');
|
||||
assert.match(component, /useEffect/, 'mount component appends the script on mount');
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
@@ -189,6 +202,14 @@ for (const name of listFixtures()) {
|
||||
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'tanstack-start') {
|
||||
const adapterResult = result.results[0];
|
||||
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
|
||||
assert.doesNotMatch(rootDoc, /ImpeccableLiveRoot/);
|
||||
assert.doesNotMatch(rootDoc, /impeccable-live-tanstack-start/);
|
||||
assert.equal(existsSync(join(tmp, adapterResult.componentFile)), false, 'TanStack mount component was removed');
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
assert.doesNotMatch(body, /impeccable-live-start/);
|
||||
|
||||
@@ -113,6 +113,8 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
|
||||
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
|
||||
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
|
||||
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
|
||||
| `tanstack-router-vite/` | Vite + TanStack Router (code-based SPA). Tracked `index.html` shell inject (the baseline Vite path, no adapter). |
|
||||
| `tanstack-start/` | Vite + TanStack Start (SSR). No static `index.html`; Live patches the `__root.tsx` document to mount a generated dev-only React component that loads the bundle. |
|
||||
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
|
||||
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
|
||||
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite 8 + TanStack Router Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: () => (
|
||||
<>
|
||||
<nav className="nav">
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about" data-testid="nav-about">About</Link>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
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(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function About() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1 className="hero-title">About Page Hero</h1>
|
||||
<p className="hero-hook">Lives on the /about route — only mounts after navigation.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h2>Home</h2>
|
||||
<p>Welcome. The hero we'll edit lives on the About page.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Vite 8 + TanStack Router (code-based SPA)",
|
||||
"config": {
|
||||
"files": ["index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
package-lock.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"
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getRouter>;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
});
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1 className="hero-title">Start Home Hero</h1>
|
||||
<p className="hero-hook">Server-rendered by TanStack Start.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Vite 8 + TanStack Start (SSR, root-document adapter)",
|
||||
"config": {
|
||||
"files": ["src/routes/__root.tsx"],
|
||||
"insertBefore": "<Scripts",
|
||||
"commentSyntax": "jsx"
|
||||
},
|
||||
"sourceFiles": [
|
||||
"src/routes/__root.tsx",
|
||||
"src/routes/index.tsx",
|
||||
"src/router.tsx",
|
||||
"vite.config.js"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps index hero in routes/index.tsx",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "src/routes/index.tsx"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npm", "run", "dev"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 180000,
|
||||
"pickSelector": "h1.hero-title",
|
||||
"reloadProbe": {
|
||||
"expectSelector": "h1.hero-title"
|
||||
},
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
},
|
||||
"steer": {
|
||||
"sourceFile": "src/routes/index.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.nitro/
|
||||
.tanstack/
|
||||
.output/
|
||||
src/routeTree.gen.ts
|
||||
package-lock.json
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Unit tests for the TanStack Start live-mode adapter.
|
||||
* Run with: node --test tests/live-tanstack-adapter.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
detectTanStackStartProject,
|
||||
applyTanStackLiveAdapter,
|
||||
removeTanStackLiveAdapter,
|
||||
patchTanStackRoot,
|
||||
unpatchTanStackRoot,
|
||||
buildTanStackLiveRootComponent,
|
||||
} from '../skill/scripts/live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ROOT_TSX = `import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
shellComponent: RootDocument,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
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 <Scripts />', () => {
|
||||
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, /<ImpeccableLiveRoot \/>/);
|
||||
// component renders before <Scripts />
|
||||
assert.ok(patched.indexOf('<ImpeccableLiveRoot />') < patched.indexOf('<Scripts />'));
|
||||
});
|
||||
|
||||
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(/<ImpeccableLiveRoot \/>/g) || []).length, 1);
|
||||
assert.equal((twice.match(/^import ImpeccableLiveRoot/gm) || []).length, 1);
|
||||
});
|
||||
|
||||
it('falls back to </body> when <Scripts /> is absent', () => {
|
||||
const noScripts = ROOT_TSX.replace(/\s*<Scripts \/>/, '');
|
||||
const patched = patchTanStackRoot(noScripts, '../impeccable/ImpeccableLiveRoot');
|
||||
assert.match(patched, /<ImpeccableLiveRoot \/>/);
|
||||
assert.ok(patched.indexOf('<ImpeccableLiveRoot />') < patched.indexOf('</body>'));
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user