Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim
tagName: 'bullet-item',
contentModel: HTMLContentModel.block,
}),
ul: HTMLElementModel.fromCustomModel({
tagName: 'ul',
contentModel: HTMLContentModel.block,
mixedUAStyles: styles.mv3,
}),
'sparkles-icon': HTMLElementModel.fromCustomModel({
tagName: 'sparkles-icon',
contentModel: HTMLContentModel.mixed,
Expand All @@ -195,6 +200,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim
styles.taskTitleMenuItem,
styles.formError,
styles.mb0,
styles.mv3,
styles.colorMuted,
styles.mutedNormalTextLabel,
styles.productTrainingTooltipText,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import React from 'react';
import {View} from 'react-native';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
import type {TNode} from 'react-native-render-html';
import {TNodeChildrenRenderer} from 'react-native-render-html';
import Text from '@components/Text';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';

function BulletItemRenderer({tnode}: CustomRendererProps<TBlock>) {
function BulletItemRenderer({tnode}: {tnode: TNode}) {
const styles = useThemeStyles();
const theme = useTheme();

return (
<View style={styles.flexRow}>
<View style={[styles.flexRow, styles.w100]}>
<Text style={{color: theme.text, fontSize: variables.fontSizeNormal, lineHeight: variables.fontSizeNormalHeight, paddingHorizontal: 8}}>{CONST.DOT_SEPARATOR}</Text>
<View style={styles.flex1}>
<TNodeChildrenRenderer tnode={tnode} />
Expand Down
41 changes: 41 additions & 0 deletions src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import {View} from 'react-native';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
import {TNodeRenderer} from 'react-native-render-html';
import useThemeStyles from '@hooks/useThemeStyles';
import BulletItemRenderer from './BulletItemRenderer';

/**
* Bypasses the library's internal ULRenderer (which wraps children in MarkedListItem)
* and renders <ul> as a plain block container that draws bullet markers around each
* direct <li> child — matching how <bullet-list>/<bullet-item> render. <li> is left
* unregistered globally so that <ol><li> still uses the library's default numeric markers.
*/
function ULRenderer({tnode, style}: CustomRendererProps<TBlock>) {
const styles = useThemeStyles();
return (
<View style={[style, styles.gap2]}>
{tnode.children.map((child, index) => {
const key = `${child.tagName ?? 'node'}-${index}`;
if (child.tagName === 'li') {
return (
<BulletItemRenderer
key={key}
tnode={child}
/>
);
}
return (
<TNodeRenderer
key={key}
tnode={child}
renderIndex={index}
renderLength={tnode.children.length}
/>
);
})}
</View>
);
}

export default ULRenderer;
2 changes: 2 additions & 0 deletions src/components/HTMLEngineProvider/HTMLRenderers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ShortMentionRenderer from './ShortMentionRenderer';
import SparklesIconRenderer from './SparklesIconRenderer';
import TaskTitleRenderer from './TaskTitleRenderer';
import TransactionHistoryLinkRenderer from './TransactionHistoryLinkRenderer';
import ULRenderer from './ULRenderer';
import UserDetailsRenderer from './UserDetailsRenderer';
import VideoRenderer from './VideoRenderer';

Expand All @@ -30,6 +31,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = {
a: AnchorRenderer,
code: CodeRenderer,
img: ImageRenderer,
ul: ULRenderer,
video: VideoRenderer,

// Custom tag renderers
Expand Down
5 changes: 5 additions & 0 deletions src/components/RenderHTML.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import Parser from '@libs/Parser';
import BulletItemRenderer from './HTMLEngineProvider/HTMLRenderers/BulletItemRenderer';
import SparklesIconRenderer from './HTMLEngineProvider/HTMLRenderers/SparklesIconRenderer';
import ULRenderer from './HTMLEngineProvider/HTMLRenderers/ULRenderer';

type LinkPressHandler = NonNullable<RenderersProps['a']>['onPress'];

Expand Down Expand Up @@ -40,6 +41,9 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp
// Remove double <emoji> tag if exists and keep the outermost tag (always the original tag).
.replaceAll(/(<emoji[^>]*>)(?:<emoji[^>]*>)+/g, '$1')
.replaceAll(/(<\/emoji[^>]*>)(?:<\/emoji[^>]*>)+/g, '$1')
// Strip orphaned <br/> tags inside <ul> that would render as extra empty bullets
.replaceAll(/<br\s*\/?>\s*(<\/ul>)/gi, '$1')
.replaceAll(/(<\/li>)\s*<br\s*\/?>\s*(?=<(?:li|\/ul)>)/gi, '$1')
);
}, [htmlParam]);

Expand All @@ -55,6 +59,7 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp
/* eslint-disable @typescript-eslint/naming-convention */
'bullet-item': BulletItemRenderer,
'sparkles-icon': SparklesIconRenderer,
ul: ULRenderer,
};

const htmlSource = (
Expand Down
116 changes: 116 additions & 0 deletions tests/unit/BulletListRendererTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {render, screen} from '@testing-library/react-native';
import React from 'react';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
import BulletItemRenderer from '@components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer';
import ULRenderer from '@components/HTMLEngineProvider/HTMLRenderers/ULRenderer';
import RenderHTML from '@components/RenderHTML';
import CONST from '@src/CONST';

jest.mock('@hooks/useWindowDimensions', () => () => ({windowWidth: 400}));
jest.mock('@hooks/useHasTextAncestor', () => () => false);

// Capture the html string ultimately passed to react-native-render-html so we can
// assert the orphaned <br/> stripping happens before the library sees the HTML.
const capturedSource: {html?: string} = {};
jest.mock('react-native-render-html', () => {
const ReactModule = jest.requireActual<typeof React>('react');
const {View: MockView, Text: MockText} = jest.requireActual<{View: React.ComponentType; Text: React.ComponentType}>('react-native');
return {
RenderHTMLConfigProvider: ({children}: {children: React.ReactNode}) => children,
RenderHTMLSource: ({source}: {source: {html?: string}}) => {
capturedSource.html = source?.html;
return ReactModule.createElement(MockView);
},
TNodeChildrenRenderer: ({tnode}: {tnode?: {mockText?: string}}) => ReactModule.createElement(MockText, null, tnode?.mockText ?? ''),
TNodeRenderer: ({tnode}: {tnode?: {mockText?: string}}) => ReactModule.createElement(MockText, null, tnode?.mockText ?? ''),
};
});

// Bypass ExpensiMark in these tests — we want to assert the regex stripping logic
// in RenderHTML, not the upstream parser's behavior.
jest.mock('@libs/Parser', () => ({
__esModule: true,
default: {replace: (html: string) => html},
}));

const buildTNode = (text = '') => ({mockText: text}) as unknown as CustomRendererProps<TBlock>['tnode'];
const buildULTNode = (children: Array<{tagName: string; text: string}>) =>
({
children: children.map((child) => ({tagName: child.tagName, mockText: child.text})),
}) as unknown as CustomRendererProps<TBlock>['tnode'];

describe('Bullet list rendering', () => {
beforeEach(() => {
capturedSource.html = undefined;
});

describe('ULRenderer', () => {
it('wraps each <li> child with a bullet marker', () => {
render(
// @ts-expect-error — only the props read by the renderer are needed for this test
<ULRenderer
tnode={buildULTNode([
{tagName: 'li', text: 'One'},
{tagName: 'li', text: 'Two'},
])}
style={{}}
/>,
);
expect(screen.getAllByText(CONST.DOT_SEPARATOR)).toHaveLength(2);
expect(screen.getByText('One')).toBeTruthy();
expect(screen.getByText('Two')).toBeTruthy();
});

it('renders non-<li> children with the default node renderer', () => {
render(
// @ts-expect-error — only the props read by the renderer are needed for this test
<ULRenderer
tnode={buildULTNode([{tagName: 'span', text: 'stray child'}])}
style={{}}
/>,
);
expect(screen.queryByText(CONST.DOT_SEPARATOR)).toBeNull();
expect(screen.getByText('stray child')).toBeTruthy();
});
});

describe('BulletItemRenderer (used for both <li> and <bullet-item>)', () => {
it('renders a bullet marker next to the item content', () => {
render(<BulletItemRenderer tnode={buildTNode('First item')} />);
expect(screen.getByText(CONST.DOT_SEPARATOR)).toBeTruthy();
expect(screen.getByText('First item')).toBeTruthy();
});
});

describe('RenderHTML strips orphaned <br/> tags inside <ul>', () => {
it('strips <br/> immediately before </ul>', () => {
render(<RenderHTML html="<ul><li>One</li><li>Two</li><br/></ul>" />);
expect(capturedSource.html).toBe('<ul><li>One</li><li>Two</li></ul>');
});

it('strips <br> (no slash) immediately before </ul>', () => {
render(<RenderHTML html="<ul><li>One</li><li>Two</li><br></ul>" />);
expect(capturedSource.html).toBe('<ul><li>One</li><li>Two</li></ul>');
});

it('strips <br/> appearing between </li> and the next <li>', () => {
render(<RenderHTML html="<ul><li>One</li><br/><li>Two</li></ul>" />);
expect(capturedSource.html).toBe('<ul><li>One</li><li>Two</li></ul>');
});

it('leaves a valid <ul>/<li> list untouched', () => {
render(<RenderHTML html="<ul><li>One</li><li>Two</li></ul>" />);
expect(capturedSource.html).toBe('<ul><li>One</li><li>Two</li></ul>');
});

it('does not strip <br/> outside of <ul> lists', () => {
render(<RenderHTML html="<p>line1<br/>line2</p>" />);
expect(capturedSource.html).toBe('<p>line1<br/>line2</p>');
});

it('preserves <br/> that lives inside <li> as an in-bullet line break', () => {
render(<RenderHTML html="<ul><li>One<br/>still one</li><li>Two</li></ul>" />);
expect(capturedSource.html).toBe('<ul><li>One<br/>still one</li><li>Two</li></ul>');
});
});
});
Loading