feat: adds new "selector" API#2322
Conversation
refactor: simplifies namespace override implementation
fix: typelevel bug with returnObjects
This comment was marked as outdated.
This comment was marked as outdated.
by default, yes |
|
Thank you so much for working on this! We're current still on One question about typing. In some cases we've got a code similar to this defined outside of the component (as it's completely static): const tabs = [
{
label: 'labels.a',
tooltip: 'tooltips.a',
route: '/a',
},
{
label: 'labels.b',
tooltip: 'tooltips.b',
route: '/b',
}
] as constand then use this inside a component, e.g. const { t: tCommon } = useTranslation('common')
return <Tooltip title={tCommon(tabs[0].tooltip}>
<Tab label={tCommon(tabs[0].label)} />
</Tooltip>How would that work with this new selector API? |
|
@szimek good question. I ran into this in a few places when applying the codemod to the freeCodeCamp repo. I think the answer will depend on your use case. This is one of the few cases that the codemod can't automate. You have a few options: 1. See if you can remove the intermediate data structure altogetherfunction Component({ tab }: { tab: 'a' | 'b' }) {
const { t } = useTranslation('common')
return <Tooltip title={t($ => $.tooltips[tab]}>
{/* 𐙘_____________𐙘 */}
<Tab label={t($ => $.labels[tab])} />
</Tooltip>
}Pros: Code is more direct/readable. You also get to delete code, which decreases entropy overall. Cons: The approach requires you to restructure things a bit, which takes time and makes the migration a bit riskier. 2. Write a helper that turns the path into an accessorconst pathToAccessor = (path: string) => ($: unknown) => path
.split('.') // ← Note: if you use a non-standard key separator, split on that instead
.reduce((acc, cur) => (acc as { [x: string]: unknown })[cur], $) as string
const tabs = [
{ label: 'labels.a', tooltip: 'tooltips.a' },
{ label: 'labels.b', tooltip: 'tooltips.b' }
] as const
function Component() {
const { t } = useTranslation('common')
return <Tooltip title={t(pathToAccessor(tabs[0].tooltip)}>
{/* 𐙘_____________________________𐙘 */}
<Tab label={t(pathToAccessor(tabs[0].label))} />
</Tooltip>
}Pros: Fast, low-risk. Cons: It adds a layer of abstraction, and might confuse other devs if they don't understand the purpose. 3. Cast the translation key as
|
|
@szimek one other approach you could use, that might make more sense for your use case specifically: const tabs = [
{
label: 'a',
// 𐙘_𐙘 used to be: 'labels.a'
tooltip: 'a',
// 𐙘_𐙘 used to be: 'tooltips.a'
route: '/a',
},
{
label: 'b',
tooltip: 'b',
route: '/b',
}
] as const
function Component() {
const { t } = useTranslation('common')
return <Tooltip title={t($ => $.tooltips[tabs[0].tooltip]}>
{/* 𐙘_________________________𐙘 */}
<Tab label={t($ => $.labels[tabs[0].label])} />
</Tooltip>
} |
|
@ahrjarrett Thank you so much! |
|
Nice 👍 |
|
From your comment,
If the idea is to go straight to a major release, Personally, I’d lean toward a minor release first, so people can try out the feature and share feedback. |
|
sorry... intended minor version (probably: v25.4.0) |
|
@adrai Ready once these are finished: |
|
I'm ready to 🚢 it if @marcalexiei is. I re-requested his review so he approve if/when he's ready |
|
v25.4.0 has just been released |
|
I want to thank you all (but especially you two @ahrjarrett and @marcalexiei ) for the great work on this 👏 I’ve just published a blog post about it * - hopefully this helps more users discover it, give it a try, and share feedback. The more people experiment with it, the better. * I hope all code snippets in the blog post are correct and the text has not too much errors ;-) |
|
@marcalexiei is the 🐐 |
|
Also shout out to @dflourusso, who gave feedback on the original implementation of this feature over a year ago |
|
Thanks, @ahrjarrett! However I’d say that title rightfully goes to you: |
|
I love this! We have a few cases where we select "the best option" of translation that is available lile this: <Trans
i18nKey={chooseKey([
`a.${it.type}-${it.semanticType}`,
`a.${it.semanticType}`,
`a.${it.type}`,
])}
t={t}
/>with chooseKey roughly being: const chooseKey = (keys: Array<string>) =>
keys.find(it =>
i18n.exists(`${String(prefix)}.${it}`, { ns: namespace }),
) ?? keys.join(",")Is there a neat way of doing the same thing with the new api? i18nKey={$ =>
$.a[`${it.type}-${it.semanticType}`] ??
$.a[`${it.semanticType}`] ??
$.a[`${it.type}`]
}feels correct, but because |
|
@reckter good question. If you only do this in a few places, it might be worth keeping the code the same as it was, and casing the string as Under the hood, If you can share the shape of your translation dictionaries I'm happy to help brainstorm a more elegant solution. |
|
Hi team, thanks for shipping this feature! Really looking forward to the type-checking performance gains once the migration is finished 🚀 I ran the codemod on a large React monorepo and found out that while it handles most cases (1000+), dynamic keys and template strings are often skipped. Additionally, with stricter type checking in place in general, there are still hundreds of files remaining that require manual effort to resolve their type errors. It's expected that the codemod can't safely refactor everything especially because in some areas, I've noticed we've adopted some suboptimal patterns with i18n. However in practice this means we need to refactor all these cases ourselves or fall back on less type-safe solutions like Is it possible to leverage a more incremental adoption path to reduce migration risk? For example:
Thanks! |
|
@giavinh79 if the project is a monorepo, you might find it easier to migrate one workspace at a time using the approach we discussed in #2344. tldr; you can create a separate If that's not enough to make the migration doable, let me know, happy to keep brainstorming |
|
Wow. Just found this. Impressive! |
|
The improved DX is very welcome! 👍 Does anyone have benchmarks on how the runtime performance is actually affected? |
Closes #1883
Closes #1972
Closes #2042
Closes #2198
Closes #2204
Closes #2276
Closes #2317
Closes #2320
Closes #2321
Closes i18next/react-i18next#1849
Merge before i18next/react-i18next#1852
Merge before i18next/i18next-gitbook#235
Merge before i18next/react-i18next-gitbook#151
Supersedes #2319
Description
This PR adds a new "selector" API for querying translations.
Feedback on any part of this PR is welcome! Let's make i18next DX even better together 👍
Note
enableSelector: trueorenableSelector: 'optimize'to you configt.Using it looks like this:
Here's an example that shows pluralization being enforced, and the translations being "pruned" when
contextis provided:Benefits
This works even when users change namespaces. Special care was taken when building the types to ensure that all mappings are "homomorphic" (which is just a fancy way of saying that they preserve the link to the original key).
With this feature, users don't need to "track down" translations any more. If they want to see where a translation is defined, they can simply jump to it in their editor. This makes troubleshooting issues easier, and helps users stay in flow.
enableSelectorset to"optimize", users see a ~1,700x improvement in type-level performanceThat means i18next is now capable of handling arbitrarily large translation sets
Before/after example using a generated translation set of 30,000 keys, 10 levels deep:
Benchmarks:
Before: Unable to run benchmarks: running
tsccrashes due to the JavaScript heap being out of memory, IDE quits after ~8 seconds withType instantiation is excessively deep, possibly infiniteerrorAfter (459 instantiations for ~30,000 keys)
enableSelectorset totrue, users see a ~300-400% improvement in type-level performanceAs in feat: new autocompletion overload #2319, I used @arktype/attest's
benchAPI to measure instantiations, which are strongly correlated to IDE performanceAnecdotally, usage feels noticeably snappier
Benchmarks:
Before (24,147 instantiations for ~40 keys)
After (6,558 instantiations for ~40 keys)
Implementation
Uses the native
Proxy.revocableAPI under the hood.The proxy keeps track of property accesses, then converts them into a format that i18next already understands.
The proxy is revoked automatically after the selector is called so that the proxy can be garbage collected.
Migration
To assist with the migration story, I've implemented a codemod that will take care of the migration for users.
For users who'd prefer to handle the migration themselves, here are the changes you'll need to apply when turning on
enableSelector:Tip
We recommend you use the codemod to automate this step for you
Example:
Tip
We recommend you use the codemod to automate this step for you
Note
A small type-level change has been made that could impact TypeScript users: when a user provides a default value, the return type of the
tfunction is now a union of the selected type, and the default value's typeInstead of passing an array of keys, users must provide fallback values explicitly
tagain indefaultValueproperty of the options objectTip
We recommend you use the codemod to automate this step for you
Example:
Todo
react-i18nextrepo that updates theTranscomponentenableSelector: "optimize"option -- with this option, i18next is able to support arbitrarily large translation sets (added in 156646d)useSelectortoenableSelectornsSeparatorlogic from proxy's join path logickeySeparatoroption again, and un-omit the property inSelectorOptions(fixed in ecd6f5b)tinto a single object, similar to howtanstack-querydoes things (decision: stick with the 2-parameter signaturereturnObjectswhen no resources are defined (fixed in f904966)Closing thoughts
The proxy implementation works without a hitch. I fuzz-tested
keysFromSelectorto build up my own confidence, but I ultimately ripped it out for this PR to keep the cognitive overhead low. Let me know if you'd like me to add that back.I also added ~200 type-level tests, so I'm fairly confident that there won't be any surprises there.
Checklist
npm run testChecklist (for documentation change)