Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow absolute path change without destroying rendered components (Twitter-style modals) #703

Closed
LinusBorg opened this issue Sep 30, 2016 · 96 comments
Labels
feature request fixed on 4.x This issue has been already fixed on the v4 but exists in v3 has workaround

Comments

@LinusBorg
Copy link
Member

LinusBorg commented Sep 30, 2016

Status Quo

In vue-router, the URL is the single source of truth. That means that no matter weither the user triggers a route from an external link, by typing out the URL himself or by clicking on a <router-link> inside our app, the same route if the URL is the same, then the same routes will be matched, and the same components will be rendered - no side-effects are taken into account.

I consider this to be a sensible, good approach. But there are situations where this behaviour is undesired:

The problem

If you go to Twitter's website, and open a tweet from any place (your own timeline, someone else's timeline, a list ...), three things happen:

  1. A modal with the tweet opens
  2. the URL changes to an absolute URL pointing to that tweet
  3. Something doesn't change, though: the background of the modal. Even though we started from e.g. twitter.com/linus_borg and went to twitter.com/vuejs/status/xxxxx when we opened the modal, my profile's timeline is still in the brackground.

The user could simply copy & paste the tweets URL, post it somewhere, go back to twitter, close the modal and continue browsing my profile. Great!

Butr currently, this is not possible with vue-router. With vue-router, we would either have to

  • render the modal as a child-route of my timeline, which means the URL would not point to the tweet really, but to /linus_borg_/status/vuejs/status/... or something.
  • render the tweet route as an absolute path, but then the background view (my profile) would be destroyed.

Downsides

This breaks vue-routers philsophy of the URL as the only source of truth, because opening a tweet from timeline leads to a different view than opening the tweet's URL directly.

Proposed Solution(s)

Here this proposal becomes thin, and I didn't add the discussion label for nothing:

I don't really have an idea on how to approach this. I know that it's probably very hard to implement this in vue-router as it working different to the whole route-matching pattern etc, so I'm open to ideas.

Maybe, we could at least implement a way to "pause" the router's URL change callback? So the user can "deactivate" the router momentarily, change the URL himself, show the modal, and on closing the modal, reactivate the router?

Or maybe this could be accomplished with named router views, if we allow route that only point to a named view , so the "normal components, e.g. the profile, stay rendered, and we show the overlay in a named view? (tested this, doesn't work right now).

Anyways, I think this wold be a great feature for many scenarios (Spotify's uses this for its ovelays showing album / playlist / artist views anywhere, for example)

So, what do you guys think? Any opinions? Ideas for solutions?

@posva
Copy link
Member

posva commented Sep 30, 2016

I think this can be done with history.pushState:

  1. You can pushState to replace the url when the modal shows up: history.pushState({}, null, '/tweet/3')
  2. Then do back to dismiss it: history.back()
  3. If you leave the route, you can also call history.back() to pop that state

I tried on a project and works like a charm. It's quite manual, so we may consider leveraging that.

Edit: This went through an RFC for v4 and is merged and supported through the route prop on <router-view>. There is an e2e test on vue-router next repo for modals

@fnlctrl
Copy link
Member

fnlctrl commented Sep 30, 2016

I think this is already possible with 2.0 because child routes can have a root path. ;)
It's not quite a noticeable change, so you probably have missed that:
https://github.com/vuejs/vue-router/blob/dev/examples/nested-routes/app.js#L39-L43

To achieve your example:

{ 
  // /linus_borg
  path: '/:userId', component: Parent,
    children: [
      // NOTE absolute path here!
      // this allows you to leverage the component nesting without being
      // limited to the nested URL.
      // components rendered at /baz: Root -> Parent -> Baz
      // /vuejs/status/xxxxx
      { path: '/:organization/status/:xxxxx', component: Baz }
   ]
}

@LinusBorg
Copy link
Member Author

LinusBorg commented Sep 30, 2016

@posva ...but pushstate will trigger a popstate event which will be picked up by the router... Which will re-route, which we don't want.

@LinusBorg
Copy link
Member Author

LinusBorg commented Sep 30, 2016

@fnlctrl but this would to duplicate paths in the pathMap if I wanted that path as a root route and as a child route in multiple places...

@fnlctrl
Copy link
Member

fnlctrl commented Sep 30, 2016

@LinusBorg
I think duplicating paths in config is unavoidable if you need it('/vuejs/status/xxxxx') to be child route in multiple places.. as for the root route, there's only one to be defined, so no duplications.

Aside from that, I guess we can also try aliases?
http://router.vuejs.org/en/essentials/redirect-and-alias.html

[{ 
  // /linus_borg
  path: '/:userId', component: Parent,
    children: [
      { path: ':organization/status/:xxxxx', 
        component: Baz,
       // /vuejs/status/xxxxx
        alias: '/:organization/status/:xxxxx' }
   ],
}, {
   path: '/foo/bar', component: Parent,
    children: [
      { path: ':organization/status/:xxxxx', 
        component: Baz,
       // /vuejs/status/xxxxx again
        alias: '/:organization/status/:xxxxx' }
    ]
}, {
   path: '/:organization/status/:xxxxx', component: Baz
}]

@LinusBorg
Copy link
Member Author

Duplicates in the pathMap are not possible, the last one would override the previous one... So only one, the last one, would ever be recognizable.

@fnlctrl
Copy link
Member

fnlctrl commented Sep 30, 2016

I see... but in that case where you want the path as a child route in multiple places, it would be mapping /:organization/status/:xxxxx to multiple components...so what /:organization/status/:xxxxx shows will depend on app state.

I think what we really need is a url rewrite feature like the backend servers have. It would be like an alias without being registered into pathMap.

i.e.
User is on /linus_borg,
navigates to /linus_borg/vuejs/status/:xxxxx
url rewrites to /vuejs/status/:xxxxx, but no actual navigation is made.

From this point on:
When the user refreshes the page, it shows the root record that /vuejs/status/:xxxxx really maps to.
When the user clicks another link, everything goes on normally.

@LinusBorg
Copy link
Member Author

Yes, that's describes pretty much what I had in mind, thanks :)

@simplesmiler
Copy link
Member

I think going from /u/alice to /u/bob/s/something will reuse the user view component, so how about "freezing" updates on it? I recall Evan talking about ability to bypass reactive updates.

@nicolas-t
Copy link

I'm facing the exact same issue.
With backbone router you have a trigger option.
It allows you to update the url without making any route matching / rerender
http://backbonejs.org/#Router-navigate

that behaviour with router.push or router.replace would be awesome :)

@nicolas-t
Copy link

any update on this topic ?

@LinusBorg LinusBorg self-assigned this Oct 21, 2016
@LinusBorg
Copy link
Member Author

I 'm on this, but haven't found the time to finish it with tests and all.

Actually it's quite easy: You could, today, use the history API directly to change the URL with pushstate() or replaceState() and revert it back after e.g. the popup closes.

Since the "popstate" event will only be called by actually clicking on the browser buttons, this can be achieved without interfering with vue-router.

I'm working on providing a convenience function for this, but since I'll be on vacation until Nov. 2nd, this may take a while.

@jeerbl
Copy link

jeerbl commented Oct 25, 2016

This IS possible with vue-router 2, I just migrated my app to vue-router 2 to achieve this.

This code (adapted from here) will actually do what you want:

const User = {
  template: `
    <div class="user">
      <h2>User {{ $route.params.id }}</h2>
      <router-view></router-view>
    </div>
  `
}

const UserHome = { template: '<div>Home</div>' }
const UserProfile = { template: '<div>Profile</div>' }
const UserPosts = { template: '<div>Posts</div>' }

const router = new VueRouter({
  routes: [
    { path: '/user/:id', component: User, name: 'user',
      children: [
        // UserHome will be rendered inside User's <router-view>
        // when /user/:id is matched
        { path: '', component: UserHome, name: 'home' },

        // UserProfile will be rendered inside User's <router-view>
        // when /user/:id/profile is matched
        { path: 'profile', component: UserProfile, name: 'profile' },

        // UserPosts will be rendered inside User's <router-view>
        // when /posts is matched
        { path: '/posts', component: UserPosts, name: 'posts' }
      ]
    },
    { path: '/posts', component: UserPosts, name: 'posts-permalink' }
  ]
})

const app = new Vue({ router }).$mount('#app')
<div id="app">
  <p>
    <router-link :to="{name: 'home', params: {id: 'foo'}}">/user/foo</router-link>
    <router-link :to="{name: 'profile', params: {id: 'foo'}}">/user/foo/profile</router-link>
    <router-link :to="{name: 'posts', params: {id: 'foo'}}">/posts (with user ID context)</router-link>
    <router-link :to="{name: 'posts-permalink'}">/posts (permalink)</router-link>
  </p>
  <router-view></router-view>
</div>

Basically you can go to the route named posts by sending the id of the user. It will have the URL /posts but will contain the information about the user, and the UserPosts component will be inserted in the router-view element of the User component (like on Twitter, the content displayed as modal above the user feed). Please note that the URL does not contain the user id.

Now, if you refresh the page, as the URL has no information regarding the user context (here the id of the user), it will match the route posts-permalink, therefore not displaying the context in which this modal was opened in the first place. The component UserPosts will then be inserted in the root app router-view element.

To summarize, both posts and posts-permalink have the same URL, just different contexts.

Just like on Twitter.

Hope this helps
Jérôme

@LinusBorg
Copy link
Member Author

LinusBorg commented Nov 18, 2016

Will have to test this, looks good. Though I'm not sure weither the definition of the same absolute path in different places can produce unwanted side-effects e.g. for the pathMap. will look into this.

If that works out as described though, I will be happy to close this issue.

@LinusBorg LinusBorg added the 2.x label Nov 18, 2016
@jeerbl
Copy link

jeerbl commented Nov 18, 2016

@LinusBorg The first route matching will be the one chosen. In this case, when going to /posts directly, the router will look for it, will see/user/:id therefore will continue and then will find /posts, the matching route.

About priorities between routes, in the vue-router documentation, see Dynamic Route Matching > Matching Priority:

Sometimes the same URL may be matched by multiple routes. In such a case the matching priority is determined by the order of route definition: the earlier a route is defined, the higher priority it gets.

@LinusBorg
Copy link
Member Author

Hm yeah that should do it ...

@bradroberts
Copy link

@jeerbl I'm trying to implement this and here's what I found...

  1. On a profile page, visitor clicks "posts" and a modal opens with posts (via child router-view)
  2. Visitor clicks back button and modal disappears. All good.
  3. Visitor then clicks forward button expecting the modal to re-open, but instead they're redirected to the posts permalink route.

I'm guessing this is because the history push was not initiated from Vue Router so you lose the ability to tell Vue Router which route you want to use.

@jeerbl
Copy link

jeerbl commented Nov 29, 2016

@bradroberts Yes indeed. Got no solution for this yet. On Twitter it works fine. I'll try to find a way.

@bradroberts
Copy link

bradroberts commented Dec 1, 2016

This seems a little hackish, but it solved the back/forward button issue for me. In keeping with the example, the parent component (User) can intercept the route change (in this case when the visitor uses the browser's forward button) using the beforeRouteLeave hook and change it to the desired route. I found that using router.push adds an additional history entry, likely because Vue Router has already added the new entry by the time the beforeRouteLeave hook is called. However, replace seems to work.

const User = {
  beforeRouteLeave (to, from, next) {
    if (to.name === 'posts-permalink') {
      router.replace({ name: 'posts'});
    } else {
      next();
    }
  }
}

It's worth noting that this will also affect route changes initiated by <router-link>, so if you actually wanted the visitor to navigate to the posts-permalink route (from the child posts route), you couldn't do so. I'm not sure how to address that unless there's a way to differentiate between a router-link click and the browser's back/forward buttons.

@Maidomax
Copy link

Maidomax commented Mar 10, 2017

@LinusBorg I think it would still make sense to implement something like the BackboneJS functionality @nicolas-t mentioned. Just explicitly say to the router: "Ok, now just change the route silently and don't do any thing about it". This is much easier to reason about. I read through the example given by @jeerbl several times, and I still can't figure out how or if I can apply it in my situation.

@hworld
Copy link
Contributor

hworld commented Mar 22, 2017

I was just about to post an issue similar and then I saw this. What @nicolas-t suggested seems like it would be perfect. Basically an "update the URL silently". I'm thinking this may be the easiest way forward? Since vue-router can operate in different modes, I would like to use vue-router to always change the URL. This way it can work in history mode or the hash fallback thing.

My scenario is just paging through the comments section of a page. I would like to change the comment page query param without refreshing the whole page. This way the URL could be shared with others and that particular comment page would show.

@99Percent
Copy link

Any updates on this? I really need this. Right now I have both routes for the modals (like login, register, product, etc) and in place rendering. But I have to sacrifice the user unable to click back button for closing the modal, and have to add a perma-link box, because in place rendering cannot update the browser url :(

@nilsi
Copy link

nilsi commented Apr 28, 2017

Would love something like this feature nicolas-t was talking about. Im building a product modal where a user can navigate between different products in the same modal.

@TitanFighter
Copy link

TitanFighter commented Oct 8, 2019

Few notes regarding @tmiame solution #703 (comment)

NOTE 1
His package.json installs:
vue ^2.5.17
vue-router ^3.0.1

but for now we have:
vue ^2.6.10
vue-router ^3.1.3

It throws 2 warnings in console regarding this part of code:

...
{
  path: '/:userId',
  name: 'user',
  alias: 'userTweets',
  component: User,
  meta: {
    twModalView: true
  },
  children: []
}
...

Warnings:

[vue-router] Named Route 'user' has a default child route. When navigating to this named route (:to="{name: 'user'"), the default child route will not be rendered. Remove the name from this route and use the name of the default child route for named links instead.

[vue-router] Non-nested routes must include a leading slash character. Fix the following routes:

  • userTweets/
  • userTweets/profile
  • userTweets/media
  • userTweets

Fixes:

  1. Remove name: 'user',
  2. Either change alias: 'userTweets', to alias: '/userTweets', or simply remove alias (I did not find the purpose of this alias - everything works ok without it).

NOTE 2
Just want to highlight, that it is necessary to add named router-view to App.vue, like <router-view class="app_view_modal" name="modal" />. Shown here.

NOTE 3
In my case I have Profile route with children which looks like navigation with active/highlighted current tab:

{
  path: '/:profileUrl',
  component: Profile,
  meta: {
    twModalView: true
  },

  children: [
    {
      path: '',
      name: 'ProfileDetails',
      component: ProfileUser
    },

    {
      path: 'events',
      name: 'Events',
      component: ProfileEvents
    },

    {
      path: 'photos',
      name: 'Photos',
      component: ProfilePhotos
    }
  ]
}

When needed modal is opened, in console I see:

[vue-router] missing param for named route "ProfileDetails": Expected "profileUrl" to be defined
[vue-router] missing param for named route "Events": Expected "profileUrl" to be defined
[vue-router] missing param for named route "Photos": Expected "profileUrl" to be defined

This is due to the fact that my modal does not have profileUrl. In order to fix it, it was necessary to add Object.assign(to.params, from.params) below to.matched[0].components.modal = TweetModal, so the block should look like:

if (to.matched[0].components) {
  // Rewrite components for `default`
  to.matched[0].components.default = from.matched[0].components.default
  // Rewrite components for `modal`
  to.matched[0].components.modal = TweetModal

  // Fix [vue-router] missing param for named route
  Object.assign(to.params, from.params) // <<<<<------
}

Be careful, if you use urls ids, it will replace "to id" by "from" id, so you need to use another way instead of Object.assign.

NOTE 4
Instead of the code which is shown above, I actually have:

children: [
  {
    path: '',
    name: 'ProfileDetails',
    // component: ProfileUser <<<<<------ this is shown above
    
    // This is what I actually have
    components: {
        user: ProfileUser,
        place: ProfilePlace,
        organization: ProfileOrganization
    }
  },
  {},
  {}
]

with the RouterView which looks like (profileType can be one of user/place/organization) ...:

<RouterView :name="$route.name === 'ProfileDetails' ? profileType : 'default'" />

... there is a problem -> if I open a modal inside ProfileDetails, profile data disappears, because we actually replace ProfileDetails route by modal's route (in case of @tmiame's it is tweet/userTweet). So if you have something that disappears, it seems you use named RouterView, name of which should be fixed (should include your-modal's-route-name).

@ksurakka
Copy link

ksurakka commented Oct 21, 2019

Hello,

I also have same kind of needs:

  • I want to open a modal view when a certain URL is encountered, e.g. /cart
  • I want to open a modal top of existing view, so that e.g. a product list can been seen behind the modal

I have written a sample solution to solve these problems, but don't know if I'm creating new ones at the same time.

I'm using a custom "router-wrapper" component to keep components untouched when needed (that is when there is no matching URL for the router view).

This solution can be tested here: https://cdpn.io/ksurakka/debug/RwwKyPy#/product/123

And source code can be seen here (if you are not familiar with CodePen): https://cdpn.io/ksurakka/pen/RwwKyPy#/product/123

(Updated more recent code pen versions, now modal+history cleaning works better)

@tagmetag
Copy link

@ksurakka Good solution, non tricky. I am looking into this

@dxc-jbeck
Copy link

dxc-jbeck commented Dec 11, 2019

window.history.replaceState({}, document.title, "/path?id" + this.id);

works if you really need it.

Thanks to StackOverflow

@MZanggl
Copy link

MZanggl commented Jan 22, 2020

@dxc-jbeck There are some side effects. For example $route.query doesn't get updated.

@posva posva added fixed on 4.x This issue has been already fixed on the v4 but exists in v3 and removed discussion labels Mar 28, 2020
@951759534
Copy link

Has this issue resolved?

@robsterlini
Copy link

robsterlini commented Mar 30, 2020

Has this issue resolved?

It looks like @posva is looking into it for an upcoming release – https://twitter.com/posva/status/1242513301726203904

@osasson
Copy link

osasson commented Mar 30, 2020

You could take a look at my solution. A bit hacky with the meta property, but I use it and it works just fine with no breaking changes: #3041

@stepanorda
Copy link

Hey, this issue has "fixed on 4.x" it's already in beta, documentation is there but I can't find any information on new features that enables this in v4. Am I missing something?

@aalyokhin
Copy link

Can we all have some kind of a short mention of how to achieve this with v4? We are ready to upgrade just because of this feature, however it's unclear how to implement it with new version. Thanks!

@icithis
Copy link

icithis commented Jan 22, 2021

As a tangent, you can successfully use replace to swap between aliased URLs without impacting your state, and successfully mutating your history.

@tcardlab
Copy link

tcardlab commented Feb 9, 2021

@stepanorda
Posva has a sample here.
I put up a codepen here.

Unfortunately, I do not know how historyState should be handled in production.
It is conveniently a global variable in this demo... maybe vuex, provide/inject, or sneak it into the router instance?
¯\_(ツ)_/¯

For Vue2 people:

EDIT: got a quick implementation of Posva's. I just used window to store historyState. For some reason, however, refreshing the page with an open modal breaks it Uncaught (in promise) TypeError: Cannot read property 'parentNode' of null. Surely there is a better fix, lemme know if you find it 🙂

EDIT2: The bug was fixed btw, you just can't use lazy-loading in the router.

@slidenerd
Copy link

  • still nothing after 4 months?
  • tmiame's solution uses a lot of duplication in the beforeRouteEnter
  • In my case I am trying to get a login modal to work which opens from anywhere
  • you can make /login as a child route of every other route on the page is literally the only solution that seems to work flawlessly with back forward buttons without manually touching history pushState popState
  • I did not understand what ksurakka is trying to do. He has a router-wrapper component. Why has he not declared it like the other components in a .vue format? what is $createElement and where is it coming from? what is the routes/index.js file doing with the bookmark and pathStack?. does it even work on nuxt? ksurraka should write a blog post if possible on what his methodology is
  • posva is touching history pushState replaceState etc, cant you do this without manipulating history. causes lots of problems with back/forward buttons

@posva
Copy link
Member

posva commented Jun 21, 2021

I'm closing this in favour of #977 because it's a solution to the same problem that avoids hacking router navigation. There is an e2e test that shows how to implement modals with v4: https://github.com/vuejs/vue-router-next/blob/master/e2e/modal/index.ts

@Multyjog
Copy link

Multyjog commented Feb 2, 2022

@dxc-jbeck
Thank you a lot. It works for me!

window.history.replaceState({}, document.title, "/path?id" + this.id);

vikunja-bot pushed a commit to go-vikunja/frontend that referenced this issue Feb 5, 2022
This is an implementation of the modals with the new possibilities of vue router 3.

See: vuejs/vue-router#703 (comment) for a better explanation
and the linked example implementation: https://github.com/vuejs/vue-router-next/blob/master/e2e/modal/index.ts
@onurusluca
Copy link

For anyone still having this problem, try memory mode:
https://router.vuejs.org/guide/essentials/history-mode.html#memory-mode

const router = createRouter({
  // Enables us to not change customer's website's URL while routing
  history: createMemoryHistory(import.meta.env.BASE_URL),
  routes, // short for `routes: routes`
});

This may not solve all of your problems but if you just want to change the route but keep the URL, this will work.

@nicolas-t
Copy link

Hello,

It landed in react via next.js 13.3 and it's called parallel routes.
Demo :
https://twitter.com/ctnicholasdev/status/1644130884323536899

Doc :
https://beta.nextjs.org/docs/routing/parallel-routes

One URL can now map to multiple component trees.
I think it's a good thing that URL and component trees are decoupled.

@nicolas-t
Copy link

@romanhrynevych
Copy link

Any official docs to this issue? I see all links that provided are now 404 status 😢

@rodrigopedra
Copy link

rodrigopedra commented Sep 6, 2023

@romanhrynevych this issue was closed in favor of #977 as you can see in this comment:

#703 (comment)

Issue #977 is yet opened, which might imply this feature is not yet available.

I am not sure, as I ended up implementing something else to solve my problem at the time, but am still subscribed to this issue updates.

By something else, I don't mean I ended up implementing a similar feature, on the app I was working at the time I changed its workflow to avoid needing this feature.

@groenroos
Copy link

I was looking to implement Twitter-style modals in Vue 3, but a lot of the examples and demos I saw above assume that a particular modal must be the child of a particular route. However, the drawback is that if a different main view has a link to navigate to the modal, the main view will also change to be the one the modal "belongs" to.

In my use case, I wanted a modal to:

  • Have its own dedicated route that's a normal part of the browser history
  • Therefore, can be opened (and closed) by ordinary router-links
  • Be navigable from the components of multiple routes, without being directly related to any of them
  • Keep the pre-existing underlying main view alive, with state
  • Ideally still allows for dynamic importing of components

In Vue 3, I solved it with a dedicated named view for modals:

App.vue
<router-view v-slot="{ Component }">
	<keep-alive>
		<component :is="Component" />
	</keep-alive>
</router-view>

<router-view v-slot="{ Component }" name="modal">
	<div class="modal-container" :class="{ 'is-active': Component }">
		<keep-alive>
			<component :is="Component" />
		</keep-alive>
	</div>
</router-view>

And using a beforeEnter navigation guard to define a default view for the modal route based on the route the user is coming from. If the user is coming to the route directly, it loads a fallback component for the main view instead.

router.js
/* Keep default view alive when navigating to modal */
function keepDefaultView(to, from) {
	if (from.matched.length) {
		to.matched[0].components.default = from.matched[0].components.default;
	} else {
		to.matched[0].components.default = () => import('Main.vue');
	}
}

const router = createRouter({
	history: createWebHistory(),
	routes: [
		{
			path: '/',
			component: () => import('Main.vue')
		},
		{
			path: '/modal',
			components: {
				modal: () => import('Modal.vue')
			},
			beforeEnter: [keepDefaultView],
		}
	]
});

This solution allows for the modal to be navigated to from any default component (as well as directly), and thanks to the keep-alives, both the modal and the main view will keep their state as the user navigates through their history.

While I don't personally need it, this could also be easily expanded to display the modal route as a page when the user navigates to it directly; or, since this supports dynamic imports, to allow the modal route to define the most appropriate fallback for the main view.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request fixed on 4.x This issue has been already fixed on the v4 but exists in v3 has workaround
Projects
3.x
  
Todo
Longterm
Doable or in refactor (low prio, low ...
Development

No branches or pull requests