From 25d2f0d0bd5be7038c4439a3f2526f9e934da345 Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 06:29:57 +0200 Subject: [PATCH] fix(ui): stop old translations and page shrink on Android The translation files get a content version in their URL, so a browser that cached an old file loads the new one. On touch screens the scroll lock of menus and selects no longer adds a right margin: Android Chrome reported one and the whole page became narrower. --- src/i18n.js | 3 ++- src/i18n.test.js | 22 ++++++++++++++++++++++ src/style.css | 9 +++++++++ vite.config.js | 16 ++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/i18n.test.js diff --git a/src/i18n.js b/src/i18n.js index b899a46..d0ca901 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -9,7 +9,8 @@ i18n.use(HttpBackend) lng: 'de', fallbackLng: 'en', backend: { - loadPath: '/i18n/{{lng}}.json', + // A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that. + loadPath: `/i18n/{{lng}}.json?v=${__TRANSLATIONS_VERSION__}`, }, interpolation: { escapeValue: false diff --git a/src/i18n.test.js b/src/i18n.test.js new file mode 100644 index 0000000..9292d25 --- /dev/null +++ b/src/i18n.test.js @@ -0,0 +1,22 @@ +import {createHash} from 'node:crypto'; +import {readdirSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it, vi} from 'vitest'; + +vi.mock('i18next-http-backend', () => ({ + default: {type: 'backend', init() {}, read(language, namespace, callback) { callback(null, {}); }}, +})); + +describe('translation files', () => { + it('are loaded under a URL that changes with their content, so no browser keeps an old copy', async () => { + const {default: i18n} = await import('./i18n.js'); + const loadPath = i18n.options.backend.loadPath; + + const dir = path.resolve(import.meta.dirname, '../public/i18n'); + const hash = createHash('sha256'); + for (const file of readdirSync(dir).filter((name) => name.endsWith('.json')).sort()) { + hash.update(readFileSync(path.join(dir, file))); + } + expect(loadPath).toBe(`/i18n/{{lng}}.json?v=${hash.digest('hex').slice(0, 12)}`); + }); +}); diff --git a/src/style.css b/src/style.css index f4e0e84..ebc46d4 100644 --- a/src/style.css +++ b/src/style.css @@ -191,3 +191,12 @@ @apply bg-background text-foreground antialiased; } } + +/* Radix menus and selects lock the page scroll and add a right margin as wide as the scrollbar, measured as + innerWidth minus the page width. Android Chrome can report a larger innerWidth, and then the whole page shrinks. + Touch screens have no scrollbar to make room for. */ +@media (pointer: coarse) { + html body[data-scroll-locked] { + margin-right: 0 !important; + } +} diff --git a/vite.config.js b/vite.config.js index 0cf1da5..f7e5743 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,13 +1,29 @@ +import {createHash} from 'node:crypto'; +import {readdirSync, readFileSync} from 'node:fs'; import path from 'node:path'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +const translationsDir = path.resolve(import.meta.dirname, 'public/i18n'); + +// Changes whenever a translation changes, so the browser loads the new file under a new URL. +const translationsVersion = () => { + const hash = createHash('sha256'); + for (const file of readdirSync(translationsDir).filter((name) => name.endsWith('.json')).sort()) { + hash.update(readFileSync(path.join(translationsDir, file))); + } + return hash.digest('hex').slice(0, 12); +}; + export default defineConfig({ plugins: [ react(), tailwindcss(), ], + define: { + __TRANSLATIONS_VERSION__: JSON.stringify(translationsVersion()), + }, resolve: { alias: { '@': path.resolve(import.meta.dirname, './src'),