Skip to content

Commit ba059b4

Browse files
committed
prevent double-escaping of interpolated values #16.4.1
1 parent 7917079 commit ba059b4

File tree

6 files changed

+86
-7
lines changed

6 files changed

+86
-7
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 16.4.1
2+
3+
- fix(Trans): prevent double-escaping of interpolated values in component props (e.g. title). Unescape HTML entities before passing prop values to React to avoid rendered output like `"` / `'`. [#1893](https://github.com/i18next/react-i18next/issues/1893)
4+
15
### 16.4.0
26

37
- `<Trans count>` prop: optional - infer count from children [1891](https://github.com/i18next/react-i18next/issues/1891)

react-i18next.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2616,8 +2616,19 @@
26162616
if (!tmp && knownComponentsMap) tmp = knownComponentsMap[node.name];
26172617
if (rootReactNode.length === 1 && !tmp) tmp = rootReactNode[0][node.name];
26182618
if (!tmp) tmp = {};
2619-
const child = Object.keys(node.attrs).length !== 0 ? mergeProps({
2620-
props: node.attrs
2619+
const props = {
2620+
...node.attrs
2621+
};
2622+
if (shouldUnescape) {
2623+
Object.keys(props).forEach(p => {
2624+
const val = props[p];
2625+
if (isString(val)) {
2626+
props[p] = unescape(val);
2627+
}
2628+
});
2629+
}
2630+
const child = Object.keys(props).length !== 0 ? mergeProps({
2631+
props
26212632
}, tmp) : tmp;
26222633
const isElement = React.isValidElement(child);
26232634
const isValidTranslationWithChildren = isElement && hasChildren(node, true) && !node.voidElement;
@@ -2661,7 +2672,8 @@
26612672
}
26622673
} else if (node.type === 'text') {
26632674
const wrapTextNodes = i18nOptions.transWrapTextNodes;
2664-
const content = shouldUnescape ? i18nOptions.unescape(i18n.services.interpolator.interpolate(node.content, opts, i18n.language)) : i18n.services.interpolator.interpolate(node.content, opts, i18n.language);
2675+
const unescapeFn = typeof i18nOptions.unescape === 'function' ? i18nOptions.unescape : getDefaults().unescape;
2676+
const content = shouldUnescape ? unescapeFn(i18n.services.interpolator.interpolate(node.content, opts, i18n.language)) : i18n.services.interpolator.interpolate(node.content, opts, i18n.language);
26652677
if (wrapTextNodes) {
26662678
mem.push(React.createElement(wrapTextNodes, {
26672679
key: `${node.name}-${i}`

react-i18next.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/TransWithoutContext.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import HTML from 'html-parse-stringify';
44
import { isObject, isString, warn, warnOnce } from './utils.js';
55
import { getDefaults } from './defaults.js';
66
import { getI18n } from './i18nInstance.js';
7+
import { unescape } from './unescape.js';
78

89
const hasChildren = (node, checkLength) => {
910
if (!node) return false;
@@ -316,8 +317,18 @@ const renderNodes = (
316317
// neither
317318
if (!tmp) tmp = {};
318319

319-
const child =
320-
Object.keys(node.attrs).length !== 0 ? mergeProps({ props: node.attrs }, tmp) : tmp;
320+
// should fix #1893
321+
const props = { ...node.attrs };
322+
if (shouldUnescape) {
323+
Object.keys(props).forEach((p) => {
324+
const val = props[p];
325+
if (isString(val)) {
326+
props[p] = unescape(val);
327+
}
328+
});
329+
}
330+
331+
const child = Object.keys(props).length !== 0 ? mergeProps({ props }, tmp) : tmp;
321332

322333
const isElement = isValidElement(child);
323334

test/i18n.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ i18n.init({
6060
transTestWithSelfClosing: 'interpolated component: <component/>',
6161
bracketNotation: '{{count}}',
6262
otherNotation: '#$?count?$#',
63+
issue1893: 'Hello <Item title="{{ name }}" />!',
6364
},
6465
other: {
6566
transTest1: 'Another go <1>there</1>.',

test/trans.render.spec.jsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
22
import React, { useRef, useEffect } from 'react';
3-
import { render, cleanup, waitFor } from '@testing-library/react';
3+
import { render, cleanup, waitFor, screen } from '@testing-library/react';
44
import i18n from './i18n';
55
import { withTranslation } from '../src/withTranslation';
66
import { Trans } from '../src/Trans';
@@ -1267,3 +1267,54 @@ describe('Trans edge cases', () => {
12671267
});
12681268
});
12691269
});
1270+
1271+
describe('trans issue 1893 - double escaping in props', () => {
1272+
function Item({ title }) {
1273+
return <span title={title}>{title}</span>;
1274+
}
1275+
1276+
function TestComponent({ name, escapeValue }) {
1277+
return (
1278+
<Trans
1279+
i18nKey="issue1893"
1280+
values={{ name }}
1281+
components={{ Item: <Item /> }}
1282+
tOptions={escapeValue ? { interpolation: { escapeValue: true } } : undefined}
1283+
shouldUnescape
1284+
/>
1285+
);
1286+
}
1287+
1288+
it('should render correctly with quotes in interpolation (case 1: escapeValue false)', () => {
1289+
const value = 'World " \' Test';
1290+
const { container } = render(<TestComponent name={value} />);
1291+
expect(container.firstChild).toMatchInlineSnapshot(`
1292+
<div>
1293+
Hello &lt;Item title="World " ' Test" /&gt;!
1294+
</div>
1295+
`);
1296+
});
1297+
1298+
it('should render correctly with quotes in interpolation (case 2: escapeValue true)', () => {
1299+
const value = 'World " \' Test';
1300+
const { container } = render(<TestComponent name={value} escapeValue />);
1301+
1302+
// Expected behavior: Component parsed and rendered correctly without double escaping
1303+
const span = screen.getByText(value);
1304+
expect(span).toBeInTheDocument();
1305+
expect(span).toHaveTextContent(value);
1306+
expect(span).toHaveAttribute('title', value);
1307+
1308+
expect(container.firstChild).toMatchInlineSnapshot(`
1309+
<div>
1310+
Hello
1311+
<span
1312+
title="World " ' Test"
1313+
>
1314+
World " ' Test
1315+
</span>
1316+
!
1317+
</div>
1318+
`);
1319+
});
1320+
});

0 commit comments

Comments
 (0)