fix: recover from stale Vite route chunks after Coolify deploy — Page unavailable / Try Again cannot load PoolPage-*.js #706
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#706
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:(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 wasindex-<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
LazyRoutepage (/charts,/trade,/protocol,/token/create, …) the tab has not yet visited. Already-mounted routes (often Swap) keep working.Environment (production)
https://dex.cl8y.com(Coolify nginx image,docker/frontend/Dockerfile)ErrorBoundary+ dynamicimport()of/assets/<Page>-<hash>.jsCurrent codebase
Lazy routes + hashed chunks
frontend-dapp/src/App.tsxwraps every page inLazyRoute:Production Vite emits content-hashed files (
PoolPage-<hash>.js,index-<hash>.js) viafrontend-dapp/vite.config.tsmanualChunks. Coolify builds a new nginx image and replaces/usr/share/nginx/htmlatomically. Previous hashes are deleted. There is no asset-generation overlay.docker/frontend/nginx.conf:location = /index.htmlCache-Control: no-cache*.js/*.css/ images / fontsexpires 7d+Cache-Control: public, immutable/pool, …)try_files→/index.htmlA new visit therefore fetches a new shell. A long-lived tab keeps executing the old
index-*.js, whose dynamic import map still namesPoolPage-<oldhash>.js. That file 404s.#172 only recovers offline retries, not stale deploys
LazyRoutebumpsloadAttemptso Try Again constructs a freshReact.lazy(loader). That is required becauselazy()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-levelErrorBoundaryshows Reload App (location.reload()) for chunk errors — but this stack is route-scoped (Lazy→Suspense→Layout), so users never get Reload App.Classifier
isChunkLoadErroralready matches Firefoxerror loading dynamically imported module, ChromeFailed to fetch dynamically imported module, SafariImporting a module script failed, and webpackLoading 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)
Layout: header/nav stay; funds/wallet chrome remain (#172 W11-C3).sanitizeChunkLoadTechnicalDetail).resetKeys(#126); Trade Suspense skeleton (#179).script-src 'self'(#378); no service worker in this repo.Failed to fetchas a chunk error (classifier order intryHumanizeFetchLikeMessagealready prefers dynamic-import before generic fetch).Why the new implementation is needed
PoolPage-*.js. Any trader who leaves the dApp open (hours/days) and then clicks Pool / Charts / Trade hits this wall.index.html. Client one-shotlocation.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
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.navigator.onLine === false. Keep #172 Try Again re-import for offline → online. Reload cannot invent a network.isChunkLoadError. Do notlocation.reload()on render/logic errors — that loops on a real bug.window.location.reload()or assignwindow.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).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.public, immutable. 404 of*.jsmust not be cached as immutable (deploy race: new HTML names a chunk not yet visible). SPA HTML staysno-cache(considermust-revalidate) including internaltry_files→/index.html.unsafe-evalor extrascript-src.skills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.md. Addmake verify-issue-<iid>.Relevant files
frontend-dapp/src/components/common/LazyRoute.tsxloadAttempt+lazy(loader); needs stale-vs-offline splitfrontend-dapp/src/components/common/ErrorBoundary.tsxfrontend-dapp/src/utils/chunkLoadError.tsisChunkLoadError, retail strings, URL scrubfrontend-dapp/src/utils/humanizeOffChainError.tsfrontend-dapp/src/App.tsxLazyRoutepages (not Pool-only)frontend-dapp/src/components/common/__tests__/LazyRoute.test.tsxfrontend-dapp/src/utils/__tests__/chunkLoadError.test.tsdocker/frontend/nginx.confdocker/frontend/Dockerfiledistdocs/frontend.md§ Lazy route chunksskills/AGENTS_FRONTEND_LAZY_CHUNK_LOAD.mddocs/runbooks/rollback-decision.mdindex.htmlRecommended direction
reloadOnceOnStaleChunk(error)inchunkLoadError.ts):isChunkLoadError.navigator.onLine === false.sessionStoragekey (e.g.cl8y-dex-stale-chunk-reload) is set, return false (show UI).window.location.reload().LazyRoute/RouteContentReadyMarkerwhen a lazy page successfully mounts.location.reload()) in addition to Try Again (Try Again = re-import for transient miss; Reload app = new shell).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.locationfor hashed assets:add_header Cache-Control "public, immutable"only on 200 (orerror_page404 without immutable). Confirm/poolHTML is notimmutable(curl-Ion/and/pool).requestIdleCallbackprefetch of the sameimport()map used inApp.tsxafter 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
index-*.jsin memory, new/assets/PoolPage-*.js404), navigating to/pool(and another lazy route) recovers via a single document reload and shows the real page — not a stuck Page unavailable.navigator.onLine === false): no auto-reload; existing Page unavailable + Try Again re-import still works when back online and hashes still exist (#172).location.reload(); app vs route copy unchanged for those.isChunkLoadError; technical details never echo fullhttps://dex.cl8y.com/assets/….AGENTS_FRONTEND_LAZY_CHUNK_LOAD.mddescribe stale-deploy vs offline;make verify-issue-<iid>covers unit (+ nginx header script or documented curl).Test plan (all paths)
Unit (Vitest)
isChunkLoadErrortrue for:Failed to fetch dynamically imported module: https://dex.cl8y.com/assets/PoolPage-BrgV5Tp1.jserror loading dynamically imported module: https://dex.cl8y.com/assets/PoolPage-BrgV5Tp1.js(Firefox)Importing a module script failed.ChunkLoadError: Loading chunk 3 failed.TypeError: Failed to fetch(indexer), LCD timeouts, contractMax spread, user reject.reloadOnceOnStaleChunk:reload(mocklocation.reload).navigator.onLine === false→ no reload, no storage write (or write is harmless).Error→ no reload.import()count (#172 test stays green).location.reload(not onlyloadAttempt++).nginx / image
curl -sI https://dex.cl8y.com/and/pool: HTMLCache-Controlcontainsno-cache(ormax-age=0/must-revalidate), notimmutable.curl -sIa live hashed/assets/index-*.js:immutable(or long expires).curl -sI https://dex.cl8y.com/assets/PoolPage-does-not-exist.js: 404;Cache-Controlmust not bepublic, immutable.docker build -f docker/frontend/Dockerfilesmoke or a repo fixture nginx config test if one exists / is added.Manual / staging (production-like)
/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./poolon a healthy deploy: Pool renders (no false stale reload)./.E2E (Playwright)
**/assets/PoolPage-*.jswith 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.Test plan (attack, hack, and abuse)
reloadin a loop in Vitest; Playwright: boundedframenavigated.https://evil.example/assets/x.js(orjavascript:). Reload must not assign that URL. Onlylocation.reload()/ same-origin path./pool?x=<script>or#/evil. Reload preserves the current same-origin location; do not parse the TypeError string intohref.dynamically imported modulecould trigger reload. Keep matching browser chunk patterns only; do not match arbitrary LCD/indexer bodies. Document the residual (untrustederror.messageis already a UI concern from #145).sessionStoragethrow (blocked / quota) → skip auto-reload, show fallback (fail safe), no uncaught exception.docs/runbooks/rollback-decision.md). Verify 404 headers (test 11).no-cacheon/index.html, reload keeps fetching the old shell. Document Coolify/CDN cache-key for/and/index.html; optionalCache-Control: no-storeon HTML if a front cache is proven sticky. Do not mark JS asno-store.X-Frame-Options DENYstays. Reload does not bypass clickwrap (#517 / #658); a full reload may re-show Legal — acceptable.VITE_*to the chunk-reload key. Simulated wallet (#118) remains env-gated; productionVITE_DEV_MODEstill rejected (#695).'unsafe-inline'beyond existing. Noevalof the error string.application/javascript(ortext/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).curl -sIon 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).
mentioned in commit
8c1d1bc5d4Verify (#706) — landed on
main(8c1d1bc5); leftover Coolify walk still openImplementation is on
main. Long-lived tabs that hit a hashed lazy-chunk 404 after a frontend roll now one-shot document-reload (sessionStoragecl8y-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 200public, max-age=604800, immutable; HTML / SPAno-cache, must-revalidate; missing*.jsis 404no-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
chunkLoadError,LazyRoute,ErrorBoundary, humanize Firefox/Safari)make verify-issue-706e2e/stale-chunk-reload-706.spec.ts(e2e-smoke, 3 tests / up to 5 workers)/and/poolno-cache, must-revalidate; hashed JSimmutable; missing JS 404no-storemake verify-issue-578(OG nginx greps)Issue-body checklist (C1–C9 / verification criteria)
location.reload().[module]/ generic sentence. IndexerFailed to fetchis not a chunk error.dex.cl8y.com). Leftover: live hashed JS is stillmax-age=604800withoutimmutable. HTML is alreadyno-cache; missing JS is 404 withoutimmutable. Needs Coolify frontend rebuild from8c1d1bc5+.make verify-issue-706.App.tsxstillLazyRoute+import(); CSP unchanged.Leftover (issue stays open)
These issue-body verification criteria are not done until the next production frontend roll:
8c1d1bc5+. Thencurl -sIHTML / hashed JS / missing JS should match C7 (VERIFY706_REQUIRE_LIVE=1 make verify-issue-706).make test-frontendwas 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.
Verify leftover (#706) — live C7 + production SPA walk PASS; closing
Coolify frontend rebuild from
8c1d1bc5+is live. Shell is/assets/index-D1SJBC9_.js(containscl8y-dex-stale-chunk-reload,route-error-reload-app,stale-chunk-updating).Live C7 (
VERIFY706_REQUIRE_LIVE=1)GET /Cache-Control: no-cache, must-revalidateGET /poolCache-Control: no-cache, must-revalidate/assets/PoolPage-does-not-exist.jsno-store/assets/index-D1SJBC9_.jspublic, max-age=604800, immutablePrevious live index (
index-CU9DNfKP.js) lackedimmutable. 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.
//pool?price=UST1)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(firstPoolPage404 → ≤1 extra document load, then real page). Next Coolify roll can still be watched live if wanted; not blocking close.C1–C9
Do not reopen #172.