Skip to content

fix(dashboard): render pagination in the dashboard's own locale - #152

Merged
AnjanJ merged 1 commit into
mainfrom
fix/dashboard-pagy-locale-isolation
Aug 13, 2026
Merged

fix(dashboard): render pagination in the dashboard's own locale#152
AnjanJ merged 1 commit into
mainfrom
fix/dashboard-pagy-locale-isolation

Conversation

@AnjanJ

@AnjanJ AnjanJ commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Fixes #148.

The bug

The dashboard rendered Pagy labels in whatever language the host app last used — Russian on one refresh, Portuguese on the next.

Root cause

Pagy stores its locale in a thread-local it never resets (pagy-43.6.1/lib/pagy/modules/i18n/i18n.rb:24-31):

# Set a valid locale or nil for the duration of a single request. Avoid errors/logging.
def locale=(value)
  Thread.current[:pagy_locale] = value.to_s[LOCALE_PATTERN]
end

def locale
  Thread.current[:pagy_locale] || 'en'
end

That comment is aspirational — there is no around_action, railtie hook, or ensure anywhere in the gem. So under Puma:

  1. Host app request on thread A sets Pagy::I18n.locale = "ru"
  2. Thread A returns to the pool still holding "ru"
  3. A dashboard request lands on thread A → renders Russian
  4. Next one lands on thread B, which last served pt → Portuguese

The language is a function of which pooled thread picked up the request, which is why it looked random and changed on refresh.

Reproduction

Deterministic, no Rails app needed — set the locale on a thread, then render info_tag on that same thread:

host set ru  -> Всего 100 записей, показаны с 1 по 25
host set pt  -> Mostrando itens 1-25 de um total de 100
host set fr  -> Affichage des éléments 1 à 25 sur 100 au total
host set es  -> Mostrando ítems 1-25 de 100 en total

Identical dashboard code, four languages.

The fix

Pagy 43 has no per-call locale option (verified across every helper signature), so set-and-restore around the request is the only correct approach. Two details are load-bearing:

  • around_action, not before_action. A before_action would strand the dashboard's locale on the thread for the host app's next request — the same leak pointing the other way. The ensure also covers the rescue_from handlers, which still render through the view layer.
  • Read the previous value from Thread.current, not Pagy::I18n.locale. The getter coerces nil"en", so restoring through it would stamp "en" onto a thread that started clean, making the dashboard a source of the very leak it fixes.

config.dashboard_locale

Added (default "en") rather than hardcoding English, so the mechanism is already in place for translating the rest of the dashboard later. Today it only affects Pagy labels; every other string in the UI is still English.

Resolution is defensive. An unknown lowercase tag ("xx") warns and falls back inside Pagy, but a wrong-cased one ("EN") passes Pagy's LOCALE_PATTERN, finds en.yml, then reads a nil dictionary because the YAML's top-level key is lowercase — raising NoMethodError mid-render on every dashboard page. Values are matched case-insensitively against the shipped dictionaries and fall back to "en", so a config typo cannot take the dashboard down.

Note for the reporter

The workaround in #148 has this same bug in the opposite direction — the admin controller sets Pagy::I18n.locale in a before_action and never restores it, leaking "en" into the public site's threads. The around_action/ensure pattern here fixes that class of bug.

Test plan

  • RSpec: 3648 examples, 0 failures (+8)
  • RuboCop: 414 files, no offenses
  • Verified the two core specs fail with the around_action removed and pass with it, including the exact reported symptom
  • Locale resolution checked against nil, "", " ", en, EN, fr, FR, ru, pt-BR, pt-br, PT-BR, zh-CN, :fr, "xx", "not-a-locale"

🤖 Generated with Claude Code

The dashboard rendered Pagy labels in whatever language the host app last
used — Russian on one refresh, Portuguese on the next.

Pagy stores its locale in Thread.current[:pagy_locale] and never resets it
(pagy 43.6.1, modules/i18n/i18n.rb:24-31; the "for the duration of a single
request" comment there is aspirational, nothing in the gem enforces it). A host
app that assigns a locale per request leaves that value on the Puma thread, so a
dashboard request landing on a recycled thread inherits it. The language you get
is a function of which pooled thread served you, which is why it looked random.

Reproduced deterministically without a Rails app: setting the locale on a thread
and then rendering info_tag on that same thread yields ru/pt/fr/es output from
identical dashboard code.

Pagy 43 has no per-call locale option (verified across every helper signature),
so the only correct fix is to set and restore around the request:

- around_action, not before_action — a before_action would strand the
  dashboard's locale on the thread for the host app's next request, the same
  leak pointing the other way. The ensure also covers the rescue_from handlers,
  which still render through the view layer.
- The previous value is read from Thread.current directly, not from
  Pagy::I18n.locale. The getter coerces nil to "en", so restoring through it
  would stamp "en" onto a thread that started clean, making the dashboard a
  source of the very leak it fixes.

Adds config.dashboard_locale (default "en") rather than hardcoding English, so
the mechanism is already in place for translating the rest of the dashboard.

Locale resolution is defensive: an unknown lowercase tag ("xx") warns and falls
back in Pagy itself, but a wrong-cased one ("EN") passes Pagy's format check and
then raises NoMethodError mid-render, because the dictionary is looked up by an
exact top-level YAML key. Values are matched case-insensitively against the
shipped dictionaries and fall back to "en", so a config typo cannot break every
dashboard page.

Test plan:
- RSpec: 3648 examples, 0 failures (+8)
- RuboCop: 414 files, no offenses
- Verified the two core specs fail with the around_action removed and pass with
  it, including the exact reported symptom
- Locale resolution checked against nil, "", "  ", en, EN, fr, FR, ru, pt-BR,
  pt-br, PT-BR, zh-CN, :fr, "xx" and "not-a-locale"

Fixes #148

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AnjanJ
AnjanJ merged commit abe49f5 into main Aug 13, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Unexpected localized Pagy labels

1 participant