Skip to content

Commit

Permalink
fix useSelectedLayoutSegment's support for parallel routes (#60912)
Browse files Browse the repository at this point in the history
fixes NEXT-2173
Fixes #59968

### TODOs

- [x] recreate [repro](https://github.com/williamli/nextjs-NEXT-2173) 
- [x] patch `useSelectedLayoutSegment` to support parallel routes (see
"What")
- [x] check `useSelectedLayoutSegments` to see if it is affected
- [x] add test cases
- [x] finalise PR description

### What?

`useSelectedLayoutSegment` does not return the name of the active state
of parallel route slots.

#### Expected Behaviour

According to
https://nextjs.org/docs/app/building-your-application/routing/parallel-routes#useselectedlayoutsegments

> When a user navigates to app/@auth/login (or /login in the URL bar),
loginSegments will be equal to the string "login".

👉🏽 We should update the docs to explain `null` and __DEFAULT__ result as
well.

According to the [API reference for
useSelectedLayoutSegment](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment#returns):

> useSelectedLayoutSegment returns a string of the active segment or
null if one doesn't exist.

> For example, given the Layouts and URLs below, the returned segment
would be:

> <img width="881" alt="CleanShot 2024-01-20 at 14 50 52@2x"
src="https://github.com/vercel/next.js/assets/179761/bfaa34c8-3139-4ec3-bd70-4346c682e36b">


#### Current Behaviour

Currently a string "children" is returned for everything inside a
parallel route with active state and `__DEFAULT__` is returned if there
is no active state for the parallel route (since the `default.tsx` is
loaded). ~`null` is returned when the `default.tsx` is not loaded
(possibly caused by another bug, see test case 5).~

#### Reproduction

[GitHub Repo](https://github.com/williamli/nextjs-NEXT-2173) is created
based on the example provided in [Next.js docs for using
`useSelectedLayoutSegment` with Parallel
Routes](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes#useselectedlayoutsegments).

#### Test Cases

1. If you visit https://next-2173.vercel.app/, you get loginSegments:
__DEFAULT__ (hard navigation) or children (soft navigation after
returning from a visit to /login)
2. If you soft nav to (/app/@auth/login and /app/@nav/login)
https://next-2173.vercel.app/login, you get
    1. loginSegment: `children` (expected value should be `login`)
    2. navSegment: `children` (expected value should be `login`)
3. If you soft nav to (/app/@auth/reset)
https://next-2173.vercel.app/reset, you get
    1. loginSegments: `children` (expected value should be `reset`)
    2. navSegment: `children` (expected value should be `login`)
4. If you soft nav to (/app/@auth/reset/withEmail)
https://next-2173.vercel.app/reset/withEmail, you get
    1. loginSegments: `children` (expected value should be `withEmail`)
    2. navSegment: `children` (expected value should be `login`)
5. ~If you hard nav to (/app/@auth/reset/withEmail)
https://next-2173.vercel.app/reset/withEmail, you get an unexpected
result due to possibly another bug:~
* ~navSegment is `null` on the deployed (Vercel) version, the navSlot is
*not* loaded~
* ~navSegment is `__DEFAULT__` on local dev, the navSlot loads
`/app/@nav/default.tsx`.~


### Why?

In `packages/next/src/client/components/navigation.ts`,
`getSelectedLayoutSegmentPath` is called and returns the correct
segmentPath for parallel routes (even though there is a TODO comment
indicating this function needs to be updated to handle parallel routes)
but `useSelectedLayoutSegment` failed to return the correct segment when
a parallelRouteKey is provided.

### How?

`useSelectedLayoutSegment` is updated to return
selectedLayoutSegments[0] for non parallel routes (original logic), but
it will return the last segments for parallel routes (or null if nothing
is active).

```
return parallelRouteKey === 'children'
    ? selectedLayoutSegments[0]
    : selectedLayoutSegments[selectedLayoutSegments.length-1] ?? null
```

---------

Co-authored-by: Zack Tanner <zacktanner@gmail.com>
  • Loading branch information
williamli and ztanner committed Jan 23, 2024
1 parent b2b654d commit 78c9793
Show file tree
Hide file tree
Showing 15 changed files with 178 additions and 3 deletions.
14 changes: 11 additions & 3 deletions packages/next/src/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '../../shared/lib/hooks-client-context.shared-runtime'
import { clientHookInServerComponentError } from './client-hook-in-server-component-error'
import { getSegmentValue } from './router-reducer/reducers/get-segment-value'
import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment'
import { PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment'

const INTERNAL_URLSEARCHPARAMS_INSTANCE = Symbol(
'internal for urlsearchparams readonly'
Expand Down Expand Up @@ -180,7 +180,6 @@ export function useParams<T extends Params = Params>(): T {
}, [globalLayoutRouter?.tree, pathParams])
}

// TODO-APP: handle parallel routes
/**
* Get the canonical parameters from the current level to the leaf node.
*/
Expand Down Expand Up @@ -243,7 +242,16 @@ export function useSelectedLayoutSegment(
return null
}

return selectedLayoutSegments[0]
const selectedLayoutSegment =
parallelRouteKey === 'children'
? selectedLayoutSegments[0]
: selectedLayoutSegments[selectedLayoutSegments.length - 1]

// if the default slot is showing, we return null since it's not technically "selected" (it's a fallback)
// and returning an internal value like `__DEFAULT__` would be confusing.
return selectedLayoutSegment === DEFAULT_SEGMENT_KEY
? null
: selectedLayoutSegment
}

export { redirect, permanentRedirect, RedirectType } from './redirect'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/@auth/default.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/@auth/login/page.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/@auth/reset/page.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/@auth/reset/withEmail/page.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/@auth/reset/withMobile/page.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/@nav/default.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/@nav/login/page.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/default.tsx</div>
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/foo/page.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client'

import { useSelectedLayoutSegment } from 'next/navigation'
import Link from 'next/link'

export default function RootLayout({
children,
auth,
nav,
}: Readonly<{
children: React.ReactNode
auth: React.ReactNode
nav: React.ReactNode
}>) {
const authSegment = useSelectedLayoutSegment('auth')
const navSegment = useSelectedLayoutSegment('nav')
const routeSegment = useSelectedLayoutSegment()

return (
<html lang="en">
<body>
<section>
<nav>
<Link href="/">Main</Link>
<Link href="/foo">Foo (regular page)</Link>
<Link href="/login">
Login (/app/@auth/login) and (/app/@nav/login)
</Link>
<Link href="/reset">Reset (/app/@auth/reset)</Link>
<Link href="/reset/withEmail">
Reset with Email (/app/@auth/reset/withEmail)
</Link>
<Link href="/reset/withMobile">
Reset with Mobile (/app/@auth/reset/withMobile)
</Link>
</nav>
<div>
navSegment (parallel route): <div id="navSegment">{navSegment}</div>
</div>
<div>
authSegment (parallel route):{' '}
<div id="authSegment">{authSegment}</div>
</div>
<div>
routeSegment (app route):{' '}
<div id="routeSegment">{routeSegment}</div>
</div>

<section id="navSlot">{nav}</section>
<section id="authSlot">{auth}</section>
<section id="children">{children}</section>
</section>
</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/app/page.tsx</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}

module.exports = nextConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createNextDescribe } from 'e2e-utils'
import { check } from 'next-test-utils'

createNextDescribe(
'parallel-routes-use-selected-layout-segment',
{
files: __dirname,
},
({ next }) => {
it('hard nav to router page and soft nav around other router pages', async () => {
const browser = await next.browser('/')
await check(() => browser.elementById('navSegment').text(), /^$/)
await check(() => browser.elementById('authSegment').text(), /^$/)
await check(() => browser.elementById('routeSegment').text(), /^$/)

await browser.elementByCss('[href="/foo"]').click()
await check(() => browser.elementById('navSegment').text(), /^$/)
await check(() => browser.elementById('authSegment').text(), /^$/)
await check(() => browser.elementById('routeSegment').text(), /foo/)
})

it('hard nav to router page and soft nav to parallel routes', async () => {
const browser = await next.browser('/')
await check(() => browser.elementById('navSegment').text(), /^$/)
await check(() => browser.elementById('authSegment').text(), /^$/)
await check(() => browser.elementById('routeSegment').text(), /^$/)

// soft nav to /login, since both @nav and @auth has /login defined, we expect both navSegment and authSegment to be 'login'
await browser.elementByCss('[href="/login"]').click()
await check(() => browser.elementById('navSegment').text(), /login/)
await check(() => browser.elementById('authSegment').text(), /login/)
await check(() => browser.elementById('routeSegment').text(), /^$/)

// when navigating to /reset, the @auth slot will render the /reset page ('reset') while maintaining the currently active page for the @nav slot ('login') since /reset is only defined in @auth
await browser.elementByCss('[href="/reset"]').click()
await check(() => browser.elementById('navSegment').text(), /login/)
await check(() => browser.elementById('authSegment').text(), /reset/)
await check(() => browser.elementById('routeSegment').text(), /^$/)

// when navigating to nested path /reset/withEmail, the @auth slot will render the nested /reset/withEmail page ('reset') while maintaining the currently active page for the @nav slot ('login') since /reset/withEmail is only defined in @auth
await browser.elementByCss('[href="/reset/withEmail"]').click()
await check(() => browser.elementById('navSegment').text(), /login/)
await check(() => browser.elementById('authSegment').text(), /withEmail/)
await check(() => browser.elementById('routeSegment').text(), /^$/)
})

it('hard nav to router page and soft nav to parallel route and soft nav back to another router page', async () => {
const browser = await next.browser('/')
await check(() => browser.elementById('navSegment').text(), /^$/)
await check(() => browser.elementById('authSegment').text(), /^$/)
await check(() => browser.elementById('routeSegment').text(), /^$/)

// when navigating to /reset, the @auth slot will render the /reset page ('reset') while maintaining the currently active page for the @nav slot ('null') since /reset is only defined in @auth
await browser.elementByCss('[href="/reset"]').click()
await check(() => browser.elementById('navSegment').text(), /^$/)
await check(() => browser.elementById('authSegment').text(), /reset/)
await check(() => browser.elementById('routeSegment').text(), /^$/)

// when soft navigate to /foo, the @auth and @nav slot will maintain their the currently active states since they do not have /foo defined
await browser.elementByCss('[href="/foo"]').click()
await check(() => browser.elementById('navSegment').text(), /^$/)
await check(() => browser.elementById('authSegment').text(), /reset/)
await check(() => browser.elementById('routeSegment').text(), /foo/)
})

it('hard nav to parallel route', async () => {
const browser = await next.browser('/reset/withMobile')
await check(() => browser.elementById('navSegment').text(), /^$/)
await check(() => browser.elementById('authSegment').text(), /withMobile/)

// the /app/default.tsx is rendered since /reset/withMobile is only defined in @auth
await check(() => browser.elementById('routeSegment').text(), /^$/)
})
}
)

0 comments on commit 78c9793

Please sign in to comment.