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:
2026-08-23 14:42:11 +00:00
parent c7b767aa04
commit 9d0c6eba31
25 changed files with 291 additions and 31 deletions
+5
View File
@@ -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;
+2
View File
@@ -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';
+31
View File
@@ -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;