From c5d43b8a24927201c5d624a4fddab257ea830560 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:56:06 -0700 Subject: [PATCH] Avoid Linear read re-fetches when workspace scope is unchanged (#17529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Avoid Linear read re-fetches when workspace scope is unchanged Derive a stable scope signature that captures only the connected state and workspace identity, ignoring volatile metadata like displayName. Use this in dependency tracking so Linear searches don't re-run on status updates that don't affect which issues can be queried. * Expand workspace scope to detect credential and org changes Cache invalidation key now includes credentialRevision and organizationUrlKey for both workspace and viewer, ensuring Linear reads re-fetch when credentials rotate or organizations are renamed — fields that affect what read operations return. * Include activeWorkspaceId in workspace scope signature URL lookup falls back to the active workspace even when all workspaces are selected, so activeWorkspaceId must be part of the scope signature to ensure reads are keyed correctly. --- .../new-workspace/SmartWorkspaceNameField.tsx | 46 +++++-- src/renderer/src/store/slices/linear.test.ts | 34 +++++ src/renderer/src/store/slices/linear.ts | 12 +- src/shared/linear/workspace-types.test.ts | 121 ++++++++++++++++++ src/shared/linear/workspace-types.ts | 34 +++++ 5 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 src/shared/linear/workspace-types.test.ts diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 47040268575..6166ecdd42e 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -79,6 +79,7 @@ import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { JiraIssue, JiraSite } from '../../../../shared/jira-types' import type { LinearIssue } from '../../../../shared/linear/issue-types' +import { linearWorkspaceScopeSignature } from '../../../../shared/linear/workspace-types' import type { BaseRefSearchResult } from '../../../../shared/repo-types' import { resolveSmartWorkspaceCommandValue } from './smart-workspace-command-value' import { isComposerFieldToFieldFocus } from './smart-workspace-source-popover-focus' @@ -425,6 +426,14 @@ export default function SmartWorkspaceNameField({ const showJiraSiteContext = mode === 'jira' && jiraConnectionStatus?.selectedSiteId === 'all' const jiraStatusId = React.useId() const linearStatusId = React.useId() + // Read the latest metadata for URL resolution without making the search effect depend on object identity. + const linearStatusRef = useRef(linearStatus) + linearStatusRef.current = linearStatus + // Store action references can change with wiring; reads should only rerun for query/scope changes. + const linearReadMethodsRef = useRef({ fetchLinearIssue, listLinearIssues, searchLinearIssues }) + linearReadMethodsRef.current = { fetchLinearIssue, listLinearIssues, searchLinearIssues } + const linearConnected = linearStatus.connected === true + const linearScopeSignature = linearWorkspaceScopeSignature(linearStatus) useEffect(() => { onActiveSourceModeChange?.(mode) @@ -1018,7 +1027,7 @@ export default function SmartWorkspaceNameField({ }, [branchSearchRequest, selectedRepoOwnerSettings]) useEffect(() => { - if (disabled || !shouldQueryLinear || !linearStatus.connected) { + if (disabled || !shouldQueryLinear || !linearConnected) { setLinearIssues([]) setLinearLoading(false) setSettledLinearUrlQuery(null) @@ -1035,18 +1044,24 @@ export default function SmartWorkspaceNameField({ const request = linearUrlIntent ? lookupLinearIssueUrl({ intent: linearUrlIntent, - knownStatus: linearStatus, + knownStatus: linearStatusRef.current, sourceContext: linearSourceContext, - fetchLinearIssue + fetchLinearIssue: linearReadMethodsRef.current.fetchLinearIssue }).then((issue) => (issue ? [issue] : [])) : trimmed - ? searchLinearIssues(getSmartWorkspaceLinearSearchQuery(trimmed), RESULT_LIMIT, { - sourceContext: linearSourceContext - }) - : listLinearIssues( - { kind: 'list', filter: 'assigned', limit: RESULT_LIMIT }, - { sourceContext: linearSourceContext } - ).then((result) => result.items) + ? linearReadMethodsRef.current.searchLinearIssues( + getSmartWorkspaceLinearSearchQuery(trimmed), + RESULT_LIMIT, + { + sourceContext: linearSourceContext + } + ) + : linearReadMethodsRef.current + .listLinearIssues( + { kind: 'list', filter: 'assigned', limit: RESULT_LIMIT }, + { sourceContext: linearSourceContext } + ) + .then((result) => result.items) void request .then((issues) => { if (!stale) { @@ -1068,8 +1083,15 @@ export default function SmartWorkspaceNameField({ stale = true } // Why: list/search are stable store methods; depending on them would refetch on unrelated store writes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disabled, linearQuery, linearSourceContext, linearStatus, linearUrlIntent, shouldQueryLinear]) + }, [ + disabled, + linearConnected, + linearQuery, + linearScopeSignature, + linearSourceContext, + linearUrlIntent, + shouldQueryLinear + ]) useEffect(() => { if (!shouldQueryJira || !jiraSourceContext || !jiraSearchJql) { diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 8aaf54e720a..ac3cad34fcc 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -86,6 +86,40 @@ describe('createLinearSlice', () => { expect(store.getState().linearStatusChecked).toBe(true) }) + it('preserves status identity when a connection probe leaves its scope unchanged', async () => { + const store = createTestStore() + const currentStatus: LinearConnectionStatus = { + connected: true, + viewer: { + displayName: 'Test User', + email: 'test@example.com', + organizationName: 'Test Org' + }, + selectedWorkspaceId: 'workspace-1', + activeWorkspaceId: 'workspace-1', + workspaces: [ + { + id: 'workspace-1', + organizationId: 'workspace-1', + organizationName: 'Test Org', + displayName: 'Test User', + email: 'test@example.com' + } + ] + } + store.setState({ + linearStatus: currentStatus, + linearStatusChecked: true, + linearStatusContextKey: 'local#0' + }) + linearTestConnection.mockResolvedValueOnce({ ok: true, viewer: currentStatus.viewer }) + linearStatus.mockResolvedValueOnce({ ...currentStatus, viewer: { ...currentStatus.viewer } }) + + await store.getState().testLinearConnection() + + expect(store.getState().linearStatus).toBe(currentStatus) + }) + it('ignores stale forced connection checks when a newer forced check finishes first', async () => { const staleCheck = deferred() const freshCheck = deferred() diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 0f86363b6ef..605b2c8edd5 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -236,6 +236,7 @@ function linearWorkspaceSignature(workspace: LinearWorkspace): string { ].join('\u001f') } +/** Cache-invalidation key: broader than `linearWorkspaceScopeSignature`, hashes full viewer and workspace metadata. */ function linearStatusScopeSignature(status: LinearConnectionStatus): string { return JSON.stringify({ connected: status.connected, @@ -607,7 +608,7 @@ export const createLinearSlice: StateCreator = (s if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) { return inflightStatusRequest.promise } - if (get().linearStatusContextKey !== contextKey) { + if (get().linearStatusContextKey !== contextKey && get().linearStatusChecked) { set({ linearStatusChecked: false }) } @@ -732,12 +733,9 @@ export const createLinearSlice: StateCreator = (s linearStatusChecked: true, linearStatusContextKey: contextKey }) - } else { - set({ - linearStatus: status, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) + } else if (!get().linearStatusChecked || get().linearStatusContextKey !== contextKey) { + // Preserve the status reference when the probe did not change scope. + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) } } return result diff --git a/src/shared/linear/workspace-types.test.ts b/src/shared/linear/workspace-types.test.ts new file mode 100644 index 00000000000..6f6174d0870 --- /dev/null +++ b/src/shared/linear/workspace-types.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { linearWorkspaceScopeSignature, type LinearConnectionStatus } from './workspace-types' + +describe('linearWorkspaceScopeSignature', () => { + it('ignores status metadata and workspace ordering', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: { displayName: 'Ada', email: 'ada@example.com', organizationName: 'Alpha' }, + selectedWorkspaceId: 'workspace-1', + workspaces: [ + { + id: 'workspace-1', + organizationId: 'org-1', + organizationName: 'Alpha', + displayName: 'Ada', + email: 'ada@example.com' + }, + { + id: 'workspace-2', + organizationId: 'org-2', + organizationName: 'Beta', + displayName: 'Ada', + email: 'ada@example.com' + } + ] + } + const second: LinearConnectionStatus = { + ...first, + viewer: { ...first.viewer!, organizationName: 'Renamed' }, + workspaces: [ + { ...first.workspaces![1], organizationName: 'Renamed Beta' }, + { ...first.workspaces![0], organizationName: 'Renamed Alpha' } + ] + } + + expect(linearWorkspaceScopeSignature(second)).toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when the selected workspace changes', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: null, + selectedWorkspaceId: 'workspace-1' + } + const second = { ...first, selectedWorkspaceId: 'workspace-2' } + + expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when the active workspace changes while all workspaces are selected', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: null, + selectedWorkspaceId: 'all', + activeWorkspaceId: 'workspace-1' + } + const second = { ...first, activeWorkspaceId: 'workspace-2' } + + expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when a workspace credential revision changes', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: null, + workspaces: [ + { + id: 'workspace-1', + organizationId: 'org-1', + organizationName: 'Alpha', + displayName: 'Ada', + email: 'ada@example.com', + credentialRevision: 1 + } + ] + } + const second: LinearConnectionStatus = { + ...first, + workspaces: [{ ...first.workspaces![0], credentialRevision: 2 }] + } + + expect(linearWorkspaceScopeSignature(second)).not.toBe(linearWorkspaceScopeSignature(first)) + }) + + it('changes when a workspace or viewer organization url key changes', () => { + const first: LinearConnectionStatus = { + connected: true, + viewer: { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha', + organizationUrlKey: 'alpha' + }, + workspaces: [ + { + id: 'workspace-1', + organizationId: 'org-1', + organizationName: 'Alpha', + displayName: 'Ada', + email: 'ada@example.com', + organizationUrlKey: 'alpha' + } + ] + } + const renamedWorkspaceKey: LinearConnectionStatus = { + ...first, + workspaces: [{ ...first.workspaces![0], organizationUrlKey: 'alpha-renamed' }] + } + const renamedViewerKey: LinearConnectionStatus = { + ...first, + viewer: { ...first.viewer!, organizationUrlKey: 'alpha-renamed' } + } + + expect(linearWorkspaceScopeSignature(renamedWorkspaceKey)).not.toBe( + linearWorkspaceScopeSignature(first) + ) + expect(linearWorkspaceScopeSignature(renamedViewerKey)).not.toBe( + linearWorkspaceScopeSignature(first) + ) + }) +}) diff --git a/src/shared/linear/workspace-types.ts b/src/shared/linear/workspace-types.ts index 802842bb72f..508fd980a1e 100644 --- a/src/shared/linear/workspace-types.ts +++ b/src/shared/linear/workspace-types.ts @@ -41,6 +41,40 @@ export type LinearConnectionStatus = { credentialError?: string } +/** + * Stable dependency key for Linear reads: only the fields that change what a read returns. + * Not interchangeable with the store's `linearStatusScopeSignature`, which hashes full + * viewer and workspace metadata for cache invalidation and so churns on read-irrelevant edits. + */ +export function linearWorkspaceScopeSignature( + status: Pick< + LinearConnectionStatus, + | 'connected' + | 'credentialError' + | 'activeWorkspaceId' + | 'selectedWorkspaceId' + | 'workspaces' + | 'viewer' + > +): string { + return JSON.stringify({ + connected: status.connected === true, + credentialError: status.credentialError ?? null, + workspaceId: status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null, + // Why: under 'all', URL lookup still falls back to the active workspace, so it must key reads too. + activeWorkspaceId: status.activeWorkspaceId ?? null, + // Why: URL resolution routes by organizationUrlKey, and credentialRevision changes what a read returns. + viewerOrganizationUrlKey: status.viewer?.organizationUrlKey ?? null, + workspaces: (status.workspaces ?? []) + .map((workspace) => + [workspace.id, workspace.organizationUrlKey ?? '', workspace.credentialRevision ?? 0].join( + '\u001f' + ) + ) + .sort() + }) +} + export type LinearWorkflowState = { id: string name: string