Skip to content

[Due for payment 2026-08-10] [$250] [Sentry: APP-GVV] IndexSizeError restoring markdown cursor on IOU description step #95347

Closed

Description

@mountiny

Sentry

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

Impact (snapshot at filing)

  • Users (total since first seen): 1
  • Events: 1
  • Users (last 14d): 1
  • First seen: 2026-07-02
  • Last seen: 2026-07-02
  • Platform: web
  • App version(s): 9.4.25-2
  • Affected route(s) (parameterized): /create/submit/description/:action/:iouType/:transactionID
  • Mechanism: auto.browser.global_handlers.onerror

Stack trace (top frames, first-party only)

IOURequestStepDescription.tsx:172 (t16) → inputRef.current?.focus()
useDiscardChangesConfirmation/index.ts:80 → onCancel?.()
MarkdownTextInput.web.js:411 (handleFocus) → setCursorPosition
cursorUtils.js:27 (setCursorPosition) → range.setStart

Suspected cause

useDiscardChangesConfirmation onCancel calls inputRef.focus() which triggers live-markdown setCursorPosition with stale DOM offsets after the description field content changed.

Reproduction

  1. Open expense description step in create/submit flow.
  2. Edit description, then cancel/discard changes (triggering discard confirmation).
  3. Observe IndexSizeError on Range.setStart in console.

Related

  • Sibling Sentry IDs (if any): none
  • Prior GH issues: none
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. melvin-bot commented on Jul 9, 2026

    @melvin-bot

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

  2. melvin-bot commented on Jul 10, 2026

    @melvin-bot

    Huh... This is 4 days overdue. Who can take care of this?

  3. melvin-bot commented on Jul 14, 2026

    @melvin-bot

    8 days overdue is a lot. Should this be a Weekly issue? If so, feel free to change it!

  4. self-assigned this
    on Jul 16, 2026
  5. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jul 16, 2026
  6. added
    Help WantedApply this label when an issue is open to proposals by contributors
    and removed on Jul 16, 2026
  7. melvin-bot commented on Jul 16, 2026

    @melvin-bot

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

  8. wildan-m commented on Jul 16, 2026

    @wildan-m
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-07-16 14:11:56 UTC.

    Proposal

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

    On web, an uncaught IndexSizeError is thrown from Range.setStart while restoring the markdown cursor on the IOU description step. It is reproduced by opening the expense description step, editing the description, then triggering the discard-changes confirmation and cancelling it. The error is captured by Sentry (APP-GVV) via the global onerror handler.

    What is the root cause of that problem?

    When the discard-changes confirmation is cancelled, the description step re-focuses the input:

    useDiscardChangesConfirmation({
    onCancel: () => {
    inputRef.current?.focus();
    },
    getHasUnsavedChanges: () => {
    if (isSaved) {

    Focusing the markdown input runs handleFocus in @expensify/react-native-live-markdown, which restores the previous caret by calling setCursorPosition with the last remembered selection. setCursorPosition computes the DOM offset from the markdown tree metadata and passes it straight into Range.setStart/Range.setEnd:

    range.setStart((startElement.childNodes[0] || startElement), start - startTreeNode.start) (node_modules/@expensify/react-native-live-markdown/src/web/utils/cursorUtils.ts, the setStart/setEnd lines inside setCursorPosition).

    The starting index is clamped to target.tree.length, but the resulting offset is only validated against the tree node's length, not against the actual length of the resolved DOM node (startElement.childNodes[0]). When the tree metadata is momentarily out of sync with the rendered DOM text node — which happens here because the input is blurred while the discard modal is open and then programmatically re-focused — the computed offset can exceed the real text node length. Range.setStart/Range.setEnd throw IndexSizeError whenever the offset is greater than the node length, and nothing catches it, so it surfaces as the crash.

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

    Make the caret restoration in setCursorPosition (in @expensify/react-native-live-markdown, src/web/utils/cursorUtils.ts) resilient to a transient tree/DOM desync by clamping each offset to the bounds of the DOM node it is applied to, before calling Range.setStart/Range.setEnd:

    • Resolve the container node first (startElement.childNodes[0] || startElement for the start, and the equivalent for the end).
    • Compute the offset as today (start - startTreeNode.start), then clamp it to that node's maximum valid offset: for a text node that is its .length, and for an element node that is its childNodes.length.
    • Pass the clamped offset into Range.setStart/Range.setEnd.

    With the offset clamped to the node's real bounds, the range API can no longer receive an out-of-range offset, so the IndexSizeError cannot be thrown. In the rare desync case the caret lands at the end of the node instead of crashing, and the next parse pass re-syncs it to the correct position — a graceful degradation instead of an app-level error.

    Because this fix lives in the bundled @expensify/react-native-live-markdown library, it should be applied to App as a patch-package patch against version 0.1.329 (and upstreamed to the library repo), matching how other library-level fixes ship in App. The patch needs to cover the library's compiled lib/module and lib/commonjs builds in addition to src, because the web bundle resolves the package's module entry point rather than its source — patching src alone would have no effect on web, which is where this error occurs.

    Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/95347-clamp-markdown-cursor-offset?expand=1

    What alternative solutions did you explore? (Optional)

    Wrapping the range.setStart/range.setEnd calls in setCursorPosition in a try/catch that swallows the IndexSizeError and returns early. This also stops the crash, but it silently abandons cursor restoration on any failure rather than placing the caret at the nearest valid position, so clamping the offset is preferred as it keeps the caret behaviour correct while still being crash-safe.

  9. samranahm commented on Jul 16, 2026

    @samranahm
    Contributor

    Proposal

    What is the root cause of that problem?

    When a user edits the description in the IOU description step and then cancels/discards the changes, the useDiscardChangesConfirmation hook's onCancel callback is triggered. This callback first reverts the content of the MarkdownTextInput component to its original state and then explicitly calls inputRef.current?.focus() on the input field, as indicated by the stack trace originating from IOURequestStepDescription.tsx:

    https://github.com/Expensify/App/blob/2746256aef0062a22797455e96b51ca91e538b6e/src/pages/iou/steps/IOURequestStepDescription.tsx#L172

    The MarkdownTextInput component, specifically its web implementation in MarkdownTextInput.web.js, has a handleFocus method that attempts to restore the cursor position using setCursorPosition from cursorUtils.js:

    https://github.com/Expensify/App/blob/2746256aef0062a22797455e96b51ca91e538b6e/src/components/MarkdownTextInput/MarkdownTextInput.web.js#L411

    The root cause of the IndexSizeError is that setCursorPosition is called with stale cursor start and end indices that were saved when the input contained the edited content. After the content is reverted to its original (potentially shorter) state, these saved indices become out of bounds for the current input value. When setCursorPosition attempts to use these invalid indices with range.setStart (or input.setSelectionRange), it throws an IndexSizeError:

    https://github.com/Expensify/App/blob/2746256aef0062a22797455e96b51ca91e538b6e/src/libs/cursorUtils.js#L27

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

    We should modify the setCursorPosition function in cursorUtils.js to ensure that the start and end indices of the selection range are always clamped within the valid bounds of the current input value's length. This will prevent IndexSizeError when attempting to set a cursor position that is out of bounds for the current text content.

    1. In cursorUtils.js, modify the setCursorPosition function to clamp the start and end values of the selection object to the current input.value.length.

      https://github.com/Expensify/App/blob/2746256aef0062a22797455e96b51ca91e538b6e/src/libs/cursorUtils.js#L27

      function setCursorPosition(input, selection) {
          if (!input) {
              return;
          }
      
          const textLength = input.value.length;
          const clampedStart = Math.min(selection.start, textLength);
          const clampedEnd = Math.min(selection.end, textLength);
      
          if (input.setSelectionRange) {
              input.setSelectionRange(clampedStart, clampedEnd);
          } else if (input.createTextRange) {
              const range = input.createTextRange();
              range.collapse(true);
              range.moveEnd('character', clampedEnd);
              range.moveStart('character', clampedStart);
              range.select();
          }
      }

      This change ensures that even if selection.start or selection.end are greater than the current text length (due to content being reverted), they will be adjusted to the maximum valid index, preventing the IndexSizeError.

    What alternative solutions did you explore? (Optional)

    Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.

  10. 43 remaining items

  11. mallenexpensify commented on Aug 12, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @x-dev90 paid $250 via Upwork
    Contributor+: @suneox due $250 via NewDot

    @x-dev90 can you please accept the job below? Please reply here and tag me once you have.
    If you're not sent an offer in Upwork when you're assigned to an issue, please tag me. If an Upwork job exists, please submit a proposal.
    https://www.upwork.com/jobs/~022077755966792443403

    Guessing it's due $250. @mountiny , comment if you disagree.

    ⚠️ Looks like this issue was linked to a Deploy Blocker #97552

    and

    @x-dev90 @suneox unfortunately look slike this was a cache issue, so we can redo these prs

  12. x-dev90 commented on Aug 12, 2026

    @x-dev90
    Contributor

    Hi @mallenexpensify @mountiny, thanks for posting the payment summary.

    Before payment is finalized, I wanted to respectfully ask for a compensation reevaluation for this issue.

    The current summary lists $250 for the contributor. In this case, the fix required work in two repositories:

    The upstream react-native-live-markdown PR fixed the root crash by clamping/restoring markdown cursor range offsets safely inside the library.

    The App PR was not only a dependency version bump. It also applied the upstream fix in the App by updating the markdown package version and changing the IOU Description/Merchant refocus behavior to use focusComposerWithDelay(inputRef.current)(true) instead of immediately calling inputRef.current?.focus(). So the App-side PR included the integration work needed for the upstream fix to actually resolve the issue in NewDot.

    Since both PRs were authored by me and merged, could you please confirm whether the contributor compensation should be increased, i.e. $250 for the upstream library fix and $250 for the App integration/update?

    I understand compensation decisions are ultimately at Expensify’s discretion, and I’m happy to follow whatever process is correct. I just wanted to raise it here because the contributor docs say compensation reevaluation requests should be made in the GitHub issue where the job was posted.

    Thanks!

  13. mountiny commented on Aug 12, 2026

    @mountiny
    ContributorAuthor

    It is normal that the cost is 250 for scenario like this, the fix is actually the dependency pr and then just a bump in the app so no need for double payment

    $250 is correct reward in this case.

    Thank you 🙇‍♂️

  14. mallenexpensify commented on Aug 12, 2026

    @mallenexpensify
    Contributor

    @x-dev90 can you please accept the job below? Please reply here and tag me once you have.
    https://www.upwork.com/jobs/~022077755966792443403

    Always accept the job ASAP buz bonuses can always be added, if needed.

  15. x-dev90 commented on Aug 13, 2026

    @x-dev90
    Contributor

    @mallenexpensify, The offer accepted. thanks.

  16. mallenexpensify commented on Aug 13, 2026

    @mallenexpensify
    Contributor

    @x-dev90 paid, summary updated above. Thx.

  17. garrettmknight commented on Aug 27, 2026

    @garrettmknight
    Contributor

    $250 approved for @suneox

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 productionDailyKSv2ExternalAdded 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