diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10c2eeb1..c1466a9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,5 @@ jobs: - name: Build OpenNext Worker working-directory: apps/web run: npx opennextjs-cloudflare build + - name: Check production prefetch behavior + run: npm run test:prefetch -w xcp-explorer-web diff --git a/apps/web/package.json b/apps/web/package.json index 4d924cbb..5c05f2f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "eslint", "test:e2e": "playwright test", + "test:prefetch": "playwright test --config playwright.prefetch.config.ts", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy" }, "dependencies": { diff --git a/apps/web/playwright.prefetch.config.ts b/apps/web/playwright.prefetch.config.ts new file mode 100644 index 00000000..9399d8a1 --- /dev/null +++ b/apps/web/playwright.prefetch.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Prefetch is disabled by Next in development. Run after the production build. +export default defineConfig({ + testDir: "./tests/prefetch", + timeout: 45_000, + expect: { timeout: 10_000 }, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: "list", + use: { + ...devices["Desktop Chrome"], + hasTouch: true, + baseURL: "http://127.0.0.1:3108", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: "npm run start -- --hostname 127.0.0.1 --port 3108", + url: "http://127.0.0.1:3108", + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/apps/web/src/features/records/cells.tsx b/apps/web/src/features/records/cells.tsx index 80a51c50..0a256a5b 100644 --- a/apps/web/src/features/records/cells.tsx +++ b/apps/web/src/features/records/cells.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { commas, short, ts, timeAgo } from "@/lib/format"; import { AssetIcon } from "@/components/ui/badges"; import { assetHref } from "@/lib/asset-link"; +import { RecordLink } from "@/features/records/components/record-link"; /** The page subject a record table renders under — drives contextual column suppression (R4: * never render the column the page already answers) and perspective signing of quantities. @@ -55,13 +56,13 @@ export const blockCell = (n?: number | null) => ) : ( — ); -export const txCell = (h?: string | null) => (h ? {mono(short(h))} : "—"); +export const txCell = (h?: string | null) => (h ? {mono(short(h))} : "—"); /** Address — single-line ellipsis over the FULL string, complete address in title. Never 4+4 (R5). */ export const addrCell = (a?: string | null) => a ? ( - + {a} - + ) : ( "—" ); @@ -69,9 +70,14 @@ export const addrCell = (a?: string | null) => * table, where two address columns share a row and the front-ellipsis form was unreadable. */ export const addrEndsCell = (a?: string | null) => a ? ( - + {short(a, 8, 6)} - + ) : ( "—" ); @@ -110,9 +116,9 @@ export const timeCell = (t?: number | null) => /** The row's trailing action — the quiet View link, sr-only header. */ export const viewCell = (h?: string | null) => h ? ( - + View - + ) : ( — ); diff --git a/apps/web/src/features/records/components/record-link.tsx b/apps/web/src/features/records/components/record-link.tsx new file mode 100644 index 00000000..d55f6d98 --- /dev/null +++ b/apps/web/src/features/records/components/record-link.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { Route } from "next"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useRef, type ComponentProps } from "react"; + +type Props = Omit, "href" | "prefetch"> & { href: Route }; + +/** Dense tables can expose hundreds of address/transaction links. Warm the destination + * on intent, once per href, without making every visible row render on the server. */ +export function RecordLink({ href, onMouseEnter, onFocus, onTouchStart, ...props }: Props) { + const router = useRouter(); + const prefetched = useRef(null); + const prefetch = () => { + if (prefetched.current === href) return; + prefetched.current = href; + router.prefetch(href); + }; + + return ( + { + onMouseEnter?.(event); + if (!event.defaultPrevented) prefetch(); + }} + onFocus={(event) => { + onFocus?.(event); + if (!event.defaultPrevented) prefetch(); + }} + onTouchStart={(event) => { + onTouchStart?.(event); + if (!event.defaultPrevented) prefetch(); + }} + /> + ); +} diff --git a/apps/web/src/features/records/registry.tsx b/apps/web/src/features/records/registry.tsx index 7d075e59..ec8a0e30 100644 --- a/apps/web/src/features/records/registry.tsx +++ b/apps/web/src/features/records/registry.tsx @@ -1,5 +1,6 @@ import { dispenseSats } from "@/lib/dispenser-pricing"; import Link from "next/link"; +import { RecordLink } from "@/features/records/components/record-link"; import type { PoolRow, RecordKind, @@ -595,9 +596,9 @@ export const REGISTRY: Registry = { if (!Number.isFinite(paid) || !paid || !qty) return "—"; const unit = sats(paid / qty); return r.dispenser_tx_hash ? ( - + {unit} - + ) : ( unit ); @@ -809,9 +810,9 @@ export const REGISTRY: Registry = { w: "130px", cell: (r) => r.order_match_id ? ( - + {short(r.order_match_id, 8, 6)} - + ) : ( "—" ), diff --git a/apps/web/tests/prefetch/record-links.spec.ts b/apps/web/tests/prefetch/record-links.spec.ts new file mode 100644 index 00000000..ef72ba2a --- /dev/null +++ b/apps/web/tests/prefetch/record-links.spec.ts @@ -0,0 +1,93 @@ +import { test, expect, type Page } from "@playwright/test"; +import type { SendRow } from "@xcp/shared/records"; + +const ADDRESS = "1K8pj6raDJ2GvUnXzE78HFVES31LMuxeTv"; +const NEXT_ADDRESS = "171grbn6H9uZRc5LVkgtSR2aFosLh8iqEw"; +const TX = "b5b0ee151fcf23d8612f816df969ae59bac7e789296ee51c11a34af3bee36705"; + +async function openTable(page: Page) { + const prefetches: string[] = []; + const apiReads: string[] = []; + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (request.headers()["next-router-prefetch"] && /^\/(address|tx)\//.test(path)) prefetches.push(path); + }); + await page.route("https://api.xcp.io/**", (route) => { + const url = new URL(route.request().url()); + apiReads.push(url.pathname); + if (url.pathname !== "/v2/sends") return route.fulfill({ status: 503, body: "unavailable" }); + const source = Number(url.searchParams.get("offset")) ? NEXT_ADDRESS : ADDRESS; + const row: SendRow = { + tx_hash: TX, + block_index: 967626, + block_time: null, + source, + destination: null, + asset: null, + quantity_normalized: "1", + send_type: "enhanced_send", + status: "valid", + memo: null, + }; + return route.fulfill({ json: { result: Array.from({ length: 30 }, () => row), next_offset: 50 } }); + }); + await page.route("https://cdn.usefathom.com/**", (route) => route.fulfill({ status: 204 })); + await page.goto("/sends", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("table").getByRole("row")).toHaveCount(31); + await page.getByRole("button", { name: "Next", exact: true }).scrollIntoViewIfNeeded(); + await page.waitForTimeout(1_000); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(1_000); + // A bounded quiet window is necessary to detect unwanted background work. + expect(prefetches).toEqual([]); + return { prefetches, apiReads, errors }; +} + +for (const intent of ["hover", "keyboard", "touch"] as const) { + test(`${intent} warms only the chosen record and navigation still works`, async ({ page }) => { + if (intent === "touch") await page.setViewportSize({ width: 390, height: 844 }); + const { prefetches, apiReads, errors } = await openTable(page); + const href = intent === "touch" ? `/tx/${TX}` : `/address/${ADDRESS}`; + const link = page.locator(`main a[href="${href}"]`).first(); + if (intent === "hover") await link.hover(); + if (intent === "keyboard") await link.focus(); + if (intent === "touch") await link.dispatchEvent("touchstart"); + await expect.poll(() => prefetches.length).toBeGreaterThan(0); + await page.waitForTimeout(1_000); + expect([...new Set(prefetches)]).toEqual([href]); + const warmed = prefetches.length; + await link.dispatchEvent("mouseenter"); + await link.dispatchEvent("focus"); + await link.dispatchEvent("touchstart"); + await page.waitForTimeout(1_000); + expect(prefetches).toHaveLength(warmed); + + if (intent === "keyboard") await page.keyboard.press("Enter"); + else if (intent === "touch") await link.tap(); + else await link.click(); + await expect(page).toHaveURL(new RegExp(`${href}$`)); + await expect(page.locator("main")).toBeVisible(); + if (intent !== "touch") { + await expect(page.getByRole("heading", { name: ADDRESS, exact: true })).toBeVisible(); + await expect.poll(() => apiReads.includes(`/v2/addresses/${ADDRESS}/summary`)).toBe(true); + } + expect(errors).toEqual([]); + }); +} + +test("pagination does not inherit prefetch intent from the previous address", async ({ page }) => { + const { prefetches, errors } = await openTable(page); + await page.locator(`main a[href="/address/${ADDRESS}"]`).first().hover(); + await expect.poll(() => prefetches.length).toBeGreaterThan(0); + await page.getByRole("button", { name: "Next", exact: true }).click(); + const link = page.locator(`main a[href="/address/${NEXT_ADDRESS}"]`).first(); + await expect(link).toHaveCount(1); + await link.scrollIntoViewIfNeeded(); + await page.waitForTimeout(1_000); + expect(prefetches).not.toContain(`/address/${NEXT_ADDRESS}`); + await link.focus(); + await expect.poll(() => prefetches.includes(`/address/${NEXT_ADDRESS}`)).toBe(true); + expect(errors).toEqual([]); +}); diff --git a/docs/frontend-prefetch-budget.md b/docs/frontend-prefetch-budget.md new file mode 100644 index 00000000..c8efc60d --- /dev/null +++ b/docs/frontend-prefetch-budget.md @@ -0,0 +1,67 @@ +# Record navigation resource budget + +Dense tables prefetch address and transaction destinations only on mouse hover, +keyboard focus, or touch start. `RecordLink` retains Next's normal link navigation +and calls `router.prefetch` once per selected href. Repeated intent events do not +restart the warm-up; a changed href after pagination can warm independently. + +This applies to the shared address/transaction cells and the dispenser/order-match +transaction links in the record registry. Asset and block links keep their existing +behavior. Data polling, API caching, server rendering, and loading/error states are +unchanged. Very fast clicks have less prefetch head start, so production navigation +latency must be checked alongside resource savings. + +## Evidence, September 19, 2026 UTC + +A 25-second live `xcp-web` tail sample at 01:11 UTC contained: + +| Destination | Requests | Requests marked `next-router-prefetch` | CPU spent on those prefetches | +| --- | ---: | ---: | ---: | +| Address | 279 | 237 (85%) | 3,821 ms | +| Transaction | 340 | 213 (63%) | 2,753 ms | + +All those prefetch responses were HTTP 200. Tail redacts many address/transaction +paths: distinct-path counts cannot establish cache reuse or traffic provenance. +This short sample identifies an optimization target, not a monthly savings estimate. + +The local before/after comparison used a production OpenNext build, Chromium at +1440×1000, the same captured 50-row Sends API response, and the same four scroll +positions with timed observation windows. The baseline was main at `a1693910`. + +| Observation | Before | After | +| --- | ---: | ---: | +| Automatic address prefetch requests | 1,153 | 0 | +| Automatic transaction prefetch requests | 31 | 0 | +| Automatic block/asset prefetch requests | 42 | 68 | +| Total automatic prefetch requests | 1,226 | 68 | +| Click to address heading after hover | 373 ms | 378 ms | +| Browser exceptions | 0 | 0 | + +The chosen address still warmed on hover (two RSC requests in the after run). +The large baseline address count included repeated requests, not 1,153 distinct +addresses. Background scheduling and available connection capacity affect counts +for the unchanged block/asset links. Click timing is one local observation, not a +latency benchmark or production SLO. The measured 94.5% reduction in total automatic +prefetch requests applies to this table exercise, not all Worker traffic or dollars. + +Desktop screenshots retained the same layout; mobile at 390×844 had no horizontal +overflow and all 50 View links remained available. Fresh address API reads still +start after navigation, including when the API is unavailable. + +## Regression checks + +`npm run test:prefetch -w xcp-explorer-web` runs after a production build; development +mode does not exercise Next's automatic prefetch behavior. CI runs it after the +OpenNext build. The four browser cases check no viewport/scroll fan-out, hover, +keyboard Enter, mobile touch/tap, repeated intent, changed destinations after +pagination, browser errors, and fresh address reads after navigation. + +Also run `npm run check`, the existing web E2E/accessibility suite, the OpenNext +build, and a Wrangler dry run. Before owner-approved deployment, record the current +Worker version for rollback. After deployment, compare same-duration request/CPU +windows, navigation responses/latency, HTTP errors, D1 usage, and R2 operations. +Separate prefetch requests from actual navigations and retain the longer matched +traffic-window comparison before claiming recurring savings. + +Next's supported manual-prefetch API is documented in its +[prefetching guide](https://nextjs.org/docs/app/guides/prefetching).