Repository: dai-shi/waku
Pull request #1227 (2025-02-13 04:47:27)

feat(create-pages): pathless routes or grouped routes
This will add support for grouped paths in createPages. resolves #977 

TODO

- [x] pages: Add failing test
- [x] pages: Implement support to pass the tests
- [x] layouts: Add failing test
- [x] layouts: Implement support to pass the tests
- [x] path typegen should remove the groups
- [x] root level group layouts do not yet work (e.g. `/(home)`)

vercel[bot]: [vc]: #Sd35rGhox7c74UDYTEQQPwLVjMg5Qcs1M6TYiE1dg2Q=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS80aTZ0c1NrZFhtNlRqRkdRVjRFZ2FEcW50UUsyIiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstdHlsZXJzYXlzaGktY3JlYXRlLXBhZ2VzLWdyb3Vwcy1kYWlzaGlzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **waku** | 🔄 Building ([Inspect](https://vercel.com/daishis-projects/waku/4i6tsSkdXm6TjFGQV4EgaDqntQK2)) | [Visit Preview](https://waku-git-fork-tylersayshi-create-pages-groups-daishis-projects.vercel.app) | Feb 13, 2025 4:47am |


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/dai-shi/waku/pr/1227/builds/587725) or the icon next to each commit SHA.
tylersayshi: @rmarscher I'd appreciate help stress testing this. The initial implementation here feels almost too simple (I'm expecting edge cases and whatnot)
rmarscher: > The initial implementation here feels almost too simple

Yeah, it seems correct to just strip the path out in the right places though. Did you test adjacent groups and routes with multiple groups?

/(home)/_layout.tsx
/(home)/index.tsx
/(about)/_layout.tsx
/(about)/about.tsx
/(about)/about/(me)/_layout.tsx
/(about)/about/(me)/me.tsx
/_layout.tsx

I saw you are already testing that it throws an error for conflicting routes. Looks good.
tylersayshi: > Yeah, it seems correct to just strip the path out in the right places though. Did you test adjacent groups and routes with multiple groups?

thanks for the suggestions! I added a couple todos from testing. The rest seemed good

Patch:
diff --git a/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
index 818d4c94..2447d881 100644
--- a/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
+++ b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from 'node:fs';
 import { SRC_ENTRIES, EXTENSIONS } from '../constants.js';
 import { joinPath } from '../utils/path.js';
 import { isIgnoredPath } from '../utils/fs-router.js';
+import { getGrouplessPath } from '../utils/create-pages.js';
 import * as swc from '@swc/core';
 
 const SRC_PAGES = 'pages';
@@ -162,13 +163,13 @@ export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
           } else if (filePath.endsWith('/index.tsx')) {
             const path = filePath.slice(0, -'/index.tsx'.length);
             fileInfo.push({
-              path: path || '/',
+              path: getGrouplessPath(path) || '/',
               src,
               hasGetConfig,
             });
           } else {
             fileInfo.push({
-              path: filePath.replace('.tsx', ''),
+              path: getGrouplessPath(filePath.replace('.tsx', '')),
               src,
               hasGetConfig,
             });
diff --git a/packages/waku/src/lib/utils/create-pages.ts b/packages/waku/src/lib/utils/create-pages.ts
new file mode 100644
index 00000000..b7391a81
--- /dev/null
+++ b/packages/waku/src/lib/utils/create-pages.ts
@@ -0,0 +1,10 @@
+/** Remove (group)s from path. Like /(group)/foo => /foo */
+export const getGrouplessPath = (path: string) => {
+  if (path.includes('(')) {
+    const withoutGroups = path
+      .split('/')
+      .filter((part) => !part.startsWith('('));
+    path = withoutGroups.length > 1 ? withoutGroups.join('/') : '/';
+  }
+  return path;
+};
diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts
index 62f05c30..4c7c84c9 100644
--- a/packages/waku/src/router/create-pages.ts
+++ b/packages/waku/src/router/create-pages.ts
@@ -10,6 +10,7 @@ import {
   pathSpecAsString,
   parseExactPath,
 } from '../lib/utils/path.js';
+import { getGrouplessPath } from '../lib/utils/create-pages.js';
 import type { PathSpec } from '../lib/utils/path.js';
 import type {
   AnyPage,
@@ -230,6 +231,9 @@ export const createPages = <
 ) => {
   let configured = false;
 
+  // layout lookups retain (group) path and pathMaps store without group
+  // paths are stored without groups to easily detect duplicates
+  const groupPathLookup = new Map<string, string>();
   const staticPathMap = new Map<
     string,
     { literalSpec: PathSpec; originalSpec?: PathSpec }
@@ -350,7 +354,7 @@ export const createPages = <
       const spec = parseExactPath(page.path);
       if (page.render === 'static') {
         staticPathMap.set(page.path, {
-          literalSpec: parseExactPath(page.path),
+          literalSpec: spec,
         });
         const id = joinPath(page.path, 'page').replace(/^\//, '');
         registerStaticComponent(id, page.component);
@@ -358,8 +362,14 @@ export const createPages = <
         dynamicPagePathMap.set(page.path, [spec, page.component]);
       }
     } else if (page.render === 'static' && numSlugs === 0) {
-      staticPathMap.set(page.path, { literalSpec: pathSpec });
-      const id = joinPath(page.path, 'page').replace(/^\//, '');
+      const pagePath = getGrouplessPath(page.path);
+      staticPathMap.set(pagePath, {
+        literalSpec: pathSpec,
+      });
+      const id = joinPath(pagePath, 'page').replace(/^\//, '');
+      if (pagePath !== page.path) {
+        groupPathLookup.set(pagePath, page.path);
+      }
       registerStaticComponent(id, page.component);
     } else if (
       page.render === 'static' &&
@@ -393,19 +403,32 @@ export const createPages = <
               break;
           }
         });
-        staticPathMap.set('/' + pathItems.join('/'), {
+        const definedPath = '/' + pathItems.join('/');
+        const pagePath = getGrouplessPath(definedPath);
+        staticPathMap.set(pagePath, {
           literalSpec: pathItems.map((name) => ({ type: 'literal', name })),
           originalSpec: pathSpec,
         });
+        if (pagePath !== definedPath) {
+          groupPathLookup.set(pagePath, definedPath);
+        }
         const id = joinPath(...pathItems, 'page');
         const WrappedComponent = (props: Record<string, unknown>) =>
           createElement(page.component as any, { ...props, ...mapping });
         registerStaticComponent(id, WrappedComponent);
       }
     } else if (page.render === 'dynamic' && numWildcards === 0) {
-      dynamicPagePathMap.set(page.path, [pathSpec, page.component]);
+      const pagePath = getGrouplessPath(page.path);
+      if (pagePath !== page.path) {
+        groupPathLookup.set(pagePath, page.path);
+      }
+      dynamicPagePathMap.set(pagePath, [pathSpec, page.component]);
     } else if (page.render === 'dynamic' && numWildcards === 1) {
-      wildcardPagePathMap.set(page.path, [pathSpec, page.component]);
+      const pagePath = getGrouplessPath(page.path);
+      if (pagePath !== page.path) {
+        groupPathLookup.set(pagePath, page.path);
+      }
+      wildcardPagePathMap.set(pagePath, [pathSpec, page.component]);
     } else {
       throw new Error('Invalid page configuration');
     }
@@ -509,7 +532,6 @@ export const createPages = <
         noSsr: boolean;
       }[] = [];
       const rootIsStatic = !rootItem || rootItem.render === 'static';
-
       for (const [path, { literalSpec, originalSpec }] of staticPathMap) {
         const noSsr = noSsrSet.has(literalSpec);
 
@@ -529,7 +551,7 @@ export const createPages = <
         };
 
         paths.push({
-          path: literalSpec,
+          path: literalSpec.filter((part) => !part.name?.startsWith('(')),
           ...(originalSpec && { pathPattern: originalSpec }),
           rootElement: { isStatic: rootIsStatic },
           routeElement: {
@@ -555,7 +577,7 @@ export const createPages = <
           [`page:${path}`]: { isStatic: false },
         };
         paths.push({
-          path: pathSpec,
+          path: pathSpec.filter((part) => !part.name?.startsWith('(')),
           rootElement: { isStatic: rootIsStatic },
           routeElement: { isStatic: true },
           elements,
@@ -578,7 +600,7 @@ export const createPages = <
           [`page:${path}`]: { isStatic: false },
         };
         paths.push({
-          path: pathSpec,
+          path: pathSpec.filter((part) => !part.name?.startsWith('(')),
           rootElement: { isStatic: rootIsStatic },
           routeElement: { isStatic: true },
           elements,
@@ -605,7 +627,8 @@ export const createPages = <
         throw new Error('Page not found: ' + path);
       }
 
-      const pathSpec = parsePathWithSlug(routePath);
+      const layoutMatchPath = groupPathLookup.get(routePath) ?? routePath;
+      const pathSpec = parsePathWithSlug(layoutMatchPath);
       const mapping = getPathMapping(pathSpec, path);
       const result: Record<string, unknown> = {
         [`page:${routePath}`]: createElement(
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a76f9c83..b4563aa1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -526,6 +526,37 @@ importers:
         specifier: ^5.7.3
         version: 5.7.3
 
+  e2e/fixtures/test-group:
+    dependencies:
+      react:
+        specifier: 19.0.0
+        version: 19.0.0
+      react-dom:
+        specifier: 19.0.0
+        version: 19.0.0(react@19.0.0)
+      react-error-boundary:
+        specifier: 5.0.0
+        version: 5.0.0(react@19.0.0)
+      react-server-dom-webpack:
+        specifier: 19.0.0
+        version: 19.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(webpack@5.98.0)
+      waku:
+        specifier: workspace:*
+        version: link:../../../packages/waku
+    devDependencies:
+      '@types/react':
+        specifier: ^19.0.10
+        version: 19.0.10
+      '@types/react-dom':
+        specifier: ^19.0.4
+        version: 19.0.4(@types/react@19.0.10)
+      server-only:
+        specifier: ^0.0.1
+        version: 0.0.1
+      typescript:
+        specifier: ^5.7.3
+        version: 5.7.3
+
   e2e/fixtures/use-router:
     dependencies:
       react:
@@ -1407,7 +1438,7 @@ importers:
         version: 7.4.3
       tsup:
         specifier: ^8.3.6
-        version: 8.3.6(@swc/core@1.10.16)(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0)
+        version: 8.3.6(@swc/core@1.10.16(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0)
       update-check:
         specifier: ^1.5.4
         version: 1.5.4
@@ -8802,7 +8833,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)):
+  eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0)(eslint@9.20.1(jiti@2.4.2)):
     dependencies:
       debug: 3.2.7
     optionalDependencies:
@@ -8824,7 +8855,7 @@ snapshots:
       doctrine: 2.1.0
       eslint: 9.20.1(jiti@2.4.2)
       eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2))
+      eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0)(eslint@9.20.1(jiti@2.4.2))
       hasown: 2.0.2
       is-core-module: 2.16.1
       is-glob: 4.0.3
@@ -11106,7 +11137,7 @@ snapshots:
 
   tslib@2.8.1: {}
 
-  tsup@8.3.6(@swc/core@1.10.16)(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0):
+  tsup@8.3.6(@swc/core@1.10.16(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0):
     dependencies:
       bundle-require: 5.1.0(esbuild@0.24.2)
       cac: 6.7.14


Test patch:
diff --git a/e2e/create-pages.spec.ts b/e2e/create-pages.spec.ts
index 5c35f023..d9031dc5 100644
--- a/e2e/create-pages.spec.ts
+++ b/e2e/create-pages.spec.ts
@@ -173,5 +173,23 @@ for (const mode of ['DEV', 'PRD'] as const) {
         page.getByRole('heading', { name: 'EXACTLY!!' }),
       ).toBeVisible();
     });
+
+    test('group', async ({ page }) => {
+      await page.goto(`http://localhost:${port}/test`);
+      await expect(
+        page.getByRole('heading', { name: 'Group Page' }),
+      ).toBeVisible();
+    });
+
+    test('group layout', async ({ page }) => {
+      await page.goto(`http://localhost:${port}/test`);
+      await expect(page.getByRole('link', { name: 'Home' })).toBeVisible();
+      await expect(
+        page.getByRole('heading', { name: '/(group) Layout' }),
+      ).toBeVisible();
+      await expect(
+        page.getByRole('heading', { name: '/test Layout' }),
+      ).not.toBeVisible();
+    });
   });
 }
diff --git a/e2e/fixtures/create-pages/src/entries.tsx b/e2e/fixtures/create-pages/src/entries.tsx
index b6df64f7..0f3034e3 100644
--- a/e2e/fixtures/create-pages/src/entries.tsx
+++ b/e2e/fixtures/create-pages/src/entries.tsx
@@ -161,6 +161,35 @@ const pages: ReturnType<typeof createPages> = createPages(
       component: () => <h1>EXACTLY!!</h1>,
     }),
 
+    createPage({
+      render: 'static',
+      path: '/(group)/test',
+      component: () => <h1>Group Page</h1>,
+    }),
+
+    // Should not show for /(group)/test
+    createLayout({
+      render: 'static',
+      path: '/test',
+      component: ({ children }) => (
+        <div>
+          <h2>/test Layout</h2>
+          {children}
+        </div>
+      ),
+    }),
+
+    createLayout({
+      render: 'static',
+      path: '/(group)',
+      component: ({ children }) => (
+        <div>
+          <h2>/(group) Layout</h2>
+          {children}
+        </div>
+      ),
+    }),
+
     createLayout({
       render: 'dynamic',
       path: '/dynamic',
diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts
index 92b0de81..307e461a 100644
--- a/packages/waku/tests/create-pages.test.ts
+++ b/packages/waku/tests/create-pages.test.ts
@@ -1581,3 +1581,159 @@ describe('createPages - exactPath', () => {
     }).rejects.toThrowError();
   });
 });
+
+describe('createPages - grouped paths', () => {
+  it('path with group', async () => {
+    const TestPage = () => null;
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/(group)/test',
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [{ name: 'test', type: 'literal' }],
+      },
+    ]);
+    const route = await handleRoute('/test', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual(['page:/test']);
+  });
+
+  it('path with nested group', async () => {
+    const TestPage = () => null;
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/(a)/test/(b)/foo',
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'page:/test/foo': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [
+          { name: 'test', type: 'literal' },
+          { name: 'foo', type: 'literal' },
+        ],
+      },
+    ]);
+    const route = await handleRoute('/test/foo', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual(['page:/test/foo']);
+  });
+
+  it('fails when group path collides with literal', async () => {
+    const TestPage = () => null;
+    createPages(async ({ createPage }) => [
+      createPage({
+        render: 'static',
+        path: '/(group)/test',
+        component: TestPage,
+      }),
+      createPage({
+        render: 'static',
+        path: '/test',
+        component: TestPage,
+      }),
+    ]);
+    const { getRouteConfig } = injectedFunctions();
+    await expect(getRouteConfig).rejects.toThrowError('Duplicated path: /test');
+  });
+
+  it('supports grouped path with layout', async () => {
+    const TestPage = () => null;
+    const TestHomePage = () => null;
+    const TestLayout = ({ children }: PropsWithChildren) => children;
+    const TestRootLayout = ({ children }: PropsWithChildren) => children;
+    createPages(async ({ createPage, createLayout }) => [
+      createLayout({ render: 'static', path: '/', component: TestRootLayout }),
+      createLayout({
+        render: 'static',
+        path: '/(group)',
+        component: TestLayout,
+      }),
+      createPage({
+        render: 'static',
+        path: '/(group)/test',
+        component: TestPage,
+      }),
+      createPage({
+        render: 'static',
+        path: '/(group)',
+        component: TestHomePage,
+      }),
+    ]);
+    const { getRouteConfig, handleRoute } = injectedFunctions();
+    expect(await getRouteConfig()).toEqual([
+      {
+        elements: {
+          'layout:/': { isStatic: true },
+          'layout:/(group)': { isStatic: true },
+          'page:/test': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [{ name: 'test', type: 'literal' }],
+      },
+      {
+        elements: {
+          'page:/': { isStatic: true },
+          'layout:/(group)': { isStatic: true },
+          'layout:/': { isStatic: true },
+        },
+        rootElement: { isStatic: true },
+        routeElement: { isStatic: true },
+        noSsr: false,
+        path: [],
+      },
+    ]);
+    const route = await handleRoute('/test', {
+      query: '?skip=[]',
+    });
+    expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
+    expect(route.routeElement).toBeDefined();
+    expect(Object.keys(route.elements)).toEqual([
+      'page:/test',
+      'layout:/',
+      'layout:/(group)',
+    ]);
+
+    const route2 = await handleRoute('/', {
+      query: '?skip=[]',
+    });
+    expect(route2).toBeDefined();
+    expect(route2.rootElement).toBeDefined();
+    expect(route2.routeElement).toBeDefined();
+    expect(Object.keys(route2.elements)).toEqual([
+      'page:/',
+      'layout:/',
+      'layout:/(group)',
+    ]);
+  });
+});