diff --git a/web/default/src/components/risk-acknowledgement-dialog.tsx b/web/default/src/components/risk-acknowledgement-dialog.tsx
index 03eaa846..1af96d34 100644
--- a/web/default/src/components/risk-acknowledgement-dialog.tsx
+++ b/web/default/src/components/risk-acknowledgement-dialog.tsx
@@ -91,14 +91,16 @@ export function RiskAcknowledgementDialog({
NormalizedRequiredTextPart[]
>(() => {
let inputIndex = 0
- return requiredTextParts.map((part) => {
+ const parts: NormalizedRequiredTextPart[] = []
+ for (const part of requiredTextParts) {
if (part.type === 'input') {
- const normalizedPart = { ...part, inputIndex }
+ parts.push({ ...part, inputIndex })
inputIndex += 1
- return normalizedPart
+ } else {
+ parts.push(part)
}
- return part
- })
+ }
+ return parts
}, [requiredTextParts])
const requiredTextInputCount = useMemo(
diff --git a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx
index cc6e883a..6d24ae6b 100644
--- a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx
@@ -145,7 +145,7 @@ export function FetchModelsDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeChannel?.id, customFetcher])
- const handleFetchModels = async () => {
+ async function handleFetchModels() {
if (!activeChannel && !customFetcher) return
setIsFetching(true)
diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx
index 5e34f458..cbb43e25 100644
--- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx
@@ -110,11 +110,11 @@ export function MultiKeyManageDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])
- const loadKeyStatus = async (
+ async function loadKeyStatus(
page: number = currentPage,
size: number = pageSize,
status: number | null = statusFilter
- ) => {
+ ) {
if (!currentRow) return
setIsLoading(true)
diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx
new file mode 100644
index 00000000..6de11cdf
--- /dev/null
+++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx
@@ -0,0 +1,193 @@
+/*
+Copyright (C) 2023-2026 MAX-API-Next
+
+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 https://github.com/MAX-API-Next/MAX-API/issues
+*/
+import { act, useState } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createReactTestEnvironment } from '@/test/react'
+import { waitFor, within } from '@testing-library/react'
+import assert from 'node:assert/strict'
+import { after, test } from 'node:test'
+import { api } from '@/lib/api'
+
+const testEnv = createReactTestEnvironment()
+await testEnv.setup()
+const previousStorage = Object.getOwnPropertyDescriptor(
+ globalThis,
+ 'localStorage'
+)
+Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: testEnv.dom.window.localStorage,
+})
+
+const { ChannelsProvider, useChannels } = await import('../channels-provider')
+const { TagBatchEditDialog } = await import('./tag-batch-edit-dialog')
+after(() => {
+ testEnv.teardown()
+ if (previousStorage)
+ Object.defineProperty(globalThis, 'localStorage', previousStorage)
+ else Reflect.deleteProperty(globalThis, 'localStorage')
+})
+
+test('a late response for a previous tag cannot overwrite the current tag', async () => {
+ const originalAdapter = api.defaults.adapter
+ const pending = new Map void>()
+ const requests: string[] = []
+ let groupRequests = 0
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ api.defaults.adapter = async (config) => {
+ assert.equal(config.method, 'get')
+ if (config.url === '/api/group/') groupRequests += 1
+ let data: unknown = { success: true, data: [] }
+ if (config.url === '/api/channel/tag/models') {
+ const tag = String(config.params.tag)
+ requests.push(tag)
+ const models = await new Promise((resolve) => {
+ pending.set(tag, resolve)
+ })
+ data = { success: true, data: models }
+ }
+ return { data, config, status: 200, statusText: 'OK', headers: {} }
+ }
+ function Harness() {
+ const { setCurrentTag } = useChannels()
+ const [, setRevision] = useState(0)
+ return (
+ <>
+
+
+
+ {}} />
+ >
+ )
+ }
+ const view = await testEnv.render(
+
+
+
+
+
+ )
+ const first = within(view.container).getByRole('button', {
+ name: 'Select first tag',
+ })
+ const second = within(view.container).getByRole('button', {
+ name: 'Select second tag',
+ })
+ const rerender = within(view.container).getByRole('button', {
+ name: 'Rerender',
+ })
+ const fields = within(testEnv.dom.window.document.body)
+ try {
+ await view.click(first)
+ await waitFor(() => assert.ok(pending.has('first')))
+ await view.click(second)
+ await waitFor(() => assert.ok(pending.has('second')))
+ await act(async () => pending.get('second')!('model-second'))
+ await waitFor(() => {
+ assert.equal(
+ (fields.getByLabelText('Models') as HTMLTextAreaElement).value,
+ 'model-second'
+ )
+ })
+ await act(async () => pending.get('first')!('model-first'))
+ await waitFor(() => {
+ assert.equal(
+ (fields.getByLabelText('Models') as HTMLTextAreaElement).value,
+ 'model-second'
+ )
+ assert.equal(
+ (fields.getByLabelText('Tag Name') as HTMLInputElement).value,
+ 'second'
+ )
+ })
+ await view.click(rerender)
+ await act(async () => {
+ await testEnv.i18n.changeLanguage('fr')
+ })
+ assert.deepEqual(requests, ['first', 'second'])
+ assert.equal(groupRequests, 1)
+ } finally {
+ await view.unmount()
+ client.clear()
+ api.defaults.adapter = originalAdapter
+ }
+})
+
+test('tag models become editable without waiting for an unused all-models request', async () => {
+ const originalAdapter = api.defaults.adapter
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ let auxiliaryRequests = 0
+ let releaseAuxiliary: () => void = () => {}
+ api.defaults.adapter = async (config) => {
+ assert.equal(config.method, 'get')
+ let data: unknown = { success: true, data: [] }
+ if (config.url === '/api/channel/tag/models') {
+ data = { success: true, data: 'tag-only-model' }
+ } else if (config.url === '/api/channel/models') {
+ auxiliaryRequests += 1
+ data = await new Promise((resolve) => {
+ releaseAuxiliary = () => resolve({ success: true, data: [] })
+ })
+ }
+ return { data, config, status: 200, statusText: 'OK', headers: {} }
+ }
+ function Harness() {
+ const { setCurrentTag } = useChannels()
+ return (
+ <>
+
+ {}} />
+ >
+ )
+ }
+ const view = await testEnv.render(
+
+
+
+
+
+ )
+ try {
+ await view.click(
+ within(view.container).getByRole('button', { name: 'Select ready tag' })
+ )
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+ const models = within(testEnv.dom.window.document.body).queryByLabelText(
+ 'Models'
+ ) as HTMLTextAreaElement | null
+ assert.equal(models !== null, true)
+ assert.equal(models?.value, 'tag-only-model')
+ assert.equal(auxiliaryRequests, 0)
+ } finally {
+ await act(async () => releaseAuxiliary())
+ await view.unmount()
+ client.clear()
+ api.defaults.adapter = originalAdapter
+ }
+})
diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx
index a8dc78cc..50499588 100644
--- a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { useState, useEffect, useMemo } from 'react'
+import { useState, useEffect, useEffectEvent, useMemo } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, AlertCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -36,12 +36,7 @@ import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { MultiSelect } from '@/components/multi-select'
-import {
- getTagModels,
- editTagChannels,
- getAllModels,
- getGroups,
-} from '../../api'
+import { getTagModels, editTagChannels, getGroups } from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
@@ -52,28 +47,45 @@ type TagBatchEditDialogProps = {
onOpenChange: (open: boolean) => void
}
-export function TagBatchEditDialog({
+export function TagBatchEditDialog(props: TagBatchEditDialogProps) {
+ const { currentTag } = useChannels()
+ const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
+ queryKey: ['groups'],
+ queryFn: getGroups,
+ })
+ return (
+
+ )
+}
+
+function TagBatchEditDialogContent({
open,
onOpenChange,
-}: TagBatchEditDialogProps) {
+ currentTag,
+ groupsData,
+ isLoadingGroups,
+}: TagBatchEditDialogProps & {
+ currentTag: string | null
+ groupsData: Awaited> | undefined
+ isLoadingGroups: boolean
+}) {
const { t } = useTranslation()
- const { currentTag } = useChannels()
const queryClient = useQueryClient()
- const [isLoading, setIsLoading] = useState(false)
+ const [isLoading, setIsLoading] = useState(Boolean(open && currentTag))
const [isSaving, setIsSaving] = useState(false)
// Form fields
- const [newTag, setNewTag] = useState('')
+ const [newTag, setNewTag] = useState(currentTag ?? '')
const [models, setModels] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [groups, setGroups] = useState([])
- // Fetch available groups
- const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
- queryKey: ['groups'],
- queryFn: getGroups,
- })
-
// Transform groups to multi-select options
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
@@ -84,40 +96,36 @@ export function TagBatchEditDialog({
}))
}, [groupsData, groups])
- useEffect(() => {
- if (open && currentTag) {
- loadTagData()
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, currentTag])
-
- const loadTagData = async () => {
- if (!currentTag) return
-
- setIsLoading(true)
- try {
- // Fetch current tag models
- const tagModelsResponse = await getTagModels(currentTag)
- if (tagModelsResponse.success && tagModelsResponse.data) {
- setModels(tagModelsResponse.data)
- }
+ const reportLoadError = useEffectEvent((error: unknown) => {
+ toast.error(
+ error instanceof Error ? error.message : t('Failed to load tag data')
+ )
+ })
- // Fetch all available models (for future use if needed)
- const allModelsResponse = await getAllModels()
- if (allModelsResponse.success && allModelsResponse.data) {
- // Available models could be used for autocomplete in the future
+ useEffect(() => {
+ if (!open || !currentTag) return
+ const tag = currentTag
+ let cancelled = false
+ async function loadTagData() {
+ try {
+ const tagModelsResponse = await getTagModels(tag)
+ if (cancelled) return
+ if (tagModelsResponse.success && tagModelsResponse.data) {
+ setModels(tagModelsResponse.data)
+ }
+ } catch (error: unknown) {
+ if (!cancelled) {
+ reportLoadError(error)
+ }
+ } finally {
+ if (!cancelled) setIsLoading(false)
}
-
- // Initialize new tag with current tag name
- setNewTag(currentTag)
- } catch (_error: unknown) {
- toast.error(
- _error instanceof Error ? _error.message : t('Failed to load tag data')
- )
- } finally {
- setIsLoading(false)
}
- }
+ void loadTagData()
+ return () => {
+ cancelled = true
+ }
+ }, [open, currentTag])
const handleSave = async () => {
if (!currentTag) return
diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx
index 137a58e6..692c853a 100644
--- a/web/default/src/features/dashboard/components/users/user-charts.tsx
+++ b/web/default/src/features/dashboard/components/users/user-charts.tsx
@@ -148,7 +148,6 @@ export function UserCharts() {
t,
topUserLimit,
customization.preset,
- customization.radius,
]
)
diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx
index d806b4a1..08b31bcc 100644
--- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx
+++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx
@@ -117,7 +117,9 @@ export function AccountBindingsTab({
const handleBindCustomOAuth = (provider: { id: number; name: string }) => {
const redirectUrl = `${window.location.origin}/oauth/${provider.id}?bind=true`
- window.location.href = `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}`
+ window.location.assign(
+ `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}`
+ )
}
useEffect(() => {
diff --git a/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx
new file mode 100644
index 00000000..629573e9
--- /dev/null
+++ b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx
@@ -0,0 +1,87 @@
+/*
+Copyright (C) 2023-2026 MAX-API-Next
+
+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 https://github.com/MAX-API-Next/MAX-API/issues
+*/
+import { act } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createReactTestEnvironment } from '@/test/react'
+import { waitFor } from '@testing-library/react'
+import assert from 'node:assert/strict'
+import { after, test } from 'node:test'
+import { api } from '@/lib/api'
+
+const errorKey = 'We could not load the channel health policy.'
+const frenchError = 'Impossible de charger la politique des canaux.'
+const testEnv = createReactTestEnvironment({
+ lng: 'fr',
+ fallbackLng: 'en',
+ resources: {
+ en: { translation: { [errorKey]: errorKey } },
+ fr: { translation: { [errorKey]: frenchError } },
+ },
+})
+await testEnv.setup()
+const { ChannelHealth } = await import('./index')
+after(() => testEnv.teardown())
+
+test('policy errors translate without changing the data cache or repeating the policy request', async () => {
+ const originalAdapter = api.defaults.adapter
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ let policyRequests = 0
+ api.defaults.adapter = async (config) => {
+ assert.equal(config.method, 'get')
+ const policy = config.url === '/api/smart-ops/channel-health/policy'
+ if (policy) policyRequests += 1
+ return {
+ data: { success: true, data: [] },
+ config,
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ }
+ }
+ const queryKey = ['smart-ops', 'channel-health-policy']
+ const view = await testEnv.render(
+
+
+
+ )
+ try {
+ await waitFor(() => {
+ assert.equal(client.getQueryState(queryKey)?.status, 'error')
+ assert.ok(view.container.textContent?.includes(frenchError))
+ })
+ const cachedError = client.getQueryState(queryKey)?.error
+ assert.ok(cachedError instanceof Error)
+ assert.equal(cachedError.message, errorKey)
+ await act(async () => {
+ await testEnv.i18n.changeLanguage('en')
+ })
+ await waitFor(() => {
+ assert.ok(view.container.textContent?.includes(errorKey))
+ })
+ assert.equal(client.getQueryState(queryKey)?.error, cachedError)
+ assert.equal(policyRequests, 1)
+ assert.equal(client.getQueryCache().findAll({ queryKey }).length, 1)
+ } finally {
+ await view.unmount()
+ client.clear()
+ api.defaults.adapter = originalAdapter
+ }
+})
diff --git a/web/default/src/features/smart-ops/channel-health/index.tsx b/web/default/src/features/smart-ops/channel-health/index.tsx
index 8cab67fe..aad66e57 100644
--- a/web/default/src/features/smart-ops/channel-health/index.tsx
+++ b/web/default/src/features/smart-ops/channel-health/index.tsx
@@ -397,7 +397,7 @@ export function ChannelHealth(): ReactElement {
)
if (!hasAllOptions) {
throw new Error(
- response.message || t('We could not load the channel health policy.')
+ response.message || 'We could not load the channel health policy.'
)
}
return response
diff --git a/web/default/src/features/system-settings/auth/passkey-section.tsx b/web/default/src/features/system-settings/auth/passkey-section.tsx
index fdd54ccf..63b88c53 100644
--- a/web/default/src/features/system-settings/auth/passkey-section.tsx
+++ b/web/default/src/features/system-settings/auth/passkey-section.tsx
@@ -187,9 +187,11 @@ export function PasskeySection(props: PasskeySectionProps) {
return (