import { ref, watch, nextTick, type Ref } from 'vue' import { useRoute, useRouter } from '#imports' interface ParamDef { default: T type?: 'string' | 'number' /** Debounce URL writes (ms). Default: 0 (immediate). */ debounce?: number } type ParamDefs = Record type InferRef = D['default'] extends number ? Ref : Ref type StateRefs = { [K in keyof T]: InferRef } interface UseUrlStateOptions { /** Called when state is restored from URL (back/forward navigation). */ onRestore?: () => void } export function useUrlState( params: T, options?: UseUrlStateOptions, ): StateRefs { const route = useRoute() const router = useRouter() const keys = Object.keys(params) as (keyof T & string)[] const refs: Record> = {} const timers: Record | null> = {} for (const key of keys) { refs[key] = ref(parseValue(route.query[key], params[key]!)) timers[key] = null } let isProgrammatic = false const buildQuery = (): Record => { const q: Record = {} for (const key of keys) { const val = refs[key]!.value if (val !== params[key]!.default) { q[key] = String(val) } } return q } const pushToUrl = () => { if (isProgrammatic) return isProgrammatic = true const query = buildQuery() router .replace({ path: route.path, query }) .catch(() => {}) .finally(() => { nextTick(() => { isProgrammatic = false }) }) } for (const key of keys) { const ms = params[key]!.debounce ?? 0 watch(refs[key]!, () => { if (isProgrammatic) return if (ms > 0) { if (timers[key]) clearTimeout(timers[key]!) timers[key] = setTimeout(pushToUrl, ms) } else { pushToUrl() } }) } watch( () => ({ ...route.query }), (newQuery) => { if (isProgrammatic) return isProgrammatic = true let changed = false for (const key of keys) { const parsed = parseValue(newQuery[key], params[key]!) if (refs[key]!.value !== parsed) { refs[key]!.value = parsed changed = true } } nextTick(() => { isProgrammatic = false if (changed && options?.onRestore) { options.onRestore() } }) }, ) return refs as StateRefs } function parseValue( raw: unknown, def: ParamDef, ): string | number { const str = typeof raw === 'string' ? raw : null if (str === null) return def.default if (def.type === 'number' || typeof def.default === 'number') { const n = Number(str) return Number.isFinite(n) ? n : def.default } return str }