fix(ios): persist RCTMarkdownUtils across shadow node clones to prevent AppHang#758
Conversation
…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.
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
|
Works for me ios.mov |
| // 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]]; |
There was a problem hiding this comment.
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.
Details
On every Yoga measure pass,
applyMarkdownFormattingToTextInputStatewas allocating a freshRCTMarkdownUtils/MarkdownParserinstance.MarkdownParserhas 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-parseexceeded iOS's ~2 s watchdog → AppHang.Fix: Add a
mutable std::shared_ptr<void> markdownUtils_member toMarkdownTextInputDecoratorShadowNode:applyMarkdownFormattingToTextInputState.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
Screen.Recording.2026-06-25.at.16.05.50.mov
Screen.Recording.2026-06-25.at.16.50.23.mov