Skip to content

Instantly share code, notes, and snippets.

@CasperEngl
Created February 19, 2024 01:28
Show Gist options
  • Save CasperEngl/8222dd6eb3dff7e77ff77edb27e7ef4c to your computer and use it in GitHub Desktop.
Save CasperEngl/8222dd6eb3dff7e77ff77edb27e7ef4c to your computer and use it in GitHub Desktop.
import { debounce } from 'lodash-es'
import type { MaybeRef } from 'vue'
import { computed, reactive, unref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { z } from 'zod'
type PrimitiveZodTypes =
| z.ZodString
| z.ZodNumber
| z.ZodDate
| z.ZodArray<PrimitiveZodTypes>
| z.ZodCatch<PrimitiveZodTypes>
| z.ZodDefault<PrimitiveZodTypes>
| z.ZodOptional<PrimitiveZodTypes>
| z.ZodNullable<PrimitiveZodTypes>
| z.ZodEffects<
PrimitiveZodTypes,
PrimitiveZodTypes | string | number,
PrimitiveZodTypes | unknown
>
| z.ZodUnion<[PrimitiveZodTypes, PrimitiveZodTypes, ...PrimitiveZodTypes[]]>
| z.ZodEnum<[string, string, ...string[]]>
| z.ZodEffects<z.ZodOptional<z.ZodDate>, string, Date>
| z.ZodLiteral<string>
export type ZodObjectSchema = z.ZodObject<Record<string, PrimitiveZodTypes>, z.UnknownKeysParam>
export function zodHasArray(schema: PrimitiveZodTypes): boolean {
if (schema instanceof z.ZodArray) {
return true
}
if (schema instanceof z.ZodObject) {
return Object.values(schema.shape).some((value) => zodHasArray(value as PrimitiveZodTypes))
}
if (schema instanceof z.ZodUnion) {
return schema.options.some(zodHasArray)
}
if (schema instanceof z.ZodEnum) {
return false
}
if (schema instanceof z.ZodTuple) {
return schema.items.some(zodHasArray)
}
if (schema instanceof z.ZodIntersection) {
if ('left' in schema && 'right' in schema) {
return schema.left
? zodHasArray(schema.left as PrimitiveZodTypes)
: zodHasArray(schema.right as PrimitiveZodTypes)
}
}
if (schema instanceof z.ZodEffects) {
return zodHasArray(schema._def.schema)
}
if (schema && '_def' in schema && typeof schema._def === 'object' && 'innerType' in schema._def) {
return zodHasArray(schema._def.innerType)
}
return false
}
function zodDefaultValue(schema: z.ZodType<unknown, unknown>) {
if (schema instanceof z.ZodDefault) {
return schema._def.defaultValue()
}
if (schema && '_def' in schema && typeof schema._def === 'object' && 'innerType' in schema._def) {
return zodDefaultValue(schema._def.innerType as z.ZodType<unknown, unknown>)
}
return undefined
}
function zodCatchValue(schema: z.ZodType<unknown, unknown>) {
if (schema instanceof z.ZodCatch) {
return schema._def.catchValue({
error: new z.ZodError([]),
input: undefined,
})
}
if (schema && '_def' in schema && typeof schema._def === 'object' && 'innerType' in schema._def) {
return zodCatchValue(schema._def.innerType as z.ZodType<unknown, unknown>)
}
return undefined
}
type UseZodParamsOptions<Schema extends ZodObjectSchema> = {
schema: Schema
enabled?: MaybeRef<boolean>
type: 'params' | 'query'
debounceMs?: number
}
// Outside your export function, define a map to hold debounced functions
const debouncedSetters = new Map()
export function useZodParams<Schema extends ZodObjectSchema>(options: UseZodParamsOptions<Schema>) {
const route = useRoute()
const enabled = options.enabled ?? true
const type = options.type ?? 'query'
const debouncedMs = options.debounceMs ?? 300
const mountedMatched = route.matched
const hasMatchedRoute = computed(() => {
return mountedMatched.some((m) => m.path === route.path)
})
const proxy = new Proxy(reactive({} as z.infer<Schema>), {
get(target, key) {
if (!unref(enabled)) return null
if (!unref(hasMatchedRoute)) return null
const schemaPath = options.schema.shape[key.toString()]
/**
* Vue Router returns a single value if there's only one array item.
* For consistent parameter handling, convert it to an array if the
* schema contains an array.
*/
const updatedTarget = Object.fromEntries(
Object.entries(target).map(([key, value]) => [
key,
zodHasArray(schemaPath) && !Array.isArray(value) ? [value] : value,
])
)
return Reflect.get(options.schema.parse(updatedTarget), key)
},
set(target, key, value) {
if (!unref(enabled)) return false
if (!unref(hasMatchedRoute)) return false
const schemaPath = options.schema.shape[key.toString()]
// Prepare the new value according to the schema
const newValue = zodHasArray(schemaPath) && !Array.isArray(value) ? [value] : value
// Check if there's already a debounced function for this key
if (!debouncedSetters.has(key)) {
const debouncedSetter = debounce((newValue) => {
const url = new URL(window.location.href)
if (Array.isArray(newValue)) {
url.searchParams.delete(key.toString())
for (const value of newValue) {
url.searchParams.append(key.toString(), value.toString())
}
} else {
url.searchParams.set(key.toString(), newValue ?? '')
}
window.history.replaceState(null, '', url.toString())
// Remove the debounced function from the map after it's called
debouncedSetters.delete(key)
}, debouncedMs) // Adjust debounce time as needed
// Store the debounced function in the map
debouncedSetters.set(key, debouncedSetter)
}
// Retrieve the debounced function and call it with the new value
debouncedSetters.get(key)(newValue)
/**
* Use Reflect.set to update the value of the key in the target object.
* This is necessary to ensure that the changes are propagated to all
* references of the object. It updates the target object immediately without debouncing,
* ensuring the proxy object is up-to-date even though the router update is debounced.
*/
return Reflect.set(target, key, newValue)
},
})
// Watch for changes in the route and update the proxy accordingly
watch(route, (newRoute) => {
if (!unref(enabled)) return
if (!unref(hasMatchedRoute)) return
const existingKeys = Object.keys(proxy)
const newKeys = Object.keys(newRoute[type])
// Handle keys present in the proxy but not in the newRoute
for (const key of existingKeys) {
if (!newKeys.includes(key)) {
Reflect.set(proxy, key, undefined)
}
}
// Handle keys present in the newRoute
for (const key of newKeys) {
const schemaPath = options.schema.shape[key]
const value = newRoute[type][key]
const catchValue = zodCatchValue(schemaPath)
const defaultValue = zodDefaultValue(schemaPath)
Reflect.set(proxy, key, value || defaultValue || catchValue)
}
})
for (const key of Object.keys(options.schema.shape)) {
const schemaPath = options.schema.shape[key]
const value = route[type][key]
const catchValue = zodCatchValue(schemaPath)
const defaultValue = zodDefaultValue(schemaPath)
Reflect.set(proxy, key, value || defaultValue || catchValue)
}
return proxy
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment