vuejs / vue-router

🚦 The official router for Vue 2
http://v3.router.vuejs.org/
MIT License
18.99k stars 5.06k forks source link

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

Open jiangfengming opened 7 years ago

jiangfengming commented 7 years ago

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 commented 7 years ago

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

posva commented 7 years ago

@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

jiangfengming commented 7 years ago

@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
posva commented 7 years ago

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 commented 7 years ago

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

fnlctrl commented 7 years ago

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)
})
jiangfengming commented 7 years ago

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.

yyx990803 commented 7 years ago

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.

wmcmurray commented 7 years ago

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).

EduardoRFS commented 7 years ago

@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?

jiangfengming commented 7 years ago

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.

posva commented 7 years ago

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

giolf commented 7 years ago

@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.

aeharding commented 7 years ago

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. :)

jiangfengming commented 7 years ago

@giolf Great idea!

ghost commented 7 years ago

@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.

giolf commented 7 years ago

@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.

aeharding commented 7 years ago

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.

giolf commented 7 years ago

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.

aeharding commented 7 years ago

@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

giolf commented 7 years ago

@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. ;)

aeharding commented 7 years ago

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... 🙈

giolf commented 7 years ago

Yes im with you about that!

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.

aeharding commented 7 years ago

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

micbenner commented 7 years ago

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!

aeharding commented 7 years ago

@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?

micbenner commented 7 years ago

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

p2pweb-me commented 6 years ago

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')
Dani216 commented 6 years ago

Hi,

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

EduardoRFS commented 6 years ago

@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

ghost commented 6 years ago

Since @EduardoRFS mentioned it, I checked out Nuxt.js and came up with this temporary solution based on what Nuxt does.

BorisTB commented 6 years ago

@raniesantos your solution is great, but do you have similar solution for nuxt? Nuxt's error method is not the ideal solution, because you can only define one layout for all errors

Goblinlordx commented 6 years ago

I don't quite get why there are so many work arounds that exist for this. I agree with @micbenner on his points that he already made. Creating something external to the router seems to me to be completely out of place.

Routing like the following:

{
  {path: "/exists", component: Exists },
  {path: "*", component: DoesntExist },
}

This trivially accomplishes the behavior expected where if a route doesn't exist in routing. If there is routing with anything dynamic then vue-router fails to provide a method with which to provide the same behavior. If a parameter is introduced, there is no longer a method provided in order to determine if the parameter is valid or not. A single parameter in the URL prevents developers from utilizing the wildcard effectively. Effectively, vue-router behaves as if infinite variations of a single parameter are valid and there is no way for the user to instead then reject any of those variations. They can cancel the entire routing process or the can redirect but they are unable to communicate to the router the match was determined to not be valid at the that point in time (when a navigation guard is called).

From my perspective, it seems that the problem is that the "matching" of a route is unable to be prevented by the user after hooks have begun being called. I believe this would be simple to accomplish by providing a method of rejecting a match via hooks. This would remove the need for "replacing" things at runtime and would instead behave as if it failed to match the route at runtime. The wildcard entry would then behave as expected matching the route on failure.

I thought of several methods of accomplishing this but I really think the best would be similar to what @micbenner suggested. I would like to instead propose that it be somewhat less dynamic and be a Boolean which can be set to true and it would return to routing as if the match was not found. There is the possibility of providing information about rejected matches in the "matches" array but I think it would be simpler to manage by just removing the entry from the array and behaving as if the match didn't occur.

Since next(error) and next(false) are already defined by the API why not use next(true). This true/false could indicate (or be named) something like rejectMatch. The normal "continue" case is already expecting to explicitly receive undefined (next()) and this would not change that. This is a simple API change which shouldn't interfere with Boolean type (only false is defined), String type, Error type, or Object type parameters which are already defined.

I believe this is a reasonable solution that seems like a small API change. I am sure it is probably a bit of work internally since I am guessing an individual "match" isn't currently able to be rejected after it was "matched". To be honest, if this solution were agreed upon I would be more than willing to work on it myself as it is a feature I very much want.

@posva Any thoughts on this suggestion?

posva commented 6 years ago

What makes this feature request a bit tricky is that people want to use it for different reasons, some are valid but others we do not completely agree with. This is probably something we will implement or at least provide a convenient alternative solution

Goblinlordx commented 6 years ago

I see, I appreciate the response~ I do look forward to seeing the implementation or convenient alternative. If bandwidth is ever an issue, as I mentioned, I am more than willing to work on it (especially if I know a PR providing it might be accepted before starting on it).

fracz commented 6 years ago

I have came up with an improvement of an idea of @aeharding that has less boilerplate.

A page-container component that is responsible for displaying the content or error page:

<template>
    <div>
        <error-403 v-if="error == 403"></error-403>
        <error-404 v-else-if="error == 404"></error-404>
        <slot v-else></slot>
    </div>
</template>

<script>
    import Error403 from "./error-403";
    import Error404 from "./error-404";
    export default {
        props: ['error'],
        components: {Error403, Error404},
    };
</script>

And its usage in another component:

<template>
    <page-container :error="error">
        <h1 v-if="entity">My cool page for {{ entity.id }}</h1>
    </page-container>
</template>

<script>
    export default {
        data(){ return {entity: undefined, error: undefined}; },
        mounted() {
            this.$http.get('entities/123')
                    .then(({entity}) => this.entity = entity)
                    .catch(response => this.error = response.status);
        }
    };
</script>

If everything works, the component is rendering normally but if the request results in an error, the appropriate error page comes in without URL change.

Still, the bicycle applies.

PierBover commented 6 years ago

I've encountered a couple of use cases that would be much better solved by either dynamically telling the router which component to render when it matches a route, or by dynamically altering the rendered component with something like replaceView.

In the past I've resorted to multiple solutions to solve these, but ultimately I think the best solution would be better implemented at the router level since it is already responsible of coordinating these matters. Solving an application matter at the component level doesn't make much sense to be honest.

Laruxo commented 6 years ago

I just found out that you can actually use @micbenner solution without side effect he mentioned. Simply add replace option. next({name: '404', params: [to.path], replace: true});

bbugh commented 6 years ago

Has anyone figured out a solution to this outside of the beforeRouteEnter guard? We have network activity (via apollo) that happens outside of the routing sequence, so the guards are no use. We need to be able to redirect users to a 404 page without actually changing the URL.

The primary reason for keeping the user's URL and displaying a 404 page is to that they can log in and view whatever is they were trying to see. Github does this, for example if you open https://github.com/bbugh/secret-repo in a browser where you're not logged in, the browser keeps the URL in the address bar and you get the login prompts. If you log in, you stay on the same page.

fayt81 commented 6 years ago

I think that feature proposed by @fenivana will be useful in a lot of cases. If we could use the router history mode, but selectively mark some routes as abstract, then we could decide which links are meaningfully available as direct links from other websites and which are not. For example, I could like to offer directs links to some features/pages/routes, but prevent direct access to others.

At present, the only way I found to implement this behaviour is using the history mode, but using routes only for the links that should be direct, while using v-if to show different components for the others, but in this way I will lose most of the power of the router and end up in very complex nested v-if components.

Are there currently other ways to implement this? Otherwise, I think that the possibility to mark only some routes as abstract is absolutely needed.

Linzdigr commented 6 years ago

Definitely would love replaceView router behavior to handle errors on component's logic, like encountering 404 from a backend API and redirecting to suitable component in the Server Side Rendering context (in my case) without changing requested URL.

samuelgozi commented 6 years ago

This is extremely important, 404 pages look awkward without it on SSR.

posva commented 5 years ago

Closing to prevent any additional comments repeating what has already been said pinging +20 people 😆