diff --git a/.eslintrc.json b/.eslintrc.json index 3cbf99e..ceb0654 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,10 +23,73 @@ }, "overrides": [ { - "files": ["src/lib/coverage/metadata.ts", "src/lib/coverage/colors.ts", "src/app/global-error.tsx", "src/lib/format.ts"], + "files": [ + "src/lib/coverage/metadata.ts", + "src/lib/coverage/colors.ts", + "src/app/global-error.tsx", + "src/lib/format.ts" + ], "rules": { "no-restricted-syntax": "off" } + }, + { + "files": [ + "src/**/*.{ts,tsx}" + ], + "excludedFiles": [ + "src/lib/realtime/**", + "**/*.test.ts", + "**/*.test.tsx", + "src/lib/coverage/metadata.ts", + "src/lib/coverage/colors.ts", + "src/app/global-error.tsx", + "src/lib/format.ts" + ], + "rules": { + "no-restricted-syntax": [ + "error", + { + "selector": "Literal[value=/^#(?:[0-9a-fA-F]{3,4}){1,2}$/]", + "message": "Hard-coded hex colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)." + }, + { + "selector": "Literal[value=/rgba?\\(/]", + "message": "Hard-coded rgb()/rgba() colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)." + }, + { + "selector": "Literal[value=1e7]", + "message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of the literal 1e7." + }, + { + "selector": "BinaryExpression[operator='**'][right.value=7]", + "message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of 10 ** 7." + }, + { + "selector": "NewExpression[callee.name=/^(WebSocket|EventSource)$/]", + "message": "Do not open sockets/streams directly. Subscribe through src/lib/realtime (realtimeClient) so dedup, backoff, auth and teardown apply. To opt out, add `// eslint-disable-next-line no-restricted-syntax -- `." + }, + { + "selector": "CallExpression[callee.property.name='subscribe'][callee.object.name='realtimeClient']:not(:matches(CallExpression[callee.name=/^use(Effect|LayoutEffect)$/] *))", + "message": "realtimeClient.subscribe must be called inside a useEffect/useLayoutEffect callback so the returned unsubscribe runs on unmount." + } + ], + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "@/lib/realtime/*", + "**/lib/realtime/*", + "./realtime/*" + ], + "message": "Import from the public entry point \"@/lib/realtime\" instead of realtime internals." + } + ] + } + ] + } } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a660d41..00a1db5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,3 +35,8 @@ const wallet = useStoreSelector(walletStore, (s) => s); - Never read `localStorage` during render. Apply persisted state after mount to avoid hydration mismatches. - Review checklist: reject any PR that subscribes to a whole store object. + +## Realtime conventions + +- Subscribe only through `realtimeClient` from `@/lib/realtime`, inside a `useEffect`/`useLayoutEffect`; never `new WebSocket`/`new EventSource` or deep imports like `@/lib/realtime/client` (enforced by ESLint). Escape hatch: `// eslint-disable-next-line -- `. +- Per-topic flags live in `public/flags.json` under `realtime.topics.` (`{ "enabled": boolean, "kill"?: boolean }`). Precedence: kill > in-memory override > `NEXT_PUBLIC_REALTIME_TOPICS_OVERRIDE` > flags.json > disabled. Missing or malformed entries fail closed. diff --git a/public/flags.json b/public/flags.json index f8832b6..41a5d85 100644 --- a/public/flags.json +++ b/public/flags.json @@ -1,5 +1,13 @@ { "flags": { "realtime-oracle": false + }, + "realtime": { + "topics": { + "oracle_update": { "enabled": false }, + "claims": { "enabled": false }, + "pool_stats": { "enabled": false }, + "notifications": { "enabled": false } + } } } diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx index f5a5658..8b47203 100644 --- a/src/components/NotificationCenter.tsx +++ b/src/components/NotificationCenter.tsx @@ -4,6 +4,11 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useNotifications } from "@/hooks/useNotifications"; import type { RefractNotification } from "@/lib/notifications"; +import { VirtualList } from "@/components/ui/VirtualList"; + +/** Above this many items the list is windowed; below it the original markup is unchanged. */ +const VIRTUALIZE_THRESHOLD = 50; +const ROW_HEIGHT = 120; const SEVERITY_ICONS: Record = { info: "ℹ", @@ -47,6 +52,59 @@ export function NotificationCenter() { }; }, [isOpen]); + const renderItem = (item: RefractNotification) => { + const colors = SEVERITY_COLORS[item.severity]; + const icon = SEVERITY_ICONS[item.severity]; + + return ( +
+
+
+ +

{item.title}

+
+ {!item.read && ( + + )} +
+

{item.message}

+ {item.href && ( +
+ { + markAsRead(item.id); + setIsOpen(false); + }} + className="text-[11px] font-semibold text-pm-violet hover:underline" + > + View Details → + +
+ )} +
+ ); + }; + return (
- )} -
-

{item.message}

- {item.href && ( -
- { - markAsRead(item.id); - setIsOpen(false); - }} - className="text-[11px] font-semibold text-pm-violet hover:underline" - > - View Details → - -
- )} - - ); - }) + notifications.length > VIRTUALIZE_THRESHOLD ? ( + n.id} + renderRow={renderItem} + label="Notifications" + /> + ) : ( + notifications.map(renderItem) + ) )} diff --git a/src/components/ui/VirtualList.tsx b/src/components/ui/VirtualList.tsx new file mode 100644 index 0000000..917d063 --- /dev/null +++ b/src/components/ui/VirtualList.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { computeWindow } from "@/lib/virtualWindow"; + +interface VirtualListProps { + items: T[]; + rowHeight: number; + height: number; + getKey: (item: T) => string; + renderRow: (item: T, index: number) => ReactNode; + overscan?: number; + className?: string; + /** Fired when the user is scrolled to the bottom, for reveal-on-scroll logic. */ + onAtBottomChange?: (atBottom: boolean) => void; + label?: string; +} + +/** + * Fixed-row-height windowed list. Renders only the visible slice plus overscan, + * keeps rows in a `list`/`listitem` structure with aria-setsize/posinset so + * screen readers still know the full length, and preserves scroll position when + * items are appended (the container owns scrollTop). + */ +export function VirtualList({ + items, + rowHeight, + height, + getKey, + renderRow, + overscan = 4, + className, + onAtBottomChange, + label, +}: VirtualListProps) { + const ref = useRef(null); + const atBottom = useRef(true); + const [scrollTop, setScrollTop] = useState(0); + + const onScroll = useCallback(() => { + const el = ref.current; + if (!el) return; + setScrollTop(el.scrollTop); + const now = el.scrollHeight - el.scrollTop - el.clientHeight < 2; + if (now !== atBottom.current) { + atBottom.current = now; + onAtBottomChange?.(now); + } + }, [onAtBottomChange]); + + useEffect(() => { + const el = ref.current; + if (el && el.scrollTop > el.scrollHeight) el.scrollTop = 0; + }, [items.length]); + + const w = computeWindow(items.length, rowHeight, scrollTop, height, overscan); + + return ( +
+
+
+ {items.slice(w.start, w.end).map((item, i) => ( +
+ {renderRow(item, w.start + i)} +
+ ))} +
+
+
+ ); +} diff --git a/src/lib/notificationThrottle.test.ts b/src/lib/notificationThrottle.test.ts new file mode 100644 index 0000000..185d0b3 --- /dev/null +++ b/src/lib/notificationThrottle.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { ToastLimiter, type ToastItem } from "./notificationThrottle"; + +const item = (id: string, priority: ToastItem["priority"] = "normal"): ToastItem => ({ id, priority, title: id }); + +function make(extra = {}) { + let t = new Date(2026, 0, 1, 12).getTime(); + const limiter = new ToastLimiter({ maxConcurrent: 3, maxPerWindow: 3, windowMs: 1000, now: () => t, ...extra }); + return { limiter, advance: (ms: number) => (t += ms) }; +} + +describe("ToastLimiter", () => { + it("caps the rate and coalesces overflow into a count", () => { + const { limiter } = make(); + for (let i = 0; i < 10; i++) limiter.push(item(`n${i}`)); + const s = limiter.getState(); + expect(s.visible).toHaveLength(3); + expect(s.overflow).toBe(7); + }); + + it("never drops critical items and evicts low priority first", () => { + const { limiter } = make(); + limiter.push(item("a", "low")); + limiter.push(item("b")); + limiter.push(item("c")); + limiter.push(item("settle", "critical")); + const ids = limiter.getState().visible.map((v) => v.id); + expect(ids).toContain("settle"); + expect(ids).not.toContain("a"); + expect(limiter.getState().overflow).toBe(1); + }); + + it("allows critical items past the rate cap", () => { + const { limiter } = make(); + for (let i = 0; i < 5; i++) limiter.push(item(`n${i}`)); + limiter.push(item("claim", "critical")); + expect(limiter.getState().visible.some((v) => v.id === "claim")).toBe(true); + }); + + it("dedupes identical ids from realtime and REST", () => { + const { limiter } = make(); + expect(limiter.push(item("x"))).toBe(true); + expect(limiter.push(item("x"))).toBe(false); + }); + + it("queues non-critical items during quiet hours and shows critical ones", () => { + const { limiter } = make({ quietHours: { start: 22, end: 14 } }); + limiter.push(item("a")); + limiter.push(item("b", "critical")); + expect(limiter.getState().deferred.map((d) => d.id)).toEqual(["a"]); + expect(limiter.getState().visible.map((d) => d.id)).toEqual(["b"]); + expect(limiter.flushDeferred()).toHaveLength(1); + }); + + it("resets on account switch", () => { + const { limiter } = make(); + limiter.push(item("a")); + limiter.reset(); + expect(limiter.push(item("a"))).toBe(true); + }); +}); diff --git a/src/lib/notificationThrottle.ts b/src/lib/notificationThrottle.ts new file mode 100644 index 0000000..615d5c3 --- /dev/null +++ b/src/lib/notificationThrottle.ts @@ -0,0 +1,137 @@ +export type ToastPriority = "critical" | "normal" | "low"; + +export interface ToastItem { + id: string; + priority: ToastPriority; + title: string; +} + +export interface ThrottleOptions { + maxConcurrent: number; + maxPerWindow: number; + windowMs: number; + /** Local hours [start, end) during which non-critical items are deferred; start > end wraps midnight. */ + quietHours?: { start: number; end: number } | null; + now?: () => number; +} + +export interface ThrottleState { + visible: ToastItem[]; + /** Overflow count shown as a "+N more" summary toast. */ + overflow: number; + /** Overflowed items, so the summary toast can expand. */ + overflowItems: ToastItem[]; + /** Queued by quiet hours, never dropped. */ + deferred: ToastItem[]; +} + +function inQuietHours(hour: number, q: { start: number; end: number }): boolean { + return q.start <= q.end ? hour >= q.start && hour < q.end : hour >= q.start || hour < q.end; +} + +/** + * Presentation-only limiter placed in front of toast rendering: notification + * state stays complete, only what is shown as a toast is capped. Critical items + * (settlement/claim confirmations) bypass every cap and quiet hours. + */ +export class ToastLimiter { + private seen = new Set(); + private stamps: number[] = []; + private state: ThrottleState = { visible: [], overflow: 0, overflowItems: [], deferred: [] }; + private listeners = new Set<(s: ThrottleState) => void>(); + + constructor(private opts: ThrottleOptions) {} + + private now(): number { + return (this.opts.now ?? Date.now)(); + } + + private emit(): void { + this.state = { ...this.state }; + this.listeners.forEach((l) => l(this.state)); + } + + getState(): ThrottleState { + return this.state; + } + + subscribe(listener: (s: ThrottleState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Returns false when the item was a duplicate (e.g. same event via realtime and REST). */ + push(item: ToastItem): boolean { + if (this.seen.has(item.id)) return false; + this.seen.add(item.id); + + const critical = item.priority === "critical"; + const q = this.opts.quietHours; + if (!critical && q && inQuietHours(new Date(this.now()).getHours(), q)) { + this.state.deferred = [...this.state.deferred, item]; + this.emit(); + return true; + } + this.show(item); + this.emit(); + return true; + } + + private show(item: ToastItem): void { + const t = this.now(); + this.stamps = this.stamps.filter((s) => t - s < this.opts.windowMs); + const critical = item.priority === "critical"; + const rateOk = this.stamps.length < this.opts.maxPerWindow; + + if (critical || rateOk) { + this.stamps.push(t); + let visible = [...this.state.visible, item]; + if (visible.length > this.opts.maxConcurrent) { + // Evict the lowest-priority, oldest non-critical toast into the summary. + const rank = { low: 0, normal: 1, critical: 2 } as const; + const evictable = visible + .filter((v) => v.priority !== "critical") + .sort((a, b) => rank[a.priority] - rank[b.priority])[0]; + if (evictable) { + visible = visible.filter((v) => v !== evictable); + this.toOverflow(evictable); + } + } + this.state.visible = visible; + } else { + this.toOverflow(item); + } + } + + private toOverflow(item: ToastItem): void { + this.state.overflowItems = [...this.state.overflowItems, item]; + this.state.overflow = this.state.overflowItems.length; + } + + dismiss(id: string): void { + this.state.visible = this.state.visible.filter((v) => v.id !== id); + this.emit(); + } + + clearOverflow(): void { + this.state.overflowItems = []; + this.state.overflow = 0; + this.emit(); + } + + /** Releases deferred items (quiet hours ended / user opened the deferred view). */ + flushDeferred(): ToastItem[] { + const out = this.state.deferred; + this.state.deferred = []; + this.emit(); + return out; + } + + /** Account switch: drop limiter memory so the new account starts clean. */ + reset(): void { + this.seen.clear(); + this.stamps = []; + this.state = { visible: [], overflow: 0, overflowItems: [], deferred: [] }; + this.emit(); + } +} diff --git a/src/lib/realtime/client.ts b/src/lib/realtime/client.ts index 8be8d3c..a0eb9e9 100644 --- a/src/lib/realtime/client.ts +++ b/src/lib/realtime/client.ts @@ -1,4 +1,5 @@ import { API_BASE_URL } from "@/lib/api/client"; +import { isTopicEnabled, onTopicFlagChange } from "./flags"; type Handler = (payload: unknown) => void; @@ -17,10 +18,29 @@ const BASE_BACKOFF_MS = 1_000; class RealtimeClient { private socket: WebSocket | null = null; private handlers = new Map>(); + /** Handlers for flag-disabled topics: held in memory only, never on the socket. */ + private parked = new Map>(); private attempt = 0; private closedByUser = false; private reconnectTimer: ReturnType | null = null; + constructor() { + onTopicFlagChange((topic, enabled) => { + if (enabled) { + const parked = this.parked.get(topic); + if (!parked?.size) return; + this.parked.delete(topic); + parked.forEach((handler) => this.attach(topic, handler)); + } else { + const live = this.handlers.get(topic); + if (!live) return; + this.handlers.delete(topic); + if (live.size) this.parked.set(topic, live); + if (this.handlers.size === 0) this.disconnect(); + } + }); + } + connect(): void { if (typeof WebSocket === "undefined" || this.socket) return; this.closedByUser = false; @@ -66,16 +86,31 @@ class RealtimeClient { }, delay); } + /** + * Disabled topics never open a subscription: the handler is parked (so a + * mid-session enable can activate it) and callers keep using their REST path. + */ subscribe(topic: string, handler: Handler): () => void { - if (!this.handlers.has(topic)) this.handlers.set(topic, new Set()); - this.handlers.get(topic)!.add(handler); - this.connect(); + if (isTopicEnabled(topic)) this.attach(topic, handler); + else this.park(topic, handler); return () => { this.handlers.get(topic)?.delete(handler); + this.parked.get(topic)?.delete(handler); }; } + private park(topic: string, handler: Handler): void { + if (!this.parked.has(topic)) this.parked.set(topic, new Set()); + this.parked.get(topic)!.add(handler); + } + + private attach(topic: string, handler: Handler): void { + if (!this.handlers.has(topic)) this.handlers.set(topic, new Set()); + this.handlers.get(topic)!.add(handler); + this.connect(); + } + disconnect(): void { this.closedByUser = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); diff --git a/src/lib/realtime/flags.ts b/src/lib/realtime/flags.ts new file mode 100644 index 0000000..74afd86 --- /dev/null +++ b/src/lib/realtime/flags.ts @@ -0,0 +1,151 @@ +import { FLAGS_ENDPOINT } from "@/lib/flags/flagsConfig"; + +/** + * Per-topic realtime flags. + * + * Schema (public/flags.json), keyed by topic identifier: + * { "realtime": { "topics": { "": { "enabled": boolean, "kill"?: boolean } } } } + * + * Precedence, highest first: + * 1. kill (runtime `killTopic()` or `kill: true` in flags.json) — always disabled + * 2. in-memory override (`setTopicOverride`, used by tests / developer flow) + * 3. env override NEXT_PUBLIC_REALTIME_TOPICS_OVERRIDE='{"oracle_update":true}' + * 4. flags.json `enabled` + * 5. default: disabled (unknown, missing or malformed entries fail closed) + */ +export type TopicFlagListener = (topic: string, enabled: boolean) => void; + +interface TopicFlag { + enabled: boolean; + kill: boolean; +} + +let fileFlags = new Map(); +let envOverrides = new Map(); +const overrides = new Map(); +const killed = new Set(); +const listeners = new Set(); +let initialised = false; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseFile(doc: unknown): Map { + const out = new Map(); + const topics = isRecord(doc) && isRecord(doc.realtime) ? doc.realtime.topics : undefined; + if (!isRecord(topics)) { + if (doc !== undefined) console.warn("[realtime-flags] missing or malformed realtime.topics; all topics disabled"); + return out; + } + for (const [topic, entry] of Object.entries(topics)) { + if (!isRecord(entry) || typeof entry.enabled !== "boolean" || (entry.kill !== undefined && typeof entry.kill !== "boolean")) { + console.warn(`[realtime-flags] malformed entry for topic "${topic}"; treating as disabled`); + out.set(topic, { enabled: false, kill: false }); + continue; + } + out.set(topic, { enabled: entry.enabled, kill: entry.kill === true }); + } + return out; +} + +function parseEnv(): Map { + const out = new Map(); + const raw = process.env.NEXT_PUBLIC_REALTIME_TOPICS_OVERRIDE; + if (!raw) return out; + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) throw new Error("not an object"); + for (const [topic, value] of Object.entries(parsed)) { + if (typeof value === "boolean") out.set(topic, value); + else console.warn(`[realtime-flags] ignoring non-boolean env override for "${topic}"`); + } + } catch { + console.warn("[realtime-flags] malformed NEXT_PUBLIC_REALTIME_TOPICS_OVERRIDE ignored"); + } + return out; +} + +/** Resolves a topic's flag without side effects. Read-through of the cached state. */ +export function isTopicEnabled(topic: string): boolean { + if (killed.has(topic)) return false; + const file = fileFlags.get(topic); + if (file?.kill) return false; + const override = overrides.get(topic); + if (override !== undefined) return override; + const env = envOverrides.get(topic); + if (env !== undefined) return env; + return file?.enabled ?? false; +} + +function snapshot(): Map { + const topics = new Set([...fileFlags.keys(), ...envOverrides.keys(), ...overrides.keys(), ...killed]); + return new Map([...topics].map((t) => [t, isTopicEnabled(t)])); +} + +function mutate(change: () => void): void { + const before = snapshot(); + change(); + const after = snapshot(); + for (const [topic, enabled] of after) { + if (before.get(topic) !== enabled && !(before.get(topic) === undefined && !enabled)) { + listeners.forEach((listener) => listener(topic, enabled)); + } + } +} + +/** Reads the flags document once and caches it. Never throws. */ +export function initRealtimeFlags(doc?: unknown): void { + mutate(() => { + fileFlags = parseFile(doc); + envOverrides = parseEnv(); + initialised = true; + }); +} + +/** Fetches /flags.json and initialises; any failure leaves every topic disabled. */ +export async function loadRealtimeFlags(fetchImpl: typeof fetch = fetch): Promise { + try { + const res = await fetchImpl(FLAGS_ENDPOINT, { cache: "no-store" }); + if (!res.ok) throw new Error(String(res.status)); + initRealtimeFlags(await res.json()); + } catch { + console.warn("[realtime-flags] could not load flags; all topics disabled"); + initRealtimeFlags(undefined); + } +} + +export function setTopicOverride(topic: string, value: boolean | null): void { + mutate(() => { + if (value === null) overrides.delete(topic); + else overrides.set(topic, value); + }); +} + +/** Kills a topic for already-connected sessions too (listeners tear subscriptions down). */ +export function killTopic(topic: string): void { + mutate(() => killed.add(topic)); +} + +export function reviveTopic(topic: string): void { + mutate(() => killed.delete(topic)); +} + +/** Observable for telemetry and for the registry to react to mid-session flips. */ +export function onTopicFlagChange(listener: TopicFlagListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function resetRealtimeFlagsForTests(): void { + fileFlags = new Map(); + envOverrides = new Map(); + overrides.clear(); + killed.clear(); + listeners.clear(); + initialised = false; +} + +export function realtimeFlagsInitialised(): boolean { + return initialised; +} diff --git a/src/lib/realtime/index.ts b/src/lib/realtime/index.ts new file mode 100644 index 0000000..525ff5a --- /dev/null +++ b/src/lib/realtime/index.ts @@ -0,0 +1,11 @@ +/** Public entry point of the realtime module — import from "@/lib/realtime", never from internals. */ +export { realtimeClient } from "./client"; +export { + initRealtimeFlags, + loadRealtimeFlags, + isTopicEnabled, + setTopicOverride, + killTopic, + reviveTopic, + onTopicFlagChange, +} from "./flags"; diff --git a/src/lib/virtualWindow.test.ts b/src/lib/virtualWindow.test.ts new file mode 100644 index 0000000..58b3c23 --- /dev/null +++ b/src/lib/virtualWindow.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { computeWindow } from "./virtualWindow"; + +describe("computeWindow", () => { + it("handles empty lists", () => { + expect(computeWindow(0, 40, 0, 300)).toEqual({ start: 0, end: 0, offsetTop: 0, totalHeight: 0 }); + }); + it("starts at index 0 with no negative overscan", () => { + const w = computeWindow(1000, 40, 0, 400, 2); + expect(w.start).toBe(0); + expect(w.end).toBe(12); + }); + it("clamps at the last index", () => { + const w = computeWindow(100, 40, 1_000_000, 400, 2); + expect(w.end).toBe(100); + expect(w.start).toBeLessThan(100); + }); + it("renders a bounded number of rows for a huge feed", () => { + const w = computeWindow(50_000, 40, 400_000, 400); + expect(w.end - w.start).toBeLessThanOrEqual(10 + 2 * 4); + expect(w.totalHeight).toBe(2_000_000); + }); + it("keeps offsets aligned to rows", () => { + const w = computeWindow(500, 50, 2000, 500, 3); + expect(w.offsetTop).toBe(w.start * 50); + }); +}); diff --git a/src/lib/virtualWindow.ts b/src/lib/virtualWindow.ts new file mode 100644 index 0000000..133bb22 --- /dev/null +++ b/src/lib/virtualWindow.ts @@ -0,0 +1,22 @@ +export interface VirtualWindow { + start: number; + end: number; // exclusive + offsetTop: number; + totalHeight: number; +} + +/** Computes the rendered slice for a fixed-row-height list with overscan. */ +export function computeWindow( + count: number, + rowHeight: number, + scrollTop: number, + viewportHeight: number, + overscan = 4, +): VirtualWindow { + if (count <= 0 || rowHeight <= 0) return { start: 0, end: 0, offsetTop: 0, totalHeight: 0 }; + const first = Math.floor(Math.max(0, scrollTop) / rowHeight); + const visible = Math.ceil(viewportHeight / rowHeight); + const start = Math.min(Math.max(0, first - overscan), count - 1); + const end = Math.min(count, first + visible + overscan); + return { start, end: Math.max(end, start + 1), offsetTop: start * rowHeight, totalHeight: count * rowHeight }; +}