chore(frontend): update dependencies and adapt to oxlint 1.79

npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7
and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11,
and npm would not move either. Neither npm update, a targeted install, nor
--package-lock-only broke the cycle, so node_modules and package-lock.json
were regenerated from scratch (601 packages, 0 vulnerabilities).

oxlint 1.79.0 then promoted five React Compiler rules into the correctness
category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree,
so nothing in our code changed - the rule set grew. They are fixed rather
than suppressed:

- refs (31): latest-value ref writes moved out of render into an effect.
  onlineClientsRef turned out to be write-only and is gone; expireDiffRef
  and trafficDiffRef were replaced by reading the values directly.
- set-state-in-effect (55): reset-on-open modals now adjust state during
  render; where an effect mixed a synchronous reset with an async fetch, the
  reset moved to render and the effect kept only the request. useMediaQuery
  became useSyncExternalStore.
- preserve-manual-memoization (11): optional-chained deps the compiler cannot
  match, hoisted to locals or dropped where the memo wrapped a string concat.
- purity (3): Date.now() in render replaced by a state-backed clock, which
  also refreshes the expiry tag every 60s instead of freezing it until the
  next unrelated re-render.
- immutability (1): applyClientStatsEvent merged websocket traffic into
  DBInbound rows in place; it now rebuilds only the rows it touches.

Two things fell out of that. clientCount is derived with useMemo instead of
an imperative rebuildClientCount() called from five sites, which also fixes a
staleness bug where changing the expiry or traffic threshold left the counts
alone until some later rebuild. statsVersion existed only to force a
re-render after an in-place mutation, is meaningless now that rows are
replaced, and nothing read it, so it is removed.

Also adds a lint:fix script - oxlint --fix was previously only reachable
through the lint-staged hook.
This commit is contained in:
Sanaei
2026-08-19 17:48:28 +02:00
parent 92fb94d856
commit b9eda09da9
54 changed files with 1497 additions and 1408 deletions
@@ -33,15 +33,21 @@ export default function PromptModal({
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const inputRef = useRef<InputRef | null>(null);
const [openedWith, setOpenedWith] = useState<string | null>(null);
const openKey = open ? `${type}\u0000${initialValue}` : null;
if (openKey !== openedWith) {
setOpenedWith(openKey);
if (open) setValue(initialValue);
}
useEffect(() => {
if (open) {
setValue(initialValue);
setTimeout(() => {
if (type === 'textarea') textareaRef.current?.focus();
else inputRef.current?.focus();
}, 50);
}
}, [open, initialValue, type]);
if (!open) return;
const id = setTimeout(() => {
if (type === 'textarea') textareaRef.current?.focus();
else inputRef.current?.focus();
}, 50);
return () => clearTimeout(id);
}, [open, type]);
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
if (type !== 'textarea' && e.key === 'Enter') {
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { Button, Input, Modal, Tabs, message } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
@@ -35,9 +35,12 @@ export default function TextModal({
const [messageApi, messageContextHolder] = message.useMessage();
const [activeKey, setActiveKey] = useState('');
useEffect(() => {
if (open && tabs && tabs.length > 0) setActiveKey(tabs[0].key);
}, [open, tabs]);
// Reset on the way out so the next open starts on the first tab; activeTab
// falls back to tabs[0] whenever activeKey no longer matches.
const close = useCallback(() => {
setActiveKey('');
onClose();
}, [onClose]);
const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0];
const activeContent = activeTab ? activeTab.content : content;
@@ -46,7 +49,7 @@ export default function TextModal({
const ok = await ClipboardManager.copyText(activeContent || '');
if (ok) {
messageApi.success(t('copied'));
onClose();
close();
}
}
@@ -61,7 +64,7 @@ export default function TextModal({
<Modal
open={open}
title={title}
onCancel={onClose}
onCancel={close}
destroyOnHidden
footer={
<>
@@ -749,8 +749,12 @@ const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
function BrowserDemo(props: GeoBrowserModalProps) {
const [open, setOpen] = useState(props.open);
const [value, setValue] = useState(props.value);
useEffect(() => setOpen(props.open), [props.open]);
useEffect(() => setValue(props.value), [props.value]);
const [synced, setSynced] = useState({ open: props.open, value: props.value });
if (synced.open !== props.open || synced.value !== props.value) {
setSynced({ open: props.open, value: props.value });
setOpen(props.open);
setValue(props.value);
}
return (
<Space orientation="vertical" size={12}>
<Space size={8}>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
@@ -69,13 +69,16 @@ export default function GeoBrowserModal({
const [entryPage, setEntryPage] = useState(1);
const [selected, setSelected] = useState<string[]>([]);
const knownRef = useRef<Set<string>>(new Set());
const seededFilesRef = useRef<Set<string>>(new Set());
const [known, setKnown] = useState<Set<string>>(() => new Set());
const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
const filesQuery = useGeodataFiles(open);
const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
const activeFile = files.find((candidate) => candidate.name === file);
const fileKind: GeoKind = activeFile?.kind ?? kind;
const activeFile = useMemo(
() => files.find((candidate) => candidate.name === file),
[files, file],
);
const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
const categoriesQuery = useGeodataCategories(file, '', open && !!file);
// While a newly picked database loads, the query still serves the previous
@@ -117,28 +120,27 @@ export default function GeoBrowserModal({
return () => window.clearTimeout(handle);
}, [entryQuery, entryFilter]);
useEffect(() => {
if (!open) return;
knownRef.current = new Set();
seededFilesRef.current = new Set();
setCategoryQuery('');
setEntryQuery('');
setEntryFilter('');
setActiveCode(undefined);
setEntryPage(1);
setSelected([]);
}, [open]);
useEffect(() => {
if (!open || file || files.length === 0) return;
// Opening, picking the default database and seeding the selection are all
// render-time adjustments — an effect would paint the previous state first.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setKnown(new Set());
setSeededFiles(new Set());
setCategoryQuery('');
setEntryQuery('');
setEntryFilter('');
setActiveCode(undefined);
setEntryPage(1);
setSelected([]);
}
} else if (open && !file && files.length > 0) {
setFile(preferredFile(files, kind));
}, [open, file, files, kind]);
useEffect(() => {
if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return;
} else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
for (const token of tokens) knownRef.current.add(token);
seededFilesRef.current.add(file);
setKnown(new Set([...known, ...tokens]));
setSeededFiles(new Set(seededFiles).add(file));
const fromValue = selectionFromValue(value, new Set(tokens));
if (fromValue.length > 0) {
setSelected((previous) => [
@@ -146,7 +148,7 @@ export default function GeoBrowserModal({
...fromValue.filter((token) => !previous.includes(token)),
]);
}
}, [open, file, categories, fileKind, value]);
}
const visibleCategories = useMemo(() => {
const query = categoryQuery.trim().toLowerCase();
@@ -276,7 +278,7 @@ export default function GeoBrowserModal({
title={t('pages.xray.geoBrowser.title')}
width={880}
onCancel={onClose}
onOk={() => onApply(mergeSelection(value, selected, knownRef.current))}
onOk={() => onApply(mergeSelection(value, selected, known))}
okText={t('pages.xray.geoBrowser.apply')}
cancelText={t('close')}
className="geo-browser-modal"
@@ -216,7 +216,11 @@ const withGeodata: Decorator = function GeodataBackend(Story) {
function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
const [current, setCurrent] = useState(value);
useEffect(() => setCurrent(value), [value]);
const [synced, setSynced] = useState(value);
if (synced !== value) {
setSynced(value);
setCurrent(value);
}
return (
<Space orientation="vertical" size={4} style={{ width: 460 }}>
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
@@ -49,13 +49,21 @@ export default function GeoTokenInput({
const validate = useValidateGeoTokens();
const { mutateAsync } = validate;
useEffect(() => {
const tokens = parseTokens(value);
if (tokens.length === 0) {
// An empty field has nothing to validate, so it clears during render rather
// than waiting a commit for the effect to catch up.
const isEmpty = parseTokens(value).length === 0;
const [wasEmpty, setWasEmpty] = useState(isEmpty);
if (isEmpty !== wasEmpty) {
setWasEmpty(isEmpty);
if (isEmpty) {
setIssues([]);
setCheckFailed(false);
return;
}
}
useEffect(() => {
const tokens = parseTokens(value);
if (tokens.length === 0) return;
let cancelled = false;
const timer = setTimeout(() => {
mutateAsync({ tokens, kind })
@@ -1,4 +1,4 @@
import { Suspense, useEffect, useState, type ReactNode } from 'react';
import { Suspense, useState, type ReactNode } from 'react';
import { Spin } from 'antd';
interface LazyMountProps {
@@ -13,9 +13,7 @@ interface LazyMountProps {
// on heavy list pages to keep the initial bundle small.
export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
const [mounted, setMounted] = useState(when);
useEffect(() => {
if (when && !mounted) setMounted(true);
}, [when, mounted]);
if (when && !mounted) setMounted(true);
if (!mounted) return null;
return <Suspense fallback={fallback}>{children}</Suspense>;
}
+4 -2
View File
@@ -268,9 +268,11 @@ export default function Sparkline(props: SparklineProps) {
extrema,
};
const cfgRef = useRef(cfg);
cfgRef.current = cfg;
const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
useEffect(() => {
cfgRef.current = cfg;
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
});
const containerRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(null);