perf(ui): defer locale and overlay bundles (#238)

## Summary
- defer locale and overlay loading work away from the first critical
render path
- seed locale state from the bootstrap preload so the first render can
use the preloaded language immediately
- keep bootstrap cache and locale fallback behavior consistent on
subsequent launches

## Testing
- npm run build --workspace @codenomad/ui
This commit is contained in:
Pascal André
2026-03-23 16:12:28 +01:00
committed by GitHub
parent 8567d49178
commit 3bad0afd7d
2 changed files with 104 additions and 30 deletions

View File

@@ -2,11 +2,6 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou
import type { ParentComponent } from "solid-js" import type { ParentComponent } from "solid-js"
import { useConfig } from "../../stores/preferences" import { useConfig } from "../../stores/preferences"
import { enMessages } from "./messages/en" import { enMessages } from "./messages/en"
import { esMessages } from "./messages/es"
import { frMessages } from "./messages/fr"
import { ruMessages } from "./messages/ru"
import { jaMessages } from "./messages/ja"
import { zhHansMessages } from "./messages/zh-Hans"
type Messages = Record<string, string> type Messages = Record<string, string>
@@ -15,14 +10,18 @@ export type TranslateParams = Record<string, unknown>
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
const messagesByLocale: Record<Locale, Messages> = { const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
en: enMessages, const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
es: esMessages,
fr: frMessages, const localeLoaders: Record<Locale, () => Promise<Messages>> = {
ru: ruMessages, en: async () => enMessages,
ja: jaMessages, es: async () => (await import("./messages/es")).esMessages,
"zh-Hans": zhHansMessages, fr: async () => (await import("./messages/fr")).frMessages,
ru: async () => (await import("./messages/ru")).ruMessages,
ja: async () => (await import("./messages/ja")).jaMessages,
"zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages,
} }
function normalizeLocaleTag(value: string): string { function normalizeLocaleTag(value: string): string {
@@ -34,8 +33,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
const normalized = normalizeLocaleTag(value) const normalized = normalizeLocaleTag(value)
const lower = normalized.toLowerCase() const lower = normalized.toLowerCase()
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])) const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
const exact = supportedLower.get(lower)
if (exact) return exact if (exact) return exact
const parts = lower.split("-") const parts = lower.split("-")
@@ -43,11 +41,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
if (!base) return null if (!base) return null
if (base === "zh") { if (base === "zh") {
const zhHans = supportedLower.get("zh-hans") const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
return zhHans ?? null return zhHans ?? null
} }
const baseMatch = supportedLower.get(base) const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
return baseMatch ?? null return baseMatch ?? null
} }
@@ -84,8 +82,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
} }
const [globalRevision, setGlobalRevision] = createSignal(0) const [globalRevision, setGlobalRevision] = createSignal(0)
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en" let globalMessages: Messages = enMessages
let globalMessages: Messages = messagesByLocale[initialGlobalLocale] let globalLocale: Locale = "en"
function getMessagesForLocale(locale: Locale): Messages {
return localeMessagesCache.get(locale) ?? enMessages
}
async function loadLocaleMessages(locale: Locale): Promise<Messages> {
const cached = localeMessagesCache.get(locale)
if (cached) {
return cached
}
const pending = localeMessagesPromises.get(locale)
if (pending) {
return pending
}
const loader = localeLoaders[locale]
const promise = loader()
.then((messages) => {
localeMessagesCache.set(locale, messages)
localeMessagesPromises.delete(locale)
return messages
})
.catch((error) => {
localeMessagesPromises.delete(locale)
throw error
})
localeMessagesPromises.set(locale, promise)
return promise
}
export async function preloadLocaleMessages(preferredLocale?: string | null): Promise<Locale> {
const resolvedLocale = matchSupportedLocale(preferredLocale ?? undefined) ?? detectNavigatorLocale() ?? "en"
try {
globalMessages = await loadLocaleMessages(resolvedLocale)
globalLocale = resolvedLocale
setGlobalRevision((value) => value + 1)
return resolvedLocale
} catch {
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
return "en"
}
}
export function tGlobal(key: string, params?: TranslateParams): string { export function tGlobal(key: string, params?: TranslateParams): string {
globalRevision() globalRevision()
@@ -101,9 +145,10 @@ const I18nContext = createContext<I18nContextValue>()
export const I18nProvider: ParentComponent = (props) => { export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig() const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en") const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
const previousMessages = globalMessages const previousGlobalMessages = globalMessages
const previousGlobalLocale = globalLocale
onMount(() => { onMount(() => {
const detected = detectNavigatorLocale() const detected = detectNavigatorLocale()
@@ -115,19 +160,44 @@ export const I18nProvider: ParentComponent = (props) => {
return configured ?? detectedLocale() ?? "en" return configured ?? detectedLocale() ?? "en"
}) })
const messages = createMemo<Messages>(() => messagesByLocale[locale()]) const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
function t(key: string, params?: TranslateParams): string { function t(key: string, params?: TranslateParams): string {
return translateFrom(messages(), key, params) return translateFrom(messages(), key, params)
} }
createEffect(() => { createEffect(() => {
globalMessages = messages() const nextLocale = locale()
setGlobalRevision((value) => value + 1) let cancelled = false
void loadLocaleMessages(nextLocale)
.then((loadedMessages) => {
if (cancelled) {
return
}
setResolvedLocale(nextLocale)
globalLocale = nextLocale
globalMessages = loadedMessages
setGlobalRevision((value) => value + 1)
})
.catch(() => {
if (cancelled) {
return
}
setResolvedLocale("en")
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
})
onCleanup(() => {
cancelled = true
})
}) })
onCleanup(() => { onCleanup(() => {
globalMessages = previousMessages globalMessages = previousGlobalMessages
globalLocale = previousGlobalLocale
setGlobalRevision((value) => value + 1) setGlobalRevision((value) => value + 1)
}) })

View File

@@ -4,7 +4,7 @@ import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences" import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config" import { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { I18nProvider } from "./lib/i18n" import { I18nProvider, preloadLocaleMessages } from "./lib/i18n"
import { storage } from "./lib/storage" import { storage } from "./lib/storage"
import "./index.css" import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css" import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -31,15 +31,19 @@ async function bootstrap() {
try { try {
const uiConfig = await storage.loadConfigOwner("ui") const uiConfig = await storage.loadConfigOwner("ui")
const theme = (uiConfig as any)?.theme ?? "system" const theme = (uiConfig as any)?.theme
const locale = typeof (uiConfig as any)?.settings?.locale === "string" ? (uiConfig as any).settings.locale : undefined
if (theme === "system") { if (theme === "light" || theme === "dark") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme) document.documentElement.setAttribute("data-theme", theme)
} else {
document.documentElement.removeAttribute("data-theme")
} }
await preloadLocaleMessages(locale)
} catch { } catch {
// If config fails to load, fall back to CSS defaults. // If config fails to load, fall back to CSS defaults.
await preloadLocaleMessages()
} }
} }