fix(iOS): main-thread deadlock in markdown measurement under locked ShadowTree commits#582
Open
eszlamczyk wants to merge 2 commits into
Open
fix(iOS): main-thread deadlock in markdown measurement under locked ShadowTree commits#582eszlamczyk wants to merge 2 commits into
eszlamczyk wants to merge 2 commits into
Conversation
…hadowTree commits
Collaborator
Author
|
Confirmed via maestro tests - no regression |
There was a problem hiding this comment.
Pull request overview
Eliminates an iOS main-thread deadlock risk in Fabric/Yoga measureContent by making markdown measurement main-thread-free and view-free, aligning the measurement pipeline with React Native’s off-main text measurement approach.
Changes:
- Replaces
dispatch_sync(main)measurement with view-free TextKit-based measurement forEnrichedMarkdownTextand segmentedEnrichedMarkdown, driven byLayoutContext(font scale + point scale factor). - Adds a lock-free
ENRMAtomicSizemailbox so streaming measurement can reuse the last committed size without readingview.boundsor blocking. - Extracts view-free table and math block measurers (
+measureHeightForTableNode:...,+measureHeightForLatex:...) to keep rendered and measured heights aligned without instantiating views.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/react-native-enriched-markdown/ios/views/TableContainerView.m | Refactors table cell rendering/layout into shared statics and adds view-free table height measurement. |
| packages/react-native-enriched-markdown/ios/views/TableContainerView.h | Exposes a view-free table measurement API for shadow-node measurement. |
| packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m | Adds a view-free math block height measurement method mirroring the view path. |
| packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h | Documents/exposes view-free math measurement API for shadow-node measurement. |
| packages/react-native-enriched-markdown/ios/utils/ENRMTextViewSetup.h | Extracts shared “finalize measured size” logic to reuse across view-backed and view-free measurement. |
| packages/react-native-enriched-markdown/ios/utils/ENRMAtomicSize.h | Introduces lock-free atomic mailbox used to publish/read last committed layout size cross-thread. |
| packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h | Updates measurement contract to forbid main-thread waits; switches font scale source to LayoutContext. |
| packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h | Implements view-free measurement pipelines for both markdown components (TextKit + segment walkers). |
| packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.mm | Switches measureContent to view-free measurement and passes LayoutContext scale factors. |
| packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.h | Removes mock-view setup API now that measurement is view-free. |
| packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm | Switches segmented markdown measureContent to view-free segment measurement. |
| packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.h | Removes mock-view setup API now that measurement is view-free. |
| packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm | Publishes last committed size in updateLayoutMetrics: and exposes it for streaming measurement. |
| packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.h | Declares thread-safe lastCommittedLayoutSize API. |
| packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm | Publishes last committed size in updateLayoutMetrics: and exposes it for streaming measurement. |
| packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h | Declares thread-safe lastCommittedLayoutSize API. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
eszlamczyk
marked this pull request as ready for review
July 23, 2026 07:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What/Why?
Fixes #550 - an iOS, physical-device-only deadlock that froze the app and got it killed by the watchdog (
0x8BADF00D); the reporter's production app logged ~19 such kills per day.The mechanism
(confirmed from symbolicated production
.ipsreports and an instrumented RN-from-source build):measureContentuseddispatch_syncto the main thread - on every measure for the Dynamic Type font scale, on streaming ticks forview.bounds, and on cache misses to render/measure through a throwaway mock view.preventShadowTreeCommitExhaustionflag (releaseLevel: experimental) re-runs an entire commit - layout included - while holdingrevisionMutexRecursive_after 3 failed optimistic commits.DISABLE_COMMIT_PAUSING_MECHANISM) commits from the main thread every frame: it both causes those consecutive commit failures during slow measured layouts and is the thread that then blocks on the mutex.dispatch_sync(main)completes the cycle; JS waits for main, main waits for the mutex JS holds. Frozen forever.The old code documented its own assumption (
safe because RN never synchronously joins the layout queue from main) - RN's flag broke that assumption without breaking any contract. The only robust fix is structural: nothing in themeasurement path may wait on the main thread, ever (Yoga's
measureContentmakes no promises about calling context or held locks).The fix
Measurement is now main-thread-free by construction, mirroring RN core's
RCTTextLayoutManagerand this library's own AndroidMeasurementStore:LayoutContext::fontSizeMultiplier(the parameter RN already delivers intomeasureContent, same sourceParagraphShadowNodeuses; RN refreshes it and re-runs layout on Dynamic Type changes). Pixel rounding usesLayoutContext::pointScaleFactor-RCTScreenScale()dispatch_syncs to main on first use.std::atomic<uint64_t>mailbox (ENRMAtomicSize) fromupdateLayoutMetrics:; the measure path reads it wait-free instead of syncingview.bounds.ENRMViewFreeMeasurement.h): parse -> the SAME renderer the visible view uses (ENRMRenderASTNodes/ENRMRenderSegmentsFromAST, so measured layout ≡ rendered layout) -> a fresh per-callNSTextStorage/NSLayoutManager/NSTextContainerstack on the calling thread (TextKit is thread-confined, not main-only - the RCTTextLayoutManager / AsyncDisplayKit pattern). The segmentedEnrichedMarkdownview gets static, view-free table and math measurers (+[TableContainerView measureHeightForTableNode:…],+[ENRMMathContainerView measureHeightForLatex:…]) extracted from the same code the views run, so heights cannot drift. Mock-view measurement is deleted.@autoreleasepoolper measure: the old main-thread path silently relied on the main runloop draining autoreleased objects; the JS/layout thread has no such drain, and without explicit pools sustained re-measurement accumulates until jetsam (caught during device verification).thread_localFFI error slot, immutableOnceLock/LazyLockstatics, per-call renderer caches.Independent bonus
The
dispatch_syncdesign also serialized all markdown measurement onto the main thread (30–80 ms per node on device), visibly stuttering animations and scroll during streaming - the second complaint in #550. Measurement now runs off main entirely.Known limitation
EnrichedMarkdownTextInput'smeasureContentstill measures live editableUITextViewstate throughdispatch_syncand needs its own strategy - tracked separately. The threading contract inShadowMeasurementUtils.hdocuments it as the one remaining violation.Testing
MAX_COMMIT_ATTEMPTS_BEFORE_LOCKING = 1in instrumented RN, width jitter forcing a full re-measure of every card every 900 ms, 8 streaming heads at 16 ms. Deadlocked within seconds, reproducibly. Paused-debugger stacks and the watchdog.ips(0x8BADF00D,EXC_CRASH SIGKILL) match the 20 production reports frame for frame:__ulock_wait -> _dispatch_sync_f_slow -> ENRMFontScaleForMeasurement -> ENRMMeasureMarkdownContent -> measureContent -> yoga -> ShadowTree::tryCommit -> ShadowTree::commit(locked fallback)__psynch_mutexwait → recursive_mutex::lock → ShadowTree::tryCommit → reanimated::…::commitUpdatesLOCKED FALLBACK ENGAGEDpaired withDONE, zero sync probes (nothing left to probe), no freeze, no watchdog, no jetsam. Memory flat (~415 MB footprint on simulator over multi-minute profiling; CoreAnimation/malloc categories attributed withfootprint/heap).PR Checklist