fix(frontend): Charts pair select must stick on first change #1266

Closed
opened 2026-09-15 13:46:19 +00:00 by PlasticDigits · 2 comments

Summary

On Charts, choosing a pair from Select Pair (#chart-pair-select) does not keep that pair on the first change. The workspace (candles, 24h stats, leaderboard, invert pill, URL) stays on the hero / previous pair. A second selection of the same target then sticks.

This is the Charts analogue of closed Trade races #357 / #350 / #301 (default-pick / selector clobber). Those tickets are closed and Trade-scoped. Do not reopen them. Open #1258 is USDT USD ingest, not selector routing.

Parent hero work: #680. Related: #666 pair-scoped stats, #547 deep links, #534 catalog order. C680-5 already forbids snapping a valid /charts/:pairAddr back to the hero; the gap is a user selectPair while the route is still bare /charts.

Repro

Given Charts is open at bare /charts (nav item, no pair segment) and the indexer pair list has loaded with UST1/cUSTC as hero plus at least one other listed pair B in #chart-pair-select.

When the user opens Select Pair and chooses B once.

Then on that first change the URL is /charts/{B} (optional ?price= for a leg of B only), #chart-pair-select shows B, and candles / pair stats / leaderboard query B — not the hero. A second identical select is not required.

Expected: first MenuSelect change is authoritative (same as Trade after #357: auto-pick must not clobber an explicit choice).

Actual: first change is overwritten: URL and workspace snap back to the #680 hero (or stay on the previous pair). The second select of B then works, because the route is no longer bare and the hero effect no longer runs.

Current codebase

ChartsPage.tsx keeps two sources of truth: selectedPairAddr and useParams().pairAddr.

  • selectPair(addr) sets selectedPairAddr and navigate(chartsPairHref(addr, …), { replace: true }).
  • #chart-pair-select onChange calls selectPair(v). Displayed value is activePairAddr = selectedPairAddr || pairOptions[0].
  • Nav lands on bare /charts (PRIMARY_NAV_ITEMS path /charts). isBareCharts is routePair === undefined.

Hero effect (post-#680):

if (!isBareCharts) return
// …
const hero = resolveChartsHeroPairAddress(pairOptions)
if (!hero || selectedPairAddr === hero) return
setSelectedPairAddr(hero)
navigate(chartsPairHref(hero, { price }), { replace: true })

There is no “user already picked a non-hero pair” guard. Trade’s auto-pick returns early when pairAddr is already set (TradePage.tsx shouldAutoPickDefaultTradePair). Charts treats any selectedPairAddr !== hero while still bare as a reason to force the hero.

Race:

  1. User picks B on bare /charts. selectPair queues state B and navigate(/charts/B).
  2. Re-render can still have isBareCharts === true before the pair segment commits.
  3. Hero effect sees B !== hero → writes hero + navigate to the hero href.
  4. Workspace stays on hero. Second pick of B runs with isBareCharts === false and sticks.

Secondary (same symptom, later): when isBareCharts is false but B is not in the current paginated pairOptions page, the fallback effect can setSelectedPairAddr(pairOptions[0]) before getPair(B) returns (needsPairFetch). Do not leave that snap-back either.

Existing tests do not click #chart-pair-select on bare /charts:

  • pair switch updates stats and leaderboard args together unmounts /charts and remounts /charts/{B}.
  • C680-1–C680-8 cover auto-hero and deep links, not a competing MenuSelect.

MenuSelect forwards onChange on option click; this is not a “click twice to open” listbox bug.

Why the new implementation is needed

  1. Retail Charts is unusable for non-hero markets until the user learns to select twice. Hero default (#680) must not eat the first explicit choice.
  2. C680-5 is incomplete if it only protects inbound /charts/:pairAddr and not the in-page selector that is supposed to create that URL.
  3. RTL currently cannot fail this; implementers will “prove” pair switch by remounting the route.

Frontend-only. No indexer, no contracts, no Coolify env.

Constraints / guardrails

  • C680-1 stays: idle bare /charts (no user select) still replace-navigates to UST1/cUSTC + ?price=UST1 when listed.
  • C680-5 stays: inbound /charts/{other} must not snap to hero. Hostile / non-bech32 pairAddr still shows the invalid notice; no stats/leaderboard fetch with the hostile string (CS-11).
  • C680-7 / T524-3: Trade other-side default unchanged. Charts invert key stays cl8y-dex-charts-pair-invert:.
  • P562: production still hides gems; selector must not reveal or navigate to hidden pair addrs.
  • Checksum / href: only chartsPairHref / isChartsPairRouteParam (isValidTerraAddress). No string concat into Navigate. replace, not push.
  • ?price=: carry only when it is a leg of the newly selected pair (matchChartsPriceParam); otherwise drop. Hostile price ignored (existing parser).
  • U1: Charts is AMM charting, not mint/redeem.
  • C653: no new shell-panel* / card-glass chrome.
  • Do not persist last pair in localStorage. Do not change Trade PairSearchSelect.
  • Do not weaken python3 scripts/check_chrome_nesting.py.

Relevant files

Path Why
frontend-dapp/src/pages/ChartsPage.tsx Hero effect vs selectPair; optional fallback snap
frontend-dapp/src/pages/ChartsPage.test.tsx Missing MenuSelect click vs hero race
frontend-dapp/src/utils/chartsPairRoute.ts Href / param helpers (only if selection becomes URL-only)
frontend-dapp/src/utils/pairCatalogRank.ts resolveChartsHeroPairAddress — keep behavior, fix call site
skills/AGENTS_FRONTEND_CHARTS_UST1_HERO.md + docs/frontend.md § Charts UST1/USD hero Document: hero applies only until an explicit select
scripts/qa/verify-issue-680.sh Must stay green; add verify-issue-<id> for this ticket
  1. Hero is idle-only. Run the #680 replace-nav only when the user has not committed a pair: e.g. selectedPairAddr empty and isBareCharts, or a ref/userSelectedRef set in selectPair that the hero effect must not override. Do not treat selectedPairAddr !== hero on bare /charts as “force hero”.
  2. Prefer URL as source of truth after the first select. Once navigate(/charts/{addr}) is intended, do not write a different addr from effects until the next selectPair. Trade’s if (pairAddr) return on auto-pick is the pattern.
  3. Fallback to pairOptions[0] must not run while needsPairFetch && selectedPairQuery.isLoading (already skipped) and must not run when selectedPairAddr is a valid bech32 the user just chose (wait for getPair / extra row). Never replace a valid validRoutePair.
  4. Test the click path, not remount: render /charts with hero + B in getPairs, userEvent the listbox, assert one getCandles/getPairStats/getLeaderboard for B and window.location /charts/{B}.

Acceptance criteria

  • AC1. Bare /charts, list loaded, first Select Pair change to listed B ≠ hero: URL, trigger label, candles, 24h stats, leaderboard all use B. No second select.
  • AC2. Idle bare /charts (no click) still auto-picks hero (C680-1).
  • AC3. Reload /charts/{B} (listed, non-hero) keeps B (C680-5). Hero effect does not run.
  • AC4. After a successful select to B, changing Find/sort/page does not snap the workspace back to hero or pairOptions[0].
  • AC5. Carried ?price= is dropped when it is not a leg of B; hostile price still ignored.
  • AC6. make verify-issue-680 (and C680-1–C680-8) stay green. New RTL covers the MenuSelect race. check_chrome_nesting.py green.

Test plan (functional paths)

# Path Expect
T1 /charts, click B in #chart-pair-select First change → /charts/{B}; candles(B); select value B
T2 /charts, no click Still hero + ?price=UST1 when UST1/cUSTC listed
T3 /charts/{B} reload Stays B; no hero navigate
T4 Select B, then select hero First change to hero works (symmetric)
T5 Select B not on current pager page Stays B (extra getPair); no snap to page[0]
T6 Keyboard listbox choose B Same as T1 (MenuSelect keyboard onSelectIndex)
T7 Rapid double-select B then C Last committed pair wins; no hero insert
T8 Existing C680 invert / volume-unchanged Unchanged

Vitest: RTL T1–T5 on ChartsPage.test.tsx. Playwright optional (PLAYWRIGHT_SKIP_CHAIN=1): one Charts pair change from nav /charts.

Test plan (attack, hack, and abuse)

# Vector Expect
A1 onChange with non-bech32 / javascript: / data: / // chartsPairHref returns null; no navigate; no fetch with that string
A2 Hero effect concatenating pairAddr + query Never; only chartsPairHref + URLSearchParams via existing helpers
A3 /charts/<script> after a failed select Invalid notice; no getPairStats('<script>') (C680-5)
A4 ?price=<script> carried across select Ignored; not copied into share/DOM
A5 Gem pair addr in production list P562 hide; cannot select or land
A6 Open redirect ?price=https://phish Parser reject; href stays /charts/{bech32}
A7 History flood from effect loop replace only; RTL no act-spam / infinite navigate
A8 Apply/write loop: hero vs selectPair User select wins; idle hero still once
A9 XSS via option label from indexer symbol Existing text content; no dangerouslySetInnerHTML
A10 Selecting paused/frozen listed pair Workspace switches; freeze banner only; no execute (Charts has no swap)

Verification criteria

  • New make verify-issue-<id>: ChartsPage RTL T1 + C680 subset + docs/skill grep that hero must not override selectPair.
  • make verify-issue-680, make verify-issue-524, python3 scripts/check_chrome_nesting.py stay green.
  • Manual: make dev → /charts → Select Pair once to a non-hero listed market → URL and chart match that pair without a second pick. Deep-link /charts/{B} still loads B. Idle /charts still opens UST1/cUSTC.

Out of scope

  • Trade PairSearchSelect (closed #350/#357).
  • Indexer USDT USD (#1258).
  • Pair catalog ranking (#534) except not snapping selection when the list re-sorts.
  • New chrome, share-button changes, indexer APIs.

First-pass model recommendation

Recommendation: composer

Rationale: One production page (ChartsPage.tsx) plus focused ChartsPage.test.tsx (optional one-line skill/docs). Same local effect-guard pattern as Trade #357 (if (pairAddr) return on auto-pick). Low/medium UI-routing risk; no contracts, auth, keys, wasm, wallet/2-of-3, or migrations. Acceptance is deterministic RTL (first #chart-pair-select change vs C680-1 idle hero). C680 invariants are already tested; this ticket adds the missing click path rather than redesigning Charts protocol.

## Summary On **Charts**, choosing a pair from **Select Pair** (`#chart-pair-select`) does not keep that pair on the **first** change. The workspace (candles, 24h stats, leaderboard, invert pill, URL) stays on the **hero / previous** pair. A **second** selection of the same target then sticks. This is the Charts analogue of closed Trade races [#357](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/357) / [#350](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/350) / [#301](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/301) (default-pick / selector clobber). Those tickets are **closed and Trade-scoped**. Do **not** reopen them. Open [#1258](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1258) is USDT USD ingest, not selector routing. Parent hero work: [#680](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/680). Related: [#666](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/666) pair-scoped stats, [#547](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/547) deep links, [#534](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/534) catalog order. **C680-5** already forbids snapping a valid `/charts/:pairAddr` back to the hero; the gap is a user `selectPair` while the route is still **bare** `/charts`. ## Repro **Given** Charts is open at bare `/charts` (nav item, no pair segment) and the indexer pair list has loaded with UST1/cUSTC as hero plus at least one other listed pair **B** in `#chart-pair-select`. **When** the user opens Select Pair and chooses **B** once. **Then** on that first change the URL is `/charts/{B}` (optional `?price=` for a **leg of B** only), `#chart-pair-select` shows **B**, and candles / pair stats / leaderboard query **B** — not the hero. A second identical select is not required. **Expected:** first `MenuSelect` change is authoritative (same as Trade after #357: auto-pick must not clobber an explicit choice). **Actual:** first change is overwritten: URL and workspace snap back to the #680 hero (or stay on the previous pair). The second select of **B** then works, because the route is no longer bare and the hero effect no longer runs. ## Current codebase [`ChartsPage.tsx`](frontend-dapp/src/pages/ChartsPage.tsx) keeps **two sources of truth**: `selectedPairAddr` and `useParams().pairAddr`. - `selectPair(addr)` sets `selectedPairAddr` and `navigate(chartsPairHref(addr, …), { replace: true })`. - `#chart-pair-select` `onChange` calls `selectPair(v)`. Displayed value is `activePairAddr = selectedPairAddr || pairOptions[0]`. - Nav lands on **bare** `/charts` (`PRIMARY_NAV_ITEMS` path `/charts`). `isBareCharts` is `routePair === undefined`. Hero effect (post-#680): ```ts if (!isBareCharts) return // … const hero = resolveChartsHeroPairAddress(pairOptions) if (!hero || selectedPairAddr === hero) return setSelectedPairAddr(hero) navigate(chartsPairHref(hero, { price }), { replace: true }) ``` There is **no** “user already picked a non-hero pair” guard. Trade’s auto-pick returns early when `pairAddr` is already set ([`TradePage.tsx`](frontend-dapp/src/pages/TradePage.tsx) `shouldAutoPickDefaultTradePair`). Charts treats any `selectedPairAddr !== hero` **while still bare** as a reason to force the hero. Race: 1. User picks **B** on bare `/charts`. `selectPair` queues state **B** and `navigate(/charts/B)`. 2. Re-render can still have `isBareCharts === true` before the pair segment commits. 3. Hero effect sees `B !== hero` → writes hero + `navigate` to the hero href. 4. Workspace stays on hero. Second pick of **B** runs with `isBareCharts === false` and sticks. Secondary (same symptom, later): when `isBareCharts` is false but **B** is not in the current paginated `pairOptions` page, the fallback effect can `setSelectedPairAddr(pairOptions[0])` before `getPair(B)` returns (`needsPairFetch`). Do not leave that snap-back either. Existing tests do **not** click `#chart-pair-select` on bare `/charts`: - `pair switch updates stats and leaderboard args together` **unmounts** `/charts` and remounts `/charts/{B}`. - **C680-1–C680-8** cover auto-hero and deep links, not a competing MenuSelect. [`MenuSelect`](frontend-dapp/src/components/ui/MenuSelect.tsx) forwards `onChange` on option click; this is not a “click twice to open” listbox bug. ## Why the new implementation is needed 1. **Retail Charts is unusable for non-hero markets** until the user learns to select twice. Hero default (#680) must not eat the first explicit choice. 2. **C680-5 is incomplete** if it only protects inbound `/charts/:pairAddr` and not the in-page selector that is supposed to create that URL. 3. RTL currently cannot fail this; implementers will “prove” pair switch by remounting the route. Frontend-only. No indexer, no contracts, no Coolify env. ## Constraints / guardrails - **C680-1** stays: *idle* bare `/charts` (no user select) still replace-navigates to UST1/cUSTC + `?price=UST1` when listed. - **C680-5** stays: inbound `/charts/{other}` must not snap to hero. Hostile / non-bech32 `pairAddr` still shows the invalid notice; no stats/leaderboard fetch with the hostile string (**CS-11**). - **C680-7 / T524-3:** Trade other-side default unchanged. Charts invert key stays `cl8y-dex-charts-pair-invert:`. - **P562:** production still hides gems; selector must not reveal or navigate to hidden pair addrs. - **Checksum / href:** only `chartsPairHref` / `isChartsPairRouteParam` (`isValidTerraAddress`). No string concat into `Navigate`. `replace`, not `push`. - **`?price=`:** carry only when it is a leg of the **newly selected** pair (`matchChartsPriceParam`); otherwise drop. Hostile `price` ignored (existing parser). - **U1:** Charts is AMM charting, not mint/redeem. - **C653:** no new `shell-panel*` / `card-glass` chrome. - Do **not** persist last pair in `localStorage`. Do not change Trade `PairSearchSelect`. - Do **not** weaken `python3 scripts/check_chrome_nesting.py`. ## Relevant files | Path | Why | | --- | --- | | `frontend-dapp/src/pages/ChartsPage.tsx` | Hero effect vs `selectPair`; optional fallback snap | | `frontend-dapp/src/pages/ChartsPage.test.tsx` | Missing MenuSelect click vs hero race | | `frontend-dapp/src/utils/chartsPairRoute.ts` | Href / param helpers (only if selection becomes URL-only) | | `frontend-dapp/src/utils/pairCatalogRank.ts` | `resolveChartsHeroPairAddress` — keep behavior, fix call site | | `skills/AGENTS_FRONTEND_CHARTS_UST1_HERO.md` + `docs/frontend.md` § Charts UST1/USD hero | Document: hero applies only until an explicit select | | `scripts/qa/verify-issue-680.sh` | Must stay green; add `verify-issue-<id>` for this ticket | ## Recommended direction 1. **Hero is idle-only.** Run the #680 replace-nav only when the user has not committed a pair: e.g. `selectedPairAddr` empty **and** `isBareCharts`, or a ref/`userSelectedRef` set in `selectPair` that the hero effect must not override. Do **not** treat `selectedPairAddr !== hero` on bare `/charts` as “force hero”. 2. **Prefer URL as source of truth after the first select.** Once `navigate(/charts/{addr})` is intended, do not write a different addr from effects until the next `selectPair`. Trade’s `if (pairAddr) return` on auto-pick is the pattern. 3. **Fallback to `pairOptions[0]`** must not run while `needsPairFetch && selectedPairQuery.isLoading` (already skipped) **and** must not run when `selectedPairAddr` is a valid bech32 the user just chose (wait for `getPair` / extra row). Never replace a valid `validRoutePair`. 4. **Test the click path**, not remount: render `/charts` with hero + **B** in `getPairs`, `userEvent` the listbox, assert one `getCandles`/`getPairStats`/`getLeaderboard` for **B** and `window.location` `/charts/{B}`. ## Acceptance criteria - [ ] **AC1.** Bare `/charts`, list loaded, first Select Pair change to listed **B** ≠ hero: URL, trigger label, candles, 24h stats, leaderboard all use **B**. No second select. - [ ] **AC2.** Idle bare `/charts` (no click) still auto-picks hero (**C680-1**). - [ ] **AC3.** Reload `/charts/{B}` (listed, non-hero) keeps **B** (**C680-5**). Hero effect does not run. - [ ] **AC4.** After a successful select to **B**, changing Find/sort/page does not snap the workspace back to hero or `pairOptions[0]`. - [ ] **AC5.** Carried `?price=` is dropped when it is not a leg of **B**; hostile price still ignored. - [ ] **AC6.** `make verify-issue-680` (and C680-1–C680-8) stay green. New RTL covers the MenuSelect race. `check_chrome_nesting.py` green. ## Test plan (functional paths) | # | Path | Expect | |---|------|--------| | T1 | `/charts`, click **B** in `#chart-pair-select` | First change → `/charts/{B}`; candles(B); select value **B** | | T2 | `/charts`, no click | Still hero + `?price=UST1` when UST1/cUSTC listed | | T3 | `/charts/{B}` reload | Stays **B**; no hero navigate | | T4 | Select **B**, then select hero | First change to hero works (symmetric) | | T5 | Select **B** not on current pager page | Stays **B** (extra `getPair`); no snap to page[0] | | T6 | Keyboard listbox choose **B** | Same as T1 (`MenuSelect` keyboard `onSelectIndex`) | | T7 | Rapid double-select **B** then **C** | Last committed pair wins; no hero insert | | T8 | Existing C680 invert / volume-unchanged | Unchanged | Vitest: RTL T1–T5 on `ChartsPage.test.tsx`. Playwright optional (`PLAYWRIGHT_SKIP_CHAIN=1`): one Charts pair change from nav `/charts`. ## Test plan (attack, hack, and abuse) | # | Vector | Expect | |---|--------|--------| | A1 | `onChange` with non-bech32 / `javascript:` / `data:` / `//` | `chartsPairHref` returns null; no navigate; no fetch with that string | | A2 | Hero effect concatenating `pairAddr` + query | Never; only `chartsPairHref` + `URLSearchParams` via existing helpers | | A3 | `/charts/<script>` after a failed select | Invalid notice; no `getPairStats('<script>')` (C680-5) | | A4 | `?price=<script>` carried across select | Ignored; not copied into share/DOM | | A5 | Gem pair addr in production list | P562 hide; cannot select or land | | A6 | Open redirect `?price=https://phish` | Parser reject; href stays `/charts/{bech32}` | | A7 | History flood from effect loop | `replace` only; RTL no act-spam / infinite navigate | | A8 | Apply/write loop: hero vs `selectPair` | User select wins; idle hero still once | | A9 | XSS via option label from indexer symbol | Existing text content; no `dangerouslySetInnerHTML` | | A10 | Selecting paused/frozen listed pair | Workspace switches; freeze banner only; no execute (Charts has no swap) | ## Verification criteria - New `make verify-issue-<id>`: ChartsPage RTL T1 + C680 subset + docs/skill grep that hero must not override `selectPair`. - `make verify-issue-680`, `make verify-issue-524`, `python3 scripts/check_chrome_nesting.py` stay green. - Manual: `make dev` → `/charts` → Select Pair **once** to a non-hero listed market → URL and chart match that pair without a second pick. Deep-link `/charts/{B}` still loads **B**. Idle `/charts` still opens UST1/cUSTC. ## Out of scope - Trade `PairSearchSelect` (closed #350/#357). - Indexer USDT USD (#1258). - Pair catalog ranking (#534) except not snapping selection when the list re-sorts. - New chrome, share-button changes, indexer APIs. ## First-pass model recommendation Recommendation: composer Rationale: One production page (`ChartsPage.tsx`) plus focused `ChartsPage.test.tsx` (optional one-line skill/docs). Same local effect-guard pattern as Trade #357 (`if (pairAddr) return` on auto-pick). Low/medium UI-routing risk; no contracts, auth, keys, wasm, wallet/2-of-3, or migrations. Acceptance is deterministic RTL (first `#chart-pair-select` change vs C680-1 idle hero). C680 invariants are already tested; this ticket adds the missing click path rather than redesigning Charts protocol.
Author
Owner

/agent implement

/agent implement
Author
Owner

cl8y-agent-control: queued implement job d4c0be9c-b8b1-444a-8bad-a35858db354e (not executed; no Hetzner VM).

cl8y-agent-control: queued `implement` job `d4c0be9c-b8b1-444a-8bad-a35858db354e` (not executed; no Hetzner VM).
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#1266
No description provided.