Skip to content
Open
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
13 changes: 13 additions & 0 deletions .agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ Some directories have a `CONTEXT.md` documenting non-obvious patterns specific t

If you encounter a `CONTEXT.md` not listed here, read it too (and consider adding it to this list).

## Architecture decision records

Decisions with repo-wide consequences are recorded in `.agents/adr/`, named
`<0000>-<slug>.md`. Read the relevant one before changing what it decided — an ADR carries the
evidence and the trade-off, so it answers "why is it like this?" without a git archaeology session.

- `0001-webpack-for-production-builds.md` — why production bundles are built with webpack while dev stays on Turbopack.

Add a new record (next free number, and a line here) whenever a decision is expensive to rediscover:
it constrains future work, was reached by measurement or an investigation worth not repeating, or
looks wrong without its context. Supersede rather than rewrite — flip the old record's `Status` to
`superseded by <n>` and leave its reasoning intact.

## Product task workflow

Product tasks (GitHub issues) are worked through a spec-driven workflow, run by the `grill-the-task`,
Expand Down
2 changes: 2 additions & 0 deletions .agents/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ vars are documented in `docs/ENVS.md`. Architectural concepts like
| **Connect Wallet** | feature | Lets users write to contracts, sign transactions, and connect a wallet to the explorer. Previously named `blockchain-interaction`; the current config key is `connectWallet`. Distinct from **Web3 Wallet**. |
| **Dispute Games** | entity | Part of the Optimism **Fault Proof System**. On-chain games used to challenge and resolve disputed L2 output roots. |
| **Easter Eggs** | feature | Hidden mini-games wired to claim links for badge rewards. |
| **Eden** | chain | A rollup built on `ev-reth` / evstack. Introduces the **Sponsored Transaction** type. |
| **Epoch** | entity | A consensus time period specific to **Celo**. Has its own index and detail pages. Always refers to a Celo epoch in this codebase — not a generic blockchain concept. |
| **Fault Proof System** | feature | Optimism's mechanism for proving the correctness of L2 state transitions on L1 via **Dispute Games**. |
| **Flashblocks** | feature | MegaETH's sub-second block streaming mechanism. |
Expand All @@ -52,6 +53,7 @@ vars are documented in `docs/ENVS.md`. Architectural concepts like
| **Rewards** | feature | The Blockscout Merits program — a token rewards and incentives system operated by Blockscout. Entirely distinct from **Block Reward** (on-chain block-producer payouts). |
| **Rollup** | concept | A chain that settles transactions on a parent (L1) chain. Introduces specific entities: deposits, withdrawals, transaction batches, output roots. Contrast with **Chain Variant**. |
| **SolidityScan** | service | Third-party smart contract security vulnerability scanner integrated into contract detail pages. |
| **Sponsored Transaction** | entity | **Eden**-specific transaction type (EIP-2718 type `0x76`): an executor submits an ordered batch of calls, while a separate sponsor signs for and pays the fee. No equivalent on standard EVM chains. |
| **SUAVE** | chain | MEV-focused chain developed by Flashbots, built around a trusted execution environment (TEE) architecture. Introduces the **Kettle** entity. |
| **TAC (Ton Application Chain)** | chain | A chain that bridges the TON blockchain and EVM ecosystems. Introduces the **Operation** entity. |
| **Tx Actions** | feature | Structured per-transaction action breakdown rendered on the tx details page — a first-party Blockscout interpretation of what a tx did. Distinct from **Tx Interpretation** (natural-language summary) and from raw calldata. |
Expand Down
108 changes: 108 additions & 0 deletions .agents/adr/0001-webpack-for-production-builds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 0001 — webpack for production builds, Turbopack for dev

| | |
| --- | --- |
| Status | accepted |
| Date | 2026-08-04 |
| Deciders | @tom2drum |
| Supersedes | — |

## Decision

**Production builds use webpack (`next build --webpack`). Dev keeps Turbopack (the Next 16 default).**

Applies to every entry point that emits a production bundle:

| Entry point | Bundler |
| --- | --- |
| `pnpm build` — what the `Dockerfile` runs for the shipped image | webpack |
| `pnpm build:next` | webpack |
| `pnpm prod:preset <alias>` — local production build, incl. perf measurements | webpack |
| `pnpm build:analyze`, `pnpm profile:preset` | webpack (already were) |
| `pnpm dev`, `pnpm dev:preset`, `pnpm dev:local` | Turbopack |

Dev stays on Turbopack because it is roughly 3× faster to compile and the crash class below only
manifests in a minified production build. Production-build regressions are caught in QA rather than
by making every local dev start slower.

## Why

### Turbopack miscompiles the Dynamic-labs SDK

Turbopack's scope hoisting emits code that reads the SDK's `UserFieldEditorContext` through the
wrong binding. `useContext` therefore receives a non-context value, returns `undefined`, and the SDK
throws from its own `useUpdateUserWithModal`:

```
useUserUpdateRequest can only be used inside the context of DynamicContextProvider
```

The throwing component is the SDK's internal `SyncAuthFlow`, which the SDK itself renders *inside*
`UserFieldEditorContextProvider` — so in a correct build the context cannot be missing. It is a
bundler defect, not a provider-tree bug in our code.

Impact: **a hard crash on the initial load of every page**, for any instance configured with
`NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=dynamic`. It is invisible in dev (unminified, no hoisting) and
was found only by running the `v2.10.0` image locally. The v2.10.0 release would have broken every
dynamic-auth instance on rollout; deployed instances were still on v2.9.4 and unaffected.

Bisected to [#3574](https://github.com/blockscout/frontend/pull/3574) (wallet-stack deferral,
subtask 4 of [#3566](https://github.com/blockscout/frontend/issues/3566)) — parent commit good, that
commit bad. The trigger could **not** be reduced to a single import: reverting the lazy `import()`
wrappers, the `_app.tsx` provider restructure, and the `@wagmi/core` dependency each left it broken.
That fits the mechanism — scope hoisting groups modules across the whole graph, so the trigger is an
emergent property of how #3574 reshaped it, and any future graph change could re-trigger it
somewhere else. Next 16.3.0 does not fix it.

### webpack is also the faster bundle

Two options fixed the crash: `--webpack`, or `experimental.turbopackScopeHoisting: false`. The flag
turned out to be the expensive one. Production builds of `main`, medians of 3
automated traces:

| Metric | Turbopack | Turbopack, hoisting off | **webpack** |
| --- | --- | --- | --- |
| M1 FCP | 432 ms | 790 ms | **501 ms** |
| M2 first API request | 60 ms | 142 ms | **57 ms** |
| M5 blocking time | 133 ms | 408 ms | **155 ms** |
| M6 JS before FCP | 1038 KB | 1064 KB | **697 KB** |
| Emitted chunk bytes | 49.2 MB | 53.4 MB | **21.4 MB** |
| Build time | 48 s | 41 s | 2.4 min |

Disabling scope hoisting nearly doubles FCP and triples blocking time while barely moving M6 (+2.5%)
— the cost lands in execution, not transfer, so M6 alone would not have caught it. webpack instead
*improves* pre-FCP JS by 341 KB (−33%) over the Turbopack build, more than any single lever in #3566
delivered on its own.

The measurement harness lives in
`.agents/tasks/3566-main-page-loading-perf/tools/` (see its README). Absolute values come from
headless Chromium on a local server and are not comparable to the numbers in that task's spec table;
the within-comparison deltas are what the decision rests on.

## Consequences

- **CI and image builds get slower** — webpack's compile step measured 84 s to 2.4 min across
machines and cache states, against 41–48 s for Turbopack, so budget roughly 2–3×. Accepted:
correctness plus a materially smaller bundle outweigh build latency.
- **Dev and production now use different bundlers.** A bug in either pipeline can only be caught on
that pipeline; production-only breakage will not appear in dev. QA runs against a real image.
- `next.config.js` must keep **both** the `webpack()` and `turbopack` sections in sync — it already
does, and this decision makes that non-optional.
- webpack surfaces one unresolvable import Turbopack silently tolerates:
`@react-native-async-storage/async-storage` inside `@metamask/sdk`, reached via
`@wagmi/connectors` → `@reown/appkit-adapter-wagmi` → `wagmi-config.ts`. It is an optional peer
dependency of a React Native code path a browser bundle never takes, so `next.config.js` maps it
to `false` in `resolve.fallback` (an empty module) and the build is warning-free. If a future
dependency bump introduces a similar optional import, extend that map rather than silencing
warnings wholesale.
- `next build --webpack` is a compatibility path in Next 16 and may eventually be removed. If that
happens before Turbopack is fixed, the fallback is `experimental.turbopackScopeHoisting: false`
and its performance cost.

## Follow-ups

- Report the miscompilation upstream to `vercel/next.js` with a minimal reproduction; the bisect
boundary and the flag that toggles it are the material.
- Re-test Turbopack on each Next upgrade. If a release fixes it, revisit — Turbopack's build speed
is worth reclaiming, but only with the M1/M5/M6 numbers above re-measured, not on the release
notes alone.
32 changes: 27 additions & 5 deletions .agents/tasks/3566-main-page-loading-perf/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,25 @@ inflate everything 2–3×).
Keep the **same preset** for every measurement — metrics M3/M4 depend on the instance's
backend latency, so numbers from different presets are not comparable.

2. Open `http://localhost:3000/` in a **clean browser profile** (incognito, no extensions —
React DevTools alone adds ~150 ms of scripting).
2. Record the trace, either by hand or scripted.

3. DevTools → Performance → "Record and reload". Stop a couple of seconds after the
transactions/blocks lists show real data. Export the trace as JSON.
**By hand** — open `http://localhost:3000/` in a **clean browser profile** (incognito, no
extensions — React DevTools alone adds ~150 ms of scripting), then DevTools → Performance →
"Record and reload". Stop a couple of seconds after the transactions/blocks lists show real
data. Export the trace as JSON.

4. Extract the metrics:
**Scripted** — `trace.mjs` drives headless Chromium over CDP and writes the same JSON:

```bash
node trace.mjs http://localhost:3000/ /tmp/traces/before 3 # 3 runs -> before-1..3.json
```

It records the same event categories the Performance panel does and uses a fresh browser
context per run (no extensions, cold cache), so it is the scripted equivalent of the clean
profile above. Prefer it whenever you need several runs per variant or a repeatable A/B; a
single exploratory trace is easier by hand, where you can also read the flame chart.

3. Extract the metrics:

```bash
python3 trace-metrics.py baseline.json # one trace
Expand All @@ -40,3 +52,13 @@ inflate everything 2–3×).
- The app under `prod:preset` proxies API calls through `localhost:3000/node-api/proxy` (the
fetched config keeps `APP_ENV=development`). Both variants of an A/B pair share this hop, so
deltas are valid — but do not compare absolute values against traces of a deployed instance.
- **Headless (`trace.mjs`) and headed absolute values are not comparable either** — same rule,
compare within one capture method. Do not mix them in a single row of the spec's table.
- **M6 is not a sufficient gate on its own.** A bundler change can leave the bytes-before-FCP
almost untouched while doubling FCP and tripling blocking time, because the cost is in executing
the code rather than transferring it. Always read M1 and M5 alongside M6 before concluding a
change is cheap — see `.agents/adr/0001-webpack-for-production-builds.md` for the case that
taught us this.
- `prod:preset` builds with the same bundler as the shipped image (webpack, per that ADR), so its
traces represent what users get. If you ever measure a build made another way, say so next to
the numbers.
80 changes: 80 additions & 0 deletions .agents/tasks/3566-main-page-loading-perf/tools/trace.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Record page-load performance traces without the DevTools UI.
//
// Produces the same JSON the Performance panel's "Record and reload" export produces, so the output
// feeds straight into trace-metrics.py. Use it when you want several runs per variant (M3/M4 need a
// median) or a repeatable A/B — the manual protocol in README.md is still fine for a one-off.
//
// Usage, against an already-running production server (`pnpm prod:preset <alias>`):
//
// node .agents/tasks/3566-main-page-loading-perf/tools/trace.mjs http://localhost:3000/ ./traces/after 3
// python3 .agents/tasks/3566-main-page-loading-perf/tools/trace-metrics.py ./traces/before-2.json ./traces/after-2.json
//
// Writes <out-prefix>-<n>.json for n in 1..runs.

/* eslint-disable no-console -- a CLI tool: stdout is its interface, for the usage hint and for
reporting each trace it wrote. */

import { chromium } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';

// Long enough for the transactions/blocks lists to fill with real data, which M3/M4 measure.
const SETTLE_MS = 10_000;

// The capture set the Performance panel uses: devtools.timeline for resources/tasks/render commits,
// loading for navigationStart, blink.user_timing for paint marks, __metadata for thread names
// (trace-metrics.py needs those to tell CrRendererMain apart from other threads).
const CATEGORIES = [
'-*',
'devtools.timeline',
'disabled-by-default-devtools.timeline',
'disabled-by-default-devtools.timeline.frame',
'blink.user_timing',
'loading',
'latencyInfo',
'v8.execute',
'__metadata',
];

const [ url, outPrefix, runsArg ] = process.argv.slice(2);
if (!url || !outPrefix) {
console.error('Usage: node trace.mjs <url> <out-prefix> [runs=1]');
process.exit(2);
}
const runs = Number(runsArg ?? 1);

mkdirSync(dirname(outPrefix), { recursive: true });

// A fresh context per run is the scripted equivalent of the protocol's "clean browser profile":
// no extensions, no warm HTTP cache, no carried-over service worker.
const browser = await chromium.launch();

for (let run = 1; run <= runs; run++) {
const context = await browser.newContext();
const page = await context.newPage();
const client = await context.newCDPSession(page);

const events = [];
client.on('Tracing.dataCollected', ({ value }) => events.push(...value));
const complete = new Promise((resolve) => client.once('Tracing.tracingComplete', resolve));

// Tracing has to start before the navigation — that is what "Record and reload" does, and
// navigationStart is the zero point every metric is relative to.
await client.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: { includedCategories: CATEGORIES, recordMode: 'recordAsMuchAsPossible' },
});

await page.goto(url, { waitUntil: 'load', timeout: 60_000 });
await page.waitForTimeout(SETTLE_MS);

await client.send('Tracing.end');
await complete;
await context.close();

const out = `${ outPrefix }-${ run }.json`;
writeFileSync(out, JSON.stringify({ traceEvents: events }));
console.log(`${ out }: ${ events.length } events`);
}

await browser.close();
Loading
Loading