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) => (