fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page (#22279)

* fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page

A persisted defaultLinearTeamSelection that is not a string array (a string
reached 1.4.207, report 0a2b6e7f) threw '(t ?? []).filter is not a function'
inside a commit-phase effect and tripped the page.tasks error boundary. The
value is now normalized where the page reads it and where a host projects it
to paired clients; anything but a string array means sticky-all.

* fix(mobile): read a malformed saved Linear team selection as sticky-all

A host that predates the desktop fix projects its raw store value, so the
mobile Linear list must tolerate the same string shape. Also trims the
desktop helper's comments to the why.

* fix(tasks): validate projected Linear team IDs and refresh parity contract

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-25 21:01:32 -07:00
committed by GitHub
co-authored by m4air m4air
parent 27ca6a229a
commit d06b43e634
8 changed files with 151 additions and 12 deletions
@@ -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'
@@ -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'])
})
})
@@ -196,11 +196,10 @@ export function linearIssueSecondaryParts(
return parts
}
export function reconcileTeamSelection(
teams: LinearTeam[],
saved: string[] | null | undefined
): Set<string> {
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<string> {
if (!Array.isArray(saved)) {
return new Set(teams.map((team) => team.id))
}
const available = new Set(teams.map((team) => team.id))
@@ -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<GlobalSettings>) {
// 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<string, unknown>): Partial<GlobalSettings> {
// 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<GlobalSettings>
}
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()
})
})
+4 -1
View File
@@ -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
@@ -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'])
})
})
@@ -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<string> {
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)
}
@@ -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<ReadonlySet<string>>(() => {
if (!defaultLinearTeamSelection) {
return new Set<string>()