Skip to content

Commit 50e34e8

Browse files
brc-ddkiaking
andauthored
fix(url): many bugs with useUrlQuerySync (#467)
Co-authored-by: Kia King Ishii <[email protected]>
1 parent 5782fbc commit 50e34e8

File tree

1 file changed

+100
-87
lines changed

1 file changed

+100
-87
lines changed

lib/composables/Url.ts

Lines changed: 100 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,141 @@
11
import isEqual from 'lodash-es/isEqual'
2-
import isPlainObject from 'lodash-es/isPlainObject'
3-
import { type MaybeRef, unref, watch } from 'vue'
4-
import { useRoute, useRouter } from 'vue-router'
2+
import { type MaybeRef, nextTick, unref, watch } from 'vue'
3+
import { type LocationQuery, useRoute, useRouter } from 'vue-router'
54

65
export interface UseUrlQuerySyncOptions {
76
casts?: Record<string, (value: any) => any>
87
exclude?: string[]
98
}
109

10+
/**
11+
* Sync between the given state and the URL query params.
12+
*
13+
* Caveats:
14+
* - Vulnerable to prototype pollution.
15+
* - Does not support objects inside arrays.
16+
*/
1117
export function useUrlQuerySync(
1218
state: MaybeRef<Record<string, any>>,
13-
{ casts = {}, exclude }: UseUrlQuerySyncOptions = {}
19+
{ casts = {}, exclude = [] }: UseUrlQuerySyncOptions = {}
1420
): void {
15-
const router = useRouter()
1621
const route = useRoute()
22+
const router = useRouter()
1723

18-
const flattenInitialState = flattenObject(
19-
JSON.parse(JSON.stringify(unref(state)))
20-
)
21-
22-
setStateFromQuery()
23-
24-
watch(() => unref(state), setQueryFromState, {
25-
deep: true,
26-
immediate: true
27-
})
24+
const flattenedDefaultState = flattenObject(unref(state))
2825

29-
function setStateFromQuery() {
30-
const flattenState = flattenObject(unref(state))
31-
const flattenQuery = flattenObject(route.query)
26+
let isSyncing = false
3227

33-
Object.keys(flattenQuery).forEach((key) => {
34-
if (exclude?.includes(key)) {
35-
return
28+
watch(
29+
() => route.query,
30+
async () => {
31+
if (!isSyncing) {
32+
isSyncing = true
33+
await setState()
34+
isSyncing = false
3635
}
36+
},
37+
{ deep: true, immediate: true }
38+
)
3739

38-
const value = flattenQuery[key]
39-
if (value === undefined) {
40-
return
40+
watch(
41+
() => unref(state),
42+
async () => {
43+
if (!isSyncing) {
44+
isSyncing = true
45+
await setQuery()
46+
isSyncing = false
4147
}
48+
},
49+
{ deep: true }
50+
)
4251

43-
const cast = casts[key]
44-
flattenState[key] = cast ? cast(value) : value
45-
})
52+
async function setState() {
53+
const newState = unflattenObject({ ...flattenedDefaultState, ...normalizeQuery(route.query) })
54+
deepAssign(unref(state), newState)
4655

47-
deepAssign(unref(state), unflattenObject(flattenState))
56+
await nextTick()
57+
await setQuery()
4858
}
4959

50-
async function setQueryFromState() {
51-
const flattenState = flattenObject(unref(state))
52-
const flattenQuery = flattenObject(route.query)
60+
async function setQuery() {
61+
const flattenedState = flattenObject(unref(state))
62+
const newQuery: Record<string, any> = {}
5363

54-
Object.keys(flattenState).forEach((key) => {
55-
if (exclude?.includes(key)) {
56-
return
64+
for (const key in flattenedState) {
65+
if (!exclude.includes(key) && flattenedDefaultState[key] !== flattenedState[key]) {
66+
newQuery[key] = flattenedState[key]
5767
}
68+
}
5869

59-
const value = flattenState[key]
60-
const initialValue = flattenInitialState[key]
70+
const currentQuery = normalizeQuery(route.query)
6171

62-
if (isEqual(value, initialValue)) {
63-
delete flattenQuery[key]
64-
} else {
65-
flattenQuery[key] = value
66-
}
72+
if (!isEqual(newQuery, currentQuery)) {
73+
await router.replace({ query: unflattenObject(newQuery) })
74+
}
75+
}
76+
77+
function normalizeQuery(query: LocationQuery): Record<string, any> {
78+
const flattenedQuery = flattenObject(query)
79+
const result: Record<string, any> = {}
6780

68-
if (flattenQuery[key] === undefined) {
69-
delete flattenQuery[key]
81+
for (const key in flattenedQuery) {
82+
if (!exclude.includes(key)) {
83+
result[key] = casts[key] ? casts[key](flattenedQuery[key]) : flattenedQuery[key]
7084
}
71-
})
85+
}
7286

73-
await router.replace({ query: unflattenObject(flattenQuery) })
87+
return result
7488
}
7589
}
7690

77-
function flattenObject(obj: Record<string, any>, prefix = '') {
78-
return Object.keys(obj).reduce((acc, k) => {
79-
const pre = prefix.length ? `${prefix}.` : ''
80-
if (isPlainObject(obj[k])) {
81-
Object.assign(acc, flattenObject(obj[k], pre + k))
91+
function flattenObject(obj: Record<string, any>, path: string[] = []): Record<string, any> {
92+
const result: Record<string, any> = {}
93+
94+
for (const key in obj) {
95+
const value = obj[key]
96+
97+
if (value && typeof value === 'object' && !Array.isArray(value)) {
98+
Object.assign(result, flattenObject(value, [...path, key]))
8299
} else {
83-
acc[pre + k] = obj[k]
100+
result[path.concat(key).join('.')] = value
84101
}
85-
return acc
86-
}, {} as Record<string, any>)
87-
}
102+
}
88103

89-
function unflattenObject(obj: Record<string, any>) {
90-
return Object.keys(obj).reduce((acc, k) => {
91-
const keys = k.split('.')
92-
keys.reduce((a, c, i) => {
93-
if (i === keys.length - 1) {
94-
a[c] = obj[k]
95-
} else {
96-
a[c] = a[c] || {}
97-
}
98-
return a[c]
99-
}, acc)
100-
return acc
101-
}, {} as Record<string, any>)
104+
return result
102105
}
103106

104-
function deepAssign(target: Record<string, any>, source: Record<string, any>) {
105-
const dest = target
106-
const src = source
107-
108-
if (isPlainObject(src)) {
109-
Object.keys(src).forEach((key) => deepAssignBase(dest, src, key))
110-
} else if (Array.isArray(src)) {
111-
dest.length = src.length
112-
src.forEach((_, key) => deepAssignBase(dest, src, key))
113-
} else {
114-
throw new TypeError('[deepAssign] src must be an object or array')
107+
function unflattenObject(obj: Record<string, any>): Record<string, any> {
108+
const result: Record<string, any> = {}
109+
110+
for (const key in obj) {
111+
const value = obj[key]
112+
113+
let target = result
114+
const keys = key.split('.')
115+
116+
for (let i = 0; i < keys.length - 1; i++) {
117+
const k = keys[i]
118+
target = target[k] = target[k] || {}
119+
}
120+
121+
target[keys[keys.length - 1]] = value
115122
}
123+
124+
return result
116125
}
117126

118-
function deepAssignBase(
119-
dest: Record<string, any>,
120-
src: Record<string, any>,
121-
key: string | number
122-
) {
123-
if (typeof src[key] === 'object' && src[key] !== null) {
124-
deepAssign(dest[key], src[key])
125-
} else {
126-
dest[key] = src[key]
127+
function deepAssign(target: Record<string, any>, source: Record<string, any>) {
128+
for (const key in source) {
129+
const value = source[key]
130+
131+
if (Array.isArray(value)) {
132+
target[key].splice(0, target[key].length, ...value)
133+
} else if (value && typeof value === 'object') {
134+
target[key] = deepAssign(target[key] || {}, value)
135+
} else {
136+
target[key] = value
137+
}
127138
}
139+
140+
return target
128141
}

0 commit comments

Comments
 (0)