fix: recover from stale Vite route chunks after Coolify deploy — Page unavailable / Try Again cannot load PoolPage-*.js #706

Closed
opened 2026-08-30 02:48:09 +00:00 by PlasticDigits · 3 comments
PlasticDigits commented 2026-08-30 02:48:09 +00:00 (Migrated from gitlab.com)

Summary

Retail report on production https://dex.cl8y.com: navigating to a lazy route (observed: Pool) paints Page unavailable with copy This page could not load. You may be offline or the app was updated — check your connection and try again. Console:

[ErrorBoundary] Unhandled error: TypeError: error loading dynamically imported module: https://dex.cl8y.com/assets/PoolPage-<hash>.js

(Firefox wording; Chrome uses Failed to fetch dynamically imported module. The hashed filename is a Vite content hash, e.g. PoolPage-BrgV5Tp1.js. The running shell was index-<hash>.js.)

This is not a PoolPage runtime crash and not an LCD/indexer outage. The route-level ErrorBoundary from #172 is classifying the failure correctly. Try Again re-imports the same dead hashed URL baked into the already-loaded shell, so an in-tab session after a Coolify frontend roll cannot recover without a full document reload.

Same failure on every LazyRoute page (/charts, /trade, /protocol, /token/create, …) the tab has not yet visited. Already-mounted routes (often Swap) keep working.

Environment (production)

  • Host: https://dex.cl8y.com (Coolify nginx image, docker/frontend/Dockerfile)
  • Symptom: in-session SPA navigation after a frontend deploy (or any time the hashed chunk named by the in-memory shell is gone)
  • Console: ErrorBoundary + dynamic import() of /assets/<Page>-<hash>.js
  • Not: cold load while fully offline (that is Chrome’s native error page, not this UI — #172 QA precondition)

Current codebase

Lazy routes + hashed chunks

frontend-dapp/src/App.tsx wraps every page in LazyRoute:

<Route path="/pool" element={<LazyRoute loader={() => import('./pages/PoolPage')} />} />

Production Vite emits content-hashed files (PoolPage-<hash>.js, index-<hash>.js) via frontend-dapp/vite.config.ts manualChunks. Coolify builds a new nginx image and replaces /usr/share/nginx/html atomically. Previous hashes are deleted. There is no asset-generation overlay.

docker/frontend/nginx.conf:

Path Cache
location = /index.html Cache-Control: no-cache
*.js / *.css / images / fonts expires 7d + Cache-Control: public, immutable
SPA routes (/pool, …) try_files → /index.html

A new visit therefore fetches a new shell. A long-lived tab keeps executing the old index-*.js, whose dynamic import map still names PoolPage-<oldhash>.js. That file 404s.

#172 only recovers offline retries, not stale deploys

LazyRoute bumps loadAttempt so Try Again constructs a fresh React.lazy(loader). That is required because lazy() caches a rejected promise (#172). It does not change the URL inside the already-evaluated bundle. Re-import() of a 404 hashed file stays a 404 forever.

Route ErrorBoundary (isRoute) always shows Try Again (onRetry → loadAttempt++). App-level ErrorBoundary shows Reload App (location.reload()) for chunk errors — but this stack is route-scoped (Lazy → Suspense → Layout), so users never get Reload App.

Classifier isChunkLoadError already matches Firefox error loading dynamically imported module, Chrome Failed to fetch dynamically imported module, Safari Importing a module script failed, and webpack Loading chunk N failed. Vitest covers Chrome/dev strings; Firefox production wording is not an explicit fixture.

Retail copy (CHUNK_LOAD_ROUTE_MESSAGE / humanizeOffChainError.ts) lumps offline and stale deploy together and tells the user to “try again” — which cannot fetch a new shell.

What is already correct (do not regress)

  • Route-scoped fallback inside Layout: header/nav stay; funds/wallet chrome remain (#172 W11-C3).
  • Try Again must still re-import when the failure is a transient network miss (user was offline, now online, same deploy hashes still on disk).
  • Technical-details scrub of localhost / module URLs (sanitizeChunkLoadTechnicalDetail).
  • Trader resetKeys (#126); Trade Suspense skeleton (#179).
  • Production script-src 'self' (#378); no service worker in this repo.
  • Hashed assets immutable for 200 responses (cache-bust by filename). HTML shell must not be cached as immutable.
  • Do not treat LCD/indexer Failed to fetch as a chunk error (classifier order in tryHumanizeFetchLikeMessage already prefers dynamic-import before generic fetch).

Why the new implementation is needed

  1. Coolify frontend deploys are frequent. Every roll deletes old PoolPage-*.js. Any trader who leaves the dApp open (hours/days) and then clicks Pool / Charts / Trade hits this wall.
  2. #172 UX is a dead end for this cause. The copy mentions “the app was updated” but the only control re-requests the deleted file. Retail users do not know to hard-refresh.
  3. It looks like the DEX is down. Wallet, LCD, and indexer can be healthy. Support will chase the wrong layer.
  4. Reload is the only way to pick up a new index.html. Client one-shot location.reload() (with a loop guard) is the standard Vite SPA fix. Optional nginx hygiene reduces deploy-race 404 caching; it does not replace the client reload.

Constraints / guardrails

  1. One-shot reload only. Use sessionStorage (or equivalent) so a broken live deploy (new shell that still 404s its own chunks) cannot infinite-reload. After a successful lazy page mount, clear the guard so a later deploy in the same tab can recover again.
  2. Do not reload when navigator.onLine === false. Keep #172 Try Again re-import for offline → online. Reload cannot invent a network.
  3. Reload only on isChunkLoadError. Do not location.reload() on render/logic errors — that loops on a real bug.
  4. Same-origin document reload only. window.location.reload() or assign window.location.href = window.location.pathname + search + hash. Never take the failed module URL, document.referrer, or query params as a navigation target (open redirect / XSS).
  5. No service worker unless a follow-up explicitly designs cache versioning. Do not add Workbox in this ticket.
  6. Do not stop code-splitting. Do not eagerly import() every page into the main bundle to “fix” 404s (LCP / #179). Idle prefetch of route chunks is optional and must not block first paint.
  7. Do not keep N generations of hashed files in the Docker image as the primary fix (Coolify image replace). Optional later ops; out of scope if client reload + HTML no-cache are correct.
  8. nginx: hashed 200 stay public, immutable. 404 of *.js must not be cached as immutable (deploy race: new HTML names a chunk not yet visible). SPA HTML stays no-cache (consider must-revalidate) including internal try_files → /index.html.
  9. CSP unchanged. Reload is same-origin; do not add unsafe-eval or extra script-src.
  10. Copy: if we auto-reload, a brief “Updating…” / existing Suspense fallback is enough — do not flash Page unavailable then immediately reload. If the guard already fired, then show the existing route card with a control that reloads the app (not only re-import).
  11. No secrets / mnemonics in reload query strings. Do not persist wallet state in the chunk-reload key.
  12. Docs + playbook: extend docs/frontend.md § Lazy route chunks and skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md. Add make verify-issue-<iid>.

Relevant files

File Role
frontend-dapp/src/components/common/LazyRoute.tsx loadAttempt + lazy(loader); needs stale-vs-offline split
frontend-dapp/src/components/common/ErrorBoundary.tsx Route Try Again vs app Reload App; chunk headline
frontend-dapp/src/utils/chunkLoadError.ts isChunkLoadError, retail strings, URL scrub
frontend-dapp/src/utils/humanizeOffChainError.ts Dynamic-import copy before generic fetch
frontend-dapp/src/App.tsx All LazyRoute pages (not Pool-only)
frontend-dapp/src/components/common/__tests__/LazyRoute.test.tsx Try Again re-import
frontend-dapp/src/utils/__tests__/chunkLoadError.test.ts Classifier + sanitize
docker/frontend/nginx.conf HTML vs hashed-asset cache
docker/frontend/Dockerfile Single-generation dist
docs/frontend.md § Lazy route chunks Invariants
skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md Agent playbook (#172)
docs/runbooks/rollback-decision.md Notes stale index.html
  1. Helper (e.g. reloadOnceOnStaleChunk(error) in chunkLoadError.ts):
    • Return false unless isChunkLoadError.
    • Return false if navigator.onLine === false.
    • If sessionStorage key (e.g. cl8y-dex-stale-chunk-reload) is set, return false (show UI).
    • Else set the key and window.location.reload().
    • Clear the key from LazyRoute / RouteContentReadyMarker when a lazy page successfully mounts.
  2. ErrorBoundary / LazyRoute: on chunk error, attempt the helper before painting Page unavailable. If it returns false, route UI Reload app (location.reload()) in addition to Try Again (Try Again = re-import for transient miss; Reload app = new shell).
  3. Classifier tests: Firefox error loading dynamically imported module: https://dex.cl8y.com/assets/PoolPage-….js; Safari import-script-failed; production hashed URL must still sanitize to [module] / generic sentence.
  4. nginx: location for hashed assets: add_header Cache-Control "public, immutable" only on 200 (or error_page 404 without immutable). Confirm /pool HTML is not immutable (curl -I on / and /pool).
  5. Optional (not required): requestIdleCallback prefetch of the same import() map used in App.tsx after first paint — still must reload if hashes changed mid-session.

Do not special-case PoolPage. The failing URL is whichever lazy chunk the old shell requested.

Acceptance criteria

  • C1. After a production-like hash change (old index-*.js in memory, new /assets/PoolPage-*.js 404), navigating to /pool (and another lazy route) recovers via a single document reload and shows the real page — not a stuck Page unavailable.
  • C2. If the chunk 404s again after that reload (broken deploy), the app does not loop-reload; route card is shown with Reload app + Try Again.
  • C3. Offline (navigator.onLine === false): no auto-reload; existing Page unavailable + Try Again re-import still works when back online and hashes still exist (#172).
  • C4. Non-chunk React errors still do not location.reload(); app vs route copy unchanged for those.
  • C5. Firefox, Chrome, and Safari chunk strings classify via isChunkLoadError; technical details never echo full https://dex.cl8y.com/assets/….
  • C6. Header/nav remain on the fallback path; wallet session is not wiped except by the normal full reload.
  • C7. Hashed 200 JS/CSS still long-cache immutable; HTML / SPA routes are not immutable; 404 JS is not advertised as immutable.
  • C8. Docs + AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md describe stale-deploy vs offline; make verify-issue-<iid> covers unit (+ nginx header script or documented curl).
  • C9. No new service worker; no main-bundle of all pages; CSP unchanged.

Test plan (all paths)

Unit (Vitest)

  1. isChunkLoadError true for:
    • Failed to fetch dynamically imported module: https://dex.cl8y.com/assets/PoolPage-BrgV5Tp1.js
    • error loading dynamically imported module: https://dex.cl8y.com/assets/PoolPage-BrgV5Tp1.js (Firefox)
    • Importing a module script failed.
    • ChunkLoadError: Loading chunk 3 failed.
  2. False for TypeError: Failed to fetch (indexer), LCD timeouts, contract Max spread, user reject.
  3. Sanitize production asset URLs from technical details.
  4. reloadOnceOnStaleChunk:
    • online + first chunk error → sets storage, calls reload (mock location.reload).
    • storage already set → no reload.
    • navigator.onLine === false → no reload, no storage write (or write is harmless).
    • non-chunk Error → no reload.
  5. Successful lazy mount clears the storage key (mock).
  6. LazyRoute: offline-style reject then Try Again still increments import() count (#172 test stays green).
  7. LazyRoute: after helper already fired, UI shows Reload app; click calls location.reload (not only loadAttempt++).
  8. App-level boundary chunk path still Reload App (#172).

nginx / image

  1. curl -sI https://dex.cl8y.com/ and /pool: HTML Cache-Control contains no-cache (or max-age=0 / must-revalidate), not immutable.
  2. curl -sI a live hashed /assets/index-*.js: immutable (or long expires).
  3. curl -sI https://dex.cl8y.com/assets/PoolPage-does-not-exist.js: 404; Cache-Control must not be public, immutable.
  4. Local: docker build -f docker/frontend/Dockerfile smoke or a repo fixture nginx config test if one exists / is added.

Manual / staging (production-like)

  1. Load / on deploy A. Deploy B (new hashes). Without refresh, click Pool, Charts, Trade, Protocol, Create token. Expect one reload then the page — not a stuck card.
  2. Repeat with Firefox and Chromium.
  3. DevTools Offline → navigate to an unvisited lazy route → Page unavailable, no reload loop; go online → Try Again loads if hashes still exist.
  4. Hard-reload /pool on a healthy deploy: Pool renders (no false stale reload).
  5. Confirm Swap (already loaded) still works if the user ignores Pool and stays on /.

E2E (Playwright)

  1. Prefer a route mock: fulfill **/assets/PoolPage-*.js with 404 once, then allow; assert no infinite reload (navigation count ≤ 1 extra) and that the app recovers or shows the guarded fallback — do not depend on a real Coolify roll in CI.
  2. Existing e2e-tx / pool specs must not flake from spurious reloads.

Test plan (attack, hack, and abuse)

  1. Infinite reload DoS: attacker (or broken deploy) 404s every chunk. Guard must stop after one reload per session until a successful mount. Assert no reload in a loop in Vitest; Playwright: bounded framenavigated.
  2. Open redirect: failed import URL is https://evil.example/assets/x.js (or javascript:). Reload must not assign that URL. Only location.reload() / same-origin path.
  3. Query/hash injection: user is on /pool?x=<script> or #/evil. Reload preserves the current same-origin location; do not parse the TypeError string into href.
  4. Classifier confusion: a malicious/contract error whose message contains dynamically imported module could trigger reload. Keep matching browser chunk patterns only; do not match arbitrary LCD/indexer bodies. Document the residual (untrusted error.message is already a UI concern from #145).
  5. sessionStorage poisoning: page in iframe / foreign origin cannot set our key (same-origin). sessionStorage throw (blocked / quota) → skip auto-reload, show fallback (fail safe), no uncaught exception.
  6. Cache poisoning: CDN caches 404 of a new hash as immutable → users of the new shell never get the chunk. nginx C7 + purge runbook (docs/runbooks/rollback-decision.md). Verify 404 headers (test 11).
  7. Stale HTML at a proxy: if a CDN ignores no-cache on /index.html, reload keeps fetching the old shell. Document Coolify/CDN cache-key for / and /index.html; optional Cache-Control: no-store on HTML if a front cache is proven sticky. Do not mark JS as no-store.
  8. Clickjacking / unexpected reload: X-Frame-Options DENY stays. Reload does not bypass clickwrap (#517 / #658); a full reload may re-show Legal — acceptable.
  9. Wallet session: reload must not log the mnemonic, WC project secrets, or VITE_* to the chunk-reload key. Simulated wallet (#118) remains env-gated; production VITE_DEV_MODE still rejected (#695).
  10. CSP bypass: no new inline handlers that require 'unsafe-inline' beyond existing. No eval of the error string.
  11. Prefetch amplification (if implemented): idle prefetch must not hammer LCD/indexer; it is static JS only. Cap concurrent prefetches.
  12. Mixed-content / MIME: keep application/javascript (or text/javascript) on /assets/*.js; a 200 HTML fallback for missing JS (try_files → index.html) would execute as a module and fail — hashed location already =404. Regression: missing JS must be 404, not SPA HTML.

Verification criteria

  • make test-frontend (or scoped Vitest listed above) green.
  • make verify-issue-<iid> added and green (classifier + reload helper + nginx header checks or documented SKIP).
  • Docs/playbook mention stale Coolify hash as the production path; #172 remains the offline Try Again path.
  • Staging or production after the next frontend roll: leave a tab on Swap across the deploy, click Pool → page loads (one automatic reload acceptable).
  • Firefox + Chromium both recover.
  • Broken-chunk second failure does not reload-loop (DevTools Network: not an endless document request).
  • curl -sI on HTML vs hashed JS vs missing JS matches C7.

Related: #172 (offline lazy Try Again — closed, UX only), #179 (Trade LCP / do not un-split), #171 (LCD outage — different banner).

## Summary Retail report on **production** `https://dex.cl8y.com`: navigating to a lazy route (observed: **Pool**) paints **Page unavailable** with copy *This page could not load. You may be offline or the app was updated — check your connection and try again.* Console: ``` [ErrorBoundary] Unhandled error: TypeError: error loading dynamically imported module: https://dex.cl8y.com/assets/PoolPage-<hash>.js ``` (Firefox wording; Chrome uses `Failed to fetch dynamically imported module`. The hashed filename is a Vite content hash, e.g. `PoolPage-BrgV5Tp1.js`. The running shell was `index-<hash>.js`.) This is **not** a PoolPage runtime crash and **not** an LCD/indexer outage. The route-level ErrorBoundary from [#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172) is classifying the failure correctly. **Try Again** re-imports the **same dead hashed URL** baked into the already-loaded shell, so an in-tab session after a Coolify frontend roll cannot recover without a full document reload. Same failure on every `LazyRoute` page (`/charts`, `/trade`, `/protocol`, `/token/create`, …) the tab has not yet visited. Already-mounted routes (often Swap) keep working. ## Environment (production) - Host: `https://dex.cl8y.com` (Coolify nginx image, [`docker/frontend/Dockerfile`](docker/frontend/Dockerfile)) - Symptom: in-session SPA navigation after a frontend deploy (or any time the hashed chunk named by the in-memory shell is gone) - Console: `ErrorBoundary` + dynamic `import()` of `/assets/<Page>-<hash>.js` - Not: cold load while fully offline (that is Chrome’s native error page, not this UI — [#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172) QA precondition) ## Current codebase ### Lazy routes + hashed chunks [`frontend-dapp/src/App.tsx`](frontend-dapp/src/App.tsx) wraps every page in [`LazyRoute`](frontend-dapp/src/components/common/LazyRoute.tsx): ```tsx <Route path="/pool" element={<LazyRoute loader={() => import('./pages/PoolPage')} />} /> ``` Production Vite emits content-hashed files (`PoolPage-<hash>.js`, `index-<hash>.js`) via [`frontend-dapp/vite.config.ts`](frontend-dapp/vite.config.ts) `manualChunks`. Coolify builds a **new nginx image** and replaces `/usr/share/nginx/html` atomically. **Previous hashes are deleted.** There is no asset-generation overlay. [`docker/frontend/nginx.conf`](docker/frontend/nginx.conf): | Path | Cache | |------|--------| | `location = /index.html` | `Cache-Control: no-cache` | | `*.js` / `*.css` / images / fonts | `expires 7d` + `Cache-Control: public, immutable` | | SPA routes (`/pool`, …) | `try_files` → `/index.html` | A **new** visit therefore fetches a new shell. A **long-lived tab** keeps executing the old `index-*.js`, whose dynamic import map still names `PoolPage-<oldhash>.js`. That file 404s. ### #172 only recovers **offline** retries, not stale deploys [`LazyRoute`](frontend-dapp/src/components/common/LazyRoute.tsx) bumps `loadAttempt` so **Try Again** constructs a fresh `React.lazy(loader)`. That is required because `lazy()` caches a rejected promise ([#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172)). It does **not** change the URL inside the already-evaluated bundle. Re-`import()` of a 404 hashed file stays a 404 forever. Route `ErrorBoundary` (`isRoute`) always shows **Try Again** (`onRetry` → `loadAttempt++`). App-level `ErrorBoundary` shows **Reload App** (`location.reload()`) for chunk errors — but this stack is **route-scoped** (`Lazy` → `Suspense` → `Layout`), so users never get Reload App. Classifier [`isChunkLoadError`](frontend-dapp/src/utils/chunkLoadError.ts) already matches Firefox `error loading dynamically imported module`, Chrome `Failed to fetch dynamically imported module`, Safari `Importing a module script failed`, and webpack `Loading chunk N failed`. Vitest covers Chrome/dev strings; **Firefox production wording is not an explicit fixture.** Retail copy ([`CHUNK_LOAD_ROUTE_MESSAGE`](frontend-dapp/src/utils/chunkLoadError.ts) / [`humanizeOffChainError.ts`](frontend-dapp/src/utils/humanizeOffChainError.ts)) lumps **offline** and **stale deploy** together and tells the user to “try again” — which cannot fetch a new shell. ### What is already correct (do not regress) - Route-scoped fallback inside [`Layout`](frontend-dapp/src/components/common/Layout.tsx): header/nav stay; funds/wallet chrome remain ([#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172) W11-C3). - **Try Again must still re-import** when the failure is a **transient network** miss (user was offline, now online, **same** deploy hashes still on disk). - Technical-details scrub of localhost / module URLs (`sanitizeChunkLoadTechnicalDetail`). - Trader `resetKeys` ([#126](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/126)); Trade Suspense skeleton ([#179](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/179)). - Production `script-src 'self'` ([#378](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/378)); no service worker in this repo. - Hashed assets **immutable** for **200** responses (cache-bust by filename). HTML shell **must not** be cached as immutable. - Do not treat LCD/indexer `Failed to fetch` as a chunk error (classifier order in `tryHumanizeFetchLikeMessage` already prefers dynamic-import before generic fetch). ## Why the new implementation is needed 1. **Coolify frontend deploys are frequent.** Every roll deletes old `PoolPage-*.js`. Any trader who leaves the dApp open (hours/days) and then clicks Pool / Charts / Trade hits this wall. 2. **#172 UX is a dead end for this cause.** The copy mentions “the app was updated” but the only control re-requests the deleted file. Retail users do not know to hard-refresh. 3. **It looks like the DEX is down.** Wallet, LCD, and indexer can be healthy. Support will chase the wrong layer. 4. **Reload is the only way to pick up a new `index.html`.** Client one-shot `location.reload()` (with a loop guard) is the standard Vite SPA fix. Optional nginx hygiene reduces deploy-race 404 caching; it does not replace the client reload. ## Constraints / guardrails 1. **One-shot reload only.** Use `sessionStorage` (or equivalent) so a **broken live deploy** (new shell that still 404s its own chunks) cannot infinite-reload. After a successful lazy page mount, **clear** the guard so a *later* deploy in the same tab can recover again. 2. **Do not reload when `navigator.onLine === false`.** Keep [#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172) Try Again re-import for offline → online. Reload cannot invent a network. 3. **Reload only on `isChunkLoadError`.** Do not `location.reload()` on render/logic errors — that loops on a real bug. 4. **Same-origin document reload only.** `window.location.reload()` or assign `window.location.href = window.location.pathname + search + hash`. Never take the failed module URL, `document.referrer`, or query params as a navigation target (open redirect / XSS). 5. **No service worker** unless a follow-up explicitly designs cache versioning. Do not add Workbox in this ticket. 6. **Do not stop code-splitting.** Do not eagerly `import()` every page into the main bundle to “fix” 404s (LCP / [#179](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/179)). Idle **prefetch** of route chunks is optional and must not block first paint. 7. **Do not keep N generations of hashed files in the Docker image** as the primary fix (Coolify image replace). Optional later ops; out of scope if client reload + HTML no-cache are correct. 8. **nginx:** hashed **200** stay `public, immutable`. **404** of `*.js` must **not** be cached as immutable (deploy race: new HTML names a chunk not yet visible). SPA HTML stays `no-cache` (consider `must-revalidate`) including internal `try_files` → `/index.html`. 9. **CSP unchanged.** Reload is same-origin; do not add `unsafe-eval` or extra `script-src`. 10. **Copy:** if we auto-reload, a brief “Updating…” / existing Suspense fallback is enough — do not flash **Page unavailable** then immediately reload. If the guard already fired, then show the existing route card with a control that **reloads the app** (not only re-import). 11. **No secrets / mnemonics** in reload query strings. Do not persist wallet state in the chunk-reload key. 12. **Docs + playbook:** extend [docs/frontend.md § Lazy route chunks](docs/frontend.md#lazy-route-chunks) and [`skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md`](skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md). Add `make verify-issue-<iid>`. ## Relevant files | File | Role | |------|------| | [`frontend-dapp/src/components/common/LazyRoute.tsx`](frontend-dapp/src/components/common/LazyRoute.tsx) | `loadAttempt` + `lazy(loader)`; needs stale-vs-offline split | | [`frontend-dapp/src/components/common/ErrorBoundary.tsx`](frontend-dapp/src/components/common/ErrorBoundary.tsx) | Route **Try Again** vs app **Reload App**; chunk headline | | [`frontend-dapp/src/utils/chunkLoadError.ts`](frontend-dapp/src/utils/chunkLoadError.ts) | `isChunkLoadError`, retail strings, URL scrub | | [`frontend-dapp/src/utils/humanizeOffChainError.ts`](frontend-dapp/src/utils/humanizeOffChainError.ts) | Dynamic-import copy before generic fetch | | [`frontend-dapp/src/App.tsx`](frontend-dapp/src/App.tsx) | All `LazyRoute` pages (not Pool-only) | | [`frontend-dapp/src/components/common/__tests__/LazyRoute.test.tsx`](frontend-dapp/src/components/common/__tests__/LazyRoute.test.tsx) | Try Again re-import | | [`frontend-dapp/src/utils/__tests__/chunkLoadError.test.ts`](frontend-dapp/src/utils/__tests__/chunkLoadError.test.ts) | Classifier + sanitize | | [`docker/frontend/nginx.conf`](docker/frontend/nginx.conf) | HTML vs hashed-asset cache | | [`docker/frontend/Dockerfile`](docker/frontend/Dockerfile) | Single-generation `dist` | | [`docs/frontend.md`](docs/frontend.md) § Lazy route chunks | Invariants | | [`skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md`](skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md) | Agent playbook (#172) | | [`docs/runbooks/rollback-decision.md`](docs/runbooks/rollback-decision.md) | Notes stale `index.html` | ## Recommended direction 1. **Helper** (e.g. `reloadOnceOnStaleChunk(error)` in `chunkLoadError.ts`): - Return false unless `isChunkLoadError`. - Return false if `navigator.onLine === false`. - If `sessionStorage` key (e.g. `cl8y-dex-stale-chunk-reload`) is set, return false (show UI). - Else set the key and `window.location.reload()`. - Clear the key from `LazyRoute` / `RouteContentReadyMarker` when a lazy page **successfully** mounts. 2. **ErrorBoundary / LazyRoute:** on chunk error, attempt the helper **before** painting **Page unavailable**. If it returns false, route UI **Reload app** (`location.reload()`) **in addition to** Try Again (Try Again = re-import for transient miss; Reload app = new shell). 3. **Classifier tests:** Firefox `error loading dynamically imported module: https://dex.cl8y.com/assets/PoolPage-….js`; Safari import-script-failed; production hashed URL must still sanitize to `[module]` / generic sentence. 4. **nginx:** `location` for hashed assets: `add_header Cache-Control "public, immutable"` only on 200 (or `error_page` 404 without immutable). Confirm `/pool` HTML is not `immutable` (curl `-I` on `/` and `/pool`). 5. **Optional (not required):** `requestIdleCallback` prefetch of the same `import()` map used in `App.tsx` after first paint — still must reload if hashes changed mid-session. Do **not** special-case PoolPage. The failing URL is whichever lazy chunk the old shell requested. ## Acceptance criteria - [ ] **C1.** After a production-like hash change (old `index-*.js` in memory, new `/assets/PoolPage-*.js` 404), navigating to `/pool` (and another lazy route) **recovers** via a single document reload and shows the real page — not a stuck **Page unavailable**. - [ ] **C2.** If the chunk 404s **again** after that reload (broken deploy), the app **does not** loop-reload; route card is shown with **Reload app** + **Try Again**. - [ ] **C3.** Offline (`navigator.onLine === false`): **no** auto-reload; existing **Page unavailable** + **Try Again** re-import still works when back online **and** hashes still exist ([#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172)). - [ ] **C4.** Non-chunk React errors still **do not** `location.reload()`; app vs route copy unchanged for those. - [ ] **C5.** Firefox, Chrome, and Safari chunk strings classify via `isChunkLoadError`; technical details never echo full `https://dex.cl8y.com/assets/…`. - [ ] **C6.** Header/nav remain on the fallback path; wallet session is not wiped except by the normal full reload. - [ ] **C7.** Hashed **200** JS/CSS still long-cache immutable; HTML / SPA routes are not immutable; **404** JS is not advertised as immutable. - [ ] **C8.** Docs + `AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md` describe stale-deploy vs offline; `make verify-issue-<iid>` covers unit (+ nginx header script or documented curl). - [ ] **C9.** No new service worker; no main-bundle of all pages; CSP unchanged. ## Test plan (all paths) ### Unit (Vitest) 1. `isChunkLoadError` true for: - `Failed to fetch dynamically imported module: https://dex.cl8y.com/assets/PoolPage-BrgV5Tp1.js` - `error loading dynamically imported module: https://dex.cl8y.com/assets/PoolPage-BrgV5Tp1.js` (Firefox) - `Importing a module script failed.` - `ChunkLoadError: Loading chunk 3 failed.` 2. False for `TypeError: Failed to fetch` (indexer), LCD timeouts, contract `Max spread`, user reject. 3. Sanitize production asset URLs from technical details. 4. `reloadOnceOnStaleChunk`: - online + first chunk error → sets storage, calls `reload` (mock `location.reload`). - storage already set → no reload. - `navigator.onLine === false` → no reload, no storage write (or write is harmless). - non-chunk `Error` → no reload. 5. Successful lazy mount **clears** the storage key (mock). 6. LazyRoute: offline-style reject then Try Again still increments `import()` count ([#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172) test stays green). 7. LazyRoute: after helper already fired, UI shows Reload app; click calls `location.reload` (not only `loadAttempt++`). 8. App-level boundary chunk path still Reload App ([#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172)). ### nginx / image 9. `curl -sI https://dex.cl8y.com/` and `/pool`: HTML `Cache-Control` contains `no-cache` (or `max-age=0` / `must-revalidate`), not `immutable`. 10. `curl -sI` a live hashed `/assets/index-*.js`: `immutable` (or long expires). 11. `curl -sI https://dex.cl8y.com/assets/PoolPage-does-not-exist.js`: **404**; `Cache-Control` must not be `public, immutable`. 12. Local: `docker build -f docker/frontend/Dockerfile` smoke **or** a repo fixture nginx config test if one exists / is added. ### Manual / staging (production-like) 13. Load `/` on deploy A. Deploy B (new hashes). Without refresh, click **Pool**, **Charts**, **Trade**, **Protocol**, **Create token**. Expect one reload then the page — not a stuck card. 14. Repeat with Firefox and Chromium. 15. DevTools Offline → navigate to an unvisited lazy route → **Page unavailable**, no reload loop; go online → Try Again loads **if** hashes still exist. 16. Hard-reload `/pool` on a healthy deploy: Pool renders (no false stale reload). 17. Confirm Swap (already loaded) still works if the user ignores Pool and stays on `/`. ### E2E (Playwright) 18. Prefer a **route mock**: fulfill `**/assets/PoolPage-*.js` with 404 once, then allow; assert no infinite reload (navigation count ≤ 1 extra) and that the app recovers **or** shows the guarded fallback — do not depend on a real Coolify roll in CI. 19. Existing e2e-tx / pool specs must not flake from spurious reloads. ## Test plan (attack, hack, and abuse) 1. **Infinite reload DoS:** attacker (or broken deploy) 404s every chunk. Guard must stop after one reload per session until a successful mount. Assert no `reload` in a loop in Vitest; Playwright: bounded `framenavigated`. 2. **Open redirect:** failed import URL is `https://evil.example/assets/x.js` (or `javascript:`). Reload must **not** assign that URL. Only `location.reload()` / same-origin path. 3. **Query/hash injection:** user is on `/pool?x=<script>` or `#/evil`. Reload preserves the current same-origin location; do not parse the TypeError string into `href`. 4. **Classifier confusion:** a malicious/contract error whose message contains `dynamically imported module` could trigger reload. Keep matching **browser chunk patterns** only; do not match arbitrary LCD/indexer bodies. Document the residual (untrusted `error.message` is already a UI concern from [#145](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/145)). 5. **sessionStorage poisoning:** page in iframe / foreign origin cannot set our key (same-origin). `sessionStorage` throw (blocked / quota) → skip auto-reload, show fallback (fail safe), no uncaught exception. 6. **Cache poisoning:** CDN caches 404 of a **new** hash as immutable → users of the new shell never get the chunk. nginx C7 + purge runbook ([`docs/runbooks/rollback-decision.md`](docs/runbooks/rollback-decision.md)). Verify 404 headers (test 11). 7. **Stale HTML at a proxy:** if a CDN ignores `no-cache` on `/index.html`, reload keeps fetching the old shell. Document Coolify/CDN cache-key for `/` and `/index.html`; optional `Cache-Control: no-store` on HTML if a front cache is proven sticky. Do not mark JS as `no-store`. 8. **Clickjacking / unexpected reload:** `X-Frame-Options DENY` stays. Reload does not bypass clickwrap ([#517](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/517) / [#658](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/658)); a full reload may re-show Legal — acceptable. 9. **Wallet session:** reload must not log the mnemonic, WC project secrets, or `VITE_*` to the chunk-reload key. Simulated wallet ([#118](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/118)) remains env-gated; production `VITE_DEV_MODE` still rejected ([#695](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/695)). 10. **CSP bypass:** no new inline handlers that require `'unsafe-inline'` beyond existing. No `eval` of the error string. 11. **Prefetch amplification (if implemented):** idle prefetch must not hammer LCD/indexer; it is **static JS only**. Cap concurrent prefetches. 12. **Mixed-content / MIME:** keep `application/javascript` (or `text/javascript`) on `/assets/*.js`; a 200 HTML fallback for missing JS (`try_files` → `index.html`) would execute as a module and fail — hashed location already `=404`. Regression: missing JS must be **404**, not SPA HTML. ## Verification criteria - [ ] `make test-frontend` (or scoped Vitest listed above) green. - [ ] `make verify-issue-<iid>` added and green (classifier + reload helper + nginx header checks or documented SKIP). - [ ] Docs/playbook mention **stale Coolify hash** as the production path; [#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172) remains the offline Try Again path. - [ ] Staging or production after the next frontend roll: leave a tab on Swap across the deploy, click Pool → page loads (one automatic reload acceptable). - [ ] Firefox + Chromium both recover. - [ ] Broken-chunk second failure does not reload-loop (DevTools Network: not an endless document request). - [ ] `curl -sI` on HTML vs hashed JS vs missing JS matches C7. Related: [#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172) (offline lazy Try Again — **closed**, UX only), [#179](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/179) (Trade LCP / do not un-split), [#171](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/171) (LCD outage — different banner).
PlasticDigits commented 2026-08-30 05:37:10 +00:00 (Migrated from gitlab.com)

mentioned in commit 8c1d1bc5d4

mentioned in commit 8c1d1bc5d4c12306d1aa5f3915ba9457d4aceabf
PlasticDigits commented 2026-08-30 05:37:48 +00:00 (Migrated from gitlab.com)

Verify (#706) — landed on main (8c1d1bc5); leftover Coolify walk still open

Implementation is on main. Long-lived tabs that hit a hashed lazy-chunk 404 after a frontend roll now one-shot document-reload (sessionStorage cl8y-dex-stale-chunk-reload). Offline still uses Try Again re-import (#172). A second 404 after that reload shows Reload app + Try Again (no loop). nginx: hashed 200 public, max-age=604800, immutable; HTML / SPA no-cache, must-revalidate; missing *.js is 404 no-store (not SPA HTML).

Playbook: skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md (L706-1–L706-8). Docs: docs/frontend.md § Lazy route chunks. Gate: make verify-issue-706.

What was verified locally

Check Result
Scoped Vitest (chunkLoadError, LazyRoute, ErrorBoundary, humanize Firefox/Safari) PASS (53)
make verify-issue-706 8 passed, 0 failed
Playwright e2e/stale-chunk-reload-706.spec.ts (e2e-smoke, 3 tests / up to 5 workers) PASS — recover ≤1 extra document request; guarded fallback no loop; offline no document-reload
Docker nginx header smoke PASS — / and /pool no-cache, must-revalidate; hashed JS immutable; missing JS 404 no-store
make verify-issue-578 (OG nginx greps) PASS
Rollback-decision docs drift PASS

Issue-body checklist (C1–C9 / verification criteria)

  • C1. Online hashed-chunk 404 recovers via one document reload (Playwright route-mock + Vitest helper).
  • C2. Guarded second failure: route card Reload app + Try Again; no reload loop.
  • C3. Offline: no auto-reload; Page unavailable + Try Again (#172). Header/nav stay.
  • C4. Non-chunk errors do not location.reload().
  • C5. Chrome / Firefox / Safari / webpack strings classify; production URLs sanitized to [module] / generic sentence. Indexer Failed to fetch is not a chunk error.
  • C6. Route fallback stays inside Layout (header/nav). Same-origin reload only.
  • C7 (repo image). Local docker nginx matches C7.
  • C7 (live dex.cl8y.com). Leftover: live hashed JS is still max-age=604800 without immutable. HTML is already no-cache; missing JS is 404 without immutable. Needs Coolify frontend rebuild from 8c1d1bc5+.
  • C8. Docs + playbook + make verify-issue-706.
  • C9. No service worker; App.tsx still LazyRoute + import(); CSP unchanged.

Leftover (issue stays open)

These issue-body verification criteria are not done until the next production frontend roll:

  1. Coolify rebuild from 8c1d1bc5+. Then curl -sI HTML / hashed JS / missing JS should match C7 (VERIFY706_REQUIRE_LIVE=1 make verify-issue-706).
  2. Tab across deploy: leave Swap open, roll frontend, click Pool / Charts / Trade / Protocol / Create token → one automatic reload, real page (not stuck Page unavailable). Chromium and Firefox.
  3. Full make test-frontend was not run here (scoped Vitest + this e2e were). Optional follow-up.

Do not reopen #172 for Coolify hash 404s. Do not wait on GitLab CI quota as leftover evidence.

make verify-issue-706
VERIFY706_REQUIRE_LIVE=1 make verify-issue-706
## Verify (#706) — landed on `main` (`8c1d1bc5`); leftover Coolify walk still open Implementation is on `main`. Long-lived tabs that hit a hashed lazy-chunk 404 after a frontend roll now **one-shot document-reload** (`sessionStorage` `cl8y-dex-stale-chunk-reload`). Offline still uses **Try Again** re-import ([#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172)). A second 404 after that reload shows **Reload app** + **Try Again** (no loop). nginx: hashed **200** `public, max-age=604800, immutable`; HTML / SPA `no-cache, must-revalidate`; missing `*.js` is **404** `no-store` (not SPA HTML). **Playbook:** [`skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md) (**L706-1–L706-8**). Docs: [`docs/frontend.md` § Lazy route chunks](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md#lazy-route-chunks). Gate: `make verify-issue-706`. ### What was verified locally | Check | Result | | ---- | ------ | | Scoped Vitest (`chunkLoadError`, `LazyRoute`, `ErrorBoundary`, humanize Firefox/Safari) | PASS (53) | | `make verify-issue-706` | **8 passed, 0 failed** | | Playwright `e2e/stale-chunk-reload-706.spec.ts` (e2e-smoke, 3 tests / up to 5 workers) | PASS — recover ≤1 extra document request; guarded fallback no loop; offline no document-reload | | Docker nginx header smoke | PASS — `/` and `/pool` `no-cache, must-revalidate`; hashed JS `immutable`; missing JS 404 `no-store` | | `make verify-issue-578` (OG nginx greps) | PASS | | Rollback-decision docs drift | PASS | ### Issue-body checklist (C1–C9 / verification criteria) - [x] **C1.** Online hashed-chunk 404 recovers via one document reload (Playwright route-mock + Vitest helper). - [x] **C2.** Guarded second failure: route card **Reload app** + **Try Again**; no reload loop. - [x] **C3.** Offline: no auto-reload; **Page unavailable** + **Try Again** (#172). Header/nav stay. - [x] **C4.** Non-chunk errors do not `location.reload()`. - [x] **C5.** Chrome / Firefox / Safari / webpack strings classify; production URLs sanitized to `[module]` / generic sentence. Indexer `Failed to fetch` is not a chunk error. - [x] **C6.** Route fallback stays inside Layout (header/nav). Same-origin reload only. - [x] **C7 (repo image).** Local docker nginx matches C7. - [ ] **C7 (live `dex.cl8y.com`).** **Leftover:** live hashed JS is still `max-age=604800` without `immutable`. HTML is already `no-cache`; missing JS is 404 without `immutable`. Needs **Coolify frontend rebuild** from `8c1d1bc5+`. - [x] **C8.** Docs + playbook + `make verify-issue-706`. - [x] **C9.** No service worker; `App.tsx` still `LazyRoute` + `import()`; CSP unchanged. ### Leftover (issue stays **open**) These issue-body verification criteria are **not** done until the next production frontend roll: 1. **Coolify rebuild** from `8c1d1bc5+`. Then `curl -sI` HTML / hashed JS / missing JS should match C7 (`VERIFY706_REQUIRE_LIVE=1 make verify-issue-706`). 2. **Tab across deploy:** leave Swap open, roll frontend, click Pool / Charts / Trade / Protocol / Create token → one automatic reload, real page (not stuck **Page unavailable**). Chromium **and Firefox**. 3. Full `make test-frontend` was not run here (scoped Vitest + this e2e were). Optional follow-up. Do **not** reopen #172 for Coolify hash 404s. Do **not** wait on GitLab CI quota as leftover evidence. ```bash make verify-issue-706 VERIFY706_REQUIRE_LIVE=1 make verify-issue-706 ```
PlasticDigits commented 2026-08-30 06:01:37 +00:00 (Migrated from gitlab.com)

Verify leftover (#706) — live C7 + production SPA walk PASS; closing

Coolify frontend rebuild from 8c1d1bc5+ is live. Shell is /assets/index-D1SJBC9_.js (contains cl8y-dex-stale-chunk-reload, route-error-reload-app, stale-chunk-updating).

Live C7 (VERIFY706_REQUIRE_LIVE=1)

URL Result
GET / Cache-Control: no-cache, must-revalidate
GET /pool Cache-Control: no-cache, must-revalidate
missing /assets/PoolPage-does-not-exist.js HTTP 404 no-store
/assets/index-D1SJBC9_.js public, max-age=604800, immutable

Previous live index (index-CU9DNfKP.js) lacked immutable. C7 live is no longer leftover.

SPA walk (healthy deploy after the roll — test plan 16)

Same-origin SPA clicks, no full refresh between routes. Header/nav stayed. Never Page unavailable.

Route Chromium (Cursor browser) Firefox (Playwright 146, production)
Swap / ticket + Connect Wallet PASS
Pool pair table PASS /pool
Charts UST1/cUSTC chart (?price=UST1) PASS
Trade Market ticket (Buy ALPHA) PASS
Protocol Global stats PASS
Create token Name/Symbol form PASS

Firefox first-visit Risk acknowledgement was dismissed (Continue to the app) before nav; it is unrelated to #706.

Tab-across-deploy (test plan 13)

This session opened after the rebuild, so a Swap tab was not left running across the Coolify image replace. Cross-hash recovery remains covered by Playwright e2e/stale-chunk-reload-706.spec.ts (first PoolPage 404 → ≤1 extra document load, then real page). Next Coolify roll can still be watched live if wanted; not blocking close.

C1–C9

  • C1–C6, C8, C9 — as in the previous note (local verify + Playwright).
  • C7 live — PASS after this rebuild.
  • Firefox + Chromium healthy production SPA walk — PASS.

Do not reopen #172.

## Verify leftover (#706) — live C7 + production SPA walk **PASS**; closing Coolify frontend rebuild from `8c1d1bc5+` is live. Shell is `/assets/index-D1SJBC9_.js` (contains `cl8y-dex-stale-chunk-reload`, `route-error-reload-app`, `stale-chunk-updating`). ### Live C7 (`VERIFY706_REQUIRE_LIVE=1`) | URL | Result | | ---- | ------ | | `GET /` | `Cache-Control: no-cache, must-revalidate` | | `GET /pool` | `Cache-Control: no-cache, must-revalidate` | | missing `/assets/PoolPage-does-not-exist.js` | HTTP **404** `no-store` | | `/assets/index-D1SJBC9_.js` | `public, max-age=604800, immutable` | Previous live index (`index-CU9DNfKP.js`) lacked `immutable`. **C7 live is no longer leftover.** ### SPA walk (healthy deploy after the roll — test plan 16) Same-origin SPA clicks, no full refresh between routes. Header/nav stayed. **Never** **Page unavailable**. | Route | Chromium (Cursor browser) | Firefox (Playwright 146, production) | | ---- | ---- | ---- | | Swap `/` | ticket + Connect Wallet | PASS | | Pool | pair table | PASS `/pool` | | Charts | UST1/cUSTC chart (`?price=UST1`) | PASS | | Trade | Market ticket (Buy ALPHA) | PASS | | Protocol | Global stats | PASS | | Create token | Name/Symbol form | PASS | Firefox first-visit **Risk acknowledgement** was dismissed (`Continue to the app`) before nav; it is unrelated to #706. ### Tab-across-deploy (test plan 13) This session opened **after** the rebuild, so a Swap tab was **not** left running across the Coolify image replace. Cross-hash recovery remains covered by Playwright `e2e/stale-chunk-reload-706.spec.ts` (first `PoolPage` 404 → ≤1 extra document load, then real page). Next Coolify roll can still be watched live if wanted; not blocking close. ### C1–C9 - [x] C1–C6, C8, C9 — as in the previous note (local verify + Playwright). - [x] **C7 live** — PASS after this rebuild. - [x] Firefox + Chromium healthy production SPA walk — PASS. Do **not** reopen [#172](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/172).
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-30 06:01:38 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#706
No description provided.