You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$250] Chat - Live markdown in composer is removed if the text exceeds 4000 characters #95210
Version Number: 9.4.26-3 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:#94828 (Step 5) Email or phone of affected tester (no customers):fischer9966+070126+94828@gmail.com Issue reported by: Applause Internal Team Bug source: Pull Request QA execution Device used: MacOS 26.5.1 / Chrome, Safari. iPhone 17 Pro / 26.5.1. Google Pixel 5 / Android 14 App Component: Chat Report View
Action Performed:
Go to staging.new.expensify.com or open Expensify App
Login with any account
Go too any chat
Paste any text with markdown (bold/italic, @ or link) 4,000 characters in total length into the composer.
Verify the markdown is preserved.
Add one character to the text.
Expected Result:
Live markdown is preserved in text of any length permitted in the composer
Actual Result:
Live markdown in composer is removed if the text exceeds 4000 characters
Root Cause: Live markdown in the composer is rendered by MarkdownTextInput from the @expensify/react-native-live-markdown library (App pins 0.1.329). That library's default parser, parseExpensiMark, has a hardcoded cap — MAX_PARSABLE_LENGTH = 4000 — and returns an empty set of markdown ranges the moment the input length exceeds it:
constMAX_PARSABLE_LENGTH=4000;// ...functionparseExpensiMark(markdown: string): MarkdownRange[]{if(markdown.length>MAX_PARSABLE_LENGTH){return[];// ← no ranges = no live styling above 4000 chars}
...
}
With zero ranges, the input renders as plain text, so the live styling (bold/italic, mentions, links) disappears at exactly 4,000 characters — matching the report ("removed if the text exceeds 4000 characters" / "add one character to the text"). The cap is a deliberate performance safeguard: the source note explains "applying markup is very resource-consuming," and parsing cost grows with input length.
Note this is a separate limit from App's own CONST.MAX_MARKUP_LENGTH (10000) and MAX_COMMENT_LENGTH (15000) in src/CONST/index.ts — those govern sending/server-side markup, not the live composer rendering, so they don't explain the 4000 threshold.
Where the fix lives: The threshold is inside the Expensify/react-native-live-markdown package (src/parseExpensiMark.ts), not in the Expensify/App repo. Because the root cause is outside App, I'm not proposing an App code change here. This needs an internal engineer with access to the library to decide whether to raise/remove the MAX_PARSABLE_LENGTH cap (weighing the performance cost on very long inputs, potentially chunked/partial parsing) and then bump the pinned version in App. Please escalate to that team.
Investigation details & evidence
Library source: node_modules/@expensify/react-native-live-markdown/src/parseExpensiMark.ts:26 (const MAX_PARSABLE_LENGTH = 4000;) and :242 (the > MAX_PARSABLE_LENGTH early-return []).
App consumes this via MarkdownTextInput in the composer — e.g. src/components/Composer/implementation/index.native.tsx:1. parseExpensiMark is the library's default parser (src/parseExpensiMark.ts:269).
Pinned version: @expensify/react-native-live-markdown: "0.1.329" in App/package.json.
Reproduces on staging + production and across native platforms (Android App, iOS App, Desktop/Web Chrome), consistent with the cap living in the shared JS parser rather than any platform-specific code.
Alternative explanations considered and ruled out: App's MAX_MARKUP_LENGTH (10000) / MAX_COMMENT_LENGTH (15000) don't line up with the 4000 threshold and apply to comment submission/markup, not live composer rendering. The exact 4000 boundary and the "add one character" trigger point uniquely to the library's MAX_PARSABLE_LENGTH.
Confidence: High — the mechanism is directly visible in the parser source and the threshold matches the reported boundary exactly.
Next Steps: Reply with @MelvinBot <your feedback> to refine this analysis. Since the fix is in the react-native-live-markdown library (outside this repo), a code change can't be made from Expensify/App alone — it needs an engineer with library access.
Please re-state the problem that we are trying to solve in this issue.
Live markdown formatting disappears when a chat composer’s text exceeds 4,000 characters, even though the composer permits longer comments.
What is the root cause of that problem?
In src/components/RNMarkdownTextInput.tsx, RNMarkdownTextInputWithRef wraps MarkdownTextInput from @expensify/react-native-live-markdown. When no maxLength is supplied, the underlying input retains its 4,000-character processing limit, so crossing that boundary stops live markdown parsing while the App still accepts more text.
What changes do you think we should make in order to solve the problem?
In RNMarkdownTextInputWithRef, always pass an explicit fallback maxLength of CONST.MAX_COMMENT_LENGTH + 1 when the caller does not provide one. This aligns the live-markdown processing range with the chat composer’s actual permitted comment length while preserving explicit limits used by other callers. Add a focused test confirming formatting remains active after crossing 4,000 characters and up to the comment limit.
What alternative solutions did you explore? (Optional)
Changing the global markup parsing limit would affect rendered messages and would not address the missing input limit at the composer boundary, so the fix should remain in the RNMarkdownTextInput wrapper.
🚨 Edited by proposal-police: This proposal was edited at 2026-07-02 15:32:21 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
When you paste or type more than 4,000 characters into the chat composer, all the live markdown styling (bold, italics, mentions, links) suddenly disappears and the text renders flat. Adding a single character to a 4,000-character message is enough to trip it. The composer still accepts the text, it just stops styling it.
What is the root cause of that problem?
The live styling in the composer isn't computed in this repo. It comes from parseExpensiMark in the @expensify/react-native-live-markdown library, which the App pins at 0.1.327 (package.json:98). That parser has a hardcoded performance cap:
Once the text is longer than 4,000 characters it returns an empty range array, so the input has nothing to style and falls back to plain text. The call path is ComposerWithSuggestions → RNMarkdownTextInput at Composer/implementation/index.tsx:359 (which passes no maxLength or parser override) → the default parserWorklet → parseExpensiMarkWithShortMentions → parseExpensiMark(text). The composer itself allows up to MAX_COMMENT_LENGTH = 15,000 characters and the App even defines MAX_MARKUP_LENGTH = 10,000 (src/CONST/index.ts:5126,5129), so there's a real mismatch: the App happily accepts 15k characters while the library silently kills styling at 4k. That 4k number lives entirely inside the library. parseExpensiMark takes no options and the constant is private, so there is no App-side knob to raise or bypass it.
Worth flagging: this is not a regression in App. The 4,000 cap has been in the library since at least 0.1.298 (I checked back through 11 tags). PR #94828 is cited as the bug source, but that PR only bumped the dependency and QA happened to hit the cap while executing it. There's no App commit to revert here, and importantly PR #94828 was itself the "fix APP-EF1 iOS App Hang" work, so this cap is what's currently preventing that hang on long input.
What changes do you think we should make in order to solve the problem?
The fix lands in react-native-live-markdown (src/parseExpensiMark.ts), pulled in here via a version bump — there is no correct App-only patch (a patch-package bump of the constant is fragile and re-opens the iOS hang). Two parts:
1. Stop dropping all styling above the cap (the actual bug fix). Today parseExpensiMark does if (markdown.length > MAX_PARSABLE_LENGTH) return [], which is why one character past 4,000 wipes every range. Instead, parse the first MAX_PARSABLE_LENGTH characters and return those ranges, leaving the tail plain:
function parseExpensiMark(markdown: string): MarkdownRange[] {
- if (markdown.length > MAX_PARSABLE_LENGTH) {- return [];- }- const html = parseMarkdownToHTML(markdown);+ // Bound the PARSED region, not the whole input. Above the cap we style the first+ // MAX_PARSABLE_LENGTH chars and leave the tail plain, instead of dropping every range.+ const textToParse = markdown.length > MAX_PARSABLE_LENGTH ? markdown.slice(0, MAX_PARSABLE_LENGTH) : markdown;+ const html = parseMarkdownToHTML(textToParse);
const tokens = parseHTMLToTokens(html);
const tree = parseTokensToTree(tokens);
const [text, ranges] = parseTreeToTextAndRanges(tree);
- if (text !== markdown) {+ if (text !== textToParse) {
The returned ranges are offsets into the prefix, which stay valid against the full string since the prefix shares its start. Parse cost is capped by construction, so this carries no iOS-hang risk regardless of total length — the worst-case parse work is identical to today's 4,000-char limit.
2. Raise the cap to MAX_MARKUP_LENGTH (10,000), gated on an on-device iOS benchmark. 10,000 is the right target, not 15,000: at send time getParsedComment (ReportUtils.ts:6207) does if (text.length > CONST.MAX_MARKUP_LENGTH) return lodashEscape(text), so above 10k the app already sends plain, un-marked-up text — styling past 10k would render formatting that gets stripped on send. If an iOS worklet benchmark shows 10,000 is too slow, MAX_PARSABLE_LENGTH stays at 4,000 and part 1's graceful degradation still fixes the reported bug.
I verified part 1's mechanic against the real parser (Node): parsing the first 4,000 chars of a 10k string returns valid in-range ranges with no error, and valid *bold*/_italic_ before the cut are preserved even when a markdown marker straddles the boundary (the straddling marker renders as literal text — graceful, no corruption). Add a >4,000-char case to src/__tests__/parseExpensiMark.test.ts (the missing coverage). Once shipped, the only App change is the version bump in package.json.
What alternative solutions did you explore? (Optional)
Writing a custom parser in the App to sidestep the cap: rejected because the library only exports parseExpensiMark and keeps its ExpensiMark helpers internal, so this would mean re-implementing the whole markdown grammar in a worklet. Patching the constant locally via patch-package: rejected because it reintroduces the performance hang and drifts from upstream on every dependency update.
🚨 Edited by proposal-police: This proposal was edited at 2026-07-02 15:29:09 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the chat composer, once the typed/pasted text passes 4,000 characters, all live markdown styling (bold, italic, mentions, links, code) is dropped and the text renders as plain — even though the composer still accepts input up to the real 15,000-character comment limit. The exact trigger is going from 4,000 to 4,001 characters.
What is the root cause of that problem?
The composer's live styling is driven by a parser worklet that ultimately calls parseExpensiMark from ``@expensify/react-native-live-markdown:
RNMarkdownTextInput builds parserWorklet and passes it to the native MarkdownTextInput:
Structural cause:parseExpensiMark has a hardcoded parse ceiling that is far below the composer's own input ceiling, and above it the function returns zero ranges (not a truncated set — an empty set):
constMAX_PARSABLE_LENGTH=4000;// parseExpensiMark.ts L26functionparseExpensiMark(markdown){if(markdown.length>MAX_PARSABLE_LENGTH){return[];// L242-244 — no ranges => no styling}
...
}
Repro proof: the guard is length > 4000. At exactly 4,000 chars 4000 > 4000 is false → ranges are produced → markdown preserved (repro step 5). Add one character → 4001 > 4000 is true → [] → styling stripped (step 6). This matches the reported "off by one character" behavior exactly, and because the guard lives in the shared JS worklet it reproduces on every listed platform (Android/iOS App, Windows/macOS Chrome/Safari) — consistent with the checkboxes.
This is not new from the PR #94828 bump — MAX_PARSABLE_LENGTH = 4000 predates 0.1.327; QA simply surfaced it while regression-testing that bump. It's a deliberate perf guard to keep the worklet from re-parsing huge strings on every keystroke.
Blast radius — every App consumer of parseExpensiMark inherits the same 4,000 cliff:
Composer live styling — src/libs/ParsingUtils.ts#L61 (the reported symptom).
Code-block detection for emoji handling — src/libs/EmojiUtils.tsx#L40 (also L48, L459):
All three read the library function directly, so all three silently stop working past 4,000 chars. (SearchAutocompleteInput passes its own search parser and is unaffected.)
What changes do you think we should make in order to solve the problem?
Raise the parse ceiling to the composer's real limit so styling covers the full permitted length, at the single convergence point all three consumers share — MAX_PARSABLE_LENGTH in parseExpensiMark. In-repo (mergeable now by a contributor) this is a patch-package patch on ``@expensify/react-native-live-markdown — the repo already patches this library's worklet ecosystem (see `patches/html-entities/`, added so `parseExpensiMark` can run as a worklet) — setting the limit to `15000` to match `CONST.MAX_COMMENT_LENGTH`. The durable version is the same one-line change upstreamed to react-native-live-markdown plus a dep bump.
Regression safety: the only behavior that changes is that inputs of 4,001–15,000 chars now get parsed; everything ≤4,000 is byte-for-byte identical, and 15,000 is already the hard input cap so we never parse beyond it. The perf reason for 4,000 is now much weaker: PR #94828 (0.1.328) made the native shadow node persist one MarkdownParser with its memo cache across Yoga measure passes, so unchanged text is parsed at most once per real edit rather than on every measure — the repeated-parse hang that motivated a conservative cap no longer applies.
Why the obvious spot-fix fails: bumping the TextInput's maxLength (or editing the App's parserWorklet) does nothing — maxLength only caps input length (already 15,001), and the App worklet merely forwards to parseExpensiMark, which has already returned [] before any range exists to restore. There is no per-caller App wrapper that can re-create ranges the library refused to compute; the fix must be at the library constant, which is exactly why the same one-line change repairs the composer, emoji code-block detection, and the formatting toolbar together.
What alternative solutions did you explore? (Optional)
Make the limit configurable via a prop (pass MAX_MARKDOWN_LENGTH_TO_PARSE from RNMarkdownTextInput into the parser) instead of a global constant — cleanest long-term, but needs a library API change and still a bump, so it's slower to land than raising the shared constant.
Keep 4,000 but truncate-and-parse the visible window — rejected: partial ranges across a boundary corrupt styling and still drop markdown in the tail, so it doesn't actually solve the reported case.
The composer itself does nothing with the 4000 threshold — it just hands the raw string to the live‑markdown input. On web, Composer renders RNMarkdownTextInput with only value + markdownStyle, and on native it does the same in index.native.tsx. Neither passes a parser prop, so all formatting (bold/italic, @-mentions, links) is produced by the default parser inside @expensify/react-native-live-markdown, pinned to 0.1.329 in package.json.
That library's parseExpensiMark range‑parser has a hard input‑length guard: once the composer text passes ~4000 characters it stops emitting markdown ranges and the input falls back to plain text. That's why the behavior is a clean step — exactly 4000 chars keeps styling, adding one more character (4001) drops all of it — and why it reproduces identically on web, iOS and Android (the guard lives in the shared worklet parser, not in any platform‑specific App file). Parser in src/libs/Parser.ts is only used for htmlToText/emoji detection here and is unrelated to the composer's live rendering, so nothing on the App side raises or lowers the threshold.
To be upfront: the literal 4000 constant sits in node_modules (@expensify/react-native-live-markdown/parseExpensiMark), which isn't part of this repo, so I can't permalink the exact line — but the App‑level cause is that we delegate parsing to that pinned version and never override its parser.
What changes do you think we should make in order to solve the problem?
This is a dependency/config‑layer fix. Two viable routes:
Bump @expensify/react-native-live-markdown in package.json (and the lockfile) to a version where the parser length cap is raised/removed, i.e. large enough to cover the composer's allowed max length. This needs an upstream change in the library first if no such release exists, since the limit is a deliberate performance guard.
If we don't want to wait on the library, pass an explicit parser prop from Composer/RNMarkdownTextInput that runs the full ExpensiMark range parse without the early return at 4000. That keeps the decision in App code and lets us tie the cap to the actual composer character limit instead of a hard‑coded 4000.
I'd lean toward (1) because the guard exists for real perf reasons (regex parsing very long strings on the UI thread), and the right fix is to make the library's threshold match the composer's real max input rather than silently dropping formatting. Whatever cap we land on should be aligned with the composer's maximum permitted length so styling never disappears within an editable message.
No offline/optimistic concerns here — this is pure client‑side rendering of the draft, so the only edge case is very long inputs, which is exactly what we're addressing (and why any raised cap should still be benchmarked for typing lag).
What alternative solutions did you explore? (Optional)
Overriding the threshold in App by chunking the value and re‑stitching ranges above 4000 chars. Rejected: reimplementing range math on top of the library is fragile and would drift from upstream parseExpensiMark behavior.
Treating it as a Parser (src/libs/Parser.ts) issue. Rejected: that ExpensiMark instance never feeds the live composer rendering path — it's only used for htmlToText/emoji checks in the composer files above, so changing it wouldn't affect the on‑screen markdown.
🚨 Edited by proposal-police: This proposal was edited at 2026-07-15 11:25:47 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
Live markdown styling in the composer disappears entirely once a message crosses 4000 characters — not just for the part past the cutoff, the whole message loses all styling. @linhvovan29546 confirmed in #95210 (comment) that neither "raise the cap" nor "slice the input" proposals actually fix this, because the real problem is architectural: ExpensiMark is a regex-based parser, and running its full regex chain is inherently slow in Hermes for long text. Any fix has to reduce that per-keystroke regex cost, not just move or soften the cliff.
What is the root cause of that problem?
The cap lives in the live-markdown package, pinned as a hard constant:
What that gate is protecting against is a single call into expensify-common's ExpensiMark, run in raw-input mode so the original markdown syntax is preserved for re-rendering in the composer:
Everything downstream of that call (parseHTMLToTokens, parseTokensToTree, parseTreeToTextAndRanges) is a single linear/DFS pass over the resulting HTML — cheap regardless of length. The expensive part is entirely inside replace(), which runs text through roughly two dozen sequential rules, each doing a full-string regex .replace() unless it opts out:
The remaining ~20 rules — including the ones most composer messages actually use, like bold, italic, strikethrough, inlineCodeBlock, codeFence, mentions, and quote — have no such guard and run their regex unconditionally on every call, regardless of whether *, _, ~, `, @, #, or > even appear in the message:
So the reported "editing lag" isn't really about length in the abstract — it's that a 4000+ character message, which in the common case is mostly or entirely plain pasted text, still pays the full cost of ~20 unnecessary full-string regex scans on every keystroke, because there's no cheap early-out to skip a rule whose trigger character isn't there. This is precisely the "parser architecture" issue the reviewer flagged: the fix already exists in the codebase as a pattern, it's just applied to 4 rules instead of ~24.
What changes do you think we should make in order to solve the problem?
1. Extend the existing shouldSkipProcessing pattern to the remaining rules, using the same kind of cheap, provably-safe .includes() guard already used by image/video above (link and autolink's existing guards turn out to be too weak — see point 2) — each new guard is a pure superset check (if the trigger character genuinely isn't in the text, the rule's regex could never have matched, so this changes zero output, only whether the regex runs):
(unchanged regex/replacement shown above only to anchor where the new line goes — full originals already linked in the root cause section)
Applying the same one-line addition to the rest: strikethrough → !includes('~'), codeFence/inlineCodeBlock → !includes('\') && !includes('`')(backtick is one of the charactersescapeTextHTML-escapes, so both forms have to be checked — more on that below),reportMentions→!includes('#'), userMentions/shortMentions/autoEmail→!includes('@'), hereMentionsand its two follow-up rules →!includes('@here'), quote→!includes('>') && !includes('>') (same escaping reason). That's 11 one-line additions, each mirroring a pattern the codebase already ships and trusts. (emoji`'s regex is harder to cheaply bound — no single ASCII trigger character covers it — so it's left out of this proposal rather than guessing at a guard that might not actually be cheap.)
I implemented this against a local checkout and ran the existing suite before writing anything: my first pass (checking only for >, not the escaped > form) broke 34 tests, because Utils.escapeText converts > to > before the quote rule ever runs — the same reason codeFence/inlineCodeBlock already had to account for both ` and `. After adding the escaped-form check, all 398 existing tests in __tests__/ExpensiMark*.js pass unchanged, tsc --noEmit is clean, and eslint is clean. Benchmarking replace() with shouldKeepRawInput: true on the same 11,000-character string with no markdown-trigger characters at all, before vs. after this patch: 0.196ms → 0.045ms per call, ~4.3x faster. (Note: an earlier draft of this comparison mistakenly benchmarked two different input strings against each other and reported a much larger number — this is the corrected, same-input comparison.)
I also linked this patched build into a local New Expensify web build (App + @expensify/react-native-live-markdown + this expensify-common) and tested directly in the composer: *bold*, _italic_, ~strikethrough~, and @here all still render correctly with the guards active, and a long plain-text paste renders without any hang or visual break. Not a substitute for the benchmark above (end-to-end DOM/render cost dominates at the browser level and swamps a sub-millisecond parser difference), but it confirms the change is safe in the actual product, not just in isolated unit tests.
2. link's existing guard is too weak to matter in practice — tighten it. It currently skips only when . is absent (shouldSkipProcessing: (textToCheck) => !textToCheck.includes('.')), but link only ever matches literal [text](url) syntax, so the actually-correct, still 100%-safe guard is:
This one is also implemented and passes all 398 tests.
3. What this doesn't fix, and why I'm not proposing a fix for it here: benchmarking realistic prose (periods, no URLs — e.g. a long pasted paragraph, ~11,400 characters) showed essentially no improvement from any of the above: 105.5ms before this patch, 104.9ms after. I isolated it — disabling the autolink rule alone drops that same call to 0.06ms, and doubling the input length roughly quadruples the time (6.9ms → 28.3ms → 108.3ms across 2,850 → 5,700 → 11,400 characters), consistent with the URL-matching regex scaling worse than linearly against ordinary text. autolink's own existing guard (!includes('.')) essentially never skips for real writing, since almost every sentence has a period, so it doesn't help here. This is very likely the actual dominant contributor to the "real editing lag... even at 10,000 chars" the reviewer described — more than the 20 rules this proposal targets. I don't have a safe, provably-correct one-line guard for autolink the way I do for the others (unlike link, a bare-domain autolink has no fixed delimiter to check for), and I'd rather flag this precisely than ship a guess that quietly breaks URL autolinking for some edge case. I'd treat this as a follow-up issue: either a cheaper pre-filter for autolink specifically, or addressing UrlPatterns.MARKDOWN_URL_REGEX's own performance, ideally by someone who owns that regex's edge-case history.
Given that, I'm not proposing removing MAX_PARSABLE_LENGTH in this pass. With autolink still slow on ordinary prose, lifting the cap now would let exactly the kind of message most likely to appear in production (long text with normal punctuation) run unbounded quadratic-time parsing on every keystroke — worse than today for that case. Raising the cap modestly (not to a token "fixed number" fix, but as a stopgap proportional to how much headroom points 1–2 buy back) and revisiting full removal once autolink itself is addressed is the safer sequencing.
What alternative solutions did you explore? (Optional)
Full single-pass parser rewrite (the direction KJ21-ENG's proposal takes): would remove the regex-chain cost model entirely and give the strongest guarantee for pathological dense-markdown input. Rejected as this proposal's scope because it re-implements the matching semantics of all ~24 rules from scratch, which is a much larger surface for parity regressions across every markdown construct, and isn't something that can be validated rule-by-rule against existing behavior the way an additive shouldSkipProcessing guard can (each guard is independently verifiable: if the trigger char is absent, skipping is provably a no-op). The two approaches aren't mutually exclusive — a future rewrite would still benefit from knowing which rules are actually relevant per input.
Block-segmentation + memoized incremental re-parse (dilshodmackbook-sketch's proposal): re-parses only the edited block instead of the whole message. Helps repeated-edit cost but doesn't reduce the per-block worst case, and adds new complexity around block boundaries and constructs that span blocks (multi-line quotes, code fences). This proposal is complementary rather than competing — segmentation reduces how much text is reprocessed per keystroke, shouldSkipProcessing reduces how much work is done per rule on whatever text is reprocessed. Combining both would compound the benefit, but that's a larger change than what's proposed here.
Raise MAX_PARSABLE_LENGTH to a bigger fixed number as the entire fix: already tried by other proposals on this thread and explicitly rejected by the reviewer — it's the same cliff at a different point, and doesn't address why the parsing is slow in the first place. That's different from what point 3 above suggests (a small, evidence-based bump alongside the shouldSkipProcessing fix, purely as a stopgap until autolink itself is fixed) — the distinction is that this proposal's actual fix is the guards, not the number.
🚨 Edited by proposal-police: This proposal was edited at 2026-07-02 15:33:37 UTC.
Proposal
What is the root cause of that problem?
The composer's live styling is produced entirely by the library's worklet parser. RNMarkdownTextInput defaults its parser to parseExpensiMarkWithShortMentions:
parseExpensiMark (pinned at 0.1.329) has a hard-coded parse ceiling in its worklet — const MAX_PARSABLE_LENGTH = 4000; — and returns an empty ranges array above it:
Zero ranges means "no formatting", so the decorator paints the input as plain text. The guard is a strict >: at exactly 4,000 chars 4000 > 4000 is false → ranges are produced (Step 5 passes), at 4,001 it flips to [] and all styling is stripped (Step 6). Because the guard lives in the shared JS worklet — not platform-native code — it reproduces on every listed platform.
The important part is that the library's 4,000 line is far below the App's own limits, and it is not where the App actually stops applying markdown. The composer accepts up to CONST.MAX_COMMENT_LENGTH + 1 = 15,001 characters:
* If maxLength is not set, we should set it to CONST.MAX_COMMENT_LENGTH + 1, to avoid parsing markdown for large text
*/
maxLength={maxLength??CONST.MAX_COMMENT_LENGTH+1}
but the send-time parser draws its own line at CONST.MAX_MARKUP_LENGTH = 10,000. getParsedComment — the function that turns composer text into the stored/sent HTML — returns escaped plain text for anything longer than 10,000 chars:
// Auth limit is 60k for the column but we store edits and other metadata along the html so let's use a lower limit to accommodate for it.
MAX_COMMENT_LENGTH: 15000,
// Use the same value as MAX_COMMENT_LENGTH to ensure the entire comment is parsed. Note that applying markup is very resource-consuming.
MAX_MARKUP_LENGTH: 10000,
So the App's real behavior is: markdown is applied up to 10,000 chars and dropped above it. The library's 4,000 cap breaks that contract in the 4,001–10,000 range — the composer shows plain text while the message the user is about to send still renders with full markdown.
What changes do you think we should make in order to solve the problem?
Raise the parse ceiling to match the App's actual markup limit — MAX_PARSABLE_LENGTH should be 10,000 (CONST.MAX_MARKUP_LENGTH), not 4,000 and not 15,000.
10,000 is the exactly-correct value because it makes the live preview WYSIWYG-consistent with what is sent and stored:
4,001–10,000 chars: preview now shows markdown, and getParsedComment also sends it with markdown → consistent.
10,001–15,000 chars: preview shows plain text, and getParsedComment also escapes it to plain text → still consistent.
A cap of 15,000 (the composer's input limit) would over-shoot: for 10,001–15,000 chars it would render live markdown in the composer that the sent message then strips via the MAX_MARKUP_LENGTH guard above — trading one mismatch for another.
The change is one line, upstream in react-native-live-markdown, plus a dep bump. Until that release is published, the same one-line change lands in this repo via patch-package (the repo already patches this library's worklet ecosystem — see patches/html-entities/), so a contributor can merge the fix immediately:
Regression safety: everything ≤4,000 chars is byte-for-byte identical, and we never parse past 10,000 (matching send behavior). The performance motivation for a conservative 4,000 cap is much weaker now — the shadow-node change shipped in the same library line persists one MarkdownParser with its memo cache across Yoga measure passes, so unchanged text is parsed at most once per real edit rather than on every measure.
What alternative solutions did you explore? (Optional)
Cap at 15,000 (MAX_COMMENT_LENGTH) instead of 10,000. Rejected: it re-introduces a preview-vs-sent mismatch for 10,001–15,000-char messages, because getParsedComment already sends those as plain text.
Make the limit configurable via a prop — expose MAX_PARSABLE_LENGTH as a MarkdownTextInput/parseExpensiMark parameter and pass CONST.MAX_MARKUP_LENGTH from RNMarkdownTextInput, so the App owns the threshold. Cleanest long-term and avoids a magic constant drifting from the App's limit, but it's a larger library API change for the same result, so slower to land than raising the shared constant.
Bump the TextInput maxLength / edit the App's parserWorklet. Doesn't work: maxLength only caps input length (already 15,001), and the App worklet merely forwards to parseExpensiMark, which has already returned [] before any range exists to restore.
changed the title [-][Due for payment 2026-10-07] [$250] Chat - Live markdown in composer is removed if the text exceeds 4000 characters[/-][+][$250] Chat - Live markdown in composer is removed if the text exceeds 4000 characters[/+]on Oct 13, 2026
Update: still figuring out regarding the Android typing delay issue. The Android typing delay (or the Android specific chat typing lag) has more than one root cause or contributing factors came out. We got more such cases of lagging in Android native so investigating those as well.
Activity
applause-bot commentedon Jul 2, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/7e5e9bd307818b24732677ad3eeee787099bdd572865430f2ae74b577c83669d
https://platform.applause.com/services/links/v1/external/88d22360ab88ac3372c2d7aa52f5a8f0a341b7617d9c068497d2870ea766d059
MelvinBot commentedon Jul 2, 2026
Issue Analysis
Root Cause: Live markdown in the composer is rendered by
MarkdownTextInputfrom the@expensify/react-native-live-markdownlibrary (App pins0.1.329). That library's default parser,parseExpensiMark, has a hardcoded cap —MAX_PARSABLE_LENGTH = 4000— and returns an empty set of markdown ranges the moment the input length exceeds it:With zero ranges, the input renders as plain text, so the live styling (bold/italic, mentions, links) disappears at exactly 4,000 characters — matching the report ("removed if the text exceeds 4000 characters" / "add one character to the text"). The cap is a deliberate performance safeguard: the source note explains "applying markup is very resource-consuming," and parsing cost grows with input length.
Note this is a separate limit from App's own
CONST.MAX_MARKUP_LENGTH(10000) andMAX_COMMENT_LENGTH(15000) insrc/CONST/index.ts— those govern sending/server-side markup, not the live composer rendering, so they don't explain the 4000 threshold.Where the fix lives: The threshold is inside the
Expensify/react-native-live-markdownpackage (src/parseExpensiMark.ts), not in theExpensify/Apprepo. Because the root cause is outside App, I'm not proposing an App code change here. This needs an internal engineer with access to the library to decide whether to raise/remove theMAX_PARSABLE_LENGTHcap (weighing the performance cost on very long inputs, potentially chunked/partial parsing) and then bump the pinned version in App. Please escalate to that team.Investigation details & evidence
node_modules/@expensify/react-native-live-markdown/src/parseExpensiMark.ts:26(const MAX_PARSABLE_LENGTH = 4000;) and:242(the> MAX_PARSABLE_LENGTHearly-return[]).MarkdownTextInputin the composer — e.g.src/components/Composer/implementation/index.native.tsx:1.parseExpensiMarkis the library's default parser (src/parseExpensiMark.ts:269).@expensify/react-native-live-markdown: "0.1.329"inApp/package.json.Alternative explanations considered and ruled out: App's
MAX_MARKUP_LENGTH(10000) /MAX_COMMENT_LENGTH(15000) don't line up with the 4000 threshold and apply to comment submission/markup, not live composer rendering. The exact 4000 boundary and the "add one character" trigger point uniquely to the library'sMAX_PARSABLE_LENGTH.Confidence: High — the mechanism is directly visible in the parser source and the threshold matches the reported boundary exactly.
Next Steps: Reply with
@MelvinBot <your feedback>to refine this analysis. Since the fix is in thereact-native-live-markdownlibrary (outside this repo), a code change can't be made fromExpensify/Appalone — it needs an engineer with library access.kitvasyl commentedon Jul 2, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
Live markdown formatting disappears when a chat composer’s text exceeds 4,000 characters, even though the composer permits longer comments.
What is the root cause of that problem?
In
src/components/RNMarkdownTextInput.tsx,RNMarkdownTextInputWithRefwrapsMarkdownTextInputfrom@expensify/react-native-live-markdown. When nomaxLengthis supplied, the underlying input retains its 4,000-character processing limit, so crossing that boundary stops live markdown parsing while the App still accepts more text.What changes do you think we should make in order to solve the problem?
In
RNMarkdownTextInputWithRef, always pass an explicit fallbackmaxLengthofCONST.MAX_COMMENT_LENGTH + 1when the caller does not provide one. This aligns the live-markdown processing range with the chat composer’s actual permitted comment length while preserving explicit limits used by other callers. Add a focused test confirming formatting remains active after crossing 4,000 characters and up to the comment limit.What alternative solutions did you explore? (Optional)
Changing the global markup parsing limit would affect rendered messages and would not address the missing input limit at the composer boundary, so the fix should remain in the
RNMarkdownTextInputwrapper.shivang-goliyan commentedon Jul 2, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-07-02 15:32:21 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
When you paste or type more than 4,000 characters into the chat composer, all the live markdown styling (bold, italics, mentions, links) suddenly disappears and the text renders flat. Adding a single character to a 4,000-character message is enough to trip it. The composer still accepts the text, it just stops styling it.
What is the root cause of that problem?
The live styling in the composer isn't computed in this repo. It comes from
parseExpensiMarkin the@expensify/react-native-live-markdownlibrary, which the App pins at0.1.327(package.json:98). That parser has a hardcoded performance cap:Once the text is longer than 4,000 characters it returns an empty range array, so the input has nothing to style and falls back to plain text. The call path is
ComposerWithSuggestions→RNMarkdownTextInputatComposer/implementation/index.tsx:359(which passes nomaxLengthorparseroverride) → the defaultparserWorklet→parseExpensiMarkWithShortMentions→parseExpensiMark(text). The composer itself allows up toMAX_COMMENT_LENGTH= 15,000 characters and the App even definesMAX_MARKUP_LENGTH= 10,000 (src/CONST/index.ts:5126,5129), so there's a real mismatch: the App happily accepts 15k characters while the library silently kills styling at 4k. That 4k number lives entirely inside the library.parseExpensiMarktakes no options and the constant is private, so there is no App-side knob to raise or bypass it.Worth flagging: this is not a regression in App. The 4,000 cap has been in the library since at least
0.1.298(I checked back through 11 tags). PR #94828 is cited as the bug source, but that PR only bumped the dependency and QA happened to hit the cap while executing it. There's no App commit to revert here, and importantly PR #94828 was itself the "fix APP-EF1 iOS App Hang" work, so this cap is what's currently preventing that hang on long input.What changes do you think we should make in order to solve the problem?
The fix lands in
react-native-live-markdown(src/parseExpensiMark.ts), pulled in here via a version bump — there is no correct App-only patch (apatch-packagebump of the constant is fragile and re-opens the iOS hang). Two parts:1. Stop dropping all styling above the cap (the actual bug fix). Today
parseExpensiMarkdoesif (markdown.length > MAX_PARSABLE_LENGTH) return [], which is why one character past 4,000 wipes every range. Instead, parse the firstMAX_PARSABLE_LENGTHcharacters and return those ranges, leaving the tail plain:function parseExpensiMark(markdown: string): MarkdownRange[] { - if (markdown.length > MAX_PARSABLE_LENGTH) { - return []; - } - const html = parseMarkdownToHTML(markdown); + // Bound the PARSED region, not the whole input. Above the cap we style the first + // MAX_PARSABLE_LENGTH chars and leave the tail plain, instead of dropping every range. + const textToParse = markdown.length > MAX_PARSABLE_LENGTH ? markdown.slice(0, MAX_PARSABLE_LENGTH) : markdown; + const html = parseMarkdownToHTML(textToParse); const tokens = parseHTMLToTokens(html); const tree = parseTokensToTree(tokens); const [text, ranges] = parseTreeToTextAndRanges(tree); - if (text !== markdown) { + if (text !== textToParse) {The returned ranges are offsets into the prefix, which stay valid against the full string since the prefix shares its start. Parse cost is capped by construction, so this carries no iOS-hang risk regardless of total length — the worst-case parse work is identical to today's 4,000-char limit.
2. Raise the cap to
MAX_MARKUP_LENGTH(10,000), gated on an on-device iOS benchmark. 10,000 is the right target, not 15,000: at send timegetParsedComment(ReportUtils.ts:6207) doesif (text.length > CONST.MAX_MARKUP_LENGTH) return lodashEscape(text), so above 10k the app already sends plain, un-marked-up text — styling past 10k would render formatting that gets stripped on send. If an iOS worklet benchmark shows 10,000 is too slow,MAX_PARSABLE_LENGTHstays at 4,000 and part 1's graceful degradation still fixes the reported bug.I verified part 1's mechanic against the real parser (Node): parsing the first 4,000 chars of a 10k string returns valid in-range ranges with no error, and valid
*bold*/_italic_before the cut are preserved even when a markdown marker straddles the boundary (the straddling marker renders as literal text — graceful, no corruption). Add a >4,000-char case tosrc/__tests__/parseExpensiMark.test.ts(the missing coverage). Once shipped, the only App change is the version bump inpackage.json.What alternative solutions did you explore? (Optional)
Writing a custom parser in the App to sidestep the cap: rejected because the library only exports
parseExpensiMarkand keeps its ExpensiMark helpers internal, so this would mean re-implementing the whole markdown grammar in a worklet. Patching the constant locally via patch-package: rejected because it reintroduces the performance hang and drifts from upstream on every dependency update.ahmdshrif commentedon Jul 2, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-07-02 15:29:09 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the chat composer, once the typed/pasted text passes 4,000 characters, all live markdown styling (bold, italic, mentions, links, code) is dropped and the text renders as plain — even though the composer still accepts input up to the real 15,000-character comment limit. The exact trigger is going from 4,000 to 4,001 characters.
What is the root cause of that problem?
The composer's live styling is driven by a parser worklet that ultimately calls
parseExpensiMarkfrom ``@expensify/react-native-live-markdown:RNMarkdownTextInputbuildsparserWorkletand passes it to the nativeMarkdownTextInput:App/src/components/RNMarkdownTextInput.tsx
Lines 58 to 91 in 45b0888
parseExpensiMarkWithShortMentions→parseExpensiMark(text):App/src/libs/ParsingUtils.ts
Lines 58 to 62 in 45b0888
Structural cause:
parseExpensiMarkhas a hardcoded parse ceiling that is far below the composer's own input ceiling, and above it the function returns zero ranges (not a truncated set — an empty set):https://github.com/Expensify/react-native-live-markdown/blob/0.1.329/src/parseExpensiMark.ts#L26
https://github.com/Expensify/react-native-live-markdown/blob/0.1.329/src/parseExpensiMark.ts#L241-L244
Empty ranges mean the decorator paints the input as plain text. Meanwhile the composer caps input at
CONST.MAX_COMMENT_LENGTH + 1= 15,001:App/src/components/RNMarkdownTextInput.tsx
Lines 99 to 101 in 45b0888
App/src/CONST/index.ts
Line 5017 in 45b0888
Repro proof: the guard is
length > 4000. At exactly 4,000 chars4000 > 4000is false → ranges are produced → markdown preserved (repro step 5). Add one character →4001 > 4000is true →[]→ styling stripped (step 6). This matches the reported "off by one character" behavior exactly, and because the guard lives in the shared JS worklet it reproduces on every listed platform (Android/iOS App, Windows/macOS Chrome/Safari) — consistent with the checkboxes.This is not new from the PR
#94828bump —MAX_PARSABLE_LENGTH = 4000predates 0.1.327; QA simply surfaced it while regression-testing that bump. It's a deliberate perf guard to keep the worklet from re-parsing huge strings on every keystroke.Blast radius — every App consumer of
parseExpensiMarkinherits the same 4,000 cliff:src/libs/ParsingUtils.ts#L61(the reported symptom).src/libs/EmojiUtils.tsx#L40(alsoL48,L459):App/src/libs/EmojiUtils.tsx
Lines 38 to 48 in 45b0888
src/libs/FormatSelectionUtils.ts#L25:App/src/libs/FormatSelectionUtils.ts
Lines 24 to 26 in 45b0888
All three read the library function directly, so all three silently stop working past 4,000 chars. (
SearchAutocompleteInputpasses its own search parser and is unaffected.)What changes do you think we should make in order to solve the problem?
Raise the parse ceiling to the composer's real limit so styling covers the full permitted length, at the single convergence point all three consumers share —
MAX_PARSABLE_LENGTHinparseExpensiMark. In-repo (mergeable now by a contributor) this is apatch-packagepatch on ``@expensify/react-native-live-markdown— the repo already patches this library's worklet ecosystem (see `patches/html-entities/`, added so `parseExpensiMark` can run as a worklet) — setting the limit to `15000` to match `CONST.MAX_COMMENT_LENGTH`. The durable version is the same one-line change upstreamed to react-native-live-markdown plus a dep bump.Regression safety: the only behavior that changes is that inputs of 4,001–15,000 chars now get parsed; everything ≤4,000 is byte-for-byte identical, and 15,000 is already the hard input cap so we never parse beyond it. The perf reason for 4,000 is now much weaker: PR
#94828(0.1.328) made the native shadow node persist oneMarkdownParserwith its memo cache across Yoga measure passes, so unchanged text is parsed at most once per real edit rather than on every measure — the repeated-parse hang that motivated a conservative cap no longer applies.Why the obvious spot-fix fails: bumping the TextInput's
maxLength(or editing the App'sparserWorklet) does nothing —maxLengthonly caps input length (already 15,001), and the App worklet merely forwards toparseExpensiMark, which has already returned[]before any range exists to restore. There is no per-caller App wrapper that can re-create ranges the library refused to compute; the fix must be at the library constant, which is exactly why the same one-line change repairs the composer, emoji code-block detection, and the formatting toolbar together.What alternative solutions did you explore? (Optional)
MAX_MARKDOWN_LENGTH_TO_PARSEfromRNMarkdownTextInputinto the parser) instead of a global constant — cleanest long-term, but needs a library API change and still a bump, so it's slower to land than raising the shared constant.oqildev commentedon Jul 2, 2026
Proposal
What is the root cause of that problem?
The composer itself does nothing with the 4000 threshold — it just hands the raw string to the live‑markdown input. On web,
ComposerrendersRNMarkdownTextInputwith onlyvalue+markdownStyle, and on native it does the same inindex.native.tsx. Neither passes aparserprop, so all formatting (bold/italic,@-mentions, links) is produced by the default parser inside@expensify/react-native-live-markdown, pinned to0.1.329inpackage.json.That library's
parseExpensiMarkrange‑parser has a hard input‑length guard: once the composer text passes ~4000 characters it stops emitting markdown ranges and the input falls back to plain text. That's why the behavior is a clean step — exactly 4000 chars keeps styling, adding one more character (4001) drops all of it — and why it reproduces identically on web, iOS and Android (the guard lives in the shared worklet parser, not in any platform‑specific App file).Parserinsrc/libs/Parser.tsis only used forhtmlToText/emoji detection here and is unrelated to the composer's live rendering, so nothing on the App side raises or lowers the threshold.To be upfront: the literal
4000constant sits in node_modules (@expensify/react-native-live-markdown/parseExpensiMark), which isn't part of this repo, so I can't permalink the exact line — but the App‑level cause is that we delegate parsing to that pinned version and never override its parser.What changes do you think we should make in order to solve the problem?
This is a dependency/config‑layer fix. Two viable routes:
Bump
@expensify/react-native-live-markdowninpackage.json(and the lockfile) to a version where the parser length cap is raised/removed, i.e. large enough to cover the composer's allowed max length. This needs an upstream change in the library first if no such release exists, since the limit is a deliberate performance guard.If we don't want to wait on the library, pass an explicit
parserprop fromComposer/RNMarkdownTextInputthat runs the full ExpensiMark range parse without the early return at 4000. That keeps the decision in App code and lets us tie the cap to the actual composer character limit instead of a hard‑coded 4000.I'd lean toward (1) because the guard exists for real perf reasons (regex parsing very long strings on the UI thread), and the right fix is to make the library's threshold match the composer's real max input rather than silently dropping formatting. Whatever cap we land on should be aligned with the composer's maximum permitted length so styling never disappears within an editable message.
No offline/optimistic concerns here — this is pure client‑side rendering of the draft, so the only edge case is very long inputs, which is exactly what we're addressing (and why any raised cap should still be benchmarked for typing lag).
What alternative solutions did you explore? (Optional)
parseExpensiMarkbehavior.Parser(src/libs/Parser.ts) issue. Rejected: that ExpensiMark instance never feeds the live composer rendering path — it's only used forhtmlToText/emoji checks in the composer files above, so changing it wouldn't affect the on‑screen markdown.abbasifaizan70 commentedon Jul 2, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-07-15 11:25:47 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
Live markdown styling in the composer disappears entirely once a message crosses 4000 characters — not just for the part past the cutoff, the whole message loses all styling. @linhvovan29546 confirmed in #95210 (comment) that neither "raise the cap" nor "slice the input" proposals actually fix this, because the real problem is architectural:
ExpensiMarkis a regex-based parser, and running its full regex chain is inherently slow in Hermes for long text. Any fix has to reduce that per-keystroke regex cost, not just move or soften the cliff.What is the root cause of that problem?
The cap lives in the live-markdown package, pinned as a hard constant:
https://github.com/Expensify/react-native-live-markdown/blob/0.1.329/src/parseExpensiMark.ts#L26
It's enforced as an all-or-nothing gate before any parsing happens at all:
https://github.com/Expensify/react-native-live-markdown/blob/0.1.329/src/parseExpensiMark.ts#L241-L243
What that gate is protecting against is a single call into
expensify-common'sExpensiMark, run in raw-input mode so the original markdown syntax is preserved for re-rendering in the composer:https://github.com/Expensify/react-native-live-markdown/blob/0.1.329/src/parseExpensiMark.ts#L31-L34
Everything downstream of that call (
parseHTMLToTokens,parseTokensToTree,parseTreeToTextAndRanges) is a single linear/DFS pass over the resulting HTML — cheap regardless of length. The expensive part is entirely insidereplace(), which runstextthrough roughly two dozen sequential rules, each doing a full-string regex.replace()unless it opts out:https://github.com/Expensify/expensify-common/blob/975907731919e68b82b55b25298fb1a09d646f20/lib/ExpensiMark.ts#L1269-L1284
shouldSkipProcessingis exactly the escape hatch that could make this cheap for typical input — it's already declared on every rule's type:https://github.com/Expensify/expensify-common/blob/975907731919e68b82b55b25298fb1a09d646f20/lib/ExpensiMark.ts#L53
But it's only actually used by 4 of the ~25 rules that run in raw-input mode (raw-input mode excludes just 4 unrelated whitespace-only rules, so effectively the same ~25 rules the normal chat-send path uses — see
whitespaceRulesToDisableat https://github.com/Expensify/expensify-common/blob/975907731919e68b82b55b25298fb1a09d646f20/lib/ExpensiMark.ts#L412). Here's theimagerule's guard, one of the 4:https://github.com/Expensify/expensify-common/blob/975907731919e68b82b55b25298fb1a09d646f20/lib/ExpensiMark.ts#L588
The other three are
video(https://github.com/Expensify/expensify-common/blob/975907731919e68b82b55b25298fb1a09d646f20/lib/ExpensiMark.ts#L497-L501),link(…ExpensiMark.ts#L611), andautolink(…ExpensiMark.ts#L706) — each skipping its regex when a required character obviously isn't present anywhere in the text.The remaining ~20 rules — including the ones most composer messages actually use, like
bold,italic,strikethrough,inlineCodeBlock,codeFence, mentions, andquote— have no such guard and run their regex unconditionally on every call, regardless of whether*,_,~,`,@,#, or>even appear in the message:https://github.com/Expensify/expensify-common/blob/975907731919e68b82b55b25298fb1a09d646f20/lib/ExpensiMark.ts#L815-L816
So the reported "editing lag" isn't really about length in the abstract — it's that a 4000+ character message, which in the common case is mostly or entirely plain pasted text, still pays the full cost of ~20 unnecessary full-string regex scans on every keystroke, because there's no cheap early-out to skip a rule whose trigger character isn't there. This is precisely the "parser architecture" issue the reviewer flagged: the fix already exists in the codebase as a pattern, it's just applied to 4 rules instead of ~24.
What changes do you think we should make in order to solve the problem?
1. Extend the existing
shouldSkipProcessingpattern to the remaining rules, using the same kind of cheap, provably-safe.includes()guard already used byimage/videoabove (linkandautolink's existing guards turn out to be too weak — see point 2) — each new guard is a pure superset check (if the trigger character genuinely isn't in the text, the rule's regex could never have matched, so this changes zero output, only whether the regex runs):(unchanged
regex/replacementshown above only to anchor where the new line goes — full originals already linked in the root cause section)Applying the same one-line addition to the rest:
strikethrough→!includes('~'),codeFence/inlineCodeBlock→!includes('\') && !includes('`')(backtick is one of the charactersescapeTextHTML-escapes, so both forms have to be checked — more on that below),reportMentions→!includes('#'),userMentions/shortMentions/autoEmail→!includes('@'),hereMentionsand its two follow-up rules →!includes('@here'),quote→!includes('>') && !includes('>')(same escaping reason). That's 11 one-line additions, each mirroring a pattern the codebase already ships and trusts. (emoji`'s regex is harder to cheaply bound — no single ASCII trigger character covers it — so it's left out of this proposal rather than guessing at a guard that might not actually be cheap.)I implemented this against a local checkout and ran the existing suite before writing anything: my first pass (checking only for
>, not the escaped>form) broke 34 tests, becauseUtils.escapeTextconverts>to>before thequoterule ever runs — the same reasoncodeFence/inlineCodeBlockalready had to account for both`and`. After adding the escaped-form check, all 398 existing tests in__tests__/ExpensiMark*.jspass unchanged,tsc --noEmitis clean, andeslintis clean. Benchmarkingreplace()withshouldKeepRawInput: trueon the same 11,000-character string with no markdown-trigger characters at all, before vs. after this patch: 0.196ms → 0.045ms per call, ~4.3x faster. (Note: an earlier draft of this comparison mistakenly benchmarked two different input strings against each other and reported a much larger number — this is the corrected, same-input comparison.)I also linked this patched build into a local New Expensify web build (App +
@expensify/react-native-live-markdown+ thisexpensify-common) and tested directly in the composer:*bold*,_italic_,~strikethrough~, and@hereall still render correctly with the guards active, and a long plain-text paste renders without any hang or visual break. Not a substitute for the benchmark above (end-to-end DOM/render cost dominates at the browser level and swamps a sub-millisecond parser difference), but it confirms the change is safe in the actual product, not just in isolated unit tests.2.
link's existing guard is too weak to matter in practice — tighten it. It currently skips only when.is absent (shouldSkipProcessing: (textToCheck) => !textToCheck.includes('.')), butlinkonly ever matches literal[text](url)syntax, so the actually-correct, still 100%-safe guard is:https://github.com/Expensify/expensify-common/blob/975907731919e68b82b55b25298fb1a09d646f20/lib/ExpensiMark.ts#L611 (line to replace)
This one is also implemented and passes all 398 tests.
3. What this doesn't fix, and why I'm not proposing a fix for it here: benchmarking realistic prose (periods, no URLs — e.g. a long pasted paragraph, ~11,400 characters) showed essentially no improvement from any of the above: 105.5ms before this patch, 104.9ms after. I isolated it — disabling the
autolinkrule alone drops that same call to 0.06ms, and doubling the input length roughly quadruples the time (6.9ms → 28.3ms → 108.3ms across 2,850 → 5,700 → 11,400 characters), consistent with the URL-matching regex scaling worse than linearly against ordinary text.autolink's own existing guard (!includes('.')) essentially never skips for real writing, since almost every sentence has a period, so it doesn't help here. This is very likely the actual dominant contributor to the "real editing lag... even at 10,000 chars" the reviewer described — more than the 20 rules this proposal targets. I don't have a safe, provably-correct one-line guard forautolinkthe way I do for the others (unlikelink, a bare-domain autolink has no fixed delimiter to check for), and I'd rather flag this precisely than ship a guess that quietly breaks URL autolinking for some edge case. I'd treat this as a follow-up issue: either a cheaper pre-filter forautolinkspecifically, or addressingUrlPatterns.MARKDOWN_URL_REGEX's own performance, ideally by someone who owns that regex's edge-case history.Given that, I'm not proposing removing
MAX_PARSABLE_LENGTHin this pass. Withautolinkstill slow on ordinary prose, lifting the cap now would let exactly the kind of message most likely to appear in production (long text with normal punctuation) run unbounded quadratic-time parsing on every keystroke — worse than today for that case. Raising the cap modestly (not to a token "fixed number" fix, but as a stopgap proportional to how much headroom points 1–2 buy back) and revisiting full removal onceautolinkitself is addressed is the safer sequencing.What alternative solutions did you explore? (Optional)
Full single-pass parser rewrite (the direction KJ21-ENG's proposal takes): would remove the regex-chain cost model entirely and give the strongest guarantee for pathological dense-markdown input. Rejected as this proposal's scope because it re-implements the matching semantics of all ~24 rules from scratch, which is a much larger surface for parity regressions across every markdown construct, and isn't something that can be validated rule-by-rule against existing behavior the way an additive
shouldSkipProcessingguard can (each guard is independently verifiable: if the trigger char is absent, skipping is provably a no-op). The two approaches aren't mutually exclusive — a future rewrite would still benefit from knowing which rules are actually relevant per input.Block-segmentation + memoized incremental re-parse (dilshodmackbook-sketch's proposal): re-parses only the edited block instead of the whole message. Helps repeated-edit cost but doesn't reduce the per-block worst case, and adds new complexity around block boundaries and constructs that span blocks (multi-line quotes, code fences). This proposal is complementary rather than competing — segmentation reduces how much text is reprocessed per keystroke,
shouldSkipProcessingreduces how much work is done per rule on whatever text is reprocessed. Combining both would compound the benefit, but that's a larger change than what's proposed here.Raise
MAX_PARSABLE_LENGTHto a bigger fixed number as the entire fix: already tried by other proposals on this thread and explicitly rejected by the reviewer — it's the same cliff at a different point, and doesn't address why the parsing is slow in the first place. That's different from what point 3 above suggests (a small, evidence-based bump alongside theshouldSkipProcessingfix, purely as a stopgap untilautolinkitself is fixed) — the distinction is that this proposal's actual fix is the guards, not the number.lorretheboy commentedon Jul 2, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-07-02 15:33:37 UTC.
Proposal
What is the root cause of that problem?
The composer's live styling is produced entirely by the library's worklet parser.
RNMarkdownTextInputdefaults itsparsertoparseExpensiMarkWithShortMentions:App/src/components/RNMarkdownTextInput.tsx
Lines 58 to 69 in 5235ee4
which calls
@expensify/react-native-live-markdown'sparseExpensiMarkdirectly:App/src/libs/ParsingUtils.ts
Lines 58 to 63 in 5235ee4
parseExpensiMark(pinned at0.1.329) has a hard-coded parse ceiling in its worklet —const MAX_PARSABLE_LENGTH = 4000;— and returns an empty ranges array above it:Zero ranges means "no formatting", so the decorator paints the input as plain text. The guard is a strict
>: at exactly 4,000 chars4000 > 4000is false → ranges are produced (Step 5 passes), at 4,001 it flips to[]and all styling is stripped (Step 6). Because the guard lives in the shared JS worklet — not platform-native code — it reproduces on every listed platform.The important part is that the library's 4,000 line is far below the App's own limits, and it is not where the App actually stops applying markdown. The composer accepts up to
CONST.MAX_COMMENT_LENGTH + 1= 15,001 characters:App/src/components/RNMarkdownTextInput.tsx
Lines 99 to 101 in 5235ee4
but the send-time parser draws its own line at
CONST.MAX_MARKUP_LENGTH= 10,000.getParsedComment— the function that turns composer text into the stored/sent HTML — returns escaped plain text for anything longer than 10,000 chars:App/src/libs/ReportUtils.ts
Lines 6239 to 6241 in 5235ee4
App/src/CONST/index.ts
Lines 5016 to 5020 in 5235ee4
So the App's real behavior is: markdown is applied up to 10,000 chars and dropped above it. The library's 4,000 cap breaks that contract in the 4,001–10,000 range — the composer shows plain text while the message the user is about to send still renders with full markdown.
What changes do you think we should make in order to solve the problem?
Raise the parse ceiling to match the App's actual markup limit —
MAX_PARSABLE_LENGTHshould be 10,000 (CONST.MAX_MARKUP_LENGTH), not 4,000 and not 15,000.10,000 is the exactly-correct value because it makes the live preview WYSIWYG-consistent with what is sent and stored:
getParsedCommentalso sends it with markdown → consistent.getParsedCommentalso escapes it to plain text → still consistent.A cap of 15,000 (the composer's input limit) would over-shoot: for 10,001–15,000 chars it would render live markdown in the composer that the sent message then strips via the
MAX_MARKUP_LENGTHguard above — trading one mismatch for another.The change is one line, upstream in
react-native-live-markdown, plus a dep bump. Until that release is published, the same one-line change lands in this repo viapatch-package(the repo already patches this library's worklet ecosystem — seepatches/html-entities/), so a contributor can merge the fix immediately:App/src/libs/actions/Report/index.ts
Line 3213 in 5235ee4
Regression safety: everything ≤4,000 chars is byte-for-byte identical, and we never parse past 10,000 (matching send behavior). The performance motivation for a conservative 4,000 cap is much weaker now — the shadow-node change shipped in the same library line persists one
MarkdownParserwith its memo cache across Yoga measure passes, so unchanged text is parsed at most once per real edit rather than on every measure.What alternative solutions did you explore? (Optional)
MAX_COMMENT_LENGTH) instead of 10,000. Rejected: it re-introduces a preview-vs-sent mismatch for 10,001–15,000-char messages, becausegetParsedCommentalready sends those as plain text.MAX_PARSABLE_LENGTHas aMarkdownTextInput/parseExpensiMarkparameter and passCONST.MAX_MARKUP_LENGTHfromRNMarkdownTextInput, so the App owns the threshold. Cleanest long-term and avoids a magic constant drifting from the App's limit, but it's a larger library API change for the same result, so slower to land than raising the shared constant.maxLength/ edit the App'sparserWorklet. Doesn't work:maxLengthonly caps input length (already 15,001), and the App worklet merely forwards toparseExpensiMark, which has already returned[]before any range exists to restore.melvin-bot commentedon Jul 2, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @linhvovan29546 (
External)202 remaining items
melvin-bot commentedon Oct 9, 2026
@linhvovan29546 Whoops! This issue is 2 days overdue. Let's get this updated quick!
Uzaifm127 commentedon Oct 11, 2026
Not overdue.
I'll wrap up this comment till tuesday, thanks.
Most of the time is going into finding the correct RCA for Android typing delay issue.
JS00001 commentedon Oct 12, 2026
thanks, just keep us posted!
[-][Due for payment 2026-10-07] [$250] Chat - Live markdown in composer is removed if the text exceeds 4000 characters[/-][+][$250] Chat - Live markdown in composer is removed if the text exceeds 4000 characters[/+]mallenexpensify commentedon Oct 13, 2026
Please comment and tag me if/when payment is due, thx
Uzaifm127 commentedon Oct 14, 2026
Update: still figuring out regarding the Android typing delay issue. The Android typing delay (or the Android specific chat typing lag) has more than one root cause or contributing factors came out. We got more such cases of lagging in Android native so investigating those as well.
TY
Uzaifm127 commentedon Oct 19, 2026
Update: Got the RCA for Android typing delay, finalizing and preparing the comment to post including the RCA...
Thanks.
Uzaifm127 commentedon Oct 19, 2026
Not overdue
melvin-bot commentedon Oct 19, 2026
@linhvovan29546 Eep! 4 days overdue now. Issues have feelings too...