perf: pause patching while navigating

This also fixes some data changes that happened hile navigating like:

* When using search and clicking on an item, the "No results available" message would appear
* When navigating to a liibrary, the transparency effects of the navdrawer or appbar would match those of the entering route.

Signed-off-by: Fernando Fernández <ferferga@hotmail.com>
This commit is contained in:
Fernando Fernández 2024-10-22 22:26:22 +02:00
parent 67d03099c0
commit 12d6bc5376
8 changed files with 159 additions and 86 deletions

View File

@ -3,30 +3,9 @@
<VApp>
<JApp>
<RouterView v-slot="{ Component, route }">
<JTransition
:name="route.meta.layout.transition.enter ?? defaultTransition"
:mode="defaultTransitionMode ?? route.meta.layout.transition.mode">
<Suspense @resolve="apploaded = true">
<JView
:key="route.meta.layout.name ?? 'default'"
:comp="getLayoutComponent(route.meta.layout.name)">
<JTransition
:name="route.meta.layout.transition.enter ?? defaultTransition"
:mode="defaultTransitionMode ?? route.meta.layout.transition.mode">
<Suspense suspensible>
<JView
:key="route.name"
:comp="Component" />
</Suspense>
</JTransition>
</JView>
<template
v-if="!apploaded"
#fallback>
<JSplashscreen />
</template>
</Suspense>
</JTransition>
<JView
:comp="Component"
:route="route" />
</RouterView>
</JApp>
<Snackbar />
@ -36,15 +15,7 @@
</template>
<script setup lang="ts">
import { shallowRef, type Component as VueComponent, onMounted } from 'vue';
import type { RouteMeta } from 'vue-router';
import DefaultLayout from '@/layouts/default.vue';
import FullPageLayout from '@/layouts/fullpage.vue';
import ServerLayout from '@/layouts/server.vue';
const apploaded = shallowRef(false);
const defaultTransition = 'slide-x-reverse';
const defaultTransitionMode = 'out-in';
import { onMounted } from 'vue';
/**
* When app is mounted, the classes and styles we initialized in the pre-Vue splashscreen in body
@ -56,21 +27,4 @@ onMounted(() => {
document.body.removeAttribute('class');
document.body.removeAttribute('style');
});
/**
* Return the appropiate layout component according to the route's meta.layout property
*/
function getLayoutComponent(layout: RouteMeta['layout']['name']): VueComponent {
switch (layout) {
case 'fullpage': {
return FullPageLayout as VueComponent;
}
case 'server': {
return ServerLayout as VueComponent;
}
default: {
return DefaultLayout;
}
}
}
</script>

View File

@ -60,14 +60,14 @@
<script setup lang="ts">
import { computed, inject, type Ref } from 'vue';
import { useRoute } from 'vue-router';
import { windowScroll, isConnectedToServer, prefersNoTransparency } from '@/store';
import { windowScroll, isConnectedToServer, transparencyEffects } from '@/store';
import { clientSettings } from '@/store/client-settings';
import { remote } from '@/plugins/remote';
import { JView_isRouting } from '@/store/keys';
const route = useRoute();
const { y } = windowScroll;
const transparentAppBar = computed(() => !prefersNoTransparency.value && route.meta.layout.transparent && y.value < 10);
const isRouting = inject(JView_isRouting);
const transparentAppBar = computed(previous => isRouting?.value ? previous : transparencyEffects.value && y.value < 10);
/**
* Cycle between the different color schemas

View File

@ -40,10 +40,10 @@
import IMdiHome from 'virtual:icons/mdi/home';
import { computed, inject, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import type { RouteNamedMap } from 'vue-router/auto-routes';
import type { getLibraryIcon } from '@/utils/items';
import { prefersNoTransparency } from '@/store';
import { transparencyEffects } from '@/store';
import { JView_isRouting } from '@/store/keys';
export interface DrawerItem {
icon: ReturnType<typeof getLibraryIcon>;
@ -56,11 +56,11 @@ const { order, drawerItems } = defineProps<{
drawerItems: DrawerItem[];
}>();
const route = useRoute();
const { t } = useI18n();
const drawer = inject<Ref<boolean>>('NavigationDrawer');
const transparentLayout = computed(() => !prefersNoTransparency.value && route.meta.layout.transparent);
const isRouting = inject(JView_isRouting);
const transparentLayout = computed(previous => isRouting?.value ? previous : transparencyEffects.value);
const items = [
{

View File

@ -4,9 +4,18 @@
class="j-transition"
v-bind="$attrs"
:name="forcedDisable || disabled ? undefined : `j-transition-${name}`"
@before-leave="leaving = true"
@after-leave="onNoLeave"
@leave-cancelled="onNoLeave">
@before-leave="() => {
leaving = true;
$attrs.onBeforeLeave?.();
}"
@after-leave="() => {
onNoLeave();
$attrs.onAfterLeave?.();
}"
@leave-cancelled="() => {
onNoLeave();
$attrs.onLeaveCancelled?.();
}">
<slot />
</component>
</template>
@ -26,6 +35,10 @@ interface Props {
* If the transition should be disabled
*/
disabled?: boolean;
/**
* Don't stop patching the DOM while transitioning
*/
skipPausing?: boolean;
}
export type JTransitionProps = TransitionProps & Props;
@ -33,14 +46,16 @@ const forcedDisable = computed(() => prefersNoMotion.value || isSlow.value);
</script>
<script setup lang="ts">
const { name = 'fade', group, disabled } = defineProps<Props>();
const { name = 'fade', group, disabled, skipPausing } = defineProps<Props>();
const leaving = shallowRef(false);
const onNoLeave = () => leaving.value = false;
usePausableEffect(leaving);
if (!skipPausing) {
usePausableEffect(leaving);
}
</script>
<!-- TODO: Set scoped and remove .j-transition* prefix after: https://github.com/vuejs/core/issues/5148 -->
<!-- TODO: Set scoped and remove .j-transition* prefix after: https://github.com/vuejs/core/issues/5148#issuecomment-2041118368 -->
<style>
.j-transition {

View File

@ -1,20 +1,103 @@
<template>
<div
class="j-transition uno-h-full">
<component
:is="comp">
<slot />
</component>
</div>
<JTransition
:name="route.meta.layout.transition.enter ?? defaultTransition"
:mode="defaultTransitionMode ?? route.meta.layout.transition.mode"
skip-pausing>
<Suspense
:suspensible="!root"
@pending="resolved = false"
@resolve=" resolved = true">
<div
:key="root ? route.meta.layout.name ?? 'default' : route.name"
class="j-transition uno-h-full">
<Component
:is="root ? getLayoutComponent(route.meta.layout.name) : comp">
<JView
v-if="root"
v-bind="$props" />
<slot v-else />
</Component>
</div>
<template
v-if="!apploaded && root"
#fallback>
<JSplashscreen />
</template>
</Suspense>
</JTransition>
</template>
<script setup lang="ts">
/**
* TODO: Remove j-transition classes from this file once https://github.com/vuejs/core/issues/5148 is fixed
*/
import type { Component } from 'vue';
<!-- TODO: Remove j-transition classes from this file once https://github.com/vuejs/core/issues/5148#issuecomment-2041118368 is fixed -->
const { comp } = defineProps<{
comp: Component;
}>();
<script lang="ts">
import { onErrorCaptured, shallowRef, type Component, watch, computed, provide, useId, ref, type WatchOptions, inject } from 'vue';
import type { RouteLocationNormalizedGeneric, RouteMeta } from 'vue-router';
import DefaultLayout from '@/layouts/default.vue';
import FullPageLayout from '@/layouts/fullpage.vue';
import ServerLayout from '@/layouts/server.vue';
import { usePausableEffect } from '@/composables/use-pausable-effect';
import { JView_isRouting } from '@/store/keys';
import { router } from '@/plugins/router';
import { isNil } from '@/utils/validation';
/**
* Return the appropiate layout component according to the route's meta.layout property
*/
function getLayoutComponent(layout: RouteMeta['layout']['name']): Component {
switch (layout) {
case 'fullpage': {
return FullPageLayout as Component;
}
case 'server': {
return ServerLayout as Component;
}
default: {
return DefaultLayout;
}
}
}
const defaultTransition = 'slide-x-reverse';
const defaultTransitionMode = 'out-in';
const watchOps = { flush: 'sync' } satisfies WatchOptions;
const apploaded = shallowRef(false);
const isRouting = shallowRef(false);
const _resolveStatus = ref<Record<string, boolean>>({});
const allResolved = computed(() => Object.values(_resolveStatus.value).every(Boolean));
const mustBePaused = computed(() => !allResolved.value || isRouting.value);
watch(() => router.currentRoute.value.name, () => isRouting.value = true, watchOps);
watch(allResolved, () => isRouting.value = false, watchOps);
</script>
<script setup lang="ts">
const { comp, route } = defineProps<{
comp: Component;
route: RouteLocationNormalizedGeneric;
}>();
const id = useId();
const root = isNil(inject(JView_isRouting));
const resolved = computed({
get() {
return _resolveStatus.value[id] ?? false;
},
set(newVal) {
_resolveStatus.value[id] = newVal;
if (root && newVal) {
apploaded.value = true;
}
}
});
if (root) {
provide(JView_isRouting, mustBePaused);
usePausableEffect(mustBePaused);
onErrorCaptured(() => {
resolved.value = true;
isRouting.value = false;
});
}
</script>

View File

@ -2,8 +2,8 @@ import type { Api } from '@jellyfin/sdk';
import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import type { AxiosResponse } from 'axios';
import { deepEqual } from 'fast-equals';
import { computed, effectScope, getCurrentScope, isRef, shallowRef, toValue, unref, watch, type ComputedRef, type Ref } from 'vue';
import { until } from '@vueuse/core';
import { computed, effectScope, getCurrentScope, inject, isRef, shallowRef, toValue, unref, watch, type ComputedRef, type Ref } from 'vue';
import { until, whenever } from '@vueuse/core';
import { useLoading } from '@/composables/use-loading';
import { useSnackbar } from '@/composables/use-snackbar';
import { i18n } from '@/plugins/i18n';
@ -11,7 +11,7 @@ import { remote } from '@/plugins/remote';
import { isConnectedToServer } from '@/store';
import { apiStore } from '@/store/api';
import { isArray, isNil } from '@/utils/validation';
import { router } from '@/plugins/router';
import { JView_isRouting } from '@/store/keys';
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
type OmittedKeys = 'fields' | 'userId' | 'enableImages' | 'enableTotalRecordCount' | 'enableImageTypes';
@ -306,9 +306,17 @@ function _sharedInternalLogic<T extends Record<K, (...args: any[]) => any>, K ex
}
});
watch(() => router.currentRoute.value.name, () => scope.stop(),
{ once: true, flush: 'sync' }
);
/**
* If we're routing, the effects of this composable are no longer useful, so we stop them
* to avoid accidental data fetching (e.g due to route param changes)
*/
const isRouting = inject(JView_isRouting);
if (!isNil(isRouting)) {
whenever(isRouting, () => scope.stop(),
{ once: true, flush: 'sync' }
);
}
}
/**

View File

@ -1,8 +1,9 @@
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
import { computedAsync, useMediaControls, useMediaQuery, useNetwork, useNow, useScroll } from '@vueuse/core';
import { shallowRef } from 'vue';
import { computed, shallowRef } from 'vue';
import { remote } from '@/plugins/remote';
import { isNil } from '@/utils/validation';
import { router } from '@/plugins/router';
/**
* This file contains global variables (specially VueUse refs) that are used multiple times across the client.
@ -85,6 +86,11 @@ export const hasHDRDisplay = useMediaQuery('(video-dynamic-range:high)');
*/
export const isSlow = useMediaQuery('(update:slow)');
/**
* Whether the layout must use transparency effects
*/
export const transparencyEffects = computed(() => !prefersNoTransparency.value && router.currentRoute.value.meta.layout.transparent);
/**
* Reactively tracks if the user is connected to the server
*/

View File

@ -0,0 +1,7 @@
/**
* This file contains all the symbols used with provide/inject API:
* https://vuejs.org/guide/components/provide-inject.html#working-with-symbol-keys
*/
import type { ComputedRef, InjectionKey } from 'vue';
export const JView_isRouting = Symbol('JView:isRouting') as InjectionKey<ComputedRef<boolean>>;