panel names take a late-bound thunk (494 -> 469)
AppRegistryEntry.name is now `string | (() => string)` and the framework calls it if it is a function. Owner-approved, with both of their conditions met. Why a thunk rather than a key: `workspaces` has no dependencies at all, deliberately not even i18n, so resolving a catalogue key there would invert the graph -- officerdev depends on that package, not the reverse. The caller binds the words; the framework only asks for them. `string` stays valid, so the 27 names migrate one at a time and a name that never needs translating keeps a plain string. Minesweeper, SendOff, Tmux, Neovim, Claude Code and bTop are exactly that case: product names, and a key whose two sides can never legitimately differ is a maintenance cost bought for nothing. Resolved at every render via resolveEntryName, never cached onto the entry, with the reason stated at both call sites -- caching would pin the name to whatever locale the registry was seeded with, and it is seeded once before any locale has loaded. There is a test that re-renders under a different provider locale and asserts the name changes; it is the test that fails if anybody memoises this later. The thunk reads i18n/standalone, which mirrors the provider for callers with no hook in scope. Deliberately not on the package barrel and documented as not-a-general-escape- hatch, because it is not reactive: it is only correct where the caller re-renders for another reason.
This commit is contained in:
@@ -2,6 +2,7 @@ import { createContext, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { I18nContextValue, InterpolationParams, TranslationMap } from './types';
|
||||
import { interpolate, resolveKey } from './resolve';
|
||||
import { setActiveI18n } from './standalone';
|
||||
|
||||
export const I18nContext = createContext<I18nContextValue>({
|
||||
t: (key: string) => key,
|
||||
@@ -17,6 +18,10 @@ type I18nProviderProps = {
|
||||
};
|
||||
|
||||
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||
// Mirrored for the one caller that cannot use a hook — see standalone.ts. Set during render rather
|
||||
// than in an effect so it is never a render behind what this provider is handing out.
|
||||
setActiveI18n({ translations, locale, fallbackLocale });
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const t = (key: string, params?: InterpolationParams) => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
|
||||
@@ -3,5 +3,7 @@ export { useTranslation } from './useTranslation';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach.
|
||||
export { activeLocale } from './standalone';
|
||||
export type { MissingKey } from './parity';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InterpolationParams, TranslationMap } from './types';
|
||||
import { interpolate, resolveKey } from './resolve';
|
||||
|
||||
/**
|
||||
* The active locale, readable OUTSIDE a React render.
|
||||
*
|
||||
* There is exactly one caller and it is not a shortcut: a panel's name in the app registry is
|
||||
* module-level data, and the framework invokes its thunk from a render where no hook of ours is
|
||||
* reachable — `workspaces` has no dependencies at all, deliberately, not even this package.
|
||||
*
|
||||
* NOT a general escape hatch. Anything inside a component uses `useTranslation`, which is reactive.
|
||||
* This is not reactive: writing here re-renders nothing. It is only correct where the caller is
|
||||
* already re-rendering for another reason, which is why `translateStandalone` is deliberately awkward
|
||||
* to reach and documented rather than exported from the package barrel.
|
||||
*/
|
||||
type ActiveI18n = { translations: TranslationMap; locale: string; fallbackLocale: string };
|
||||
|
||||
let active: ActiveI18n = { translations: {}, locale: 'en', fallbackLocale: 'en' };
|
||||
|
||||
/** Called by `I18nProvider` on every render, so this never lags the provider it mirrors. */
|
||||
export const setActiveI18n = (next: ActiveI18n): void => {
|
||||
active = next;
|
||||
};
|
||||
|
||||
export const translateStandalone = (key: string, params?: InterpolationParams): string => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
return interpolate(resolveKey(active.translations, active.locale, key, active.fallbackLocale, count), params);
|
||||
};
|
||||
|
||||
/** The locale the provider currently holds. Exposed so a caller can key a render on it. */
|
||||
export const activeLocale = (): string => active.locale;
|
||||
Reference in New Issue
Block a user