Defend against Chrome rewriting the DOM, and add the language setting #145

Open
opened 2026-08-31 16:15:04 +00:00 by jcoffey-dev · 0 comments
Owner

Groundwork for un-shelving translations: the structural defence against Chrome's translator, plus the setting the served language reads from.

Three findings that shaped the scope

1. There is no i18n system. No dependency, no catalogues, no t() — every user-facing string is hardcoded English JSX. Per the brief's own constraint (only expose locales that have strings shipped), the selector offers English alone today. Phase 1 (de/fr/nl/es/pt-BR) is string extraction plus five translations, which is its own effort.

2. settings.locale already existed, meaning something else — the formatting locale (dates, times, numerals), backed by ~620 CLDR tags. So this adds uiLanguage instead. They must stay separate: German dates with an English interface is a real preference, and folding them together would silently rewrite everyone's date format the first time they picked a language.

3. Task 2's server-side requirement isn't achievable, and shouldn't be. ihasmail serves a static shell and holds no account state; settings.json lives in the reader's own JMAP Files on Stalwart. Reading it before the page existed would mean authenticating to Stalwart on every page load — the thing the stateless design exists to avoid.

lang is instead set where applyTheme is set: at store module load, from the localStorage cache, before createRoot() renders anything. index.html already ships static lang="en", so the first bytes are right for the default and the store only corrects a reader who chose otherwise. Both halves are tested — the markup claim and the module-load timing.

Task 4 audit — 24 findings, 15 refactored

Scanned with TypeScript's own parser, not grep (it's already a devDependency; regex gave 182 hits, nearly all JSX attributes and imports). {items.map(...)} was excluded: Chrome wraps text, not elements.

  • Shape B, conditional text (15){cond ? "a" : "b"} at text level. The worse shape: React removes the exact node Chrome swapped.
  • Shape A, text beside a conditional element (9) — including MessageList.tsx:650, the mail-list row named in the brief.

Pluralisation and "count + label" pairs are collapsed into a single expression, so the text is a lone child React updates via textContent rather than a node with conditional siblings to insert around. InviteCard's {method === "REPLY" && organizer ? "" : ""} rendered an empty string either way and is simply gone.

Reviewed, not changed (9): ui/misc.tsx:50 ({icon} is a ReactNode, and wrapping it risks the .empty child selectors); FilesTree.tsx:197, MessageView.tsx:287 and the remaining fragment-level cases, where the "text" is a render helper returning elements — the parser's textish check is conservative and these are false positives on inspection; plus five settings hints where the conditional is a whole sentence rather than an interpolation next to one.

Task 3 — narrow markers

Email bodies, raw message source, attachment text, generated and hand-edited Sieve, the brand, the login name. Not on <body>: someone whose language ihasmail doesn't speak yet should still be able to translate the parts that are ours. Email bodies turn out to live in a shadow root (MessageView.tsx:363), so React never reconciles them and they were never a crash risk — the marker there is about not rewriting what a sender actually wrote.

The honest result on the crash

I could not reproduce it on React 19.2.8. Wrapping 207–249 React-managed text nodes in <font> — exactly as the translator does — then driving in-place conditional toggles and navigations, left the app intact with the boundary never firing. The original issue is from React 16 and the reconciler has changed a great deal since.

So this lands as defence whose premise is weaker than assumed, rather than as a fix for something observed here. The boundary is insurance, not load-bearing. The notranslate markers and the collapsed interpolations stand on their own merits regardless — and if the crash is genuinely no longer reachable on React 19, that is an argument for un-shelving i18n sooner than planned, which is worth deciding deliberately rather than inheriting.

Checks

433 web tests (+15) and 109 server tests pass; typecheck and build clean. Verified in a real browser against the mock: lang="en" before and after sign-in, the boundary's display: contents leaving the grid chain intact (panes at 520/937px), and translate="no" present on the rendered email host, brand and code blocks.

No new dependencies. The boundary is a class component because React offers no hook for it.

Merged 2026-08-31 as coffey-labs/ihasmail@90ed579876

Rebuilt from: git history, session transcript.

Groundwork for un-shelving translations: the structural defence against Chrome's translator, plus the setting the served language reads from. ## Three findings that shaped the scope **1. There is no i18n system.** No dependency, no catalogues, no `t()` — every user-facing string is hardcoded English JSX. Per the brief's own constraint (*only expose locales that have strings shipped*), the selector offers **English alone** today. Phase 1 (de/fr/nl/es/pt-BR) is string extraction plus five translations, which is its own effort. **2. `settings.locale` already existed, meaning something else** — the *formatting* locale (dates, times, numerals), backed by ~620 CLDR tags. So this adds **`uiLanguage`** instead. They must stay separate: German dates with an English interface is a real preference, and folding them together would silently rewrite everyone's date format the first time they picked a language. **3. Task 2's server-side requirement isn't achievable, and shouldn't be.** ihasmail serves a static shell and holds no account state; `settings.json` lives in the reader's own JMAP Files on Stalwart. Reading it before the page existed would mean authenticating to Stalwart on every page load — the thing the stateless design exists to avoid. `lang` is instead set where `applyTheme` is set: at store module load, from the localStorage cache, **before `createRoot()` renders anything**. `index.html` already ships static `lang="en"`, so the first bytes are right for the default and the store only corrects a reader who chose otherwise. Both halves are tested — the markup claim and the module-load timing. ## Task 4 audit — 24 findings, 15 refactored Scanned with TypeScript's own parser, not grep (it's already a devDependency; regex gave 182 hits, nearly all JSX attributes and imports). `{items.map(...)}` was excluded: Chrome wraps **text**, not elements. - **Shape B, conditional text (15)** — `{cond ? "a" : "b"}` at text level. The worse shape: React removes the exact node Chrome swapped. - **Shape A, text beside a conditional element (9)** — including `MessageList.tsx:650`, the mail-list row named in the brief. Pluralisation and "count + label" pairs are collapsed into a single expression, so the text is a lone child React updates via `textContent` rather than a node with conditional siblings to insert around. `InviteCard`'s `{method === "REPLY" && organizer ? "" : ""}` rendered an empty string either way and is simply gone. **Reviewed, not changed (9):** `ui/misc.tsx:50` (`{icon}` is a ReactNode, and wrapping it risks the `.empty` child selectors); `FilesTree.tsx:197`, `MessageView.tsx:287` and the remaining fragment-level cases, where the "text" is a render helper returning elements — the parser's `textish` check is conservative and these are false positives on inspection; plus five settings hints where the conditional is a whole sentence rather than an interpolation next to one. ## Task 3 — narrow markers Email bodies, raw message source, attachment text, generated and hand-edited Sieve, the brand, the login name. **Not** on `<body>`: someone whose language ihasmail doesn't speak yet should still be able to translate the parts that are ours. Email bodies turn out to live in a **shadow root** (`MessageView.tsx:363`), so React never reconciles them and they were never a crash risk — the marker there is about not rewriting what a sender actually wrote. ## The honest result on the crash **I could not reproduce it on React 19.2.8.** Wrapping 207–249 React-managed text nodes in `<font>` — exactly as the translator does — then driving in-place conditional toggles and navigations, left the app intact with the boundary never firing. The original issue is from React 16 and the reconciler has changed a great deal since. So this lands as defence whose premise is weaker than assumed, rather than as a fix for something observed here. The boundary is insurance, not load-bearing. The `notranslate` markers and the collapsed interpolations stand on their own merits regardless — and if the crash is genuinely no longer reachable on React 19, **that is an argument for un-shelving i18n sooner than planned**, which is worth deciding deliberately rather than inheriting. ## Checks 433 web tests (+15) and 109 server tests pass; typecheck and build clean. Verified in a real browser against the mock: `lang="en"` before and after sign-in, the boundary's `display: contents` leaving the grid chain intact (panes at 520/937px), and `translate="no"` present on the rendered email host, brand and code blocks. No new dependencies. The boundary is a class component because React offers no hook for it. **Merged** 2026-08-31 as coffey-labs/ihasmail@90ed5798766f <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.