Skip to content

feat: add optional configurable JSX placeholder naming#2505

Merged
andrii-bodnar merged 7 commits intolingui:nextfrom
mogelbrod:jsx-placeholder-naming
Apr 17, 2026
Merged

feat: add optional configurable JSX placeholder naming#2505
andrii-bodnar merged 7 commits intolingui:nextfrom
mogelbrod:jsx-placeholder-naming

Conversation

@mogelbrod
Copy link
Copy Markdown
Contributor

@mogelbrod mogelbrod commented Apr 9, 2026

Description

Add a new feature that allows developers to customize the identifiers of JSX placeholders generated by the <Trans> macro. This results in placeholder identifiers in extracted messages that are more stable, human-readable, and contextual, instead of the default arbitrary numeric indices (e.g., replacing <0> with <em> or <link>).

It is accomplished via two new optional configuration options (via lingui.config.js in the macro options):

  1. jsxPlaceholderAttribute: Allows defining a custom prop name (e.g., _t) that can be used on components to set an explicit placeholder name locally.
  2. jsxPlaceholderDefaults: Allows providing a record of tag names to placeholder names, enabling global default renaming for specific JSX components.

SWC implementation at lingui/swc-plugin#207

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Examples update

Fixes #1188

Checklist

  • I have read the CONTRIBUTING and CODE_OF_CONDUCT docs
  • I have added tests that prove my fix is effective or that my feature works
  • I have added the necessary documentation (if appropriate) - will do before merging

Usage

lingui.config.js

import { defineConfig } from '@lingui/cli'

export default defineConfig({
  sourceLocale: 'en',
  locales: ['en', 'sv'],
  macro: {
    jsxPlaceholderAttribute: '_t',
    jsxPlaceholderDefaults: {
      a: 'a',
      b: 'b',
      br: 'br',
    },
  },
})

globals.d.ts or similarly imported definitions file

import 'react'

declare module 'react' {
  interface Attributes {
    _t?: string
  }
}

source-file.tsx

import { Trans } from '@lingui/react/macro'

function App() {
  return <Trans>Translated <a href="/">link</a> with some <div _t="div">additional</div> markup.</Trans>
}

Disclaimer

Much of this PR was generated by Copilot using the Gemini 3.1 Pro (Preview) model with training/sharing disabled.

Initial prompt used

Plan: Implement JSX named placeholders

Add opt-in support for named JSX placeholders through Lingui configuration (jsxPlaceholderAttribute and jsxPlaceholderDefaults), replacing numeric indices in extracted messages for better maintainability and readability. Include smart element deduplication that reuses element names if their props are identical, otherwise with direct suffix appended for duplicated names.

Steps

  1. Extend LinguiConfig: Update type definition in types.ts to include jsxPlaceholderAttribute?: string and jsxPlaceholderDefaults?: Record<string, string> in the macro object.
  2. Update Validation Configuration: Add these new properties to exampleConfig in makeConfig.ts so jest-validate will accept them. Let defaultConfig omit them (or set them to undefined / empty object).
  3. Pass Config to Macro: In index.ts, extract the new options from the linguiConfig macro passing them to the MacroJSX instantiation inside MacroJsxOpts.
  4. Update MacroJsxOpts Interface: Expand MacroJsxOpts in macroJsx.ts to accept jsxPlaceholderAttribute and jsxPlaceholderDefaults.
  5. Add State for Placeholder Names: Introduce a tracked collection in macroJsx.ts (scoped to MacroJsxContext per message evaluation) to track used placeholder names and their original JSX node props.
  6. Modify tokenizeElement: In macroJsx.ts, parse out the requested name:
    • Check the JSX node's attributes for an exact match to jsxPlaceholderAttribute.
    • If found, use its string literal value as the requested placeholder name, and remove the attribute from the node's AST.
    • If not found, check jsxPlaceholderDefaults using the literal tag name.
    • If neither are present, fallback to the current behavior this.ctx.elementIndex().
    • Apply deduplication: Check if the base name is already used.
      • Look up previously stored element by that name.
      • Reuse rule: Evaluate whether the previously stored node and the current node have identical props (either both have no additional props, or their existing props are structurally equal). If equal, reuse the exact same key without incrementing.
      • Collision rule: If the props differ (e.g. they both have different href props), generate a new key by directly appending an incremental numeric suffix to the original name (starting at 2, e.g. a -> a2, a3), avoiding an underscore separator. Store it and assign the newly suffixed name to the current element.
  7. Create Tests: Add a test fixture testing scenarios with _t attribute, tag-name defaults, deep nesting, prop deduplication (including equal props and differing props), and direct suffix logic.

Relevant files

  • packages/conf/src/types.ts — Add properties to LinguiConfig.macro type.
  • packages/conf/src/makeConfig.ts — Update exampleConfig for validator compatibility.
  • packages/babel-plugin-lingui-macro/src/index.ts — Route configuration from macroContext to MacroJSX.
  • packages/babel-plugin-lingui-macro/src/macroJsx.ts — Implement name lookup, AST attribute pruning, and the prop equality / direct-suffix (x2) element duplication rules.

Verification

  1. Validate pnpm test in babel-plugin-lingui-macro.
  2. Compare emitted ICU format visually to directly match the test cases:
    • Identical elements (same props or exactly no props) reuse the same name.
    • Distinct elements with same tag/name emit <a>...</a> and <a2>...</a2>.

Decisions

  • The configuration attribute is explicitly stripped from the extracted component output to avoid unused React props and potential runtime warnings.
  • Deduplication relies on checking equality of props (or both having zero props) to map them to the same component index.
  • Duplicate names follow direct appending mapping e.g., x, x2, x3.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 9, 2026

@mogelbrod is attempting to deploy a commit to the Crowdin Team on Vercel.

A member of the Team first needs to authorize it.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
js-lingui Ready Ready Preview Apr 16, 2026 1:49pm

Request Review

@timofei-iatsenko
Copy link
Copy Markdown
Collaborator

@yslpn @Bertg @andrii-bodnar hey, could you share your opinion on the feature design?

I think the feature is cool, but i want to make sure that we don't forget anything and the initial design is good to go.

@timofei-iatsenko
Copy link
Copy Markdown
Collaborator

@mogelbrod these new options should also be added to the types/validation in the lingui config. Please also add these new options with a descriptive jsdoc inline comments, explaining how they works and what affects.

@mogelbrod
Copy link
Copy Markdown
Contributor Author

@mogelbrod these new options should also be added to the types/validation in the lingui config. Please also add these new options with a descriptive jsdoc inline comments, explaining how they works and what affects.

Thanks for the reminder! This should be done in the most recent push 👍

Copy link
Copy Markdown
Contributor

@andrii-bodnar andrii-bodnar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mogelbrod thanks for the contribution!

It looks good to me. I can't think of any risks or cases that were overlooked. I have left one minor comment.

@timofei-iatsenko
Copy link
Copy Markdown
Collaborator

@mogelbrod please don't rebase and force push, i'm going to jump on your branch and help with failing snapshots.

@yslpn
Copy link
Copy Markdown
Contributor

yslpn commented Apr 16, 2026

@yslpn @Bertg @andrii-bodnar hey, could you share your opinion on the feature design?

Regarding the feature design, in my personal opinion, it would be better to start with a simple implementation, where the names are taken from the JSX tag name: <a href="/">link</a> => <a>link<a>, <button type="button">Click Me!</button> => <button>Click Me!</button>, and so on. Only then, in the future, add customization via lingui.config.js and a custom _t attribute.

This will be easier to maintain and more safe.

UPD: I've found several very critical issues in the current implementation that are very difficult to detect. Therefore, I would recommend starting with a simpler version. I'm afraid there are still some critical issues I simply haven't found.


if (attrIndex !== -1) {
const attr = attributes[attrIndex] as JSXAttribute
if (attr.value && attr.value.type === "StringLiteral") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a custom placeholder attribute is present but its value is not a non-empty string literal, such as

_t={linkName} or _t="",

this code still removes the attribute from the JSX and then silently falls back to the auto-generated numeric placeholder.

Throw an error?

Copy link
Copy Markdown
Contributor Author

@mogelbrod mogelbrod Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Should be addressed in a18eeea
Please resolve this thread if you think the solution covers the issue @yslpn 😃

Comment thread packages/babel-plugin-lingui-macro/src/macroJsx.ts Outdated
const existingElement = elementsTracking.get(name)

if (existingElement) {
const existingAttrs = existingElement.openingElement.attributes
Copy link
Copy Markdown
Contributor

@yslpn yslpn Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Both placeholders will be rendered as either <em /> or <strong /> depending on the traversal order.
<Trans>
  <em _t="same">A</em> and <strong _t="same">B</strong>
</Trans>
  1. The order of attributes isn't compared. For regular props, this often doesn't matter, but for spread, it's important:
const props = { href: "/b" }

<Trans>
  <a _t="same" {...props} href="/a">A</a>
  <a _t="same" href="/a" {...props}>B</a>
</Trans>

That is, the React semantics are different. But the current check sees "there is a spread and there is an href" in both cases and considers the elements identical. After this, one of them quietly wins, and both placeholders begin to render identically, even though the original JSX was different.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Should be addressed in 24d1533

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. However i think this is quite extreme edge case

@mogelbrod
Copy link
Copy Markdown
Contributor Author

mogelbrod commented Apr 16, 2026

Regarding the feature design, in my personal opinion, it would be better to start with a simple implementation, where the names are taken from the JSX tag name: <a href="/">link</a> => <a>link<a>, <button type="button">Click Me!</button> => <button>Click Me!</button>, and so on. Only then, in the future, add customization via lingui.config.js and a custom _t attribute.

This will be easier to maintain and more safe.

Thanks for the feedback! Limiting the functionality to only use the component name as placeholder name with no way of overriding it introduces a number of issues. Most notably, it would no longer be possible to use two or more of the same JSX element in a single <Trans> element due to naming collisions. Enabling the setting would thus not be possible in code bases where this happens. If a developer needs to add an already occurring JSX element to an existing <Trans> block they'd have to turn the option off.

Replacing one JSX element with another (but otherwise not touching the copy) would also require a re-translation due to the name changing. Eg. <a href="/">copy</a> -> <Link href="/">copy</Link> results in different placeholders, even though the placeholder itself hasn't fundamentally changed.

Offering both options seems necessary to ensure maintainability and giving developers a tool to work around issues that could otherwise arise.

Appreciate the comments @yslpn! I'll get right on addressing the issues you raised.

@yslpn
Copy link
Copy Markdown
Contributor

yslpn commented Apr 16, 2026

Most notably, it would no longer be possible to use two or more of the same JSX element in a single <Trans> element due to naming collisions.

Why? We can add indexes just like we did before for <0><0>, <1><1>, and so on. Just add indexes to the tag names.

<button1><button1>
<button2><button2>

@mogelbrod
Copy link
Copy Markdown
Contributor Author

Why? We can add indexes just like we did before for <0><0>, <1><1>, and so on. Just add indexes to the tag names.

<button1><button1>
<button2><button2>

Ah gotcha - that more closely mirrored the original behaviour of this PR. But it also suffers from the same problem as the current placeholders (<0>, <1>, …) where re-arranging elements in the source string causes their names to change, which may break translations.

@timofei-iatsenko
Copy link
Copy Markdown
Collaborator

Regarding the feature design, in my personal opinion, it would be better to start with a simple implementation, where the names are taken from the JSX tag name: <a href="/">link</a> => <a>link<a>, <button type="button">Click Me!</button> => <button>Click Me!</button>, and so on. Only then, in the future, add customization via lingui.config.js and a custom _t attribute.

This will be easier to maintain and more safe.

UPD: I've found several very critical issues in the current implementation that are very difficult to detect. Therefore, I would recommend starting with a simpler version. I'm afraid there are still some critical issues I simply haven't found.

Taking the name from a just a JSX element tag name has a little value in my opinion. From my experience usually only simple elements inside of the translation strings are used, mostly strong or link, br etc. Taking the name of JSX element into a translation we pushing an internal implementation details to a translation.

In most of the projects i worked on, links were implemented in a few different way such as <Link> (as custom element), as <a> html element, as <button> and so on. So inferring the name from the jsx tag will open a pandora's box of DX issues which we will throw to our users.

The proposed design allows to set a semantic, stable name for the placeholder which will not depend on the internal implementation. You can swap <a> to <Link> without any issue.

So basically users would be able to define a default set such as strong br a. And then provide an explicit name in every usage place.

UPD: I've found several very critical issues in the current implementation that are very difficult to detect. Therefore, I would recommend starting with a simpler version. I'm afraid there are still some critical issues I simply haven't found.

Appreciate for the digging deeper in this PR code. I think the initial design is OK for me. We can mark this feature as experimental, users don't have to use right away, but those who will start could provide feedback and we can improve all the cases in the future.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 16, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.35%. Comparing base (dd43fb0) to head (c52e5c7).
⚠️ Report is 337 commits behind head on next.

Additional details and impacted files
@@             Coverage Diff             @@
##             next    #2505       +/-   ##
===========================================
+ Coverage   76.66%   89.35%   +12.68%     
===========================================
  Files          81      118       +37     
  Lines        2083     3373     +1290     
  Branches      532     1007      +475     
===========================================
+ Hits         1597     3014     +1417     
+ Misses        375      324       -51     
+ Partials      111       35       -76     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread packages/babel-plugin-lingui-macro/src/macroJsx.ts Outdated
@Bertg
Copy link
Copy Markdown
Contributor

Bertg commented Apr 16, 2026

Man, I love this change. Having translations with sometimes more than 5 tags are a PITA.

Some notes:

  • I like the _t id, but I forse problems with that. Eg when devs/ai start copy pasting that outside of lingui macros. I would prefer something like data-lingui-name which would not break React rules.
  • when we have tags without attributes (eg strong or br) then repeating the name in the translation string should be fine. When there are attributes I would use a numeric suffix always.
  • I prefer <a 1>...</a> over <a1>...</a1>. If that is invalid I would even go for <a id=1>...</a> or <a name=1>...</a>
  • with setting a custom name (see above) I would go with <a id=theCustomNameHere>...</a>. (Or name instead of id).
  • if we really want to do custom tags, we could still go with data-lingui-tag

(Sorry this is a bit messy, but I'm writing this from my phone and don't feel like reformatting)

@mogelbrod
Copy link
Copy Markdown
Contributor Author

Man, I love this change. Having translations with sometimes more than 5 tags are a PITA.

Glad to hear it!

  • I like the _t id, but I forse problems with that. Eg when devs/ai start copy pasting that outside of lingui macros. I would prefer something like data-lingui-name which would not break React rules.
  • ...
  • with setting a custom name (see above) I would go with <a id=theCustomNameHere>...</a>. (Or name instead of id).
  • if we really want to do custom tags, we could still go with data-lingui-tag

The attribute name is completely configurable (and disabled by default), with _t just being a suggestion in the docs. I guess this could be covered in a repo's AGENTS.md or the like, which would be on-par with how other frameworks handle it.

  • when we have tags without attributes (eg strong or br) then repeating the name in the translation string should be fine. When there are attributes I would use a numeric suffix always.

Same tags with none/identical attributes reuse the same placeholder name and JSX element in the current implementation. I previously appended a incremented suffix for non-unique elements, but as @timofei-iatsenko pointed out here it's probably better to throw since those cases are likely unintentional and would be better off explicitly named.

  • I prefer <a 1>...</a> over <a1>...</a1>. If that is invalid I would even go for <a id=1>...</a> or <a name=1>...</a>

Are you referring to the placeholders in the translation string? My guess is most TMSes don't accept spaces in element names as it's typically a delimeter between tag/attributes, but I haven't double checked. IMO it's better to stick with syntax that's as similar to HTML/JSX as possible for maximum compatibility and understandability.

@timofei-iatsenko
Copy link
Copy Markdown
Collaborator

For me the current design looks good. I propose to merge it as-is and start gathering feedback. I think at the current stage without actually trying to work with it in real projects it's hard to anticipate what works good and what needs polishing.

Copy link
Copy Markdown
Collaborator

@timofei-iatsenko timofei-iatsenko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code itself could be structured a little bit better, but it good for now as-is. It LGTM for me.

Let's focus on merging the base of this feature and blocking other PRs - the rest of polishing could be provided as separate PRs

@mogelbrod
Copy link
Copy Markdown
Contributor Author

mogelbrod commented Apr 17, 2026

Great to hear! Happy to clean up and/or refactor in this PR if I get some pointers, as well as following up on any issues that are reported post merge 👍

@mogelbrod
Copy link
Copy Markdown
Contributor Author

mogelbrod commented Apr 17, 2026

@timofei-iatsenko do you think it's time to update lingui/swc-plugin#207 to mirror this implementation and rebase #2514 against this branch now?

@timofei-iatsenko
Copy link
Copy Markdown
Collaborator

@mogelbrod no, wait while this one would be merged to next, and then update #2514 over the main. You don't need to use rebase and force push (and i even encourage you to not doing this) since we will squash and merge the PR anyway.

do you think it's time to update lingui/swc-plugin#207

I think so

@andrii-bodnar
Copy link
Copy Markdown
Contributor

andrii-bodnar commented Apr 17, 2026

@mogelbrod @timofei-iatsenko let's keep this feature in sync with the SWC implementation. I would like to merge them both and then release the current scope as v6.0.0-next.4 and v6.0.0-next.3 (SWC plugin)

I hope this will be the last one before the stable v6 release.

@mogelbrod
Copy link
Copy Markdown
Contributor Author

Great! Working on aligning the SWC plugin as we speak 👍

@andrii-bodnar andrii-bodnar merged commit 8a36496 into lingui:next Apr 17, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants