Skip to content

[$175] [Sentry: APP-N98] Web: editing a message loops setSelection into "Maximum update depth exceeded" #104748

Open

Description

@mountiny

Sentry

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

Impact (snapshot at filing)

  • Users (total since first seen): 2
  • Events: 12
  • Users (last 14d): 2 (9.5.13: 1, 9.5.14: 1)
  • First seen: 2026-10-20 (new)
  • Last seen: 2026-10-21 (ongoing)
  • Platform: web (standalone)
  • App version(s): 9.5.13, 9.5.14
  • Affected route(s) (parameterized): /r/:reportID
  • Mechanism: auto.browser.global_handlers.onerror

Stack trace (top frames, first-party only)

Error: Maximum update depth exceeded (React #185)
  src/pages/inbox/report/ReportActionItemMessageEdit.tsx:116  setSelection       setSelectionState(newSelection)
  src/pages/inbox/report/ReportActionItemMessageEdit.tsx:379  onSelectionChange  setSelection(e.nativeEvent.selection)
  src/components/Composer/implementation/index.tsx  addCursorPositionToSelectionChange
  @expensify/react-native-live-markdown MarkdownTextInput.web.js:176  onSelectionChange
  @expensify/react-native-live-markdown MarkdownTextInput.web.js:287  updateSelection

Suspected cause

In the message edit composer, setSelection writes the selection to local state and to currentEditMessageSelection. An effect then copies currentEditMessageSelection back into local state, and the new selection prop makes MarkdownTextInput.web fire onSelectionChange again. The loop does not stop. The crash first appeared in 9.5.13, which includes the @expensify/react-native-live-markdown bump from 0.1.342 to 0.1.343 in #102266.

Reproduction

Unknown. See the Sentry events linked from the Sentry issue. The crash happens while the user edits a message in a report.

Related

  • Sibling Sentry IDs: none confirmed
  • Prior GH issues: none found
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @eVoloshchak

Activity

  1. self-assigned this
    on Oct 22, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 22, 2026
  3. melvin-bot commented on Oct 22, 2026

    @melvin-bot

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

  4. changed the title [-][Sentry: APP-N98] Web: editing a message loops setSelection into "Maximum update depth exceeded"[/-] [+][$175] [Sentry: APP-N98] Web: editing a message loops setSelection into "Maximum update depth exceeded"[/+] on Oct 22, 2026
  5. melvin-bot commented on Oct 22, 2026

    @melvin-bot
  6. Abdallah-1551 commented on Oct 22, 2026

    @Abdallah-1551

    Proposal

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

    On web, editing a message in a report can crash the page with React #185 ("Maximum update depth exceeded"). The edit composer (ReportActionItemMessageEdit) and MarkdownTextInput.web keep re-triggering each other's selection updates, and the cycle never stops. About 2 users have hit it so far, starting in 9.5.13.

    What is the root cause of that problem?

    This is based on the issue's stack trace and the suspected-cause analysis. I haven't verified it against the code or a repro.

    The selection flows in a circle with no equality check anywhere:

    1. MarkdownTextInput.web calls onSelectionChange.
    2. Composer runs addCursorPositionToSelectionChange, which builds a new object {start, end, positionX, positionY} on every call.
    3. setSelection(e.nativeEvent.selection) writes that object to currentEditMessageSelection and calls setSelectionState(newSelection).
    4. A new object reference always counts as a state change, so the component re-renders even when start and end are identical.
    5. An effect copies currentEditMessageSelection back into local state, which is another new reference and another render.
    6. selection is passed as a prop to MarkdownTextInput. Since live-markdown 0.1.343 (PR Bump react-native-reanimated to 4.7.0 and react-native-worklets to 0.13.0 #102266), its updateSelection fires onSelectionChange again when it receives a selection prop. This returns to step 1.

    Before 0.1.343 the input didn't echo prop-driven selection changes, so the missing equality checks went unnoticed. The bump exposed the latent loop, and React stops it after 50 nested updates.

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

    Break the cycle in ReportActionItemMessageEdit.tsx by making every selection write idempotent. If start and end haven't changed, no state update happens.

    1. Add a comparison helper:
    const isSameSelection = (a?: TextSelection | null, b?: TextSelection | null) =>
        !!a && !!b && a.start === b.start && a.end === b.end;
    1. Make setSelection bail out when nothing changed. Returning prev from a functional updater makes React skip the render:
    const setSelection = useCallback((newSelection: TextSelection) => {
        currentEditMessageSelection = newSelection;
        setSelectionState((prev) => (isSameSelection(prev, newSelection) ? prev : newSelection));
    }, []);
    1. Guard the effect that copies currentEditMessageSelection back into state in the same way:
    useEffect(() => {
        const saved = currentEditMessageSelection;
        if (!saved) {
            return;
        }
        setSelectionState((prev) => (isSameSelection(prev, saved) ? prev : saved));
    }, [/* existing deps */]);

    If the effect only exists to restore the selection after a remount, run it only on mount or when the focus condition changes, and drop any dependency on selection.

    1. In the onSelectionChange handler, skip the call if the selection hasn't changed:
    onSelectionChange={(e) => {
        const next = e.nativeEvent.selection;
        if (isSameSelection(selectionRef.current, next)) {
            return;
        }
        selectionRef.current = next;
        setSelection(next);
        // existing logic
    }}

    Here selectionRef is a ref kept in sync with the state, so the handler has no stale closure.

    1. Ignore positionX and positionY when comparing. They are derived DOM coordinates and shouldn't drive re-renders. If something in the edit flow needs them, such as emoji picker positioning, read them from currentEditMessageSelection or the ref instead of state.

    2. Optional upstream hardening: in react-native-live-markdown, updateSelection should not call onSelectionChange when the selection already matches the DOM selection. This is defense in depth and shouldn't block the App fix.

    What alternative solutions did you explore? (Optional)

    • Revert live-markdown to 0.1.342. This stops the crash but regresses whatever 0.1.343 fixed, and it leaves the loop in place for the next library change.
    • Remove the effect and the currentEditMessageSelection global entirely. It is cleaner, but it risks regressing selection restoration when the edit composer remounts, for example after the emoji picker closes or on focus changes. Without a repro it is too risky.
    • Debounce or setTimeout the setSelection call. This hides the symptom, adds latency and flicker to the cursor, and doesn't remove the cycle.

    Testing

    1. Unit test for ReportActionItemMessageEdit:
      • Render the component and fire onSelectionChange repeatedly with the same start and end but different positionX and positionY. Assert the render count stays bounded and no "Maximum update depth" error is thrown.
      • Fire it with a different start and end and assert the state updates.
    2. Manual, web, with live-markdown 0.1.343 or later:
      • Edit a message and type, move the cursor with the arrow keys, click within the text, and select text with Shift+arrows. Confirm the cursor behaves normally and there are no console errors.
      • Open the emoji picker from the edit composer and insert an emoji. It should land at the cursor.
      • Start editing, switch to another report or blur the composer, then return. The selection should be restored without a loop.
      • Edit a long message with markdown and mentions, then save and cancel.
    3. Regression check: confirm that edit still works on native (iOS and Android) and that the main composer is unaffected.
  7. MelvinBot commented on Oct 22, 2026

    @MelvinBot
    Contributor

    ⚠️ Preliminary analysis (low confidence) — treat as a starting point, not a conclusion.

    The issue's suspected cause doesn't hold up. The live-markdown bump didn't cause this, and a new selection prop never makes MarkdownTextInput.web fire onSelectionChange. The most likely App-side contributor is the effect at ReportActionItemMessageEdit.tsx:122-126. It copies a new selection object back into local state after every keystroke, which adds an extra render per keystroke.

    I couldn't confirm a true infinite loop, and my web repro attempt was blocked by a broken test session.

    Suggested low-risk fix: in that effect, skip the update when start and end are unchanged, and drop the unused draft.length and setSelection dependencies:

    useEffect(() => {
        const next = currentEditMessageSelection ?? defaultSelection;
        setSelectionState((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
    }, [currentEditMessageSelection, defaultSelection]);

    To confirm: check the Sentry breadcrumbs for APP-N98. A burst of key or input events just before the error (for example, a held key or a paste) supports this theory. A single isolated keystroke means the cause is elsewhere. Also check whether any events came from versions before 9.5.13, because the composer code didn't change between 9.5.12 and 9.5.13.

    Investigation details

    Why live-markdown isn't the cause

    Why the prop path can't loop by itself

    • In MarkdownTextInput.web.js 0.1.343, onSelectionChange is only called from updateSelection, and only when start or end differ from the stored selection (lines 192-202).
    • The effect that handles the selection prop (lines 558-569) moves the caret but never calls onSelectionChange.
    • The Sentry frame at line 287 is inside handleOnChangeText. That means each crash starts from a typing event, not from a prop update.
    • The Composer's prop-to-state effect also skips when the values match (Composer/implementation/index.tsx:94-100).

    The extra render per keystroke

    • setSelection (ReportActionItemMessageEdit.tsx:114-120) sets local state to the event's selection. It also sets the context to a new merged copy, {...prev, ...newSelection}.
    • The effect at lines 122-126 then runs, because the context object and draft.length changed. It calls setSelectionState with a different object, so React can't skip the render.
    • React 19 counts nested synchronous updates and throws error Add link styles for sidebar footer #185 after 50. The theory is that a busy main thread plus fast input (auto-repeat or paste) lets these extra renders pile up. That would explain why the crash is rare (2 users, 12 events). This part is unproven.

    Separate bug noticed
    setCurrentEditMessageSelection silently does nothing unless editingState === EDITING (ReportActionEditMessageContext.tsx:137-143). When the write is dropped, the effect resets the caret to the end of the text each time the length changes.


    Next Steps: Reply with @MelvinBot <your feedback> to refine this analysis.


    view run · no recording available

  8. magomedta commented on Oct 22, 2026

    @magomedta

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~01777d047a06f0e470

    Proposal

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

    On Web, while editing a message in a report, the application can crash with Error: Maximum update depth exceeded (React #185).

    What is the root cause of that problem?

    In src/pages/inbox/report/ReportActionItemMessageEdit.tsx:

    1. Selection state is tracked in two places: local state selection (lines 112) and context state currentEditMessageSelection from useReportActionActiveEdit():
      const setSelection = useCallback(
          (newSelection: TextSelection) => {
              setSelectionState(newSelection);
              setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection}));
          },
          [setSelectionState, setCurrentEditMessageSelection],
      );
    2. Lines 122–126 have an effect that mirrors currentEditMessageSelection back into setSelectionState:
      useEffect(() => {
          setSelectionState(currentEditMessageSelection ?? defaultSelection);
      }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]);
    3. Whenever setCurrentEditMessageSelection is called, it produces a new object reference ({...prevSelection, ...newSelection}).
    4. This triggers the useEffect, which calls setSelectionState with a new object reference, passing a new selection prop into <Composer selection={selection} /> (line 378).
    5. In @expensify/react-native-live-markdown >= 0.1.343, receiving a new selection prop triggers updateSelection, which emits onSelectionChange -> setSelection -> setCurrentEditMessageSelection -> useEffect -> setSelectionState -> infinite loop until React crashes with Maximum update depth exceeded.

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

    In src/pages/inbox/report/ReportActionItemMessageEdit.tsx:
    Make selection updates idempotent by checking if start and end have actually changed before updating state. Returning prev from state updaters tells React to skip re-rendering:

    --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx
    +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx
    @@ -111,18 +111,31 @@ function ReportActionItemMessageEdit({
         const defaultSelection = useMemo(() => ({start: draft.length, end: draft.length, positionX: 0, positionY: 0}), [draft.length]);
         const [selection, setSelectionState] = useState<TextSelection>(() => currentEditMessageSelection ?? defaultSelection);
     
    +    const isSameSelection = (a?: TextSelection | null, b?: TextSelection | null) =>
    +        !!a && !!b && a.start === b.start && a.end === b.end;
    +
         const setSelection = useCallback(
             (newSelection: TextSelection) => {
    -            setSelectionState(newSelection);
    -            setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection}));
    +            setSelectionState((prev) => (isSameSelection(prev, newSelection) ? prev : newSelection));
    +            setCurrentEditMessageSelection((prevSelection) => {
    +                if (isSameSelection(prevSelection, newSelection)) {
    +                    return prevSelection;
    +                }
    +                return {...prevSelection, ...newSelection};
    +            });
             },
             [setSelectionState, setCurrentEditMessageSelection],
         );
     
         useEffect(() => {
             // When the current edit message selection changes, we need to update the selection state
    -        // eslint-disable-next-line react-hooks/set-state-in-effect
    -        setSelectionState(currentEditMessageSelection ?? defaultSelection);
    -    }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]);
    +        setSelectionState((prev) => {
    +            const target = currentEditMessageSelection ?? defaultSelection;
    +            if (isSameSelection(prev, target)) {
    +                return prev;
    +            }
    +            return target;
    +        });
    +    }, [currentEditMessageSelection, defaultSelection]);

    Key Advantages:

    1. Breaks the circular feedback loop: When MarkdownTextInput.web echoes back the same selection coordinates, isSameSelection bails out, preventing state re-renders and halting the cycle immediately.
    2. Preserves external updates: If emojis or mentions are inserted from outside (e.g. Suggestions), currentEditMessageSelection will have different start/end values and will update local selection state as intended.
    3. Cleans up effect dependencies: Removes unnecessary dependencies (draft.length, setSelection) that were causing redundant re-synchronizations.

    What alternative solutions did you explore? (Optional)

    • Reverting live-markdown to 0.1.342:
      Rejected. Reverting dependencies introduces regressions for other bug fixes and leaves the underlying lack of state idempotence unaddressed.
    • Removing the useEffect entirely:
      Risky, as external actions (such as emoji insertion via context or suggestion pickers) rely on currentEditMessageSelection syncing into the composer. Guarding with isSameSelection is much safer and preserves existing UX.

    What automated tests can you add to prevent this from re-occurring?

    In tests/unit/ReportActionItemMessageEditTest.tsx:

    • Render ReportActionItemMessageEdit.
    • Trigger repeated onSelectionChange events with identical start and end values.
    • Assert that the component render count remains stable and does not exceed React's update depth limit.
  9. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. camesenin commented on Oct 22, 2026

    @camesenin

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604

    Proposal

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

    On Web, editing a message in chat can crash the application with React Error #185 ("Maximum update depth exceeded"), originating from a feedback loop between ReportActionItemMessageEdit.tsx (setSelectionState) and MarkdownTextInput.web's selection updates.

    What is the root cause of that problem?

    In src/pages/inbox/report/ReportActionItemMessageEdit.tsx:114-131:

    const [selection, setSelectionState] = useState<TextSelection>(() => currentEditMessageSelection ?? defaultSelection);
    
    const setSelection = useCallback(
        (newSelection: TextSelection) => {
            setSelectionState(newSelection);
            setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection}));
        },
        [setSelectionState, setCurrentEditMessageSelection],
    );
    
    useEffect(() => {
        setSelectionState(currentEditMessageSelection ?? defaultSelection);
    }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]);
    1. Circular Trigger Loop:
      • In react-native-live-markdown (bumped in PR Bump react-native-reanimated to 4.7.0 and react-native-worklets to 0.13.0 #102266), updateSelection synchronizes the DOM selection with the passed selection prop, emitting onSelectionChange events.
      • onSelectionChange invokes setSelection(newSelection).
      • setSelection creates a new object literal reference {...prevSelection, ...newSelection} and writes setSelectionState(newSelection).
    2. Missing Referential & Value Equality Bailing:
      • Because newSelection is a freshly allocated object literal on each event, setSelectionState(newSelection) unconditionally triggers a component re-render even when start and end indices are completely unchanged.
      • currentEditMessageSelection also changes reference, triggering the useEffect on line 125 (setSelectionState(currentEditMessageSelection ?? defaultSelection)).
      • This re-render passes the new selection reference back to MarkdownTextInput, which re-fires onSelectionChange, creating an unbounded infinite render loop exceeding React's maximum update depth (50 iterations).

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

    In src/pages/inbox/report/ReportActionItemMessageEdit.tsx:
    Make all selection state writes strictly idempotent by comparing start and end values. Using React's functional updater bail-out (return prev) skips re-renders whenever selection indices are unchanged:

    --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx
    +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx
    @@ -111,18 +111,31 @@ function ReportActionItemMessageEdit({
         const defaultSelection = useMemo(() => ({start: draft.length, end: draft.length, positionX: 0, positionY: 0}), [draft.length]);
         const [selection, setSelectionState] = useState<TextSelection>(() => currentEditMessageSelection ?? defaultSelection);
    
    +    const isSameSelection = (a?: TextSelection | null, b?: TextSelection | null) =>
    +        !!a && !!b && a.start === b.start && a.end === b.end;
    +
         const setSelection = useCallback(
             (newSelection: TextSelection) => {
    -            setSelectionState(newSelection);
    -            setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection}));
    +            setSelectionState((prev) => (isSameSelection(prev, newSelection) ? prev : newSelection));
    +            setCurrentEditMessageSelection((prevSelection) => {
    +                if (isSameSelection(prevSelection, newSelection)) {
    +                    return prevSelection;
    +                }
    +                return {...prevSelection, ...newSelection};
    +            });
             },
             [setSelectionState, setCurrentEditMessageSelection],
         );
    
         useEffect(() => {
             // When the current edit message selection changes, we need to update the selection state
    -        // eslint-disable-next-line react-hooks/set-state-in-effect
    -        setSelectionState(currentEditMessageSelection ?? defaultSelection);
    -    }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]);
    +        setSelectionState((prev) => {
    +            const target = currentEditMessageSelection ?? defaultSelection;
    +            if (isSameSelection(prev, target)) {
    +                return prev;
    +            }
    +            return target;
    +        });
    +    }, [currentEditMessageSelection, defaultSelection]);

    Why this solution is optimal:

    1. Breaks the infinite cycle immediately: When MarkdownTextInput.web echoes back the identical cursor coordinates, isSameSelection returns true and React bails out of re-rendering, terminating the ping-pong cycle on iteration 1.
    2. Preserves external interactions: External updates (such as clicking emojis or mentions from Suggestions) produce distinct start/end indices and update cleanly.
    3. Cleans redundant effect dependencies: Removes unnecessary dependencies (draft.length, setSelection) that were previously triggering spurious effect executions.

    What alternative solutions did you explore? (Optional)

    • Reverting react-native-live-markdown:
      Rejected. Reverting dependencies creates regressions for other markdown fixes and ignores the lack of state idempotency in the composer.
    • Removing the useEffect entirely:
      Rejected because external actions (e.g. emoji picker selection restoration) rely on currentEditMessageSelection synchronizing into the edit composer.

    What automated tests can you add to prevent this from re-occurring?

    In tests/unit/ReportActionItemMessageEditTest.tsx:

    • Render ReportActionItemMessageEdit in editing mode.
    • Simulate repeated onSelectionChange events with identical start: 5, end: 5 coordinates.
    • Assert that the component render count stays constant and does not throw React error Add link styles for sidebar footer #185.
  11. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. sijo0218 commented on Oct 22, 2026

    @sijo0218

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~011091133bbb09cd1c

    Proposal

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

    On Web, while editing a message in a report chat, the application crashes with Error: Maximum update depth exceeded (React #185) (Sentry APP-N98). The edit composer (ReportActionItemMessageEdit.tsx) and the markdown input (MarkdownTextInput.web) enter an unbounded synchronous feedback loop of selection updates.

    What is the root cause of that problem?

    In src/pages/inbox/report/ReportActionItemMessageEdit.tsx (lines 111–130 and line 379):

    1. Unbounded Selection Synchronization Loop:
      • In ReportActionItemMessageEdit.tsx, selection is tracked both in local component state (selection, line 112) and in active edit context (currentEditMessageSelection, line 102).
      • In setSelection (lines 114–120):
        const setSelection = useCallback(
            (newSelection: TextSelection) => {
                setSelectionState(newSelection);
                setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection}));
            },
            [setSelectionState, setCurrentEditMessageSelection],
        );
      • Whenever setSelection runs, setCurrentEditMessageSelection allocates a brand-new object literal {...prevSelection, ...newSelection}.
      • Lines 122–126 contain an effect that synchronizes currentEditMessageSelection back into selectionState:
        useEffect(() => {
            setSelectionState(currentEditMessageSelection ?? defaultSelection);
        }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]);
      • When currentEditMessageSelection receives a new object reference, this effect executes and calls setSelectionState(...) with a newly allocated reference.
    2. Interaction with @expensify/react-native-live-markdown >= 0.1.343:
      • In PR Bump react-native-reanimated to 4.7.0 and react-native-worklets to 0.13.0 #102266, @expensify/react-native-live-markdown was bumped from 0.1.342 to 0.1.343. In 0.1.343, MarkdownTextInput.web.js:287 updates the DOM selection whenever it receives a new selection prop reference (updateSelection), which in turn emits an onSelectionChange event (MarkdownTextInput.web.js:176).
      • In Composer/implementation/index.tsx, addCursorPositionToSelectionChange catches onSelectionChange, calculates caret coordinates, and calls props.onSelectionChange with a new selectionValue = {start, end, positionX, positionY}.
      • Line 379 of ReportActionItemMessageEdit.tsx handles this via onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}.
    3. Missing Value Equality / Referential Bailout:
      • Neither setSelection, setCurrentEditMessageSelection, nor the synchronization useEffect performs value equality checks on the selection boundaries (start and end).
      • Because newSelection is a freshly allocated object literal on every cycle, React cannot bail out using reference identity (Object.is).
      • Each state change re-renders the component, passing a fresh selection prop into Composer, which causes MarkdownTextInput.web to emit another onSelectionChange, re-invoking setSelection in an infinite cycle until React terminates the app with error Add link styles for sidebar footer #185 after 50 nested iterations.

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

    In src/pages/inbox/report/ReportActionItemMessageEdit.tsx:

    1. Define a robust selection equality helper isSameSelection outside the component:

      • Compares start and normalized end (a.end ?? a.start === b.end ?? b.start) so collapsed carets without an explicit end property compare correctly.
      • Intentionally excludes positionX and positionY from equality comparison because caret layout coordinates derived from getClientRects() are floating-point layout metrics that must not trigger text selection re-renders.
      • Short-circuits on referential equality (a === b) and handles null/undefined safely.
    2. In setSelection:

      • Use React's functional updater bailouts (return prevSelection) in both setSelectionState and setCurrentEditMessageSelection. When the selection boundaries are identical, returning the previous state reference instructs React to completely bail out of re-rendering.
    3. In the synchronization useEffect:

      • Guard setSelectionState so it only updates if the resolved selection differs from the current state.
      • Clean up the dependency array by removing draft.length (already tracked via defaultSelection) and setSelection.
    --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx
    +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx
    @@ -70,6 +70,16 @@ type ReportActionItemMessageEditProps = {
         action: OnyxTypes.ReportAction;
     };
     
    +function isSameSelection(a?: TextSelection | null, b?: TextSelection | null): boolean {
    +    if (a === b) {
    +        return true;
    +    }
    +    if (!a || !b) {
    +        return false;
    +    }
    +    return a.start === b.start && (a.end ?? a.start) === (b.end ?? b.start);
    +}
    +
     function ReportActionItemMessageEdit({
         action,
         draft: draftComment,
    @@ -113,17 +123,30 @@ function ReportActionItemMessageEdit({
         const [selection, setSelectionState] = useState<TextSelection>(() => currentEditMessageSelection ?? defaultSelection);
     
         const setSelection = useCallback(
             (newSelection: TextSelection) => {
    -            setSelectionState(newSelection);
    -            setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection}));
    +            setSelectionState((prevSelection) => (isSameSelection(prevSelection, newSelection) ? prevSelection : newSelection));
    +            setCurrentEditMessageSelection((prevSelection) => {
    +                if (isSameSelection(prevSelection, newSelection)) {
    +                    return prevSelection;
    +                }
    +                return {...prevSelection, ...newSelection};
    +            });
             },
             [setSelectionState, setCurrentEditMessageSelection],
         );
     
         useEffect(() => {
             // When the current edit message selection changes, we need to update the selection state
             // eslint-disable-next-line react-hooks/set-state-in-effect
    -        setSelectionState(currentEditMessageSelection ?? defaultSelection);
    -    }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]);
    +        setSelectionState((prevSelection) => {
    +            const nextSelection = currentEditMessageSelection ?? defaultSelection;
    +            if (isSameSelection(prevSelection, nextSelection)) {
    +                return prevSelection;
    +            }
    +            return nextSelection;
    +        });
    +    }, [currentEditMessageSelection, defaultSelection]);

    Why this approach is optimal:

    • Immediately breaks the cycle: When MarkdownTextInput.web echoes back the identical selection coordinates, isSameSelection returns true, React bails out of re-rendering, and the feedback loop halts on iteration 1.
    • Robust against optional end indices: Unlike naive a.end === b.end checks, (a.end ?? a.start) === (b.end ?? b.start) properly treats collapsed carets ({start: 5} vs {start: 5, end: 5}) as identical.
    • Preserves external interactions: External actions that intentionally change cursor position (e.g. inserting emojis from EmojiPickerButton or mentions from Suggestions) yield distinct indices, allowing state and context to update as intended.
    • No closure allocation per render: Defining isSameSelection at module level avoids recreating function instances across renders.

    What alternative solutions did you explore? (Optional)

    • Reverting @expensify/react-native-live-markdown to 0.1.342:
      Rejected because reverting introduces regressions for other web markdown and composer bug fixes, and ignores the lack of state idempotence in the message edit component.
    • Removing the useEffect entirely:
      Rejected because external actions (such as restoring selection when closing modal sheets or suggestion pickers) rely on currentEditMessageSelection propagating into the local composer state. Guarding with isSameSelection safely preserves this synchronization without looping.
    • Comparing positionX and positionY:
      Rejected because subpixel floating-point client rect calculations can introduce minute fluctuations that do not represent text cursor changes and would defeat memoization.

    What automated tests can you add to prevent this from re-occurring?

    In tests/unit/ReportActionItemMessageEditTest.tsx:

    1. Render ReportActionItemMessageEdit in edit mode.
    2. Fire multiple sequential onSelectionChange events on the composer with identical start and end indices (e.g., {start: 4, end: 4}).
    3. Verify that the component render count does not increase after the initial update and that no Maximum update depth exceeded error is thrown.
    4. Fire onSelectionChange with distinct indices (e.g., {start: 8, end: 8}) and verify that selection updates appropriately.
  13. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  14. eVoloshchak commented on Oct 23, 2026

    @eVoloshchak
    Contributor

    To confirm: check the Sentry breadcrumbs for APP-N98. A burst of key or input events just before the error (for example, a held key or a paste) supports this theory. A single isolated keystroke means the cause is elsewhere.

    Checked this, it's a single keystroke in every instance, so Melvins' proposal won't do it

    Image Image

    Also check whether any events came from versions before 9.5.13, because the composer code didn't change between 9.5.12 and 9.5.13.

    First seen in release 9.5.13

  15. eVoloshchak commented on Oct 23, 2026

    @eVoloshchak
    Contributor

    To confirm: check the Sentry breadcrumbs for APP-N98. A burst of key or input events just before the error (for example, a held key or a paste) supports this theory. A single isolated keystroke means the cause is elsewhere.
    Checked this, it's a single keystroke in every instance, so Melvins' proposal won't do it

    Turns out, breadcrumbs don't tell you whether the user pasted text or typed it one character at a time, so it still could be the culprit. Let's implement Melvin's approach and see if that fixes it, as there are no reliable steps to reproduce the issue

  16. eVoloshchak commented on Oct 23, 2026

    @eVoloshchak
    Contributor

    @MelvinBot, implement this

  17. MelvinBot commented on Oct 23, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: Skip redundant selection updates in the message edit composer
    Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.

    The effect in ReportActionItemMessageEdit.tsx:122-128 now keeps the previous selection object when start and end are unchanged. It also drops the unused draft.length and setSelection dependencies.

    Checks
    • Lint, typecheck, and spell check pass.
    • Related Jest suites pass (22/22): ReportActionItemMessageEditTest, ReportActionMessageEditLayoutTest, useReportScrollManagerTest.
    • The React Compiler check reports 5 "Cannot access refs during render" errors in this file. The same 5 errors exist on main, so this change adds none.
    • I didn't add a unit test. There is no reliable repro, so a test couldn't show the crash is fixed.
    • A manual web check couldn't run. The test session showed a blank page during onboarding, before any chat loaded, so the edit composer was never reached.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething 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