diff --git a/web/app/components/apps/hooks/use-apps-query-state.spec.ts b/web/app/components/apps/hooks/use-apps-query-state.spec.ts
deleted file mode 100644
index c0a188d7c3..0000000000
--- a/web/app/components/apps/hooks/use-apps-query-state.spec.ts
+++ /dev/null
@@ -1,363 +0,0 @@
-/**
- * Test suite for useAppsQueryState hook
- *
- * This hook manages app filtering state through URL search parameters, enabling:
- * - Bookmarkable filter states (users can share URLs with specific filters active)
- * - Browser history integration (back/forward buttons work with filters)
- * - Multiple filter types: tagIDs, keywords, isCreatedByMe
- *
- * The hook syncs local filter state with URL search parameters, making filter
- * navigation persistent and shareable across sessions.
- */
-import { act, renderHook } from '@testing-library/react'
-
-// Import the hook after mocks are set up
-import useAppsQueryState from './use-apps-query-state'
-
-// Mock Next.js navigation hooks
-const mockPush = vi.fn()
-const mockPathname = '/apps'
-let mockSearchParams = new URLSearchParams()
-
-vi.mock('next/navigation', () => ({
- usePathname: vi.fn(() => mockPathname),
- useRouter: vi.fn(() => ({
- push: mockPush,
- })),
- useSearchParams: vi.fn(() => mockSearchParams),
-}))
-
-describe('useAppsQueryState', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockSearchParams = new URLSearchParams()
- })
-
- describe('Basic functionality', () => {
- it('should return query object and setQuery function', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query).toBeDefined()
- expect(typeof result.current.setQuery).toBe('function')
- })
-
- it('should initialize with empty query when no search params exist', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.tagIDs).toBeUndefined()
- expect(result.current.query.keywords).toBeUndefined()
- expect(result.current.query.isCreatedByMe).toBe(false)
- })
- })
-
- describe('Parsing search params', () => {
- it('should parse tagIDs from URL', () => {
- mockSearchParams.set('tagIDs', 'tag1;tag2;tag3')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
- })
-
- it('should parse single tagID from URL', () => {
- mockSearchParams.set('tagIDs', 'single-tag')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.tagIDs).toEqual(['single-tag'])
- })
-
- it('should parse keywords from URL', () => {
- mockSearchParams.set('keywords', 'search term')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.keywords).toBe('search term')
- })
-
- it('should parse isCreatedByMe as true from URL', () => {
- mockSearchParams.set('isCreatedByMe', 'true')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.isCreatedByMe).toBe(true)
- })
-
- it('should parse isCreatedByMe as false for other values', () => {
- mockSearchParams.set('isCreatedByMe', 'false')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.isCreatedByMe).toBe(false)
- })
-
- it('should parse all params together', () => {
- mockSearchParams.set('tagIDs', 'tag1;tag2')
- mockSearchParams.set('keywords', 'test')
- mockSearchParams.set('isCreatedByMe', 'true')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
- expect(result.current.query.keywords).toBe('test')
- expect(result.current.query.isCreatedByMe).toBe(true)
- })
- })
-
- describe('Updating query state', () => {
- it('should update keywords via setQuery', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ keywords: 'new search' })
- })
-
- expect(result.current.query.keywords).toBe('new search')
- })
-
- it('should update tagIDs via setQuery', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
- })
-
- expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
- })
-
- it('should update isCreatedByMe via setQuery', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ isCreatedByMe: true })
- })
-
- expect(result.current.query.isCreatedByMe).toBe(true)
- })
-
- it('should support partial updates via callback', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ keywords: 'initial' })
- })
-
- act(() => {
- result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
- })
-
- expect(result.current.query.keywords).toBe('initial')
- expect(result.current.query.isCreatedByMe).toBe(true)
- })
- })
-
- describe('URL synchronization', () => {
- it('should sync keywords to URL', async () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ keywords: 'search' })
- })
-
- // Wait for useEffect to run
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 0))
- })
-
- expect(mockPush).toHaveBeenCalledWith(
- expect.stringContaining('keywords=search'),
- { scroll: false },
- )
- })
-
- it('should sync tagIDs to URL with semicolon separator', async () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
- })
-
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 0))
- })
-
- expect(mockPush).toHaveBeenCalledWith(
- expect.stringContaining('tagIDs=tag1%3Btag2'),
- { scroll: false },
- )
- })
-
- it('should sync isCreatedByMe to URL', async () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ isCreatedByMe: true })
- })
-
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 0))
- })
-
- expect(mockPush).toHaveBeenCalledWith(
- expect.stringContaining('isCreatedByMe=true'),
- { scroll: false },
- )
- })
-
- it('should remove keywords from URL when empty', async () => {
- mockSearchParams.set('keywords', 'existing')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ keywords: '' })
- })
-
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 0))
- })
-
- // Should be called without keywords param
- expect(mockPush).toHaveBeenCalled()
- })
-
- it('should remove tagIDs from URL when empty array', async () => {
- mockSearchParams.set('tagIDs', 'tag1;tag2')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ tagIDs: [] })
- })
-
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 0))
- })
-
- expect(mockPush).toHaveBeenCalled()
- })
-
- it('should remove isCreatedByMe from URL when false', async () => {
- mockSearchParams.set('isCreatedByMe', 'true')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ isCreatedByMe: false })
- })
-
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 0))
- })
-
- expect(mockPush).toHaveBeenCalled()
- })
- })
-
- describe('Edge cases', () => {
- it('should handle empty tagIDs string in URL', () => {
- // NOTE: This test documents current behavior where ''.split(';') returns ['']
- // This could potentially cause filtering issues as it's treated as a tag with empty name
- // rather than absence of tags. Consider updating parseParams if this is problematic.
- mockSearchParams.set('tagIDs', '')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.tagIDs).toEqual([''])
- })
-
- it('should handle empty keywords', () => {
- mockSearchParams.set('keywords', '')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.keywords).toBeUndefined()
- })
-
- it('should handle undefined tagIDs', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ tagIDs: undefined })
- })
-
- expect(result.current.query.tagIDs).toBeUndefined()
- })
-
- it('should handle special characters in keywords', () => {
- // Use URLSearchParams constructor to properly simulate URL decoding behavior
- // URLSearchParams.get() decodes URL-encoded characters
- mockSearchParams = new URLSearchParams('keywords=test%20with%20spaces')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- expect(result.current.query.keywords).toBe('test with spaces')
- })
- })
-
- describe('Memoization', () => {
- it('should return memoized object reference when query unchanged', () => {
- const { result, rerender } = renderHook(() => useAppsQueryState())
-
- const firstResult = result.current
- rerender()
- const secondResult = result.current
-
- expect(firstResult.query).toBe(secondResult.query)
- })
-
- it('should return new object reference when query changes', () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- const firstQuery = result.current.query
-
- act(() => {
- result.current.setQuery({ keywords: 'changed' })
- })
-
- expect(result.current.query).not.toBe(firstQuery)
- })
- })
-
- describe('Integration scenarios', () => {
- it('should handle sequential updates', async () => {
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({ keywords: 'first' })
- })
-
- act(() => {
- result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
- })
-
- act(() => {
- result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
- })
-
- expect(result.current.query.keywords).toBe('first')
- expect(result.current.query.tagIDs).toEqual(['tag1'])
- expect(result.current.query.isCreatedByMe).toBe(true)
- })
-
- it('should clear all filters', () => {
- mockSearchParams.set('tagIDs', 'tag1;tag2')
- mockSearchParams.set('keywords', 'search')
- mockSearchParams.set('isCreatedByMe', 'true')
-
- const { result } = renderHook(() => useAppsQueryState())
-
- act(() => {
- result.current.setQuery({
- tagIDs: undefined,
- keywords: undefined,
- isCreatedByMe: false,
- })
- })
-
- expect(result.current.query.tagIDs).toBeUndefined()
- expect(result.current.query.keywords).toBeUndefined()
- expect(result.current.query.isCreatedByMe).toBe(false)
- })
- })
-})
diff --git a/web/app/components/apps/hooks/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/use-apps-query-state.spec.tsx
new file mode 100644
index 0000000000..29f2e17556
--- /dev/null
+++ b/web/app/components/apps/hooks/use-apps-query-state.spec.tsx
@@ -0,0 +1,248 @@
+import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
+import type { ReactNode } from 'react'
+/**
+ * Test suite for useAppsQueryState hook
+ *
+ * This hook manages app filtering state through URL search parameters, enabling:
+ * - Bookmarkable filter states (users can share URLs with specific filters active)
+ * - Browser history integration (back/forward buttons work with filters)
+ * - Multiple filter types: tagIDs, keywords, isCreatedByMe
+ */
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import useAppsQueryState from './use-apps-query-state'
+
+const renderWithAdapter = (searchParams = '') => {
+ const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+ const { result } = renderHook(() => useAppsQueryState(), { wrapper })
+ return { result, onUrlUpdate }
+}
+
+// Groups scenarios for useAppsQueryState behavior.
+describe('useAppsQueryState', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Covers the hook return shape and default values.
+ describe('Initialization', () => {
+ it('should expose query and setQuery when initialized', () => {
+ const { result } = renderWithAdapter()
+
+ expect(result.current.query).toBeDefined()
+ expect(typeof result.current.setQuery).toBe('function')
+ })
+
+ it('should default to empty filters when search params are missing', () => {
+ const { result } = renderWithAdapter()
+
+ expect(result.current.query.tagIDs).toBeUndefined()
+ expect(result.current.query.keywords).toBeUndefined()
+ expect(result.current.query.isCreatedByMe).toBe(false)
+ })
+ })
+
+ // Covers parsing of existing URL search params.
+ describe('Parsing search params', () => {
+ it('should parse tagIDs when URL includes tagIDs', () => {
+ const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3')
+
+ expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
+ })
+
+ it('should parse keywords when URL includes keywords', () => {
+ const { result } = renderWithAdapter('?keywords=search+term')
+
+ expect(result.current.query.keywords).toBe('search term')
+ })
+
+ it('should parse isCreatedByMe when URL includes true value', () => {
+ const { result } = renderWithAdapter('?isCreatedByMe=true')
+
+ expect(result.current.query.isCreatedByMe).toBe(true)
+ })
+
+ it('should parse all params when URL includes multiple filters', () => {
+ const { result } = renderWithAdapter(
+ '?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true',
+ )
+
+ expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
+ expect(result.current.query.keywords).toBe('test')
+ expect(result.current.query.isCreatedByMe).toBe(true)
+ })
+ })
+
+ // Covers updates driven by setQuery.
+ describe('Updating query state', () => {
+ it('should update keywords when setQuery receives keywords', () => {
+ const { result } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ keywords: 'new search' })
+ })
+
+ expect(result.current.query.keywords).toBe('new search')
+ })
+
+ it('should update tagIDs when setQuery receives tagIDs', () => {
+ const { result } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
+ })
+
+ expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
+ })
+
+ it('should update isCreatedByMe when setQuery receives true', () => {
+ const { result } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ isCreatedByMe: true })
+ })
+
+ expect(result.current.query.isCreatedByMe).toBe(true)
+ })
+
+ it('should support partial updates when setQuery uses callback', () => {
+ const { result } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ keywords: 'initial' })
+ })
+
+ act(() => {
+ result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
+ })
+
+ expect(result.current.query.keywords).toBe('initial')
+ expect(result.current.query.isCreatedByMe).toBe(true)
+ })
+ })
+
+ // Covers URL updates triggered by query changes.
+ describe('URL synchronization', () => {
+ it('should sync keywords to URL when keywords change', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ keywords: 'search' })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.get('keywords')).toBe('search')
+ expect(update.options.history).toBe('push')
+ })
+
+ it('should sync tagIDs to URL when tagIDs change', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
+ })
+
+ it('should sync isCreatedByMe to URL when enabled', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ isCreatedByMe: true })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.get('isCreatedByMe')).toBe('true')
+ })
+
+ it('should remove keywords from URL when keywords are cleared', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter('?keywords=existing')
+
+ act(() => {
+ result.current.setQuery({ keywords: '' })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.has('keywords')).toBe(false)
+ })
+
+ it('should remove tagIDs from URL when tagIDs are empty', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
+
+ act(() => {
+ result.current.setQuery({ tagIDs: [] })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.has('tagIDs')).toBe(false)
+ })
+
+ it('should remove isCreatedByMe from URL when disabled', async () => {
+ const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
+
+ act(() => {
+ result.current.setQuery({ isCreatedByMe: false })
+ })
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
+ expect(update.searchParams.has('isCreatedByMe')).toBe(false)
+ })
+ })
+
+ // Covers decoding and empty values.
+ describe('Edge cases', () => {
+ it('should treat empty tagIDs as empty list when URL param is empty', () => {
+ const { result } = renderWithAdapter('?tagIDs=')
+
+ expect(result.current.query.tagIDs).toEqual([])
+ })
+
+ it('should treat empty keywords as undefined when URL param is empty', () => {
+ const { result } = renderWithAdapter('?keywords=')
+
+ expect(result.current.query.keywords).toBeUndefined()
+ })
+
+ it('should decode keywords with spaces when URL contains encoded spaces', () => {
+ const { result } = renderWithAdapter('?keywords=test+with+spaces')
+
+ expect(result.current.query.keywords).toBe('test with spaces')
+ })
+ })
+
+ // Covers multi-step updates that mimic real usage.
+ describe('Integration scenarios', () => {
+ it('should keep accumulated filters when updates are sequential', () => {
+ const { result } = renderWithAdapter()
+
+ act(() => {
+ result.current.setQuery({ keywords: 'first' })
+ })
+
+ act(() => {
+ result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
+ })
+
+ act(() => {
+ result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
+ })
+
+ expect(result.current.query.keywords).toBe('first')
+ expect(result.current.query.tagIDs).toEqual(['tag1'])
+ expect(result.current.query.isCreatedByMe).toBe(true)
+ })
+ })
+})
diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts
index f142b9e97e..ecf7707e8a 100644
--- a/web/app/components/apps/hooks/use-apps-query-state.ts
+++ b/web/app/components/apps/hooks/use-apps-query-state.ts
@@ -1,6 +1,5 @@
-import type { ReadonlyURLSearchParams } from 'next/navigation'
-import { usePathname, useRouter, useSearchParams } from 'next/navigation'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
+import { useCallback, useMemo } from 'react'
type AppsQuery = {
tagIDs?: string[]
@@ -8,54 +7,51 @@ type AppsQuery = {
isCreatedByMe?: boolean
}
-// Parse the query parameters from the URL search string.
-function parseParams(params: ReadonlyURLSearchParams): AppsQuery {
- const tagIDs = params.get('tagIDs')?.split(';')
- const keywords = params.get('keywords') || undefined
- const isCreatedByMe = params.get('isCreatedByMe') === 'true'
- return { tagIDs, keywords, isCreatedByMe }
-}
-
-// Update the URL search string with the given query parameters.
-function updateSearchParams(query: AppsQuery, current: URLSearchParams) {
- const { tagIDs, keywords, isCreatedByMe } = query || {}
-
- if (tagIDs && tagIDs.length > 0)
- current.set('tagIDs', tagIDs.join(';'))
- else
- current.delete('tagIDs')
-
- if (keywords)
- current.set('keywords', keywords)
- else
- current.delete('keywords')
-
- if (isCreatedByMe)
- current.set('isCreatedByMe', 'true')
- else
- current.delete('isCreatedByMe')
-}
+const normalizeKeywords = (value: string | null) => value || undefined
function useAppsQueryState() {
- const searchParams = useSearchParams()
- const [query, setQuery] = useState(() => parseParams(searchParams))
+ const [urlQuery, setUrlQuery] = useQueryStates(
+ {
+ tagIDs: parseAsArrayOf(parseAsString, ';'),
+ keywords: parseAsString,
+ isCreatedByMe: parseAsBoolean,
+ },
+ {
+ history: 'push',
+ },
+ )
- const router = useRouter()
- const pathname = usePathname()
- const syncSearchParams = useCallback((params: URLSearchParams) => {
- const search = params.toString()
- const query = search ? `?${search}` : ''
- router.push(`${pathname}${query}`, { scroll: false })
- }, [router, pathname])
+ const query = useMemo(() => ({
+ tagIDs: urlQuery.tagIDs ?? undefined,
+ keywords: normalizeKeywords(urlQuery.keywords),
+ isCreatedByMe: urlQuery.isCreatedByMe ?? false,
+ }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
- // Update the URL search string whenever the query changes.
- useEffect(() => {
- const params = new URLSearchParams(searchParams)
- updateSearchParams(query, params)
- syncSearchParams(params)
- }, [query, searchParams, syncSearchParams])
+ const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
+ const buildPatch = (patch: AppsQuery) => {
+ const result: Partial = {}
+ if ('tagIDs' in patch)
+ result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
+ if ('keywords' in patch)
+ result.keywords = patch.keywords ? patch.keywords : null
+ if ('isCreatedByMe' in patch)
+ result.isCreatedByMe = patch.isCreatedByMe ? true : null
+ return result
+ }
- return useMemo(() => ({ query, setQuery }), [query])
+ if (typeof next === 'function') {
+ setUrlQuery(prev => buildPatch(next({
+ tagIDs: prev.tagIDs ?? undefined,
+ keywords: normalizeKeywords(prev.keywords),
+ isCreatedByMe: prev.isCreatedByMe ?? false,
+ })))
+ return
+ }
+
+ setUrlQuery(buildPatch(next))
+ }, [setUrlQuery])
+
+ return useMemo(() => ({ query, setQuery }), [query, setQuery])
}
export default useAppsQueryState