fix(ui): keep tx column-filter state in canonical values across language switches - #184
Merged
Merged
Conversation
…age switches Setting a column filter on the transactions table and then switching the UI language emptied the visible table (0 rows and a blank empty-state) while #tx-count still reported the server's row count, because the filter state stored the localized option label while filterVal() re-localized on every comparison. The table header also stayed in the previous language: it is built once per container lifetime to preserve filter-input focus (#148), so column titles and dropdown labels never re-rendered. - TX_COLUMNS type/status options now emit {value, label}: value is the language-independent DB value (consume/earn/... , 成功/入账/处理中), label is the localized text. filterVal() compares against the raw row value. - Delete TX_TYPE_INV / TX_STATUS_INV: the "localized label -> DB value" inverse maps were keyed by runtime language output, so they could only express "translate whatever the dropdown currently shows". With the state holding the DB value they have nothing left to do. - tableTheadHtml renders both {value, label} and plain-string options. - Rebuild the header on atp:langchange so titles and options render in the newly selected language; the state (and therefore the user's filters) is preserved. Verified with a real-DOM A/B probe driving actual input events and I18n.setLang(): zh->en and en->zh flip from 0 rows + wrong-language header + blank empty-state to the correct rows and a re-localized header, while the zh->zh and en->en runs stay clean in both revisions. A separate guard confirms #148's focus-preserving property is intact (same node identity across typing and sort re-renders).
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
With a column filter applied on the transactions table, switching the UI language emptied the visible table. Reproduction, booting in
zh:typecolumn filter to消费→ 1 row is shown (correct).en.Result: 0 rows + a blank empty-state, while
#tx-countstill reports the count the server returned. The table header is a second, independent half of the same defect — after the switch the column titles and both dropdowns stay in the boot language, so the table renders half in the old language and half in the new one.Root cause (state): the filter state stored the localized option label, while
filterVal()re-localizes the row value on every comparison.TX_TYPE_INV/TX_STATUS_INVwere inverse maps keyed by runtime language output — they can only express "translate whatever the dropdown currently shows", so as soon as the boot language and the current language disagree the comparison matches nothing and every fetched row is filtered out.TX_TYPE_INV/TX_STATUS_INVwere born in the same commit as locale-valued options andfilterVal(68f9f70, #86, i18n v1.21).Root cause (header):
buildDataTablebuilds<thead>only when the container has no<table>—let table = container.querySelector("table"); if (!table), introduced by4ee2a2c(#148, "keep filter row alive during table rebuild") to stop destroying the filter inputs and thereby preserve input focus. Column titles (col.title()) and option labels (options()) are therefore written exactly once per container lifetime and never follow a language switch. An A/B against4ee2a2c~1shows the pre-#148 table did re-localize its header while still blanking the rows, so #148 added the stuck header; it did not cause the blanking — the two halves are genuinely separate.Why 167 green Rust tests and three green i18n gates never saw this: they are static assertions over source text, whereas
TX_TYPE_INVis keyed by runtime language output. That is also why the fix here is an assertion (a real-DOM A/B), not a new gate — a fourth static gate over the same three files would have been green on the bug.What this work started from, and what it refuted
The previous cycle's analysis claimed the switch made the filter send a raw i18n label to the backend as
type=. It does not reproduce. Because the header is never re-rendered, the dropdown keeps both its labels and its values in the boot language, so the "stale" inverse map still matches. Measured on the pre-change revision: organic requests carrying a NON-DB value = 0. The earlier probe had hand-built the stale map instead of driving the product, which is why it could not refute its own premise. The real defect is the one fixed here.Related Issue
None — no issue exists for this. The direction came from an internal audit of how the UI layer stores filter state, not from a report. No issue number was fabricated.
Changes
ui/js/app.js—TX_COLUMNStype/statusoptions now emit{ value, label }:valueis the language-independent DB value (consume/earn/topup/withdraw/gift, and成功/入账/处理中),labelis the localized display text.filterVal()now compares against the raw row value ((t) => t.type/(t) => t.status) instead of the localized string. The row objects already carry the raw DB values (txsToViewpassest.type/t.statusthrough), so no data-layer change was needed.ui/js/app.js— deletedTX_TYPE_INV/TX_STATUS_INVand their two consumers (txFilterParams()'sTX_STATUS_INV[f.status] || f.status,loadTransactions()'sTX_TYPE_INV[txTable.filters.type] || …). The select value is now the canonical DB value and is sent directly. These maps existed solely to translate a displayed label back into a DB value; with the state holding the DB value they have nothing left to do, so removing them eliminates the class rather than one instance.ui/js/app.js—tableTheadHtml's option renderer accepts both{ value, label }objects and plain strings (value == label), so the generic table renderer is unchanged for columns with no locale-dependent label.ui/js/app.js— addedrebuildDataTableHeader()and called it from the singleatp:langchangehandler, so the header and filter row are rebuilt in the language the user just selected. Filter values live instate.filters(now canonical values) and are re-applied asselected, so the user's selection survives the switch.ui/index.html— cache-bust forapp.js(20260912-5→20260912-6).api.js/data.js/i18n.jsand the stylesheet are untouched and keep their existing versions.src/i18n_pack.rscounts stay 786/786/330/305/537/430).Tests
cargo test全部通过 — 167 passed; 0 failedcargo fmt --check通过cargo clippy --all-targets -- -D warnings通过A/B probe over the real DOM. jsdom loads the real
index.html,api.js,data.js,i18n.jsandapp.js; the only stub isfetch. Every step is a real DOMinputevent and the realI18n.setLang()— no part of the code under test is reimplemented by the probe. The same probe is run against the pre-change revision and the post-change revision:6f8b599)zh → enen → zhzh → zhen → enThe two same-language runs are the negative controls: they must stay clean in both revisions and they do, so the flip is attributable to the change rather than to the probe. Both fatal controls hold in both revisions (otherwise the run proves nothing): the in-view
data-i18ntabs must follow the switch, and clearing the filter must restore every fetched row. The header check is by language, not "contains CJK" — underena correct header is CJK-free, but underzha CJK-free header would itself be the bug.Regression guard for #148. A separate probe confirms the focus-preserving property the build-once guard exists for is intact: the same node identity is preserved across typing + re-render and across a sort re-render, and the typed value survives both; the filter value additionally survives the language switch (where the node identity is expected to change, since the header is deliberately rebuilt).
Honest request audit. Every
/api/transactions?request is tagged with the phase that issued it, and only the two organic phases are allowed to testify — the probe cannot credit its own control traffic to the defect. Those organic requests carry DB values only, in both revisions.On instrumentation. The fix lives in jsdom-level event/DOM behaviour that
src/i18n_pack.rscannot reach (it asserts over source text). The evidence is therefore the DOM probe rather than a new#[cfg(test)]case; adding a Rust test that greps for{ value, label }would be a static check that was green on the bug. Same reasoning as above: assert the behaviour, not the text.Checklist
fix/)