From 436234ffd3d084f6953810297a78e8003862e64c Mon Sep 17 00:00:00 2001 From: ShadowOfTime1 <129950615+ShadowOfTime1@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:17:07 +0000 Subject: [PATCH] Fix the pre-existing eslint errors and enable lint in CI pnpm lint now exits 0 (18 errors across 12 files, no new warnings), and CI runs it before test and build. - Mip3HeroSection: one timer per frame from a small nextHeroTick timeline. The old effect scheduled the restart and then set running false, whose cleanup cancelled that same timer, so the counter played once and stopped. It now holds the last frame for 3s and starts over, with the same timings. - LanguageProvider, ExplainModeProvider and InfraContent read the saved locale, explain mode and URL-hash tab through useSyncExternalStore. The server and the hydrating client render the defaults and the saved value applies right after hydration, so the first client render matches the server markup. InfraContent keeps a hash-restored tab mounted after switching away, as before. - Auto-play in MemoryPoolSection, JumpdestSection and BundlerComparisonSection stops in the timer callback that reaches the last step instead of from the effect body; state and timing are unchanged. - ConstructionSection renders the reduced-motion final frame from the preference instead of writing it into state after hydration. - OraclePlayground resets when a different token is picked, in the click handler. AnalyzerPage guards its one-time ?q= auto-analyze with a ref and lists setTab so the compiler can keep the memoization. SpamHeroSection draws its first random block one task after hydration. - TransactionTimelineSection uses the useInView ref directly instead of writing to it from a ref callback. CollisionSection wraps its // labels in braces. Tests cover the hero timeline looping and the server render ignoring a saved preference, with window and localStorage present. Closes #17 --- .github/workflows/ci.yml | 8 +- src/components/analyzer/AnalyzerPage.tsx | 11 +-- src/components/btx/ConstructionSection.tsx | 36 +++++---- src/components/infra/InfraContent.tsx | 53 +++++++----- src/components/infra/OraclePlayground.tsx | 15 ++-- src/components/mip3/MemoryPoolSection.tsx | 14 ++-- src/components/mip3/Mip3HeroSection.tsx | 35 +++----- src/components/mip3/heroTimeline.test.ts | 31 +++++++ src/components/mip3/heroTimeline.ts | 25 ++++++ .../mip4/BundlerComparisonSection.tsx | 23 +++--- .../mip4/TransactionTimelineSection.tsx | 9 +-- src/components/mip7/CollisionSection.tsx | 6 +- src/components/mip7/JumpdestSection.tsx | 14 ++-- .../spam-mev/ExplainModeContext.tsx | 33 ++++---- src/components/spam-mev/SpamHeroSection.tsx | 10 ++- src/i18n/LanguageContext.tsx | 81 ++++++++----------- src/lib/hydration.test.ts | 70 ++++++++++++++++ src/lib/stored-preference.ts | 27 +++++++ 18 files changed, 324 insertions(+), 177 deletions(-) create mode 100644 src/components/mip3/heroTimeline.test.ts create mode 100644 src/components/mip3/heroTimeline.ts create mode 100644 src/lib/hydration.test.ts create mode 100644 src/lib/stored-preference.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47d174e..42d23ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,5 @@ -# Test and build every PR and every push to master, so pool contributions get -# checked automatically instead of a maintainer running the suite by hand. -# No lint gate yet: the tree has ~20 pre-existing eslint errors, and a gate -# that fails on day one teaches everyone to ignore it. Add `pnpm lint` here -# once those are fixed (tracked in the issues). +# Lint, test and build every PR and every push to master, so pool contributions +# get checked automatically instead of a maintainer running the suite by hand. name: CI @@ -25,5 +22,6 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm lint - run: pnpm test - run: pnpm build diff --git a/src/components/analyzer/AnalyzerPage.tsx b/src/components/analyzer/AnalyzerPage.tsx index 0209b4a..3a19c2e 100644 --- a/src/components/analyzer/AnalyzerPage.tsx +++ b/src/components/analyzer/AnalyzerPage.tsx @@ -146,7 +146,8 @@ export default function AnalyzerPage() { const [result, setResult] = useState(null); const [selectedContract, setSelectedContract] = useState(0); const [selectedSlots, setSelectedSlots] = useState>(new Set()); - const [autoAnalyzed, setAutoAnalyzed] = useState(false); + // Not state: it only guards the one-time auto-analyze and never renders. + const autoAnalyzed = useRef(false); // Trace state const [selectedFunction, setSelectedFunction] = useState(""); @@ -256,16 +257,16 @@ export default function AnalyzerPage() { analyzeProgress.finish(); setLoading(false); }, - [analyzeProgress] + [analyzeProgress, setTab] ); // Auto-analyze when navigated with ?q= param useEffect(() => { - if (queryInput && !autoAnalyzed) { - setAutoAnalyzed(true); + if (queryInput && !autoAnalyzed.current) { + autoAnalyzed.current = true; handleAnalyze(); } - }, [queryInput, autoAnalyzed, handleAnalyze]); + }, [queryInput, handleAnalyze]); const handleSelectContract = useCallback( (idx: number) => { diff --git a/src/components/btx/ConstructionSection.tsx b/src/components/btx/ConstructionSection.tsx index dad320c..c89df45 100644 --- a/src/components/btx/ConstructionSection.tsx +++ b/src/components/btx/ConstructionSection.tsx @@ -8,6 +8,8 @@ import Hint from "./Hint"; const COMMITTEE_N = 5; const BATCH = 5; +const ALL_BATCH: boolean[] = new Array(BATCH).fill(true); +const ALL_COMMITTEE: boolean[] = new Array(COMMITTEE_N).fill(true); export default function ConstructionSection() { const { ref, isVisible } = useInView(0.1); @@ -208,6 +210,13 @@ function CommitteeAnimation() { const playingRef = useRef(false); const timersRef = useRef[]>([]); const nextDotId = useRef(0); + // Reduced motion shows the final frame. The preference is only known after + // hydration, so it is applied here at render time instead of being written + // into state from an effect. + const ctShown = reduced ? ALL_BATCH : ctVisible; + const serversShown = reduced ? ALL_COMMITTEE : serverOn; + const ptShown = reduced ? ALL_BATCH : ptVisible; + const dotsShown = reduced ? [] : dots; const cx = 320; const cy = 140; @@ -345,14 +354,9 @@ function CommitteeAnimation() { const el = svgRef.current; if (!el) return; if (reduced) { - // The initializers above ran during hydration, when the media query - // isn't known yet (reduced=false) — force the final frame here. + // The final frame is rendered from `reduced` above; just stop playback. clearTimers(); playingRef.current = false; - setCtVisible(new Array(BATCH).fill(true)); - setServerOn(new Array(COMMITTEE_N).fill(true)); - setPtVisible(new Array(BATCH).fill(true)); - setDots([]); return; } const io = new IntersectionObserver( @@ -397,7 +401,7 @@ function CommitteeAnimation() { {cts.map((c, i) => ( σ{i + 1} @@ -487,7 +491,7 @@ function CommitteeAnimation() { {pts.map((p, i) => ( ))} - {dots.map((d) => ( + {dotsShown.map((d) => ( ))} @@ -582,6 +586,9 @@ function FftComparison() { reduced ? btxActiveOrder.length : 0, ); + const naiveShown = reduced ? 64 : naiveCount; + const btxShown = reduced ? btxActiveOrder.length : btxCount; + useEffect(() => { if (reduced) { naiveRefs.current.forEach((el) => { @@ -597,8 +604,7 @@ function FftComparison() { el.style.opacity = "0.95"; } }); - setNaiveCount(64); - setBtxCount(btxActiveOrder.length); + // The counts are rendered from `reduced` below. return; } @@ -757,11 +763,11 @@ function FftComparison() { > Without FFT:{" "} - {naiveCount} / 64 + {naiveShown} / 64 With FFT:{" "} - {btxCount} / ~ + {btxShown} / ~ {btxActiveOrder.length} diff --git a/src/components/infra/InfraContent.tsx b/src/components/infra/InfraContent.tsx index 1957625..98b02ac 100644 --- a/src/components/infra/InfraContent.tsx +++ b/src/components/infra/InfraContent.tsx @@ -1,7 +1,7 @@ "use client"; import { motion } from "framer-motion"; -import { useState, useEffect } from "react"; +import { useState, useSyncExternalStore } from "react"; import OraclePlayground from "./OraclePlayground"; import IndexerPlayground from "./IndexerPlayground"; import SwapPlayground from "./SwapPlayground"; @@ -25,25 +25,40 @@ const CATEGORIES: Category[] = [ { id: "auth", label: "Add Auth", icon: "⊡", ready: false }, ]; +/** The ready category named by the URL hash, if any. */ +export function readHashCategory(): string | null { + const hash = window.location.hash.slice(1); + return CATEGORIES.some((c) => c.id === hash && c.ready) ? hash : null; +} + +export function serverHashCategory(): string | null { + return null; +} + +// The hash is only restored on load, so there is nothing to subscribe to. +const subscribeHash = () => () => {}; + export default function InfraContent() { - const [active, setActive] = useState("oracles"); + // Server and hydration render the default tab; a valid hash applies right + // after hydration. A tab the user picks takes over from there. + const hashCategory = useSyncExternalStore( + subscribeHash, + readHashCategory, + serverHashCategory + ); + const [picked, setPicked] = useState(null); const [visited, setVisited] = useState(() => new Set(["oracles"])); - - // Restore active tab from URL hash on mount - useEffect(() => { - const hash = window.location.hash.slice(1); - const valid = CATEGORIES.find((c) => c.id === hash && c.ready); - if (valid) { - setActive(hash); - setVisited((prev) => new Set(prev).add(hash)); - } - }, []); + const active = picked ?? hashCategory ?? "oracles"; + // The open tab always counts as visited, including one restored from the hash. + const opened = new Set(visited).add(active); const selectCategory = (id: string) => { - setActive(id); + setPicked(id); + // Keep the tab being left mounted too, so a tab restored from the hash + // keeps its state like any other visited one. setVisited((prev) => { - if (prev.has(id)) return prev; - return new Set(prev).add(id); + if (prev.has(id) && prev.has(active)) return prev; + return new Set(prev).add(active).add(id); }); window.history.replaceState(null, "", `#${id}`); }; @@ -104,28 +119,28 @@ export default function InfraContent() { {/* Active playground — lazy mount, keep alive */}
- {visited.has("oracles") && ( + {opened.has("oracles") && (
)} - {visited.has("indexers") && ( + {opened.has("indexers") && (
)} - {visited.has("swaps") && ( + {opened.has("swaps") && (
)} - {visited.has("payments") && ( + {opened.has("payments") && (
diff --git a/src/components/infra/OraclePlayground.tsx b/src/components/infra/OraclePlayground.tsx index c63c3e1..0ecdc1e 100644 --- a/src/components/infra/OraclePlayground.tsx +++ b/src/components/infra/OraclePlayground.tsx @@ -201,6 +201,15 @@ export default function OraclePlayground() { const [error, setError] = useState(null); const latestPriceRef = useRef(null); + // A new token starts from an empty chart; the effect below fetches it. + const selectToken = (i: number) => { + if (i === selectedToken) return; + setSelectedToken(i); + setLoading(true); + setError(null); + setPrices([]); + }; + const token = TOKENS[selectedToken]; // Fetch real prices from RedStone API @@ -231,10 +240,6 @@ export default function OraclePlayground() { } }; - // Reset state for new token - setLoading(true); - setError(null); - setPrices([]); latestPriceRef.current = null; fetchPrice(); @@ -294,7 +299,7 @@ export default function OraclePlayground() { {TOKENS.map((t, i) => (