Skip to content

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

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

Uzaifm127 wants to merge 5 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: <code>example.com

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

  • Case: <pre>example.com

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

  • Case: <a>example.com

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

  • Case: <video>example.com

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

  • Case: example.com followed by a new line and `</a>`

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

  • 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>

  • 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>

  • 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 existing parser behavior in each case.
  2. Verify that no unexpected error appears in the JS console.

Test 2: Complete and valid HTML parsing

  1. Repeat steps 1-5 from Test 1.

  2. Replace the invitation message with:

    <span>example.com</span>
  3. Invite the member and inspect the welcomeNote payload.

  4. Verify that the span remains unchanged and that example.com is not incorrectly converted into a link inside it.

  5. Repeat the same test with the following valid or nested HTML cases:

    • <h1>example.com</h1>
    • <a><span>*bold*</span></a>
    • <code><h1>example.com</h1></code>
    • <code title="a<b">example.com</code>
    • <code><code>example.com</code>after.com</code> outside.com
  6. Verify the following behavior:

    • example.com inside <h1> is linked as in the existing parser.
    • *bold* inside the nested anchor/span structure is formatted correctly.
    • The nested code and heading structure preserves the existing parser output.
    • The < inside the quoted attribute does not break HTML parsing.
    • Text inside protected code tags is not incorrectly linked, while outside.com is linked.
  7. Verify that no unexpected error appears in the JS console.

Test 3: 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 4: 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
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

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 followed by a new line and `</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>
14. 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

These regression cases come under the two categories of regressions, other two regressions are related to performance so those are separate.

@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
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.

1 participant