UNPKG

@chronicstone/vue-route-query

Version:

Type-safe URL query parameter synchronization for Vue 3 with Zod validation

430 lines (425 loc) 14.8 kB
import { nextTick, onUnmounted, ref, watch, toRaw } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { z } from 'zod'; class GlobalQueryManager { static instance; updates = /* @__PURE__ */ new Map(); processingPromise = null; router = null; currentQuery = {}; initialQueryHandled = false; instances = /* @__PURE__ */ new Map(); constructor() { } static getInstance() { if (!GlobalQueryManager.instance) { GlobalQueryManager.instance = new GlobalQueryManager(); } return GlobalQueryManager.instance; } init(router, currentQuery) { this.router = router; if (!this.initialQueryHandled) { this.currentQuery = currentQuery; this.initialQueryHandled = true; } } registerInstance(instanceId, schemaKeys) { this.instances.set(instanceId, schemaKeys); } unregisterInstance(instanceId) { const keys = this.instances.get(instanceId); if (!keys) return; this.instances.delete(instanceId); for (const key of keys) { if (!this.isKeyOwnedByInstance(key)) delete this.currentQuery[key]; } } isKeyOwnedByInstance(key) { for (const [instanceId, keys] of this.instances.entries()) if (keys.includes(key)) return true; return false; } enqueue(key, value, mode = "replace") { this.updates.set(key, { value, mode }); if (value === void 0) { const prefix = `${key}.`; Object.keys(this.currentQuery).filter((k) => k.startsWith(prefix)).forEach((childKey) => { this.updates.set(childKey, { value: void 0, mode }); }); } if (!this.processingPromise) { this.processingPromise = nextTick().then(() => this.processUpdates()); } } async processUpdates() { if (!this.router) return; let finalMode = "replace"; for (const update of this.updates.values()) { if (update.mode === "push") { finalMode = "push"; break; } } const updates = {}; this.updates.forEach((update, key) => { updates[key] = update.value; }); this.updates.clear(); this.processingPromise = null; const finalQuery = {}; Object.keys(this.currentQuery).filter((key) => !Object.prototype.hasOwnProperty.call(updates, key)).forEach((key) => { finalQuery[key] = this.currentQuery[key]; }); Object.entries(updates).filter(([_, value]) => value !== void 0).forEach(([key, value]) => { finalQuery[key] = value; }); const currentQueryStr = JSON.stringify(this.currentQuery); const finalQueryStr = JSON.stringify(finalQuery); if (currentQueryStr !== finalQueryStr) { this.currentQuery = finalQuery; if (finalMode === "push") { await this.router.push({ query: finalQuery }); } else { await this.router.replace({ query: finalQuery }); } } } removeKeys(keys, mode = "replace") { keys.forEach((key) => { this.enqueue(key, void 0, mode); }); } updateCurrentQuery(query) { if (!this.initialQueryHandled) { this.currentQuery = query; this.initialQueryHandled = true; } } removeAllWithPrefix(prefix, mode = "replace") { const keysToRemove = Object.keys(this.currentQuery).filter( (key) => key === prefix.slice(0, -1) || key.startsWith(prefix) ); keysToRemove.forEach((key) => { this.enqueue(key, void 0, mode); }); } static cleanup() { GlobalQueryManager.instance = null; } } function buildQueryObject(object, rootKey, prefix = "") { if (typeof object !== "object" || object === null) { return rootKey ? { [rootKey]: object } : {}; } if (Array.isArray(object) && rootKey) { return { [rootKey]: !object.length ? null : JSON.stringify(object) }; } let result = {}; for (const [key, value] of Object.entries(object)) { const newKey = prefix ? `${prefix}.${key}` : rootKey ? `${rootKey}.${key}` : key; if (Array.isArray(value)) { result[newKey] = !value.length ? null : JSON.stringify(value); } else if (typeof value === "object" && value !== null) { const nestedResult = buildQueryObject(value, void 0, newKey); result = { ...result, ...nestedResult }; } else { result[newKey] = value; } } return result; } function rebuildObjectFromQuery(query, schema, rootKey) { if (schema instanceof z.ZodType) { if (!rootKey) throw new Error("rootKey is required for single values"); const value = query[rootKey]; if (isZodArray(schema)) { return value !== void 0 ? tryParse(value, schema) : []; } return value !== void 0 ? tryParse(value, schema) : void 0; } const result = {}; const prefix = rootKey ? `${rootKey}.` : ""; const relevantKeys = Object.keys(query).filter( (key) => !rootKey || key.startsWith(prefix) ); relevantKeys.sort((a, b) => { const dotsA = (a.match(/\./g) || []).length; const dotsB = (b.match(/\./g) || []).length; return dotsA - dotsB; }); for (const fullKey of relevantKeys) { const unprefixedKey = rootKey ? fullKey.slice(prefix.length) : fullKey; const value = query[fullKey]; const parts = unprefixedKey.split("."); const schemaKey = parts[0]; if (!(schemaKey in schema)) continue; const fieldSchema = schema[schemaKey]; const currentPath = parts.slice(0, -1).reduce((obj, part) => obj?.[part], result); if (currentPath !== void 0 && typeof currentPath !== "object") { continue; } let current = result; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!(part in current)) { current[part] = {}; } if (typeof current[part] !== "object") { break; } current = current[part]; } const lastPart = parts[parts.length - 1]; if (typeof current === "object") { current[lastPart] = tryParse(value, fieldSchema); } } return result; } function tryParse(value, schema) { if (typeof value !== "string") return value; if (value.startsWith("[") || value.startsWith("{")) { try { const parsed = JSON.parse(value); if (schema && isZodArray(schema)) { return Array.isArray(parsed) ? parsed : []; } return parsed; } catch { if (schema && isZodArray(schema)) { return []; } return value; } } if (value === "true") return true; if (value === "false") return false; if (schema) { if (schema instanceof z.ZodNumber) { const num = Number(value); if (!Number.isNaN(num)) return num; } if (isZodArray(schema)) { return []; } } return value; } function isZodArray(schema) { return schema instanceof z.ZodArray; } function isDirty(initialState, currentState) { const compareArrays = (arr1, arr2) => { if (arr1.length !== arr2.length) return true; for (let i = 0; i < arr1.length; i++) { if (isDirty(arr1[i], arr2[i])) return true; } return false; }; const compareObjects = (obj1, obj2) => { const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) return true; for (const key of keys1) { if (!(key in obj2) || isDirty(obj1[key], obj2[key])) return true; } return false; }; if (initialState === currentState) return false; if (typeof initialState !== typeof currentState) return true; if (Array.isArray(initialState) && Array.isArray(currentState)) return compareArrays(initialState, currentState); if (typeof initialState === "object" && typeof currentState === "object" && initialState !== null && currentState !== null) return compareObjects(initialState, currentState); return true; } function deepPartialify(schema) { return _deepPartialify(schema); } function _deepPartialify(schema) { if (schema instanceof z.ZodObject) { const newShape = {}; for (const key in schema.shape) { const fieldSchema = schema.shape[key]; newShape[key] = z.ZodOptional.create(_deepPartialify(fieldSchema)); } return new z.ZodObject({ ...schema._def, shape: () => newShape }); } else if (schema instanceof z.ZodArray) { return new z.ZodArray({ ...schema._def, type: _deepPartialify(schema.element) }); } else if (schema instanceof z.ZodOptional) { return z.ZodOptional.create(_deepPartialify(schema.unwrap())); } else if (schema instanceof z.ZodNullable) { return z.ZodNullable.create(_deepPartialify(schema.unwrap())); } else if (schema instanceof z.ZodTuple) { return z.ZodTuple.create( schema.items.map((item) => _deepPartialify(item)) ); } else { return schema; } } function deepMerge(target, source) { const result = { ...target }; for (const key in source) { const sourceValue = source[key]; const targetValue = target[key]; if (sourceValue && typeof sourceValue === "object" && !Array.isArray(sourceValue) && targetValue && typeof targetValue === "object" && !Array.isArray(targetValue)) { result[key] = deepMerge(targetValue, sourceValue); } else if (sourceValue !== void 0) { result[key] = sourceValue; } } return result; } function useRouteQuery(params) { const route = useRoute(); const router = useRouter(); const defaultValue = params.default; const mode = params.mode ?? "replace"; const instanceId = Symbol(); if (params.debug) console.log("useRouteQuery init with:", { route: route.query, default: defaultValue, schema: params.schema, mode }); const queryManager = GlobalQueryManager.getInstance(); const { nullable = false, key: rootKey } = params; if (params.schema instanceof z.ZodType && !rootKey) { throw new Error("key is required for single value schemas"); } const instanceKeys = params.schema instanceof z.ZodType || params.key ? [params.key] : Object.keys(params.schema); queryManager.init(router, route.query); queryManager.registerInstance(instanceId, instanceKeys); onUnmounted(() => queryManager.unregisterInstance(instanceId)); const baseSchema = params.schema instanceof z.ZodType ? params.schema : z.object(params.schema).partial(); const zodSchema = nullable ? baseSchema.nullable() : baseSchema; const initialQuery = route.query; const parsedInitialQuery = parseQuery(initialQuery); if (params.debug) console.log("initializing with:", { query: initialQuery, parsed: parsedInitialQuery, hasUrlState: Object.keys(initialQuery).length > 0 }); let previousValue = parsedInitialQuery; const dataRef = ref(parsedInitialQuery); if (params.enabled ?? true) { watch( () => JSON.stringify(route.query), (raw) => { const query = raw ? JSON.parse(raw) : {}; queryManager.updateCurrentQuery(query); const parsed = parseQuery(query); if (!isDirty(parsed, dataRef.value)) return; dataRef.value = parsed; }, { immediate: true } ); watch( dataRef, (newValue) => { const prevValue = JSON.parse(JSON.stringify(previousValue)); previousValue = JSON.parse(JSON.stringify(newValue)); if (newValue === null && nullable) { if (typeof params.schema === "object") { const schemaKeys = Object.keys(params.schema).map( (key) => rootKey ? `${rootKey}.${key}` : key ); queryManager.removeKeys(schemaKeys, mode); } else { queryManager.enqueue(rootKey, void 0, mode); } return; } if (!(typeof params.schema === "object")) { if (newValue === "" || newValue === void 0 || newValue === null || Array.isArray(newValue) && newValue.length === 0 || newValue === defaultValue) { queryManager.enqueue(rootKey, void 0, mode); } else { queryManager.enqueue(rootKey, newValue, mode); } return; } const queryUpdates = buildQueryObject(newValue, rootKey); const defaultQueryUpdates = buildQueryObject(defaultValue, rootKey); if (typeof params.schema === "object") { Object.keys(params.schema).forEach((schemaKey) => { const keyPath = rootKey ? `${rootKey}.${schemaKey}` : schemaKey; const currentValue = newValue?.[schemaKey]; const prevSchemaValue = prevValue?.[schemaKey]; if (typeof currentValue === "object" && currentValue !== null && !Array.isArray(currentValue) && typeof prevSchemaValue === "object" && prevSchemaValue !== null && !Array.isArray(prevSchemaValue)) { const currentKeys = Object.keys(currentValue); const prevKeys = Object.keys(prevSchemaValue); const removedKeys = prevKeys.filter( (key) => !currentKeys.includes(key) ); for (const removedKey of removedKeys) { const removePath = `${keyPath}.${removedKey}.`; queryManager.removeAllWithPrefix(removePath, mode); } if (Object.keys(currentValue).length === 0) { queryManager.removeAllWithPrefix(`${keyPath}.`, mode); } } }); } Object.entries(queryUpdates).forEach(([key, value]) => { const defaultVal = defaultQueryUpdates[key]; if (value === "" || value === void 0 || value === null || Array.isArray(value) && value.length === 0 || typeof defaultVal !== "object" && value === defaultVal || typeof defaultVal === "object" && typeof value === "object" && !isDirty(value || {}, defaultVal || {})) { queryManager.enqueue(key, void 0, mode); } else { queryManager.enqueue(key, value, mode); } }); }, { deep: true } ); } function parseQuery(query) { const _defaultValue = Object.freeze( JSON.parse(JSON.stringify(toRaw(defaultValue))) ); if (params.schema instanceof z.ZodType) { if (params.debug) console.log("single schema", rootKey, { rootKey, query, _defaultValue }); const value = rootKey ? query[rootKey] : void 0; try { const parsed = value !== void 0 ? tryParse(value, params.schema) : _defaultValue; return parsed; } catch { return _defaultValue; } } const rebuiltQuery = rebuildObjectFromQuery(query, params.schema, rootKey); let parsedData; try { parsedData = deepPartialify(zodSchema).parse( rebuiltQuery ); } catch (err) { console.error("FAILED TO PARSE", { err, rebuiltQuery }); parsedData = nullable ? null : {}; } if (nullable && parsedData === null) { return null; } return deepMerge(_defaultValue, parsedData); } return dataRef; } export { useRouteQuery };