Skip to content

Mounting an I18nProvider in a test file silently changes what every LATER provider-less render in that file resolves #4514

Description

@yinlianghui

Measured while writing the locale tests for #4468 (PR #4512). Observation-class: no user hits this, but it costs a debugging lap and it can make a test look like it is asserting one thing while asserting another.

The mechanism

useObjectTranslation() is provider-safe by design — outside I18nProvider it falls back to react-i18next's global instance:

// packages/i18n/src/provider.tsx:637
language: context?.language || i18n.language || 'en',

The catch is that the global instance is shared across the whole test file, and every I18nProvider mounted in that file leaves it on the language it was given. Vitest isolates modules per file, not per test, so the effect persists across cleanup() and across describe boundaries: once any case has mounted a zh provider, every provider-less render later in the same file resolves zh.

Measured twice, in two different shapes

  1. A provider-less assertion that reads as a fallback pin but is really an ordering pin. In PR fix(i18n): every date branch threads the active locale — future-relative and absolute forms localize (#4468) #4512 a case asserting useDisplayLocale()'s 'en' last resort was written after the zh cases and failed with 3天后 — correct behaviour for react-i18next's state, and nothing to do with the fallback under test. Withdrawn, with the pin left in DateCellRenderer.test.tsx, which mounts no provider at all.

  2. An unrelated test in another file broken by adding a provider above it. Adding one I18nProvider case to packages/fields/src/widgets/GridField.test.tsx turned shows an uploaded file as a removable chip red, ~200 lines further down. That test reads FileField.tsx:474's translated aria-label:

    aria-label={t('fields.file.remove', { defaultValue: `Remove ${file.name}`, name: file.name })}

    Deterministic, and it reproduced on a clean re-run; running the chip test alone passes. The zh case was moved to its own file, and GridField.test.tsx carries a comment saying why.

Why this is worth recording

The failure never names its cause. It surfaces as an assertion about some unrelated label or date in a test that was not touched, several hundred lines from the provider that caused it, and it is invisible when that test is run alone — which is the first thing anyone does to triage it.

Candidate directions (not a recommendation — this needs measuring first)

  • An afterEach in the shared DOM setup that restores the global i18next instance's language, so provider-less renders always mean the same thing.
  • Have I18nProvider create an instance that is never installed as react-i18next's global default, so a provider can only affect its own subtree. Needs checking against the deliberate design in provider.tsx — the global fallback is what makes useObjectTranslation() provider-safe in the first place, so this may be a trade rather than a fix.
  • Leave the mechanism alone and document it where tests are written (AGENTS.md §测试纪律), on the grounds that the two occurrences above were both cheap to diagnose once known.

No suggested severity — filing it plainly for triage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions