diff --git a/packages/router/_types.ts b/packages/router/_types.ts index e962380572d..0d84518398d 100644 --- a/packages/router/_types.ts +++ b/packages/router/_types.ts @@ -1,7 +1,9 @@ import type { MaybeRef } from '@vueuse/shared' -import type { useRoute, useRouter } from 'vue-router' +import type { RouteParamValueRaw, useRoute, useRouter } from 'vue-router' -export type RouterQueryValue = null | undefined | string | string[] +export type RouteQueryValueRaw = RouteParamValueRaw | string[] + +export type RouteHashValueRaw = string | null | undefined export interface ReactiveRouteOptions { /** diff --git a/packages/router/useRouteHash/index.test.ts b/packages/router/useRouteHash/index.test.ts new file mode 100644 index 00000000000..f5ce4414543 --- /dev/null +++ b/packages/router/useRouteHash/index.test.ts @@ -0,0 +1,65 @@ +import { nextTick } from 'vue-demi' +import { describe, expect, it } from 'vitest' +import { useRouteHash } from '.' + +describe('useRouteHash', () => { + const getRoute = (hash?: any) => ({ + query: {}, + fullPath: '', + hash, + matched: [], + meta: {}, + name: '', + params: {}, + path: '', + redirectedFrom: undefined, + }) + + it('should export', () => { + expect(useRouteHash).toBeDefined() + }) + + it('should return current value', () => { + let route = getRoute('header') + const router = { replace: (r: any) => route = r } as any + + const hash = useRouteHash(null, { route, router }) + + expect(hash.value).toBe(route.hash) + }) + + it('should re-evaluate the value immediately', () => { + let route = getRoute('header') + const router = { replace: (r: any) => route = r } as any + + const hash = useRouteHash(null, { route, router }) + + hash.value = 'footer' + + expect(hash.value).toBe('footer') + }) + + it('should update the route', async () => { + let route = getRoute('foo') + const router = { replace: (r: any) => route = r } as any + + const hash = useRouteHash(null, { route, router }) + + hash.value = 'footer' + + await nextTick() + + expect(hash.value).toBe('footer') + expect(route.hash).toBe('footer') + }) + + it('should return default value', () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + + const hash = useRouteHash('baz', { route, router }) + + expect(hash.value).toBe('baz') + expect(route.hash).toBeUndefined() + }) +}) diff --git a/packages/router/useRouteHash/index.ts b/packages/router/useRouteHash/index.ts index 93814bb7149..86f1cfa5dee 100644 --- a/packages/router/useRouteHash/index.ts +++ b/packages/router/useRouteHash/index.ts @@ -1,24 +1,38 @@ -import { computed, nextTick } from 'vue-demi' +import { customRef, nextTick } from 'vue-demi' import { useRoute, useRouter } from 'vue-router' -import { toValue } from '@vueuse/shared' -import type { ReactiveRouteOptions } from '../_types' +import { toValue, tryOnScopeDispose } from '@vueuse/shared' +import type { ReactiveRouteOptions, RouteHashValueRaw } from '../_types' + +let _hash: RouteHashValueRaw export function useRouteHash( - defaultValue?: string, + defaultValue?: RouteHashValueRaw, { mode = 'replace', route = useRoute(), router = useRouter(), }: ReactiveRouteOptions = {}, ) { - return computed({ + _hash = route.hash + + tryOnScopeDispose(() => { + _hash = undefined + }) + + return customRef((track, trigger) => ({ get() { - return route.hash ?? defaultValue + track() + + return _hash || defaultValue }, set(v) { + _hash = v === null ? undefined : v + + trigger() + nextTick(() => { - router[toValue(mode)]({ ...route, hash: v }) + router[toValue(mode)]({ ...route, hash: _hash as string }) }) }, - }) + })) } diff --git a/packages/router/useRouteParams/index.test.ts b/packages/router/useRouteParams/index.test.ts index 302acf13f0f..6c1d909528d 100644 --- a/packages/router/useRouteParams/index.test.ts +++ b/packages/router/useRouteParams/index.test.ts @@ -1,25 +1,146 @@ +import { effectScope, nextTick, ref } from 'vue-demi' import { describe, expect, it } from 'vitest' +import type { Ref } from 'vue-demi' import { useRouteParams } from '.' -describe('useRouteQuery', () => { +describe('useRouteParams', () => { const getRoute = (params: Record = {}) => ({ + params, query: {}, fullPath: '', hash: '', matched: [], meta: {}, name: '', - params: { id: '1', ...params }, path: '', redirectedFrom: undefined, }) - it('should return transformed value', () => { + it('should export', () => { + expect(useRouteParams).toBeDefined() + }) + + it('should return current value', () => { const router = {} as any + const route = getRoute({ + id: '1', + }) + + const id = useRouteParams('id', null, { route, router }) + + expect(id.value).toBe('1') + }) + + it('should return transformed value', () => { const route = getRoute() + const router = {} as any const id = useRouteParams('id', '1', { transform: Number, route, router }) expect(id.value).toBe(1) }) + + it('should re-evaluate the value immediately', () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + + const slug: Ref = useRouteParams('slug', 'foo', { route, router }) + const id: Ref = useRouteParams('id', '123', { route, router }) + const page: Ref = useRouteParams('page', null, { route, router }) + + slug.value = 'bar' + id.value = '456' + page.value = '2' + + expect(slug.value).toBe('bar') + expect(id.value).toBe('456') + expect(page.value).toBe('2') + }) + + it('should update the route', async () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + + const code: Ref = useRouteParams('code', null, { route, router }) + const page: Ref = useRouteParams('page', null, { route, router }) + const lang: Ref = useRouteParams('lang', null, { route, router }) + + code.value = 'bar' + page.value = '1' + lang.value = 'en' + + await nextTick() + + expect(code.value).toBe('bar') + expect(route.params.code).toBe('bar') + expect(page.value).toBe('1') + expect(route.params.page).toBe('1') + expect(lang.value).toBe('en') + expect(route.params.lang).toBe('en') + }) + + it('should return default value', () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + + const page: Ref = useRouteParams('page', 10, { route, router }) + const lang: Ref = useRouteParams('lang', 'pt-BR', { route, router }) + + expect(page.value).toBe(10) + expect(lang.value).toBe('pt-BR') + }) + + it('should reset state on scope dispose', async () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + const scopeA = effectScope() + const scopeB = effectScope() + + let page: Ref = ref(null) + let lang: Ref = ref(null) + let code: Ref = ref(null) + let slug: Ref = ref(null) + + await scopeA.run(async () => { + page = useRouteParams('page', null, { route, router }) + lang = useRouteParams('lang', null, { route, router }) + + page.value = 2 + lang.value = 'pt-BR' + + await nextTick() + }) + + expect(page.value).toBe(2) + expect(lang.value).toBe('pt-BR') + expect(route.params.page).toBe(2) + expect(route.params.lang).toBe('pt-BR') + + await scopeB.run(async () => { + code = useRouteParams('code', null, { route, router }) + slug = useRouteParams('slug', null, { route, router }) + + code.value = 'xyz' + slug.value = 'vueuse' + + await nextTick() + }) + + expect(code.value).toBe('xyz') + expect(slug.value).toBe('vueuse') + expect(route.params.code).toBe('xyz') + expect(route.params.slug).toBe('vueuse') + + scopeB.stop() + + expect(page.value).toBe(2) + expect(lang.value).toBe('pt-BR') + expect(code.value).toBeNull() + expect(slug.value).toBeNull() + + scopeA.stop() + + expect(page.value).toBeNull() + expect(lang.value).toBeNull() + }) }) diff --git a/packages/router/useRouteParams/index.ts b/packages/router/useRouteParams/index.ts index 533727e29e1..df59b435616 100644 --- a/packages/router/useRouteParams/index.ts +++ b/packages/router/useRouteParams/index.ts @@ -1,15 +1,18 @@ import type { Ref } from 'vue-demi' -import { computed, nextTick } from 'vue-demi' +import { customRef, nextTick } from 'vue-demi' +import type { RouteParamValueRaw } from 'vue-router' import { useRoute, useRouter } from 'vue-router' -import { toValue } from '@vueuse/shared' -import type { ReactiveRouteOptionsWithTransform, RouterQueryValue } from '../_types' +import { toValue, tryOnScopeDispose } from '@vueuse/shared' +import type { ReactiveRouteOptionsWithTransform } from '../_types' + +const _cache = new WeakMap() export function useRouteParams( name: string ): Ref export function useRouteParams< - T extends RouterQueryValue = RouterQueryValue, + T extends RouteParamValueRaw = RouteParamValueRaw, K = T, >( name: string, @@ -18,7 +21,7 @@ export function useRouteParams< ): Ref export function useRouteParams< - T extends RouterQueryValue = RouterQueryValue, + T extends RouteParamValueRaw = RouteParamValueRaw, K = T, >( name: string, @@ -32,15 +35,32 @@ export function useRouteParams< transform = value => value as any as K, } = options - return computed({ + if (!_cache.has(route)) + _cache.set(route, new Map()) + + const _params: Map = _cache.get(route) + + tryOnScopeDispose(() => { + _params.delete(name) + }) + + _params.set(name, route.params[name]) + + return customRef((track, trigger) => ({ get() { - const data = route.params[name] ?? defaultValue + track() + + const data = _params.get(name) ?? defaultValue return transform(data as T) }, set(v) { + _params.set(name, (v === defaultValue || v === null) ? undefined : v) + + trigger() + nextTick(() => { - router[toValue(mode)]({ ...route, params: { ...route.params, [name]: v } }) + router[toValue(mode)]({ ...route, params: { ...route.params, ...Object.fromEntries(_params.entries()) } }) }) }, - }) + })) } diff --git a/packages/router/useRouteQuery/index.test.ts b/packages/router/useRouteQuery/index.test.ts index e0aced01c84..429631311d0 100644 --- a/packages/router/useRouteQuery/index.test.ts +++ b/packages/router/useRouteQuery/index.test.ts @@ -1,13 +1,11 @@ +import { effectScope, nextTick, ref } from 'vue-demi' import { describe, expect, it } from 'vitest' +import type { Ref } from 'vue-demi' import { useRouteQuery } from '.' describe('useRouteQuery', () => { const getRoute = (query: Record = {}) => ({ - query: { - search: 'vue3', - page: '1', - ...query, - }, + query, fullPath: '', hash: '', matched: [], @@ -18,9 +16,16 @@ describe('useRouteQuery', () => { redirectedFrom: undefined, }) + it('should export', () => { + expect(useRouteQuery).toBeDefined() + }) + it('should return transformed value', () => { const router = {} as any - const route = getRoute() + const route = getRoute({ + search: 'vue3', + page: '1', + }) const transform = Number const toArray = (param: string | string[] | null) => Array.isArray(param) ? param : [param] @@ -32,4 +37,106 @@ describe('useRouteQuery', () => { expect(perPage.value).toBe(15) expect(tags.value).toEqual(['vite']) }) + + it('should re-evaluate the value immediately', () => { + let route = getRoute({ + search: 'vue3', + }) + const router = { replace: (r: any) => route = r } as any + + const code: Ref = useRouteQuery('code', 'foo', { route, router }) + const search: Ref = useRouteQuery('search', null, { route, router }) + + code.value = 'bar' + + expect(code.value).toBe('bar') + expect(search.value).toBe('vue3') + }) + + it('should update the route', async () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + + const code: Ref = useRouteQuery('code', null, { route, router }) + const page: Ref = useRouteQuery('page', null, { route, router }) + const lang: Ref = useRouteQuery('lang', null, { route, router }) + + code.value = 'bar' + page.value = '1' + lang.value = 'en' + + await nextTick() + + expect(code.value).toBe('bar') + expect(route.query.code).toBe('bar') + expect(page.value).toBe('1') + expect(route.query.page).toBe('1') + expect(lang.value).toBe('en') + expect(route.query.lang).toBe('en') + }) + + it('should return default value', () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + + const page: Ref = useRouteQuery('page', 2, { route, router }) + const lang: Ref = useRouteQuery('lang', 'pt-BR', { route, router }) + + expect(page.value).toBe(2) + expect(lang.value).toBe('pt-BR') + }) + + it('should reset state on scope dispose', async () => { + let route = getRoute() + const router = { replace: (r: any) => route = r } as any + const scopeA = effectScope() + const scopeB = effectScope() + + let page: Ref = ref(null) + let lang: Ref = ref(null) + let code: Ref = ref(null) + let search: Ref = ref(null) + + await scopeA.run(async () => { + page = useRouteQuery('page', null, { route, router }) + lang = useRouteQuery('lang', null, { route, router }) + + page.value = 2 + lang.value = 'pt-BR' + + await nextTick() + }) + + expect(page.value).toBe(2) + expect(lang.value).toBe('pt-BR') + expect(route.query.page).toBe(2) + expect(route.query.lang).toBe('pt-BR') + + await scopeB.run(async () => { + code = useRouteQuery('code', null, { route, router }) + search = useRouteQuery('search', null, { route, router }) + + code.value = 'xyz' + search.value = 'vueuse' + + await nextTick() + }) + + expect(code.value).toBe('xyz') + expect(search.value).toBe('vueuse') + expect(route.query.code).toBe('xyz') + expect(route.query.search).toBe('vueuse') + + scopeB.stop() + + expect(page.value).toBe(2) + expect(lang.value).toBe('pt-BR') + expect(code.value).toBeNull() + expect(search.value).toBeNull() + + scopeA.stop() + + expect(page.value).toBeNull() + expect(lang.value).toBeNull() + }) }) diff --git a/packages/router/useRouteQuery/index.ts b/packages/router/useRouteQuery/index.ts index 5e8a4318bda..3fe3a3b35f4 100644 --- a/packages/router/useRouteQuery/index.ts +++ b/packages/router/useRouteQuery/index.ts @@ -1,17 +1,17 @@ import type { Ref } from 'vue-demi' -import { computed, nextTick } from 'vue-demi' -import { toValue } from '@vueuse/shared' +import { customRef, nextTick } from 'vue-demi' +import { toValue, tryOnScopeDispose } from '@vueuse/shared' import { useRoute, useRouter } from 'vue-router' -import type { ReactiveRouteOptionsWithTransform, RouterQueryValue } from '../_types' +import type { ReactiveRouteOptionsWithTransform, RouteQueryValueRaw } from '../_types' -let _queue: Record = {} +const _cache = new WeakMap() export function useRouteQuery( name: string ): Ref export function useRouteQuery< - T extends RouterQueryValue = RouterQueryValue, + T extends RouteQueryValueRaw = RouteQueryValueRaw, K = T, >( name: string, @@ -20,7 +20,7 @@ export function useRouteQuery< ): Ref export function useRouteQuery< - T extends RouterQueryValue = RouterQueryValue, + T extends RouteQueryValueRaw = RouteQueryValueRaw, K = T, >( name: string, @@ -34,18 +34,35 @@ export function useRouteQuery< transform = value => value as any as K, } = options - return computed({ + if (!_cache.has(route)) + _cache.set(route, new Map()) + + const _query: Map = _cache.get(route) + + tryOnScopeDispose(() => { + _query.delete(name) + }) + + _query.set(name, route.query[name]) + + return customRef((track, trigger) => ({ get() { - const data = route.query[name] ?? defaultValue + track() + + const data = _query.get(name) ?? defaultValue return transform(data as T) }, set(v) { - _queue[name] = (v === defaultValue || v === null) ? undefined : v + _query.set(name, (v === defaultValue || v === null) ? undefined : v) + + trigger() nextTick(() => { - router[toValue(mode)]({ ...route, query: { ...route.query, ..._queue } }) - nextTick(() => _queue = {}) + router[toValue(mode)]({ + ...route, + query: { ...route.query, ...Object.fromEntries(_query.entries()) }, + }) }) }, - }) + })) }