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

- Vike default `+Page` modules now link to exact local components, with nearest inherited literal `+route` overrides and guards against guessed paths under unsupported routing configuration.

- Qwik City default index pages and method exports now produce exact route roots, including anonymous `component$` defaults and their body calls, with layouts and generic middleware excluded.

- SolidStart 2 default file routes now link to exact page and HTTP handlers, including page/API coexistence, nested layouts, parameters and GET-to-HEAD fallback.
Expand Down
289 changes: 289 additions & 0 deletions __tests__/vike-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import { afterEach, 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 { routeRoots } from '../src/ui-server/api/route-roots';

describe('Vike default pages and literal route overrides', () => {
let cg: CodeGraph | undefined;
let dir: string;
const write = (file: string, content: string) => {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), content);
};
const config = `import react from '@vitejs/plugin-react';import vike from 'vike/plugin';export default {plugins:[react(),vike()]};`;
const setup = () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vike-'));
write(
'package.json',
JSON.stringify({ dependencies: { vike: '0.4.266', 'vike-react': '0.6.26' } }),
);
write('vite.config.js', config);
};
const page = (folder: string, name: string) =>
write(`${folder}/+Page.tsx`, `export default function ${name}(){return <p/>}`);
const routes = () => cg!.getNodesByKind('route').filter((n) => n.id.startsWith('route:vike:'));
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});

// vikejs/vike@715c15d11be196caa69159b929ae09697f3ce734: react-minimal About and
// file-structure-domain-driven/product/pages/index source examples share a minimal app.
it('indexes official Page and route override fixtures with exact targets', async () => {
setup();
write(
'pages/about/+Page.tsx',
"export default Page\nimport React from 'react'\n\nfunction Page() {\n return (\n <>\n <h1>About</h1>\n <p>Example of using Vike.</p>\n </>\n )\n}\n",
);
write(
'product/pages/index/+Page.jsx',
"export default Page\n\nimport React from 'react'\n\nfunction Page({ routeParams }) {\n return <>Product {routeParams.productId}</>\n}\n",
);
write('product/pages/index/+route.js', "export default '/product/@productId'\n");
cg = await CodeGraph.init(dir, { index: true });
expect(
routes()
.map((n) => n.name)
.sort(),
).toEqual(['/about', '/product/:productId']);
const roots = routeRoots(cg, routes());
expect(
routes()
.map((n) => [n.name, roots.get(n.id)!.node.name, roots.get(n.id)!.node.filePath])
.sort(),
).toEqual([
['/about', 'Page', 'pages/about/+Page.tsx'],
['/product/:productId', 'Page', 'product/pages/index/+Page.jsx'],
]);
});
it('uses whole-root filesystem conventions and keeps parent pages distinct from layouts', async () => {
setup();
for (const [folder, name] of [
['pages/index', 'Home'],
['src/pages/parent', 'Parent'],
['src/pages/parent/child', 'Child'],
['domain/pages/(shop)/index/@id', 'Item'],
['src/index/pages/renderer/Foo.bar', 'Dot'],
['pages/docs/catchall', 'CatchAll'],
])
page(folder!, name!);
write('pages/parent/+Layout.tsx', 'export default function Layout(){return <p/>}');
write('pages/docs/catchall/+route.ts', `export default '/docs/*';`);
write('pages/nothing/Page.tsx', 'export default function NotPlus(){return <p/>}');
cg = await CodeGraph.init(dir, { index: true });
expect(
routes()
.map((n) => n.name)
.sort(),
).toEqual(['/', '/Foo.bar', '/docs/*', '/domain/:id', '/parent', '/parent/child']);
});
it('uses the nearest inherited route and never falls back through a dynamic override', async () => {
setup();
page('pages/blog/post', 'Post');
page('pages/blog/other', 'Other');
write('pages/blog/+route.ts', `export default '/inherited';`);
write('pages/blog/post/+route.ts', `export default '/specific/@id';`);
cg = await CodeGraph.init(dir, { index: true });
expect(
routes()
.map((n) => n.name)
.sort(),
).toEqual(['/inherited', '/specific/:id']);
write('pages/blog/+route.ts', `export default ctx=>ctx.urlPathname;`);
await cg.sync();
expect(routes().map((n) => n.name)).toEqual(['/specific/:id']);
write('pages/blog/post/+meta.ts', 'export default dynamic;');
await cg.sync();
expect(routes()).toEqual([]);
});
it('links a multiline default function value to its actual symbol', async () => {
setup();
write('pages/index/+Page.tsx', 'const Page =\n () => <p/>;\nexport default Page;');
cg = await CodeGraph.init(dir, { index: true });
expect(routeRoots(cg, routes()).get(routes()[0]!.id)?.node.name).toBe('Page');
});
it('supports named Page exports and route aliases', async () => {
setup();
write('pages/one/+Page.tsx', 'function Screen(){return <p/>};export {Screen as Page};');
write('pages/one/+route.ts', `const value='/one/@id';export {value as route};`);
write('pages/two/+Page.tsx', 'export const Page=()=> <p/>;');
write('pages/two/+route.ts', `export const route='/two';`);
cg = await CodeGraph.init(dir, { index: true });
expect(
routes()
.map((n) => n.name)
.sort(),
).toEqual(['/one/:id', '/two']);
expect(
routeRoots(cg, routes()).get(routes().find((n) => n.name === '/one/:id')!.id)!.node.name,
).toBe('Screen');
});
it.each([
`export default pageContext=>({match:pageContext.urlPathname==='/x'});`,
`export default prefix + '/x';`,
`export {default} from './other';`,
`export default '/one';export const route='/two';`,
`function route(){};export type {route};`,
])('does not fall back when a route override is unsupported (%s)', async (source) => {
setup();
page('pages/guessed', 'Guessed');
write('pages/guessed/+route.ts', source);
cg = await CodeGraph.init(dir, { index: true });
expect(routes()).toEqual([]);
});
it('uses inheritance locations rather than normalized URLs when suppressing configs', async () => {
setup();
page('src/pages/a', 'A');
page('src/admin/pages/b', 'B');
page('pages/c', 'C');
write('src/pages/+config.ts', `export default {filesystemRoutingRoot:'/custom'};`);
cg = await CodeGraph.init(dir, { index: true });
expect(routes().map((n) => n.name)).toEqual(['/c']);
write('pages/+config.ts', `export default {route:'/root'};`);
await cg.sync();
expect(routes()).toEqual([]);
});
it('accepts canonical vike-react rendering config but excludes unknown extensions and configured pages', async () => {
setup();
page('pages/one', 'One');
page('pages/two', 'Two');
write(
'pages/+config.ts',
`import vikeReact from 'vike-react/config';export default {extends:vikeReact,ssr:true,title:'Site'};`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(
routes()
.map((n) => n.name)
.sort(),
).toEqual(['/one', '/two']);
write(
'pages/+config.ts',
`import vikeReact from 'vike-react/config';export default {extends:[vikeReact],ssr:false};`,
);
await cg.sync();
expect(routes()).toHaveLength(2);
write('pages/one/+config.ts', `import other from './custom';export default {extends:other};`);
await cg.sync();
expect(routes()).toEqual([]);
fs.unlinkSync(path.join(dir, 'pages/one/+config.ts'));
write('pages/two/+config.ts', `export default {Page:Other};`);
await cg.sync();
expect(routes().map((n) => n.name)).toEqual(['/one']);
});
it.each([
`export default {extends:[{onBeforeRoute:()=>({pageContext:{urlLogical:'/changed'}})}]};`,
`const hidden={onBeforeRoute:()=>({})};export default {...hidden};`,
`export default {meta:custom};`,
`export default {Page:Other,extends:[{onBeforeRoute:()=>({})}]};`,
`const config={};Object.assign(config,{onBeforeRoute:()=>({})});export default config;`,
`import vikeReact from 'vike-react/config';Object.assign(vikeReact,{onBeforeRoute:()=>({})});export default {extends:vikeReact};`,
])('unknown config cannot hide a global routing hook (%s)', async (source) => {
setup();
page('pages/one', 'One');
page('pages/two', 'Two');
write('pages/one/+config.ts', source);
cg = await CodeGraph.init(dir, { index: true });
expect(routes()).toEqual([]);
});
it('treats onBeforeRoute as global and removes stale paths', async () => {
setup();
page('pages/one', 'One');
page('src/pages/two', 'Two');
cg = await CodeGraph.init(dir, { index: true });
expect(routes()).toHaveLength(2);
write(
'src/pages/+onBeforeRoute.ts',
'export function onBeforeRoute(){return {pageContext:{}}}',
);
await cg.sync({ paths: ['src/pages/+onBeforeRoute.ts'] });
expect(routes()).toEqual([]);
fs.unlinkSync(path.join(dir, 'src/pages/+onBeforeRoute.ts'));
await cg.sync();
expect(routes()).toHaveLength(2);
write('pages/+config.ts', 'export default {onBeforeRoute:custom};');
await cg.sync();
expect(routes()).toEqual([]);
});
it('excludes ambiguous, type-only, reassigned and re-exported page targets', async () => {
setup();
write(
'pages/ambiguous/+Page.tsx',
'export default function Default(){return <p/>}export function Page(){return <p/>}',
);
write('pages/typed/+Page.tsx', 'function Page(){return <p/>}export type {Page};');
write('pages/mutated/+Page.tsx', 'export function Page(){return <p/>}Page=Other;');
write('pages/reexport/+Page.tsx', `export {default} from './real';`);
write('pages/decoy/+Page.tsx', 'function Decoy(){return <p/>}export default 1;');
cg = await CodeGraph.init(dir, { index: true });
expect(routes()).toEqual([]);
});
it.each([
`import vike from 'other';export default {plugins:[vike()]};`,
`import vike from 'vike/plugin';export default {root:'other',plugins:[vike()]};`,
`import vike from 'vike/plugin';export default {plugins:[vike({pages:['custom']})]};`,
`import vike from 'vike/plugin';export default {plugins:[vike()],...config};`,
])('requires supported plugin registration (%s)', async (source) => {
setup();
page('pages/index', 'Home');
write('vite.config.js', source);
cg = await CodeGraph.init(dir, { index: true });
expect(routes()).toEqual([]);
});
it('syncs route and target changes after reopening, including scoped config changes', async () => {
setup();
page('pages/index', 'Home');
cg = await CodeGraph.init(dir, { index: true });
cg.close();
cg = await CodeGraph.open(dir);
write('pages/index/+route.ts', `export default '/moved/@id';`);
await cg.sync({ paths: ['pages/index/+route.ts'] });
expect(routes().map((n) => n.name)).toEqual(['/moved/:id']);
write('pages/index/+route.ts', 'export default dynamic;');
await cg.sync();
expect(routes()).toEqual([]);
fs.unlinkSync(path.join(dir, 'pages/index/+route.ts'));
await cg.sync();
expect(routes().map((n) => n.name)).toEqual(['/']);
page('pages/index', 'Renamed');
await cg.sync();
expect(routeRoots(cg, routes()).get(routes()[0]!.id)!.node.name).toBe('Renamed');
write('vite.config.js', 'export default {}');
await cg.sync({ paths: ['vite.config.js'] });
expect(routes()).toEqual([]);
});
it.each([false, true])('detects newly introduced Vike (scoped=%s)', async (scoped) => {
setup();
write('package.json', '{}');
page('pages/index', 'Home');
cg = await CodeGraph.init(dir, { index: true });
expect(routes()).toEqual([]);
write('package.json', JSON.stringify({ dependencies: { vike: '0.4.266' } }));
write('vite.config.js', config + '\n');
await cg.sync(scoped ? { paths: ['vite.config.js'] } : undefined);
expect(routes().map((n) => n.name)).toEqual(['/']);
});
it.runIf(fs.existsSync(path.resolve('dist/index.js')))(
'uses fresh compiled workers for exact page roots',
() => {
setup();
page('pages/index', 'Home');
const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))});(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});const r=cg.getNodesByKind('route').find(r=>r.id.startsWith('route:vike:'));console.log(JSON.stringify([r?.name,r&&cg.getOutgoingEdges(r.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_PARSE_WORKERS: '2',
CODEGRAPH_PARALLEL_RESOLVE_MIN: '1',
CODEGRAPH_RESOLVE_WORKERS: '2',
},
});
expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', ['Home']]);
},
);
});
4 changes: 2 additions & 2 deletions docs/design/PLAN-application-router-coverage.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Status: 10/13 — Qwik City validated; publishing step 10
Status: 11/13 — Vike validated; publishing step 11

- [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.
Expand All @@ -10,7 +10,7 @@ Status: 10/13 — Qwik City validated; publishing step 10
- [x] 8 Solid Router — registered JSX/config and static lazy imports bind exact components; nested bases/splats, mutations, scoped introduction and fresh workers pass; build passes, 115 WASM focused/control tests pass, full native suite 4,523 pass / 46 skip; independent review clear.
- [x] 9 SolidStart — pinned default pages and HTTP handlers bind exact targets; file hierarchy, page/API coexistence, scoped/reopened sync and fresh workers pass; build passes, 115 WASM focused/control tests pass, full native suite 4,542 pass / 46 skip; independent review clear.
- [x] 10 Qwik City — default pages and method exports bind exact roots, including anonymous components and named/anonymous callback calls; config/scoped/reopened sync and fresh workers pass; build passes, 112 WASM focused/control tests pass, full native suite 4,562 pass / 46 skip; independent review clear.
- [ ] 11 Vike — default `+Page` conventions and literal `+route` overrides. Gate: shared proof, parameters, exact component links, and no fallback route when an unsupported override changes routing.
- [x] 11 Vike — default `+Page` conventions and inherited literal `+route` overrides bind exact components; config/scoped/reopened sync and fresh workers pass; build passes, 126 WASM focused/control tests pass, full native suite 4,592 pass / 46 skip; independent review clear.
- [ ] 12 Waku filesystem routes — default pages, parameters, and layout exclusions for a pinned version. Gate: shared proof and `_root`/`_layout`/`_slices` controls.
- [ ] 13 Waku programmatic routes — literal `createPage` declarations within the documented `createPages` registration. Gate: shared proof, async registration syntax without executing it, and computed path negatives.

Expand Down
3 changes: 3 additions & 0 deletions docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ guessed.
| Solid Router | `frameworks/solid-router.ts` | — | `solid-router.test.ts` | pinned 0.16.3 README lazy example; exact component roots, nested paths and fresh workers |
| SolidStart | `frameworks/solid-start.ts` | — | `solid-start.test.ts` | pinned 2.0.4 About/API fixtures; page/API coexistence, file hierarchy and config sync |
| Qwik City | `frameworks/qwik-city.ts` | — | `qwik-city.test.ts` | pinned 1.20.0 page/API sources; anonymous component roots, body-call ownership, sync and workers |
| Vike | `frameworks/vike.ts` | — | `vike-routes.test.ts` | pinned 0.4.266 About/product examples; nearest route inheritance, config guards, sync and workers |

Vike recognizes local named default or `Page` ES-module exports in JS/TS `+Page` files when a default-root Vite config registers option-free `vike()`. Filesystem URLs remove complete `pages`, `src`, `index`, `renderer` and group segments; configuration inheritance removes only `pages`/`renderer`. The nearest inherited literal `+route` wins and `@` parameters become named graph parameters. [About source](https://github.com/vikejs/vike/blob/715c15d11be196caa69159b929ae09697f3ce734/examples/react-minimal/pages/about/%2BPage.tsx), [product override](https://github.com/vikejs/vike/blob/715c15d11be196caa69159b929ae09697f3ce734/examples/file-structure-domain-driven/product/pages/index/%2Broute.js). Canonical `extends: vikeReact` and literal arrays from `vike-react/config` are accepted; its [0.6.26 config](https://github.com/vikejs/vike-react/blob/b51cdccaba8cda8cb951f66edc61e359f77ae239/packages/vike-react/src/config.ts) affects rendering rather than paths. Dynamic/ambiguous route overrides, configured Page/root overrides, unknown extensions and metadata do not receive guessed paths; `onBeforeRoute` blocks app-wide inference. Custom roots/plugin options, inherited Page-only targets, anonymous/wrapped/re-exported components and non-JS/TS template files are unsupported. No navigation is inferred.

Qwik City recognizes default `src/routes/**/index.{js,jsx,ts,tsx}` with option-free `qwikCity()` in literal Vite configuration, including direct-return synchronous/async config callbacks. Pages additionally require imported `QwikCityProvider`/`RouterOutlet` in the default root component. Named functions, named `component$` bindings and anonymous default `component$` calls have exact roots; anonymous component symbols own only their callback's existing file-level references. [Official page](https://github.com/QwikDev/qwik/blob/971465f941e44e5adf2b2c2e44566b590d0990d8/starters/apps/qwikcity-test/src/routes/issue2441/abc.page/index.tsx), [API fixture](https://github.com/QwikDev/qwik/blob/971465f941e44e5adf2b2c2e44566b590d0990d8/packages/docs/src/routes/demo/qwikcity/middleware/json/index.tsx). Groups, legacy `__` directories, dynamic/mixed parameters, catchalls and default trailing slashes follow 1.20.0. Index method exports create endpoints without implicit HEAD. Layout methods and `onRequest` remain middleware, not independent endpoints. Custom roots/options, route rewrites, layout-override index names, optional parameters, Markdown/MDX, re-exports and arbitrary component wrappers are unsupported. No navigation is inferred.

Expand Down
Loading