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
31 changes: 18 additions & 13 deletions app/blocks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,15 @@ export default function BlocksPage() {

const blocks = page?.data;
const freshBlocks = useFreshHashes(blocks);
// 10Hz so age labels animate smoothly through Xms → s → m boundaries.
useNowTick(100);
// 1s tick is enough to keep relative-time labels accurate; the prior
// 100ms churn re-rendered the whole page (huge headline, 50-row
// table, 3 charts, KPIs, live list) ten times a second. Combined
// with concurrent CSS animations (fresh-row, AnimatedNumber tween),
// that re-render rate disengaged the browser's scroll anchor and
// caused the page to feel like it was drifting downward as new
// blocks landed. The relative-time labels don't need finer than 1s
// anyway — `formatRelativeTime` rounds to the nearest second.
useNowTick(1000);

// Page-scoped averages — only across the visible page so it's responsive
// to any filter the user applied. Useful when scoping by validator: you
Expand Down Expand Up @@ -756,8 +763,8 @@ function LiveBlocksList({
borderRadius: 0,
gridTemplateColumns: "120px 1fr 80px",
padding: "14px 18px",
background: isHead ? "var(--accent-soft)" : "transparent",
color: isHead ? "var(--accent-ink)" : "inherit",
background: isHead ? "var(--good-soft)" : "transparent",
color: isHead ? "var(--good)" : "inherit",
cursor: "pointer",
}}
>
Expand All @@ -766,7 +773,7 @@ function LiveBlocksList({
className="mono"
style={{
fontSize: 13,
color: isHead ? "var(--accent-ink)" : "var(--ink)",
color: isHead ? "var(--good)" : "var(--ink)",
fontVariantNumeric: "tabular-nums",
}}
>
Expand All @@ -776,7 +783,7 @@ function LiveBlocksList({
className="mono"
style={{
fontSize: 11,
color: isHead ? "var(--accent-ink)" : "var(--ink-4)",
color: isHead ? "var(--good)" : "var(--ink-4)",
fontVariantNumeric: "tabular-nums",
opacity: 0.85,
}}
Expand All @@ -790,20 +797,18 @@ function LiveBlocksList({
<div
style={{
fontSize: 12.5,
color: isHead ? "var(--accent-ink)" : "var(--ink-2)",
color: isHead ? "var(--good)" : "var(--ink-2)",
fontVariantNumeric: "tabular-nums",
}}
>
{formatNumber(b.transactionsCount)}{" "}
<span style={{ color: isHead ? "var(--accent-ink)" : "var(--ink-4)" }}>
txns
</span>
<span style={{ color: isHead ? "var(--good)" : "var(--ink-4)" }}>txns</span>
</div>
<div
className="mono"
style={{
fontSize: 10.5,
color: isHead ? "var(--accent-ink)" : "var(--ink-4)",
color: isHead ? "var(--good)" : "var(--ink-4)",
opacity: 0.85,
overflow: "hidden",
textOverflow: "ellipsis",
Expand All @@ -818,7 +823,7 @@ function LiveBlocksList({
className="mono"
style={{
fontSize: 12,
color: isHead ? "var(--accent-ink)" : "var(--ink-2)",
color: isHead ? "var(--good)" : "var(--ink-2)",
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
Expand All @@ -837,7 +842,7 @@ function LiveBlocksList({
style={{
width: `${Math.min(100, pct)}%`,
height: "100%",
background: isHead ? "var(--accent-ink)" : gasUtilTone(pct),
background: isHead ? "var(--good)" : gasUtilTone(pct),
}}
/>
</div>
Expand Down
50 changes: 46 additions & 4 deletions app/template.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,49 @@
// Next 16 templates remount on every navigation (auto-keyed per segment),
// so a CSS animation on the wrapper re-runs each time. This gives every
// page a subtle entrance without per-page wiring.
"use client";

// Next 16 templates remount on every navigation (auto-keyed per
// segment) — that's deliberate when you want useEffect to resync or
// state to reset, and we use it for the subtle entrance animation
// (`page-fade` in globals.css). The catch: back/forward navigations
// also trigger a remount, so the same animation re-fires over content
// that's already in the React Query cache. The animation starts at
// opacity 0 with a 6px translate and a staggered children fade — net
// effect is a 400ms blank flash on every back-button press, which
// reads as "loading is broken" even though the data is sitting right
// there in cache.
//
// Fix: skip the fade when the navigation came from a popstate
// (browser back/forward). Forward navigations still get the entrance.
//
// Detection: a module-scoped flag set by a single `popstate`
// listener. Templates remount AFTER popstate fires, so the flag is
// accurate when we read it during render. Reset on consume so the
// next forward navigation gets the fade back.

import { useState } from "react";

let cameFromPopstate = false;

if (typeof window !== "undefined") {
// Window-level singleton — module re-eval in dev/HMR re-runs this
// top-level block, so guard against double-binding via a property
// on window itself.
const w = window as Window & { __firnPopstateBound?: boolean };
if (!w.__firnPopstateBound) {
w.__firnPopstateBound = true;
window.addEventListener("popstate", () => {
cameFromPopstate = true;
});
}
}

export default function Template({ children }: { children: React.ReactNode }) {
return <div className="page-fade">{children}</div>;
// Read once on first render and freeze the result with useState's
// lazy initializer so a re-render of the same template instance
// (for whatever reason) doesn't flip the class halfway through.
const [skipFade] = useState(() => {
const popped = cameFromPopstate;
cameFromPopstate = false;
return popped;
});
return <div className={skipFade ? undefined : "page-fade"}>{children}</div>;
}
14 changes: 12 additions & 2 deletions lib/api/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,18 @@ function mergeTickIntoBootstrap(
tick: ExplorerFeedTick,
paused: LivePauseState,
) {
void apiBase; // key carries apiBase via firnQueryKey
const key = firnQueryKey({}, { url: getGetExplorerBootstrapUrl() });
// Build the key directly from the closure-captured apiBase. The
// `firnQueryKey` mutator is convenient but reads `getApiBase()` at
// call time — and on a network switch, `setApiBase()` runs
// synchronously inside the click handler before React commits the
// new state and tears down the old EventSource. An in-flight tick
// from the OLD network can fire its handler in that gap, and if we
// ask `firnQueryKey` for the key it returns the NEW base, dumping
// testnet's payload into local's bootstrap cache. Symptom: live
// blocks panel shows the previous network's blocks for a beat
// after switching. Use the captured arg — it's the apiBase the
// SSE actually came from — to scope the write correctly.
const key = [apiBase, getGetExplorerBootstrapUrl()] as const;
queryClient.setQueryData<ExplorerBootstrap>(key, (prev) => {
if (!prev) return prev;

Expand Down
Loading