From f9e3a852a0f173e6b1d05866187cadf11e1e281f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 25 Jun 2026 15:21:15 +0200 Subject: [PATCH 1/2] fix(ios): persist RCTMarkdownUtils across shadow node clones to prevent AppHang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- apple/MarkdownTextInputDecoratorShadowNode.h | 6 ++++++ apple/MarkdownTextInputDecoratorShadowNode.mm | 20 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apple/MarkdownTextInputDecoratorShadowNode.h b/apple/MarkdownTextInputDecoratorShadowNode.h index c2f19b41..b821ab2d 100644 --- a/apple/MarkdownTextInputDecoratorShadowNode.h +++ b/apple/MarkdownTextInputDecoratorShadowNode.h @@ -48,6 +48,12 @@ class JSI_EXPORT MarkdownTextInputDecoratorShadowNode final YGMeasureMode heightMode); static YogaLayoutableShadowNode & shadowNodeFromContext(YGNodeConstRef yogaNode); + + // Persisted RCTMarkdownUtils instance shared across shadow node clones so + // that MarkdownParser's one-entry memo cache (keyed on text + parserId) + // survives repeated Yoga measure callbacks instead of being discarded on + // every call to applyMarkdownFormattingToTextInputState. + mutable std::shared_ptr markdownUtils_; }; } // namespace react diff --git a/apple/MarkdownTextInputDecoratorShadowNode.mm b/apple/MarkdownTextInputDecoratorShadowNode.mm index 43278241..fd0baf45 100644 --- a/apple/MarkdownTextInputDecoratorShadowNode.mm +++ b/apple/MarkdownTextInputDecoratorShadowNode.mm @@ -32,6 +32,13 @@ ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment) : ConcreteViewShadowNode(sourceShadowNode, fragment) { + // Carry the persisted RCTMarkdownUtils over from the source node so the + // MarkdownParser memo cache survives the frequent cloning that happens + // during layout and re-render cycles. + const auto &source = + static_cast(sourceShadowNode); + markdownUtils_ = source.markdownUtils_; + initialize(); makeChildNodeMutable(); @@ -182,10 +189,19 @@ const auto defaultNSTextAttributes = RCTNSTextAttributesFromTextAttributes(defaultTextAttributes); - // this can possibly be optimized + // 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( + (__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]]; From afe24e78f162b8f3055b2e2fd99a2ccbf4966612 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sat, 27 Jun 2026 17:39:44 +0200 Subject: [PATCH 2/2] fix(ios): apply markdown style/parserId + parse/format atomically Addresses review feedback: the persisted RCTMarkdownUtils is shared across shadow node clones, and Fabric runs commits/layout optimistically on multiple threads (ShadowTree::tryCommit performs lock-free layoutIfNeeded between a shared-lock read and a unique-lock CAS, with a retry loop). Two concurrent commits cloning from the same ancestor share one RCTMarkdownUtils, so the previous separate setMarkdownStyle:/setParserId: calls could interleave with another thread's parse/format and apply the wrong parserId/style for a frame. Add an applyMarkdownFormatting:...markdownStyle:parserId: overload that sets both properties and runs parse+format under a single @synchronized(self) block (recursive, so it nests cleanly with MarkdownParser's own lock in parse:). The shadow node measure path now uses this atomic method. The view-layer caller uses a separate instance on the main thread and is unaffected. --- apple/MarkdownTextInputDecoratorShadowNode.mm | 13 +++++++++---- apple/RCTMarkdownUtils.h | 9 +++++++++ apple/RCTMarkdownUtils.mm | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apple/MarkdownTextInputDecoratorShadowNode.mm b/apple/MarkdownTextInputDecoratorShadowNode.mm index fd0baf45..57c88dd0 100644 --- a/apple/MarkdownTextInputDecoratorShadowNode.mm +++ b/apple/MarkdownTextInputDecoratorShadowNode.mm @@ -202,8 +202,7 @@ RCTMarkdownStyle *markdownStyle = [[RCTMarkdownStyle alloc] initWithStruct:decoratorProps.markdownStyle]; - [utils setMarkdownStyle:markdownStyle]; - [utils setParserId:[NSNumber numberWithInt:decoratorProps.parserId]]; + NSNumber *parserId = [NSNumber numberWithInt:decoratorProps.parserId]; // convert the attibuted string stored in state to // NSAttributedString @@ -244,7 +243,10 @@ // apply markdown NSMutableAttributedString *newString = [nsAttributedString mutableCopy]; - [utils applyMarkdownFormatting:newString withDefaultTextAttributes:defaultNSTextAttributes]; + [utils applyMarkdownFormatting:newString + withDefaultTextAttributes:defaultNSTextAttributes + markdownStyle:markdownStyle + parserId:parserId]; // create a clone of the old TextInputState and update the // attributed string box to point to the string with markdown @@ -256,7 +258,10 @@ // apply markdown NSMutableAttributedString *newString = [nsAttributedString mutableCopy]; - [utils applyMarkdownFormatting:newString withDefaultTextAttributes:defaultNSTextAttributes]; + [utils applyMarkdownFormatting:newString + withDefaultTextAttributes:defaultNSTextAttributes + markdownStyle:markdownStyle + parserId:parserId]; // create a clone of the old TextInputState and update the // attributed string box to point to the string with markdown diff --git a/apple/RCTMarkdownUtils.h b/apple/RCTMarkdownUtils.h index 12fb1ba9..9b290783 100644 --- a/apple/RCTMarkdownUtils.h +++ b/apple/RCTMarkdownUtils.h @@ -11,6 +11,15 @@ NS_ASSUME_NONNULL_BEGIN - (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedString withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes; +// Atomically sets the style/parser and applies formatting under a single lock. +// Use this from the shadow node measure path, where one RCTMarkdownUtils +// instance is shared across shadow node clones and may be accessed from +// concurrent Fabric commits/layout passes. +- (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedString + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes + markdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle + parserId:(nonnull NSNumber *)parserId; + @end NS_ASSUME_NONNULL_END diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index 71e14702..b2919d55 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -34,4 +34,22 @@ - (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedS withMarkdownStyle:_markdownStyle]; } +- (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedString + withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes + markdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle + parserId:(nonnull NSNumber *)parserId +{ + // Keep the style/parserId assignment and the parse+format together under a + // single lock. The shadow node shares one instance across clones, and Fabric + // runs commits/layout optimistically on multiple threads, so without this the + // setters could interleave with another thread's parse/format and apply the + // wrong parserId/style for a frame. `@synchronized` is recursive, so nesting + // with `MarkdownParser`'s own `@synchronized(self)` in `parse:` is safe. + @synchronized (self) { + _markdownStyle = markdownStyle; + _parserId = parserId; + [self applyMarkdownFormatting:attributedString withDefaultTextAttributes:defaultTextAttributes]; + } +} + @end