From eeb2b9d39cb767ef9dfa129358a7535813794c96 Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 26 Dec 2025 10:16:54 +0800 Subject: [PATCH] refactor: unify query param state with nuqs --- .../plugins/marketplace/context.tsx | 54 +++-- .../marketplace/plugin-type-switch.tsx | 2 + .../components/plugins/marketplace/utils.ts | 22 +- .../components/plugins/plugin-page/index.tsx | 53 ++--- .../header/version-history-button.tsx | 2 +- web/app/layout.tsx | 25 ++- web/context/modal-context.tsx | 112 ++++------ web/hooks/use-query-params.ts | 210 ++++++++++++++++++ web/package.json | 1 + web/pnpm-lock.yaml | 36 +++ web/utils/index.ts | 3 + 11 files changed, 363 insertions(+), 157 deletions(-) create mode 100644 web/hooks/use-query-params.ts diff --git a/web/app/components/plugins/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx index 4053c4a556..484fddcdc5 100644 --- a/web/app/components/plugins/marketplace/context.tsx +++ b/web/app/components/plugins/marketplace/context.tsx @@ -22,6 +22,7 @@ import { createContext, useContextSelector, } from 'use-context-selector' +import { useMarketplaceFilters } from '@/hooks/use-query-params' import { useInstalledPluginList } from '@/service/use-plugins' import { getValidCategoryKeys, @@ -37,7 +38,6 @@ import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' import { getMarketplaceListCondition, getMarketplaceListFilterType, - updateSearchParams, } from './utils' export type MarketplaceContextValue = { @@ -107,16 +107,22 @@ export const MarketplaceContextProvider = ({ scrollContainerId, showSearchParams, }: MarketplaceContextProviderProps) => { + // Use nuqs hook for URL-based filter state + const [urlFilters, setUrlFilters] = useMarketplaceFilters() + const { data, isSuccess } = useInstalledPluginList(!shouldExclude) const exclude = useMemo(() => { if (shouldExclude) return data?.plugins.map(plugin => plugin.plugin_id) }, [data?.plugins, shouldExclude]) - const queryFromSearchParams = searchParams?.q || '' - const tagsFromSearchParams = searchParams?.tags ? getValidTagKeys(searchParams.tags.split(',')) : [] + + // Initialize from URL params (legacy support) or use nuqs state + const queryFromSearchParams = searchParams?.q || urlFilters.q + const tagsFromSearchParams = getValidTagKeys(urlFilters.tags) const hasValidTags = !!tagsFromSearchParams.length - const hasValidCategory = getValidCategoryKeys(searchParams?.category) - const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all + const hasValidCategory = getValidCategoryKeys(urlFilters.category) + const categoryFromSearchParams = hasValidCategory || urlFilters.category || PLUGIN_TYPE_SEARCH_MAP.all + const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams) const searchPluginTextRef = useRef(searchPluginText) const [filterPluginTags, setFilterPluginTags] = useState(tagsFromSearchParams) @@ -158,10 +164,6 @@ export const MarketplaceContextProvider = ({ sortOrder: sortRef.current.sortOrder, type: getMarketplaceListFilterType(activePluginTypeRef.current), }) - const url = new URL(window.location.href) - if (searchParams?.language) - url.searchParams.set('language', searchParams?.language) - history.replaceState({}, '', url) } else { if (shouldExclude && isSuccess) { @@ -183,28 +185,32 @@ export const MarketplaceContextProvider = ({ resetPlugins() }, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins]) - const debouncedUpdateSearchParams = useMemo(() => debounce(() => { - updateSearchParams({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - }) - }, 500), []) - - const handleUpdateSearchParams = useCallback((debounced?: boolean) => { + const applyUrlFilters = useCallback(() => { if (!showSearchParams) return + const nextFilters = { + q: searchPluginTextRef.current, + category: activePluginTypeRef.current, + tags: filterPluginTagsRef.current, + } + const categoryChanged = urlFilters.category !== nextFilters.category + setUrlFilters(nextFilters, { + history: categoryChanged ? 'push' : 'replace', + }) + }, [setUrlFilters, showSearchParams, urlFilters.category]) + + const debouncedUpdateSearchParams = useMemo(() => debounce(() => { + applyUrlFilters() + }, 500), [applyUrlFilters]) + + const handleUpdateSearchParams = useCallback((debounced?: boolean) => { if (debounced) { debouncedUpdateSearchParams() } else { - updateSearchParams({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - }) + applyUrlFilters() } - }, [debouncedUpdateSearchParams, showSearchParams]) + }, [applyUrlFilters, debouncedUpdateSearchParams]) const handleQueryPlugins = useCallback((debounced?: boolean) => { handleUpdateSearchParams(debounced) diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 963a7de9a9..bb4a419335 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -84,12 +84,14 @@ const PluginTypeSwitch = ({ const handlePopState = useCallback(() => { if (!showSearchParams) return + // nuqs handles popstate automatically const url = new URL(window.location.href) const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all handleActivePluginTypeChange(category) }, [showSearchParams, handleActivePluginTypeChange]) useEffect(() => { + // nuqs manages popstate internally, but we keep this for URL sync window.addEventListener('popstate', handlePopState) return () => { window.removeEventListener('popstate', handlePopState) diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 82af4b65c8..e7dcbf3542 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -153,21 +153,11 @@ export const getMarketplaceListFilterType = (category: string) => { return 'plugin' } +// Deprecated: Use useMarketplaceFilters hook from hooks/use-query-params.ts instead +// This function is kept for backward compatibility but should not be used in new code +/** @deprecated Use the useMarketplaceFilters hook from hooks/use-query-params.ts instead */ export const updateSearchParams = (pluginsSearchParams: PluginsSearchParams) => { - const { query, category, tags } = pluginsSearchParams - const url = new URL(window.location.href) - const categoryChanged = url.searchParams.get('category') !== category - if (query) - url.searchParams.set('q', query) - else - url.searchParams.delete('q') - if (category) - url.searchParams.set('category', category) - else - url.searchParams.delete('category') - if (tags && tags.length) - url.searchParams.set('tags', tags.join(',')) - else - url.searchParams.delete('tags') - history[`${categoryChanged ? 'pushState' : 'replaceState'}`]({}, '', url) + console.warn('updateSearchParams is deprecated. Use the useMarketplaceFilters hook from hooks/use-query-params.ts instead.') + // This is now handled by the useMarketplaceFilters hook + // Keeping the function for any legacy code that hasn't been migrated yet } diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index afa85d7010..361f562352 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -9,10 +9,6 @@ import { import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/compat' import Link from 'next/link' -import { - useRouter, - useSearchParams, -} from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -25,6 +21,7 @@ import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@ import { useGlobalPublicStore } from '@/context/global-public-context' import I18n from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' +import { usePluginInstallation } from '@/hooks/use-query-params' import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' import { sleep } from '@/utils' import { cn } from '@/utils/classnames' @@ -42,9 +39,6 @@ import PluginTasks from './plugin-tasks' import useReferenceSetting from './use-reference-setting' import { useUploader } from './use-uploader' -const PACKAGE_IDS_KEY = 'package-ids' -const BUNDLE_INFO_KEY = 'bundle-info' - export type PluginPageProps = { plugins: React.ReactNode marketplace: React.ReactNode @@ -55,33 +49,13 @@ const PluginPage = ({ }: PluginPageProps) => { const { t } = useTranslation() const { locale } = useContext(I18n) - const searchParams = useSearchParams() - const { replace } = useRouter() useDocumentTitle(t('plugin.metadata.title')) - // just support install one package now - const packageId = useMemo(() => { - const idStrings = searchParams.get(PACKAGE_IDS_KEY) - try { - return idStrings ? JSON.parse(idStrings)[0] : '' - } - catch { - return '' - } - }, [searchParams]) + // Use nuqs hook for installation state + const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation() const [uniqueIdentifier, setUniqueIdentifier] = useState(null) - const [dependencies, setDependencies] = useState([]) - const bundleInfo = useMemo(() => { - const info = searchParams.get(BUNDLE_INFO_KEY) - try { - return info ? JSON.parse(info) : undefined - } - catch { - return undefined - } - }, [searchParams]) const [isShowInstallFromMarketplace, { setTrue: showInstallFromMarketplace, @@ -90,11 +64,9 @@ const PluginPage = ({ const hideInstallFromMarketplace = () => { doHideInstallFromMarketplace() - const url = new URL(window.location.href) - url.searchParams.delete(PACKAGE_IDS_KEY) - url.searchParams.delete(BUNDLE_INFO_KEY) - replace(url.toString()) + setInstallState(null) } + const [manifest, setManifest] = useState(null) useEffect(() => { @@ -114,12 +86,19 @@ const PluginPage = ({ return } if (bundleInfo) { - const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo) - setDependencies(data.version.dependencies) - showInstallFromMarketplace() + // bundleInfo is a JSON string from URL, needs parsing + try { + const parsedBundleInfo = typeof bundleInfo === 'string' ? JSON.parse(bundleInfo) : bundleInfo + const { data } = await fetchBundleInfoFromMarketPlace(parsedBundleInfo) + setDependencies(data.version.dependencies) + showInstallFromMarketplace() + } + catch (e) { + console.error('Failed to parse bundle info:', e) + } } })() - }, [packageId, bundleInfo]) + }, [packageId, bundleInfo, showInstallFromMarketplace]) const { referenceSetting, diff --git a/web/app/components/workflow/header/version-history-button.tsx b/web/app/components/workflow/header/version-history-button.tsx index ae3bd68b48..9ec9e6934e 100644 --- a/web/app/components/workflow/header/version-history-button.tsx +++ b/web/app/components/workflow/header/version-history-button.tsx @@ -61,7 +61,7 @@ const VersionHistoryButton: FC = ({ >