UNPKG

vue-qs

Version:

Type‑safe, reactive URL query params for Vue

870 lines (858 loc) 27.4 kB
var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/composables/use-query-ref.ts import { getCurrentInstance, onBeforeUnmount, ref, watch } from "vue"; // src/adapter-context.ts import { inject, provide } from "vue"; // src/core/injection-keys.ts var QUERY_ADAPTER_INJECTION_KEY = Symbol.for("vue-qs:query-adapter"); // src/adapter-context.ts function provideQueryAdapter(queryAdapter) { try { provide(QUERY_ADAPTER_INJECTION_KEY, queryAdapter); } catch (error) { console.warn("Failed to provide query adapter:", error); } } function useQueryAdapter() { try { return inject(QUERY_ADAPTER_INJECTION_KEY, void 0, false); } catch (error) { console.warn("Failed to inject query adapter:", error); return void 0; } } function createVueQsPlugin(options) { const { queryAdapter } = options; return { install(app) { try { app.provide(QUERY_ADAPTER_INJECTION_KEY, queryAdapter); } catch (error) { console.error("Failed to install vue-qs plugin:", error); } } }; } // src/adapters/history-adapter.ts import { reactive } from "vue"; // src/utils/core-helpers.ts function isBrowserEnvironment() { try { return typeof window !== "undefined" && typeof document !== "undefined"; } catch { return false; } } function createRuntimeEnvironment() { const isBrowser = isBrowserEnvironment(); return { isBrowser, windowObject: isBrowser ? window : null }; } function parseSearchString(searchString) { try { const urlParams = new URLSearchParams( searchString.startsWith("?") ? searchString : `?${searchString}` ); const result = {}; urlParams.forEach((value, key) => { result[key] = value; }); return result; } catch (error) { console.warn("Failed to parse search string:", error); return {}; } } function buildSearchString(queryObject) { try { const urlParams = new URLSearchParams(); Object.entries(queryObject).forEach(([key, value]) => { if (value !== void 0) { urlParams.set(key, value); } }); const searchString = urlParams.toString(); return searchString ? `?${searchString}` : ""; } catch (error) { console.warn("Failed to build search string:", error); return ""; } } function areValuesEqual(valueA, valueB, customEquals) { try { return customEquals ? customEquals(valueA, valueB) : Object.is(valueA, valueB); } catch (error) { console.warn("Error comparing values:", error); return false; } } function mergeObjects(baseObject, updateObject) { try { return { ...baseObject, ...updateObject }; } catch (error) { console.warn("Error merging objects:", error); return baseObject; } } function removeUndefinedValues(sourceObject) { try { const cleanedObject = {}; Object.entries(sourceObject).forEach(([key, value]) => { if (value !== void 0) { cleanedObject[key] = value; } }); return cleanedObject; } catch (error) { console.warn("Error removing undefined values:", error); return sourceObject; } } // src/adapters/history-adapter.ts var isHistoryPatched = false; function patchHistoryAPI(windowObject) { if (isHistoryPatched) { return; } try { isHistoryPatched = true; const { history } = windowObject; let shouldSuppressEvent = false; history.__vueQsSuppress = (operation) => { shouldSuppressEvent = true; try { operation(); } finally { shouldSuppressEvent = false; } }; const dispatchHistoryChangeEvent = () => { try { windowObject.dispatchEvent(new Event("vue-qs:history-change")); } catch (error) { console.warn("Failed to dispatch history change event:", error); } }; const wrapHistoryMethod = (methodName) => { const originalMethod = history[methodName]; history[methodName] = function(...args) { try { const result = originalMethod.apply(this, args); if (!shouldSuppressEvent) { dispatchHistoryChangeEvent(); } return result; } catch (error) { console.warn(`Error in patched ${methodName}:`, error); return originalMethod.apply(this, args); } }; }; wrapHistoryMethod("pushState"); wrapHistoryMethod("replaceState"); } catch (error) { console.warn("Failed to patch history API:", error); isHistoryPatched = false; } } function createHistoryAdapter(options = {}) { const runtimeEnvironment = createRuntimeEnvironment(); const { suppressHistoryEvents = false } = options; const serverCache = reactive({ queryParams: {} }); const queryAdapter = { getCurrentQuery() { try { if (!runtimeEnvironment.isBrowser || !runtimeEnvironment.windowObject) { return { ...serverCache.queryParams }; } const searchString = runtimeEnvironment.windowObject.location.search; return parseSearchString(searchString); } catch (error) { console.warn("Error getting current query:", error); return {}; } }, updateQuery(queryUpdates, updateOptions) { try { if (!runtimeEnvironment.isBrowser || !runtimeEnvironment.windowObject) { serverCache.queryParams = mergeObjects(serverCache.queryParams, queryUpdates); return; } const windowObject = runtimeEnvironment.windowObject; const currentUrl = new URL(windowObject.location.href); const currentQuery = parseSearchString(currentUrl.search); const mergedQuery = mergeObjects(currentQuery, queryUpdates); const newSearchString = buildSearchString(mergedQuery); if (currentUrl.search === newSearchString) { return; } currentUrl.search = newSearchString; const newPath = `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`; const historyStrategy = updateOptions?.historyStrategy ?? "replace"; const historyMethod = historyStrategy === "push" ? "pushState" : "replaceState"; if (!suppressHistoryEvents) { const historyWithSuppression = windowObject.history; if (historyWithSuppression.__vueQsSuppress) { historyWithSuppression.__vueQsSuppress(() => { windowObject.history[historyMethod]({}, "", newPath); }); } else { windowObject.history[historyMethod]({}, "", newPath); } } else { windowObject.history[historyMethod]({}, "", newPath); } if (windowObject.location.search !== newSearchString) { try { windowObject.location.href = newPath; } catch (error) { console.warn("Failed to update location directly:", error); } } } catch (error) { console.warn("Error updating query:", error); } }, onQueryChange(callback) { try { if (!runtimeEnvironment.isBrowser || !runtimeEnvironment.windowObject) { return () => { }; } const windowObject = runtimeEnvironment.windowObject; if (!suppressHistoryEvents) { patchHistoryAPI(windowObject); } const handleQueryChange = () => { try { callback(); } catch (error) { console.warn("Error in query change callback:", error); } }; windowObject.addEventListener("popstate", handleQueryChange); if (!suppressHistoryEvents) { windowObject.addEventListener("vue-qs:history-change", handleQueryChange); } return () => { try { windowObject.removeEventListener("popstate", handleQueryChange); if (!suppressHistoryEvents) { windowObject.removeEventListener("vue-qs:history-change", handleQueryChange); } } catch (error) { console.warn("Error unsubscribing from query changes:", error); } }; } catch (error) { console.warn("Error setting up query change listener:", error); return () => { }; } } }; return { queryAdapter, runtimeEnvironment }; } // src/serializers.ts var serializers_exports = {}; __export(serializers_exports, { booleanCodec: () => booleanCodec, createArrayCodec: () => createArrayCodec, createEnumCodec: () => createEnumCodec, createJsonCodec: () => createJsonCodec, dateISOCodec: () => dateISOCodec, numberCodec: () => numberCodec, stringCodec: () => stringCodec }); var stringCodec = { parse: (rawValue) => { try { return rawValue ?? ""; } catch (error) { console.warn("Error parsing string value:", error); return ""; } }, serialize: (stringValue) => { try { return String(stringValue); } catch (error) { console.warn("Error serializing string value:", error); return null; } } }; var numberCodec = { parse: (rawValue) => { try { if (rawValue === null || rawValue === "") { return NaN; } const numericValue = Number(rawValue); return numericValue; } catch (error) { console.warn("Error parsing number value:", error); return NaN; } }, serialize: (numericValue) => { try { return Number.isFinite(numericValue) ? String(numericValue) : null; } catch (error) { console.warn("Error serializing number value:", error); return null; } } }; var booleanCodec = { parse: (rawValue) => { try { return rawValue === "true" || rawValue === "1"; } catch (error) { console.warn("Error parsing boolean value:", error); return false; } }, serialize: (booleanValue) => { try { return booleanValue ? "true" : "false"; } catch (error) { console.warn("Error serializing boolean value:", error); return "false"; } } }; var dateISOCodec = { parse: (rawValue) => { try { return rawValue !== null && rawValue !== "" ? new Date(rawValue) : /* @__PURE__ */ new Date(NaN); } catch (error) { console.warn("Error parsing date value:", error); return /* @__PURE__ */ new Date(NaN); } }, serialize: (dateValue) => { try { if (dateValue instanceof Date && !isNaN(dateValue.getTime())) { return dateValue.toISOString(); } return null; } catch (error) { console.warn("Error serializing date value:", error); return null; } } }; function createJsonCodec() { return { parse: (rawValue) => { try { if (rawValue === null || rawValue === "") { return null; } return JSON.parse(rawValue); } catch (error) { console.warn("Error parsing JSON value:", error); return null; } }, serialize: (objectValue) => { try { if (objectValue === null || objectValue === void 0) { return null; } return JSON.stringify(objectValue); } catch (error) { console.warn("Error serializing JSON value:", error); return null; } } }; } function createArrayCodec(elementCodec, delimiter = ",") { return { parse: (rawValue) => { try { if (rawValue === null || rawValue === "") { return []; } const arrayElements = rawValue.split(delimiter); return arrayElements.map((element) => elementCodec.parse(element)); } catch (error) { console.warn("Error parsing array value:", error); return []; } }, serialize: (arrayValue) => { try { if (!Array.isArray(arrayValue) || arrayValue.length === 0) { return null; } const serializedElements = arrayValue.map((element) => elementCodec.serialize(element)).filter((serialized) => serialized !== null); return serializedElements.length > 0 ? serializedElements.join(delimiter) : null; } catch (error) { console.warn("Error serializing array value:", error); return null; } } }; } function createEnumCodec(allowedValues) { const defaultValue = allowedValues[0]; return { parse: (rawValue) => { try { if (allowedValues.includes(rawValue)) { return rawValue; } return defaultValue; } catch (error) { console.warn("Error parsing enum value:", error); return defaultValue; } }, serialize: (enumValue) => { try { return allowedValues.includes(enumValue) ? enumValue : defaultValue; } catch (error) { console.warn("Error serializing enum value:", error); return defaultValue; } } }; } // src/composables/use-query-ref.ts var sharedHistoryAdapterInstance; function getSharedHistoryAdapter() { if (!sharedHistoryAdapterInstance) { const { queryAdapter } = createHistoryAdapter(); sharedHistoryAdapterInstance = queryAdapter; } return sharedHistoryAdapterInstance; } function selectQueryAdapter(providedAdapter) { if (providedAdapter !== void 0) { return providedAdapter; } const componentInstance = getCurrentInstance(); const injectedAdapter = componentInstance !== null ? useQueryAdapter() : void 0; return injectedAdapter ?? getSharedHistoryAdapter(); } function getCodecFunctions(parseFunction, serializeFunction, codec) { return { parseValue: parseFunction ?? codec?.parse ?? stringCodec.parse, serializeValue: serializeFunction ?? codec?.serialize ?? stringCodec.serialize }; } function useQueryRef(parameterName, options = {}) { const { defaultValue, codec, parseFunction, serializeFunction, isEqual: customEquals, shouldOmitDefault = true, historyStrategy = "replace", queryAdapter: providedAdapter, enableTwoWaySync = false } = options; const selectedAdapter = selectQueryAdapter(providedAdapter); const { parseValue, serializeValue } = getCodecFunctions(parseFunction, serializeFunction, codec); function getInitialValue() { try { const currentQuery = selectedAdapter.getCurrentQuery(); const rawValue = currentQuery[parameterName] ?? null; if (typeof rawValue === "string" && rawValue.length > 0) { return parseValue(rawValue); } return defaultValue; } catch (error) { console.warn(`Error getting initial value for parameter "${parameterName}":`, error); return defaultValue; } } const initialValue = getInitialValue(); const internalRef = ref(initialValue); const queryRef = internalRef; function isDefaultValue(value) { if (defaultValue === void 0) { return false; } return areValuesEqual(value, defaultValue, customEquals); } function updateURL(value) { try { const serializedValue = serializeValue(value); const shouldOmit = shouldOmitDefault && isDefaultValue(value); const queryUpdate = { [parameterName]: shouldOmit ? void 0 : serializedValue ?? void 0 }; selectedAdapter.updateQuery(queryUpdate, { historyStrategy }); } catch (error) { console.warn(`Error updating URL for parameter "${parameterName}":`, error); } } if (defaultValue !== void 0 && !shouldOmitDefault) { const currentQuery = selectedAdapter.getCurrentQuery(); if (!(parameterName in currentQuery)) { updateURL(defaultValue); } } let isSyncingFromURL = false; const stopWatcher = watch( queryRef, (newValue) => { if (isSyncingFromURL) { return; } updateURL(newValue); }, { flush: "sync" } // Sync immediately to avoid batching delays ); queryRef.syncToUrl = () => { updateURL(queryRef.value); }; let unsubscribeFromURLChanges; if (enableTwoWaySync) { let syncFromURL2 = function() { try { const currentQuery = selectedAdapter.getCurrentQuery(); const rawValue = currentQuery[parameterName] ?? null; const parsedValue = typeof rawValue === "string" && rawValue.length > 0 ? parseValue(rawValue) : defaultValue; isSyncingFromURL = true; try { internalRef.value = parsedValue; } finally { queueMicrotask(() => { isSyncingFromURL = false; }); } } catch (error) { console.warn(`Error syncing from URL for parameter "${parameterName}":`, error); } }; var syncFromURL = syncFromURL2; if (selectedAdapter.onQueryChange) { unsubscribeFromURLChanges = selectedAdapter.onQueryChange(syncFromURL2); } else if (typeof window !== "undefined") { const handlePopState = () => syncFromURL2(); window.addEventListener("popstate", handlePopState); unsubscribeFromURLChanges = () => { window.removeEventListener("popstate", handlePopState); }; } } const componentInstance = getCurrentInstance(); if (componentInstance !== null) { onBeforeUnmount(() => { try { stopWatcher(); unsubscribeFromURLChanges?.(); } catch (error) { console.warn("Error during useQueryRef cleanup:", error); } }); } return queryRef; } // src/composables/use-query-reactive.ts import { getCurrentInstance as getCurrentInstance2, onBeforeUnmount as onBeforeUnmount2, reactive as reactive2, watch as watch2 } from "vue"; var sharedHistoryAdapterInstance2; function getSharedHistoryAdapter2() { if (!sharedHistoryAdapterInstance2) { const { queryAdapter } = createHistoryAdapter(); sharedHistoryAdapterInstance2 = queryAdapter; } return sharedHistoryAdapterInstance2; } function useQueryReactive(parameterSchema, options = {}) { const { historyStrategy = "replace", queryAdapter: providedAdapter, enableTwoWaySync = false } = options; const componentInstance = getCurrentInstance2(); const injectedAdapter = componentInstance ? useQueryAdapter() : void 0; const selectedAdapter = providedAdapter ?? injectedAdapter ?? getSharedHistoryAdapter2(); const currentURLQuery = selectedAdapter.getCurrentQuery(); const reactiveState = reactive2({}); Object.keys(parameterSchema).forEach((paramKey) => { const paramConfig = parameterSchema[paramKey]; try { const parseValue = paramConfig.parseFunction ?? paramConfig.codec?.parse ?? stringCodec.parse; const rawValue = currentURLQuery[paramKey] ?? null; const initialValue = typeof rawValue === "string" && rawValue.length > 0 ? parseValue(rawValue) : paramConfig.defaultValue; reactiveState[paramKey] = initialValue; } catch (error) { console.warn(`Error initializing parameter "${paramKey}":`, error); reactiveState[paramKey] = paramConfig.defaultValue; } }); function serializeStateSubset(stateSubset) { const serializedQuery = {}; Object.keys(stateSubset).forEach((paramKey) => { if (!(paramKey in parameterSchema)) { return; } try { const paramValue = stateSubset[paramKey]; const paramConfig = parameterSchema[paramKey]; const serializeValue = paramConfig.serializeFunction ?? paramConfig.codec?.serialize ?? stringCodec.serialize; const isDefaultValue = paramConfig.defaultValue !== void 0 && areValuesEqual(paramValue, paramConfig.defaultValue, paramConfig.isEqual); const shouldOmit = (paramConfig.shouldOmitDefault ?? true) && isDefaultValue; if (shouldOmit) { serializedQuery[paramKey] = void 0; } else { const serialized = serializeValue(paramValue); serializedQuery[paramKey] = serialized ?? void 0; } } catch (error) { console.warn(`Error serializing parameter "${paramKey}":`, error); serializedQuery[paramKey] = void 0; } }); return serializedQuery; } function syncAllToURL() { try { const fullState = {}; Object.keys(parameterSchema).forEach((key) => { fullState[key] = reactiveState[key]; }); const serializedQuery = serializeStateSubset(fullState); selectedAdapter.updateQuery(serializedQuery, { historyStrategy }); } catch (error) { console.warn("Error syncing all parameters to URL:", error); } } let isSyncingFromURL = false; let isBatchUpdating = false; const stopWatcher = watch2( () => { const stateSnapshot = {}; Object.keys(parameterSchema).forEach((key) => { stateSnapshot[key] = reactiveState[key]; }); return stateSnapshot; }, (changedState) => { if (isSyncingFromURL || isBatchUpdating) { return; } try { const serializedQuery = serializeStateSubset(changedState); selectedAdapter.updateQuery(serializedQuery, { historyStrategy }); } catch (error) { console.warn("Error syncing state changes to URL:", error); } }, { deep: true, flush: "sync" // Immediate updates to avoid batching delays } ); function updateBatch(updates, batchOptions) { try { isBatchUpdating = true; Object.keys(updates).forEach((key) => { if (key in parameterSchema) { reactiveState[key] = updates[key]; } }); const serializedQuery = serializeStateSubset(updates); const finalHistoryStrategy = batchOptions?.historyStrategy ?? historyStrategy; selectedAdapter.updateQuery(serializedQuery, { historyStrategy: finalHistoryStrategy }); } catch (error) { console.warn("Error during batch update:", error); } finally { queueMicrotask(() => { isBatchUpdating = false; }); } } let unsubscribeFromURLChanges; if (enableTwoWaySync) { let syncFromURL2 = function() { try { const currentQuery = selectedAdapter.getCurrentQuery(); isSyncingFromURL = true; try { Object.keys(parameterSchema).forEach((paramKey) => { const paramConfig = parameterSchema[paramKey]; const parseValue = paramConfig.parseFunction ?? paramConfig.codec?.parse ?? stringCodec.parse; const rawValue = currentQuery[paramKey] ?? null; const parsedValue = typeof rawValue === "string" && rawValue.length > 0 ? parseValue(rawValue) : paramConfig.defaultValue; reactiveState[paramKey] = parsedValue; }); } finally { queueMicrotask(() => { isSyncingFromURL = false; }); } } catch (error) { console.warn("Error syncing from URL:", error); } }; var syncFromURL = syncFromURL2; if (selectedAdapter.onQueryChange) { unsubscribeFromURLChanges = selectedAdapter.onQueryChange(syncFromURL2); } else if (typeof window !== "undefined") { const handlePopState = () => syncFromURL2(); window.addEventListener("popstate", handlePopState); unsubscribeFromURLChanges = () => { window.removeEventListener("popstate", handlePopState); }; } } if (componentInstance) { onBeforeUnmount2(() => { try { stopWatcher(); unsubscribeFromURLChanges?.(); } catch (error) { console.warn("Error during useQueryReactive cleanup:", error); } }); } return { queryState: reactiveState, updateBatch, syncAllToUrl: syncAllToURL }; } // src/adapters/vue-router-adapter.ts function createVueRouterAdapter(vueRouter, options = {}) { const { warnOnArrayParams = true } = options; function normalizeRouterQuery(routerQuery) { const normalizedQuery = {}; try { Object.entries(routerQuery).forEach(([key, value]) => { if (Array.isArray(value)) { const firstValue = value[0]; normalizedQuery[key] = typeof firstValue === "string" && firstValue.length > 0 ? String(firstValue) : void 0; if (warnOnArrayParams && value.length > 1) { console.warn( `Query parameter "${key}" has multiple values. Only the first value will be used.`, { key, values: value } ); } } else if (typeof value === "string" && value.length > 0) { normalizedQuery[key] = String(value); } else { normalizedQuery[key] = void 0; } }); return normalizedQuery; } catch (error) { console.warn("Error normalizing router query:", error); return {}; } } function areQueriesEqual(queryA, queryB) { try { const normalizedA = normalizeRouterQuery(queryA); const normalizedB = normalizeRouterQuery(queryB); const keysA = Object.keys(normalizedA); const keysB = Object.keys(normalizedB); if (keysA.length !== keysB.length) { return false; } return keysA.every((key) => normalizedA[key] === normalizedB[key]); } catch (error) { console.warn("Error comparing queries:", error); return false; } } const queryAdapter = { getCurrentQuery() { try { const currentRoute = vueRouter.currentRoute.value; return normalizeRouterQuery(currentRoute.query); } catch (error) { console.warn("Error getting current query from Vue Router:", error); return {}; } }, updateQuery(queryUpdates, updateOptions) { try { const currentRoute = vueRouter.currentRoute.value; const currentQuery = { ...currentRoute.query }; Object.entries(queryUpdates).forEach(([key, value]) => { if (value === void 0) { delete currentQuery[key]; } else { currentQuery[key] = value; } }); if (areQueriesEqual(currentRoute.query, currentQuery)) { return; } const historyStrategy = updateOptions?.historyStrategy ?? "replace"; const navigationMethod = historyStrategy === "push" ? vueRouter.push : vueRouter.replace; navigationMethod.call(vueRouter, { query: currentQuery }).catch((error) => { if (error?.name !== "NavigationDuplicated") { console.warn("Vue Router navigation error:", error); } }); } catch (error) { console.warn("Error updating query in Vue Router:", error); } }, onQueryChange(callback) { try { const unsubscribeHook = vueRouter.afterEach(() => { try { callback(); } catch (error) { console.warn("Error in Vue Router query change callback:", error); } }); return unsubscribeHook; } catch (error) { console.warn("Error setting up Vue Router query change listener:", error); return () => { }; } } }; return queryAdapter; } export { areValuesEqual, booleanCodec, buildSearchString, createArrayCodec, createEnumCodec, createHistoryAdapter, createJsonCodec, createRuntimeEnvironment, createVueQsPlugin, createVueRouterAdapter, dateISOCodec, isBrowserEnvironment, mergeObjects, numberCodec, parseSearchString, provideQueryAdapter, removeUndefinedValues, serializers_exports as serializers, stringCodec, useQueryAdapter, useQueryReactive, useQueryRef };