From 2c468f9aa1a8cd2a1d5cae58fc161326b080f631 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:13:57 -0700 Subject: [PATCH] Stop Jira list loops after saved-filter load failures (#29011) --- ...ge-jira-saved-filter-load-failure.test.tsx | 237 ++++++++++++++++++ .../use-task-page-jira-saved-filters.ts | 15 +- 2 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/task-page-jira-saved-filter-load-failure.test.tsx diff --git a/src/renderer/src/components/task-page-jira-saved-filter-load-failure.test.tsx b/src/renderer/src/components/task-page-jira-saved-filter-load-failure.test.tsx new file mode 100644 index 00000000000..a759ddea4f9 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-saved-filter-load-failure.test.tsx @@ -0,0 +1,237 @@ +// @vitest-environment happy-dom +import { useCallback, useRef, useState } from 'react' +import { create } from 'zustand' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { JiraIssue } from '../../../shared/jira-types' +import type { JiraSavedFilterState } from '../../../shared/jira-saved-filters' +import type { AppState } from '@/store/types' +import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects' +import { createJiraSlice } from '@/store/slices/jira' +import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters' +import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects' +import type { JiraStatusCatalogItem } from './task-page-jira-status-catalog' + +const runtime = vi.hoisted(() => ({ + capability: vi.fn(), + rpc: vi.fn(), + list: vi.fn(), + search: vi.fn(), + statusOrder: vi.fn() +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + assertRuntimeEnvironmentCapability: runtime.capability, + callRuntimeRpc: runtime.rpc +})) +vi.mock('@/runtime/runtime-jira-client', () => ({ + jiraListIssuesForTarget: runtime.list, + jiraSearchIssues: runtime.search, + jiraGetProjectStatusOrder: runtime.statusOrder +})) + +const issue: JiraIssue = { + id: 'load-failure-1', + key: 'APP-1', + siteId: 'site-1', + title: 'Jira still loads when saved filters are unavailable', + url: 'https://jira.invalid/browse/APP-1', + project: { id: 'project-1', key: 'APP', name: 'App' }, + issueType: { id: 'task', name: 'Task' }, + status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-10-14', + updatedAt: '2026-10-14' +} +const issues = [issue] +const selectedStatuses: string[] = [] +const noop = () => {} +const suspendedRead = new Promise(() => {}) + +function createStore() { + return create()( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Jira collection actions read only the settings, connection status, and caches initialized here. + (...args) => ({ settings: null, ...createJiraSlice(...args) }) as AppState + ) +} + +function useTaskPageFixture(store: ReturnType, remote: boolean) { + const renders = useRef(0) + renders.current += 1 + const [jiraIssues, setJiraIssues] = useState([]) + const [jiraLoading, setJiraLoading] = useState(false) + const [jiraSearchInput, setJiraSearchInput] = useState('') + const [appliedJiraSearch, setAppliedJiraSearch] = useState('') + const [jiraRefreshNonce, setJiraRefreshNonce] = useState(0) + const [, setJiraStatusCatalog] = useState<{ + scopeKey: string + items: JiraStatusCatalogItem[] + } | null>(null) + const setQuery = useCallback((query: string) => { + setJiraSearchInput(query) + setAppliedJiraSearch(query) + }, []) + const owner = { + taskSource: 'jira', + taskResumeApplied: true, + accountBackedTaskSourceHostId: remote ? 'runtime:unavailable-filter-owner' : 'local', + jiraTaskSourceContext: null + } as const + const savedFilters = useTaskPageJiraSavedFilters(owner, setQuery) + const model = { + ...owner, + ...savedFilters, + jiraConnected: true, + selectedJiraSiteId: 'site-1', + jiraHasWorkspace: false, + linkedJiraIssueRefs: [], + linkedJiraIssueRefsSignature: '[]', + jiraTaskSourceScopeKey: 'load-failure', + jiraSearchPersistReadyRef: useRef(true), + selectedJiraIssueKey: null, + selectedJiraIssueFallback: null, + setSelectedJiraIssueKey: noop, + setSelectedJiraIssueFallback: noop, + setTaskResumeState: noop, + setJiraError: noop, + setJiraErrorDetailsOpen: noop, + setJiraJqlRejection: noop, + setJiraStatusCatalog, + setJiraProjectStatusOrder: noop, + setJiraIssues, + setJiraLoading, + jiraSearchInput, + appliedJiraSearch, + setAppliedJiraSearch, + jiraRefreshNonce, + activeJiraPreset: 'assigned', + jiraCurrentSprint: false, + jiraProjectFilter: null, + selectedJiraStatuses: selectedStatuses, + jiraQuickFiltersLoading: false, + displayedJiraIssues: jiraIssues, + listJiraIssues: store.getState().listJiraIssues, + searchJiraIssues: store.getState().searchJiraIssues + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All fields read by the real Jira effects are supplied; unrelated provider pipeline stages are omitted. + useTaskPageJiraListEffects(model as unknown as TaskPageLinearCollectionEffectsModel) + return { + ...savedFilters, + jiraIssues, + jiraLoading, + appliedJiraSearch, + renders: renders.current, + setJiraSearchInput, + refresh: () => setJiraRefreshNonce((value) => value + 1) + } +} + +beforeEach(() => { + vi.resetAllMocks() + runtime.capability.mockResolvedValue(undefined) + runtime.rpc.mockRejectedValue(new Error('Jira host disconnected')) + runtime.list.mockResolvedValue(issues) + runtime.search.mockResolvedValue(issues) + runtime.statusOrder.mockResolvedValue({ statusIdsByColumn: [], columns: [] }) + vi.stubGlobal('api', { ui: {} }) +}) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +it.each([ + { failure: 'plain missing local bridge', remote: false, cache: 'fresh' }, + { failure: 'plain missing local bridge', remote: false, cache: 'stale' }, + { failure: 'rejected remote read', remote: true, cache: 'fresh' }, + { failure: 'rejected remote read', remote: true, cache: 'stale' } +])( + 'settles $cache Jira results after $failure and keeps query/refresh working', + async (testCase) => { + const store = createStore() + store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) + await store.getState().listJiraIssues('assigned', 50) + if (testCase.cache === 'stale') { + store.setState((state) => ({ + jiraSearchCache: Object.fromEntries( + Object.entries(state.jiraSearchCache).map(([key, entry]) => [ + key, + { ...entry, fetchedAt: Date.now() - 120_000 } + ]) + ) + })) + } + runtime.list.mockClear() + const originalList = store.getState().listJiraIssues + const originalSearch = store.getState().searchJiraIssues + const list = vi.fn((...args) => + // Stop a regression from hanging the runner while preserving its excess-call evidence. + list.mock.calls.length > 8 ? suspendedRead : originalList(...args) + ) + const search = vi.fn((...args) => + search.mock.calls.length > 8 ? suspendedRead : originalSearch(...args) + ) + store.setState({ listJiraIssues: list, searchJiraIssues: search }) + + const { result, rerender } = renderHook(() => useTaskPageFixture(store, testCase.remote)) + await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false)) + await act(async () => {}) + expect(result.current.jiraSavedFiltersError).toMatch(/Update the Jira host|disconnected/) + expect(result.current.jiraSavedFiltersReady).toBe(false) + expect(result.current.jiraLoading).toBe(false) + expect(result.current.jiraIssues).toEqual(issues) + expect(list).toHaveBeenCalledTimes(1) + expect(runtime.list).toHaveBeenCalledTimes(testCase.cache === 'stale' ? 1 : 0) + expect(result.current.renders).toBeLessThan(12) + await act(async () => rerender()) + expect(list).toHaveBeenCalledTimes(1) + + act(() => result.current.setJiraSearchInput('login bug')) + await waitFor(() => expect(result.current.appliedJiraSearch).toBe('login bug')) + await waitFor(() => expect(result.current.jiraLoading).toBe(false)) + expect(search).toHaveBeenCalledExactlyOnceWith('text ~ "login bug*"', 50, { + sourceContext: null, + force: false + }) + act(() => result.current.refresh()) + await waitFor(() => expect(search).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(result.current.jiraLoading).toBe(false)) + expect(search).toHaveBeenLastCalledWith('text ~ "login bug*"', 50, { + sourceContext: null, + force: true + }) + expect(runtime.search).toHaveBeenCalledTimes(2) + expect(list).toHaveBeenCalledTimes(1) + } +) + +it('recovers from a failed owner read and restores the active exact JQL on retry', async () => { + const store = createStore() + store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) + const savedState: JiraSavedFilterState = { + filters: [{ id: 'restored', name: 'Restored', jql: 'filter = 12345' }], + activeFilterId: 'restored' + } + const originalList = store.getState().listJiraIssues + const list = vi.fn((...args) => + list.mock.calls.length > 8 ? suspendedRead : originalList(...args) + ) + const search = vi.fn(store.getState().searchJiraIssues) + store.setState({ listJiraIssues: list, searchJiraIssues: search }) + const { result } = renderHook(() => useTaskPageFixture(store, true)) + await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false)) + await act(async () => {}) + expect(list).toHaveBeenCalledTimes(1) + + runtime.rpc.mockResolvedValueOnce(savedState) + act(() => result.current.retryJiraSavedFilters()) + await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true)) + await waitFor(() => expect(result.current.jiraLoading).toBe(false)) + expect(result.current.jiraSavedFiltersError).toBeNull() + expect(result.current.appliedJiraSearch).toBe('filter = 12345') + expect(search).toHaveBeenCalledExactlyOnceWith('filter = 12345', 50, { + sourceContext: null, + force: false + }) + expect(list).toHaveBeenCalledTimes(1) + expect(runtime.rpc).toHaveBeenCalledTimes(2) +}) diff --git a/src/renderer/src/components/use-task-page-jira-saved-filters.ts b/src/renderer/src/components/use-task-page-jira-saved-filters.ts index fb22e50e663..46dc63a86d7 100644 --- a/src/renderer/src/components/use-task-page-jira-saved-filters.ts +++ b/src/renderer/src/components/use-task-page-jira-saved-filters.ts @@ -5,14 +5,16 @@ import { mutateJiraSavedFilters, readJiraSavedFilterState } from '@/runtime/runtime-jira-saved-filter-state' -import { - normalizeJiraSavedFilterState, - type JiraSavedFilterMutation, - type JiraSavedFilter +import type { + JiraSavedFilterMutation, + JiraSavedFilter, + JiraSavedFilterState } from '../../../shared/jira-saved-filters' import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state' import { translate } from '@/i18n/i18n' +const EMPTY_SAVED_FILTER_STATE: JiraSavedFilterState = { filters: [], activeFilterId: null } + type JiraSavedFilterModel = Pick< TaskPageLinearViewStateModel, 'jiraTaskSourceContext' | 'accountBackedTaskSourceHostId' | 'taskResumeApplied' | 'taskSource' @@ -29,7 +31,7 @@ export function useTaskPageJiraSavedFilters( const target = useMemo(() => ipcOrRuntimeTargetForHostId(ownerKey), [ownerKey]) const [snapshot, setSnapshot] = useState({ ownerKey: '', - state: normalizeJiraSavedFilterState(undefined) + state: EMPTY_SAVED_FILTER_STATE }) const [loading, setLoading] = useState(true) const [pendingLifetime, setPendingLifetime] = useState(null) @@ -52,8 +54,7 @@ export function useTaskPageJiraSavedFilters( } } }, [owner]) - const state = - snapshot.ownerKey === ownerKey ? snapshot.state : normalizeJiraSavedFilterState(undefined) + const state = snapshot.ownerKey === ownerKey ? snapshot.state : EMPTY_SAVED_FILTER_STATE const ready = target !== null && snapshot.ownerKey === ownerKey useEffect(() => { const lifetime = lifetimeRef.current