Compare commits

...

3 Commits

Author SHA1 Message Date
Matt Miller
e1e7ab4447 test: resolve router.replace promise in deep-link loader mocks
strip() in usePreservedQueryDeepLink now chains .catch() on
router.replace(), so mocks returning undefined threw. Return a
resolved promise from the router.replace mocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:13:51 -07:00
Matt Miller
c4e81eaf93 fix: log and guard strip URL replace in usePreservedQueryDeepLink
Restore the .catch() logging that useCreateWorkspaceUrlLoader had before the
refactor so a rejected router.replace() during URL cleanup surfaces instead of
becoming an unhandled rejection. Guard the replace behind the key's presence to
skip redundant no-op navigations.
2026-07-02 13:50:16 -07:00
Matt Miller
e5d68349f5 refactor: extract usePreservedQueryDeepLink for URL deep-link loaders
DRY the duplicated preserved-query hydrate/merge/replace head and
strip/clear tail out of useInviteUrlLoader, useSharedWorkflowUrlLoader,
and useCreateWorkspaceUrlLoader into a shared composable. Each caller
keeps its own gate, validation, and action explicit; no auth logic
moves into the helper. Net behavior is unchanged.
2026-07-02 13:27:01 -07:00
7 changed files with 182 additions and 101 deletions

View File

@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePreservedQueryDeepLink } from './usePreservedQueryDeepLink'
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))
describe('usePreservedQueryDeepLink', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('hydrateAndRead', () => {
it('merges a preserved value into the URL and returns it', async () => {
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
invite: 'preserved-token'
})
const { hydrateAndRead } = usePreservedQueryDeepLink('invite', 'invite')
const value = await hydrateAndRead()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'invite'
)
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { invite: 'preserved-token' }
})
expect(value).toBe('preserved-token')
})
it('returns the current route value without replacing when nothing is preserved', async () => {
mockRouteQuery.value = { invite: 'from-url' }
const { hydrateAndRead } = usePreservedQueryDeepLink('invite', 'invite')
const value = await hydrateAndRead()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'invite'
)
expect(mockRouterReplace).not.toHaveBeenCalled()
expect(value).toBe('from-url')
})
})
describe('strip', () => {
it('removes the key from the URL and clears the preserved namespace', () => {
mockRouteQuery.value = { invite: 'from-url', other: 'keep' }
const { strip } = usePreservedQueryDeepLink('invite', 'invite')
strip()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'keep' }
})
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('clears the preserved namespace without replacing when the key is absent', () => {
mockRouteQuery.value = { other: 'keep' }
const { strip } = usePreservedQueryDeepLink('invite', 'invite')
strip()
expect(mockRouterReplace).not.toHaveBeenCalled()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('logs a warning when cleaning the URL param fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const replaceError = new Error('navigation guard rejected')
mockRouterReplace.mockRejectedValueOnce(replaceError)
mockRouteQuery.value = { invite: 'from-url' }
const { strip } = usePreservedQueryDeepLink('invite', 'invite')
strip()
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('invite'),
replaceError
)
)
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
})
})

View File

@@ -0,0 +1,47 @@
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
/**
* Shared head/tail for URL deep-link loaders that survive a login redirect via
* the preserved-query (sessionStorage) system. Extracts ONLY the boilerplate:
* - hydrateAndRead(): restore the preserved param, reflect it in the URL, and
* return the value of `key` from the (possibly merged) query.
* - strip(): remove `key` from the URL and clear the preserved namespace.
* Each caller keeps its OWN gate/validation and action explicit — this helper
* intentionally contains no auth logic.
*/
export function usePreservedQueryDeepLink(namespace: string, key: string) {
const route = useRoute()
const router = useRouter()
const hydrateAndRead = async (): Promise<LocationQuery[string]> => {
hydratePreservedQuery(namespace)
const merged = mergePreservedQueryIntoQuery(namespace, route.query)
if (merged) {
await router.replace({ query: merged })
}
return (merged ?? route.query)[key] as LocationQuery[string]
}
const strip = () => {
if (key in route.query) {
const newQuery = { ...route.query }
delete newQuery[key]
router.replace({ query: newQuery }).catch((error) => {
console.warn(
`[usePreservedQueryDeepLink] Failed to clean URL param "${key}":`,
error
)
})
}
clearPreservedQuery(namespace)
}
return { hydrateAndRead, strip }
}

View File

@@ -195,6 +195,7 @@ function createDeferred() {
describe('useSharedWorkflowUrlLoader', () => {
beforeEach(() => {
vi.resetAllMocks()
mockRouterReplace.mockResolvedValue(undefined)
mockQueryParams = {}
mockIsLoggedIn.value = false
mockDialogStack.length = 0

View File

@@ -1,18 +1,14 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useTelemetry } from '@/platform/telemetry'
import { usePreservedQueryDeepLink } from '@/platform/navigation/composables/usePreservedQueryDeepLink'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { isValidShareId } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
@@ -37,8 +33,6 @@ type OpeningAction = Exclude<DialogResult['action'], 'cancel'>
const OPEN_SHARED_WORKFLOW_DIALOG_KEY = 'open-shared-workflow'
export function useSharedWorkflowUrlLoader() {
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { t } = useI18n()
const workflowShareService = useWorkflowShareService()
@@ -48,31 +42,9 @@ export function useSharedWorkflowUrlLoader() {
const { isLoggedIn } = useCurrentUser()
const { mode, isAppMode } = useAppMode()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
const deepLink = usePreservedQueryDeepLink(SHARE_NAMESPACE, 'share')
async function ensureShareQueryFromIntent() {
hydratePreservedQuery(SHARE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
SHARE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
function cleanupUrlParams() {
const newQuery = { ...route.query }
delete newQuery.share
void router.replace({ query: newQuery })
}
function clearShareIntent() {
cleanupUrlParams()
clearPreservedQuery(SHARE_NAMESPACE)
}
const clearShareIntent = () => deepLink.strip()
function showOpenSharedWorkflowDialog(
shareId: string
@@ -113,8 +85,7 @@ export function useSharedWorkflowUrlLoader() {
}
async function loadSharedWorkflowFromUrl(): Promise<SharedWorkflowUrlLoadStatus> {
const query = await ensureShareQueryFromIntent()
const shareParam = query.share
const shareParam = await deepLink.hydrateAndRead()
if (shareParam == null) {
return 'not-present'

View File

@@ -1,10 +1,4 @@
import { useRoute, useRouter } from 'vue-router'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { usePreservedQueryDeepLink } from '@/platform/navigation/composables/usePreservedQueryDeepLink'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useDialogService } from '@/services/dialogService'
@@ -21,9 +15,8 @@ const NAMESPACE = PRESERVED_QUERY_NAMESPACES.CREATE_WORKSPACE
* as the invite URL loader.
*/
export function useCreateWorkspaceUrlLoader() {
const route = useRoute()
const router = useRouter()
const dialogService = useDialogService()
const deepLink = usePreservedQueryDeepLink(NAMESPACE, 'create_workspace')
/**
* Opens the team workspaces dialog if `?create_workspace=1` is present.
@@ -35,25 +28,10 @@ export function useCreateWorkspaceUrlLoader() {
* 4. Clean up URL and preserved query
*/
async function loadCreateWorkspaceFromUrl() {
hydratePreservedQuery(NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(NAMESPACE, route.query)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
const query = mergedQuery ?? route.query
const param = query.create_workspace
const param = await deepLink.hydrateAndRead()
if (!param || typeof param !== 'string') return
const newQuery = { ...route.query }
delete newQuery.create_workspace
router.replace({ query: newQuery }).catch((error) => {
console.warn(
'[useCreateWorkspaceUrlLoader] Failed to clean URL params:',
error
)
})
clearPreservedQuery(NAMESPACE)
deepLink.strip()
try {
await dialogService.showTeamWorkspacesDialog()

View File

@@ -28,7 +28,7 @@ vi.mock(
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn())
const mockRouterReplace = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('vue-router', () => ({
useRoute: () => ({

View File

@@ -1,12 +1,7 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { usePreservedQueryDeepLink } from '@/platform/navigation/composables/usePreservedQueryDeepLink'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
@@ -22,39 +17,11 @@ import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
* as the template URL loader.
*/
export function useInviteUrlLoader() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
/**
* Hydrates preserved query from sessionStorage and merges into route.
* This restores the invite token after login redirects.
*/
const ensureInviteQueryFromIntent = async () => {
hydratePreservedQuery(INVITE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
INVITE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
/**
* Removes invite parameter from URL using Vue Router
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.invite
void router.replace({ query: newQuery })
}
const deepLink = usePreservedQueryDeepLink(INVITE_NAMESPACE, 'invite')
/**
* Loads and accepts workspace invite from URL query parameters if present.
@@ -68,9 +35,7 @@ export function useInviteUrlLoader() {
* 5. Clean up URL and preserved query
*/
const loadInviteFromUrl = async () => {
// Restore preserved query from sessionStorage (handles login redirect case)
const query = await ensureInviteQueryFromIntent()
const inviteParam = query.invite
const inviteParam = await deepLink.hydrateAndRead()
if (!inviteParam || typeof inviteParam !== 'string') {
return
}
@@ -100,8 +65,7 @@ export function useInviteUrlLoader() {
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
cleanupUrlParams()
clearPreservedQuery(INVITE_NAMESPACE)
deepLink.strip()
}
}