Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
padding: '1rem',
backgroundColor: '#fff3e0',
border: '2px dashed orange',
marginTop: '1rem',
}}
>
<h3 style={{ marginBottom: '1rem' }}>@sidebar Slot (Parallel Route)</h3>
<p>
<strong>catchAll param:</strong> [{catchAll?.join(', ')}]
</p>
<p>
<strong>Computed route:</strong> {route}
</p>

<h3 style={{ marginTop: '1rem' }}>Test Routes:</h3>
<ul style={{ padding: '1rem 0 0 1rem' }}>
<li>
<Link href="/parallel-routes/dashboard">Dashboard (static)</Link>
</li>
<li>
<Link href="/parallel-routes/settings">Settings (static)</Link>
</li>
</ul>
</div>
);
}
7 changes: 7 additions & 0 deletions apps/nextjs-15/src/app/parallel-routes/@sidebar/default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function SidebarDefault() {
return (
<div style={{ padding: '1rem', backgroundColor: '#f5f5f5' }}>
<p>@sidebar default (no active parallel route)</p>
</div>
);
}
36 changes: 36 additions & 0 deletions apps/nextjs-15/src/app/parallel-routes/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { computeRoute } from '@vercel/analytics';

export default function DashboardPage() {
const path = '/parallel-routes/dashboard';
const route = computeRoute(path, {});

return (
<div style={{ padding: '2rem' }}>
<h1>Dashboard (Static Route)</h1>
<p>This is a static page with no dynamic params.</p>

<div
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#e3f2fd',
}}
>
<p>
<strong>Path:</strong> {path}
</p>
<p>
<strong>Computed route:</strong> {route}
</p>
</div>

<p style={{ marginTop: '1rem', color: '#666' }}>
The sidebar slot (<code>@sidebar/[...catchAll]</code>) matches this path
with <code>catchAll: [&apos;dashboard&apos;]</code>. The{' '}
<code>Analytics</code> component in the root layout sees both sets of
params via <code>useParams()</code>, causing it to compute the wrong
route.
</p>
</div>
);
}
20 changes: 20 additions & 0 deletions apps/nextjs-15/src/app/parallel-routes/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';

export default function ParallelRoutesLayout({
children,
sidebar,
}: {
children: ReactNode;
sidebar: ReactNode;
}) {
return (
<div style={{ padding: '1rem' }}>
<div
style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1rem' }}
>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions apps/nextjs-15/src/app/parallel-routes/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { computeRoute } from '@vercel/analytics';

export default function SettingsPage() {
const path = '/parallel-routes/settings';
const route = computeRoute(path, {});

return (
<div style={{ padding: '2rem' }}>
<h1>Settings (Static Route)</h1>
<p>This is a static page with no dynamic params.</p>

<div
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#e3f2fd',
}}
>
<p>
<strong>Path:</strong> {path}
</p>
<p>
<strong>Computed route:</strong> {route}
</p>
</div>
</div>
);
}
4 changes: 2 additions & 2 deletions apps/nextjs/e2e/development/beforeSend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions apps/nextjs/e2e/development/pageview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
27 changes: 15 additions & 12 deletions apps/vue/index.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
<html lang="en">

<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>

<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

</html>
41 changes: 40 additions & 1 deletion packages/web/src/nextjs/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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!!
Expand Down
47 changes: 45 additions & 2 deletions packages/web/src/nextjs/utils.ts
Original file line number Diff line number Diff line change
@@ -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 = (): {
Expand All @@ -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<string, string | string[]>,
segments: string[],
): Record<string, string | string[]> {
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.
Expand Down