Skip to content

fix: preserve raw HTML parsing and optimize invalid URL scanning - #945

Open
Uzaifm127 wants to merge 8 commits into
Expensify:mainfrom
Uzaifm127:fix/95210-preserve-html-context
Open

Uzaifm127 wants to merge 8 commits into
Expensify:mainfrom
Uzaifm127:fix/95210-preserve-html-context

Conversation

@Uzaifm127

@Uzaifm127 Uzaifm127 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

This is a follow up PR for ExpensiMark performance optimization for #95210.

The original optimization avoids running the autolink, bold, and strikethrough regexes against the complete message on every edit. Instead, it finds small possible Markdown and URL candidates first, then validates only those candidates with the existing regexes.

While comparing the optimized parser with the previous parser, we found four regression categories:

  • Raw malformed HTML or HTML-boundary input with shouldEscapeText: false, such as unfinished tags, unclosed protected tags, unmatched closing tags, and Markdown followed by >. These cases could produce different URL, bold, or strikethrough output than the old full-text regex.
  • Complete or nested raw HTML with shouldEscapeText: false, such as <span>example.com</span>, <h1>example.com</h1>, <a><span>*bold*</span></a>, nested code/pre tags, and < inside quoted HTML attributes.
  • Long invalid .comx candidates. For example, aaaa...aaaa.comx is not a valid URL, but the scanner could previously treat .com as a possible TLD and send a very large invalid candidate to the URL regex.
  • Dot-heavy plain text, such as a.a.a.a... upto 9k characters. The text is not a URL, but the scanner could create a large candidate and the URL regex spent time rejecting it.

To preserve compatibility, the optimized candidate scanner now runs only when it is safe. When shouldEscapeText is false and the text contains raw < or > characters, ExpensiMark uses the original full-text regex behavior instead. This preserves the old parser behavior for both malformed and valid raw HTML.

The URL scanner now also validates hostname labels and requires a known TLD with a valid boundary before creating a URL candidate. This prevents long .comx and dot-heavy plain-text inputs from reaching the expensive URL regex.

Fixed Issues

$ #95210
PROPOSAL:

Tests

Test 1: Malformed HTML and HTML-boundary parsing

  1. Open the App on Web.

  2. Open the Devtool by right clicking on the App and select a inspect or press F12.

  3. In Chrome DevTools, select the Network tab.

  4. Go to Workspaces > workspace > Members > Invite member.

  5. Enter an email address that is not already a workspace member.

  6. Continue to the invitation message step.

  7. Replace the invitation message with:

    before.com <unfinished after.com
    
  8. Clear the Network panel in devtool.

  9. Click Invite.

  10. Open the AddMembersToWorkspace API request.

  11. Inspect the welcomeNote in the payload of API.

  12. Verify that both before.com and after.com are converted into links.

  13. Repeat the same test with the following cases:

  • Case:

    example.com
    `</a>`
    

    expected: <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a><br /><code></a></code>

  • Case:

    <unfinished example.com `code`
    

    expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <code>code</code>

  • Case: <unfinished example.com 😄

    expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <emoji>😄</emoji>

  • Case: <unfinished example.com [label](https://example.com)

    expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <a href="https://example.com" target="_blank" rel="noreferrer noopener">label</a>

  • Case: <unfinished example.com ![alt](https://example.com/image.png)

    expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <img src="https://example.com/image.png" alt="alt" />

  • Case: <unfinished example.com ![video](https://example.com/video.mp4)

    expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <video data-expensify-source="https://example.com/video.mp4" >video</video>

  • Case: # heading <unfinished example.com

    expected: <h1>heading <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a></h1>

  • Case: <unfinished example.com @here

    expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <mention-here>@here</mention-here>

  • Case: *bold* >

    expected: *bold* >

  • Case: ~strike~ >

    expected: ~strike~ >

  • Case: *one* > *two*

    expected: <strong>one* > *two</strong>

  • Case: ~one~ > ~two~

    expected: <del>one~ > ~two</del>

  1. Verify that the URL and Markdown output matches the expected parser behavior in each case.

Test 2: Long invalid .comx URL candidate

  1. Open the App.
  2. Open any chat.
  3. Paste the text containing 8000 to 9000 characters into the composer. Make sure that the text must be like: aaaaaaaaaaa...... upto 8000 characters then .comx, for example: aaaaaaaa....8000.comx

Note

Follow the following steps to copy the correct text to test:
1. Open Chrome DevTools and select the Console tab.
2. Run: copy('a'.repeat(8496) + '.comx')

  1. Type several characters quickly at the end of the text.

  2. Verify that:

    • The complete text remains plain text.
    • .comx is not converted into a link.
    • Typing remains smooth and responsive.
    • Characters do not continue appearing after typing stops.
    • The composer does not freeze.
  3. Repeat the test with: copy('a'.repeat(8496) + '.com') but make sure to add a space after .com at the end of the text.

  4. Verify that the valid .com text becomes a link and that typing remains responsive after adding a space at the end.

Test 3: Dot-heavy invalid URL candidate

  1. Open the App.
  2. Open any chat.
  3. Paste the text containing 8000 to 9000 characters into the composer and the text must be a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.... up to 8000 characters.

Note

Follow the following steps to copy the correct text to test:
1. Open Chrome DevTools and select the Console tab.
2. Run: copy('a.'.repeat(4499) + 'a')

  1. Paste the text into the composer.

  2. Type several characters quickly at the end.

  3. Verify that:

    • The complete text remains plain text.
    • No part of the text becomes a link.
    • Typing remains responsive.
    • Typed characters stop appearing immediately when typing stops.
    • The composer does not freeze or become unresponsive.
  • Verify that no errors appear in the JS console

Offline tests

Same as Test

QA Steps

Same as Test

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native

Test 2

Android-native-2.mov

Test 3

Android-native-3.mov
Android: mWeb Chrome

Test 1

Android-mweb-1.mp4

Test 2

Android-mweb-2.mov

Test 3

Android-mweb-3.mov
iOS: Native

Test 1

iOS-native-1.mp4

Test 2

iOS-native-2.mp4

Test 3

iOS-native-3.mp4
iOS: mWeb Safari

Test 2

iOS-mweb-2.mov

Test 3

iOS-mweb-3.mov
MacOS: Chrome / Safari

Test 1

macOS-1.mov

Test 2

macOS-2.mov

Test 3

macOS-3.mov

Terminal based test

terminal-based-test.mp4

Keep surrounding raw HTML boundaries when validating URL and Markdown candidates so unfinished and protected tags retain the previous parser behavior.
@Uzaifm127 Uzaifm127 changed the title fix: preserve HTML context in optimized ExpensiMark parsing [WIP] fix: preserve HTML context in optimized ExpensiMark parsing Sep 16, 2026
Preserve raw HTML context across nested, malformed, and multi-marker URL and Markdown inputs. Bound dot-heavy hostname candidates to avoid expensive URL-regex work
The raw HTML check in canUseCandidateScanning function already handles this case, so remove the extra marker tracking
@Uzaifm127

Uzaifm127 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

These are the malformed/incomplete HTML cases which we covered for first category of regressions:

Raw malformed HTML or HTML-boundary input with shouldEscapeText: false, such as unfinished tags, unclosed protected tags, unmatched closing tags, and Markdown followed by >. These cases could produce different URL, bold, or strikethrough output than the old full-text regex.

  1. Case: <code>example.com

expected: <code><a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a>

  1. Case: <pre>example.com

expected: <pre><a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a>

  1. Case: <a>example.com

expected: <a><a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a>

  1. Case: <video>example.com

expected: <video><a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a>

  1. Case:

    example.com
    `</a>`
    

expected: <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a><br /><code></a></code>

  1. Case: <code>before.com<pre>inside.com</pre>after.com

expected: <code><a href="https://before.com" target="_blank" rel="noreferrer noopener">before.com</a><pre>inside.com</pre><a href="https://after.com" target="_blank" rel="noreferrer noopener">after.com</a>

  1. Case: <a>*bold* ~strike~ <unfinished after.com

expected: <a><strong>bold</strong> <del>strike</del> <unfinished <a href="https://after.com" target="_blank" rel="noreferrer noopener">after.com</a>

  1. Case:

    <unfinished example.com `code`
    

expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <code>code</code>

  1. Case: <unfinished example.com 😄

expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <emoji>😄</emoji>

  1. Case: <unfinished example.com [label](https://example.com)

expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <a href="https://example.com" target="_blank" rel="noreferrer noopener">label</a>

  1. Case: <unfinished example.com ![alt](https://example.com/image.png)

expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <img src="https://example.com/image.png" alt="alt" />

  1. Case: <unfinished example.com ![video](https://example.com/video.mp4)

expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <video data-expensify-source="https://example.com/video.mp4" >video</video>

  1. Case: # heading <unfinished example.com

expected: <h1>heading <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a></h1>
15. Case: <unfinished example.com @here

expected: <unfinished <a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a> <mention-here>@here</mention-here>

  1. Case: *bold* >

expected: *bold* >

  1. Case: ~strike~ >

expected: ~strike~ >

  1. Case: *one* > *two*

expected: <strong>one* > *two</strong>

  1. Case: ~one~ > ~two~

expected: <del>one~ > ~two</del>


These are the valid/complete HTML cases we covered for second category of regressions

Complete or nested raw HTML with shouldEscapeText: false, such as <span>example.com</span>, <h1>example.com</h1>, <a><span>*bold*</span></a>, nested code/pre tags, and < inside quoted HTML attributes.

  1. Case: <span>example.com</span>

expected: <span>example.com</span>

  1. Case: <h1>example.com</h1>

expected: <h1><a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a></h1>

  1. Case: <a><span>*bold*</span></a>

expected: <a><span><strong>bold</strong></span></a>

  1. Case: <code><h1>example.com</h1></code>

expected: <code><h1><a href="https://example.com" target="_blank" rel="noreferrer noopener">example.com</a></h1></code>

  1. Case: <code title="a<b">example.com</code>

expected: <code title="a<b">example.com</code>

  1. Case: <code><code>example.com</code>after.com</code> outside.com

expected: <code><code>example.com</code>after.com</code> <a href="https://outside.com" target="_blank" rel="noreferrer noopener">outside.com</a>


Note

  1. These regression cases come under the two categories of regressions, other two regressions are related to performance so those are separate.
  2. All the cases listed above have been covered through unit tests.

@Uzaifm127 Uzaifm127 changed the title [WIP] fix: preserve HTML context in optimized ExpensiMark parsing [WIP] fix: preserve raw HTML parsing and optimize invalid URL scanning Sep 21, 2026
Add Given, When, and Then comments explaining the setup, expected behavior, and purpose of the long-input and raw HTML tests.
Explain the hostname checks, TLD limit, HTML boundary filtering, and incomplete-tag handling in the optimized parser.
@Uzaifm127

Copy link
Copy Markdown
Contributor Author

I haven't tested the Test 1 in iOS safari and Android native because there was no payload in Android native and I have some issues with iOS safari web inspector but I don't think it should be problematic as I attached the terminal based testing in macOS section. TY!

@Uzaifm127 Uzaifm127 changed the title [WIP] fix: preserve raw HTML parsing and optimize invalid URL scanning fix: preserve raw HTML parsing and optimize invalid URL scanning Sep 22, 2026
@Uzaifm127
Uzaifm127 marked this pull request as ready for review September 22, 2026 12:45
@Uzaifm127
Uzaifm127 requested a review from a team as a code owner September 22, 2026 12:45
@melvin-bot
melvin-bot Bot requested review from carlosmiceli and removed request for a team September 22, 2026 12:46
@carlosmiceli

Copy link
Copy Markdown
Contributor

Maybe better for thiss to be reviewed by @JS00001 ?

@Uzaifm127

Copy link
Copy Markdown
Contributor Author

@carlosmiceli Sorry for the review request, you are automatically requested for review. This PR will be reviewed by @linhvovan29546 and @JS00001

TY.

@JS00001

JS00001 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Yep, we will review, I need them to comment here so I can assign them

@JS00001
JS00001 requested review from JS00001 and removed request for carlosmiceli September 22, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants