diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index 859fb8d1459..3ba14c571d6 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -110,7 +110,8 @@ const hash = (parts: string[] | string): string => const SCREEN_RPC_SCREEN_HOOKS = '0f66df2141117dfec2f8a0adb3f598312e6fda8e80833a365a645796f5ab48c3' const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' const SCREEN_RPC_STATEMENTS = 'dd8f33cb3cf96f5c39abac397cb77e35f59079291033a1866ead462b041ab979' -const MAIN_REBASED_DECLARATIONS = '920a1b66445d10e2a64fbdbe9d7138a4ebe21bbccde1b9ac9c89267cecc584b9' +// Saved Linear selections now accept unknown persisted values; reconciliation tests cover them. +const MAIN_REBASED_DECLARATIONS = 'ec77d34712c7c4ab19ac6a4d57878f32d22aba790d2420cc14c2c0f000d120e9' const SCREEN_RPC_SEMANTICS = 'e07a63387d57106483ee703ec6c19dea593e0eca5c651758f42bcb36254850b7' const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' const SCREEN_RPC_RENDER_TREE = '086742f95f1e87fb89d8c67ffd9f7a229799ae05115f9f4bcc1a925e56dcc8bb' diff --git a/mobile/src/tasks/mobile-tasks-reviewer-linear.test.ts b/mobile/src/tasks/mobile-tasks-reviewer-linear.test.ts new file mode 100644 index 00000000000..3fec8b222ae --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-reviewer-linear.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./mobile-tasks-dependencies', () => import('../theme/mobile-theme')) +import { reconcileTeamSelection } from './mobile-tasks-reviewer-linear' +import type { LinearTeam } from './mobile-tasks-provider-detail-types' + +function team(id: string): LinearTeam { + return { id, name: id, key: id.toUpperCase() } +} + +describe('reconcileTeamSelection', () => { + it('selects every team for sticky-all', () => { + expect([...reconcileTeamSelection([team('a'), team('b')], null)]).toEqual(['a', 'b']) + }) + + it('keeps saved teams that still exist', () => { + expect([...reconcileTeamSelection([team('a'), team('b')], ['b'])]).toEqual(['b']) + }) + + // Why: an older host projects its raw store value, which reached 1.4.207 + // desktops as a string (0a2b6e7f); the mobile list must not fail on it. + it('reads a saved value of the wrong shape as sticky-all instead of throwing', () => { + expect([...reconcileTeamSelection([team('a'), team('b')], 'a')]).toEqual(['a', 'b']) + expect([...reconcileTeamSelection([team('a'), team('b')], { 0: 'a' })]).toEqual(['a', 'b']) + }) +}) diff --git a/mobile/src/tasks/mobile-tasks-reviewer-linear.ts b/mobile/src/tasks/mobile-tasks-reviewer-linear.ts index ed5e51f8bf7..32b872550aa 100644 --- a/mobile/src/tasks/mobile-tasks-reviewer-linear.ts +++ b/mobile/src/tasks/mobile-tasks-reviewer-linear.ts @@ -196,11 +196,10 @@ export function linearIssueSecondaryParts( return parts } -export function reconcileTeamSelection( - teams: LinearTeam[], - saved: string[] | null | undefined -): Set { - if (!saved) { +// Why `unknown`: a host predating the desktop fix for 0a2b6e7f projects its +// raw store value, which can be a string; that must read as sticky-all. +export function reconcileTeamSelection(teams: LinearTeam[], saved: unknown): Set { + if (!Array.isArray(saved)) { return new Set(teams.map((team) => team.id)) } const available = new Set(teams.map((team) => team.id)) diff --git a/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts b/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts new file mode 100644 index 00000000000..c26eecb3d95 --- /dev/null +++ b/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Why: the projection is what paired clients render page.tasks from, and a +// non-array in the host store crashed that page in 1.4.207 (0a2b6e7f). +function projectionOf(settings: Partial) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: get() reads nothing but store.getSettings(); every other RuntimeStore member is unreachable from that path. + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +function hostSettings(overrides: Record): Partial { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the override deliberately carries the malformed on-disk shape the projection must tolerate. + return { + ...createGlobalSettingsFixture({ workspaceDir: '/w' }), + ...overrides + } as Partial +} + +describe('RuntimeClientSettingsController Linear team selection projection', () => { + it('publishes a saved team array unchanged', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: ['t1', 't2'] })) + .defaultLinearTeamSelection + ).toEqual(['t1', 't2']) + }) + + it('publishes sticky-all as null', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: null })).defaultLinearTeamSelection + ).toBeNull() + }) + + it('publishes only string team IDs from a malformed array', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: ['t1', 7, null, {}, 't2'] })) + .defaultLinearTeamSelection + ).toEqual(['t1', 't2']) + }) + + it('publishes null when the host store holds a string or an object', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: 't1' })).defaultLinearTeamSelection + ).toBeNull() + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: { 0: 't1' } })) + .defaultLinearTeamSelection + ).toBeNull() + }) +}) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 262021da0e5..76c7e772e5c 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -107,7 +107,10 @@ export class RuntimeClientSettingsController { defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS], defaultRepoSelection: settings.defaultRepoSelection ?? null, - defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, + // Persisted settings can violate the paired client's string-array contract. + defaultLinearTeamSelection: Array.isArray(settings.defaultLinearTeamSelection) + ? settings.defaultLinearTeamSelection.filter((id): id is string => typeof id === 'string') + : null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, // The three that decide whether a new agent tab -- and so an orchestration worker -- is a diff --git a/src/renderer/src/components/task-page-linear-team-selection.test.ts b/src/renderer/src/components/task-page-linear-team-selection.test.ts index 6eb1878fbbd..b75b6404080 100644 --- a/src/renderer/src/components/task-page-linear-team-selection.test.ts +++ b/src/renderer/src/components/task-page-linear-team-selection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { LinearTeam } from '../../../shared/linear/workspace-types' -import { reconcileLinearTeamSelection } from './task-page-linear-team-selection' +import { + reconcileLinearTeamSelection, + storedLinearTeamSelection +} from './task-page-linear-team-selection' function team(id: string): LinearTeam { return { @@ -34,4 +37,39 @@ describe('reconcileLinearTeamSelection', () => { 'd' ]) }) + + // Why: 0a2b6e7f (1.4.207) crashed page.tasks with "(t ?? []).filter is not a + // function": the persisted setting reached the renderer as a string, which + // `new Set(value)` in the hook initializer accepts and this call did not. + it('treats a saved selection of the wrong shape as sticky-all', () => { + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], 'a'))).toEqual([ + 'a', + 'b' + ]) + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], { 0: 'a' }))).toEqual([ + 'a', + 'b' + ]) + }) +}) + +describe('storedLinearTeamSelection', () => { + it('keeps a string array', () => { + expect(storedLinearTeamSelection(['a', 'b'])).toEqual(['a', 'b']) + }) + + it('reads null and undefined as sticky-all', () => { + expect(storedLinearTeamSelection(null)).toBeNull() + expect(storedLinearTeamSelection(undefined)).toBeNull() + }) + + it('reads a string, an object or a number as sticky-all instead of throwing', () => { + expect(storedLinearTeamSelection('a')).toBeNull() + expect(storedLinearTeamSelection({ 0: 'a' })).toBeNull() + expect(storedLinearTeamSelection(7)).toBeNull() + }) + + it('drops non-string entries from a mixed array', () => { + expect(storedLinearTeamSelection(['a', 1, null, 'b'])).toEqual(['a', 'b']) + }) }) diff --git a/src/renderer/src/components/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page-linear-team-selection.ts index 3ec82284a59..9cebac902b7 100644 --- a/src/renderer/src/components/task-page-linear-team-selection.ts +++ b/src/renderer/src/components/task-page-linear-team-selection.ts @@ -1,8 +1,18 @@ import type { LinearTeam } from '../../../shared/linear/workspace-types' +// Why: the persisted value is not validated on the way in and a string reached +// 1.4.207 (0a2b6e7f); anything but a string array reads as sticky-all. +export function storedLinearTeamSelection(value: unknown): string[] | null { + if (!Array.isArray(value)) { + return null + } + return value.filter((id): id is string => typeof id === 'string') +} + +// Why `unknown`: this is the crash site, so it must hold for the raw setting too. export function reconcileLinearTeamSelection( availableTeams: LinearTeam[], - storedSelection: readonly string[] | null | undefined + storedSelection: unknown ): ReadonlySet { const availableIds = availableTeams.map((team) => team.id) if (availableIds.length === 0) { @@ -10,7 +20,9 @@ export function reconcileLinearTeamSelection( } const availableIdSet = new Set(availableIds) - const validStoredSelection = (storedSelection ?? []).filter((id) => availableIdSet.has(id)) + const validStoredSelection = (storedLinearTeamSelection(storedSelection) ?? []).filter((id) => + availableIdSet.has(id) + ) if (validStoredSelection.length > 0) { return new Set(validStoredSelection) } diff --git a/src/renderer/src/components/use-task-page-linear-list-selection.ts b/src/renderer/src/components/use-task-page-linear-list-selection.ts index 8c8de172f9a..ae2e03f0603 100644 --- a/src/renderer/src/components/use-task-page-linear-list-selection.ts +++ b/src/renderer/src/components/use-task-page-linear-list-selection.ts @@ -7,7 +7,10 @@ import { buildLinearTeamUrl, getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear/links' -import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' +import { + reconcileLinearTeamSelection, + storedLinearTeamSelection +} from '@/components/task-page-linear-team-selection' import { useTaskPageLinearFilterSelection } from './use-task-page-linear-filter-selection' export type TaskPageLinearListSelectionPreludeModel = ReturnType< typeof useTaskPageLinearListSelectionPrelude @@ -42,7 +45,13 @@ export function useTaskPageLinearListSelectionPrelude(model: TaskPageGitLabLoadi linearCustomViewContentsError, availableTeams } = model - const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection + // Why memoized: the normalized array feeds an effect that sets state, and a + // fresh array per render would re-run it every render. + const rawLinearTeamSelection = settings?.defaultLinearTeamSelection + const defaultLinearTeamSelection = useMemo( + () => storedLinearTeamSelection(rawLinearTeamSelection), + [rawLinearTeamSelection] + ) const [linearTeamSelection, setLinearTeamSelection] = useState>(() => { if (!defaultLinearTeamSelection) { return new Set()