Skip to content

[Due for payment 2026-09-09] [$250] [Sentry: APP-EF1] HybridApp iOS App Hang in MarkdownTextInput Yoga layout (createStringFromUtf8) #93831

Closed

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-EF1

Impact (snapshot at filing)

  • Users (total since first seen): 1,082
  • Events: 1,506
  • Users (last 14d, 9.4.x releases): 481
  • First seen: 2026-06-03
  • Last seen: ongoing
  • Platform: iOS HybridApp
  • App version(s): 9.4.5–9.4.9
  • Affected route(s): various (layout pass during text input render)
  • Mechanism: AppHang

Stack trace (top frames, first-party only)

facebook::react::MarkdownTextInputDecoratorShadowNode::yogaNodeMeasureCallbackConnector
facebook::react::MarkdownTextInputDecoratorShadowNode::measureContent
facebook::react::MarkdownTextInputDecoratorShadowNode::applyMarkdownFormattingToTextInputState
-[RCTMarkdownUtils applyMarkdownFormatting:withDefaultTextAttributes:]
-[MarkdownParser parse:withParserId:]
facebook::jsi::WithRuntimeDecorator<T>::createStringFromUtf8

Suspected cause

Markdown formatting during Yoga layout measure blocks the main thread for 2+ seconds, triggering an iOS watchdog App Hang.

Reproduction

Unknown — see Sentry events linked from the Sentry issue.

Related

  • Sibling Sentry IDs (if any): APP-EG9 (similar createStringFromUtf8 hang)
  • Prior GH issues: none
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @situchan

Activity

  1. self-assigned this
    on Jun 17, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    Help WantedApply this label when an issue is open to proposals by contributors
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jun 17, 2026
  3. melvin-bot commented on Jun 17, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @abzokhattab (External)

  4. melvin-bot commented on Jun 17, 2026

    @melvin-bot
  5. godzillaxbox19982014-lgtm commented on Jun 17, 2026

    @godzillaxbox19982014-lgtm

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    [$250] [Sentry: APP-EF1] HybridApp iOS App Hang in MarkdownTextInput Yoga layout (createStringFromUtf8)

    What is the root cause of that problem?

    I traced this to the path that handles APP, EF1, HybridApp:

    • src/libs/HybridApp.ts:118 (closeReactNativeApp): closeReactNativeApp({shouldSetNVP: false, isTrackingGPS: false});
    • src/libs/HybridApp.ts:95 (if): if (!credentials?.autoGeneratedLogin || !credentials?.autoGeneratedPassword || account?.requiresTwoFactorAuth === undefined || account?.needsTwoFactorAuthSetup === undefined) {
    • src/libs/HybridApp.ts:115 (if): if (tryNewDot !== undefined) {
    • The local evidence matches this recurring failure shape: native callback assumes current policy/report still exists. Here, src/libs/HybridApp.ts:118 and src/libs/HybridApp.ts:95 do not agree on the same APP, EF1, HybridApp value, so the user-visible path can show a missing or stale state even though nearby data already has the needed context.

    What changes do you think we should make in order to solve the problem?

    • Update src/libs/HybridApp.ts:118 to derive APP, EF1, HybridApp from the matched app state path that owns this flow before the value is consumed.
    • Carry the same normalized value through src/libs/HybridApp.ts:95; that follows the safer family move to add the guard at the ownership boundary, not only at render, so the write/optimistic/read paths agree instead of only changing the visible component.
    • Keep the change inside the Sentry Crash, Null/Undefined, Native Hybrid surface and avoid changing do not hide crashes with blanket catch, native platform differences.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    1. Add or update a regression in tests/unit/**/*.ts for the exact issue flow: [$250] [Sentry: APP-EF1] HybridApp iOS App Hang in MarkdownTextInput Yoga layout (createStringFromUtf8).
    2. Cover the counter-case near src/libs/HybridApp.ts:118 so valid existing APP, EF1, HybridApp behavior still works.
    3. Manually repeat the issue steps after the patch and confirm the visible state/action is no longer stale.

    What alternative solutions did you explore? (Optional)

    • I would not add a broad new condition outside these referenced paths; the safer fix is to keep APP, EF1, HybridApp consistent at the first read/write boundary shown above.
  6. MelvinBot commented on Jun 17, 2026

    @MelvinBot
    Contributor

    Issue Analysis

    Root Cause (high confidence): This App Hang is not in the App repo — it's in the @expensify/react-native-live-markdown@0.1.327 native iOS code. On every Yoga layout/measure pass, the markdown decorator synchronously re-parses the entire input text on the main thread via JSI. For a large composer value (e.g. a big paste), that parse exceeds iOS's ~2s watchdog and the OS kills the app as an AppHang.

    The reported stack maps line-for-line to the library:

    Stack frame Library location
    yogaNodeMeasureCallbackConnector MarkdownTextInputDecoratorShadowNode.mm:256-299
    measureContent MarkdownTextInputDecoratorShadowNode.mm:110-131
    applyMarkdownFormattingToTextInputState MarkdownTextInputDecoratorShadowNode.mm:164-253
    RCTMarkdownUtils applyMarkdownFormatting → MarkdownParser parse → createStringFromUtf8 RCTMarkdownUtils.mm:20-35

    Two things make it expensive on every measure:

    1. measureContent calls applyMarkdownFormattingToTextInputState unconditionally on each measure pass (...ShadowNode.mm:126) — Yoga can call this many times per layout.
    2. A fresh RCTMarkdownUtils / MarkdownParser / MarkdownFormatter is allocated and the full string re-parsed every time, with no caching keyed on the unchanged text (...ShadowNode.mm:186-189, RCTMarkdownUtils.mm:29). The author even flagged this: // this can possibly be optimized.

    MarkdownParser parse runs a JSI/Hermes-backed parser (hence createStringFromUtf8), so the cost grows with text length and runs on the main thread during layout.

    ⚠️ This fix is outside the App repo. The root cause lives in Expensify/react-native-live-markdown (a separate package), so I can't propose an App-side code change here. The existing proposal above pointing at src/libs/HybridApp.ts is unrelated — that file has nothing to do with the markdown measure path. This should be escalated to an engineer who can work in the live-markdown repo.

    Recommended fix (in react-native-live-markdown):

    • Cache the parse result keyed on {string, parserId, markdownStyle} so an unchanged text isn't re-parsed on every measure pass / Yoga measure call.
    • Skip re-applying markdown in measureContent when the attributed string content hasn't changed since the last formatting pass.
    • Once a fixed version is published, bump @expensify/react-native-live-markdown in App/package.json (currently 0.1.327).
    Investigation details & alternatives considered
    • Trigger (unconfirmed): The crash mechanism is confirmed from code+stack, but the specific input that pushes a single parse past 2s isn't proven from logs. Most likely a large text value in a markdown-enabled input (composer / big paste). A log-investigator / Sentry breadcrumb pass on APP-EF1 events could confirm the route and approximate text size, but isn't required to identify the root cause.
    • Sibling APP-EG9 (createStringFromUtf8 hang) is almost certainly the same library, different entry point — fixing the parse caching here should address both.
    • Alternative explanation considered: a genuine infinite/retain loop in measure. Ruled out — the frames show a single linear parse→format chain, not recursion; the hang scales with text size, consistent with O(n) parsing on the main thread, not a loop.
    • Why not fix in App: App only consumes MarkdownTextInput from the library; the measure callback, parser allocation, and lack of caching are all inside the package's native code. There is no App-level hook to make this parse async or cached.

    Next Steps: This requires a change in Expensify/react-native-live-markdown, not App — please escalate to an engineer with access to that repo. Reply with @MelvinBot <feedback> to refine this analysis.

  7. yusufdeveloper2903 commented on Jun 17, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

    What is the root cause of that problem?

    The stack trace is correct about where the time is spent, but the conclusion that this is "outside the App repo / not fixable in App" is wrong. The parser that the native measure path executes is our own worklet, registered from App code, so the hang is fully addressable here.

    Walking the chain:

    1. On every Yoga measure pass, the library re-applies markdown synchronously inside measureContent:

      • MarkdownTextInputDecoratorShadowNode.mm#L110-L131 calls applyMarkdownFormattingToTextInputState unconditionally at L126.
      • That allocates a fresh parser and re-parses the entire string with no caching keyed on unchanged text — the author even flagged // this can possibly be optimized (...ShadowNode.mm#L164-L189).
      • [_markdownParser parse:withParserId:] (RCTMarkdownUtils.mm#L29) does not run any built-in C++ parser — it dispatches into the JS worklet that was registered for parserId, hence the createStringFromUtf8 / Hermes frames.
    2. That registered worklet is App code: RNMarkdownTextInput.tsx#L58-L69 builds parserWorklet, which (for the composer, where no parser prop is passed) defaults to parseExpensiMarkWithShortMentions → ParsingUtils.ts#L57-L62, i.e. a full ExpensiMark replace() tokenization of the whole input.

    3. Why it hangs: Yoga calls the measure callback multiple times per layout pass, and the decorator re-runs the full parse on each call with no memoization of the unchanged string. On iOS HybridApp the initial/synchronous layout of a composer pre-filled with a sizeable draft runs on the main thread, so N × (single ExpensiMark parse) stacks up past iOS's ~2s watchdog → AppHang. The cost is multiplicative in the number of measure calls, not a single O(n) parse — which is exactly why moderate inputs (well under the library's 4000-char cut-off at parseExpensiMark.ts#L242) still hang.

    So: native measureContent fires the App's parserWorklet many times per layout → each call re-parses the full string with no caching → on a main-thread layout the accumulated parse time trips the watchdog → AppHang (createStringFromUtf8).

    Note the existing App "optimization" does not help here: the comment at RNMarkdownTextInput.tsx#L98-L101 claims maxLength "avoid[s] parsing markdown for large text", but maxLength only caps typed length (defaulted to CONST.MAX_COMMENT_LENGTH + 1 = 15001) — it never short-circuits the measure-time parse.

    What changes do you think we should make in order to solve the problem?

    Collapse the repeated parses into one by memoizing parserWorklet on its input string, inside the worklet that is already registered — no library change, no native code.

    parserWorklet already closes over a Reanimated shared value (mentionsSharedVal, via useSharedValue already imported at RNMarkdownTextInput.tsx#L6 and used at #L34). Add a second useSharedValue cache holding the last {text, ranges} and have the worklet return the cached ranges when text is unchanged:

    const parseCache = useSharedValue<{text: string; ranges: MarkdownRange[]} | null>(null);
    
    const parserWorklet = useCallback((text: string) => {
        'worklet';
        if (parser) {
            return parser(text);
        }
        const cached = parseCache.get();
        if (cached && cached.text === text) {
            return cached.ranges;
        }
        const ranges = parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
        parseCache.set({text, ranges});
        return ranges;
    }, [currentUserMentions, mentionsSharedVal, parser, parseCache]);

    Because the worklet runs on the live-markdown runtime, the shared-value read/write is synchronous and coherent across the burst of measure calls in a single layout pass, so the N parses per layout collapse to 1 per unique string — directly removing the multiplier that causes the watchdog timeout. This is the same "cache the parse result keyed on the (unchanged) text" idea noted as the upstream fix, but done at the layer the App already owns.

    I'd also fold the stale maxLength "optimization" comment into a real guard: short-circuit parserWorklet to [] for very long inputs (reusing the exact early-return pattern already at parseExpensiMark.ts#L242) so a single oversized parse can never run on the main thread either.

    Minor changes/improvements (cache key including parserId/mentions, exact threshold) can be discussed in the PR phase.

    What alternative solutions did you explore? (Optional)

    Bump @expensify/react-native-live-markdown once caching is added natively in measureContent. That is the more permanent home for this, but it requires a coordinated library release + upgrade and still re-parses redundantly until then; the worklet-level memoization above is App-only, ships immediately, and fixes both this issue and the sibling createStringFromUtf8 hang (APP-EG9) since they share the same registered worklet.

  8. KJ21-ENG commented on Jun 17, 2026

    @KJ21-ENG
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-06-17 15:59:31 UTC.

    Proposal

    What is the root cause of that problem?

    The Sentry stack is in the iOS implementation of @expensify/react-native-live-markdown, which App ships as 0.1.327 / RNLiveMarkdown 0.1.327:

    App/package.json

    Lines 94 to 98 in 6ea10e7

    "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils",
    "@expensify/react-native-background-task": "file:./modules/background-task",
    "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app",
    "@expensify/react-native-live-markdown": "0.1.327",
    "@expensify/react-native-wallet": "0.1.11",

    App/ios/Podfile.lock

    Lines 3703 to 3706 in 6ea10e7

    - RNGoogleSignin (10.0.1):
    - GoogleSignIn (~> 7.0)
    - React-Core
    - RNLiveMarkdown (0.1.327):

    App routes markdown-enabled inputs through RNMarkdownTextInput, including the composer and BaseTextInput type="markdown" call sites. That wrapper passes the native package parser and defaults its native maxLength to CONST.MAX_COMMENT_LENGTH + 1, while CONST.MAX_COMMENT_LENGTH is 15,000.

    <AnimatedMarkdownTextInput
    allowFontScaling={false}
    textBreakStrategy="simple"
    keyboardAppearance={theme.colorScheme}
    parser={parserWorklet}
    ref={inputRef}
    formatSelection={toggleSelectionFormat}
    // eslint-disable-next-line react/forbid-component-props
    fsClass={forwardedFSClass}
    // eslint-disable-next-line
    {...props}
    /**
    * 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}
    onBlur={handleBlur}

    return (
    <RNMarkdownTextInput
    id={CONST.COMPOSER.NATIVE_ID}
    multiline
    autoComplete="off"
    placeholderTextColor={theme.placeholderText}
    ref={setTextInputRef}
    value={value}
    rejectResponderTermination={false}
    smartInsertDelete={false}
    textAlignVertical="center"
    style={[composerStyle, maxHeightStyle]}
    markdownStyle={markdownStyle}
    {...props}
    autoFocus={isInLandscapeMode ? false : props.autoFocus}
    readOnly={isDisabled}
    onPaste={pasteFile}
    onClear={onClear}
    disableFullscreenUI
    />

    const InputComponentMap = new Map<InputType, InputComponentType>([
    ['default', RNTextInput as InputComponentType],
    ['mask', RNMaskedTextInput as InputComponentType],
    ['markdown', RNMarkdownTextInput as InputComponentType],
    ]);

    App/src/CONST/index.ts

    Lines 5090 to 5094 in 6ea10e7

    // 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,

    On iOS, the package replaces the child TextInput's Yoga measure callback, and each measureContent() call applies markdown before returning the measured size. Inside that measure path it allocates a new RCTMarkdownUtils/MarkdownParser, so MarkdownParser's one-entry _prevText cache is discarded across repeated Yoga measurements. MarkdownParser.parse() then creates a JSI string from the whole native string before the JS parser's MAX_PARSABLE_LENGTH = 4000 guard can return an empty range list. That is why a long or repeatedly measured markdown input can block the iOS layout pass at createStringFromUtf8, matching the reported stack.
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/apple/MarkdownTextInputDecoratorShadowNode.mm#L65-L93
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/apple/MarkdownTextInputDecoratorShadowNode.mm#L110-L127
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/apple/MarkdownTextInputDecoratorShadowNode.mm#L185-L190
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/apple/MarkdownTextInputDecoratorShadowNode.mm#L229-L231
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/apple/RCTMarkdownUtils.mm#L10-L15
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/apple/MarkdownParser.mm#L14-L32
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/src/parseExpensiMark.ts#L26-L27
    https://github.com/Expensify/react-native-live-markdown/blob/c54eb86e18a5b14d0839e47b4b9665c1ed570aad/src/parseExpensiMark.ts#L241-L244

    What changes do you think we should make in order to solve the problem?

    1. Patch @expensify/react-native-live-markdown on iOS so MarkdownParser.parse() applies the same parsable-length budget natively before jsi::String::createFromUtf8(). For over-budget input, return the empty ranges immediately (or cache only the over-budget decision without retaining the full string) so Yoga can measure plain text without entering JSI or the worklet parser.
    2. Stop losing the native parser cache on every layout pass. Either keep a reusable RCTMarkdownUtils/MarkdownParser for the decorator shadow node, or add a package-level native range cache keyed by text + parser id. The style/default attributes can still be applied after the cached ranges are returned, so markdown rendering for normal composer text is unchanged.
    3. Consume the package fix in App by either bumping @expensify/react-native-live-markdown and the iOS pod lock, or by adding a temporary App patch-package entry plus patches/.../details.md if a package release is not ready. Add an iOS regression fixture around MarkdownParser/RCTMarkdownUtils proving a string longer than 4,000 characters returns empty ranges before the worklet/JSI parse path is invoked.

    Scope boundaries: files touched should be limited to the live-markdown iOS parser/utility/shadow-node code and the App dependency/patch metadata (package.json, package-lock.json, ios/Podfile.lock, and/or patches/@expensify/react-native-live-markdown). Platforms affected: iOS native / HybridApp markdown TextInputs only. Code paths not touched: backend APIs, comment length validation, Android/web markdown rendering, and normal <=4,000-character markdown highlighting.

    What alternative solutions did you explore? (Optional)

    Lowering RNMarkdownTextInput's maxLength or CONST.MAX_COMMENT_LENGTH would change valid comment/task/description limits and still would not protect controlled/default native values that are already mounted. Disabling live markdown on HybridApp would avoid the hang but regresses all markdown-enabled inputs. Moving all markdown formatting out of measurement is broader and risks incorrect initial auto-grow sizing; the native length guard plus persistent parser cache targets the exact Sentry stack with less behavior change.

  9. github-actions commented on Jun 17, 2026

    @github-actions
    Contributor

    ⚠️ @KJ21-ENG Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).

  10. 93 remaining items

  11. situchan commented on Sep 3, 2026

    @situchan
    Contributor

    Contributor+ Checklist:

    • [Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.

      Link to the comment on the PR:
      Link to the comment on the Issue:

    • [Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.

      Link to discussion:

    • [Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.

    Regression Test Proposal

    Not reliably reproducible crash reported by Sentry so we can skip regression test

  12. melvin-bot commented on Sep 5, 2026

    @melvin-bot

    @abzokhattab Whoops! This issue is 2 days overdue. Let's get this updated quick!

  13. melvin-bot commented on Sep 7, 2026

    @melvin-bot

    @abzokhattab Eep! 4 days overdue now. Issues have feelings too...

  14. melvin-bot commented on Sep 9, 2026

    @melvin-bot

    @abzokhattab Still overdue 6 days?! Let's take care of this!

  15. abzokhattab commented on Sep 11, 2026

    @abzokhattab
    Contributor

    @situchan can you please check if the issue no longer occurring so that Matt can process the payment and close the issue

  16. situchan commented on Sep 11, 2026

    @situchan
    Contributor

    The fix landed on 9.4.65-3.
    Looks like no longer happening after 9.4.64 so I think we're good to handle payment and close.

    Image

    cc: @mallenexpensify

  17. melvin-bot commented on Sep 14, 2026

    @melvin-bot

    @situchan Whoops! This issue is 2 days overdue. Let's get this updated quick!

  18. mallenexpensify commented on Sep 15, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @abzokhattab due $250 via NewDot
    Contributor+: @situchan due $250 via NewDot

    I think/hope that's correct.

  19. laurenreidexpensify commented on Sep 28, 2026

    @laurenreidexpensify
    Contributor

    Approved $250 @situchan New Expensify

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions