diff --git a/examples/basic/.gitignore b/examples/basic/.gitignore
index 2af291991..18d9cbc46 100644
--- a/examples/basic/.gitignore
+++ b/examples/basic/.gitignore
@@ -10,6 +10,7 @@
# shuvi.js
/.shuvi/
+build/
# production
/dist/
diff --git a/examples/basic/package.json b/examples/basic/package.json
index 33819a904..cc6bc9b9c 100644
--- a/examples/basic/package.json
+++ b/examples/basic/package.json
@@ -1,17 +1,18 @@
{
"private": true,
+ "name": "example-basic",
"scripts": {
"dev": "shuvi dev",
"build": "shuvi build",
"start": "shuvi start"
},
"dependencies": {
- "shuvi": "1.0.0-rc.5"
+ "shuvi": "workspace:*"
},
"devDependencies": {
"@types/node": "^18.6.1",
- "@types/react": "^18.0.17",
- "@types/react-dom": "^18.0.6",
+ "@types/react": "18.0.9",
+ "@types/react-dom": "18.0.6",
"typescript": "^4.7.4"
}
}
diff --git a/examples/basic/src/routes/$/layout.tsx b/examples/basic/src/routes/$/layout.tsx
new file mode 100644
index 000000000..32a7947fc
--- /dev/null
+++ b/examples/basic/src/routes/$/layout.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { RouterView } from '@shuvi/runtime';
+
+export default function Layout() {
+ return (
+
+ /$/layout.tsx
+
+
+ );
+}
+
+export const loader = async () => {
+ console.log('[demo] /$/layout loader');
+ return {};
+};
diff --git a/examples/basic/src/routes/$/page.tsx b/examples/basic/src/routes/$/page.tsx
new file mode 100644
index 000000000..8647b4862
--- /dev/null
+++ b/examples/basic/src/routes/$/page.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+
+export default function Home() {
+ return <>/$/pages.tsx>;
+}
+export const loader = async () => {
+ console.log('[demo] /$/page loader');
+ return {};
+};
diff --git a/examples/basic/src/routes/$symbol/calc/layout.tsx b/examples/basic/src/routes/$symbol/calc/layout.tsx
new file mode 100644
index 000000000..fd3c7759e
--- /dev/null
+++ b/examples/basic/src/routes/$symbol/calc/layout.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { RouterView } from '@shuvi/runtime';
+
+export default function Layout() {
+ return (
+
+ /calc/layout.tsx
+
+
+ );
+}
+
+export const loader = async () => {
+ console.log('[demo] /calc/layout loader');
+ return {};
+};
diff --git a/examples/basic/src/routes/$symbol/calc/page.tsx b/examples/basic/src/routes/$symbol/calc/page.tsx
new file mode 100644
index 000000000..a12ecdecb
--- /dev/null
+++ b/examples/basic/src/routes/$symbol/calc/page.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+
+export default function Home() {
+ return <>/calc/pages.tsx>;
+}
+export const loader = async () => {
+ console.log('[demo] /calc/page loader');
+ return {};
+};
diff --git a/examples/basic/src/routes/home/layout.tsx b/examples/basic/src/routes/home/layout.tsx
new file mode 100644
index 000000000..90b665dbf
--- /dev/null
+++ b/examples/basic/src/routes/home/layout.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { RouterView } from '@shuvi/runtime';
+
+export default function Layout() {
+ return (
+
+ /home/layout.tsx
+
+
+ );
+}
+
+export const loader = async () => {
+ console.log('[demo] /home/layout loader');
+ return {};
+};
diff --git a/examples/basic/src/routes/home/page.tsx b/examples/basic/src/routes/home/page.tsx
new file mode 100644
index 000000000..18cbd12a1
--- /dev/null
+++ b/examples/basic/src/routes/home/page.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+
+export default function Home() {
+ return <>/home/pages.tsx>;
+}
+export const loader = async () => {
+ console.log('[demo] /home/page loader');
+ return {};
+};
diff --git a/packages/router/src/__tests__/createRoutesFromArray.test.ts b/packages/router/src/__tests__/createRoutesFromArray.test.ts
index f7fe00164..4467255e9 100644
--- a/packages/router/src/__tests__/createRoutesFromArray.test.ts
+++ b/packages/router/src/__tests__/createRoutesFromArray.test.ts
@@ -47,4 +47,20 @@ describe('createRoutesFromArray', () => {
{ caseSensitive: false, path: '/' }
]);
});
+
+ it('allow children with empty path', () => {
+ expect(
+ createRoutesFromArray([
+ { path: '/' },
+ { path: '/*', children: [{ path: '' }] }
+ ])
+ ).toStrictEqual([
+ { path: '/', caseSensitive: false },
+ {
+ path: '/*',
+ children: [{ path: '', caseSensitive: false }],
+ caseSensitive: false
+ }
+ ]);
+ });
});
diff --git a/packages/router/src/__tests__/pathRanking.test.ts b/packages/router/src/__tests__/pathRanking.test.ts
index 76b35b663..f2fff652e 100644
--- a/packages/router/src/__tests__/pathRanking.test.ts
+++ b/packages/router/src/__tests__/pathRanking.test.ts
@@ -4,6 +4,56 @@ import { tokensToParser, comparePathParserScore } from '../pathParserRanker';
type PathParserOptions = Parameters[1];
+const LeafPageRoute = {
+ path: ''
+};
+
+describe('tokensToParser', () => {
+ it('scores is correct', () => {
+ expect(tokensToParser(tokenizePath('/')).score).toStrictEqual([[80]]);
+ expect(tokensToParser(tokenizePath('/home')).score).toStrictEqual([[80]]);
+ expect(tokensToParser(tokenizePath('/:symbol')).score).toStrictEqual([
+ [60]
+ ]);
+ expect(tokensToParser(tokenizePath('/:symbol/calc')).score).toStrictEqual([
+ [60],
+ [80]
+ ]);
+ expect(tokensToParser(tokenizePath('/*')).score).toStrictEqual([[19]]);
+ });
+
+ it('patch score for pageBranch which ends with empty route', () => {
+ expect(
+ tokensToParser(
+ tokenizePath('/home'),
+ {},
+ { routes: [{ path: '/home' }, LeafPageRoute] }
+ ).score
+ ).toStrictEqual([[80], [0.1]]);
+ expect(
+ tokensToParser(
+ tokenizePath('/:symbol'),
+ {},
+ { routes: [{ path: '/:symbol' }, LeafPageRoute] }
+ ).score
+ ).toStrictEqual([[60], [0.1]]);
+ expect(
+ tokensToParser(
+ tokenizePath('/:symbol/calc'),
+ {},
+ { routes: [{ path: '/:symbol' }, { path: '/calc' }, LeafPageRoute] }
+ ).score
+ ).toStrictEqual([[60], [80], [0.1]]);
+ expect(
+ tokensToParser(
+ tokenizePath('/*'),
+ {},
+ { routes: [{ path: '/*' }, LeafPageRoute] }
+ ).score
+ ).toStrictEqual([[19], [0.1]]);
+ });
+});
+
describe('Path ranking', () => {
describe('comparePathParser', () => {
function compare(a: number[][], b: number[][]): number {
@@ -241,6 +291,10 @@ describe('Path ranking', () => {
});
});
+ it('catchAll /* should be the last one', () => {
+ checkPathOrder(['/home', '/:symbol/calc', '/*']);
+ });
+
it('handles sub segments', () => {
checkPathOrder([
'/a/_2_',
diff --git a/packages/router/src/createRoutesFromArray.ts b/packages/router/src/createRoutesFromArray.ts
index 9e2bdf8b2..b2f35d9f3 100644
--- a/packages/router/src/createRoutesFromArray.ts
+++ b/packages/router/src/createRoutesFromArray.ts
@@ -3,16 +3,24 @@ import { IPartialRouteRecord, IRouteRecord } from './types';
export function createRoutesFromArray<
T extends IPartialRouteRecord,
U extends IRouteRecord
->(array: T[]): U[] {
+>(
+ array: T[],
+ /**
+ * allowEmptyPath: allow empty path for children
+ * A pageBranch could be ended with a page route with empty path.
+ */
+ allowEmptyPath = false
+): U[] {
return array.map(partialRoute => {
+ const defaultPath = allowEmptyPath ? '' : '/';
let route: U = {
...(partialRoute as any),
caseSensitive: !!partialRoute.caseSensitive,
- path: partialRoute.path || '/'
+ path: partialRoute.path || defaultPath
};
if (partialRoute.children) {
- route.children = createRoutesFromArray(partialRoute.children);
+ route.children = createRoutesFromArray(partialRoute.children, true);
}
return route;
diff --git a/packages/router/src/matchRoutes.ts b/packages/router/src/matchRoutes.ts
index 0fc7e308c..219736a3e 100644
--- a/packages/router/src/matchRoutes.ts
+++ b/packages/router/src/matchRoutes.ts
@@ -62,9 +62,9 @@ export function rankRouteBranches(
}
const normalizedPaths = branches.map((branch, index) => {
- const [path] = branch;
+ const [path, routes] = branch;
return {
- ...tokensToParser(tokenizePath(path)),
+ ...tokensToParser(tokenizePath(path), undefined, { routes }),
path,
index
};
@@ -89,7 +89,15 @@ function flattenRoutes(
parentIndexes: number[] = []
): IRouteBranch[] {
routes.forEach((route, index) => {
- let path = joinPaths([parentPath, route.path]);
+ let path;
+ if (route.path === '') {
+ /**
+ * An empty path is allowed, don't append a slash.
+ */
+ path = parentPath;
+ } else {
+ path = joinPaths([parentPath, route.path]);
+ }
let routes = parentRoutes.concat(route);
let indexes = parentIndexes.concat(index);
diff --git a/packages/router/src/pathParserRanker.ts b/packages/router/src/pathParserRanker.ts
index e4e0a5125..fc620929f 100644
--- a/packages/router/src/pathParserRanker.ts
+++ b/packages/router/src/pathParserRanker.ts
@@ -1,4 +1,5 @@
import { Token, TokenType, TokenCatchAll } from './pathTokenizer';
+import { IRouteRecord } from './types';
type PathParams = Record;
@@ -113,7 +114,8 @@ const enum PathScore {
BonusOptional = -0.8 * _multiplier, // /:w? or /:w*
// these two have to be under 0.1 so a strict /:page is still lower than /:a-:b
BonusStrict = 0.07 * _multiplier, // when options strict: true is passed, as the regex omits \/?
- BonusCaseSensitive = 0.025 * _multiplier // when options strict: true is passed, as the regex omits \/?
+ BonusCaseSensitive = 0.025 * _multiplier, // when options strict: true is passed, as the regex omits \/?
+ BonusEmptyStringPath = 0.01 * _multiplier // when the path is an empty string
}
// Special Regex characters that must be escaped in static tokens
@@ -128,7 +130,8 @@ const REGEX_CHARS_RE = /[.+*?^${}()[\]/\\]/g;
*/
export function tokensToParser(
segments: Array,
- extraOptions?: _PathParserOptions
+ extraOptions?: _PathParserOptions,
+ branchInfo?: { routes: IRouteRecord[] }
): PathParser {
const options = Object.assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions);
@@ -354,6 +357,18 @@ export function tokensToParser(
return path;
}
+ /**
+ * To make sure the score of pageBranch is always higher priority
+ * than the layoutRoute.
+ * We append a bonus score if the last route is an empty path.
+ */
+ if (branchInfo?.routes) {
+ const lastRoutePath = branchInfo.routes[branchInfo.routes.length - 1]?.path;
+ if (lastRoutePath === '') {
+ score.push([PathScore.BonusEmptyStringPath]);
+ }
+ }
+
return {
re,
score,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index af12d6b64..20a360386 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -126,6 +126,25 @@ importers:
specifier: 4.8.4
version: 4.8.4
+ examples/basic:
+ dependencies:
+ shuvi:
+ specifier: workspace:*
+ version: link:../../packages/shuvi
+ devDependencies:
+ '@types/node':
+ specifier: ^18.6.1
+ version: 18.19.34
+ '@types/react':
+ specifier: 18.0.9
+ version: 18.0.9
+ '@types/react-dom':
+ specifier: 18.0.6
+ version: 18.0.6
+ typescript:
+ specifier: ^4.7.4
+ version: 4.8.4
+
packages/compiler:
dependencies:
'@napi-rs/triples':
@@ -1007,6 +1026,12 @@ importers:
specifier: workspace:*
version: link:../../../packages/shuvi
+ test/fixtures/catch-all:
+ dependencies:
+ shuvi:
+ specifier: workspace:*
+ version: link:../../../packages/shuvi
+
test/fixtures/compiler:
dependencies:
'@emotion/react':
@@ -5015,7 +5040,7 @@ packages:
dependencies:
'@types/http-cache-semantics': 4.0.1
'@types/keyv': 3.1.4
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
'@types/responselike': 1.0.0
dev: true
@@ -5093,7 +5118,7 @@ packages:
/@types/fs-extra@9.0.13:
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
dependencies:
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
dev: true
/@types/glob@7.2.0:
@@ -5116,7 +5141,7 @@ packages:
/@types/http-proxy@1.17.4:
resolution: {integrity: sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==}
dependencies:
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
dev: false
/@types/istanbul-lib-coverage@2.0.1:
@@ -5185,7 +5210,7 @@ packages:
/@types/keyv@3.1.4:
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
dependencies:
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
dev: true
/@types/loader-utils@2.0.1:
@@ -5229,7 +5254,6 @@ packages:
/@types/node@12.12.7:
resolution: {integrity: sha512-E6Zn0rffhgd130zbCbAr/JdXfXkoOUFAKNs/rF8qnafSJ8KYaA/j3oz7dcwal+lYjLA7xvdd5J4wdYpCTlP8+w==}
- dev: true
/@types/node@17.0.23:
resolution: {integrity: sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==}
@@ -5237,6 +5261,13 @@ packages:
/@types/node@18.0.6:
resolution: {integrity: sha512-/xUq6H2aQm261exT6iZTMifUySEt4GR5KX8eYyY+C4MSNPqSh9oNIP7tz2GLKTlFaiBbgZNxffoR3CVRG+cljw==}
+ dev: true
+
+ /@types/node@18.19.34:
+ resolution: {integrity: sha512-eXF4pfBNV5DAMKGbI02NnDtWrQ40hAN558/2vvS4gMpMIxaf6JmD7YjnZbq0Q9TDSSkKBamime8ewRoomHdt4g==}
+ dependencies:
+ undici-types: 5.26.5
+ dev: true
/@types/normalize-package-data@2.4.1:
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
@@ -5313,7 +5344,7 @@ packages:
/@types/responselike@1.0.0:
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
dependencies:
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
dev: true
/@types/rimraf@3.0.0:
@@ -5327,7 +5358,7 @@ packages:
resolution: {integrity: sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ==}
dependencies:
'@types/glob': 7.2.0
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
dev: true
/@types/scheduler@0.16.2:
@@ -5337,14 +5368,14 @@ packages:
/@types/semver@7.3.1:
resolution: {integrity: sha512-ooD/FJ8EuwlDKOI6D9HWxgIgJjMg2cuziXm/42npDC8y4NjxplBUn9loewZiBNCt44450lHAU0OSb51/UqXeag==}
dependencies:
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
dev: true
/@types/send@0.14.5:
resolution: {integrity: sha512-0mwoiK3DXXBu0GIfo+jBv4Wo5s1AcsxdpdwNUtflKm99VEMvmBPJ+/NBNRZy2R5JEYfWL/u4nAHuTUTA3wFecQ==}
dependencies:
'@types/mime': 2.0.1
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
dev: true
/@types/source-list-map@0.1.2:
@@ -5410,7 +5441,7 @@ packages:
resolution: {integrity: sha512-tWkdf9nO0zFgAY/EumUKwrDUhraHKDqCPhwfFR/R8l0qnPdgb9le0Gzhvb7uzVpouuDGBgiE//ZdY+5jcZy2TA==}
dependencies:
'@types/anymatch': 1.3.1
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
'@types/tapable': 1.0.4
'@types/uglify-js': 3.0.4
'@types/webpack-sources': 0.1.5
@@ -11020,7 +11051,7 @@ packages:
'@jest/fake-timers': 28.1.1
'@jest/types': 28.1.1
'@types/jsdom': 16.2.14
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
jest-mock: 28.1.1
jest-util: 28.1.1
jsdom: 19.0.0
@@ -11296,7 +11327,7 @@ packages:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
dependencies:
- '@types/node': 18.0.6
+ '@types/node': 12.12.7
merge-stream: 2.0.0
supports-color: 8.1.1
dev: false
@@ -16295,6 +16326,10 @@ packages:
through: 2.3.8
dev: true
+ /undici-types@5.26.5:
+ resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+ dev: true
+
/unicode-canonical-property-names-ecmascript@1.0.4:
resolution: {integrity: sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==}
engines: {node: '>=4'}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 1c4b3a8a0..fbb7c2c6d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,3 +4,4 @@ packages:
- 'test/fixtures/*'
- 'test/packages/*'
- 'test/utils'
+ - 'examples/*'
diff --git a/test/e2e/catch-all.test.ts b/test/e2e/catch-all.test.ts
new file mode 100644
index 000000000..5db3182e9
--- /dev/null
+++ b/test/e2e/catch-all.test.ts
@@ -0,0 +1,52 @@
+import { AppCtx, Page, devFixture } from '../utils';
+
+let ctx: AppCtx;
+let page: Page;
+
+jest.setTimeout(5 * 60 * 1000);
+
+describe('catch-all', () => {
+ beforeAll(async () => {
+ ctx = await devFixture('catch-all');
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ afterEach(async () => {
+ await page.close();
+ });
+
+ test('should exact match home page', async () => {
+ page = await ctx.browser.page(ctx.url('/home'));
+ await page.waitForSelector('[id="home-page"]');
+ expect(await page.$text('[id="global-layout"]')).toBe('/layout.js');
+ expect(await page.$text('[id="home-page"]')).toBe('/home/page.js');
+ expect(await page.$text('[id="home-layout"]')).toBe('/home/layout.js');
+ });
+
+ test('should match catchAll', async () => {
+ page = await ctx.browser.page(ctx.url('/other'));
+ await page.waitForSelector('[id="catchAll-page"]');
+ expect(await page.$text('[id="global-layout"]')).toBe('/layout.js');
+ expect(await page.$text('[id="catchAll-page"]')).toBe('/$/page.js');
+ expect(await page.$text('[id="catchAll-layout"]')).toBe('/$/layout.js');
+ });
+
+ test('should match /:symbol/calc', async () => {
+ page = await ctx.browser.page(ctx.url('/symbol/calc'));
+ await page.waitForSelector('[id="calc-page"]');
+ expect(await page.$text('[id="global-layout"]')).toBe('/layout.js');
+ expect(await page.$text('[id="calc-page"]')).toBe('/$symbol/calc/page.js');
+ expect(await page.$text('[id="calc-layout"]')).toBe(
+ '/$symbol/calc/layout.js'
+ );
+ });
+
+ test('/symbol/calc2 should match catchAll', async () => {
+ page = await ctx.browser.page(ctx.url('/symbol/calc2'));
+ await page.waitForSelector('[id="catchAll-page"]');
+ expect(await page.$text('[id="global-layout"]')).toBe('/layout.js');
+ expect(await page.$text('[id="catchAll-page"]')).toBe('/$/page.js');
+ expect(await page.$text('[id="catchAll-layout"]')).toBe('/$/layout.js');
+ });
+});
diff --git a/test/fixtures/catch-all/package.json b/test/fixtures/catch-all/package.json
new file mode 100644
index 000000000..bf450f976
--- /dev/null
+++ b/test/fixtures/catch-all/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "fixture-catch-all",
+ "dependencies": {
+ "shuvi": "workspace:*"
+ }
+}
diff --git a/test/fixtures/catch-all/shuvi.config.js b/test/fixtures/catch-all/shuvi.config.js
new file mode 100644
index 000000000..8f9018e7a
--- /dev/null
+++ b/test/fixtures/catch-all/shuvi.config.js
@@ -0,0 +1,3 @@
+export default {
+ ssr: true
+};
diff --git a/test/fixtures/catch-all/src/routes/$/layout.js b/test/fixtures/catch-all/src/routes/$/layout.js
new file mode 100644
index 000000000..6b91715c2
--- /dev/null
+++ b/test/fixtures/catch-all/src/routes/$/layout.js
@@ -0,0 +1,10 @@
+import { RouterView } from '@shuvi/runtime';
+
+const Layout = () => (
+
+);
+
+export default Layout;
diff --git a/test/fixtures/catch-all/src/routes/$/page.js b/test/fixtures/catch-all/src/routes/$/page.js
new file mode 100644
index 000000000..345e54fea
--- /dev/null
+++ b/test/fixtures/catch-all/src/routes/$/page.js
@@ -0,0 +1,7 @@
+export default function Index() {
+ return (
+ <>
+ /$/page.js
+ >
+ );
+}
diff --git a/test/fixtures/catch-all/src/routes/$symbol/calc/layout.js b/test/fixtures/catch-all/src/routes/$symbol/calc/layout.js
new file mode 100644
index 000000000..888ec2312
--- /dev/null
+++ b/test/fixtures/catch-all/src/routes/$symbol/calc/layout.js
@@ -0,0 +1,10 @@
+import { RouterView } from '@shuvi/runtime';
+
+const Layout = () => (
+
+
/$symbol/calc/layout.js
+
+
+);
+
+export default Layout;
diff --git a/test/fixtures/catch-all/src/routes/$symbol/calc/page.js b/test/fixtures/catch-all/src/routes/$symbol/calc/page.js
new file mode 100644
index 000000000..b7bd64667
--- /dev/null
+++ b/test/fixtures/catch-all/src/routes/$symbol/calc/page.js
@@ -0,0 +1,7 @@
+export default function Index() {
+ return (
+ <>
+ /$symbol/calc/page.js
+ >
+ );
+}
diff --git a/test/fixtures/catch-all/src/routes/home/layout.js b/test/fixtures/catch-all/src/routes/home/layout.js
new file mode 100644
index 000000000..5a6fbafb3
--- /dev/null
+++ b/test/fixtures/catch-all/src/routes/home/layout.js
@@ -0,0 +1,10 @@
+import { RouterView } from '@shuvi/runtime';
+
+const Layout = () => (
+
+);
+
+export default Layout;
diff --git a/test/fixtures/catch-all/src/routes/home/page.js b/test/fixtures/catch-all/src/routes/home/page.js
new file mode 100644
index 000000000..b31c7c5c5
--- /dev/null
+++ b/test/fixtures/catch-all/src/routes/home/page.js
@@ -0,0 +1,7 @@
+export default function Index() {
+ return (
+ <>
+ /home/page.js
+ >
+ );
+}
diff --git a/test/fixtures/catch-all/src/routes/layout.js b/test/fixtures/catch-all/src/routes/layout.js
new file mode 100644
index 000000000..6be37a89f
--- /dev/null
+++ b/test/fixtures/catch-all/src/routes/layout.js
@@ -0,0 +1,10 @@
+import { RouterView } from '@shuvi/runtime';
+
+const GlobalLayout = () => (
+
+);
+
+export default GlobalLayout;