summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-27 15:46:36 +0100
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-27 15:46:36 +0100
commitbbb3ffea3df68d5e08bed6dd7e5c4fc8bdc05129 (patch)
tree16aae3fdf456a9303a3cb9fd5ad7dee4fdfc7fd2
parentb8ff10c6506fa2944220f32e83155e29b23b96e7 (diff)
parent333f00150b78d929faa94d70ca689d3fde2d7e44 (diff)
downloadmullvadvpn-bbb3ffea3df68d5e08bed6dd7e5c4fc8bdc05129.tar.xz
mullvadvpn-bbb3ffea3df68d5e08bed6dd7e5c4fc8bdc05129.zip
Merge branch 'b-tags-are-sometimes-not-rendered-properly-des-2641'
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx51
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts47
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/utils.ts33
3 files changed, 101 insertions, 30 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx
index f0a0457eb0..43c0721f29 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/html-formatter.tsx
@@ -35,29 +35,46 @@ const componentMap: Partial<
} as const;
export function formatHtml(inputString: string): React.ReactElement {
- const formattedString: JSX.Element[] = [];
+ const inputStringArray: Array<string | JSX.Element> = [inputString];
- Object.entries(testMap).forEach(([key, { test, replace }]) => {
- const parts = inputString.split(test).filter((part) => part !== '');
- if (parts.length <= 1) {
- return;
- }
+ const transformedStrings = Object.entries(testMap).reduce((strings, [key, { test, replace }]) => {
+ const newStrings = strings.flatMap((value) => {
+ if (typeof value === 'string') {
+ // If the value is a string we should see if our current transformer should transform it.
+ return value
+ .split(test)
+ .filter((v) => v.length > 0)
+ .map((substring) => {
+ // Create a new RegExp object to avoid `lastIndex` side effects, see:
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/lastIndex#avoiding_side_effects
+ const tester = new RegExp(test);
- parts.map((value, index) => {
- if (test.test(value)) {
- const Component = componentMap[key as keyof typeof componentMap]!;
- const valueWithoutTags = value.replaceAll(replace, '');
+ // If the value is matched for the current transformer then it should be turned into a component
+ if (tester.test(substring)) {
+ const Component = componentMap[key as keyof typeof componentMap]!;
+ const valueWithoutTags = substring.replaceAll(replace, '');
- formattedString.push(<Component key={index}>{valueWithoutTags}</Component>);
+ return <Component key={substring}>{valueWithoutTags}</Component>;
+ } else {
+ // If the value is not a match for the current transformer we should return the string as is,
+ // so it can be potentially manipulated by a later transformer
+ return substring;
+ }
+ });
} else {
- formattedString.push(<React.Fragment key={index}>{value}</React.Fragment>);
+ // If the value is not a string it has already been transformed into a component by a transformer in a previous iteration.
+ return value;
}
});
- });
- if (formattedString.length === 0) {
- formattedString.push(<React.Fragment key={inputString}>{inputString}</React.Fragment>);
- }
+ return newStrings;
+ }, inputStringArray);
+
+ // After all the transformers have been applied,
+ // loop over all non-transformed strings and wrap them in fragments
+ const htmlFormattedInputString = transformedStrings.map((value) =>
+ typeof value === 'string' ? <React.Fragment key={value}>{value}</React.Fragment> : value,
+ );
- return <>{formattedString}</>;
+ return <React.Fragment>{htmlFormattedInputString}</React.Fragment>;
}
diff --git a/desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts b/desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts
index 7cdce41994..d13343f1d4 100644
--- a/desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/unit/html-formatter.spec.ts
@@ -1,21 +1,9 @@
-import { expect } from 'chai';
import { describe, it } from 'mocha';
-import React from 'react';
import { formatHtml } from '../../src/renderer/lib/html-formatter';
-
-type WithChildren = React.ReactElement<{ children?: React.ReactNode }>;
+import { expectChildrenToMatch } from './utils';
describe('Format html', () => {
- const expectChildrenToMatch = (element: React.ReactElement, expectedParts: string[]) => {
- const kids = React.Children.toArray((element as WithChildren).props.children);
-
- expect(kids).to.have.lengthOf(expectedParts.length);
- kids.forEach((kid, index) => {
- expect((kid as WithChildren).props.children).to.equal(expectedParts[index]);
- });
- };
-
it('should format middle bold tag', () => {
expectChildrenToMatch(formatHtml('Some <b>bold</b> text'), ['Some ', 'bold', ' text']);
});
@@ -34,6 +22,12 @@ describe('Format html', () => {
' text',
]);
});
+ it('should produce reliable output on each call', () => {
+ expectChildrenToMatch(formatHtml('<b>Some</b> bold text'), ['Some', ' bold text']);
+ expectChildrenToMatch(formatHtml('Some non bold text'), ['Some non bold text']);
+ // Same string used as in first expectChildrenToMatch call
+ expectChildrenToMatch(formatHtml('<b>Some</b> bold text'), ['Some', ' bold text']);
+ });
it('should format middle emphasis tag', () => {
expectChildrenToMatch(formatHtml('Some <em>emphasized</em> text'), [
'Some ',
@@ -59,4 +53,31 @@ describe('Format html', () => {
['Some ', 'emphasized', ' and ', 'more emphasized', ' text'],
);
});
+ it('should format both bold and emphasis tags', () => {
+ expectChildrenToMatch(formatHtml('Some <b>bold</b> and <em>emphasized</em> text'), [
+ 'Some ',
+ 'bold',
+ ' and ',
+ 'emphasized',
+ ' text',
+ ]);
+ });
+ it('should format multiple bold and emphasis tags', () => {
+ expectChildrenToMatch(
+ formatHtml(
+ 'Some <b>bold</b> and <em>emphasized</em> text. Then another <b>bold text</b> and one more <em>text</em> which was emphasized.',
+ ),
+ [
+ 'Some ',
+ 'bold',
+ ' and ',
+ 'emphasized',
+ ' text. Then another ',
+ 'bold text',
+ ' and one more ',
+ 'text',
+ ' which was emphasized.',
+ ],
+ );
+ });
});
diff --git a/desktop/packages/mullvad-vpn/test/unit/utils.ts b/desktop/packages/mullvad-vpn/test/unit/utils.ts
new file mode 100644
index 0000000000..715bcfe03c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/unit/utils.ts
@@ -0,0 +1,33 @@
+import { expect } from 'chai';
+import React from 'react';
+
+type ReactElementWithChildren = React.ReactElement<{ children: React.ReactNode }>;
+
+function isReactElementWithChildren(element: unknown): element is ReactElementWithChildren {
+ if (React.isValidElement(element)) {
+ if (element.props && element.props instanceof Object) {
+ return 'children' in element.props;
+ }
+ }
+
+ return false;
+}
+
+export function expectChildrenToMatch(
+ element: React.ReactElement<unknown>,
+ expectedParts: string[],
+) {
+ if (!isReactElementWithChildren(element)) {
+ throw new Error('React element does not have children on it');
+ }
+
+ const elementChildren = React.Children.toArray(element.props.children);
+ expect(elementChildren).to.have.lengthOf(expectedParts.length);
+ elementChildren.forEach((elementChild, index) => {
+ if (!isReactElementWithChildren(elementChild)) {
+ throw new Error('React element child does not have children on it');
+ }
+
+ expect(elementChild.props.children).to.equal(expectedParts[index]);
+ });
+}