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
    1. 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('
        • One
        • Two
        '); + }); + + it('strips
        (no slash) immediately before
      ', () => { + render(); + expect(capturedSource.html).toBe('
      • One
      • Two
      '); + }); + + it('strips
      appearing between
    2. and the next
    3. ', () => { + render(); + expect(capturedSource.html).toBe('
      • One
      • Two
      '); + }); + + it('leaves a valid
        /
      • list untouched', () => { + render(); + expect(capturedSource.html).toBe('
        • One
        • Two
        '); + }); + + 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('
          • One
            still one
          • Two
          '); + }); + }); +});