.agents/skills/sanity-i18n-translate/SKILL.md
<Translate> (from packages/sanity/src/core/i18n/Translate.tsx) renders locale resources that
contain markup, eg 'Search for "<Red>{{keyword}}</Red>"'. The components prop maps tag names
in the locale string to React components or intrinsic HTML tag names.
Prefer the plain t() function when the message has no markup — <Translate> is more expensive
to render.
components inlineComponents in the components map MUST be stable, module-scope components. Never define them
inline during render — each render then creates a new component identity, so React unmounts and
remounts the subtree (losing state, DOM, and focus). This is the same class of bug as
react/no-unstable-nested-components, and it is enforced for <Translate> by the in-repo oxlint
rule @repo/i18n/no-inline-translate-components (implemented in the
@repo/oxlint-plugin-i18n workspace package, wired via jsPlugins in .oxlintrc.json).
The rule only sees object literals written directly in the JSX attribute. Maps built during
render some other way (useMemo, useCallback, factory calls) are just as wrong — hoist those
too, even though the rule cannot flag them.
// ❌ Wrong - new component identity every render (and fails the lint rule)
;<Translate t={t} i18nKey="key" components={{Badge: ({children}) => <strong>{children}</strong>}} />
// ❌ Wrong - useMemo does not fix the identity problem across dependency changes
const components = useMemo(() => ({Badge: ({children}) => <b>{children}</b>}), [])
Plain HTML wrapper — map to the intrinsic tag name as a string. Strings never receive
componentProps, so no stray DOM attributes:
<Translate t={t} i18nKey="key" components={{Code: 'code', Emphasis: 'em'}} />
Static markup (fixed link, styled wrapper) — hoist a module-scope component:
function DocsLink({children}: {children?: ReactNode}) {
return <a href="https://www.sanity.io/docs">{children}</a>
}
;<Translate t={t} i18nKey="key" components={{DocsLink}} />
Component that needs data from render — hoist it and pass the data through componentProps.
The object is forwarded to every non-string component in the map (including exotic ones like
memo components), so declare only the props each component reads:
function VersionBadge({children, tone}: {children?: ReactNode; tone?: BadgeTone}) {
return <VersionInlineBadge $tone={tone}>{children}</VersionInlineBadge>
}
;<Translate
t={t}
i18nKey="key"
components={{VersionBadge}}
componentProps={{tone: getReleaseTone(release)}}
/>
componentProps drives the generic: literal values widen ({tone: 'caution'} infers
{tone: string}). Use as const on literals that must stay narrow:
componentProps={{tone: 'caution' as const}}.TComponentProps is constrained to object - primitives and null are compile errors.children only for wrapping tags (<X>...</X>); self-closing tags
(<X/>) render the component without children. Declare children optional.RECOGNIZED_HTML_TAGS in Translate.tsx;
anything else falls back to interpolated plain text with a console warning.getVersionInlineBadge) inside the hoisted component
either - that recreates the render-time identity problem one level down, and the React
Compiler lint flags it. Render the underlying component with a prop instead.packages/sanity/src/core/releases/tool/detail/ReleaseActivityListItem.tsx - componentProps
passing an event object.packages/@sanity/vision/src/components/VisionGuiResult.tsx - componentProps passing data to a
self-closing component pair.packages/sanity/src/core/studio/components/navbar/search/components/common/FilterLabel.tsx -
several components sharing one componentProps object.packages/sanity/src/core/i18n/__tests__/Translate.test.tsx - behavior coverage, including
memo components and non-forwarding to intrinsic tags.