From a0d0e5049e2d0db326ee36864906f43cfefb0c05 Mon Sep 17 00:00:00 2001
From: RedwindA <128586631+RedwindA@users.noreply.github.com>
Date: Sat, 25 Jul 2026 15:59:59 +0800
Subject: [PATCH] fix(web/channel): stabilize inline priority updates (#6415)
* fix(channel): debounce inline priority updates
* fix(channel): commit spinner edits only on Enter or focus leave
Blurring the inline edit input to the +/- buttons flushed the pending
priority update immediately, bypassing the container focus-containment
check and defeating the debounce. Commit now happens via the container
blur handler or explicitly on Enter. Add unit tests for the priority
update scheduler.
* fix(channel): preserve row identity when priority updates reorder channels
---
.../channels/components/channels-columns.tsx | 103 +++++++++------
.../channels/components/channels-table.tsx | 2 +
.../components/numeric-spinner-input.tsx | 16 ++-
.../__tests__/channel-table-row-id.test.ts | 56 +++++++++
.../lib/channel-priority-update.test.ts | 117 ++++++++++++++++++
.../channels/lib/channel-priority-update.ts | 65 ++++++++++
.../features/channels/lib/channel-utils.ts | 8 ++
web/src/features/channels/lib/index.ts | 1 +
8 files changed, 327 insertions(+), 41 deletions(-)
create mode 100644 web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts
create mode 100644 web/src/features/channels/lib/channel-priority-update.test.ts
create mode 100644 web/src/features/channels/lib/channel-priority-update.ts
diff --git a/web/src/features/channels/components/channels-columns.tsx b/web/src/features/channels/components/channels-columns.tsx
index f538296d..8d91485c 100644
--- a/web/src/features/channels/components/channels-columns.tsx
+++ b/web/src/features/channels/components/channels-columns.tsx
@@ -27,7 +27,7 @@ import {
Shuffle,
SlidersHorizontal,
} from 'lucide-react'
-import { useState, useMemo, useContext } from 'react'
+import { useState, useMemo, useContext, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -71,6 +71,7 @@ import {
handleUpdateChannelField,
handleUpdateTagField,
handleUpdateChannelBalance,
+ createChannelPriorityUpdateScheduler,
isTagAggregateRow,
type TagRow,
} from '../lib'
@@ -172,55 +173,77 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
* Priority cell component with inline editing
*/
function PriorityCell({ channel }: { channel: Channel }) {
+ if (isTagAggregateRow(channel)) {
+ return
+ }
+
+ return
+}
+
+function TagPriorityCell({ channel }: { channel: TagRow }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
- const isTagRow = isTagAggregateRow(channel)
const priority = channel.priority
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingValue, setPendingValue] = useState(null)
+ const tag = channel.tag || ''
+ const channelCount = channel.children?.length || 0
- // Tag row - editable with confirmation for all tag channels
- if (isTagRow) {
- const tag = channel.tag || ''
- const channelCount = channel.children?.length || 0
+ return (
+ <>
+ {
+ setPendingValue(value)
+ setConfirmOpen(true)
+ }}
+ min={-999}
+ />
+ {
+ if (pendingValue !== null) {
+ handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
+ }
+ setConfirmOpen(false)
+ }}
+ />
+ >
+ )
+}
- return (
- <>
- {
- setPendingValue(value)
- setConfirmOpen(true)
- }}
- min={-999}
- />
- {
- if (pendingValue !== null) {
- handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
- }
- setConfirmOpen(false)
- }}
- />
- >
- )
- }
+function ChannelPriorityCell({ channel }: { channel: Channel }) {
+ const queryClient = useQueryClient()
+ const priorityUpdateScheduler = useMemo(
+ () =>
+ createChannelPriorityUpdateScheduler((value) => {
+ void handleUpdateChannelField(
+ channel.id,
+ 'priority',
+ value,
+ queryClient
+ )
+ }),
+ [channel.id, queryClient]
+ )
+
+ useEffect(
+ () => () => priorityUpdateScheduler.flush(),
+ [priorityUpdateScheduler]
+ )
- // Regular channel row - editable
return (
{
- handleUpdateChannelField(channel.id, 'priority', value, queryClient)
- }}
+ value={channel.priority ?? 0}
+ onChange={priorityUpdateScheduler.schedule}
+ onCommit={priorityUpdateScheduler.flush}
min={-999}
/>
)
diff --git a/web/src/features/channels/components/channels-table.tsx b/web/src/features/channels/components/channels-table.tsx
index e9d058bb..51f3f3ec 100644
--- a/web/src/features/channels/components/channels-table.tsx
+++ b/web/src/features/channels/components/channels-table.tsx
@@ -55,6 +55,7 @@ import {
import {
channelsQueryKeys,
aggregateChannelsByTag,
+ getChannelTableRowId,
isTagAggregateRow,
getChannelTypeIcon,
getChannelTypeLabel,
@@ -330,6 +331,7 @@ export function ChannelsTable() {
onColumnFiltersChange: handleColumnFiltersChange,
onPaginationChange,
onGlobalFilterChange,
+ getRowId: getChannelTableRowId,
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true,
manualSorting: true,
diff --git a/web/src/features/channels/components/numeric-spinner-input.tsx b/web/src/features/channels/components/numeric-spinner-input.tsx
index ab5736c4..cd53f45a 100644
--- a/web/src/features/channels/components/numeric-spinner-input.tsx
+++ b/web/src/features/channels/components/numeric-spinner-input.tsx
@@ -25,6 +25,7 @@ import { cn } from '@/lib/utils'
interface NumericSpinnerInputProps {
value: number | null | undefined
onChange: (value: number) => void
+ onCommit?: () => void
min?: number
max?: number
step?: number
@@ -36,6 +37,7 @@ interface NumericSpinnerInputProps {
export function NumericSpinnerInput({
value,
onChange,
+ onCommit,
min = 0,
max,
step = 1,
@@ -96,7 +98,7 @@ export function NumericSpinnerInput({
const commitValue = () => {
setEditing(false)
const num = Number(localValue)
- if (isNaN(num) || localValue === '' || localValue === '-') {
+ if (Number.isNaN(num) || localValue === '' || localValue === '-') {
setLocalValue(String(value ?? 0))
return
}
@@ -107,10 +109,21 @@ export function NumericSpinnerInput({
}
}
+ const handleControlBlur = (e: React.FocusEvent) => {
+ if (
+ e.relatedTarget instanceof Node &&
+ e.currentTarget.contains(e.relatedTarget)
+ ) {
+ return
+ }
+ onCommit?.()
+ }
+
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
commitValue()
+ onCommit?.()
} else if (e.key === 'Escape') {
setEditing(false)
setLocalValue(String(value ?? 0))
@@ -126,6 +139,7 @@ export function NumericSpinnerInput({
)}
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import assert from 'node:assert/strict'
+import { describe, test } from 'node:test'
+
+import type { Channel } from '../../types'
+import { getChannelTableRowId, type TagRow } from '../channel-utils'
+
+function channel(id: number): Channel {
+ return { id } as Channel
+}
+
+describe('channel table row identity', () => {
+ test('keeps each channel identity when priority updates reorder the rows', () => {
+ const first = channel(101)
+ const updated = channel(202)
+ const third = channel(303)
+
+ const beforeUpdate = [first, updated, third].map(getChannelTableRowId)
+ const afterUpdate = [updated, first, third].map(getChannelTableRowId)
+
+ assert.deepEqual(beforeUpdate, [
+ 'channel:101',
+ 'channel:202',
+ 'channel:303',
+ ])
+ assert.deepEqual(afterUpdate, ['channel:202', 'channel:101', 'channel:303'])
+ })
+
+ test('uses separate namespaces for tag and channel rows', () => {
+ const tagRow = {
+ id: '202' as unknown as number,
+ tag: '202',
+ children: [channel(202)],
+ } as TagRow
+
+ assert.equal(getChannelTableRowId(tagRow), 'tag:202')
+ assert.equal(getChannelTableRowId(channel(202)), 'channel:202')
+ })
+})
diff --git a/web/src/features/channels/lib/channel-priority-update.test.ts b/web/src/features/channels/lib/channel-priority-update.test.ts
new file mode 100644
index 00000000..f77efd7c
--- /dev/null
+++ b/web/src/features/channels/lib/channel-priority-update.test.ts
@@ -0,0 +1,117 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import assert from 'node:assert/strict'
+import { describe, test } from 'node:test'
+
+import {
+ CHANNEL_PRIORITY_UPDATE_DELAY_MS,
+ createChannelPriorityUpdateScheduler,
+} from './channel-priority-update'
+
+function createFakeTimers() {
+ const pending = new Map void>()
+ let nextId = 1
+
+ return {
+ timers: {
+ setTimeout: (callback: () => void, delay: number) => {
+ assert.equal(delay, CHANNEL_PRIORITY_UPDATE_DELAY_MS)
+ const id = nextId++
+ pending.set(id, callback)
+ return id
+ },
+ clearTimeout: (id: number) => {
+ pending.delete(id)
+ },
+ },
+ fireAll() {
+ const callbacks = [...pending.values()]
+ pending.clear()
+ for (const callback of callbacks) callback()
+ },
+ get pendingCount() {
+ return pending.size
+ },
+ }
+}
+
+describe('channel priority update scheduler', () => {
+ test('coalesces rapid schedules into one update with the latest value', () => {
+ const fake = createFakeTimers()
+ const updates: number[] = []
+ const scheduler = createChannelPriorityUpdateScheduler(
+ (value) => updates.push(value),
+ fake.timers
+ )
+
+ scheduler.schedule(1)
+ scheduler.schedule(2)
+ scheduler.schedule(3)
+ assert.deepEqual(updates, [])
+ assert.equal(fake.pendingCount, 1)
+
+ fake.fireAll()
+ assert.deepEqual(updates, [3])
+ })
+
+ test('flush commits the pending value immediately and cancels the timer', () => {
+ const fake = createFakeTimers()
+ const updates: number[] = []
+ const scheduler = createChannelPriorityUpdateScheduler(
+ (value) => updates.push(value),
+ fake.timers
+ )
+
+ scheduler.schedule(7)
+ scheduler.flush()
+ assert.deepEqual(updates, [7])
+ assert.equal(fake.pendingCount, 0)
+
+ fake.fireAll()
+ assert.deepEqual(updates, [7])
+ })
+
+ test('flush without a pending value does nothing', () => {
+ const fake = createFakeTimers()
+ const updates: number[] = []
+ const scheduler = createChannelPriorityUpdateScheduler(
+ (value) => updates.push(value),
+ fake.timers
+ )
+
+ scheduler.flush()
+ scheduler.schedule(5)
+ scheduler.flush()
+ scheduler.flush()
+ assert.deepEqual(updates, [5])
+ })
+
+ test('preserves a pending value of 0', () => {
+ const fake = createFakeTimers()
+ const updates: number[] = []
+ const scheduler = createChannelPriorityUpdateScheduler(
+ (value) => updates.push(value),
+ fake.timers
+ )
+
+ scheduler.schedule(0)
+ scheduler.flush()
+ assert.deepEqual(updates, [0])
+ })
+})
diff --git a/web/src/features/channels/lib/channel-priority-update.ts b/web/src/features/channels/lib/channel-priority-update.ts
new file mode 100644
index 00000000..746de26b
--- /dev/null
+++ b/web/src/features/channels/lib/channel-priority-update.ts
@@ -0,0 +1,65 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+export const CHANNEL_PRIORITY_UPDATE_DELAY_MS = 800
+
+interface ChannelPriorityUpdateTimers {
+ setTimeout: (callback: () => void, delay: number) => number
+ clearTimeout: (id: number) => void
+}
+
+const browserTimers: ChannelPriorityUpdateTimers = {
+ setTimeout: (callback, delay) => window.setTimeout(callback, delay),
+ clearTimeout: (id) => window.clearTimeout(id),
+}
+
+export function createChannelPriorityUpdateScheduler(
+ onUpdate: (value: number) => void,
+ timers: ChannelPriorityUpdateTimers = browserTimers
+) {
+ let timeoutId: number | undefined
+ let pendingValue: number | undefined
+
+ const clearPendingTimer = () => {
+ if (timeoutId === undefined) return
+ timers.clearTimeout(timeoutId)
+ timeoutId = undefined
+ }
+
+ const commitPendingValue = () => {
+ clearPendingTimer()
+ if (pendingValue === undefined) return
+
+ const value = pendingValue
+ pendingValue = undefined
+ onUpdate(value)
+ }
+
+ return {
+ schedule(value: number) {
+ clearPendingTimer()
+ pendingValue = value
+ timeoutId = timers.setTimeout(
+ commitPendingValue,
+ CHANNEL_PRIORITY_UPDATE_DELAY_MS
+ )
+ },
+ flush: commitPendingValue,
+ }
+}
diff --git a/web/src/features/channels/lib/channel-utils.ts b/web/src/features/channels/lib/channel-utils.ts
index dc143a66..d30c4f7a 100644
--- a/web/src/features/channels/lib/channel-utils.ts
+++ b/web/src/features/channels/lib/channel-utils.ts
@@ -616,6 +616,14 @@ export function isTagAggregateRow(row: Channel | TagRow): row is TagRow {
return Array.isArray((row as TagRow).children)
}
+export function getChannelTableRowId(row: Channel | TagRow): string {
+ if (isTagAggregateRow(row)) {
+ return `tag:${row.tag || ''}`
+ }
+
+ return `channel:${row.id}`
+}
+
/**
* Aggregate channels by tag for tag mode display
* Converts flat array into tree structure grouped by tag
diff --git a/web/src/features/channels/lib/index.ts b/web/src/features/channels/lib/index.ts
index 9c41eb68..71059a81 100644
--- a/web/src/features/channels/lib/index.ts
+++ b/web/src/features/channels/lib/index.ts
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
// Re-export all library functions
export * from './channel-actions'
+export * from './channel-priority-update'
export * from './advanced-custom'
export * from './channel-form-errors'
export * from './channel-form'