Skip to content

Commit 18dc525

Browse files
committed
feat: adds nextjs parallel routs support
1 parent d69be8b commit 18dc525

8 files changed

Lines changed: 189 additions & 3 deletions

File tree

apps/nextjs/app/blog/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default function Blog() {
55
<div>
66
<h1>Welcome to the Blog</h1>
77
<Link href="/blog/test">First blog entry</Link>
8+
<Link href="/parallel-routes/dashboard">Parallel routes (App Router)</Link>git
89
</div>
910
);
1011
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { computeRoute } from '@vercel/speed-insights';
2+
import Link from 'next/link';
3+
4+
export default async function SidebarCatchAll({ params }: { params: Promise<{ catchAll: string[] }> }) {
5+
const { catchAll } = await params;
6+
const path = `/parallel-routes/${catchAll.join('/')}`;
7+
const route = computeRoute(path, { catchAll });
8+
9+
return (
10+
<div
11+
style={{
12+
padding: '1rem',
13+
backgroundColor: '#fff3e0',
14+
border: '2px dashed orange',
15+
marginTop: '1rem',
16+
}}
17+
>
18+
<h3 style={{ marginBottom: '1rem' }}>@sidebar Slot (Parallel Route)</h3>
19+
<p>
20+
<strong>catchAll param:</strong> [{catchAll?.join(', ')}]
21+
</p>
22+
<p>
23+
<strong>Computed route:</strong> {route}
24+
</p>
25+
26+
<h3 style={{ marginTop: '1rem' }}>Test Routes:</h3>
27+
<ul style={{ padding: '1rem 0 0 1rem' }}>
28+
<li>
29+
<Link href="/parallel-routes/dashboard">Dashboard (static)</Link>
30+
</li>
31+
<li>
32+
<Link href="/parallel-routes/settings">Settings (static)</Link>
33+
</li>
34+
</ul>
35+
</div>
36+
);
37+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function SidebarDefault() {
2+
return (
3+
<div style={{ padding: '1rem', backgroundColor: '#f5f5f5' }}>
4+
<p>@sidebar default (no active parallel route)</p>
5+
</div>
6+
);
7+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { computeRoute } from '@vercel/speed-insights';
2+
3+
export default function DashboardPage() {
4+
const path = '/parallel-routes/dashboard';
5+
const route = computeRoute(path, {});
6+
7+
return (
8+
<div style={{ padding: '2rem' }}>
9+
<h1>Dashboard (Static Route)</h1>
10+
<p>This is a static page with no dynamic params.</p>
11+
12+
<div
13+
style={{
14+
marginTop: '1rem',
15+
padding: '1rem',
16+
backgroundColor: '#e3f2fd',
17+
}}
18+
>
19+
<p>
20+
<strong>Path:</strong> {path}
21+
</p>
22+
<p>
23+
<strong>Computed route:</strong> {route}
24+
</p>
25+
</div>
26+
27+
<p style={{ marginTop: '1rem', color: '#666' }}>
28+
The sidebar slot (<code>@sidebar/[...catchAll]</code>) matches this path with{' '}
29+
<code>catchAll: [&apos;dashboard&apos;]</code>. The <code>Analytics</code> component in the root layout sees
30+
both sets of params via <code>useParams()</code>, causing it to compute the wrong route.
31+
</p>
32+
</div>
33+
);
34+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { ReactNode } from 'react';
2+
3+
export default function ParallelRoutesLayout({ children, sidebar }: { children: ReactNode; sidebar: ReactNode }) {
4+
return (
5+
<div style={{ padding: '1rem' }}>
6+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1rem' }}>
7+
<aside>{sidebar}</aside>
8+
<main>{children}</main>
9+
</div>
10+
</div>
11+
);
12+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { computeRoute } from '@vercel/speed-insights';
2+
3+
export default function SettingsPage() {
4+
const path = '/parallel-routes/settings';
5+
const route = computeRoute(path, {});
6+
7+
return (
8+
<div style={{ padding: '2rem' }}>
9+
<h1>Settings (Static Route)</h1>
10+
<p>This is a static page with no dynamic params.</p>
11+
12+
<div
13+
style={{
14+
marginTop: '1rem',
15+
padding: '1rem',
16+
backgroundColor: '#e3f2fd',
17+
}}
18+
>
19+
<p>
20+
<strong>Path:</strong> {path}
21+
</p>
22+
<p>
23+
<strong>Computed route:</strong> {route}
24+
</p>
25+
</div>
26+
</div>
27+
);
28+
}

packages/web/src/nextjs/utils.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, it } from 'vitest';
2-
import { getBasePath, getConfigString } from './utils';
2+
import { filterParallelRouteParams, getBasePath, getConfigString } from './utils';
33

44
const processSave = { ...process };
55
const envSave = { ...process.env };
@@ -9,6 +9,49 @@ afterEach(() => {
99
process.env = { ...envSave };
1010
});
1111

12+
describe('filterParallelRouteParams()', () => {
13+
it('filters single-segment slot catch-all from static main route', () => {
14+
// @sidebar/[...catchAll] matched "dashboard" — a single segment not in main route
15+
const result = filterParallelRouteParams(
16+
{ catchAll: ['dashboard'] },
17+
['dashboard'],
18+
);
19+
expect(result).toEqual({});
20+
});
21+
22+
it('keeps multi-segment catch-all that appears in segments', () => {
23+
const result = filterParallelRouteParams(
24+
{ slug: ['blog', 'my-post'] },
25+
['blog/my-post'],
26+
);
27+
expect(result).toEqual({ slug: ['blog', 'my-post'] });
28+
});
29+
30+
it('keeps string params and filters slot array params', () => {
31+
const result = filterParallelRouteParams(
32+
{ id: 'abc', catchAll: ['dashboard'] },
33+
['some-other-segment'],
34+
);
35+
expect(result).toEqual({ id: 'abc' });
36+
});
37+
38+
it('filters multi-segment slot catch-all whose joined value is not in segments', () => {
39+
const result = filterParallelRouteParams(
40+
{ catchAll: ['a', 'b'] },
41+
['c', 'd'],
42+
);
43+
expect(result).toEqual({});
44+
});
45+
46+
it('keeps all params when segments is empty', () => {
47+
const result = filterParallelRouteParams(
48+
{ id: 'abc', slug: ['x', 'y'] },
49+
[],
50+
);
51+
expect(result).toEqual({ id: 'abc' });
52+
});
53+
});
54+
1255
describe('getBasePath()', () => {
1356
it('returns null without process', () => {
1457
// @ts-expect-error -- yes, we want to completely drop process for this test!!

packages/web/src/nextjs/utils.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
'use client';
2-
import { useParams, usePathname, useSearchParams } from 'next/navigation.js';
2+
import {
3+
useParams,
4+
usePathname,
5+
useSearchParams,
6+
useSelectedLayoutSegments,
7+
} from 'next/navigation.js';
38
import { computeRoute } from '../utils';
49

510
export const useRoute = (): string | null => {
@@ -10,13 +15,32 @@ export const useRoute = (): string | null => {
1015
if (!params) {
1116
return null;
1217
}
18+
const segments = useSelectedLayoutSegments();
1319
// in Next.js@13, useParams() could return an empty object for pages router, and we default to searchParams.
14-
const finalParams = Object.keys(params).length
20+
const paramObject = Object.keys(params).length
1521
? params
1622
: Object.fromEntries(searchParams.entries());
23+
24+
const finalParams =
25+
segments !== null
26+
? filterParallelRouteParams(paramObject, segments)
27+
: paramObject;
1728
return computeRoute(path, finalParams);
1829
};
1930

31+
export function filterParallelRouteParams(
32+
params: Record<string, string | string[]>,
33+
segments: string[],
34+
): Record<string, string | string[]> {
35+
return Object.fromEntries(
36+
Object.entries(params).filter(([, value]) => {
37+
if (!Array.isArray(value)) return true;
38+
const joined = value.join('/');
39+
return joined.includes('/') && segments.includes(joined);
40+
}),
41+
);
42+
}
43+
2044
// !! important !!
2145
// do not access env variables using process.env[varname]
2246
// some bundlers won't replace the value at build time.

0 commit comments

Comments
 (0)