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
@@ -99,8 +99,26 @@ export default function InboundInfoModal({
}
}, [clientStats, t]);
useEffect(() => {
if (!open || !dbInbound) return;
// The panel's contents are a pure function of the props, so they are adopted
// during render; only the IP lookup below stays asynchronous.
const [syncedProps, setSyncedProps] = useState<{
dbInbound: typeof dbInbound;
clientIndex: typeof clientIndex;
nodeAddress: typeof nodeAddress;
subSettings: typeof subSettings;
ipLimitEnable: typeof ipLimitEnable;
} | null>(null);
if (
open &&
dbInbound &&
(syncedProps === null ||
syncedProps.dbInbound !== dbInbound ||
syncedProps.clientIndex !== clientIndex ||
syncedProps.nodeAddress !== nodeAddress ||
syncedProps.subSettings !== subSettings ||
syncedProps.ipLimitEnable !== ipLimitEnable)
) {
setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable });
const info = buildInboundInfo(dbInbound);
setInbound(info);
setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
@@ -189,7 +207,16 @@ export default function InboundInfoModal({
}
});
}
}, [open, dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable, t]);
}
// The expiry tag colours against the current time; a state-backed clock keeps
// render pure and still refreshes the tag while the modal stays open.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
const id = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(id);
}, [open]);
const isEnable = useMemo(() => {
if (clientSettings) return !!clientSettings.enable;
@@ -202,9 +229,9 @@ export default function InboundInfoModal({
const used = (clientStats.up ?? 0) + (clientStats.down ?? 0);
if (total > 0 && used >= total) return true;
const expiry = clientSettings.expiryTime ?? 0;
if (expiry > 0 && Date.now() >= expiry) return true;
if (expiry > 0 && now >= expiry) return true;
return false;
}, [clientStats, clientSettings]);
}, [clientStats, clientSettings, now]);
const remainingStats = useMemo(() => {
if (!clientStats || !clientSettings) return '-';
@@ -212,10 +239,12 @@ export default function InboundInfoModal({
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
}, [clientStats, clientSettings]);
const wgPubKey = useMemo(() => {
if (!dbInbound?.isWireguard || !inbound?.settings?.secretKey) return '';
return Wireguard.generateKeypair(inbound.settings.secretKey as string).publicKey;
}, [dbInbound?.isWireguard, inbound?.settings?.secretKey]);
const isWireguard = !!dbInbound?.isWireguard;
const wgSecretKey = inbound?.settings?.secretKey as string | undefined;
const wgPubKey = useMemo(
() => (isWireguard && wgSecretKey ? Wireguard.generateKeypair(wgSecretKey).publicKey : ''),
[isWireguard, wgSecretKey],
);
const formatLastOnline = useCallback(
(email: string) => {
@@ -438,9 +467,7 @@ export default function InboundInfoModal({
</td>
<td>
{(clientSettings?.expiryTime ?? 0) > 0 ? (
<Tag
color={ColorUtils.usageColor(Date.now(), expireDiff, clientSettings!.expiryTime!)}
>
<Tag color={ColorUtils.usageColor(now, expireDiff, clientSettings!.expiryTime!)}>
{IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)}
</Tag>
) : (clientSettings?.expiryTime ?? 0) < 0 ? (