Skip to content

fix(deploy): reload documents left running on a superseded deployment - #2563

Open
innolope-dev wants to merge 1 commit into
devfrom
fix/stale-deployment-reload
Open

fix(deploy): reload documents left running on a superseded deployment#2563
innolope-dev wants to merge 1 commit into
devfrom
fix/stale-deployment-reload

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Summary

Investigated as a "CSP regression removing RPC endpoints from connect-src". That turned out not to exist — but the investigation surfaced a real defect underneath it, which is what this PR fixes.

The reported CSP regression is not real

It also was not a spike that started 22 hours ago: PEANUT-UI-R79 has been open since 2026-07-22 (8,002 events / 406 users). Sentry's issue search reports "first seen" relative to the query window, which is what made a week-old issue look new.

What those 7,800 events actually are

Every sampled violation carries a superseded original-policy:

Issue policy vintage report-uri
PEANUT-UI-R79 (chainstack, 8,002 occ / 406 users) pre-#2479 — no RPC hosts at all Sentry-direct
PEANUT-UI-R7M (wss://api.peanut.me, 5,083 occ / 1,603 users) post-#2479, pre-#2519 — no wss: Sentry-direct

They come from documents loaded days earlier and still open. Those documents also carry the old report-uri pointing straight at Sentry's ingest endpoint, so #2519's collector (/api/csp-report, de-dup + 1% sampling) is structurally unable to filter them. No code change suppresses that traffic.

The actual defect — and what this PR fixes

1,603 users were running weeks-old bundles. A loaded document can outlive arbitrarily many deployments, keeping its original JS chunks, API assumptions and response headers, because three things compound:

  • src/app/layout.tsx skips window.location.reload() on controllerchange in standalone PWA — deliberate, an Android standalone session can bounce out to Chrome.
  • App Router navigations fetch RSC payloads, never a document, so headers are never refreshed.
  • src/app/sw.ts spreads serwist's defaultCache; browser navigations fall through its others NetworkFirst rule, and a cached Response replays the headers it was stored with.

There was no deployment-version check anywhere. The only reload-on-stale machinery, src/utils/chunk-error-recovery.ts, is reactive (fires once an asset 404s), rate-limited, and also PWA-disabled.

Changes

File
src/app/api/version/route.ts new — force-dynamic, no-store, returns the live deployment's commit
src/hooks/useStaleDeploymentReload.ts new — detect, gate, purge, reload
src/components/Global/StaleDeploymentReload/index.tsx new — null-rendering mount point
src/app/ClientProviders.tsx mount it inside the provider tree
src/constants/cache.consts.ts add DOCUMENT_CACHE_PATTERNS
src/utils/cache.utils.ts new — purgeCaches / isStandalonePwa
src/context/authContext.tsx reuse purgeCaches instead of the inline copy

Detection. NEXT_PUBLIC_GIT_COMMIT_HASH is already inlined at build time (next.config.js), so a stale bundle holds the old hash while /api/version — always executed by the live deployment — returns the current one. Checked on mount (catches a document served back out of the SW cache), on tab re-focus, and on a 30-minute poll. The poll matters: a tab that is never backgrounded and never navigated is exactly the shape of the sessions that stayed stale for days, and mount-only detection would miss them entirely.

Reload timing is deliberately conservative. A forced reload mid-flow can destroy state that only lives in component memory (a Sumsub WebSDK session, a half-filled card application) or leave a user unsure whether a payment went through. Staleness is latched on detection and acted on only at a safe boundary — a route change, or a flow finishing — with the gate clear:

Signal Source
hasPendingTransactions usePendingTransactions (BALANCE_DECREASE mutations)
isSendingUserOp zerodev redux slice
isLoading loading-state context (covers 'Executing transaction', 'Withdrawing', 'Paying', 'Submitting Offramp', 'Awaiting KYC confirmation')
path deny-list card, withdraw, setup, add-money, kyc — matched per segment, so /en/card is covered too

Caches are purged before reloading. Otherwise the reload can be answered out of Cache Storage with the same stale document and its stale headers.

Loop guard. A one-attempt sessionStorage latch: if a reload does not clear the mismatch, the reload is not the fix and the hook stands down for the session rather than looping. The latch is cleared once the bundle is current again, so a later deploy can still act.

Mounted inside the providers rather than called in ClientProviders' body like useOtaUpdates, because it reads the query client, redux and the loading-state context. Inert on Capacitor — native serves local files and updates through Capgo.

Verification

  • 22 new unit tests, covering each gate signal, the deny-list, the loop guard, the standalone path, the poll, and both failure modes of the endpoint.
  • tsc --noEmit: no errors in any touched file.
  • pnpm build succeeds; /api/version builds as ƒ (dynamic).
  • Probed against the built server: {"commit":"134cf01"} with Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, matching git rev-parse --short=7 HEAD.
  • Full suite 2,300 passing. The 2 failing suites (countryCurrencyMapping, lib/content) are fresh-worktree artifacts — uninitialized src/content submodule and missing generated public/flags — not touched here.

Risks

  • Standalone PWA needs a real-device check before merge. This intentionally does not copy the blanket PWA skip — that skip is the main reason documents survive. It uses window.location.replace() rather than reload() to avoid the documented Android PWA→Chrome bounce, but that has only been reasoned about, not observed on hardware. If the bounce reproduces, the fallback is to degrade standalone to a "New version available — Reload" prompt and keep the automatic path for browser tabs.
  • This helps future deploys only. Already-stale documents cannot receive this code, so the current ~1,400 CSP events/day will decay as those sessions die, not drop on merge. That is expected, not a failed fix.
  • Adds one small serverless invocation per open tab per 30 minutes.

Not in scope

#2543's collector follow-ups (Vercel WAF rate-limit on /api/csp-report, route-level tests) are unchanged and still open.

A loaded document can outlive arbitrarily many deploys, keeping its original
JS chunks, API assumptions and response headers. The service worker's
controllerchange reload is skipped in standalone PWA (an Android standalone
session can bounce out to Chrome), App Router navigations fetch RSC payloads
rather than documents, and chunk-error recovery only fires once an asset
404s. 1,603 users were on weeks-old bundles — visible as CSP violation
reports replaying allow-lists that had already been fixed.

/api/version returns the live deployment's commit; useStaleDeploymentReload
compares it against the build-inlined NEXT_PUBLIC_GIT_COMMIT_HASH on mount,
on tab re-focus and on a 30-minute poll. The mount-only case is exactly the
shape of the sessions that stayed stale for days, so the poll is what makes
this land on the real population.

Reload timing is deliberately conservative: a forced reload mid-flow can
destroy state that only lives in component memory (a Sumsub WebSDK session, a
half-filled card application) or leave a user unsure whether a payment went
through. Staleness is latched on detection and acted on only at a safe
boundary — gated on pending balance-decreasing mutations, the zerodev user-op
flag, the loading-state context, and a path deny-list.

Document caches are purged before reloading: a cached Response replays the
headers it was stored with, so the reload could otherwise be answered from
cache with the same stale document. A one-attempt sessionStorage latch stands
the hook down if a reload does not clear the mismatch, instead of looping.

purgeCaches/isStandalonePwa are extracted to cache.utils and reused by logout,
which had the purge inline.
@innolope-dev innolope-dev self-assigned this Jul 29, 2026
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 29, 2026 8:35am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1bf66b70-9f5d-4be9-80db-2959347d80c9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6656.05 → 6675.28 (+19.23)
Findings: +8 net (+18 new, -10 resolved)

🆕 New findings (18)

  • high complexity — src/hooks/useStaleDeploymentReload.ts — CC 37, MI 61.19, SLOC 140
  • high hotspot — src/context/authContext.tsx — 33 commits, +255/-198 lines since 6 months ago
  • medium high-mdd — src/context/authContext.tsx:65 — AuthProvider: MDD 78.7 (uses across many lines from declarations)
  • medium high-dlt — src/context/authContext.tsx:65 — AuthProvider: DLT 42 (calls 42 distinct functions — high context load)
  • medium high-mdd — src/hooks/useStaleDeploymentReload.ts:73 — useStaleDeploymentReload: MDD 29.7 (uses across many lines from declarations)
  • medium high-dlt — src/hooks/useStaleDeploymentReload.ts:73 — useStaleDeploymentReload: DLT 30 (calls 30 distinct functions — high context load)
  • medium complexity — src/context/authContext.tsx — CC 25, MI 56.41, SLOC 185
  • medium high-mdd — src/context/authContext.tsx:200 — : MDD 21.0 (uses across many lines from declarations)
  • medium complexity — src/constants/cache.consts.ts — CC 1, MI 63.08, SLOC 10
  • low high-dlt — src/context/authContext.tsx:200 — : DLT 17 (calls 17 distinct functions — high context load)
  • low high-mdd — src/context/authContext.tsx:134 — addAccount: MDD 16.0 (uses across many lines from declarations)
  • low high-mdd — src/context/authContext.tsx:86 — : MDD 11.3 (uses across many lines from declarations)
  • low missing-return-type — src/app/api/version/route.ts:18 — GET: exported fn missing return type annotation
  • low missing-return-type — src/app/ClientProviders.tsx:36 — ClientProviders: exported fn missing return type annotation
  • low missing-return-type — src/components/Global/StaleDeploymentReload/index.tsx:10 — StaleDeploymentReload: exported fn missing return type annotation
  • low nextjs-use-client-overuse — src/components/Global/StaleDeploymentReload/index.tsx:1 — 'use client' but no hooks/handlers/browser API
  • low missing-return-type — src/context/authContext.tsx:65 — AuthProvider: exported fn missing return type annotation
  • low missing-return-type — src/hooks/useStaleDeploymentReload.ts:73 — useStaleDeploymentReload: exported fn missing return type annotation

✅ Resolved (10)

  • src/context/authContext.tsx — 32 commits, +253/-186 lines since 6 months ago
  • src/context/authContext.tsx:64 — AuthProvider: MDD 77.3 (uses across many lines from declarations)
  • src/context/authContext.tsx:64 — AuthProvider: DLT 46 (calls 46 distinct functions — high context load)
  • src/context/authContext.tsx — CC 29, MI 57.6, SLOC 202
  • src/context/authContext.tsx:199 — : MDD 23.5 (uses across many lines from declarations)
  • src/context/authContext.tsx:199 — : DLT 23 (calls 23 distinct functions — high context load)
  • src/context/authContext.tsx:133 — addAccount: MDD 16.0 (uses across many lines from declarations)
  • src/context/authContext.tsx:85 — : MDD 11.3 (uses across many lines from declarations)
  • src/app/ClientProviders.tsx:35 — ClientProviders: exported fn missing return type annotation
  • src/context/authContext.tsx:64 — AuthProvider: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/useStaleDeploymentReload.ts 0.0 7.5 +7.5
src/utils/cache.utils.ts 0.0 4.5 +4.5
src/app/api/version/route.ts 0.0 3.0 +3.0
src/components/Global/StaleDeploymentReload/index.tsx 0.0 2.8 +2.8
src/constants/cache.consts.ts 3.1 4.1 +1.0

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2328 ran, 0 failed, 0 skipped, 29.5s

📊 Coverage (unit)

metric %
statements 61.0%
branches 44.3%
functions 50.3%
lines 61.6%
⏱ 10 slowest test cases
time test
2.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
0.8s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.2s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.2s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.1s src/utils/__tests__/auth-token.test.ts › returns the token hydrated from Preferences after authReady
0.1s src/utils/__tests__/url.utils.test.ts › uses the public BASE_URL in Capacitor, not the localhost WebView origin
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

innolope-dev added a commit that referenced this pull request Jul 29, 2026
…s, PIX exit, verification tasks

Cherry-picks the open native-facing work onto the release branch:

- KYC now routes every native entry point through the Sumsub Cordova SDK
  instead of the WebSDK (peanut-ui #2562) — the one change here that genuinely
  requires a new binary rather than an OTA.
- Deferred deep linking survives the store install, so a link followed before
  install lands on its destination on first launch (#2560, TASK-20772).
- PIX deposit "Done" exits to home from both the completed and processing
  states instead of dropping the user into a new deposit (#2548).
- Pending Bridge verification tasks card on home, dismissible and resurfaced
  under Unlocked regions (#2549).
- Native demo/passkey follow-ups: the awaited token clear in clearAuthState
  plus the two callers it changed (#2517 — the rest shipped in 1.0.40).
- Documents left running on a superseded deployment now reload (#2563).
- Android safe zone sized from natively measured insets.

Not carried: the CSP allow-list gaps (#2564). It builds on the #2519 collector
rework — a /api/csp-report route and ten commits that are not on this branch —
and the policy is report-only, so the gaps cost report fidelity, not behaviour.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant