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:

+ +
+ ); +} 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 ( +
+
+ +
{children}
+
+
+ ); +} 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.