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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
25 changes: 25 additions & 0 deletions apps/web/playwright.prefetch.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
20 changes: 13 additions & 7 deletions apps/web/src/features/records/cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -55,23 +56,28 @@ export const blockCell = (n?: number | null) =>
) : (
<span className="blk">—</span>
);
export const txCell = (h?: string | null) => (h ? <Link href={`/tx/${h}`}>{mono(short(h))}</Link> : "—");
export const txCell = (h?: string | null) => (h ? <RecordLink href={`/tx/${h}`}>{mono(short(h))}</RecordLink> : "—");
/** Address — single-line ellipsis over the FULL string, complete address in title. Never 4+4 (R5). */
export const addrCell = (a?: string | null) =>
a ? (
<Link className="address" href={`/address/${a}`} title={a}>
<RecordLink className="address" href={`/address/${a}`} title={a}>
{a}
</Link>
</RecordLink>
) : (
"—"
);
/** Bookended address (prefix…suffix, full in title) — the owner-sanctioned exception for the matches
* table, where two address columns share a row and the front-ellipsis form was unreadable. */
export const addrEndsCell = (a?: string | null) =>
a ? (
<Link className="font-mono text-[12px] text-(--t3)" style={{ color: "var(--t3)" }} href={`/address/${a}`} title={a}>
<RecordLink
className="font-mono text-[12px] text-(--t3)"
style={{ color: "var(--t3)" }}
href={`/address/${a}`}
title={a}
>
{short(a, 8, 6)}
</Link>
</RecordLink>
) : (
"—"
);
Expand Down Expand Up @@ -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 ? (
<Link className="view" href={`/tx/${h}`}>
<RecordLink className="view" href={`/tx/${h}`}>
View
</Link>
</RecordLink>
) : (
<span className="view">—</span>
);
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/features/records/components/record-link.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends string> = Omit<ComponentProps<typeof Link>, "href" | "prefetch"> & { href: Route<T> };

/** 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<T extends string>({ href, onMouseEnter, onFocus, onTouchStart, ...props }: Props<T>) {
const router = useRouter();
const prefetched = useRef<string | null>(null);
const prefetch = () => {
if (prefetched.current === href) return;
prefetched.current = href;
router.prefetch(href);
};

return (
<Link
{...props}
href={href}
prefetch={false}
onMouseEnter={(event) => {
onMouseEnter?.(event);
if (!event.defaultPrevented) prefetch();
}}
onFocus={(event) => {
onFocus?.(event);
if (!event.defaultPrevented) prefetch();
}}
onTouchStart={(event) => {
onTouchStart?.(event);
if (!event.defaultPrevented) prefetch();
}}
/>
);
}
9 changes: 5 additions & 4 deletions apps/web/src/features/records/registry.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 ? (
<Link href={`/tx/${r.dispenser_tx_hash}`} title="the dispenser that sold">
<RecordLink href={`/tx/${r.dispenser_tx_hash}`} title="the dispenser that sold">
{unit}
</Link>
</RecordLink>
) : (
unit
);
Expand Down Expand Up @@ -809,9 +810,9 @@ export const REGISTRY: Registry = {
w: "130px",
cell: (r) =>
r.order_match_id ? (
<Link href={`/tx/${r.order_match_id.split("_")[0]}`} className="font-mono">
<RecordLink href={`/tx/${r.order_match_id.split("_")[0]}`} className="font-mono">
{short(r.order_match_id, 8, 6)}
</Link>
</RecordLink>
) : (
"—"
),
Expand Down
93 changes: 93 additions & 0 deletions apps/web/tests/prefetch/record-links.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
67 changes: 67 additions & 0 deletions docs/frontend-prefetch-budget.md
Original file line number Diff line number Diff line change
@@ -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).
Loading