diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
index 1e9113d1d69e..13dc7fbaaebb 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
@@ -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,
@@ -195,6 +200,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim
styles.taskTitleMenuItem,
styles.formError,
styles.mb0,
+ styles.mv3,
styles.colorMuted,
styles.mutedNormalTextLabel,
styles.productTrainingTooltipText,
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx
index 540a48015bba..bad8b5579b37 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx
@@ -1,6 +1,6 @@
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';
@@ -8,12 +8,12 @@ import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
-function BulletItemRenderer({tnode}: CustomRendererProps) {
+function BulletItemRenderer({tnode}: {tnode: TNode}) {
const styles = useThemeStyles();
const theme = useTheme();
return (
-
+
{CONST.DOT_SEPARATOR}
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx
new file mode 100644
index 000000000000..c42c714c053f
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ULRenderer.tsx
@@ -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 as a plain block container that draws bullet markers around each
+ * direct - child — matching how / render.
- is left
+ * unregistered globally so that
- still uses the library's default numeric markers.
+ */
+function ULRenderer({tnode, style}: CustomRendererProps) {
+ const styles = useThemeStyles();
+ return (
+
+ {tnode.children.map((child, index) => {
+ const key = `${child.tagName ?? 'node'}-${index}`;
+ if (child.tagName === 'li') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default ULRenderer;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
index 3d91670c38e7..ef1d630ce007 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
@@ -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';
@@ -30,6 +31,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = {
a: AnchorRenderer,
code: CodeRenderer,
img: ImageRenderer,
+ ul: ULRenderer,
video: VideoRenderer,
// Custom tag renderers
diff --git a/src/components/RenderHTML.tsx b/src/components/RenderHTML.tsx
index ababd16463b8..4ae470aada04 100644
--- a/src/components/RenderHTML.tsx
+++ b/src/components/RenderHTML.tsx
@@ -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['onPress'];
@@ -40,6 +41,9 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp
// Remove double tag if exists and keep the outermost tag (always the original tag).
.replaceAll(/(]*>)(?:]*>)+/g, '$1')
.replaceAll(/(<\/emoji[^>]*>)(?:<\/emoji[^>]*>)+/g, '$1')
+ // Strip orphaned
tags inside that would render as extra empty bullets
+ .replaceAll(/
\s*(<\/ul>)/gi, '$1')
+ .replaceAll(/(<\/li>)\s*
\s*(?=<(?:li|\/ul)>)/gi, '$1')
);
}, [htmlParam]);
@@ -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 = (
diff --git a/tests/unit/BulletListRendererTest.tsx b/tests/unit/BulletListRendererTest.tsx
new file mode 100644
index 000000000000..8e18194d3d53
--- /dev/null
+++ b/tests/unit/BulletListRendererTest.tsx
@@ -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
stripping happens before the library sees the HTML.
+const capturedSource: {html?: string} = {};
+jest.mock('react-native-render-html', () => {
+ const ReactModule = jest.requireActual('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['tnode'];
+const buildULTNode = (children: Array<{tagName: string; text: string}>) =>
+ ({
+ children: children.map((child) => ({tagName: child.tagName, mockText: child.text})),
+ }) as unknown as CustomRendererProps['tnode'];
+
+describe('Bullet list rendering', () => {
+ beforeEach(() => {
+ capturedSource.html = undefined;
+ });
+
+ describe('ULRenderer', () => {
+ it('wraps each - child with a bullet marker', () => {
+ render(
+ // @ts-expect-error — only the props read by the renderer are needed for this test
+ ,
+ );
+ expect(screen.getAllByText(CONST.DOT_SEPARATOR)).toHaveLength(2);
+ expect(screen.getByText('One')).toBeTruthy();
+ expect(screen.getByText('Two')).toBeTruthy();
+ });
+
+ it('renders non-
- children with the default node renderer', () => {
+ render(
+ // @ts-expect-error — only the props read by the renderer are needed for this test
+ ,
+ );
+ expect(screen.queryByText(CONST.DOT_SEPARATOR)).toBeNull();
+ expect(screen.getByText('stray child')).toBeTruthy();
+ });
+ });
+
+ describe('BulletItemRenderer (used for both
- and )', () => {
+ it('renders a bullet marker next to the item content', () => {
+ render();
+ expect(screen.getByText(CONST.DOT_SEPARATOR)).toBeTruthy();
+ expect(screen.getByText('First item')).toBeTruthy();
+ });
+ });
+
+ describe('RenderHTML strips orphaned
tags inside ', () => {
+ it('strips
immediately before
', () => {
+ render();
+ expect(capturedSource.html).toBe('');
+ });
+
+ it('strips
(no slash) immediately before
', () => {
+ render();
+ expect(capturedSource.html).toBe('');
+ });
+
+ it('strips
appearing between and the next - ', () => {
+ render();
+ expect(capturedSource.html).toBe('');
+ });
+
+ it('leaves a valid
/- list untouched', () => {
+ render();
+ expect(capturedSource.html).toBe('');
+ });
+
+ it('does not strip
outside of lists', () => {
+ render();
+ expect(capturedSource.html).toBe('line1
line2
');
+ });
+
+ it('preserves
that lives inside - as an in-bullet line break', () => {
+ render();
+ expect(capturedSource.html).toBe('');
+ });
+ });
+});