Skip to content

fix(ios): persist RCTMarkdownUtils across shadow node clones to prevent AppHang#758

Open
abzokhattab wants to merge 1 commit into
Expensify:mainfrom
abzokhattab:fix/ios-app-hang-markdown-parser-cache
Open

fix(ios): persist RCTMarkdownUtils across shadow node clones to prevent AppHang#758
abzokhattab wants to merge 1 commit into
Expensify:mainfrom
abzokhattab:fix/ios-app-hang-markdown-parser-cache

Conversation

@abzokhattab

@abzokhattab abzokhattab commented Jun 25, 2026

Copy link
Copy Markdown

Details

On every Yoga measure pass, applyMarkdownFormattingToTextInputState was allocating a fresh RCTMarkdownUtils / MarkdownParser instance. MarkdownParser has a one-entry memo cache keyed on {text, parserId} — because a new instance was created on each call, the cache was discarded immediately after every parse, forcing a full JSI re-parse of the entire input string on the main thread every time.

Yoga calls the measure callback multiple times per layout pass. For inputs with complex markdown, the cumulative cost of N × full-parse exceeded iOS's ~2 s watchdog → AppHang.

Fix: Add a mutable std::shared_ptr<void> markdownUtils_ member to MarkdownTextInputDecoratorShadowNode:

  • Lazily initialised on first use inside applyMarkdownFormattingToTextInputState.
  • Carried through the clone constructor — when React clones a shadow node during re-render / layout, the new instance inherits the pointer, so the RCTMarkdownUtils (and its parser cache) is never discarded mid-layout.

Unchanged text now hits the parser's cache and skips the expensive JSI call entirely.

Related Issues

Expensify/App#93831

Manual Tests

  1. Paste the following comment into the composer input: https://expensify.slack.com/archives/C05LX9D6E07/p1782316444025709?thread_ts=1782312343.503839&cid=C05LX9D6E07
  2. Open and close the keyboard.
  3. Verify that the app doesn't crash.
Screen.Recording.2026-06-25.at.16.05.50.mov
Screen.Recording.2026-06-25.at.16.50.23.mov

…nt AppHang

On every Yoga measure pass, `applyMarkdownFormattingToTextInputState` was
allocating a fresh `RCTMarkdownUtils` / `MarkdownParser` instance, which
discarded `MarkdownParser`'s existing one-entry memo cache (keyed on text +
parserId) on every call. This forced a full JSI re-parse of the entire input
string on each measure callback — all on the main thread.

For inputs with complex markdown, the cumulative cost of N parses per layout
pass exceeded iOS's ~2s watchdog threshold, causing an AppHang (Sentry
APP-EF1 / APP-EG9, stack: `yogaNodeMeasureCallbackConnector` →
`applyMarkdownFormattingToTextInputState` → `createStringFromUtf8`).

Fix: add a `mutable std::shared_ptr<void> markdownUtils_` member to the
shadow node that lazily creates `RCTMarkdownUtils` on first use and reuses
it on subsequent calls. The clone constructor copies the pointer from the
source node so the instance — and its parser cache — survive the frequent
cloning that happens during layout and re-render cycles. Unchanged text now
hits the cache and skips the expensive JSI parse entirely.
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@abzokhattab

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

exfy-clabot Bot added a commit to Expensify/CLA that referenced this pull request Jun 25, 2026
@abzokhattab abzokhattab marked this pull request as ready for review June 25, 2026 14:13
Comment thread apple/MarkdownTextInputDecoratorShadowNode.mm
@mountiny mountiny requested a review from situchan June 25, 2026 16:07
@situchan

Copy link
Copy Markdown

Works for me

ios.mov

Comment on lines +192 to 206
// Lazily create and persist the RCTMarkdownUtils instance so the MarkdownParser
// one-entry memo cache (keyed on text + parserId) survives repeated Yoga measure
// callbacks. Previously a fresh utils/parser was allocated on every call,
// discarding the cache and forcing a full JSI re-parse each time.
if (!markdownUtils_) {
RCTMarkdownUtils *freshUtils = [[RCTMarkdownUtils alloc] init];
markdownUtils_ = std::shared_ptr<void>(
(__bridge_retained void *)freshUtils, [](void *p) { CFRelease(p); });
}
RCTMarkdownUtils *utils = (__bridge RCTMarkdownUtils *)markdownUtils_.get();

RCTMarkdownStyle *markdownStyle =
[[RCTMarkdownStyle alloc] initWithStruct:decoratorProps.markdownStyle];
RCTMarkdownUtils *utils = [[RCTMarkdownUtils alloc] init];
[utils setMarkdownStyle:markdownStyle];
[utils setParserId:[NSNumber numberWithInt:decoratorProps.parserId]];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lazy-init block + the setMarkdownStyle:/setParserId: calls right after) — sharing one mutable RCTMarkdownUtils across all clones in a family re-introduces cross-clone state coupling that the previous fresh-per-call instance was immune to.**

setMarkdownStyle: and setParserId: mutate the shared RCTMarkdownUtils/MarkdownParser outside the parser's @synchronized(self) block, which only covers parse:.
If two clones of the same family are measured concurrently on different threads (e.g. overlapping background layout vs. a synchronous main-thread text measurement during mount), clone A's setParserId:/setMarkdownStyle: can interleave with clone B's, so A's parse:/formatAttributedString: runs against B's parserId/style — producing wrong markdown ranges or wrong styling for that frame.
With the old fresh-instance-per-call code this interleaving was impossible.
The trigger (concurrent measure of the same family) is uncommon and the result is transient, not a crash — hence PLAUSIBLE rather than CONFIRMED.

So I'd like to confirm that decorator measurement is single-threaded per family; if so, this is a non-issue and the fix is clean as-is.

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