-
Notifications
You must be signed in to change notification settings - Fork 1
MPDX-9934 - Ensure {{ name }} and not {name} in <Trans>
#1977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -64,6 +64,27 @@ module.exports = { | |||||||||||||||||||
| ignoreMemberSort: false, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| ], | ||||||||||||||||||||
| 'no-restricted-syntax': [ | ||||||||||||||||||||
| 'error', | ||||||||||||||||||||
| { | ||||||||||||||||||||
| selector: | ||||||||||||||||||||
| "JSXOpeningElement[name.name='Trans']:not(:has(JSXAttribute[name.name='t']))", | ||||||||||||||||||||
| message: | ||||||||||||||||||||
| '<Trans> must be passed a t={t} prop from useTranslation() so it resolves keys against the component i18n instance.', | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| { | ||||||||||||||||||||
| /* i18next interpolates {{ name }}, not {name}. A bare {name} child is a | ||||||||||||||||||||
| * plain value at runtime, so it lands in the extracted key and the | ||||||||||||||||||||
| * lookup can never match. | ||||||||||||||||||||
| * Includes Trans and children of Trans, and determines if the descendent matches an | ||||||||||||||||||||
| * Identifier (e.g. {name}), MemberExpression (e.g. {user.name}), or CallExpression (e.g. {getName()}) | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| selector: | ||||||||||||||||||||
| ":matches(JSXElement[openingElement.name.name='Trans'], JSXElement[openingElement.name.name='Trans'] JSXElement) > JSXExpressionContainer > :matches(Identifier, MemberExpression, CallExpression)", | ||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[Medium] **This selector has 2 proven false positives and 9 proven false-negative classes.** Nine separate agent findings consolidated here — they share one root cause: esquery cannot distinguish JSX *children* from *attribute* subtrees, so the descendant combinator walks into `components={{...}}`.
All reproduced against this exact config with the real ESLint 8.57.0 +
The Verified drop-in replacement — passes all 16 probes and produces 0 violations across selector:
":matches(JSXElement[openingElement.name.name='Trans'], JSXElement[openingElement.name.name='Trans'] JSXElement:not(JSXAttribute JSXElement), JSXElement[openingElement.name.name='Trans'] JSXFragment) > JSXExpressionContainer > :not(Literal, JSXEmptyExpression, ObjectExpression, JSXElement, JSXFragment, LogicalExpression)",Three changes: Rule 1 (line 70-71) is not fixable in esquery — |
||||||||||||||||||||
| message: | ||||||||||||||||||||
| 'Single-brace {name} inside <Trans> becomes part of the extracted key, so the lookup never matches. Use {{ name }} which only typechecks as a direct child of <Trans>, so if this sits inside a nested element, move that element outside the <Trans> instead.', | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
From my research, upgrading our i18n libraries might fix this restriction.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[Medium] **This message steers authors toward the one `` form that can silently fail — and the blocker in this PR is proof.**
"…so if this sits inside a nested element, move that element outside the The Two suggestions, in order of value:
|
||||||||||||||||||||
| }, | ||||||||||||||||||||
| ], | ||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[Medium] **The single-brace ban is now a hard CI error documented nowhere.** `CLAUDE.md` § Localization never mentions `` at all, and `.claude/rules/code-review.md` records only the `t`-prop item, not this one. The ESLint message is currently the sole documentation — and per the two comments above, its stated guarantees diverge from its actual behavior on 11 proven counts, so it cannot carry that job alone.
Suggested additions:
|
||||||||||||||||||||
| curly: 'error', | ||||||||||||||||||||
| eqeqeq: 'error', | ||||||||||||||||||||
| 'no-console': 'error', | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { Linter } from 'eslint'; | ||
| import eslintConfig from '../../.eslintrc'; | ||
|
|
||
| interface RestrictedSyntaxOption { | ||
| selector: string; | ||
| message: string; | ||
| } | ||
|
|
||
| // Assert against the real rules so these tests survive wording changes and fail | ||
| // if a selector is narrowed. | ||
| const restrictedSyntax = eslintConfig.rules['no-restricted-syntax'] as [ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[Medium] **This cast hard-codes exactly two options, and TypeScript never length-checks it.** Add a third `no-restricted-syntax` entry and you get no compile error and no test failure — the new rule is active in `verify()` but never asserted on. Insert one *before* the existing two and the positional destructure on line 16 silently mis-binds, so the tests would assert the wrong messages while still passing. (Reordering the current two *is* caught, since the expected messages are read from the config.)
Bind by selector text instead of index, and pin the count: const restrictedSyntax = eslintConfig.rules['no-restricted-syntax'] as [
'error',
...RestrictedSyntaxOption[],
];
const options = restrictedSyntax.slice(1) as RestrictedSyntaxOption[];
expect(options).toHaveLength(2); // a new rule must add its own coverage
const missingTRule = options.find((option) =>
option.selector.includes("JSXAttribute[name.name='t']"),
)!;
const singleBraceRule = options.find((option) =>
option.selector.includes('JSXExpressionContainer'),
)!;Reading the rules from the live config was the right call — it survives message rewording. This just closes the one hole in that approach. |
||
| 'error', | ||
| RestrictedSyntaxOption, | ||
| RestrictedSyntaxOption, | ||
| ]; | ||
| const [, missingTRule, singleBraceRule] = restrictedSyntax; | ||
|
|
||
| const linter = new Linter(); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[Suggestion] Pin the config type: `new Linter({ configType: 'eslintrc' })`.
This is correct today — the repo is on ESLint 8.57.0 ( (For the record: |
||
|
|
||
| const lintTrans = (body: string): string[] => | ||
| linter | ||
| .verify(`export const Probe = ({ name, url, t }) => (\n ${body}\n);`, { | ||
| parserOptions: { | ||
| ecmaVersion: 2020, | ||
| sourceType: 'module', | ||
| ecmaFeatures: { jsx: true }, | ||
| }, | ||
| rules: { 'no-restricted-syntax': restrictedSyntax }, | ||
| }) | ||
| .filter((message) => message.ruleId === 'no-restricted-syntax') | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[Medium] **This filter drops fatal parse errors, so all five negative tests can pass vacuously — and the harness lints a different language than CI does.** Two defects with one fix.
1. Parse errors are swallowed. ESLint reports them with So 2. Wrong parser. Fix: const linter = new Linter({ configType: 'eslintrc' });
linter.defineParser('ts', require('@typescript-eslint/parser'));
const lintTrans = (body: string): string[] => {
const messages = linter.verify(
`export const Probe = ({ name, url, t }) => (\n ${body}\n);`,
{
parser: 'ts',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
rules: { 'no-restricted-syntax': restrictedSyntax },
},
);
// A parse error reports ruleId: null, which the filter below would drop,
// turning every toEqual([]) expectation into a false pass.
const fatal = messages.find((message) => message.fatal);
if (fatal) {
throw new Error(`Probe failed to parse: ${fatal.message}`);
}
return messages
.filter((message) => message.ruleId === 'no-restricted-syntax')
.map((message) => message.message);
};Untested branches worth adding (each verified against the real config): optional chaining |
||
| .map((message) => message.message); | ||
|
|
||
| describe('<Trans> no-restricted-syntax rules', () => { | ||
| it('flags a <Trans> with no t prop', () => { | ||
| expect(lintTrans('<Trans>All set</Trans>')).toEqual([missingTRule.message]); | ||
| }); | ||
|
|
||
| it('flags a self-closing <Trans> with no t prop', () => { | ||
| expect( | ||
| lintTrans('<Trans defaults="Hello {{name}}" values={{ name }} />'), | ||
| ).toEqual([missingTRule.message]); | ||
| }); | ||
|
|
||
| it('accepts a <Trans> that is passed t', () => { | ||
| expect(lintTrans('<Trans t={t}>All set</Trans>')).toEqual([]); | ||
| }); | ||
|
|
||
| it('flags a single-brace interpolation', () => { | ||
| expect(lintTrans('<Trans t={t}>Hello {name}</Trans>')).toEqual([ | ||
| singleBraceRule.message, | ||
| ]); | ||
| }); | ||
|
|
||
| it('flags a single-brace interpolation nested inside a child element', () => { | ||
| expect(lintTrans('<Trans t={t}><Box>Hello {name}</Box></Trans>')).toEqual([ | ||
| singleBraceRule.message, | ||
| ]); | ||
| }); | ||
|
|
||
| it('flags a t() call used as a child', () => { | ||
| expect(lintTrans(`<Trans t={t}>{t('Hello')}</Trans>`)).toEqual([ | ||
| singleBraceRule.message, | ||
| ]); | ||
| }); | ||
|
|
||
| it('accepts double-brace interpolation', () => { | ||
| expect( | ||
| lintTrans('<Trans t={t} values={{ name }}>Hello {{ name }}</Trans>'), | ||
| ).toEqual([]); | ||
| }); | ||
|
|
||
| /* | ||
| * react-i18next supports nested JSX/interpolation: nodesToString recursively | ||
| * serializes nested nodes, and the Trans rendering path resolves interpolation | ||
| * values recursively. The JSX form {{ name }} is different: it is parsed as an | ||
| * object literal ({ name }) inside a JSX expression container, so it is not a | ||
| * ReactNode and TypeScript rejects it when <Box>'s children prop is ReactNode. | ||
| */ | ||
| it('does not flag nested double-brace interpolation, which only TypeScript rejects', () => { | ||
| expect( | ||
| lintTrans('<Trans t={t}><Box>Hello {{ name }}</Box></Trans>'), | ||
| ).toEqual([]); | ||
| }); | ||
|
|
||
| it('accepts a string literal child such as a whitespace separator', () => { | ||
| expect(lintTrans(`<Trans t={t}>Hello{' '}<b>there</b></Trans>`)).toEqual( | ||
| [], | ||
| ); | ||
| }); | ||
|
|
||
| it('accepts an expression in a nested element attribute', () => { | ||
| expect( | ||
| lintTrans('<Trans t={t}><Link href={url}>Docs</Link></Trans>'), | ||
| ).toEqual([]); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,10 +43,10 @@ const FinishPage: React.FC = () => { | |
| </Head> | ||
| <SetupPage | ||
| title={ | ||
| <Trans> | ||
| {t('Congratulations!')} | ||
| <Trans t={t}> | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[High] **This `` key can never resolve, and it orphans two keys that are translated in 22 locales.**
Verified by execution, not inference — three agents each ran the real extractor into a scratchpad and rendered the real
i18next-parser 9.0.2 serializes a propless Why the old code worked: Impact: both inner keys have real translations in 22 locales ( Fix — satisfies both new lint rules, keeps every existing translation, no Crowdin round-trip needed: // line 5: drop Trans from the import
import { useTranslation } from 'react-i18next';
// lines 45-51
title={
<>
{t('Congratulations!')}
<br />
{t("You're all set!")}
</>
}Worth adding the assertion that would have caught this, too: it('renders the congratulations title', () => {
const { getByText } = render(<TestComponent />);
expect(getByText(/Congratulations!/)).toBeInTheDocument();
expect(getByText(/You're all set!/)).toBeInTheDocument();
}); |
||
| Congratulations! | ||
| <br /> | ||
| {t("You're all set!")} | ||
| You're all set! | ||
| </Trans> | ||
| } | ||
| > | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,10 +85,10 @@ export const RequestSummaryCard: React.FC<RequestSummaryCardProps> = ({ | |
| <b>{t('Your Annual MHA Total')}</b> | ||
| </Typography> | ||
| <Box sx={{ color: 'text.secondary' }}> | ||
| <Trans values={{ above }}> | ||
| This is calculated from your {above} responses and is the | ||
| lower of the Annual Fair Rental Value or the Annual Cost of | ||
| Providing a Home. | ||
| <Trans t={t} values={{ above }}> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you research
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Making a separate PR for disallowing both |
||
| This is calculated from your {{ above }} responses and is | ||
| the lower of the Annual Fair Rental Value or the Annual Cost | ||
| of Providing a Home. | ||
| </Trans> | ||
| </Box> | ||
| </TableCell> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ interface CreateButtonProps { | |
| } | ||
|
|
||
| const CreateButton: React.FC<CreateButtonProps> = ({ page }) => { | ||
| const { t } = useTranslation(); | ||
| const { openTaskModal, preloadTaskModal } = useTaskModal(); | ||
| const [contactsDialogOpen, setContactsDialogOpen] = useState(false); | ||
|
|
||
|
|
@@ -51,7 +52,7 @@ const CreateButton: React.FC<CreateButtonProps> = ({ page }) => { | |
| backgroundColor: theme.palette.mpdxBlue.main, | ||
| }} | ||
| > | ||
| <Trans defaults="Add new {{page}}" values={{ page }} /> | ||
| <Trans t={t} defaults="Add new {{page}}" values={{ page }} /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another possible rule would be to reject |
||
| </Button> | ||
| {renderDialog( | ||
| AddMenuItemsEnum.NewContact, | ||
|
|
@@ -104,19 +105,21 @@ const NullState: React.FC<Props> = ({ | |
| <> | ||
| <Typography variant="h5"> | ||
| <Trans | ||
| t={t} | ||
| defaults="Looks like you haven't added any {{page}}s yet" | ||
| values={{ page }} | ||
| /> | ||
| </Typography> | ||
| <Typography> | ||
| <Trans | ||
| t={t} | ||
| defaults="You can import {{page}}s from another service or add a new {{page}}." | ||
| values={{ page }} | ||
| /> | ||
| </Typography> | ||
| <Box display="flex" mt={1}> | ||
| <Button variant="contained"> | ||
| <Trans defaults="Import {{page}}s" values={{ page }} /> | ||
| <Trans t={t} defaults="Import {{page}}s" values={{ page }} /> | ||
| </Button> | ||
| <CreateButton page={page} /> | ||
| </Box> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I love that we can do this with the ESLint selectors instead of needing to write a custom code-based rule!