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
@@ -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 })