From 8ae94629cdd0b079e1dd63bbe0a07a116fc3078b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 12:31:31 +0500 Subject: [PATCH] fix(ios): localize plural counts without Intl.PluralRules (Hermes gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes (React Native's JS engine) ships without `Intl.PluralRules`, and i18next's plural resolver depends on it — so on device every plural key fell back to English ("1 app installed" instead of "已安装 1 个应用") while non-plural keys localized fine. Web has `Intl.PluralRules`, so it never showed there. Found via iPhone 17 simulator testing; diagnostic confirmed `Intl.PluralRules === undefined` on Hermes. The standard `@formatjs/intl-pluralrules` polyfill can't install here — Hermes declares `Intl.PluralRules` as a non-configurable stub, so the polyfill's `defineProperty` throws ("property is not writable"). Instead add a tiny `tCount(baseKey, count)` helper that selects the English one/other form manually (no Intl) and uses the single "other" form for the other locales; the strings interpolate `{{n}}` so i18next's own plural machinery never triggers. Routed the four plural call sites through it: apps installed count, search result count, search "looking across N objects", and the record-list count. Verified on device: "已安装 1 个应用" now renders. e2e (33) + unit (1298) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(app)/[appName]/[objectName]/index.tsx | 3 ++- app/(tabs)/apps.tsx | 3 ++- app/(tabs)/search.tsx | 5 +++-- lib/i18n.ts | 17 +++++++++++++++++ locales/ar.json | 8 ++++---- locales/en.json | 16 ++++++++-------- locales/zh.json | 8 ++++---- 7 files changed, 40 insertions(+), 20 deletions(-) diff --git a/app/(app)/[appName]/[objectName]/index.tsx b/app/(app)/[appName]/[objectName]/index.tsx index 867f056..087d78f 100644 --- a/app/(app)/[appName]/[objectName]/index.tsx +++ b/app/(app)/[appName]/[objectName]/index.tsx @@ -4,6 +4,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { Plus } from "lucide-react-native"; import { useClient, useQuery, useView } from "@objectstack/client-react"; import { useTranslation } from "react-i18next"; +import { tCount } from "~/lib/i18n"; import { useCallback, useState } from "react"; import { ListViewRenderer } from "~/components/renderers"; import type { ListViewMeta } from "~/components/renderers"; @@ -84,7 +85,7 @@ export default function ObjectListScreen() { const recordCount = records.length; const countLabel = isLoading ? undefined - : t("records.recordCount", { count: recordCount }); + : tCount("records.recordCount", recordCount); return ( diff --git a/app/(tabs)/apps.tsx b/app/(tabs)/apps.tsx index a294ca3..bedb71a 100644 --- a/app/(tabs)/apps.tsx +++ b/app/(tabs)/apps.tsx @@ -11,6 +11,7 @@ import { ListSkeleton } from "~/components/ui/ListSkeleton"; import { useApps } from "~/hooks/useApps"; import { getIcon } from "~/lib/getIcon"; import { getUserErrorMessage } from "~/lib/error-handling"; +import { tCount } from "~/lib/i18n"; export default function AppsScreen() { const { apps, isLoading, error, refetch } = useApps(); @@ -46,7 +47,7 @@ export default function AppsScreen() { {showSkeleton ? t("apps.loading") - : t("apps.installed", { count: apps.length })} + : tCount("apps.installed", apps.length)} diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index 64339bc..e3351e5 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -4,6 +4,7 @@ import { webContentMaxWidth } from "~/lib/responsive"; import { Search as SearchIcon, X, ChevronRight, FileText, SearchX } from "lucide-react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; +import { tCount } from "~/lib/i18n"; import { Input } from "~/components/ui/Input"; import { EmptyState } from "~/components/ui/EmptyState"; import { useGlobalSearch } from "~/hooks/useGlobalSearch"; @@ -49,7 +50,7 @@ export default function SearchScreen() { title={t("search.emptyTitle")} description={ objectCount > 0 - ? t("search.lookingAcross", { count: objectCount }) + ? tCount("search.lookingAcross", objectCount) : t("search.emptyHint") } /> @@ -73,7 +74,7 @@ export default function SearchScreen() { keyboardShouldPersistTaps="handled" > - {t("search.resultCount", { count: totalCount })} + {tCount("search.resultCount", totalCount)} {groups.map((group) => ( diff --git a/lib/i18n.ts b/lib/i18n.ts index e50fe83..0e89c33 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -58,4 +58,21 @@ i18n.use(initReactI18next).init({ compatibilityJSON: "v4", }); +/** + * Count-aware translation that does NOT depend on `Intl.PluralRules`. + * + * Hermes (React Native's JS engine) ships without `Intl.PluralRules`, and + * i18next's plural resolver relies on it — so on device every plural key falls + * back to the fallback language (e.g. "1 app installed" instead of the + * localized string) while non-plural keys localize fine. This picks the English + * one/other form manually and uses the single "other" form for the other + * locales; the strings interpolate `{{n}}` (named so i18next's own plural + * handling never triggers). + */ +export function tCount(baseKey: string, count: number): string { + const isEnglish = (i18n.language || "en").startsWith("en"); + const suffix = isEnglish && count === 1 ? "_one" : "_other"; + return i18n.t(`${baseKey}${suffix}`, { n: count }); +} + export default i18n; diff --git a/locales/ar.json b/locales/ar.json index ebf30b3..c186dfa 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -59,7 +59,7 @@ "recordCount_two": "سجلان", "recordCount_few": "{{count}} سجلات", "recordCount_many": "{{count}} سجلاً", - "recordCount_other": "{{count}} سجل", + "recordCount_other": "{{n}} سجل", "deleteConfirm": "هل أنت متأكد من أنك تريد حذف هذا السجل؟", "deleteConfirmNamed": "هل أنت متأكد من أنك تريد حذف \"{{label}}\"؟", "deleteFailed": "فشل حذف السجل.", @@ -139,7 +139,7 @@ "apps": { "title": "التطبيقات", "loading": "جارٍ تحميل تطبيقاتك…", - "installed_other": "{{count}} تطبيق مثبّت", + "installed_other": "{{n}} تطبيق مثبّت", "loadErrorTitle": "تعذّر تحميل التطبيقات", "emptyTitle": "لا توجد تطبيقات", "emptyDesc": "ستظهر تطبيقات مؤسستك هنا بمجرد تثبيتها.", @@ -227,7 +227,7 @@ "lookingAcross_two": "يتم البحث في كائنين", "lookingAcross_few": "يتم البحث في {{count}} كائنات", "lookingAcross_many": "يتم البحث في {{count}} كائناً", - "lookingAcross_other": "يتم البحث في {{count}} كائن", + "lookingAcross_other": "يتم البحث في {{n}} كائن", "noResultsTitle": "لا توجد نتائج", "noResultsBody": "لا يوجد ما يطابق \"{{query}}\". جرّب كلمة مفتاحية أخرى.", "resultCount_zero": "{{count}} نتيجة", @@ -235,7 +235,7 @@ "resultCount_two": "نتيجتان", "resultCount_few": "{{count}} نتائج", "resultCount_many": "{{count}} نتيجة", - "resultCount_other": "{{count}} نتيجة", + "resultCount_other": "{{n}} نتيجة", "openLabel": "فتح {{title}}" }, "offline": { diff --git a/locales/en.json b/locales/en.json index 87810ef..496c2e5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -54,8 +54,8 @@ "editRecord": "Edit Record", "deleteRecord": "Delete Record", "createNamed": "Create {{name}}", - "recordCount_one": "{{count}} record", - "recordCount_other": "{{count}} records", + "recordCount_one": "{{n}} record", + "recordCount_other": "{{n}} records", "deleteConfirm": "Are you sure you want to delete this record?", "deleteConfirmNamed": "Are you sure you want to delete \"{{label}}\"?", "deleteFailed": "Failed to delete the record.", @@ -135,8 +135,8 @@ "apps": { "title": "Apps", "loading": "Loading your apps…", - "installed_one": "{{count}} app installed", - "installed_other": "{{count}} apps installed", + "installed_one": "{{n}} app installed", + "installed_other": "{{n}} apps installed", "loadErrorTitle": "Unable to Load Apps", "emptyTitle": "No Apps", "emptyDesc": "Your enterprise applications will appear here once installed.", @@ -219,12 +219,12 @@ "clearLabel": "Clear search", "emptyTitle": "Search across all your records", "emptyHint": "Type to start searching", - "lookingAcross_one": "Looking across {{count}} object", - "lookingAcross_other": "Looking across {{count}} objects", + "lookingAcross_one": "Looking across {{n}} object", + "lookingAcross_other": "Looking across {{n}} objects", "noResultsTitle": "No Results", "noResultsBody": "Nothing matched “{{query}}”. Try a different keyword.", - "resultCount_one": "{{count}} result", - "resultCount_other": "{{count}} results", + "resultCount_one": "{{n}} result", + "resultCount_other": "{{n}} results", "openLabel": "Open {{title}}" }, "offline": { diff --git a/locales/zh.json b/locales/zh.json index 2f49418..069ed3c 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -54,7 +54,7 @@ "editRecord": "编辑记录", "deleteRecord": "删除记录", "createNamed": "创建{{name}}", - "recordCount_other": "{{count}} 条记录", + "recordCount_other": "{{n}} 条记录", "deleteConfirm": "确定要删除此记录吗?", "deleteConfirmNamed": "确定要删除“{{label}}”吗?", "deleteFailed": "删除记录失败。", @@ -134,7 +134,7 @@ "apps": { "title": "应用", "loading": "正在加载你的应用…", - "installed_other": "已安装 {{count}} 个应用", + "installed_other": "已安装 {{n}} 个应用", "loadErrorTitle": "无法加载应用", "emptyTitle": "暂无应用", "emptyDesc": "安装后,你的企业应用将显示在这里。", @@ -217,10 +217,10 @@ "clearLabel": "清除搜索", "emptyTitle": "搜索您的所有记录", "emptyHint": "输入以开始搜索", - "lookingAcross_other": "正在 {{count}} 个对象中搜索", + "lookingAcross_other": "正在 {{n}} 个对象中搜索", "noResultsTitle": "无结果", "noResultsBody": "没有与“{{query}}”匹配的结果。请尝试其他关键词。", - "resultCount_other": "{{count}} 条结果", + "resultCount_other": "{{n}} 条结果", "openLabel": "打开 {{title}}" }, "offline": {