next.js: [NEXT-1160] Clicking Links in intercepted routes does not unmount the interceptor route

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
      Platform: linux
      Arch: x64
      Version: #41~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 31 16:00:14 UTC 2
    Binaries:
      Node: 18.12.1
      npm: 8.19.2
      Yarn: 1.22.19
      pnpm: N/A
    Relevant packages:
      next: 13.4.2-canary.5
      eslint-config-next: N/A
      react: 18.2.0
      react-dom: 18.2.0
      typescript: N/A

Which area(s) of Next.js are affected? (leave empty if unsure)

App directory (appDir: true), Routing (next/router, next/navigation, next/link)

Link to the code that reproduces this issue

https://codesandbox.io/p/github/mkarajohn/nextgram/draft/restless-leftpad?file=/components/frame/index.js

To Reproduce

  1. Open the codesandbox in a new separate tab in order to be able to see the actual browser URL
  2. Click on a photo in order to intercept the route and open the modal with the photo details.
  3. Instead of clicking on the overlay click the “go back” link instead, located just below the photo

Describe the Bug

When you click the Link and the URL changes to / the interceptor route remains mounted and does not go away.

Expected Behavior

I would expect that since the route was changed (via Link no less) the interceptor page should disappear once the route that it was intercepting was changed.

Screencast from 11-05-2023 04:23:04 ΜΜ.webm

Which browser are you using? (if relevant)

No response

How are you deploying your application? (if relevant)

No response

NEXT-1160

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 43
  • Comments: 59 (5 by maintainers)

Commits related to this issue

Most upvoted comments

Should really mark it as experimental feature considering how unfinished intercepting/parallel routes are. I’ve wasted too much time trying to get this to work.

@feedthejim Why is this issue closed? This is still a problem.

Also, it is somehow related to what @mkarajohn is saying - router.push() doesn’t seem to work either. Only router.back() does, but in my case, I don’t want to use router.back(). When someone gets sent the link with the modal opened then they click on the button to close it, router.back() doesn’t make sense. It would be better to call router.push() and navigate the user to the route behind the modal.

Working setup I have is here: https://github.com/lmatteis/nextgram

Apparently you need the right page.js that returns null at the right level to tell the slot to not render anything.

image

Has there been any update or progress on this? Ive just been implementing some parallel / intercepting routes for a model preview and discovered it not possible to navigate from the preview modal to the actual page. I think this is the same issue as this thread unless i’m missing something with how to navigate from within the modal 🤷

Has there been any word of progress on this? this seems annoyingly buggy, especially for something outright recommended in the docs 😦

I fixed this by making the modal itself a client component and checking if it should show based on the pathname

// /@modals/(.)posts/create/page.tsx
"use client";

import { usePathname } from "next/navigation";

export default function Dialog() {
  const pathname = usePathname();
  const shouldShowModal = pathname.includes("/posts/create");
  if (!shouldShowModal) return null;
  return (
    <div>my modal content</div>
  );
}

Definitely a bug though, I shouldn’t have to do this. Are intercepting routes ready for production?

Just thought of it now, I guess it’s worth noting that you could make the layout a client component and conditionally render the modal based on the pathname to fix it entirely. Hopefully that could help for now until it’s sorted out.

//(some-group)/layout.tsx
"use client";
import { usePathname } from "next/navigation";

export default function GroupLayout({
	children,
	modal,
}: {
	children: React.ReactNode;
	modal: React.ReactNode;
}) {
	const pathname = usePathname();
	const shouldShowModal = pathname.includes("/photos/");
	return (
		<div>
			{shouldShowModal && <div>{modal}</div>}
			<div>{children}</div>
		</div>
	);
}

@Apollo-XIV I was able make intercepted routes unmounted by creating dummy page routes under the @slot folder. For example, if we have page.tsx, foo/page.tsx and bar/[id]/page.tsx, creating @modal/page.tsx, @modal/foo/page.tsx and @modal/bar/[id]/page.tsx helps with unmounting. If we open @modal/(.)foobar/page.tsx and then navigate away from the intercepted /foobar route, Next.js renders another route in the @modal slot.

You can find an example in https://github.com/vercel/next.js/issues/53170. It’s a bug report related to multiple route groups, but if you have just one group with one layout, the trick with dummy routes might do the unmounting for you.

so,when can the official solve this problem? This problem has caused great trouble

Same issue. In my case I’m trying to redirect to another page using router.push() however, while it redirects to the correct page, the modal stays up. My solution to this for now is just checking the current pathname like other users suggested.

I tried changing the Link with

    <button
      className="action"
      onClick={() => {
        router.push(`/`);
      }}
    >
      Close
    </button>

and this still does not work, while

    <button
      className="action"
      onClick={() => {
        router.back();
      }}
    >
      Close
    </button>

this works.

So, router.back() (moving back in the history stack) and router.push() (adding to the history stack) behave differently somehow for this case? They both change the url to / in the end. I am confused at how it’s determined that in one case the route changed while in the other it’s treated as if it did not.

Okay, I have done some significant work here to make this work for me. Here is the context:

I will do everything in my power to stay away from client-side state management. I love using routes to manage all of my locational state (including modals). I noticed that whenever I was doing this, redirect("/profile") did not redirect me back! Instead, my modals stayed open.

Using hints from above, I discovered that what is really important is the layout on which your modal is being rendered. That is, you should be rendering your slot in the layout that minimally contains your intercept routes.

So, if I have a route structure like such:

/profile
/profile/group/[id]/edit // A modal.
/profile/group/[id]/delete // A modal.

I really only want to be slotting in the minimal containing folder. Here, that would be group. Let’s discuss why we want this.

Why Only Grab the Minimum?

Let’s start by describing our file structure. Here, I lay out what we do not want.

/profile
/profile/layout.tsx
/profile/@dialog/(.)group/[id]/edit 
/profile/@dialog/(.)group/[id]/delete

The layout.tsx here looks something like…

import { ReactNode } from "react";

export default function Layout({
  children,
  dialog,
}: {
  children: ReactNode;
  dialog: ReactNode;
}) {
  return (
    <>
      {children}
      {dialog}
    </>
  );
}

Now, if I am in the edit modal and I redirect back to /profile, am I changing the layout properties? No. I might argue that I should be, but right now you are not. Instead, you are just saying “rerender the layout in the state you already have”, which still contains your slot.

How to grab the minimum

I’ll keep the answer simple: we want to use route groups.

/profile
/profile/layout.tsx
/profile/(modal)/layout.tsx
/profile/(modal)/@dialog/(.)group/[id]/edit 
/profile/(modal)/@dialog/(.)group/[id]/delete

There are some nuances here. First, remember that we do not want the profile/layout.tsx to be rendering the @dialog slot. Do not do that. Instead, use the (modal)/layout.tsx to render your dialog slot. Second, do not use (modal)/layout.tsx to render children either. Only use it to render your dialog slot.

// /profile/layout.tsx
import { ReactNode } from "react";

export default function Layout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <>
      {children}
    </>
  );
}
// (modal)/layout.tsx
import { ReactNode } from "react";

export default function Layout({
  dialog,
}: {
  dialog: ReactNode;
}) {
  return (
    <>
      {dialog}
    </>
  );
}

Summary

Like @IvanRomanovski said- this is likely not a bug, we’re just not using the system right. Now, that’s arguably a bad take from a DX perspective and I do think that this should be changed. But hopefully this sheds some light onto the complexities behind this.

@Apollo-XIV I was able make intercepted routes unmounted by creating dummy page routes under the @slot folder. For example, if we have page.tsx, foo/page.tsx and bar/[id]/page.tsx, creating @modal/page.tsx, @modal/foo/page.tsx and @modal/bar/[id]/page.tsx helps with unmounting. If we open @modal/(.)foobar/page.tsx and then navigate away from the intercepted /foobar route, Next.js renders another route in the @modal slot.

You can find an example in #53170. It’s a bug report related to multiple route groups, but if you have just one group with one layout, the trick with dummy routes might do the unmounting for you.

This is exactly what I needed to do to workaround this without additional code. I was missing @modal/page.tsx but had a page.tsx file in all subsequently nested @modal routes. e.g) @modal/(.)add/page.tsx, @modal/(.)edit/page.tsx.

It is not documented in Next docs this is required which is perhaps the biggest culprit that could easily be updated to ease a lot of developer pain from this.

So in summary for my case, the @modal needed a page.tsx null return similar to default.tsx to wipe out the intercepted route with router.push() after saving in the modal without needing router.back() or any other code workarounds.

If you aren’t use .tsx but .ts or .js in this file structure you have to have them match all the way through for it to work too.

Looks like it is somehow broken by design, hm. what if there are two intercepting routes, say, one to /sign-in and another one to /sign-up, which both lead to a modal dialog, and there is a link from signin modal to signup modal and in other direction as well, which is a common scenario))

Just to clarify, I’m very grateful for your comment. Sorry if my quote seemed too assertive.

Intercepted routes is perfect for so many things I want to do, so I would love to see these issues ironed out. I would probably have no chance, but if someone can point me to the right places I would love to at least try to help out here.

I agree. One of the biggest selling points of migrating to NextJS app/ directory for me was the intercept/parallel routes feature. It’s a bit disheartening right now to have fully migrated to it and so many things are broken.

If these aren’t fixed, I will ultimately just have to go back to the old way of doing things (parallel/intercept routes with useSearchParams -> regular React state/manually change URL).

Any update on this? This issue still makes the feature completely unusable for many use cases, or extremely convoluted at minimum with all the workarounds needed.

I have also found another issue that simply breaks the router history if stumbled upon. My previous repo that was linked in this thread a while back has been updated to latest 13.4.20-canary.31. Repo: https://github.com/vetledv/repro-intercept

To reproduce this issue:

  • Open a photo.
  • Click on “Link to random photo” as many times as you like.
  • Refresh the page.
  • Navigate back to any of the intercepted pages. The path will update, but every single intercepted path in the history will simply not render. Any path that was intercepted in the history will need to be refreshed in order to render (individually).

Example: Navigate to three random pages. Refresh the third page. Go to second page, won’t render. Refresh. Go back to the first intercepted page. Also won’t render.

I see now that this must be similar to @extrabright 's issue. Maybe a feature to pop all intercepted routes would be beneficial? Edit: Specifying if you want to intercept or not could be nice maybe. This issue would be avoided if it wasn’t possible to intercept from an intercepted route.

Intercepted routes is perfect for so many things I want to do, so I would love to see these issues ironed out. I would probably have no chance, but if someone can point me to the right places I would love to at least try to help out here.

@Apollo-XIV I was able make intercepted routes unmounted by creating dummy page routes under the @slot folder. For example, if we have page.tsx, foo/page.tsx and bar/[id]/page.tsx, creating @modal/page.tsx, @modal/foo/page.tsx and @modal/bar/[id]/page.tsx helps with unmounting. If we open @modal/(.)foobar/page.tsx and then navigate away from the intercepted /foobar route, Next.js renders another route in the @modal slot.

You can find an example in #53170. It’s a bug report related to multiple route groups, but if you have just one group with one layout, the trick with dummy routes might do the unmounting for you.

You are an absolute lifesaver, you’ve easily saved me hours of finding a workaround. Thank you sm 😃)

Here’s a tiny example that reproduces the issue:

https://codesandbox.io/p/sandbox/nifty-bessie-d9632z

  1. Go to /test/dashboard
  2. Click on “/test/dashboard/modal1” link and note that “Page for Modal1” is displayed correctly in the modal slot
  3. Click on “/test/dashboard” link and note that “Page for Modal1” is still displayed

Adding @modal/[...catchAll]/page.tsx as suggested in the docs here didn’t help.

Don’t want to use router.back due to the reasons described by @tiersept

+1 The Modal slot mechanism is buggy. I have forced redirects or used router.back, while the url changes, the ModalInterceptor always get executed. Sometimes, the new route Im trying to go to gets properly executed and others it never does, as if it ended on the intercepted route and thats it.

Yeah as far as I can tell catch all routes just don’t work for clearing an intercepted route. See https://github.com/vercel/next.js/issues/48719 and https://github.com/vercel/next.js/issues/49531

IMHO the cleanest workaround is to create a layout inside the slot and conditionally render children based on the output of the usePathname hook. If you have the following folder structure:

Screenshot from 2023-11-04 22:11:16

You can create the following layout inside @modal:

"use client";

import {usePathname} from "next/navigation";

export default function Layout({children}) {
  const pathname = usePathname();
  return pathname.startsWith("/posts/") ? children : null;
}

If you have multiple intercepting routes this seems to be less messy than rendering conditionally inside the parent layout (especially if the parent layout is the root layout, which can’t be made a client component).

The cleanest solution would obviously be using catch-all routes, but they’re still broken.

This is still a big issue. Working off the oficial nextgram example for reference, if you want to link away to a different part of the app from the intercepted, parallel route modal you’re just stuck with it wherever you go…

Catch-all pattern suggested by the docs seems to do nothing. Placing a null returning page.tsx like @lmatteis suggested works if you’re linking back to / but if you’re linking somewhere else in the app like /dashboard then the modal just stays open…

I know this is bit out of discussion but we also tried implementing this in our e-commerce but now we are trying to evaluate if the use case was right

in our case we made something that product details view would show product image and pricing details and some few other thing+add to cart button in the intercepted route

and in the actual route::

we would like like to see all the above + things like product description suggested products reviews…etc…

we tried finding way to expand from intercepted route to actual route and we could not find… (maybe something like (intercept=false so default behavior is intercept=true for backward compatibility) to the Link as a prop i don’t know how easy that is of course to implement

That could maybe also be tackled together with this coz we even ended up adding router called mini-product/[id] to intercept then route.push could go to product/[id] but as the issue suggest that was not possible and also beats the all logic of our intended use case for instance

I suppose the only reason it works is because it is no longer rendering the layout containing the modal. And yeah, the catchAll or any of the suggestions does not seem to work. Would like to see this reopened.

@vetledv thanks for this, very informative, I played with it a little. Like you said, the grouping is the only thing that seems to be “fixing” this behaviour, nice find. The [...catchAll] route doesn’t seem to do anything, can be deleted without effect.

And, again, like you said, it does not seem like “intended behaviour” considering you cannot return to / using a Link, since it’s in the same group.

Still not figured it out so any help is appreciated. Meanwhile I managed to get the behaviour I needed using a context provider which tracks the amount pathname changes made inside the modal. When I want to close the modals, I loop the counter, smth like this, since router.back() is the only way to close this intercepted routes:

if (clickCount === 0) {
      router.back()
    }
    else {
      for (let i = 0; i < clickCount; i++) {
        router.back()
      }
    }
    
    clearClickCount()

So far it works pretty good with no glitches. However, I am hoping for official support on this issue.

This is still a big issue. Working off the oficial nextgram example for reference, if you want to link away to a different part of the app from the intercepted, parallel route modal you’re just stuck with it wherever you go…

Catch-all pattern suggested by the docs seems to do nothing. Placing a null returning page.tsx like @lmatteis suggested works if you’re linking back to / but if you’re linking somewhere else in the app like /dashboard then the modal just stays open…

Okay so, in case it helps anyone, the only way to handle this right now is to isolate the @slot and the layout.tsx which uses it in it’s own space (either nested or using a route group), so that this layout.tsx does not cover the pages where your modal is not required. The nextgram example isn’t good for this because it’s the root layout that uses the slot (modal) so all pages you make in there will be covered by it.

Ran into the same issue and the usePathname technique mentioned by @vetledv worked for me. 🙏 🙏 🙏
Would be nice to have a proper way though

@mkarajohn I see now that it only works in my specific use page (I was linking to an equivalent of /hello in this example). Here is a repo that I believe should cover all the behaviours. This does very much not seem like intended behaviours. https://github.com/vetledv/repro-intercept

+1 I’m having this issue as well. Like OP, I’ve also tried using the the catch all route and putting default.tsx in various places but nothing works.

The only way for me to close the modal seems to be to use router.back() since using a Link to the previous page changes the URL but leaves the modal opened.