@chronicstone/vue-route-query
Version:
Type-safe URL query parameter synchronization for Vue 3 with Zod validation
432 lines (426 loc) • 14.9 kB
JavaScript
;
const vue = require('vue');
const vueRouter = require('vue-router');
const zod = require('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 = vue.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 zod.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 zod.z.ZodNumber) {
const num = Number(value);
if (!Number.isNaN(num)) return num;
}
if (isZodArray(schema)) {
return [];
}
}
return value;
}
function isZodArray(schema) {
return schema instanceof zod.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 zod.z.ZodObject) {
const newShape = {};
for (const key in schema.shape) {
const fieldSchema = schema.shape[key];
newShape[key] = zod.z.ZodOptional.create(_deepPartialify(fieldSchema));
}
return new zod.z.ZodObject({
...schema._def,
shape: () => newShape
});
} else if (schema instanceof zod.z.ZodArray) {
return new zod.z.ZodArray({
...schema._def,
type: _deepPartialify(schema.element)
});
} else if (schema instanceof zod.z.ZodOptional) {
return zod.z.ZodOptional.create(_deepPartialify(schema.unwrap()));
} else if (schema instanceof zod.z.ZodNullable) {
return zod.z.ZodNullable.create(_deepPartialify(schema.unwrap()));
} else if (schema instanceof zod.z.ZodTuple) {
return zod.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 = vueRouter.useRoute();
const router = vueRouter.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 zod.z.ZodType && !rootKey) {
throw new Error("key is required for single value schemas");
}
const instanceKeys = params.schema instanceof zod.z.ZodType || params.key ? [params.key] : Object.keys(params.schema);
queryManager.init(router, route.query);
queryManager.registerInstance(instanceId, instanceKeys);
vue.onUnmounted(() => queryManager.unregisterInstance(instanceId));
const baseSchema = params.schema instanceof zod.z.ZodType ? params.schema : zod.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 = vue.ref(parsedInitialQuery);
if (params.enabled ?? true) {
vue.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 }
);
vue.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(vue.toRaw(defaultValue)))
);
if (params.schema instanceof zod.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;
}
exports.useRouteQuery = useRouteQuery;