fix: stop every /request/<id> URL returning HTTP 500 - #116
Merged
Conversation
html2pdf.js 0.10.2 — the version package-lock.json pins — invokes its UMD
wrapper with a bare `self` identifier, evaluated before the inner
require()s and with no `typeof self !== "undefined"` guard. Node has no
global `self`, so merely importing the module server-side throws. It was
imported at module scope in use-export-pdf.tsx, whose only importer is
request/[id]/page.tsx — which is exactly why that one route 500ed while
every other route returned 200.
Reproduced locally before the fix:
ReferenceError: self is not defined
at 11378 (.next/server/app/request/[id]/page.js)
at Object.t [as require] (.next/server/webpack-runtime.js)
The import now happens inside the export handler, so it only ever
evaluates in a browser. Side benefit: the route's page bundle drops from
196 kB to 22.8 kB and its first-load JS from 1.02 MB to 851 kB, since
html2pdf no longer ships eagerly.
CI could not have caught this. `next build` compiles the route but never
requests it — page.tsx has no generateStaticParams, so the build marks it
"ƒ (Dynamic) server-rendered on demand". `npm run check` is
`biome check --write`, formatting only. There are no tests in the repo. A
request-time smoke check is added in the follow-up commit.
Also in this commit, all found while tracing the above:
- Add app/error.tsx, app/global-error.tsx and app/not-found.tsx. The app
had no App Router error boundary at all, so any render-time throw
escaped to a raw 500 with no recovery. Neither file leaks an error
message or stack to the user.
- Stop reporting a missing request as 200. The page called
redirect("/not-found"), a 307 to a page that itself returns 200 — a
soft 404 telling crawlers a nonexistent request exists. Now notFound().
- Distinguish "failed" from "not found". The page read only `isLoading`,
never `error`, so a backend failure rendered as "not found". It now
gates on the query's `status`: pending → skeleton, error → error state,
success-and-empty → notFound(). Gating on `isLoading` was doubly wrong
here: with react-query v5 `isLoading` is `isPending && isFetching`, and
no fetch happens during SSR, so it is false on the server and the
skeleton guard never fired there.
- Surface errors in the three "Recent …" widgets, which ignored the
`status` their hooks already return and rendered "No data" on failure.
This is live today: the payments subgraph returns "no allocations", so
the home page currently reports empty rather than broken.
- Guard two crashers on malformed data: the fully-unguarded
`extensionsData[0].parameters` path in request-table.tsx, which throws
while evaluating arguments and therefore outside the try/catch inside
calculateShortPaymentReference (the likely cause of #89), and
BigInt(Number(gasUsed) * Number(gasPrice)) in payment-table.tsx, where
a missing field makes BigInt(NaN) throw a RangeError. The latter now
multiplies in BigInt, which also avoids losing precision above
Number.MAX_SAFE_INTEGER.
- Drop @react-pdf/renderer, which is imported nowhere.
Verified locally: /request/<id> now returns 200 with no `self is not
defined` in the server log, / and /requests still 200, and an unmatched
route correctly 404s.
Known gap, addressed separately: a nonexistent request id still returns
200 rather than 404, and crawlers still receive no content, because the
page fetches on the client. Both need a server-side fetch.
Contributor
Author
This stack of pull requests is managed by Graphite. Learn more about stacking. |
This was referenced Jul 28, 2026
Greptile SummaryFixes request-page server rendering and improves failure handling across the application.
Confidence Score: 5/5The PR appears safe to merge, with no concrete blocking or independently actionable issues identified in the changed code. The browser-only PDF dependency is deferred from server evaluation, the revised request-state branches remain reachable, malformed table data is guarded, and the new error boundaries do not depend on the normal provider tree. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Open request route] --> B{Request query status}
B -->|Pending| C[Render skeleton]
B -->|Error| D[Render error state]
B -->|Success without request| E[Invoke notFound]
B -->|Success with request| F[Render request details]
F --> G{Export PDF clicked?}
G -->|Yes| H[Dynamically import html2pdf.js]
H --> I[Generate and download PDF]
Reviews (1): Last reviewed commit: "fix: stop every /request/<id> URL return..." | Re-trigger Greptile |
bassgeta
approved these changes
Jul 29, 2026
bassgeta
left a comment
There was a problem hiding this comment.
Looks good, error handling is nice even though it's a dead repo 😅
Contributor
Author
Merge activity
|
rodrigopavezi
added a commit
that referenced
this pull request
Jul 29, 2026
One unavailable subgraph currently destroys **all** payment data. Stacked on #116. ## On #114 — do not merge it first This touches the same six files as #114 (`xdai → gnosis`, `mainnet → ethereum`). I originally suggested merging #114 first to avoid conflicts. **Having read it, I withdraw that.** #114 renames the Hasura remote-schema names *inside the GraphQL documents* (`payment_xdai` → `payment_gnosis`, `payment_mainnet` → `payment_ethereum`). Production evidence says those remotes are still `payment_xdai`/`payment_mainnet`: the SRF validation error in the console today lists both in the query text and Hasura rejects only `payment_zksyncera`'s inner field — and Hasura validates the whole document, so an unknown top-level field would have been reported too. So merging #114 as-is would take payment data from "broken on one chain" to "broken on every chain". Details and a suggested identifier-only fix are in a comment on #114. This PR does not need to wait for it. ## The problem, live in production right now The payment and SRF queries were single GraphQL documents aliasing 11-14 chain remotes at once. Hasura returns `data: null` when any one remote errors, so a single unavailable chain destroyed the entire result: ``` fetchRequestPayments Error: subgraph not found: no allocations field 'singleRequestProxyDeployments' not found in type: 'payment_zksynceraQuery' ``` Confirmed by loading the site: **`/payments` renders completely empty**, "Recent payments" shows "No data", and **every request page reports `Balance: 0`** no matter what was actually paid. Thirteen healthy chains were being discarded because of one. For actual users this is worse than the 500 in #116 — an explorer that reports every request as unpaid. ## The fix Each fan-out is now one request per chain via `Promise.allSettled`, merged through the existing unchanged formatters. A rejected chain is skipped and logged with its name; healthy chains still return data. Only a **total** failure of every chain throws — a full outage must not be indistinguishable from "no results", and #116's error states surface it. Output is byte-identical when all chains are healthy. **One thing worth knowing, preserved rather than "fixed":** today's behaviour is already per-chain top-N, not global top-N — `first: 10` across 14 chains can return 140 rows, because the formatters merge and sort but never apply a global slice. Changing that would alter visible row counts and pagination, so it deserves its own decision rather than riding along here. ## Also here - **Dropped `payment_zksyncera` from the SRF queries only** — that subgraph genuinely lacks the `singleRequestProxyDeployments` entity, so querying it could only ever error. Expressed as data in `consts.ts` via the pre-existing but unused `PAYMENT_CHAINS` enum. Note `SRF_CHAIN_REMOTES` is *not* "all chains minus zksyncera": the SRF queries never covered fantom, fuse or moonbeam, so an explicit list was the only way to preserve today's coverage exactly. - **`hooks/payments.ts` was a byte-for-byte duplicate** of `queries/payments.ts` whose `fetchPayments` swallowed errors and returned `[]`, making failure indistinguishable from empty at the query layer. Nothing imports it; it is now a thin re-export rather than a second copy of the same bug. - **A CI smoke check** that actually serves the app and requests `/request/<id>`, failing on 5xx. This is the signal that was missing for #116's bug: `next build` only compiles that route, `npm run check` is formatting only, and there are no tests. Dummy env, no secrets, polls for readiness rather than sleeping. ## Trade-off, accepted deliberately Up to **14 parallel HTTP round-trips** per logical query instead of 1. `revalidate: 60` and Hasura's `@cached` blunt it, and no batching layer or dependency was added. Worth watching Hasura rate limits. ## Not addressed - **`queries/transactions.ts` and `address-transactions.ts` have the identical fragility** against the two *storage* remotes — if `storage_sepolia` is down, mainnet requests vanish too. Left alone to keep this diff scoped; they need the same treatment. - **Restoring the missing subgraph is upstream indexer work**, not a change in this repo (likely `payments-subgraph` / indexer allocations). This PR stops one dead chain from zeroing the UI; it does **not** bring the data back. `Balance: 0` will persist until the subgraph is served again. ## Verification `npm run build` ✅, `tsc --noEmit` clean, biome clean, built query documents validated through `graphql.parse`. **Not verified:** happy-path merge against live data — the local Hasura URL is a stand-in, so this is verified by construction and by its degradation behaviour, not against production data.
rodrigopavezi
added a commit
that referenced
this pull request
Jul 29, 2026
Makes the request page serve the actual request in its HTML, instead of an empty skeleton. Top of the stack, on #117. #116 stopped the 500s, but the page still served nothing useful: it fetched on the client, so the HTML was a skeleton with **zero** request data. For the SEO audit that started this, a 200 with an empty shell is barely better than the 500 it replaced. `page.tsx` is now an async server component that awaits `fetchRequest` and passes the `Channel` to a new client component, `request-details.tsx`, which keeps everything genuinely browser-bound: PDF export, the JSON viewer, clipboard buttons, `TimeAgo`, `useState`. The relocation is verbatim — same markup, classNames, copy, table structure. **Verified** against a local stand-in backend: the served HTML now contains the gateway, payee, payer, expected amount, payment reference, encryption status, and the transactions table. Before, none of it was there. Plus `generateMetadata`, so these pages finally have a real title and description for search results and link previews. ## Two findings worth reading ### json-edit-react cannot be server-rendered Its theme provider calls `document.documentElement.style.setProperty("--jer-highlight-color", …)` **during render**. The moment the details actually server-rendered, that threw `ReferenceError: document is not defined`, React bailed out to client rendering, and the HTML went straight back to being an empty shell — the twin of #116's bug, hidden until now because SSR had never got as far as rendering this component. It is now loaded via `next/dynamic` with `ssr: false`. The raw JSON blob is not the indexable content, so nothing is lost. Note this is a *render-time* touch of `document`, not an import-time one — which is why an import-level audit of the very same file came back clean. Worth remembering when auditing the next one. ### A missing request still returns 200, not 404 It renders the not-found UI, but the status is wrong, and this is a framework limit rather than something left undone. The route streams (`Transfer-Encoding: chunked`), so the HTTP status commits when the shell flushes — before the lookup resolves. `notFound()` can then only swap the rendered UI. Both placements were tried and measured: | Approach | Result | | --- | --- | | `notFound()` from the page body | 200 | | `notFound()` from `generateMetadata` | 200 | A real 404 needs the existence check to run before the response commits — i.e. middleware, with a Hasura call on every request. That is a bigger trade than this PR should make unilaterally, so I stopped and flagged it instead. **What I did instead addresses the actual harm:** a missing request is served `<meta name="robots" content="noindex, nofollow">`, which is what stops soft 404s accumulating in the search index. Valid requests are not noindexed — checked both ways. ## Implementation notes - The request lookup is wrapped in React's `cache()` keyed on the id, so `generateMetadata` and the page share **one** network call. The fetch-level cache cannot do this: `graphql-request` issues a POST, which Next's Data Cache does not dedupe, and the `cache()` inside `graphQlClient` is keyed on a fresh `RequestInit` per call. - A thrown fetch is deliberately left to propagate to the error boundary. A backend outage must not be reported as "this request does not exist" — that distinction was established in #116 and is preserved here. - The two dependent queries (payments, SRF deployments) stay client-side: they hang off a payment reference derived from the request, they are the polling surface via `refetchInterval`, and keeping them there guarantees neither can affect the HTTP status or block the request's own content. - **One deliberate behaviour change:** the page no longer blocks on a full-page skeleton while payments load. The four payments-derived cells — Status, Balance, Modified, and the payments table — render their own inline loading state. Without that they would briefly display *wrong* values (`Balance: 0`, Status taken from the last transaction instead of "Paid") before the query lands. Everything else renders immediately from the server. ## Verification `npm run check` ✅, `npm run build` ✅, `tsc --noEmit` clean, zero SSR errors in the server log, and against a local stand-in backend: | Check | Result | | --- | --- | | Request fields in served HTML | ✅ present (were absent) | | Missing request → `noindex` | ✅ | | Valid request → not noindexed | ✅ | | Missing request → 404 status | ❌ 200 — framework limit, documented above |
rodrigopavezi
added a commit
that referenced
this pull request
Jul 30, 2026
Bumps the version `0.3.4` → `0.4.0` and regenerates the lockfile. Covers the three fixes just merged: - **#116** — every `/request/<id>` URL returned HTTP 500 (`html2pdf.js` evaluating a bare `self` at import time under Node) - **#117** — one unavailable subgraph wiped out all payment data; queries are now per-chain, plus the repo's first tests - **#118** — the request page is server-rendered, so crawlers and link previews receive the request instead of an empty skeleton Minor rather than patch, since #118 changes how that route renders and #117 changes the query layer's failure behaviour. ## Notes - **The lockfile diff is only the two version fields** — no dependency was upgraded as a side effect. Worth stating, since running `npm install` on a bump can quietly pull newer versions within existing ranges. - The footer version badge reads `version` from `package.json` (`src/components/ui/version-badge.tsx`), so it picks this up with no code change — it will read `0.4.0` once deployed. ## Verification `npm run check` ✅ · `npm test` 5/5 ✅ · `npx tsc --noEmit` ✅ · `npm run build` ✅ ## Heads-up on production Production is still serving the 500 on `/request/<id>`. Staging auto-deployed on merge and is verified returning 200, but production deploys only on a **published release** — so the three merged fixes are not live on `scan.request.network` yet. This bump is presumably the version you'd cut that release at; the release itself is a human action and I have not taken it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Every
/request/<id>URL on scan.request.network returns HTTP 500. This fixes it. Bottom of a 3-PR stack; independently mergeable.Came out of an SEO audit (Linear REQ-285) that listed 132 request URLs returning 5XX. It is not 132 broken pages — it is one bug, and the set is unbounded: a request created minutes ago 500s just the same.
Root cause
html2pdf.js0.10.2 — the versionpackage-lock.jsonpins — invokes its UMD wrapper with a bareselfidentifier, evaluated before the innerrequire()s and with notypeof self !== "undefined"guard. Node has no globalself, so importing it server-side throws. It was imported at module scope inuse-export-pdf.tsx, whose only importer isrequest/[id]/page.tsx— which is exactly why that one route 500ed while every other returned 200.Reproduced locally before the fix:
The import now happens inside the export handler, so it only evaluates in a browser.
Impact worth stating precisely: Next.js bails out to client rendering after the SSR throw, so a real browser renders the page fine — users clicking through from the homepage were unaffected. Crawlers, shared links, hard refreshes and anything status-code-sensitive got a 500 with no content.
Side benefit: the route's page bundle drops 196 kB → 22.8 kB and first-load JS 1.02 MB → 851 kB, since html2pdf no longer ships eagerly.
Why CI never caught this
next buildcompiles the route but never requests it —page.tsxhas nogenerateStaticParams, so the build marks itƒ (Dynamic) server-rendered on demand.npm run checkisbiome check --write, formatting only, and--writemeans it cannot fail on anything auto-fixable. There are no tests in the repo. PR #117 adds the request-time smoke check that would have caught it.Also in this PR
All found while tracing the above:
app/error.tsx, noglobal-error.tsx, no rootnot-found.tsx(app/not-found/page.tsxis an ordinary route, not the convention file). Any render-time throw escaped to a raw 500. Added all three; neither leaks an error message or stack.redirect("/not-found")— a 307 to a page that itself returns 200, i.e. a soft 404 telling crawlers a nonexistent request exists. NownotFound().isLoading, nevererror, so a backend failure rendered as "not found". Now gates on the query'sstatus: pending → skeleton, error → error state, success-and-empty →notFound().isLoadingwas doubly wrong here — with react-query v5 it isisPending && isFetching, and no fetch happens during SSR, so it is false on the server and the skeleton guard never fired there.statustheir hooks already return and rendered "No data" on failure. This is live today: the payments subgraph returnsno allocations, so the home page currently reports empty rather than broken.extensionsData[0].parameterspath inrequest-table.tsxthrows while evaluating arguments, and therefore outside the try/catch insidecalculateShortPaymentReference— the likely cause of Request Scan Requests page crashes due to "Error calculating short payment reference" #89. AndBigInt(Number(gasUsed) * Number(gasPrice))inpayment-table.tsx, where a missing field makesBigInt(NaN)throw aRangeError; it now multiplies in BigInt, which also avoids losing precision aboveNumber.MAX_SAFE_INTEGER.@react-pdf/renderer, imported nowhere. The lockfile is regenerated to match — worth noting becausenpm cifails on a package.json/lockfile mismatch, so CI would have broken on its first job otherwise.Likely also closes #72 (an unindexed request showing as "404") now that error and not-found are distinct states.
Verification
npm ci→npm run check→npm run build, then a real server:/request/<id>/,/requests,/paymentsself is not definedin logKnown gap, fixed in #118
The page still fetches on the client, so the served HTML is a skeleton with no request data, and a nonexistent id returns 200 rather than 404. Both need a server-side fetch — that is #118.