How to use Vue Router scroll behavior with page transition - vue.js

I'm using Vue Router 4 on an application where the top level RouterView is wrapped in a transition, like so:
<router-view v-slot="{ Component }">
<transition mode="out-in">
<component :is="Component" />
</PageTransition>
</router-view>
When I try to add scroll behavior to the Router, to scroll to a specific element when users navigate back to the index page, the scroll behavior fires during the out phase of the transition, when the index page isn't mounted yet, so the element isn't found.
eg.
const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior (to, from) {
if (to.name === 'index' && isContentPage(from)) {
return { el: '#menu' }
}
return undefined
}
})
I would get a warning in the console: Couldn't find element using selector "#menu" returned by scrollBehavior.
The Vue Router docs on scroll behavior mention that it's possible to work around this issue by hooking up to transition events:
It's possible to hook this up with events from a page-level transition component to make the scroll behavior play nicely with your page transitions, but due to the possible variance and complexity in use cases, we simply provide this primitive to enable specific userland implementations.
But I couldn't figure out what sort of approach it was suggesting, nor could I find any "userland implementations".

Finally I found a solution to this, which was to use a module holding some state—in this case a Promise—to act as the link between the transition and the router.
The module:
let transitionState: Promise<void> = Promise.resolve()
let resolveTransition: (() => void)|null = null
/**
* Call this before the leave phase of the transition
*/
export function transitionOut (): void {
transitionState = new Promise(resolve => {
resolveTransition = resolve
})
}
/**
* Call this in the enter phase of the transition
*/
export function transitionIn (): void {
if (resolveTransition != null) {
resolveTransition()
resolveTransition = null
}
}
/**
* Await this in scrollBehavior
*/
export function pageTransition (): Promise<void> {
return transitionState
}
I hooked up the transition events:
<router-view v-slot="{ Component }">
<transition mode="out-in" #before-leave="transitionOut" #enter="transitionIn">
<component :is="Component" />
</PageTransition>
</router-view>
...and in the Router:
const router = createRouter({
history: createWebHashHistory(),
routes,
async scrollBehavior (to, from) {
if (to.name === 'index' && isContentPage(from)) {
await pageTransition()
return { el: '#menu' }
}
return undefined
}
})
What's more, I actually have a nested RouterView also wrapped in a transition. With that transition extracted to a component, both instances could call transitionOut and transitionIn and it seems to work (though I haven't tested it much for race conditions).
If anyone has found simpler solutions though, I'd be interested to see them.

Related

Let a (mocked) transition finish asap inside v-menu in a vuetify unit test

v-menu uses a transition internally to show the given Element when the activator is triggered.
We simply want to test if the menu element is shown after the activator hit it.
In our setup we have a component test using Vue Test Utils and do:
const wrapper = createWrapper(...)
expect(wrapper.contains('.my-class')).toBe(false) // the Element is not there in the beginning
clickTheTriggerElement(wrapper) // trigger v-menu to show
await wrapper.vm.$nextTick()
expect(wrapper.find('.my-class').isVisible()).toBe(true) // fails
This fails because the transition used by v-menu is not finished yet. (It would after a setTimeout(...), thou but this is less acceptable).
Vue Test Utils describes this as a common issue and provides a solution that seems to work for plain vue.js Transitions. (They use a mock for Transition to render immediately.) Unfortunately this doesn't seem to work with internal transitions of vuetify.
How would you handle that?
I wrote unit-test for v-list inside v-menu and I didn't mock transitions. This is my code:
//ACTION
const button = wrapper.find('.theme-picker');
await button.trigger('click');
const topic = wrapper.find('.v-list-item');
await topic.trigger('click');
const result = wrapper.vm.$router;
//ASSERT
expect(result).toContainEqual(expected);
If you have some async behaviour which you can't 'reach' you can use:
test('description', (done) => {
wrapper.vm.$nextTick(() => {
expect(result).toBe(expected);
done();
});
});
It work for Jest and Mocha. Test will wait until async code finish.
May be it will help someone.
I don't think it's the transitions that are causing the problem here. You should be able to disable transitions completely with the following code:
import { config } from '#vue/test-utils';
config.stubs.transition = false;
I tried this, and it did not help my situation. What I ended up doing was stub out VMenu entirely. I created a component stub somewhere only accessible to tests, e.g. tests/unit/__stubs__/VMenuStub.vue
<template>
<div class="v-menu-stub">
<slot
name="activator"
:on="{
click: toggleValue,
}"
></slot>
<slot v-if="value"></slot>
</div>
</template>
<script lang="ts">
/**
* VMenuStub - used to stub out v-menu
* as a workaround for issues dealing with v-menu directly in a unit test
* example usage: use the "stubs" option for mount
* componentWrapper = mount(component, {
* ... other options, like localVue, vuetify ...
* stubs: {
* 'v-menu': VMenuStub,
* },
* };
*/
import { defineComponent } from '#vue/composition-api';
export default defineComponent({
props: {
value: {
type: Boolean,
required: true,
},
},
setup(props, context) {
function toggleValue() {
context.emit('input', !props.value);
}
return {
toggleValue,
};
},
});
</script>
I can use this stub like so:
import VMenuStub from '../../__stubs__/VMenuStub';
it('test', () => {
componentWrapper = mount(component, {
... other options, like localVue, vuetify ...
stubs: {
'v-menu': VMenuStub,
},
};
});
once the stub is added, whichever button I have set up to trigger menu open/close will work as expected.
given my component to test has a menu with activator slot:
<v-menu
v-model="menu"
>
<template #activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on">
ClickMe
</v-btn>
</template>
I can open the menu by triggering click via trigger('click') or vm.$emit('click')
it('test', () => {
componentWrapper = mount(component, {
... other options, like localVue, vuetify ...
stubs: {
'v-menu': VMenuStub,
},
};
const menuButton = componentWrapper.findAll('.v-btn').filter(i => i.text().includes('ClickMe')).at(0);
// either of these will work
await menuButton.trigger('click');
// OR
await menuButton.vm.$emit('click');
// and I can verify
expect(componentWrapper.vm.menu).toEqual(true);
});

Vue: How do I wait for a Promise in the component initialization process?

I have several Vue components (realized as .vue files process by Webpack & the Vue Loader) that need to be synchronized (i.e. display / allow interaction of a video stream). Some of them require some initialization effort (i.e. downloading meta data or images).
Can I somehow delay a component until that initialization is ready? Perfect would be if I could wait for a Promise to be fulfilled in a lifecycle hook like beforeCreate.
I know about asynchronous components, but as far as I understand all I could do is to lazy-load that component, and I still would have a way to wait for a certain initialization.
You can do that on route level.
const router = new VueRouter({
routes: [
{
path: '/foo',
component: Foo,
beforeEnter: (to, from, next) => {
doSomeRequests()
.then(() => (next()))
}
}
]
})
doSomeRequests is a function that is doing some async code. When that async code finish
then you call next() to allow vue router to enter the component which corresponds to the url. In the example the component Foo
My suggestion would be to show a loader in the component.
<template>
<!-- code -->
<LoaderComponent :loading="loading" />
<!-- code -->
</template>
And the script part:
<script>
export default {
// code
data: () => ({
loading: false
}),
created () {
this.loading = true
doSomeRequests()
.then(<!-- code -->)
.finally(() => (this.loading = false))
}
}
</script>

How to stop FOUC when leaving route with 'vue-flickity' carousel/slider?

I'm using the vue-flickity package for MetaFizzy's Flickity in my Vue app. When navigating away from a route that has an instance of vue-flickity slider, you get a brief FOUC showing all the slides unstyled in the document as the <Flickity /> slider is dismounted(?) and the view changes.
I've tried wrapping it in a <keep-alive>, but that doesn't help.
What would be the best approach to hiding or "animating out" the component before the user navigates away from a route?
I also tried to use beforeRouteLeave(), transition opacity on <Flickity ref="mySlider" /> to 0, then change route. I tried something like the following, but it didn't work as expected:
// SliderView.vue
<template>
<Flickity ref="mySlider">
<div v-for="(slide, index) in slides" :key="index">
// slide content markup
</div>
</Flickity>
</template>
<script>
import 'Flickity' from 'vue-flickity'
export default {
name: 'SliderView'
}
</script>
// router.js
import Vue from 'vue'
import Router from 'vue-router'
import SliderView from './views/SliderView.vue'
export default new Router({
routes: [
{
path: '/routeWithSlider',
component: SliderView,
beforeRouteLeave (to, from, next) {
const slider = this.$refs.mySlider
if (slider) {
slider.style.transition = 'opacity .5s'
slider.style.opacity = 0
setTimeout(() => {
next()
}, 600)
} else {
next()
}
}
}
]
})
Is this the correct approach, or should I be approaching this differently?
Also, if there was a way to do this globally for all <Flickity /> instances on all routes without specifying refs, that would also be useful too.
I haven't been using Vue for that long, so am looking for some guidance on the direction to take.
I found the best way to acheive this is to use BeforeRouteLeave() lifecycle hook on the component with the slider to animate the Flickity slider exit before changing route:
beforeRouteLeave (from, to, next) {
const slider = this.$refs.flickity.$el
slider.style.transition = 'opacity .25s ease'
slider.style.opacity = 0
setTimeout(() => {
next()
}, 250)
}

Vue-router component reusing

I would like to know how can I stop component reusing in Vue-router.
I'm building a simple page application and I am unable to update data after clicking the same link twice.
Is it possible to somehow force reloading or what are the best practices in my situation?
Use the key attribute on router-view set to current url. It's built in, so no need to write any code.
<router-view :key="$route.fullPath"></router-view>
Vue Router reuses the same component therefore the mounted hook won't be called. As stated in the documentation:
The same component instance will be reused [...] the lifecycle hooks of the component will not be called.
If you want to update the data you have two options:
Watch the $route object
const User = {
template: '...',
watch: {
'$route' (to, from) {
// react to route changes...
}
}
}
Use the beforeRouteUpdate navigation guard
const User = {
template: '...',
beforeRouteUpdate (to, from, next) {
// react to route changes...
// don't forget to call next()
}
}
For a more detailed explanation you can check the section Reacting to Param Changes of the Vue Router documentation: https://router.vuejs.org/guide/essentials/dynamic-matching.html#reacting-to-params-changes.
One way to do this is to put a key on the router-view and append a timestamp querystring to your router-link
const Home = {
template: '<div>Home</div>',
created() {
console.log('This should log everytime you click home.');
},
};
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
]
});
new Vue({
router,
el: '#app',
});
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<div id="app">
<router-link :to="`/?q=${Date.now()}`">/home</router-link>
<router-view :key="$route.fullPath"></router-view>
</div>
One reason not to do it this way is because it'll force rerenders on components that you may want to be reused such as on routes like
/posts/1
/posts/2

Best Practice for Reacting to Params Changes with Vue Router

When using Vue Router with routes like /foo/:val you have to add a watcher to react for parameter changes. That results in somewhat annoying duplicate code in all views that have parameters in the URL.
This could look like the following example:
export default {
// [...]
created() {
doSomething.call(this);
},
watch: {
'$route' () {
doSomething.call(this);
}
},
}
function doSomething() {
// e.g. request API, assign view properties, ...
}
Is there any other way to overcome that? Can the handlers for created and $route changes be combined? Can the reuse of the component be disabled so that the watcher would not be necessary at all? I am using Vue 2, but this might be interesting for Vue 1, too.
One possible answer that I just found thanks to a GitHub issue is the following.
It is possible to use the key attribute that is also used for v-for to let Vue track changes in the view. For that to work, you have to add the attribute to the router-view element:
<router-view :key="$route.fullPath"></router-view>
After you add this to the view, you do not need to watch the $route anymore. Instead, Vue.js will create a completely new instance of the component and also call the created callback.
However, this is an all-or-nothing solution. It seems to work well on the small application that I am currently developing. But it might have effects on performance in another application. If you really want to disable the reuse of the view for some routes only, you can have a look at setting the key's value based on the route. But I don't really like that approach.
I used this variant without :key prop on router-view component.
routes.js:
{
path: 'url/:levels(.*)',
name: ROUTES.ANY_LEVEL,
props: true,
component: (): PromiseVue => import('./View.vue'),
},
view.vue
<template>
<MyComponent :config="config" />
</template>
---*****----
<script>
data: () => ({ config: {} }),
methods: {
onConfigurationChanged(route) {
const { params } = route
if (params && params.levels) {
this.config = // some logic
} else {
this.config = null
}
},
},
beforeRouteUpdate(to, from, next) {
this.onConfigurationChanged(to)
next()
},
}
</script>
Inside the component, I use the config as a property. In my case, reactivity is preserved and the component is updated automatically from parameter changes inside the same URL.
Works on Vue 2
vue3 and script setup:
watch(route, () => { fetch()})
in import:
import { watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute()