vue-router icon indicating copy to clipboard operation
vue-router copied to clipboard

change current component used by `router-view` without changing current URL

Open jiangfengming opened this issue 8 years ago • 43 comments

The scenario is, when fetching data from backend api failed, I want to route to an error page, but not changing the current location, and prompt the user that you could refresh the page to try again. If the location changed, refreshing the page would always on the error page. Another common usage is showing the login page if user is not logged in, but do not redirect to a login URL.

I know I can achieve it via putting a piece of code into App.vue:

<component :is="forceShowPage" v-if="forceShowPage"></component>
<router-view v-else></router-view>

But then these pages can't reuse the layout components that using nested routes.

Proposed API

A prop named route for <RouterView> that allows overriding what is displayed, like in v4 (https://next.router.vuejs.org/api/#route). There are existing tests in https://github.com/vuejs/vue-router-next/tree/master/e2e/modal

jiangfengming avatar Dec 03 '16 08:12 jiangfengming

@fnlctrl I think you misunderstood my meaning, I means "Trigger router-view change without changing current URL"

jiangfengming avatar Dec 05 '16 06:12 jiangfengming

@fenivana Sorry for the delay. In that case, it looks like a very app-specific behaviour. I actually prefer giving the user a clickable link that brings him to the right place (or something like try again) instead of asking him to reload the page. BTW you can also do router.history.updateRoute({ path: '/error' }) or use it with a name

posva avatar Dec 09 '16 09:12 posva

@posva The point is the current URL should not change, rather than a URL like http://www.example.com/error. Error page should works like the 404 page, it doesn't need a visible URL.

And I've tried your suggestion in Chrome console but without success:

> $vm0.$router.history.updateRoute({ path: '/foo' })

[Vue warn]: Error when rendering root instance:                 vue.runtime.common.js?d43f:509
  warn @ vue.runtime.common.js?d43f:509
  Vue._render @ vue.runtime.common.js?d43f:2932
  (anonymous function) @ vue.runtime.common.js?d43f:2333
  get @ vue.runtime.common.js?d43f:1639
  run @ vue.runtime.common.js?d43f:1708
  flushSchedulerQueue @ vue.runtime.common.js?d43f:1526
  (anonymous function) @ vue.runtime.common.js?d43f:461
  nextTickHandler @ vue.runtime.common.js?d43f:410


TypeError: Cannot read property '0' of undefined(…)             vue.runtime.common.js?d43f:423
  logError @ vue.runtime.common.js?d43f:423

jiangfengming avatar Dec 09 '16 12:12 jiangfengming

Oh, sorry. It actually needs more parameters. The path being one of them and name being optional actually xD https://github.com/vuejs/vue-router/blob/154e269ecf9fee4a60a46fb24ffd7562dfd0d427/flow/declarations.js#L67

posva avatar Dec 09 '16 12:12 posva

I think it's a useful feature. We could expose a method on the router instance:

router.replaceView({ name: 'error' })

and add support for the name syntax too (as a router-link)

It also makes easier to handle graceful degradation. So basically what we actually need is a way to modify the navigation to a different view while keeping the url. Most of the time this is something that would be used in the before* guard, isn't it? So we could use some kind of property on the object passed to the next method. Something similar to replace: true. Let's say replace: { name: 'error' } that would only replace the view. Adding a new property can be confusing because they cannot be used together (we can always warn the user in dev mode, though).

@fnlctrl @yyx990803 What do you think? I'm not satisfied with the API below because of the 2 different ways of doing it --> People may want to use the callback to replace the view: vm => vm.$router.replaceView({}) Maybe we should introduce a new kind of route that cannot be directly navigated but that can be used to replace the view of another route

posva avatar Dec 10 '16 12:12 posva

Maybe we should introduce a new kind of route that cannot be directly navigated but that can be used to replace the view of another route

Hmm.. Maybe we can directly pass components instead of routes to router.replaceView? This way we don't have the problem you proposed. Since the replaced view doesn't affect url, so should it be totally unrelated to routes. (And maybe the replaced view shouldn't have nested router-views in it.)

import FooView from 'foo';
router.replaceView(FooView)

router.replaceView(Vue.component('regiesterd-global-component'))

The only problem would be importing the components everywhere router.replaceView(Foo) is used, but it shouldn't be too much trouble, and no trouble at all if it's used inside global guards.

import FooView from 'foo';
import BarView from 'foo';

const router = new VueRouter({routes: [
  {path: '/foo', component: FooView},
  {path: '/bar', component: BarView}
]})

router.beforeEach(() => {
   //...
   router.replaceView(FooView)
})

fnlctrl avatar Dec 10 '16 14:12 fnlctrl

I think we can add a abstract option in the route definition. If it is set to true, then <router-link>, router.push() and router.replace() will not change the URL, just like the current abstract mode.

new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      component: () => System.import('./layouts/default.vue'),
      children: [
        { path: '', component: () => System.import('./views/Index.vue') },
        { path: 'foo', component: () => System.import('./views/Foo.vue') },
        { path: 'login', abstract: true, component: () => System.import('./views/Login.vue') },
        { path: 'error', abstract: true, component: () => System.import('./views/Error.vue') },
        { path: '*', component: () => System.import('./views/HTTP404.vue') }
      ]
    }
  ]
})

Here page login and error are defined as abstract routes.

jiangfengming avatar Dec 10 '16 16:12 jiangfengming

It still feels like a hack to me. This is basically a piece of global state (showErrorOverlay) that is not stored in the URL, why does it have to be done through a router API?

I'm not sure if I understand what you mean by the following:

But then these pages can't reuse the layout components that using nested routes.

yyx990803 avatar Jan 19 '17 23:01 yyx990803

Yes ! I want this feature please :slightly_smiling_face: It doesn't have to be complicated, adding a method :

router.replaceView(my404PageComponent);

would be just fine (in my humble opinion).

wmcmurray avatar Jan 27 '17 07:01 wmcmurray

@yyx990803 because the router API choose the actual view, we need only to replace the view, by another component, when a error occur, like in a classic mvc if a entry dont exist pass to other view

why does it have to be done through a router API?

EduardoRFS avatar Feb 12 '17 17:02 EduardoRFS

These days I'm facing the problem again. I did what @yyx990803 suggested to switch <router-view> and error page in layout.vue, that works well.

But the problem is when clicking back on the login page, it will back to the last second page, because the login page actually isn't in the history stack. I have to hack on the history.pushState() and onpopstate . The basic idea is, when opening the login page, I use history.pushState({ dummy: true }, ''). When clicking back, I remove the dummy history and slide out the login page. When refreshing on the login page, I need to remove the dummy history.

I haven't done yet ,so I don't know whether it will work.

jiangfengming avatar Mar 13 '17 02:03 jiangfengming

If the error overlay is meant to disappear by going back in history, then you should definitely push a new entry to the history. It can be the same url, with an ?error=true. You can also push the same route but hold the error in the app state. If we add something as replaceView, you'll still face the same problem

posva avatar Mar 13 '17 12:03 posva

@fenivana You could reach your goal in a easy way. At the end of your routes have something like this:

     {
        path: '*',
        name: 'notFound',
        component: NotFoundComponent
    },
    {
        path: '*',
        name: 'errors',
        component: ErrorComponent
    }

then use the redirect method to the name 'errors' view.
In this way you don't broke anything because if the user type a path that does not exist, the first match is with 'notFound' name view.

giolf avatar May 12 '17 11:05 giolf

I just wanted to say that I took a step back to look at this problem.

To me, this isn't a problem with the Vue router. Routes should be immutable that always display a certain component.

The only exception is if there is no route to match, in which I feel like the '*' route covers this well.

The router is doing its job. It's matching a route to a component. Because your data is bad (404/500 from server) is not the router's fault or problem.

The real question (to me) is how best to handle this problem outside of the router.

Just my 2c. :)

aeharding avatar May 12 '17 16:05 aeharding

@giolf Great idea!

jiangfengming avatar May 13 '17 01:05 jiangfengming

@aeharding @yyx990803 @posva

The only exception is if there is no route to match, in which I feel like the '*' route covers this well.

I don't think it does. Apps will inevitably have URLs that have usernames, ids, and various other identifiers appended to them, and '*' can't cover those cases.

raniesantos avatar May 15 '17 20:05 raniesantos

@aeharding I think you're right, as a full stack web developer in a MVC framework usually this task is handled by the controller.

If something goes wrong it's the controller that return a 404view, the router doesn't do and know anything. it's just a dispatcher

But at the same time on the front-end environment we don't have controller.
For this reason in my opinion the best way to handle it is in the business logic of the component.

vueJS has great hook (component guard for example) to reach that goal.

giolf avatar May 15 '17 20:05 giolf

Just for an example of handling the 404s, etc of something, this is how I handle it:

Say I have a comment that I can get to at mysite.com/comments/:id.

I have a src/pages/Comment.vue file (rendered with the route above) which has this:

<template>
  <component :is="component"></component>
</template>

<script>
import Comment from '@/components/comment/Comment';
import NotFound from './NotFound';

export default {
  asyncData({ store, route }) {
    return store.dispatch('getCommentById', route.params.id);
  },
  computed: {
    component() {
      if (this.$store.getters.currentComment.status === 404) {
        return 'NotFound';
      }

      return 'Comment';
    }
  },
  components: {
    Comment,
    NotFound
  }
};
</script>

The above renders very clean markup (no nested divs/components) for easy styling. Everything is just replaced.

In essence, I have a Comment component and a Comment page. The Comment page handles rendering either the Comment component -or- the 404 page, or whatever else I want for logic.

The currentComment will always be something, whether that's 500, 404, or whatnot. getCommentById will never reject (unless a NetworkError, in which case I prevent navigating and use place an alert banner in the top of the page). If it's a 2xx, I render the Comment component (which assumes, if rendered $store.getters.currentCommend.data exists).

This works really well for SSR. I can even set the statusCode of the response on the ssr context in the Comment page.

This discussion is probably best outside of github issues.

EDIT Just to be clear, I realize this is a bit extra work/scaffolding, but at least it keeps clean HTML and a clean Comment component, which is separate from the Comment page that handles logic for what to do if the request fails. It's been an extremely powerful pattern for me. However, I only really bother doing it when users might be entering a URL/sharing URLs where a 404 would occur.

aeharding avatar May 15 '17 21:05 aeharding

so in every component page you have:

<template>
  <component :is="component"></component>
</template>

<script>
import Comment from '@/components/comment/Comment';
import NotFound from './NotFound';

and

  computed: {
    component() {
      if (this.$store.getters.currentComment.status === 404) {
        return 'NotFound';
      }

      return 'Comment';
    }

where of course will change the main component name in each page, right ?
I don't like so much the idea to copy and paste the same code in each component. But of course if you make it a bit more dynamic it could be a great and elegant solution.

giolf avatar May 15 '17 21:05 giolf

@giolf Yes, I do realize it's a bit of scaffolding. I'm not sure how it could be made simpler, but I'd love if someone could make a solution and share it. :)

To be fair, the above solution does provide the ability to have a very fine-grained approach to the logic of what to do upon an error. Perhaps you want to have a different 404 page for a specific route - this approach allows that.

It's also very testable and debuggable - the router always has a predictable page component that it will render. The page then renders a component via data in the very debuggable vuex store.

For some components with /thing/:id I don't bother handling the edge case, and I fail the route if there is an error. This is acceptable to me for internal URLs that aren't made to be shared.

For me, I only really need to repeat the above logic for 2 components in a large app. :)

I remember the craziness in ui-router, and to me, this is definitely a breath of fresh air! hehe

aeharding avatar May 15 '17 21:05 aeharding

@aeharding If you can centralize that logic in a single place and make it a bit more dynamic, every update on that logic could be made only in one place.

I'm still not at this point on my SPA. i'm still working on the backend side ... Next week (i hope) i will think about it and i will share also my solution. ;)

giolf avatar May 15 '17 21:05 giolf

That's good to hear. I look forward to your solution!

I guess what I'm trying to get at is that the ability for the view to depart the route and make the view not determinable from the route in ui-router (AngularJS) made for a lot of headaches in large apps. Lots of edge cases... Which is what this issue is proposing with replaceState.

In my opinion, the ability to predict the view given a route is a feature, not a bug.

I'd really appreciate a terse solution that keeps the view tied to the route in an immutable way. I'm just not sure how to do that without at least a little custom logic, like I wrote above.

Anyways. Sorry for the email spam for anyone subscribed... 🙈

aeharding avatar May 15 '17 22:05 aeharding

Yes im with you about that!

  • What do you think to register your notFound/Errorcomponent in your main Appcomponent instead in each page component?

  • Then remove the :isstatement with an if on the root of your tag-template component where you could check if the resource you're fetching is in the state of your store (mapping a vuex getter). If it's there show it if not it will never rendered int he DOM

  • Attach your notFound/errorscomponent outside from your router-view scope and show it with an ifstatement only if the page component call a specific action that mutate a state variable to true (it could be resourceNotFound) if your API return a bad status code.

I think in that way you don't repeat anything, yours page component are still the boss that decide if render or not their template or a notFound/error component, and the notFound/errorcomponent is not directly injected into your all page component. I think also in terms of performance it's a bit better.

giolf avatar May 15 '17 22:05 giolf

That would definitely work :) I'd definitely like to see an example (I'm a learn-by-example guy), but sounds promising. It would be great to have a page on the router's docs dedicated to this problem at hand, and possible solutions with the associated code. Like the Vue SSR docs.

I would love to have this in the hackernews example as well (right now it has basically no handling - renders plaintext "404 - Not Found": https://vue-hn.now.sh/item/thisisnotavalidid).


Back in regards to the OP of this issue: I found an image to represent replaceState. I kinda feel like it is like engineering a road to accommodate square wheels, when what you really need is round wheels. 😄

In other words, sure, it could work, but nobody stopped to ask why. :)

image

aeharding avatar May 15 '17 23:05 aeharding

Disclaimer: I'm not really a Front-end or Javascript developer

I gave this, in my eyes, a fairly good wack. I had a fairly good shout at a solution similar to what @giolf's proposed two comments above as well as my own solution. The result was a convoluted mess and shenanigans for how the router responds to the next request.

The other problem I came across, and perhaps I am doing something wrong, is that when you change a state to display an error component and you call next() in beforeRouteEnter() it will still call the child component's guard's. This was not a desirable behaviour for me. (You could work around this, but then you get convoluted guards in the child components, etc, etc)

Until I have come up with something better I am sticking with what giolf also proposed above:

{
    path: '*',
    name: 'notFound',
    component: NotFoundComponent
},

But inside my guard the addition of:

next({ name: 'notFound', params: [to.path] });

This is required to keep the URL the same.

The problem with this solution is that Vue adds another browser history entry.

In conclusion:

Perhaps your computer science professor might not think it's correct, but we really need to be able to do something like this from the navigation guards:

next({ replace: NotFoundComponent }); // or
mock({ component: NotFoundComponent });

Sorry to bore you with my 2cents and I apologise if I have missed something, I am not an experienced Frontender 👍 and I am having difficulty wrapping my head around this!

micbenner avatar May 28 '17 07:05 micbenner

@micbenner The major problem with this is that it makes the route/state relationship non-determinable. Mutating the state matching regardless of the current URL.

In other words, for a given route, the page/component cannot be determined from it. (Is it the component that it was initially supposed to be, or is it the NotFoundComponent?)

It's also very hard to debug. Extremely confusing in Angular 1 apps I've built with this functionality in ui-router.

What is wrong with my solution above, or @giolf's one?

aeharding avatar May 28 '17 15:05 aeharding

Hi @aeharding. As far as I can see my concerns with the solution above:

  1. If a parent component sets an error on the state then the child component guards will still be called. This means either unnecessary data fetching or having to fill guards with conditionals. This doesn't feel right to me.
  2. Once the Application is showing the error page, when does it know to hide this? (Such as when a user clicks a new link). One option is to remove the error at the start of the global route guards, but then the error component will also be removed from the dom before the route guards have fully executed. Not exactly the behaviour one is after.

Again I am far from an expert, just trying to find an elegant solution, perhaps you have a better way of avoiding this?

At the end of the day though, I think this is a sorely missing feature and quite a glaring hole in the docs. Even if the answer is just an option to direct the route to a named route without changing the URL.

beforeRouteEnter (to, from, next) {
    next({ mock: 'notFound'});
}

M

micbenner avatar May 28 '17 23:05 micbenner


VueRouter.prototype.ln = (path)->
    pos = path.indexOf(' ')
    history = @history
    route = @match('/'+path.slice(pos+1), history.current)
    history.current = @match('/'+path.slice(0,pos) , history.current)
    history.ensureURL()
    history.confirmTransition(
        route
        ->
            history.updateRoute(route)
    )


router.ln('test init')

p2pweb-me avatar Dec 04 '17 15:12 p2pweb-me

Hi,

any updates about replaceView feature? It would be helpful for handling 404 pages.

Dani216 avatar Dec 05 '17 10:12 Dani216

@Dani216 actually nuxt somehow do that, using a error function in the context, you can switch to nuxt(i really recommend) or searching the code

EduardoRFS avatar Dec 05 '17 23:12 EduardoRFS