Files
orca/mobile/web-entry/index.tsx
T
OrcaWinandOrcaWin 12a7d4c13a feat(mobile): localization infrastructure and language setting (#35399)
* feat(mobile): add UI localization infrastructure

Mobile follows the phone's language (or an in-app choice under Settings > Appearance)
across the same 11 languages as desktop, switching live like the theme does.

- i18next with desktop's translate(key, fallback, options) call shape; catalogs in
  mobile/src/i18n/locales load lazily, English fallbacks stay inline
- Hermes lacks Intl.PluralRules: load the FormatJS polyfill with the 11 languages' rules
- OTA pages get the shell's language through an optional bridge init field
- iOS permission prompts localized; iOS and Android 13+ per-app language pickers
- verify:mobile-localization and a coverage ratchet run in lint, check:changed and CI

* fix(mobile): mount pages after the newest language request; tighten localization gates

- Wait for the latest page language activation, not a superseded one
- Keep literal fallbacks when a key also has a dynamic one
- Count headerTitle as visible copy; select the mobile check when the runtime catalog generator changes

* feat(mobile): repaint themed components on language changes

Components that read the theme already subscribe for repaint; a language switch
repaints them too, so labels built by helpers during render follow it. RPC
recordings load the real i18next and JSON catalogs.

* fix(mobile): scope the translate() gate to render bodies and memos; prune stale keys on sync

Handlers and effects may call translate() when they run. sync:mobile-localization now
removes keys no code references from every catalog.

* fix(mobile): use node: imports in the locale config plugin; pass uiLocale in bridge hook test

* chore(mobile): refresh the localization coverage baseline

* fix(mobile): follow render callbacks in the translate() gate; keep used plural siblings on prune

Also let anonymous default-exported screens count as components, flag any plural
family a locale started, and mark JSON modules as ES modules in the recording loader.

* fix(mobile): treat async and named local callbacks as handlers in the translate() gate

* chore(mobile): refresh the localization coverage baseline

* chore(mobile): refresh the localization coverage baseline

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 17:14:05 -07:00

182 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Web shell entry for the phone’s h/[hostId] route tree, packaged by build:mobile-web.
import { useEffect, type PropsWithChildren } from 'react'
import { createRoot } from 'react-dom/client'
import { ExpoRoot } from 'expo-router'
import {
applyMobileColorScheme,
colors,
getMobileColorScheme,
subscribeMobileColorScheme
} from '../src/theme/mobile-theme'
import type { BridgeRpcClient } from '../src/mobile-web-shell/bridge/bridge-rpc-client'
import {
bootstrapShellPage,
createShellPageClient,
stampPageMountState,
type PageMountTarget
} from '../src/mobile-web-shell/bridge/page-bootstrap'
import { storageReadResultSchema } from '../src/mobile-web-shell/bridge/bridge-native-verbs'
import { publishPageStorage } from '../src/mobile-web-shell/bridge/page-async-storage'
import {
RouteScreenPaintProvider,
createRouteScreenPaintReporter
} from '../src/mobile-web-shell/bridge/page-first-paint'
import { PageFaultBoundary } from '../src/mobile-web-shell/bridge/page-fault-boundary'
import type { BridgeUiLocale } from '../src/mobile-web-shell/bridge/bridge-envelope'
import { activateMobileUiLocale, subscribeMobileUiLanguage } from '../src/i18n/mobile-i18n'
import { getUiIntlLocale } from '../src/i18n/ui-intl-locale'
import { readDeviceUiLocaleTags, resolveUiLocaleFromTags } from '../src/i18n/system-ui-locale'
import { publishPageHostProfile } from '../src/mobile-web-shell/bridge/page-host-profile'
import { publishExternalLinkOpener } from '../src/platform/external-link.web'
import { publishHapticsNotifier } from '../src/platform/haptics.web'
import { startBlurringOnKeyboardClose } from '../src/platform/keyboard-close-blur.web'
import { publishShellKeyboardSource } from '../src/platform/keyboard-occlusion.web'
// Select the page-client provider explicitly; the builder aliases extensionless screen imports to this same web module.
import { RpcClientProvider } from '../src/transport/client-context.web'
// Body replaced at build time: esbuild has no require.context, so the builder synthesizes one.
import routeContext from './route-manifest'
// The h/ tree omits the native root provider, so supply ExpoRoot’s wrapper after init gives us a client.
// Router suspense handles pending chunks; rejected chunks belong to the outer fault boundary.
function createRootProviders(client: BridgeRpcClient, target: PageMountTarget) {
// Wait for the second frame before dropping the shell’s cover; a React commit alone proves no paint.
const reportRouteScreenPaint = createRouteScreenPaintReporter(
{
requestFrame: (callback) => requestAnimationFrame(callback),
cancelFrame: (handle) => {
cancelAnimationFrame(handle)
}
},
() => {
client.notifyPagePainted()
}
)
return function RootProviders({ children }: PropsWithChildren) {
// Child-first effects prove router commit, possibly its suspense fallback; screen paint is reported separately.
useEffect(() => {
stampPageMountState(target, 'mounted')
}, [])
return (
<RpcClientProvider client={client}>
<RouteScreenPaintProvider report={reportRouteScreenPaint}>
{children}
</RouteScreenPaintProvider>
</RpcClientProvider>
)
}
}
// Older shells send no language; follow the WebView's, which tracks the phone's.
function activatePageUiLocale(locale: BridgeUiLocale | null): Promise<void> {
return activateMobileUiLocale(locale ?? resolveUiLocaleFromTags(readDeviceUiLocaleTags()))
}
// Mount after the newest activation, not a superseded one that settled first.
async function settleLatest(latest: () => Promise<void>): Promise<void> {
let current: Promise<void>
do {
current = latest()
await current
} while (current !== latest())
}
// Build outside React because an unresolved route tree cannot mount its own recovery panel.
function renderShellTooOldPanel(container: HTMLElement): void {
const panel = document.createElement('div')
panel.setAttribute('role', 'alert')
panel.style.cssText =
'font:16px/1.5 system-ui,-apple-system,sans-serif;color:#e6e6e6;background:#141414;' +
'min-height:100vh;display:flex;flex-direction:column;align-items:center;' +
'justify-content:center;gap:8px;padding:24px;text-align:center'
const title = document.createElement('div')
title.style.cssText = 'font-weight:600'
title.textContent = 'Update Orca to open this workspace'
const body = document.createElement('div')
body.style.cssText = 'color:#9a9a9a;font-size:14px'
body.textContent = 'This version of the app cannot open the workspace it downloaded.'
panel.append(title, body)
container.replaceChildren(panel)
}
const container = document.getElementById('root')
if (!container) {
throw new Error('[orca-mobile-web-app] #root missing')
}
const target = document.documentElement
// Keep <html> transparent: overlay routes show the native screen through it, and the shell paints bgBase behind the rest.
function paintDocumentScheme(): void {
target.style.setProperty('--muted-foreground', colors.textMuted)
target.style.setProperty('--foreground', colors.textPrimary)
target.style.colorScheme = getMobileColorScheme()
}
paintDocumentScheme()
subscribeMobileColorScheme(paintDocumentScheme)
// The WebView picks CJK glyph variants and hyphenation from <html lang>.
subscribeMobileUiLanguage(() => {
target.lang = getUiIntlLocale()
})
stampPageMountState(target, 'started')
bootstrapShellPage({
target,
client: createShellPageClient(),
replaceUrl: (href) => {
history.replaceState(null, '', href)
},
mount: (client, session) => {
// Apply before the first render so no component paints the default dark palette first.
applyMobileColorScheme(session.colorScheme)
client.onColorSchemeUpdate(applyMobileColorScheme)
// Subscribe before the first catalog load so a switch during startup is not lost; the latest request wins.
let uiLocaleActivation = activatePageUiLocale(session.uiLocale)
client.onUiLocaleUpdate((locale) => {
uiLocaleActivation = activatePageUiLocale(locale)
})
// Publish plain-function seams before first render because effects and initial list paint already read them.
publishPageHostProfile(session.host)
publishExternalLinkOpener((url) => client.notifyExternalLink(url))
publishHapticsNotifier((kind) => client.notifyHaptics(kind))
// Expose shell keyboard height to hooks and plain functions.
publishShellKeyboardSource({
read: () => client.getShellSession()?.keyboardInset ?? 0,
subscribe: client.onKeyboardInsetUpdate
})
startBlurringOnKeyboardClose()
// Without a host in init, leave every storage key unwritable for older shells.
publishPageStorage(
session.storage,
(key, value) => client.notifyStorageWrite(key, value),
session.host?.id ?? '',
session.route?.pathname ?? '',
session.storageOversize,
session.ownsHostArea
? async (key) =>
storageReadResultSchema.parse(
(await client.callNativeVerb('native.storage.read', { key })).result
).value
: null
)
// Activation settles to English on failure, so the page always mounts.
void settleLatest(() => uiLocaleActivation).then(() =>
createRoot(container).render(
// Catch outside ExpoRoot so route-resolution/import failures cannot bypass the page boundary.
<PageFaultBoundary
onFault={(error) => {
client.notifyPageFault(error)
}}
>
<ExpoRoot
context={routeContext}
// Pass the post-init URL explicitly; ExpoRoot’s import-time location predates bridge frames and still names /.
location={new URL(window.location.href)}
wrapper={createRootProviders(client, target)}
/>
</PageFaultBoundary>
)
)
},
refuseUnroutedShell: () => {
renderShellTooOldPanel(container)
}
})