refactor(frontend): replace for...in usages and forbid via lint rule

Replaces 33 for...in loops across 18 files with for...of + Object.keys/entries
or indexed for loops. for...in iterates enumerable string keys including
inherited ones, which is especially risky on reactive arrays (tasks, labels,
assignees, etc.) where polyfilled properties may appear.

Loops that mutate via splice during iteration now iterate backwards to avoid
index-shift bugs. Adds a no-restricted-syntax ESLint rule forbidding
ForInStatement to prevent regressions.

Closes #513
This commit is contained in:
kolaente 2026-04-15 11:57:03 +02:00 committed by kolaente
parent 95180a341d
commit 2c6029eac4
18 changed files with 58 additions and 49 deletions

View File

@ -112,6 +112,11 @@ export default [
'depend/ban-dependencies': 'warn', 'depend/ban-dependencies': 'warn',
'no-restricted-syntax': ['error', {
selector: 'ForInStatement',
message: 'Use for...of with Object.keys/entries, or .forEach, instead of for...in. See https://github.com/go-vikunja/vikunja/issues/513',
}],
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error', 'error',
{ {

View File

@ -448,7 +448,7 @@ function createOrSelectOnEnter() {
} }
function remove(item: T) { function remove(item: T) {
for (const ind in internalValue.value) { for (let ind = 0; ind < internalValue.value.length; ind++) {
if (internalValue.value[ind] === item) { if (internalValue.value[ind] === item) {
internalValue.value.splice(ind, 1) internalValue.value.splice(ind, 1)
break break

View File

@ -235,7 +235,7 @@ function updateTasks(updatedTask: ITask) {
return return
} }
for (const t in tasks.value) { for (let t = 0; t < tasks.value.length; t++) {
if (tasks.value[t].id === updatedTask.id) { if (tasks.value[t].id === updatedTask.id) {
tasks.value[t] = updatedTask tasks.value[t] = updatedTask
break break

View File

@ -291,7 +291,7 @@ async function deleteSharable() {
await stuffService.delete(stuffModel) await stuffService.delete(stuffModel)
showDeleteModal.value = false showDeleteModal.value = false
for (const i in sharables.value) { for (let i = sharables.value.length - 1; i >= 0; i--) {
if ( if (
(sharables.value[i].username === stuffModel.username && props.shareType === 'user') || (sharables.value[i].username === stuffModel.username && props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId && props.shareType === 'team') (sharables.value[i].id === stuffModel.teamId && props.shareType === 'team')
@ -344,15 +344,15 @@ async function toggleType(sharable) {
} }
const r = await stuffService.update(stuffModel) const r = await stuffService.update(stuffModel)
for (const i in sharables.value) { for (const sharableEntry of sharables.value) {
if ( if (
(sharables.value[i].username === (sharableEntry.username ===
stuffModel.username && stuffModel.username &&
props.shareType === 'user') || props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId && (sharableEntry.id === stuffModel.teamId &&
props.shareType === 'team') props.shareType === 'team')
) { ) {
sharables.value[i].permission = r.permission sharableEntry.permission = r.permission
} }
} }
success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})}) success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})})

View File

@ -478,7 +478,7 @@ async function editComment() {
commentEdit.taskId = props.taskId commentEdit.taskId = props.taskId
try { try {
const comment = await taskCommentService.update(commentEdit) const comment = await taskCommentService.update(commentEdit)
for (const c in comments.value) { for (let c = 0; c < comments.value.length; c++) {
if (comments.value[c].id === commentEdit.id) { if (comments.value[c].id === commentEdit.id) {
comments.value[c] = comment comments.value[c] = comment
} }

View File

@ -99,7 +99,7 @@ async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId}) await taskStore.removeAssignee({user: user, taskId: props.taskId})
// Remove the assignee from the project // Remove the assignee from the project
for (const a in assignees.value) { for (let a = assignees.value.length - 1; a >= 0; a--) {
if (assignees.value[a].id === user.id) { if (assignees.value[a].id === user.id) {
assignees.value.splice(a, 1) assignees.value.splice(a, 1)
} }

View File

@ -124,9 +124,9 @@ async function removeLabel(label: ILabel) {
await taskStore.removeLabel({label, taskId: props.taskId}) await taskStore.removeLabel({label, taskId: props.taskId})
} }
for (const l in labels.value) { for (let l = labels.value.length - 1; l >= 0; l--) {
if (labels.value[l].id === label.id) { if (labels.value[l].id === label.id) {
labels.value.splice(l, 1) // FIXME: l should be index labels.value.splice(l, 1)
} }
} }
emit('update:modelValue', labels.value) emit('update:modelValue', labels.value)

View File

@ -13,7 +13,7 @@ export function objectToCamelCase(object: Record<string, any>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedObject: Record<string, any> = {} const parsedObject: Record<string, any> = {}
for (const m in object) { for (const m of Object.keys(object)) {
parsedObject[camelCase(m)] = object[m] parsedObject[camelCase(m)] = object[m]
// Recursive processing // Recursive processing
@ -51,7 +51,7 @@ export function objectToSnakeCase(object: Record<string, any>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedObject: Record<string, any> = {} const parsedObject: Record<string, any> = {}
for (const m in object) { for (const m of Object.keys(object)) {
parsedObject[snakeCase(m)] = object[m] parsedObject[snakeCase(m)] = object[m]
// Recursive processing // Recursive processing

View File

@ -19,11 +19,11 @@ describe('Filter Transformation', () => {
} }
describe('For API', () => { describe('For API', () => {
for (const c in fieldCases) { for (const [c, snakeCase] of Object.entries(fieldCases)) {
it('should transform all filter params for ' + c + ' to snake_case', () => { it('should transform all filter params for ' + c + ' to snake_case', () => {
const transformed = transformFilterStringForApi(c + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver) const transformed = transformFilterStringForApi(c + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
expect(transformed).toBe(fieldCases[c] + ' = ipsum') expect(transformed).toBe(snakeCase + ' = ipsum')
}) })
} }
@ -416,9 +416,9 @@ describe('Filter Transformation', () => {
}) })
describe('To API', () => { describe('To API', () => {
for (const c in fieldCases) { for (const [c, snakeCase] of Object.entries(fieldCases)) {
it('should transform all filter params for ' + c + ' to snake_case', () => { it('should transform all filter params for ' + c + ' to snake_case', () => {
const transformed = transformFilterStringFromApi(fieldCases[c] + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver) const transformed = transformFilterStringFromApi(snakeCase + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
expect(transformed).toBe(c + ' = ipsum') expect(transformed).toBe(c + ' = ipsum')
}) })

View File

@ -18,7 +18,7 @@ export const saveCollapsedBucketState = (
) => { ) => {
const state = getAllState() const state = getAllState()
state[projectId] = collapsedBuckets state[projectId] = collapsedBuckets
for (const bucketId in state[projectId]) { for (const bucketId of Object.keys(state[projectId])) {
if (!state[projectId][bucketId]) { if (!state[projectId][bucketId]) {
delete state[projectId][bucketId] delete state[projectId][bucketId]
} }

View File

@ -12,13 +12,13 @@ const days = {
sunday: 0, sunday: 0,
} as Record<string, number> } as Record<string, number>
for (const n in days) { for (const n of Object.keys(days)) {
test(`today on a ${n}`, () => { test(`today on a ${n}`, () => {
expect(calculateDayInterval('today', days[n])).toBe(0) expect(calculateDayInterval('today', days[n])).toBe(0)
}) })
} }
for (const n in days) { for (const n of Object.keys(days)) {
test(`tomorrow on a ${n}`, () => { test(`tomorrow on a ${n}`, () => {
expect(calculateDayInterval('tomorrow', days[n])).toBe(1) expect(calculateDayInterval('tomorrow', days[n])).toBe(1)
}) })
@ -34,7 +34,7 @@ const nextMonday = {
sunday: 1, sunday: 1,
} as Record<string, number> } as Record<string, number>
for (const n in nextMonday) { for (const n of Object.keys(nextMonday)) {
test(`next monday on a ${n}`, () => { test(`next monday on a ${n}`, () => {
expect(calculateDayInterval('nextMonday', days[n])).toBe(nextMonday[n]) expect(calculateDayInterval('nextMonday', days[n])).toBe(nextMonday[n])
}) })
@ -50,7 +50,7 @@ const thisWeekend = {
sunday: 0, sunday: 0,
} as Record<string, number> } as Record<string, number>
for (const n in thisWeekend) { for (const n of Object.keys(thisWeekend)) {
test(`this weekend on a ${n}`, () => { test(`this weekend on a ${n}`, () => {
expect(calculateDayInterval('thisWeekend', days[n])).toBe(thisWeekend[n]) expect(calculateDayInterval('thisWeekend', days[n])).toBe(thisWeekend[n])
}) })
@ -66,7 +66,7 @@ const laterThisWeek = {
sunday: 0, sunday: 0,
} as Record<string, number> } as Record<string, number>
for (const n in laterThisWeek) { for (const n of Object.keys(laterThisWeek)) {
test(`later this week on a ${n}`, () => { test(`later this week on a ${n}`, () => {
expect(calculateDayInterval('laterThisWeek', days[n])).toBe(laterThisWeek[n]) expect(calculateDayInterval('laterThisWeek', days[n])).toBe(laterThisWeek[n])
}) })
@ -82,13 +82,13 @@ const laterNextWeek = {
sunday: 7 + 0, sunday: 7 + 0,
} as Record<string, number> } as Record<string, number>
for (const n in laterNextWeek) { for (const n of Object.keys(laterNextWeek)) {
test(`later next week on a ${n} (this week)`, () => { test(`later next week on a ${n} (this week)`, () => {
expect(calculateDayInterval('laterNextWeek', days[n])).toBe(laterNextWeek[n]) expect(calculateDayInterval('laterNextWeek', days[n])).toBe(laterNextWeek[n])
}) })
} }
for (const n in days) { for (const n of Object.keys(days)) {
test(`next week on a ${n}`, () => { test(`next week on a ${n}`, () => {
expect(calculateDayInterval('nextWeek', days[n])).toBe(7) expect(calculateDayInterval('nextWeek', days[n])).toBe(7)
}) })

View File

@ -165,7 +165,7 @@ describe('Parse Task Text', () => {
'at 12:00 am': '0:0', 'at 12:00 am': '0:0',
} as const } as const
for (const c in cases) { for (const c of Object.keys(cases)) {
it(`should recognize today with a time ${c}`, () => { it(`should recognize today with a time ${c}`, () => {
const result = parseTaskText(`Lorem Ipsum today ${c}`) const result = parseTaskText(`Lorem Ipsum today ${c}`)
@ -526,7 +526,7 @@ describe('Parse Task Text', () => {
] ]
prefix.forEach(p => { prefix.forEach(p => {
for (const d in days) { for (const d of Object.keys(days)) {
it(`should recognize ${p}${d}`, () => { it(`should recognize ${p}${d}`, () => {
const result = parseTaskText(`Lorem Ipsum ${p}${d}`) const result = parseTaskText(`Lorem Ipsum ${p}${d}`)
@ -556,7 +556,7 @@ describe('Parse Task Text', () => {
// This tests only standalone days are recognized and not things like "github", "monitor" or "renewed". // This tests only standalone days are recognized and not things like "github", "monitor" or "renewed".
// We're not using real words here to generate tests for all days on the fly. // We're not using real words here to generate tests for all days on the fly.
for (const d in days) { for (const d of Object.keys(days)) {
it(`should not recognize ${d} with a space before it but none after it`, () => { it(`should not recognize ${d} with a space before it but none after it`, () => {
const text = `Lorem Ipsum ${d}ipsum` const text = `Lorem Ipsum ${d}ipsum`
const result = parseTaskText(text) const result = parseTaskText(text)
@ -660,7 +660,7 @@ describe('Parse Task Text', () => {
'01.02.25': '2025-2-1', '01.02.25': '2025-2-1',
} as Record<string, string | null> } as Record<string, string | null>
for (const c in cases) { for (const c of Object.keys(cases)) {
const assertResult = ({date, text}: ParsedTaskText) => { const assertResult = ({date, text}: ParsedTaskText) => {
if (date === null && cases[c] === null) { if (date === null && cases[c] === null) {
expect(date).toBeNull() expect(date).toBeNull()
@ -722,7 +722,7 @@ describe('Parse Task Text', () => {
'5th Mar at 3pm': '2021-3-5 15:0', '5th Mar at 3pm': '2021-3-5 15:0',
} as Record<string, string> } as Record<string, string>
for (const c in cases) { for (const c of Object.keys(cases)) {
it(`should parse '${c}' as '${cases[c]}'`, () => { it(`should parse '${c}' as '${cases[c]}'`, () => {
const {date} = parseDate(c, now) const {date} = parseDate(c, now)
if (date === null && cases[c] === null) { if (date === null && cases[c] === null) {
@ -846,7 +846,7 @@ describe('Parse Task Text', () => {
}) })
describe('Priority', () => { describe('Priority', () => {
for (const p in PRIORITIES) { for (const p of Object.keys(PRIORITIES) as Array<keyof typeof PRIORITIES>) {
it(`should parse priority ${p}`, () => { it(`should parse priority ${p}`, () => {
const result = parseTaskText(`Lorem Ipsum !${PRIORITIES[p]}`) const result = parseTaskText(`Lorem Ipsum !${PRIORITIES[p]}`)
@ -969,7 +969,7 @@ describe('Parse Task Text', () => {
'yearly': {type: 'years', amount: 1}, 'yearly': {type: 'years', amount: 1},
} as Record<string, IRepeatAfter> } as Record<string, IRepeatAfter>
for (const c in cases) { for (const c of Object.keys(cases)) {
it(`should parse ${c} as recurring date every ${cases[c].amount} ${cases[c].type}`, () => { it(`should parse ${c} as recurring date every ${cases[c].amount} ${cases[c].type}`, () => {
const result = parseTaskText(`Lorem Ipsum ${c}`) const result = parseTaskText(`Lorem Ipsum ${c}`)

View File

@ -15,7 +15,7 @@ interface Paths {
reset?: string reset?: string
} }
function convertObject(o: Record<string, unknown>) { function convertObject(o: unknown) {
if (o instanceof Date) { if (o instanceof Date) {
return o.toISOString() return o.toISOString()
} }
@ -28,13 +28,14 @@ function prepareParams(params: Record<string, unknown | unknown[]>) {
return params return params
} }
for (const p in params) { for (const p of Object.keys(params)) {
if (Array.isArray(params[p])) { const val = params[p]
params[p] = params[p].map(convertObject) if (Array.isArray(val)) {
params[p] = val.map(convertObject)
continue continue
} }
params[p] = convertObject(params[p]) params[p] = convertObject(val)
} }
return objectToSnakeCase(params) return objectToSnakeCase(params)

View File

@ -62,7 +62,7 @@ export default class TaskService extends AbstractService<ITask> {
model.reminderDates = null model.reminderDates = null
// remove all nulls, these would create empty reminders // remove all nulls, these would create empty reminders
for (const index in model.reminders) { for (let index = model.reminders.length - 1; index >= 0; index--) {
if (model.reminders[index] === null) { if (model.reminders[index] === null) {
model.reminders.splice(index, 1) model.reminders.splice(index, 1)
} }

View File

@ -124,9 +124,9 @@ export const useKanbanStore = defineStore('kanban', () => {
let found = false let found = false
const findAndUpdate = b => { const findAndUpdate = (b: number) => {
for (const t in buckets.value[b].tasks) { for (const [t, taskInBucket] of buckets.value[b].tasks.entries()) {
if (buckets.value[b].tasks[t].id === task.id) { if (taskInBucket.id === task.id) {
const bucket = buckets.value[b] const bucket = buckets.value[b]
bucket.tasks[t] = task bucket.tasks[t] = task
@ -138,7 +138,7 @@ export const useKanbanStore = defineStore('kanban', () => {
} }
} }
for (const b in buckets.value) { for (let b = 0; b < buckets.value.length; b++) {
findAndUpdate(b) findAndUpdate(b)
if (found) { if (found) {
return return

View File

@ -287,7 +287,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string, filterId: nu
// FIXME: this modification should happen in the store // FIXME: this modification should happen in the store
function updateTasks(updatedTask: ITask) { function updateTasks(updatedTask: ITask) {
for (const t in tasks.value) { for (let t = 0; t < tasks.value.length; t++) {
if (tasks.value[t].id === updatedTask.id) { if (tasks.value[t].id === updatedTask.id) {
tasks.value[t] = updatedTask tasks.value[t] = updatedTask
// Move the task to the end of the done tasks if it is now done // Move the task to the end of the done tasks if it is now done

View File

@ -1174,9 +1174,12 @@ function setRelatedTasksActive() {
// If the related tasks are already available, show the form again // If the related tasks are already available, show the form again
const el = activeFieldElements['relatedTasks'] const el = activeFieldElements['relatedTasks']
for (const child in el?.children) { if (!el) {
if (el?.children[child]?.id === 'showRelatedTasksFormButton') { return
el?.children[child]?.click() }
for (const child of Array.from(el.children)) {
if ((child as HTMLElement).id === 'showRelatedTasksFormButton') {
(child as HTMLElement).click()
break break
} }
} }

View File

@ -349,9 +349,9 @@ async function toggleUserType(member: ITeamMember) {
member.admin = !member.admin member.admin = !member.admin
member.teamId = teamId.value member.teamId = teamId.value
const r = await teamMemberService.value.update(member) const r = await teamMemberService.value.update(member)
for (const tm in team.value.members) { for (const tm of team.value.members) {
if (team.value.members[tm].id === member.id) { if (tm.id === member.id) {
team.value.members[tm].admin = r.admin tm.admin = r.admin
break break
} }
} }