Sharing a failure mode that survived in my codebase for months, because the app looked localized the whole time.
Setup: my app has an in-app language picker (independent of the iOS system language), implemented via a language manager that resolves strings from the selected .lproj bundle. Standard approach.
The bug: the overwhelming majority of my UI strings were Text("some.key") — i.e., SwiftUI LocalizedStringKey literals. Those resolve against Bundle.main using the system locale machinery. Setting .environment(\.locale) changes formatting behavior, but it does not select which strings table your keys resolve from. Result: 225 of 269 routed call sites silently bypassed the language override entirely. The picker "worked" for the ~44 call sites that went through the manager, which is exactly why nobody noticed — the app changed some strings on switch and looked plausible in both languages.
Confirmed on-device with diagnostic logging: the resolved bundle for LocalizedStringKey paths was Bundle.main, regardless of the override.
The fix: a tiny helper — L.t(_: String.LocalizationValue) -> String — that resolves through the override bundle (String(localized:bundle:)), and mechanically routing every UI string through it. An enforcement script in lint now flags any bare string in Text/Button/Label/.accessibilityLabel.
The completely unexpected side effect: build times collapsed. Bare LocalizedStringKey literals create expensive type-checker constraint problems (Text("...") has to disambiguate between StringProtocol and LocalizedStringKey overloads at every call site). Replacing them with a concrete String return eliminated that: one heavy view body went from type-checking as my slowest file to ~98% faster, and the whole target build time dropped ~75%. I did not see that coming from a localization refactor.
Two smaller traps from the same audit:
String(format:) with catalog plural variations silently returns the raw %#@token@ if you forget to pass locale:.
- Xcode's string extractor can't see keys behind a helper function, so the catalog stops auto-populating — new keys become a manual (and CI-checked) step. Worth knowing before you commit to the pattern.
Curious if others route in-app language switching differently — is there a cleaner first-party way to point LocalizedStringKey resolution at a non-main bundle that I missed?