diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 5ffea73..7967f6d 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -23,7 +23,7 @@ jobs:
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
- version: latest
+ version: 2.3.10
- name: Run Biome
run: biome ci
diff --git a/apps/nextjs-15/src/app/parallel-routes/@sidebar/[...catchAll]/page.tsx b/apps/nextjs-15/src/app/parallel-routes/@sidebar/[...catchAll]/page.tsx
new file mode 100644
index 0000000..11296f9
--- /dev/null
+++ b/apps/nextjs-15/src/app/parallel-routes/@sidebar/[...catchAll]/page.tsx
@@ -0,0 +1,41 @@
+import { computeRoute } from '@vercel/analytics';
+import Link from 'next/link';
+
+export default async function SidebarCatchAll({
+ params,
+}: {
+ params: Promise<{ catchAll: string[] }>;
+}) {
+ const { catchAll } = await params;
+ const path = `/parallel-routes/${catchAll.join('/')}`;
+ const route = computeRoute(path, { catchAll });
+
+ return (
+
+
@sidebar Slot (Parallel Route)
+
+ catchAll param: [{catchAll?.join(', ')}]
+
+
+ Computed route: {route}
+
+
+
Test Routes:
+
+
+ Dashboard (static)
+
+
+ Settings (static)
+
+
+
+ );
+}
diff --git a/apps/nextjs-15/src/app/parallel-routes/@sidebar/default.tsx b/apps/nextjs-15/src/app/parallel-routes/@sidebar/default.tsx
new file mode 100644
index 0000000..b49f635
--- /dev/null
+++ b/apps/nextjs-15/src/app/parallel-routes/@sidebar/default.tsx
@@ -0,0 +1,7 @@
+export default function SidebarDefault() {
+ return (
+
+
@sidebar default (no active parallel route)
+
+ );
+}
diff --git a/apps/nextjs-15/src/app/parallel-routes/dashboard/page.tsx b/apps/nextjs-15/src/app/parallel-routes/dashboard/page.tsx
new file mode 100644
index 0000000..35736b7
--- /dev/null
+++ b/apps/nextjs-15/src/app/parallel-routes/dashboard/page.tsx
@@ -0,0 +1,36 @@
+import { computeRoute } from '@vercel/analytics';
+
+export default function DashboardPage() {
+ const path = '/parallel-routes/dashboard';
+ const route = computeRoute(path, {});
+
+ return (
+
+
Dashboard (Static Route)
+
This is a static page with no dynamic params.
+
+
+
+ Path: {path}
+
+
+ Computed route: {route}
+
+
+
+
+ The sidebar slot (@sidebar/[...catchAll]) matches this path
+ with catchAll: ['dashboard']. The{' '}
+ Analytics component in the root layout sees both sets of
+ params via useParams(), causing it to compute the wrong
+ route.
+
+
+ );
+}
diff --git a/apps/nextjs-15/src/app/parallel-routes/layout.tsx b/apps/nextjs-15/src/app/parallel-routes/layout.tsx
new file mode 100644
index 0000000..e1a9762
--- /dev/null
+++ b/apps/nextjs-15/src/app/parallel-routes/layout.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from 'react';
+
+export default function ParallelRoutesLayout({
+ children,
+ sidebar,
+}: {
+ children: ReactNode;
+ sidebar: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/nextjs-15/src/app/parallel-routes/settings/page.tsx b/apps/nextjs-15/src/app/parallel-routes/settings/page.tsx
new file mode 100644
index 0000000..515fd67
--- /dev/null
+++ b/apps/nextjs-15/src/app/parallel-routes/settings/page.tsx
@@ -0,0 +1,28 @@
+import { computeRoute } from '@vercel/analytics';
+
+export default function SettingsPage() {
+ const path = '/parallel-routes/settings';
+ const route = computeRoute(path, {});
+
+ return (
+
+
Settings (Static Route)
+
This is a static page with no dynamic params.
+
+
+
+ Path: {path}
+
+
+ Computed route: {route}
+
+
+
+ );
+}
diff --git a/apps/nextjs/e2e/development/beforeSend.spec.ts b/apps/nextjs/e2e/development/beforeSend.spec.ts
index 5d59d45..f161720 100644
--- a/apps/nextjs/e2e/development/beforeSend.spec.ts
+++ b/apps/nextjs/e2e/development/beforeSend.spec.ts
@@ -35,13 +35,13 @@ test.describe('beforeSend', () => {
expect(
messages.find((m) =>
- m.includes('[pageview] http://localhost:3000/before-send/first'),
+ m.includes('[view] http://localhost:3000/before-send/first'),
),
).toBeDefined();
expect(
messages.find((m) =>
m.includes(
- '[pageview] http://localhost:3000/before-send/second?secret=REDACTED',
+ '[view] http://localhost:3000/before-send/second?secret=REDACTED',
),
),
).toBeDefined();
diff --git a/apps/nextjs/e2e/development/pageview.spec.ts b/apps/nextjs/e2e/development/pageview.spec.ts
index 5ac69d8..0368e32 100644
--- a/apps/nextjs/e2e/development/pageview.spec.ts
+++ b/apps/nextjs/e2e/development/pageview.spec.ts
@@ -35,12 +35,12 @@ test.describe('pageview', () => {
expect(
messages.find((m) =>
- m.includes('[pageview] http://localhost:3000/navigation/first'),
+ m.includes('[view] http://localhost:3000/navigation/first'),
),
).toBeDefined();
expect(
messages.find((m) =>
- m.includes('[pageview] http://localhost:3000/navigation/second'),
+ m.includes('[view] http://localhost:3000/navigation/second'),
),
).toBeDefined();
});
diff --git a/apps/vue/index.html b/apps/vue/index.html
index e81c5da..5316a2b 100644
--- a/apps/vue/index.html
+++ b/apps/vue/index.html
@@ -1,13 +1,16 @@
-
-
-
-
-
- Vite App
-
-
-
-
-
-
+
+
+
+
+
+
+ Vite App
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/web/src/nextjs/utils.test.ts b/packages/web/src/nextjs/utils.test.ts
index 708c54a..210e57b 100644
--- a/packages/web/src/nextjs/utils.test.ts
+++ b/packages/web/src/nextjs/utils.test.ts
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest';
-import { getBasePath, getConfigString } from './utils';
+import {
+ filterParallelRouteParams,
+ getBasePath,
+ getConfigString,
+} from './utils';
const processSave = { ...process };
const envSave = { ...process.env };
@@ -9,6 +13,41 @@ afterEach(() => {
process.env = { ...envSave };
});
+describe('parallel route slot param filtering', () => {
+ it('filters single-segment slot catch-all from static main route', () => {
+ const params = { catchAll: ['dashboard'] };
+ const segments = ['parallel-routes', 'dashboard'];
+ expect(filterParallelRouteParams(params, segments)).toEqual({});
+ });
+
+ it('keeps multi-segment catch-all from main route', () => {
+ const params = { slug: ['api', 'reference'] };
+ // Next.js joins catch-all values with '/' in the router tree
+ const segments = ['docs', 'api/reference'];
+ expect(filterParallelRouteParams(params, segments)).toEqual({
+ slug: ['api', 'reference'],
+ });
+ });
+
+ it('keeps string params and filters slot array params', () => {
+ const params = { id: '123', catchAll: ['123', 'detail', 'test'] };
+ const segments = ['parallel-routes', '123', 'detail', 'test'];
+ expect(filterParallelRouteParams(params, segments)).toEqual({ id: '123' });
+ });
+
+ it('filters multi-segment slot catch-all whose joined value is not a single segment in children', () => {
+ // The slot joins to '123/detail/test' but children has them as separate segments
+ const params = { id: '123', catchAll: ['123', 'detail', 'test'] };
+ const segments = ['parallel-routes', '123', 'detail', 'test'];
+ expect(filterParallelRouteParams(params, segments)).toEqual({ id: '123' });
+ });
+
+ it('keeps all params when segments is empty (graceful fallback)', () => {
+ const params = { id: '123' };
+ expect(filterParallelRouteParams(params, [])).toEqual({ id: '123' });
+ });
+});
+
describe('getBasePath()', () => {
it('returns null without process', () => {
// @ts-expect-error -- yes, we want to completely drop process for this test!!
diff --git a/packages/web/src/nextjs/utils.ts b/packages/web/src/nextjs/utils.ts
index c90bede..2422aed 100644
--- a/packages/web/src/nextjs/utils.ts
+++ b/packages/web/src/nextjs/utils.ts
@@ -1,5 +1,10 @@
'use client';
-import { useParams, usePathname, useSearchParams } from 'next/navigation.js';
+import {
+ useParams,
+ usePathname,
+ useSearchParams,
+ useSelectedLayoutSegments,
+} from 'next/navigation.js';
import { computeRoute } from '../utils';
export const useRoute = (): {
@@ -9,18 +14,56 @@ export const useRoute = (): {
const params = useParams();
const searchParams = useSearchParams();
const path = usePathname();
+ // null in Pages Router, string[] in App Router
+ const segments = useSelectedLayoutSegments();
// Until we have route parameters, we don't compute the route
if (!params) {
return { route: null, path };
}
+
// in Next.js@13, useParams() could return an empty object for pages router, and we default to searchParams.
- const finalParams = Object.keys(params).length
+ const paramObject = Object.keys(params).length
? params
: Object.fromEntries(searchParams.entries());
+
+ const finalParams =
+ segments !== null
+ ? filterParallelRouteParams(paramObject, segments)
+ : paramObject;
+
return { route: computeRoute(path, finalParams), path };
};
+/**
+ * Filters out array params from parallel route slots.
+ *
+ * In Next.js App Router, `useParams()` merges params from all active route
+ * segments including parallel slots (`@folder` convention). Slot params
+ * don't correspond to the URL structure and corrupt route computation.
+ *
+ * Next.js stores multi-segment catch-all values joined with '/' in the router
+ * tree (e.g., `[...slug]` matching `['api', 'ref']` → stored as `'api/ref'`),
+ * so `useSelectedLayoutSegments('children')` returns slash-containing strings
+ * only for genuine main-route catch-alls.
+ *
+ * Array params are kept only when their joined value contains '/' and appears
+ * as a segment in the children path. Single-segment catch-alls are treated as
+ * static paths — an acceptable trade-off vs. slot contamination.
+ */
+export function filterParallelRouteParams(
+ params: Record,
+ segments: string[],
+): Record {
+ return Object.fromEntries(
+ Object.entries(params).filter(([, value]) => {
+ if (!Array.isArray(value)) return true;
+ const joined = value.join('/');
+ return joined.includes('/') && segments.includes(joined);
+ }),
+ );
+}
+
// !! important !!
// do not access env variables using process.env[varname]
// some bundlers won't replace the value at build time.