diff options
| author | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-27 15:46:36 +0100 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-27 15:46:36 +0100 |
| commit | bbb3ffea3df68d5e08bed6dd7e5c4fc8bdc05129 (patch) | |
| tree | 16aae3fdf456a9303a3cb9fd5ad7dee4fdfc7fd2 | |
| parent | b8ff10c6506fa2944220f32e83155e29b23b96e7 (diff) | |
| parent | 333f00150b78d929faa94d70ca689d3fde2d7e44 (diff) | |
| download | mullvadvpn-bbb3ffea3df68d5e08bed6dd7e5c4fc8bdc05129.tar.xz mullvadvpn-bbb3ffea3df68d5e08bed6dd7e5c4fc8bdc05129.zip | |
Merge branch 'b-tags-are-sometimes-not-rendered-properly-des-2641'
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]); + }); +} |
