--- name: sanity-i18n-translate description: Use the i18n component correctly, especially its components map and componentProps. Use when rendering locale strings that contain markup or embedded components, when adding or reviewing usages, or when the @repo/i18n/no-inline-translate-components lint rule fires. --- # Sanity i18n Translate ## Start Here `` (from `packages/sanity/src/core/i18n/Translate.tsx`) renders locale resources that contain markup, eg `'Search for "{{keyword}}"'`. 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 — `` is more expensive to render. ## Law: never define `components` inline Components 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 `` 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. ```tsx // ❌ Wrong - new component identity every render (and fails the lint rule) ; {children}}} /> // ❌ Wrong - useMemo does not fix the identity problem across dependency changes const components = useMemo(() => ({Badge: ({children}) => {children}}), []) ``` ## How to fix each shape **Plain HTML wrapper** — map to the intrinsic tag name as a string. Strings never receive `componentProps`, so no stray DOM attributes: ```tsx ``` **Static markup** (fixed link, styled wrapper) — hoist a module-scope component: ```tsx function DocsLink({children}: {children?: ReactNode}) { return {children} } ; ``` **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: ```tsx function VersionBadge({children, tone}: {children?: ReactNode; tone?: BadgeTone}) { return {children} } ; ``` ## Gotchas - `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. - Components receive `children` only for wrapping tags (`...`); self-closing tags (``) render the component without children. Declare `children` optional. - Unmapped lowercase tags only render when listed in `RECOGNIZED_HTML_TAGS` in `Translate.tsx`; anything else falls back to interpolated plain text with a console warning. - Do not call component factories (eg `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. ## Reference implementations - `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.