fix(i18n): ein werfender Hörer hält die übrigen beim Sprachwechsel nicht auf

setLang rief die Hörer in einer Schleife ohne Schutz: warf einer (etwa beim Neuaufbau der Kopfzeile), blieben die folgenden ungerufen und der
Aufrufer bekam den Fehler zu sehen, obwohl die Sprache schon umgestellt war.

- i18n.ts: jeder Hörer läuft in try/catch; der Fehler geht mit console.error in die Konsole, der nächste Hörer folgt.
- i18n.ts: der Parameter `browserLanguages` von initialLang hieß wie die Funktion darunter und verdeckte sie; jetzt `languages`.
- Tests: ein werfender Hörer vor, zwischen und nach anderen: alle übrigen laufen, setLang wirft nicht, Sprache und <html lang> stehen, der Fehler
  steht einmal je Aufruf in der Konsole, der werfende Hörer bleibt angemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:32:01 +00:00
1 parent 0bcdfe258d
commit 1fd08d6373
2 files changed
+51 -4

No files matched your search

+39
View File
@@ -179,6 +179,45 @@ describe('onLangChange', () => {
expect(calls).toEqual(['en']); expect(calls).toEqual(['en']);
}); });
it('ein Hörer, der wirft, hält die späteren nicht auf: die Sprache steht, der Fehler geht in die Konsole', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const boom = new Error('boom');
const before = listen();
stops.push(
onLangChange(() => {
throw boom;
}),
);
const after = listen();
expect(() => setLang('en')).not.toThrow();
expect(currentLang()).toBe('en');
expect(document.documentElement.lang).toBe('en');
expect(before).toEqual(['en']);
expect(after).toEqual(['en']);
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(expect.any(String), boom);
// der werfende Hörer bleibt angemeldet und stört auch den nächsten Wechsel nicht
expect(() => setLang('de')).not.toThrow();
expect(after).toEqual(['en', 'de']);
expect(error).toHaveBeenCalledTimes(2);
});
it('mehrere werfende Hörer: jeder wird gemeldet, alle übrigen laufen', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const calls: string[] = [];
for (const name of ['a', 'b', 'c']) {
stops.push(
onLangChange(() => {
calls.push(name);
if (name !== 'b') throw new Error(name);
}),
);
}
setLang('en');
expect(calls).toEqual(['a', 'b', 'c']);
expect(error).toHaveBeenCalledTimes(2);
});
it('ein Hörer darf sich beim Melden abmelden, die übrigen werden trotzdem gerufen', () => { it('ein Hörer darf sich beim Melden abmelden, die übrigen werden trotzdem gerufen', () => {
const calls: string[] = []; const calls: string[] = [];
const stopA = onLangChange(() => { const stopA = onLangChange(() => {
+12 -4
View File
@@ -11,9 +11,9 @@ export const LANG_KEY = 'aircard.lang';
* Startsprache: die gespeicherte Wahl (`de` oder `en`), sonst Deutsch, wenn die erste Browsersprache mit `de` beginnt, * Startsprache: die gespeicherte Wahl (`de` oder `en`), sonst Deutsch, wenn die erste Browsersprache mit `de` beginnt,
* andernfalls Englisch. Dieselbe Regel steht im Skript in index.html, das <html lang> vor dem ersten Paint setzt. * andernfalls Englisch. Dieselbe Regel steht im Skript in index.html, das <html lang> vor dem ersten Paint setzt.
*/ */
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang { export function initialLang(stored: string | null, languages: readonly string[]): Lang {
if (stored === 'de' || stored === 'en') return stored; if (stored === 'de' || stored === 'en') return stored;
return (browserLanguages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en'; return (languages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en';
} }
function readStored(): string | null { function readStored(): string | null {
@@ -42,7 +42,8 @@ export function currentLang(): Lang {
/** /**
* Stellt die Sprache um: setzt <html lang>, merkt sie sich (außer mit `persist: false`) und meldet einen echten Wechsel an * Stellt die Sprache um: setzt <html lang>, merkt sie sich (außer mit `persist: false`) und meldet einen echten Wechsel an
* die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer. * die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer; wirft einer, kommt der Fehler in die Konsole und die
* übrigen werden trotzdem gerufen.
*/ */
export function setLang(next: Lang, opts: { persist?: boolean } = {}): void { export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
const changed = next !== currentLang(); const changed = next !== currentLang();
@@ -55,8 +56,15 @@ export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
// gilt dann nur für diese Sitzung // gilt dann nur für diese Sitzung
} }
} }
if (!changed) return;
// Kopie, damit sich ein Hörer beim Melden abmelden darf. // Kopie, damit sich ein Hörer beim Melden abmelden darf.
if (changed) for (const fn of [...listeners]) fn(next); for (const fn of [...listeners]) {
try {
fn(next);
} catch (err) {
console.error('Ein Hörer des Sprachwechsels ist gescheitert', err);
}
}
} }
/** Meldet jeden Sprachwechsel (nicht den bloßen Aufruf mit der aktuellen Sprache). Gibt die Abmeldung zurück. */ /** Meldet jeden Sprachwechsel (nicht den bloßen Aufruf mit der aktuellen Sprache). Gibt die Abmeldung zurück. */