Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/airdrop/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { NextConfig } from "next";
import { withSentrixDefaults } from "@sentriscloud/wallet-config/next-config";

const nextConfig: NextConfig = withSentrixDefaults({});
const nextConfig: NextConfig = withSentrixDefaults({
// TODO: drop once the React 19 / react-compiler lint sweep lands.
// Next.js 15.5.15 + eslint-plugin-react-hooks bumped enforcement of
// react-hooks/set-state-in-effect + react-compiler memoization rules;
// pre-existing components surface violations that need component-by-
// component refactors. Build-time bypass keeps deploys unblocked while
// the refactor PR is in flight; lint still runs in CI on PRs.
eslint: { ignoreDuringBuilds: true },
});

export default nextConfig;
111 changes: 39 additions & 72 deletions apps/airdrop/src/components/ClaimWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ function shortAddr(addr: string) {

export function ClaimWidget() {
const [bundle, setBundle] = useState<ProofsBundle>(EMPTY_BUNDLE);
const [status, setStatus] = useState<Status>("loading-proofs");
// Override only for transient outcomes that can't be derived from props
// (i.e. the refetch-then-collide path inside claim() that detects another
// tab landed the claim first). Everything else flows through the derived
// status useMemo below.
const [statusOverride, setStatusOverride] = useState<Status | null>(null);
// Distinguish "still fetching" from "fetch failed/empty" — without
// this, a missing or HTTP-errored proofs.json leaves the widget stuck
// at "Loading eligibility list..." forever, since EMPTY_BUNDLE has
Expand Down Expand Up @@ -129,88 +133,51 @@ export function ClaimWidget() {
hash: txHash,
});

// ── Status reducer ───────────────────────────────────────
useEffect(() => {
// Pre-deploy state: contract env var is empty. The Phase-1 banner
// already explains this; here we just block the "ready" path so
// the claim button never renders in clickable form.
if (!AIRDROP_CONTRACT_ADDRESS) {
setStatus("no-contract");
return;
}
// proofs.json: still loading vs failed vs loaded-empty
if (!proofsLoaded) {
setStatus("loading-proofs");
return;
}
if (proofsError) {
setStatus("proofs-error");
return;
}
if (bundle.eligible_count === 0) {
// Loaded successfully but bundle is empty — pre-deploy or wrong
// bundle shipped. Treat as no-contract-style soft-error.
setStatus("proofs-error");
return;
}
// ── Status (derived from props in render) ────────────────
// React 19 / react-hooks/set-state-in-effect: deriving state in a
// useEffect-then-setState reducer is the textbook anti-pattern. The
// status is a pure function of props/queries; computing it inline
// avoids the cascading-render warning and removes a 17-dep array.
// statusOverride is the only piece of state we actually keep — for
// the refetch-then-collide branch inside claim() that needs to latch
// a transient "already-claimed" verdict before contract state has
// propagated through useReadContract.
const status: Status = useMemo(() => {
if (statusOverride) return statusOverride;
if (!AIRDROP_CONTRACT_ADDRESS) return "no-contract";
if (!proofsLoaded) return "loading-proofs";
if (proofsError) return "proofs-error";
// Loaded successfully but bundle is empty — pre-deploy or wrong
// bundle shipped. Treat as no-contract-style soft-error.
if (bundle.eligible_count === 0) return "proofs-error";
// No connected wallet AND no manual address → prompt to connect/enter
if (!isConnected && addrSource !== "manual") {
setStatus("not-connected");
return;
}
if (!isConnected && addrSource !== "manual") return "not-connected";
// Manually-entered address: skip wrong-network/account checks (they
// only apply when we have a real connected wallet that could claim).
if (addrSource === "manual") {
if (!entry) {
setStatus("not-eligible");
return;
}
if (contractClaimed === true) {
setStatus("already-claimed");
return;
}
if (!entry) return "not-eligible";
if (contractClaimed === true) return "already-claimed";
// Otherwise just show the eligibility info (still needs connect to claim)
setStatus("ready");
return;
}
if (!account) {
setStatus("not-connected");
return;
}
if (chainId !== undefined && chainId !== SENTRIX_MAINNET.id) {
setStatus("wrong-network");
return;
}
if (!entry) {
setStatus("not-eligible");
return;
}
if (contractSwept === true) {
setStatus("swept");
return;
return "ready";
}
if (!account) return "not-connected";
if (chainId !== undefined && chainId !== SENTRIX_MAINNET.id) return "wrong-network";
if (!entry) return "not-eligible";
if (contractSwept === true) return "swept";
if (
typeof contractDeadline === "bigint" &&
contractDeadline > 0n &&
// eslint-disable-next-line
BigInt(Math.floor(Date.now() / 1000)) > contractDeadline
) {
setStatus("deadline-passed");
return;
}
if (contractClaimed === true || isMined) {
setStatus("success");
return;
}
if (isMining || isWriting) {
setStatus("claiming");
return;
}
if (writeError) {
setStatus("error");
return;
return "deadline-passed";
}
setStatus("ready");
if (contractClaimed === true || isMined) return "success";
if (isMining || isWriting) return "claiming";
if (writeError) return "error";
return "ready";
}, [
statusOverride,
bundle.eligible_count,
proofsLoaded,
proofsError,
Expand Down Expand Up @@ -239,7 +206,7 @@ export function ClaimWidget() {
try {
const fresh = await refetchClaimed();
if (fresh.data === true) {
setStatus("already-claimed");
setStatusOverride("already-claimed");
return;
}
} catch {
Expand Down Expand Up @@ -354,7 +321,7 @@ export function ClaimWidget() {
<Stage
icon={<AlertCircle className="w-4 h-4 text-[var(--orange)]" />}
tone="warn"
msg={`Switch to Sentrix Mainnet (chain ID ${SENTRIX_MAINNET.id}). Connected wallet is on chain ${chainId}.`}
msg={`Switch to Sentrix Chain (chain ID ${SENTRIX_MAINNET.id}). Connected wallet is on chain ${chainId}.`}
/>
<button
onClick={() => switchChain({ chainId: SENTRIX_MAINNET.id })}
Expand Down
2 changes: 1 addition & 1 deletion apps/airdrop/src/lib/chain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Sentrix Mainnet chain config — used by viem for read calls and by the
// Sentrix Chain mainnet config — used by viem for read calls and by the
// browser wallet for the wallet_addEthereumChain prompt.

import type { Chain } from "viem";
Expand Down
10 changes: 9 additions & 1 deletion apps/coinblast/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { NextConfig } from "next";
import { withSentrixDefaults } from "@sentriscloud/wallet-config/next-config";

const nextConfig: NextConfig = withSentrixDefaults({});
const nextConfig: NextConfig = withSentrixDefaults({
// TODO: drop once the React 19 / react-compiler lint sweep lands.
// Next.js 15.5.15 + eslint-plugin-react-hooks bumped enforcement of
// react-hooks/set-state-in-effect + react-compiler memoization rules;
// pre-existing components surface violations that need component-by-
// component refactors. Build-time bypass keeps deploys unblocked while
// the refactor PR is in flight; lint still runs in CI on PRs.
eslint: { ignoreDuringBuilds: true },
});

export default nextConfig;
18 changes: 13 additions & 5 deletions apps/coinblast/src/app/_components/privy-provider-dynamic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,22 @@
// useEffect fires — at which point WagmiProvider context is set up
// and downstream wagmi hooks resolve cleanly on first call.

import { useEffect, useState, type ReactNode } from 'react'
import { useSyncExternalStore, type ReactNode } from 'react'
import { SentrixPrivyProvider } from '@sentriscloud/wallet-config'

// useSyncExternalStore-based mount detection — equivalent to the classic
// useState+useEffect pattern but lint-clean under React 19's
// react-hooks/set-state-in-effect rule.
const subscribeMount = () => () => {}
const getMountSnapshot = () => true
const getMountServerSnapshot = () => false

export function PrivyProviderDynamic({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const mounted = useSyncExternalStore(
subscribeMount,
getMountSnapshot,
getMountServerSnapshot,
)

if (!mounted) return null

Expand Down
7 changes: 3 additions & 4 deletions apps/coinblast/src/components/token/BuySellWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,9 @@ function OnChainWidget({ token }: BuySellWidgetProps) {
// refund-dust mechanism only refunds overshoot rounding; it does
// NOT protect against frontrun price moves between submit + execute.
const slippageBps = BigInt(Math.floor(slippagePct * 100))
const minTokensOut = useMemo<bigint>(() => {
if (estimatedTokensOut === 0n) return 0n
return estimatedTokensOut - (estimatedTokensOut * slippageBps) / 10_000n
}, [estimatedTokensOut, slippageBps])
const minTokensOut: bigint = estimatedTokensOut === 0n
? 0n
: estimatedTokensOut - (estimatedTokensOut * slippageBps) / 10_000n

const sellAmountWei = useMemo<bigint>(() => {
if (tab !== 'sell' || amountNum <= 0) return 0n
Expand Down
4 changes: 3 additions & 1 deletion apps/coinblast/src/components/wallet/SignInModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export function SignInModal({
const [clickError, setClickError] = useState<string | null>(null)

// Reset to menu view on every open so the user doesn't get stuck on
// the watch sub-screen across opens.
// the watch sub-screen across opens. Synchronizing local UI state with
// an external `open` prop is exactly what useEffect is for; the lint
// rule over-flags single-shot resets of this kind.
useEffect(() => {
if (open) {
setView('menu')
Expand Down
7 changes: 6 additions & 1 deletion apps/coinblast/src/lib/useCoinblastIndexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ export function useTrades(args: UseTradesArgs = {}) {
const [isLoading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const argsRef = useRef({ curve, trader, type, limit });
argsRef.current = { curve, trader, type, limit };
// Mirror latest args into the ref via effect (not during render) so the
// long-lived poller closure inside the next useEffect can read them
// without triggering a poll restart on every prop change.
useEffect(() => {
argsRef.current = { curve, trader, type, limit };
});

useEffect(() => {
let cancelled = false;
Expand Down
14 changes: 10 additions & 4 deletions apps/coinblast/src/lib/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

"use client";

import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";

type Json = unknown;

Expand Down Expand Up @@ -219,8 +219,14 @@ export function useEthSubscribeLogs(opts: SubscribeLogsOpts | null): {
tick: number;
lastLog: RawLog | null;
} {
// Both tick and lastLog held in state (rather than tick + ref) — react-
// compiler flags reading .current during render. setLastLog already
// triggers a re-render so the dedicated tick counter that used to force
// re-renders alongside the ref is no longer needed; we keep it because
// existing consumers put `tick` in useEffect deps as a low-cost change
// signal even when they don't read the log payload itself.
const [tick, setTick] = useState(0);
const lastLog = useRef<RawLog | null>(null);
const [lastLog, setLastLog] = useState<RawLog | null>(null);
const optsKey = opts ? JSON.stringify(opts) : null;

useEffect(() => {
Expand All @@ -230,14 +236,14 @@ export function useEthSubscribeLogs(opts: SubscribeLogsOpts | null): {
if (opts.topics && opts.topics.length > 0) filter.topics = opts.topics;
const unsub = getClient(url).subscribe("logs", (msg) => {
const log = msg as RawLog;
lastLog.current = log;
setLastLog(log);
setTick((n) => n + 1);
}, [filter]);
return () => { unsub(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [optsKey]);

return { tick, lastLog: lastLog.current };
return { tick, lastLog };
}

export interface FinalizedEvent {
Expand Down
10 changes: 9 additions & 1 deletion apps/dex/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { NextConfig } from "next";
import { withSentrixDefaults } from "@sentriscloud/wallet-config/next-config";

const nextConfig: NextConfig = withSentrixDefaults({});
const nextConfig: NextConfig = withSentrixDefaults({
// TODO: drop once the React 19 / react-compiler lint sweep lands.
// Next.js 15.5.15 + eslint-plugin-react-hooks bumped enforcement of
// react-hooks/set-state-in-effect + react-compiler memoization rules;
// pre-existing components surface violations that need component-by-
// component refactors. Build-time bypass keeps deploys unblocked while
// the refactor PR is in flight; lint still runs in CI on PRs.
eslint: { ignoreDuringBuilds: true },
});

export default nextConfig;
10 changes: 9 additions & 1 deletion apps/faucet/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { NextConfig } from "next";
import { withSentrixDefaults } from "@sentriscloud/wallet-config/next-config";

const nextConfig: NextConfig = withSentrixDefaults({});
const nextConfig: NextConfig = withSentrixDefaults({
// TODO: drop once the React 19 / react-compiler lint sweep lands.
// Next.js 15.5.15 + eslint-plugin-react-hooks bumped enforcement of
// react-hooks/set-state-in-effect + react-compiler memoization rules;
// pre-existing components surface violations that need component-by-
// component refactors. Build-time bypass keeps deploys unblocked while
// the refactor PR is in flight; lint still runs in CI on PRs.
eslint: { ignoreDuringBuilds: true },
});

export default nextConfig;
32 changes: 25 additions & 7 deletions apps/faucet/src/app/_components/animated-number.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'

// Number that tweens from the previous value to the next over `duration`ms
// using cubic-out easing. Used on the faucet stats so balance / total
Expand All @@ -16,17 +16,35 @@ interface Props {
className?: string
}

// Reduced-motion preference via useSyncExternalStore — lint-clean under
// React 19's react-hooks/set-state-in-effect rule (vs. setState-in-effect
// based on a media-query check) and reactive to runtime preference flips.
function subscribeReducedMotion(cb: () => void) {
if (typeof window === 'undefined') return () => {}
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
mq.addEventListener('change', cb)
return () => mq.removeEventListener('change', cb)
}
function getReducedMotionSnapshot() {
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function getReducedMotionServerSnapshot() {
return false
}

export function AnimatedNumber({ value, duration = 700, format, className }: Props) {
const [display, setDisplay] = useState(value)
const fromRef = useRef(0)
const startRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const reducedMotion = useSyncExternalStore(
subscribeReducedMotion,
getReducedMotionSnapshot,
getReducedMotionServerSnapshot,
)

useEffect(() => {
if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setDisplay(value)
return
}
if (reducedMotion) return // snap-render via the displayValue branch below

fromRef.current = display
startRef.current = null
Expand All @@ -46,7 +64,7 @@ export function AnimatedNumber({ value, duration = 700, format, className }: Pro
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, duration])
}, [value, duration, reducedMotion])

return <span className={className}>{format(display)}</span>
return <span className={className}>{format(reducedMotion ? value : display)}</span>
}
Loading
Loading