Repository: wakujs/waku
Pull request #1508 (2025-07-05 01:38:19)

fix(create-pages): Fix API catch-all routes matching before standard API routes
### Details:

This change adds e2e test cases for both issues:

- catch-all api route incorrectly matching before standard api route #1507 
- ~~catch-all route not handling index route~~ -> moved this to it's own issue: #1518 

and also adds a fix to the first issue above by sorting the routes first and then finding the first match. The sorting logic now accounts for "specificity" of the route (e.g. number of path specs) and also if the path spec is a wildcard.

vercel[bot]: [vc]: #FoSMNo0NurSegks1TnumTZ7tmMismv7HjlijsHRBMpQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS84c1VmeEVwUXF4YmViaEdWYnFlM1U5dVZuNUZKIiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstaGFtbGltLWNhdGNoLWFsbC1yb3V0ZS1vcmRlci1idWctZGFpc2hpcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkcifV19
**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/8sUfxEpQqxbebhGVbqe3U9uVn5FJ)) | [Visit Preview](https://waku-git-fork-hamlim-catch-all-route-order-bug-daishis-projects.vercel.app) | Jul 5, 2025 1:38am |


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/wakujs/waku/pr/1508/builds/629650) or the icon next to each commit SHA.
hamlim: I'm going to split out the `/[...catchAll]` route not matching `/` paths bug into it's own issue -> #1518
tylersayshi: lgtm, thanks!

Patch:
diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts
index f973180f..2f68993e 100644
--- a/packages/waku/src/router/create-pages.ts
+++ b/packages/waku/src/router/create-pages.ts
@@ -248,6 +248,38 @@ type ComponentList = {
 
 type ComponentEntry = FunctionComponent<any> | ComponentList;
 
+const routePriorityComparator = (
+  a: {
+    path: PathSpec;
+    type: 'route' | 'api';
+  },
+  b: {
+    path: PathSpec;
+    type: 'route' | 'api';
+  },
+) => {
+  const aPath = a.path;
+  const bPath = b.path;
+  const aPathLength = aPath.length;
+  const bPathLength = bPath.length;
+  const aHasWildcard = aPath.at(-1)?.type === 'wildcard';
+  const bHasWildcard = bPath.at(-1)?.type === 'wildcard';
+
+  // Compare path lengths first (longer paths are more specific)
+  if (aPathLength !== bPathLength) {
+    return aPathLength > bPathLength ? -1 : 1;
+  }
+
+  // If path lengths are equal, compare wildcard presence
+  // sort the route without the wildcard higher, to check it earlier
+  if (aHasWildcard !== bHasWildcard) {
+    return aHasWildcard ? 1 : -1;
+  }
+
+  // If all else is equal, routes have the same priority
+  return 0;
+};
+
 export const createPages = <
   AllPages extends (AnyPage | ReturnType<CreateLayout>)[],
 >(
@@ -312,7 +344,14 @@ export const createPages = <
     path: string,
     method: string,
   ) => string | undefined = (path, method) => {
-    for (const [p, v] of apiPathMap.entries()) {
+    const apiConfigEntries = Array.from(apiPathMap.entries()).sort(
+      ([, a], [, b]) =>
+        routePriorityComparator(
+          { path: a.pathSpec, type: 'api' },
+          { path: b.pathSpec, type: 'api' },
+        ),
+    );
+    for (const [p, v] of apiConfigEntries) {
       if (
         (method in v.handlers || v.handlers.all) &&
         getPathMapping(parsePathWithSlug(p!), path)
@@ -754,23 +793,10 @@ export const createPages = <
         },
       );
 
-      const getRoutePriority = (
-        pathConfig: (typeof routeConfigs)[number] | (typeof apiConfigs)[number],
-      ) => {
-        const hasWildcard = pathConfig.path.at(-1)?.type === 'wildcard';
-        if (pathConfig.type === 'api') {
-          return hasWildcard ? 2 : 1;
-        }
-        return hasWildcard ? 3 : 0;
-      };
-
       return (
         [...routeConfigs, ...apiConfigs]
           // Sort routes by priority: "standard routes" -> api routes -> api wildcard routes -> standard wildcard routes
-          .sort(
-            (configA, configB) =>
-              getRoutePriority(configA) - getRoutePriority(configB),
-          )
+          .sort((configA, configB) => routePriorityComparator(configA, configB))
       );
     },
     handleRoute: async (path, { query }) => {


Test patch:
diff --git a/e2e/fixtures/wildcard-api-routes/src/pages/[...wildcard].tsx b/e2e/fixtures/wildcard-api-routes/src/pages/[...wildcard].tsx
index 50b3c577..47039069 100644
--- a/e2e/fixtures/wildcard-api-routes/src/pages/[...wildcard].tsx
+++ b/e2e/fixtures/wildcard-api-routes/src/pages/[...wildcard].tsx
@@ -1,6 +1,7 @@
 const Page = ({ wildcard }: { wildcard: string[] }) => (
   <div>
     <h1>/{wildcard.join('/')}</h1>
+    <p>Catch All Pages Route</p>
   </div>
 );
 
diff --git a/e2e/fixtures/wildcard-api-routes/src/pages/api/[...misc].ts b/e2e/fixtures/wildcard-api-routes/src/pages/api/[...misc].ts
new file mode 100644
index 00000000..c923a7a8
--- /dev/null
+++ b/e2e/fixtures/wildcard-api-routes/src/pages/api/[...misc].ts
@@ -0,0 +1,3 @@
+export async function GET(__request: Request): Promise<Response> {
+  return new Response('/api root catch-all');
+}
diff --git a/e2e/fixtures/wildcard-api-routes/src/pages/api/greet.ts b/e2e/fixtures/wildcard-api-routes/src/pages/api/greet.ts
index 959de603..0f77a1ae 100644
--- a/e2e/fixtures/wildcard-api-routes/src/pages/api/greet.ts
+++ b/e2e/fixtures/wildcard-api-routes/src/pages/api/greet.ts
@@ -1,3 +1,3 @@
 export async function GET(__request: Request): Promise<Response> {
-  return new Response('Hello, world!');
+  return new Response('Greetings from the API!');
 }
diff --git a/e2e/wildcard-api-routes.spec.ts b/e2e/wildcard-api-routes.spec.ts
index 01b503ed..6b945c4d 100644
--- a/e2e/wildcard-api-routes.spec.ts
+++ b/e2e/wildcard-api-routes.spec.ts
@@ -14,20 +14,34 @@ test.describe(`wildcard api routes`, async () => {
     await stopApp();
   });
 
+  // @TODO: re-enable when root wildcard route can match index route - #1518
+  test.skip('catch all route can match as index route', async ({ page }) => {
+    await page.goto(`http://localhost:${port}/`);
+    await expect(page.getByText('Catch All Pages Route')).toBeVisible();
+  });
+
   test(`api route matches before wildcard route`, async ({ page }) => {
-    // index route matches wildcard:
-    await page.goto(`http://localhost:${port}/foo`);
-    await expect(page.getByRole('heading', { name: '/foo' })).toBeVisible();
+    // misc route matches wildcard:
+    await page.goto(`http://localhost:${port}/foo/bar`);
+    await expect(page.getByRole('heading', { name: '/foo/bar' })).toBeVisible();
 
     // api route request
     const response = await page.request.get(
       `http://localhost:${port}/api/greet`,
     );
     const text = await response.text();
-    expect(text).toBe('Hello, world!');
+    expect(text).toBe('Greetings from the API!');
+  });
+
+  test('api standard route matches before wildcard route', async ({ page }) => {
+    const response = await page.request.get(
+      `http://localhost:${port}/api/greet`,
+    );
+    const text = await response.text();
+    expect(text).toBe('Greetings from the API!');
   });
 
-  test(`api wildcard route matches before standard wildcard route`, async ({
+  test(`static nested catch-all route matches before root catch-all route`, async ({
     page,
   }) => {
     const response = await page.request.get(
@@ -35,5 +49,11 @@ test.describe(`wildcard api routes`, async () => {
     );
     const text = await response.text();
     expect(text).toBe('API Wildcard!');
+
+    const response2 = await page.request.get(
+      `http://localhost:${port}/api/foo/bar`,
+    );
+    const text2 = await response2.text();
+    expect(text2).toBe('/api root catch-all');
   });
 });
diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts
index a508a6a4..f1e90840 100644
--- a/packages/waku/tests/create-pages.test.ts
+++ b/packages/waku/tests/create-pages.test.ts
@@ -1105,227 +1105,506 @@ describe('createPages pages and layouts', () => {
     expect(await getConfig()).toEqual([
       {
         type: 'route',
+        path: [
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'wild',
+          },
+          {
+            type: 'literal',
+            name: 'foo',
+          },
+          {
+            type: 'literal',
+            name: 'foo-2',
+          },
+          {
+            type: 'literal',
+            name: 'foo-3',
+          },
+        ],
+        pathPattern: [
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'wild',
+          },
+          {
+            type: 'wildcard',
+            name: 'wild',
+          },
+        ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/client/static': { isStatic: true },
+          'page:/static/wild/foo/foo-2/foo-3': {
+            isStatic: true,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
-        path: [
-          { name: 'client', type: 'literal' },
-          { name: 'static', type: 'literal' },
-        ],
       },
       {
         type: 'route',
-        elements: {
-          'page:/server/static/static-echo': { isStatic: true },
-        },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
-        noSsr: false,
         path: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'static-echo', type: 'literal' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'static-echo',
+          },
+          {
+            type: 'literal',
+            name: 'static-echo-2',
+          },
         ],
         pathPattern: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'echo', type: 'group' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'group',
+            name: 'echo',
+          },
+          {
+            type: 'group',
+            name: 'echo2',
+          },
         ],
-      },
-      {
-        type: 'route',
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/server/static/static-echo-2': { isStatic: true },
+          'page:/server/static/static-echo/static-echo-2': {
+            isStatic: true,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
+      },
+      {
+        type: 'route',
         path: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'static-echo-2', type: 'literal' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'hello',
+          },
+          {
+            type: 'literal',
+            name: 'hello-2',
+          },
         ],
         pathPattern: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'echo', type: 'group' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'group',
+            name: 'echo',
+          },
+          {
+            type: 'group',
+            name: 'echo2',
+          },
         ],
-      },
-      {
-        type: 'route',
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/server/static/static-echo/static-echo-2': { isStatic: true },
+          'page:/server/static/hello/hello-2': {
+            isStatic: true,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
+      },
+      {
+        type: 'route',
         path: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'static-echo', type: 'literal' },
-          { name: 'static-echo-2', type: 'literal' },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'wild',
+          },
+          {
+            type: 'literal',
+            name: 'hello',
+          },
+          {
+            type: 'literal',
+            name: 'hello-2',
+          },
         ],
         pathPattern: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'echo', type: 'group' },
-          { name: 'echo2', type: 'group' },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'wild',
+          },
+          {
+            type: 'wildcard',
+            name: 'wild',
+          },
         ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
+        elements: {
+          'page:/static/wild/hello/hello-2': {
+            isStatic: true,
+          },
+        },
+        noSsr: false,
       },
       {
         type: 'route',
+        path: [
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'two',
+          },
+          {
+            type: 'group',
+            name: 'echo',
+          },
+          {
+            type: 'group',
+            name: 'echo2',
+          },
+        ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/server/static/hello/hello-2': { isStatic: true },
+          'page:/server/two/[echo]/[echo2]': {
+            isStatic: false,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
-        path: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'hello', type: 'literal' },
-          { name: 'hello-2', type: 'literal' },
-        ],
-        pathPattern: [
-          { name: 'server', type: 'literal' },
-          { name: 'static', type: 'literal' },
-          { name: 'echo', type: 'group' },
-          { name: 'echo2', type: 'group' },
-        ],
       },
       {
         type: 'route',
+        path: [
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'oneAndWild',
+          },
+          {
+            type: 'group',
+            name: 'slug',
+          },
+          {
+            type: 'wildcard',
+            name: 'wild',
+          },
+        ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/static/wild/bar': { isStatic: true },
+          'page:/server/oneAndWild/[slug]/[...wild]': {
+            isStatic: false,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
+      },
+      {
+        type: 'route',
         path: [
-          { name: 'static', type: 'literal' },
-          { name: 'wild', type: 'literal' },
-          { name: 'bar', type: 'literal' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'static-echo',
+          },
         ],
         pathPattern: [
-          { name: 'static', type: 'literal' },
-          { name: 'wild', type: 'literal' },
-          { name: 'wild', type: 'wildcard' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'group',
+            name: 'echo',
+          },
         ],
-      },
-      {
-        type: 'route',
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/static/wild/hello/hello-2': { isStatic: true },
+          'page:/server/static/static-echo': {
+            isStatic: true,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
+      },
+      {
+        type: 'route',
         path: [
-          { name: 'static', type: 'literal' },
-          { name: 'wild', type: 'literal' },
-          { name: 'hello', type: 'literal' },
-          { name: 'hello-2', type: 'literal' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'static-echo-2',
+          },
         ],
         pathPattern: [
-          { name: 'static', type: 'literal' },
-          { name: 'wild', type: 'literal' },
-          { name: 'wild', type: 'wildcard' },
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'group',
+            name: 'echo',
+          },
         ],
-      },
-      {
-        type: 'route',
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/static/wild/foo/foo-2/foo-3': { isStatic: true },
+          'page:/server/static/static-echo-2': {
+            isStatic: true,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
+      },
+      {
+        type: 'route',
         path: [
-          { name: 'static', type: 'literal' },
-          { name: 'wild', type: 'literal' },
-          { name: 'foo', type: 'literal' },
-          { name: 'foo-2', type: 'literal' },
-          { name: 'foo-3', type: 'literal' },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'wild',
+          },
+          {
+            type: 'literal',
+            name: 'bar',
+          },
         ],
         pathPattern: [
-          { name: 'static', type: 'literal' },
-          { name: 'wild', type: 'literal' },
-          { name: 'wild', type: 'wildcard' },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+          {
+            type: 'literal',
+            name: 'wild',
+          },
+          {
+            type: 'wildcard',
+            name: 'wild',
+          },
         ],
-      },
-      {
-        type: 'route',
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/client/dynamic': { isStatic: false },
+          'page:/static/wild/bar': {
+            isStatic: true,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
-        path: [
-          { name: 'client', type: 'literal' },
-          { name: 'dynamic', type: 'literal' },
-        ],
       },
       {
         type: 'route',
+        path: [
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'one',
+          },
+          {
+            type: 'group',
+            name: 'echo',
+          },
+        ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/server/one/[echo]': { isStatic: false },
+          'page:/server/one/[echo]': {
+            isStatic: false,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
-        path: [
-          { name: 'server', type: 'literal' },
-          { name: 'one', type: 'literal' },
-          { name: 'echo', type: 'group' },
-        ],
       },
       {
         type: 'route',
+        path: [
+          {
+            type: 'literal',
+            name: 'server',
+          },
+          {
+            type: 'literal',
+            name: 'wild',
+          },
+          {
+            type: 'wildcard',
+            name: 'wild',
+          },
+        ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/server/two/[echo]/[echo2]': { isStatic: false },
+          'page:/server/wild/[...wild]': {
+            isStatic: false,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
-        path: [
-          { name: 'server', type: 'literal' },
-          { name: 'two', type: 'literal' },
-          { name: 'echo', type: 'group' },
-          { name: 'echo2', type: 'group' },
-        ],
       },
       {
         type: 'route',
+        path: [
+          {
+            type: 'literal',
+            name: 'client',
+          },
+          {
+            type: 'literal',
+            name: 'static',
+          },
+        ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/server/wild/[...wild]': { isStatic: false },
+          'page:/client/static': {
+            isStatic: true,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
-        path: [
-          { name: 'server', type: 'literal' },
-          { name: 'wild', type: 'literal' },
-          { name: 'wild', type: 'wildcard' },
-        ],
       },
       {
         type: 'route',
+        path: [
+          {
+            type: 'literal',
+            name: 'client',
+          },
+          {
+            type: 'literal',
+            name: 'dynamic',
+          },
+        ],
+        rootElement: {
+          isStatic: true,
+        },
+        routeElement: {
+          isStatic: true,
+        },
         elements: {
-          'page:/server/oneAndWild/[slug]/[...wild]': { isStatic: false },
+          'page:/client/dynamic': {
+            isStatic: false,
+          },
         },
-        rootElement: { isStatic: true },
-        routeElement: { isStatic: true },
         noSsr: false,
-        path: [
-          { name: 'server', type: 'literal' },
-          { name: 'oneAndWild', type: 'literal' },
-          { name: 'slug', type: 'group' },
-          { name: 'wild', type: 'wildcard' },
-        ],
       },
     ]);
     const route = await handleRoute('/server/two/a/b', {