fix(frontend): propagate locale changes reactively to mounted shells
All checks were successful
CI / test-and-quality (push) Successful in 2m44s

This commit is contained in:
2026-03-01 19:31:53 +00:00
parent dd3b48067a
commit dbe7c50681
4 changed files with 92 additions and 6 deletions

View File

@@ -1,11 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail { interface SessionDetail {
session: { code: string; status: string; current_round: number }; session: { code: string; status: string; current_round: number };
@@ -64,7 +64,7 @@ type LeaderboardResponse = FinishGameResponse;
</div> </div>
`, `,
}) })
export class HostShellComponent implements OnInit { export class HostShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale(); locale = resolvePreferredLocale();
readonly clientHasNoAudioOutput = clientHasNoAudioOutput; readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
@@ -84,8 +84,12 @@ export class HostShellComponent implements OnInit {
private readonly api = createApiClient(); private readonly api = createApiClient();
private readonly controller = createVerticalSliceController(this.api); private readonly controller = createVerticalSliceController(this.api);
private unsubscribeLocale: (() => void) | null = null;
ngOnInit(): void { ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
@@ -105,6 +109,11 @@ export class HostShellComponent implements OnInit {
void this.refreshSession(); void this.refreshSession();
} }
ngOnDestroy(): void {
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
}
copy(key: string): string { copy(key: string): string {
return t(key, this.locale); return t(key, this.locale);
} }

View File

@@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail { interface SessionDetail {
session: { code: string; status: string; current_round: number }; session: { code: string; status: string; current_round: number };
@@ -112,6 +112,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null; private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeLocale: (() => void) | null = null;
constructor() { constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) { if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -125,6 +126,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
const hashRoute = window.location.hash.replace(/^#\/?/, ''); const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? ''; const codeFromRoute = match?.[1] ?? '';
@@ -151,6 +156,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
this.clearReconnectTimer(); this.clearReconnectTimer();
this.clearStateSyncTimer(); this.clearStateSyncTimer();
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
} }
private readonly handleOnline = (): void => { private readonly handleOnline = (): void => {

View File

@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
};
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
};
}
describe('lobby i18n locale propagation', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
});
it('notifies subscribers immediately and on locale changes', async () => {
const localStorage = storageMock({ 'wpp.locale': 'en' });
vi.stubGlobal('window', {
location: { search: '' },
localStorage,
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
const updates: string[] = [];
const unsubscribe = i18n.subscribeToLocaleChanges((locale) => updates.push(locale));
expect(updates).toEqual(['en']);
i18n.setPreferredLocale('da');
expect(updates).toEqual(['en', 'da']);
unsubscribe();
i18n.setPreferredLocale('en');
expect(updates).toEqual(['en', 'da']);
});
});

View File

@@ -5,6 +5,9 @@ type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale; const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[]; const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
let activeLocale: SupportedLocale | null = null;
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
export function normalizeLocale(rawLocale?: string | null): SupportedLocale { export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
const locale = (rawLocale ?? '').trim().toLowerCase(); const locale = (rawLocale ?? '').trim().toLowerCase();
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) { if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
@@ -20,25 +23,45 @@ export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
} }
export function resolvePreferredLocale(): SupportedLocale { export function resolvePreferredLocale(): SupportedLocale {
if (activeLocale) {
return activeLocale;
}
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return DEFAULT_LOCALE; activeLocale = DEFAULT_LOCALE;
return activeLocale;
} }
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang'); const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
const storedLocale = window.localStorage?.getItem?.('wpp.locale'); const storedLocale = window.localStorage?.getItem?.('wpp.locale');
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : ''; const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
return normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE); activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
return activeLocale;
} }
export function setPreferredLocale(locale: string): SupportedLocale { export function setPreferredLocale(locale: string): SupportedLocale {
const normalized = normalizeLocale(locale); const normalized = normalizeLocale(locale);
activeLocale = normalized;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage?.setItem?.('wpp.locale', normalized); window.localStorage?.setItem?.('wpp.locale', normalized);
} }
for (const subscriber of localeSubscribers) {
subscriber(normalized);
}
return normalized; return normalized;
} }
export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) => void): () => void {
localeSubscribers.add(callback);
callback(resolvePreferredLocale());
return () => {
localeSubscribers.delete(callback);
};
}
export function t(key: string, locale: string): string { export function t(key: string, locale: string): string {
const normalizedLocale = normalizeLocale(locale); const normalizedLocale = normalizeLocale(locale);
const fallbackLocale = DEFAULT_LOCALE; const fallbackLocale = DEFAULT_LOCALE;