Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- Remix default file routes and registered React Router `flatRoutes()` pages now link to their components and navigation, including optional segments and configuration-only sync changes.

- TanStack Start server routes now link to their HTTP handlers while keeping API-only files out of the page map, including routes introduced after initial indexing.

- React Router framework-mode pages now appear with their page components and navigation, including nested routes and pathless layouts.
Expand Down
259 changes: 259 additions & 0 deletions __tests__/remix-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execFileSync } from 'child_process';
import { CodeGraph } from '../src';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import {
remixFileRoutePath,
usesDefaultFlatRoutes,
reactRouterFilesResolver,
} from '../src/resolution/frameworks/react-router';
import { routeRoots } from '../src/ui-server/api/route-roots';
import type { ResolutionContext } from '../src/resolution/types';

beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
});
// React Router 7aea711dd1ae2bc5a076d13ff17291829690fa74,
// packages/react-router-fs-routes/flatRoutes.ts: filename parsing and default scan.
describe('default flat filenames', () => {
it.each([
['_index.tsx', '/'],
['concerts.trending.tsx', '/concerts/trending'],
['concerts.$city.tsx', '/concerts/:city'],
['concerts._index.tsx', '/concerts'],
['_auth.login.tsx', '/login'],
['concerts_.mine.tsx', '/concerts/mine'],
['($lang).categories.tsx', '/:lang?/categories'],
['(lang).categories.tsx', '/lang?/categories'],
['files.$.tsx', '/files/*'],
['sitemap[.]xml.tsx', '/sitemap.xml'],
['hello[(]world[)].tsx', '/hello(world)'],
['weird-url.[_index].tsx', '/weird-url/_index'],
['concerts.$city/route.tsx', '/concerts/:city'],
['concerts.$city/card.tsx', null],
['concerts/$city.tsx', null],
['_auth.tsx', null],
['.hidden.tsx', null],
['about.md', null],
['broken[.tsx', null],
['parent._index.child.tsx', null],
])('%s -> %s', (file, url) => expect(remixFileRoutePath('app/routes/' + file)).toBe(url));
it('does not apply root conventions to another app directory', () => {
expect(remixFileRoutePath('packages/web/app/routes/_index.tsx')).toBeNull();
});
});

const imports = `import {flatRoutes as files} from '@react-router/fs-routes';\n`;
describe('flatRoutes registration', () => {
it.each([
'files()',
'await files()',
'files(); const unrelated = 1',
'[...(await files())]',
'[route("extra","./extra.tsx"), ...files()] satisfies RouteConfig',
])('accepts %s', (expression) => {
expect(usesDefaultFlatRoutes(imports + 'export default ' + expression + ';')).toBe(true);
});
it.each([
'[]',
'files(options)',
'files().map(change)',
'[...(await files())].map(change)',
'[layout("./layout.tsx", [...files()])]',
'[files()]',
'dynamic',
])('rejects %s', (expression) => {
expect(usesDefaultFlatRoutes(imports + 'export default ' + expression + ';')).toBe(false);
});
it('ignores type-only, commented, unrelated and unregistered calls', () => {
expect(usesDefaultFlatRoutes(imports + 'const unused=files();\nexport default [];')).toBe(
false,
);
expect(
usesDefaultFlatRoutes(
"import type {flatRoutes} from '@react-router/fs-routes';\nexport default flatRoutes();",
),
).toBe(false);
expect(
usesDefaultFlatRoutes("import {flatRoutes} from 'other';\nexport default flatRoutes();"),
).toBe(false);
expect(usesDefaultFlatRoutes(imports + '// export default files();')).toBe(false);
expect(
usesDefaultFlatRoutes(imports + 'const text=`export default files()`;\nexport default [];'),
).toBe(false);
});
it('requires registration for React Router and rejects route config overrides', () => {
const files = new Map<string, string>([
[
'package.json',
JSON.stringify({ dependencies: { '@react-router/fs-routes': '*', 'react-router': '*' } }),
],
]);
const context = { readFile: (f: string) => files.get(f) ?? null } as ResolutionContext;
expect(reactRouterFilesResolver.detect(context)).toBe(false);
files.set('app/routes.ts', imports + 'export default files();');
expect(reactRouterFilesResolver.detect(context)).toBe(true);
for (const config of [
'export default {appDirectory:"src"}',
'export default {"appDirectory":"src"}',
'export default {...options}',
'export default configuration',
]) {
files.set('react-router.config.ts', config);
expect(reactRouterFilesResolver.detect(context)).toBe(false);
}
});
});

describe('file-route apps through indexing', () => {
let cg: CodeGraph | undefined;
let dir: string;
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
const write = (file: string, source: string) => {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), source);
};
it.each([false, true])(
'refreshes existing pages when only configuration changes (scoped=%s)',
async (scoped) => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-remix-config-'));
write(
'package.json',
JSON.stringify({ dependencies: { 'react-router': '7', '@react-router/dev': '7' } }),
);
write('app/routes.ts', imports + 'export default [];');
write(
'app/routes/about.tsx',
'function Unused(){return <Outlet/>;} export default function About(){return <div/>;}',
);
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route')).toHaveLength(0);
write('app/routes.ts', imports + 'export default files();');
const enabled = await cg.sync(scoped ? { paths: ['app/routes.ts'] } : undefined);
expect(enabled.filesModified).toBe(2);
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/about']);
write('app/routes.ts', imports + 'export default [];');
await cg.sync(scoped ? { paths: ['app/routes.ts'] } : undefined);
expect(cg.getNodesByKind('route')).toHaveLength(0);
write('app/routes.ts', imports + 'export default files();');
await cg.sync();
cg.close();
fs.unlinkSync(path.join(dir, 'app/routes.ts'));
cg = await CodeGraph.open(dir);
await cg.sync(scoped ? { paths: ['app/routes.ts'] } : undefined);
expect(cg.getNodesByKind('route')).toHaveLength(0);
},
);
it.runIf(fs.existsSync(path.resolve('dist/index.js')))(
'indexes file conventions in fresh compiled workers',
() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-remix-workers-'));
write('package.json', JSON.stringify({ dependencies: { '@remix-run/react': '2' } }));
write('app/routes/_index.tsx', 'export default function Home(){return <div/>;}');
const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))});
(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});
const route=cg.getNodesByKind('route')[0];
console.log(JSON.stringify([route.name,cg.getOutgoingEdges(route.id).filter(e=>e.kind==='references').map(e=>cg.getNode(e.target)?.name)]));cg.close();})().catch(e=>{console.error(e);process.exit(1)});`;
const output = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf8',
timeout: 60000,
env: {
...process.env,
CODEGRAPH_PARALLEL_RESOLVE_MIN: '1',
CODEGRAPH_RESOLVE_WORKERS: '2',
CODEGRAPH_PARSE_WORKERS: '2',
},
});
expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', ['Home']]);
},
);
it.each(['remix', 'framework'])(
'%s binds pages and optional navigation, excluding resources/layouts/colocation',
async (mode) => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-remix-'));
write(
'package.json',
JSON.stringify({
dependencies:
mode === 'remix'
? { '@remix-run/react': '2', '@remix-run/dev': '2' }
: { 'react-router': '7', '@react-router/dev': '7', '@react-router/fs-routes': '7' },
}),
);
if (mode === 'framework') {
write(
'app/routes.ts',
imports +
`import {route} from '@react-router/dev/routes';\nexport default [...(await files()), route('extra','./extra.tsx')];`,
);
write('app/extra.tsx', 'export default function Extra(){return <div/>;}');
}
for (const file of [
'_index',
'concerts.$city',
'_auth.login',
'($lang).categories',
'(lang).static',
'folder/route',
])
write(`app/routes/${file}.tsx`, 'export default function Page(){return <div/>;}');
write('app/routes/_auth.tsx', 'export default function Layout(){return <Outlet/>;}');
write('app/routes/concerts.tsx', 'export default function Layout(){return <Outlet/>;}');
write('app/routes/health.ts', 'export function loader(){return new Response("ok");}');
write('app/routes/folder/card.tsx', 'export default function Card(){return <div/>;}');
write(
'app/nav.ts',
`import {redirect} from '${mode === 'remix' ? '@remix-run/react' : 'react-router'}';
export function city(){return redirect('/concerts/paris')}
export function lang(){return redirect('/en/categories')}
export function noLang(){return redirect('/categories')}
export function fixed(){return redirect('/lang/static')}
export function noFixed(){return redirect('/static')}`,
);
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual(
[
'/',
'/concerts/:city',
'/login',
'/:lang?/categories',
'/lang?/static',
'/folder',
...(mode === 'framework' ? ['/extra'] : []),
].sort(),
);
const roots = routeRoots(cg, routes);
expect(roots.size).toBe(routes.length);
for (const [fn, url] of [
['city', '/concerts/:city'],
['lang', '/:lang?/categories'],
['noLang', '/:lang?/categories'],
['fixed', '/lang?/static'],
['noFixed', '/lang?/static'],
]) {
const from = cg.getNodesByKind('function').find((n) => n.name === fn)!;
expect(cg.getOutgoingEdges(from.id)).toContainEqual(
expect.objectContaining({
kind: 'navigates',
target: routes.find((n) => n.name === url)!.id,
}),
);
}
write('app/routes/new.tsx', 'export default function NewPage(){return <div/>;}');
await cg.sync();
expect(cg.getNodesByKind('route').some((n) => n.name === '/new')).toBe(true);
fs.unlinkSync(path.join(dir, 'app/routes/new.tsx'));
await cg.sync();
expect(cg.getNodesByKind('route').some((n) => n.name === '/new')).toBe(false);
},
);
});
4 changes: 2 additions & 2 deletions docs/design/PLAN-application-router-coverage.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Status: 2/13 — step 2 verified, preparing its PR; step 1 published as PR #3
Status: 3/13 — preparing step 3 PR (Remix / React Router file routes); steps 1–2 published as PRs #3–4

- [x] 1 React Router framework mode — seven official-fixture pages bind exact components; nested index/navigation, config/module sync and fresh compiled workers verified; build passes, 112 WASM focused/control tests pass, full native suite 4,353 pass / 46 skip; independent review clear.
- [x] 2 TanStack Start server routes — literal method tables and `createHandlers` bind handlers/calls; page/API coexistence and full/scoped sync verified; build passes, 62 WASM focused/control tests pass, full native suite 4,375 pass / 46 skip; independent review clear.
- [ ] 3 Remix and React Router file conventions — support the pinned default file convention, including index, nested, parameter, pathless, and splat cases; require evidence that the convention is enabled. Gate: shared proof, explicit-config/file-route coexistence, and layout-only controls.
- [x] 3 Remix and React Router file conventions — registered default conventions bind exact page components, optional navigation and explicit config coexistence; layout/resource controls and config-only full/scoped/reopened sync pass; build passes, 102 WASM tests pass, full native suite 4,415 pass / 46 skip; independent review clear.
- [ ] 4 Astro route completion — connect existing page routes to components and API method exports, then navigation where the source declares it. Gate: shared proof, page/API distinction, underscore exclusions, and cross-file handler resolution.
- [ ] 5 RedwoodSDK — imported `rwsdk/router` declarations, method tables, and statically resolvable registration/prefix context. Gate: shared proof, `defineApp`/`render` composition, and interrupters excluded from page roots.
- [ ] 6 Angular Router — registered literal route tables, nested children, component references, and statically resolvable lazy modules/components. Gate: shared proof, router registration and nesting, plus unregistered objects and custom matcher negatives.
Expand Down
4 changes: 3 additions & 1 deletion docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ guessed.
|---|---|---|---|---|
| Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — |
| Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter |
| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts`, `react-router-framework.test.ts` | proshop; pinned official framework config (7 pages) |
| React Router / Remix | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts`, `react-router-framework.test.ts`, `remix-routes.test.ts` | proshop; pinned official framework config and flat filenames |
| TanStack Router / Start | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts`, `tanstack-start.test.ts` | TanStack examples, fastapi-template frontend; pinned Start server-handler syntax |
| Vue Router / Nuxt | `frameworks/vue-router.ts` | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges) |
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) |

Remix default `app/routes/` conventions and React Router configs registering an imported, option-free `flatRoutes()` call support JS/TS pages and immediate `folder/route` modules. [Pinned filename parser](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/packages/react-router-fs-routes/flatRoutes.ts#L351): dot nesting, index/pathless segments, parameters, optional segments, splats and bracket escapes. Resource-only and direct `Outlet`-only defaults are excluded. Config-only full/scoped sync and reopening an index refresh existing pages. Custom configuration, folder `index` fallback, Markdown/MDX, anonymous defaults and re-exports remain unsupported.

React Router framework mode reads default exported literal arrays in `app/routes.ts` or `app/routes.js`, using imported `route`, `index`, `layout`, and spread `prefix` helpers. Module paths bind named default components; nested index pages take precedence over their parent. [Official source fixture](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/docs/start/framework/routing.md#L28): seven expected pages, verified through indexing and navigation. Tests also cover module/config sync and a fresh compiled process using parse/resolver workers. Custom app directories, computed arrays, `relative`, anonymous defaults, and re-exports remain unsupported.

TanStack Start reads imported `createFileRoute` calls assigned to exported `const Route`: literal `server.handlers` tables, including `ANY`, and the destructured `createHandlers` callback form. Named handlers and direct inline calls bind through the existing HTTP reader. Page/API combinations retain both nodes; server-only routes do not become pages. [Pinned official handler syntax](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/docs/start/framework/react/guide/server-routes.md#L172), [executable middleware fixture](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/e2e/react-start/server-routes/src/routes/api/middleware-context.ts). Computed/spread tables, member handlers, custom factories and server `update` chains remain unresolved. `createServerFn` has no declared public route and gets no fabricated endpoint.
Expand Down
4 changes: 3 additions & 1 deletion site/src/content/docs/guides/framework-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **Axum / actix / Rocket** | `.route("/x", get(handler))` |
| **ASP.NET** | `[HttpGet("/x")]` attributes on action methods |
| **Vapor** | `app.get("x", use: handler)` |
| **React Router** | JSX/data-router pages; literal framework-mode `app/routes.ts` arrays with `route`, `index`, `layout`, and `prefix`, linked to named default components |
| **React Router / Remix** | JSX/data-router pages; literal framework-mode arrays; default Remix and registered `flatRoutes()` file pages, linked to named default components |
| **SvelteKit** | Route component nodes |
| **TanStack Router / Start** | Page routes plus literal `server.handlers` method tables and `createHandlers` callbacks on exported file routes; middleware is excluded from handler links |
| **Next.js** | App Router and Pages Router pages; `app/api/**/route.ts` method exports and `pages/api/**` default handlers |
Expand All @@ -41,6 +41,8 @@ Route resolution is automatic — there's nothing to configure. If a framework f

React Router framework mode assumes the default `app/` directory. Computed arrays, custom app directories, `relative` helpers, anonymous defaults, and re-exports remain unsupported. Layout helpers contribute nesting without creating extra pages; an index page takes precedence over its parent layout.

Remix default `app/routes/` filenames and React Router configs registering an imported, option-free `flatRoutes()` call support JS/TS pages, immediate `folder/route` modules, dot nesting, index/pathless segments, parameters, optional segments, splats, and bracket escapes. Resource-only and direct `Outlet`-only defaults are excluded. Custom configuration, folder `index` fallback, and Markdown/MDX are unsupported.

The JavaScript HTTP readers require a recognized package import (ES modules or CommonJS), except for the global `Bun.serve`. They follow immutable local router bindings and literal declarations, without executing your application. Named handlers produce references; direct calls inside inline handlers produce call edges. Static responses have an endpoint without an invented handler. Member handlers remain unresolved by this reader.

TanStack Start server routes support imported `createFileRoute` calls assigned to exported `const Route`. Computed/spread tables, custom factories, member handlers, and `update` chains remain unsupported. RPC functions created with `createServerFn` are not presented as public route URLs.
Expand Down
Loading