Skip to content

[$175] [Sentry: APP-M0W] HybridApp iOS: App Hang, Live Markdown parser blocks the main thread on the JS runtime lock #104744

Open

Description

@mountiny

Sentry

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

Impact (snapshot at filing)

  • Users (total since first seen): 21
  • Events: 21
  • Users (last 14d, all versions): 9
  • Users (last 14d, production 9.5.13 to 9.5.15): 3, all on 9.5.13-6
  • First seen: 2026-09-16
  • Last seen: 2026-10-21 (ongoing)
  • Platform: HybridApp iOS
  • App version(s): 9.5.2 to 9.5.13
  • Mechanism: AppHang (Fatal App Hang Fully Blocked, main thread blocked for at least 2000 ms), level fatal

Stack trace (main thread, top frames)

__psynch_mutexwait
std::__1::recursive_mutex::lock
facebook::jsi::WithRuntimeDecorator<T>::createStringFromUtf8
-[MarkdownParser parseUncached:withParserId:]
-[MarkdownParser parseIfRegistered:withParserId:]
-[MarkdownParser parse:withParserId:]
-[RCTMarkdownUtils applyMarkdownFormatting:withDefaultTextAttributes:]
-[MarkdownTextStorageDelegate textStorage:didProcessEditing:range:changeInLength:]
-[MarkdownTextInputDecoratorComponentView applyNewStyles]
-[MarkdownTextInputDecoratorComponentView updateProps:oldProps:]
RCTPerformMountInstructions (RCTMountingManager.mm:83)
...
reanimated::ReanimatedModuleProxy::executeLayoutAnimationsRequests
worklets::WorkletRuntime::runSyncAndDiscard<T>

Suspected cause

A reanimated layout animation runs a Fabric mount on the main thread. The mount updates the props of a MarkdownTextInput, and Live Markdown parses the text right away on the main thread. The parser calls into a JS runtime and waits for its lock, and another thread holds that lock, so the main thread stays blocked until the watchdog kills the app.

Reproduction

Unknown. See the Sentry events linked from the Sentry issue.

Related

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

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 - @TaduJR (External)

  4. changed the title [-][Sentry: APP-M0W] HybridApp iOS: App Hang, Live Markdown parser blocks the main thread on the JS runtime lock[/-] [+][$175] [Sentry: APP-M0W] HybridApp iOS: App Hang, Live Markdown parser blocks the main thread on the JS runtime lock[/+] 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 HybridApp iOS, the app sometimes freezes for 2 seconds or more, and the iOS watchdog reports a fatal App Hang. The main thread is blocked inside -[MarkdownParser parseUncached:withParserId:]. It is waiting on a std::recursive_mutex that guards a JS (worklet) runtime, and another thread holds that lock. The call chain is:

    1. A Reanimated layout animation runs a Fabric mount on the main thread.
    2. The mount calls MarkdownTextInputDecoratorComponentView updateProps → applyNewStyles.
    3. applyNewStyles triggers textStorage:didProcessEditing, which synchronously calls the markdown parser.

    The goal is that the main thread never blocks on a JS runtime lock because of Live Markdown. At minimum, a props or style update on a MarkdownTextInput must not trigger a synchronous JS call.

    What is the root cause of that problem?

    This analysis is from the stack trace only. There is no reproduction, so the details below are strong hypotheses.

    1. The native parser calls into a JS runtime synchronously on the main thread. MarkdownParser.mm has to create JSI values (createStringFromUtf8) and run the parser worklet on a worklet runtime. That runtime is wrapped in a decorator that takes a recursive_mutex for every access. If any other thread is using the runtime, the main thread waits for it.
    2. The re-parse is triggered by a props update, not only by a text edit.
      • updateProps → applyNewStyles rewrites the attributes of the whole text storage, which fires didProcessEditing and re-runs the parser.
      • The parse result only contains ranges (type, location, length, depth). It does not depend on markdownStyle.
      • Re-parsing on a style-only or layout-driven prop update is therefore unnecessary. When the parse cache misses, as with a new or long text, the call goes to the JS runtime and can block.
    3. The runtime lock is held for a long time by another thread. A likely holder is the JS thread or the UI runtime running a long markdown parse worklet (long text, or parseExpensiMark invoked elsewhere via runOnRuntime/runOnJS). The Reanimated layout-animation path (executeLayoutAnimationsRequests → runSyncAndDiscard) makes this worse, because the mount runs inside a synchronous worklet-runtime section on the main thread. That creates a lock-ordering and contention window.

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

    1. Main fix, in react-native-live-markdown (native iOS). Do not call into JS when only styles or props change.

    • In MarkdownTextInputDecoratorComponentView, store the last parsed ranges together with the text and parserId they were computed from.
    • In applyNewStyles, if the text and parser are unchanged, re-apply attributes from the cached ranges and skip MarkdownParser parse:.
    // RCTMarkdownUtils.mm (sketch)
    - (NSAttributedString *)applyMarkdownFormatting:(NSAttributedString *)input
                          withDefaultTextAttributes:(NSDictionary *)defaults {
      NSString *text = input.string;
      NSArray *ranges;
      if ([text isEqualToString:_lastText] && _lastParserId == _parserId) {
        ranges = _lastRanges;                    // no JS call, no runtime lock
      } else {
        ranges = [_parser parse:text withParserId:_parserId]; // may hit JS
        _lastText = [text copy]; _lastParserId = _parserId; _lastRanges = ranges;
      }
      return [self applyRanges:ranges toText:input defaults:defaults];
    }

    This removes the hang path from the stack trace, where a layout-animation mount only changes props or styles. It is also a performance win.

    2. Defense in depth in react-native-live-markdown. For the unavoidable parse on the main thread (a real text change or the initial mount), avoid an unbounded wait on the runtime lock.

    • Use a dedicated worklet runtime for the markdown parser. It must be used only by Live Markdown and never shared with the UI runtime or user code.
    • Keep the existing parse cache and make sure it is populated before mount where possible. For example, pre-parse the defaultValue or value on the JS side.
    • If a non-blocking try-lock is available, fall back to plain unformatted text and schedule an async re-format on the next run loop tick instead of blocking.

    3. In Expensify/App.

    • Verify that the markdown parser (parseExpensiMark) is only executed on the dedicated markdown runtime. Remove any usage on the JS thread or UI runtime (runOnRuntime, runOnUI and similar).
    • Audit the screens in the stack trace (Composer, MarkdownTextInput inside Animated.View or LayoutAnimation). Do not apply Reanimated layout/entering/exiting to an ancestor that remounts or relayouts a MarkdownTextInput. Animate a wrapper or use plain transforms instead.
    • After the live-markdown fix is released, bump the @expensify/react-native-live-markdown version in App's package.json and update the patch if one exists.

    What alternative solutions did you explore? (Optional)

    • Increase the watchdog timeout or ignore the Sentry event: rejected, because the app is still frozen and gets killed.
    • Run parsing fully asynchronously in all cases: rejected for the main fix. Text would flash unstyled while the user types, and edits would need reconciling with stale results. It is kept only as a fallback for the contended case.
    • Replace the JS parser with a native (C++) parser: the most robust option, because it removes the runtime lock entirely. It is too large for this issue and is a possible follow-up.
    • Disable layout animations globally in App: rejected as overly broad and a UX regression. The targeted audit in step 3 is preferred.

    Testing

    1. Unit/native test (live-markdown): mount a MarkdownTextInput with text and spy on MarkdownParser parse:. Update only markdownStyle, layout props or the font. Assert that the parser is not called again and that the attributes are updated correctly. Change the text and assert that it is called once.
    2. Contention test: in the live-markdown example app, hold the markdown runtime lock on a background thread, for example with a long-running worklet via runOnRuntime. Trigger a layout animation that changes the props of a MarkdownTextInput. Before the fix the main thread blocks. After the fix there is no hang, and the UI stays responsive.
    3. Manual in HybridApp iOS: open a report with a long draft in the composer, then trigger navigation and layout animations such as opening and closing the emoji picker, attachments, and the keyboard. Confirm there are no freezes, and that markdown formatting (bold, links, mentions, code) is still highlighted correctly after style changes.
    4. Monitoring: after release, watch Sentry issue APP-M0W for new events on the fixed version. It should stop recurring.
  7. MelvinBot commented on Oct 22, 2026

    @MelvinBot
    Contributor

    Updated 2026-10-25: Corrected based on feedback from TaduJR.

    Proposal

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

    The app freezes with a fatal App Hang (APP-M0W) while a Live Markdown input parses text on the main thread. The same hang appears on Android.

    What is the root cause of that problem?

    A lock-order deadlock between two worklet runtimes. TaduJR confirmed it with lldb on the iOS simulator.

    1. The main thread holds the UI runtime lock and waits for the markdown runtime lock. A Reanimated layout animation flush mounts a MarkdownTextInput, and Live Markdown parses its text on the main thread through the LiveMarkdownRuntime.
    2. The JS thread holds the markdown runtime lock and waits for the UI runtime lock. Our parser worklets read Reanimated SharedValues. On a runtime other than the UI runtime, a SharedValue read is a synchronous call into the UI runtime. Two parsers do this:

    What changed to cause this?

    • Start: the Reanimated 4.3.1 upgrade on 2026-06-01 made SharedValue reads on other runtimes synchronous calls into the UI runtime.
    • APP-EF1 was the same deadlock. live-markdown 0.1.336 (from Bump react-native-live-markdown) only changed where it shows up in the stack.
    • APP-M0W was first seen on 9.4.74-2. The JS thread holds the markdown lock in all 25 events.
    • Android: the same hang appears as APP-KA6, APP-KY2, APP-KH2, APP-NC2 and APP-M9K. This change fixes those too.

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

    Never read a SharedValue inside a parser worklet. Capture the data in the parser closure instead, and keep the parsers stable while typing.

    1. RNMarkdownTextInput: the default parser captures availableLoginsList in its closure. Pass a custom parser prop straight through instead of wrapping it.
    2. SearchAutocompleteInput: pass one KnownFilterValues object of Sets (userLogins, currencies, categories, tags, exportedTo) to parseForLiveMarkdown, and use Set.has in filterOutRangesWithCorrectValue.
    3. Use the closure, not a global on the markdown runtime. Live Markdown caches ranges by text and parser id. A global keeps stale highlights after a list changes, and the first parse can run before the effect sets it. With the closure, a list change gives the input a new parser id and it re-parses.
    4. Keep the parsers stable. A list with a new identity means a new parser id, a new registration, and an uncached parse on the main thread. So:
      • Select categories and tags with useOnyx selectors.
      • Build each Set where its list is created. Building it later in the component hits a React Compiler memoization issue.
      • In useExportedToFilterOptions, read the policy IDs again after useCombinedExportTemplates. React Compiler drops memoization for a value made before a hook call and used after it, so the hook returned a new array on every render and the Search parser changed on every keystroke.
    5. Remove the scheduleOnLiveMarkdownRuntime effects, the helper, and its mock in jest/setup.ts.

    The library hardening in react-native-live-markdown (main-thread parse reads only the cache) is out of scope and will be a separate follow-up.

    Tests
    • Update SearchAutocompleteUtilsTest to pass KnownFilterValues.
    • Add a test that useExportedToFilterOptions returns the same array across re-renders with unchanged data.
    • Add a test that SearchAutocompleteInput passes the same parser when only the query changes.

    What alternative solutions did you explore? (Optional)

    • A global on the markdown runtime set by an effect: rejected, because it keeps stale cached highlights and can miss the first parse.
    • Library change only: stops the main thread from blocking, but a background parse can still block on the UI runtime.
    • Turn off locking on LiveMarkdownRuntime: unsafe, because the parser runs on several threads.

    Confidence: high. TaduJR confirmed the lock owners with lldb.

    Implemented in Stop reading SharedValues in Live Markdown parsers to prevent an App Hang deadlock.

  8. camesenin commented on Oct 22, 2026

    @camesenin

    Contributor details
    Your Expensify account email: camesenin@gmail.com
    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 HybridApp iOS, the app encounters a fatal App Hang freeze (>2 seconds), reported in Sentry as APP-M0W. The main thread hangs inside -[MarkdownParser parseUncached:withParserId:] waiting on std::recursive_mutex::lock for a JS worklet runtime while a Fabric mount is running during a layout animation.

    What is the root cause of that problem?

    1. Cross-Runtime Deadlock:
      • In src/components/RNMarkdownTextInput.tsx:70, the parser worklet executes on the dedicated LiveMarkdownRuntime:
        return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
      • Notice that mentionsSharedVal is a Reanimated SharedValue (useSharedValue(availableLoginsList)). Reanimated SharedValues belong to the UI runtime.
      • Calling mentionsSharedVal.get() inside a worklet running on LiveMarkdownRuntime triggers a synchronous cross-runtime call into the UI runtime while holding LiveMarkdownRuntime's lock.
    2. Fabric Mount Contention Window:
      • During a layout animation (or when executeLayoutAnimationsRequests runs on the main thread), the UI runtime and main thread hold the Reanimated UI lock and trigger Fabric mount mutations.
      • When MarkdownTextInputDecoratorComponentView updateProps -> applyNewStyles fires, it calls textStorage:didProcessEditing, which synchronously invokes -[MarkdownParser parseUncached:withParserId:] on the main thread.
      • The main thread waits to acquire the LiveMarkdownRuntime lock. Meanwhile, a background parse already holding the LiveMarkdownRuntime lock attempts to acquire the UI runtime lock via mentionsSharedVal.get().
      • Classic AB-BA deadlock between two mutexes occurs, freezing the main thread until the iOS watchdog terminates the process.

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

    In src/components/RNMarkdownTextInput.tsx:
    Eliminate reading the Reanimated mentionsSharedVal from within the parser worklet. Store the mentions list directly in a global property scoped to the LiveMarkdownRuntime:

    --- a/src/components/RNMarkdownTextInput.tsx
    +++ b/src/components/RNMarkdownTextInput.tsx
    @@ -35,7 +35,6 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ref, forwardedFSClass =
         const styles = useThemeStyles();
    
         const {availableLoginsList, currentUserMentions} = useShortMentionsList();
    -    const mentionsSharedVal = useSharedValue<string[]>(availableLoginsList);
         const inputRef = useRef<AnimatedMarkdownTextInputRef>(null);
    
         // Expose the ref to the parent component
    @@ -67,20 +66,23 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ref, forwardedFSClass =
                 if (parser) {
                     return parser(text);
                 }
    
    -            return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
    +            const mentions = (global as unknown as {liveMarkdownMentions?: string[]}).liveMarkdownMentions ?? [];
    +            return parseExpensiMarkWithShortMentions(text, mentions, currentUserMentions);
             },
    -        [currentUserMentions, mentionsSharedVal, parser],
    +        [currentUserMentions, parser],
         );
    
         useEffect(() => {
             scheduleOnLiveMarkdownRuntime(() => {
                 'worklet';
    
    -            mentionsSharedVal.set(availableLoginsList);
    +            (global as unknown as {liveMarkdownMentions?: string[]}).liveMarkdownMentions = availableLoginsList;
             });
    -    }, [availableLoginsList, mentionsSharedVal]);
    +    }, [availableLoginsList]);

    Why this solution is optimal:

    1. Completely breaks the deadlock cycle: The parser worklet reads liveMarkdownMentions directly from its own global memory on LiveMarkdownRuntime. It never calls into the Reanimated UI runtime, eliminating the AB-BA lock inversion.
    2. Zero native patch overhead: Fixes the hang cleanly in application code without requiring complex native C++ / Objective-C patches.
    3. Preserves mention parsing: Mentions remain fully reactive; whenever availableLoginsList changes, scheduleOnLiveMarkdownRuntime synchronously updates the global store.

    What alternative solutions did you explore? (Optional)

    • Caching ranges in RCTMarkdownUtils.mm only:
      Rejected as a primary fix. While caching avoids re-parsing unchanged text, initial mounts or active user keystrokes still run parseUncached and would deadlock whenever layout animations coincide with user typing. Eliminating the cross-runtime SharedValue.get() call is essential.

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

    In tests/unit/RNMarkdownTextInputTest.tsx:

    • Render RNMarkdownTextInput and execute the parserWorklet.
    • Verify that parseExpensiMarkWithShortMentions runs successfully without reading Reanimated.useSharedValue.
    • Update availableLoginsList and verify that updated mentions are parsed on subsequent worklet executions.
  9. melvin-bot commented on Oct 22, 2026

    @melvin-bot

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

  10. shilo1979jk commented on Oct 24, 2026

    @shilo1979jk

    Proposal

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

    On HybridApp iOS, the app can freeze for >2 seconds (Sentry APP-M0W App Hang) when mounting or updating RNMarkdownTextInput while a layout animation is flushing on the main thread. The main thread blocks inside -[MarkdownParser parseUncached:withParserId:] waiting for the LiveMarkdownRuntime mutex while holding the Reanimated UI runtime lock.

    What is the root cause of that problem?

    The hang is a classic AB-BA lock-order inversion between the Reanimated UI runtime mutex and the LiveMarkdownRuntime worker mutex:

    1. Main thread (holds UI runtime lock -> waits for LiveMarkdownRuntime lock):
      During a Reanimated layout animation flush on the main thread (-[REANodesManager performOperations] -> executeLayoutAnimationsRequests), the main thread holds the UI runtime lock and flushes Fabric mount mutations (RCTMountingManager.mm:83 -> -[MarkdownTextInputDecoratorComponentView updateProps:oldProps:] -> applyNewStyles -> -[RCTMarkdownUtils applyMarkdownFormatting:inputView:] -> -[MarkdownParser parseUncached:withParserId:]). Because the component view's RCTMarkdownUtils instance has its own parser cache (separate from the shadow node's cache), the cache misses on initial mount and synchronously enters LiveMarkdownRuntime, waiting on its std::recursive_mutex.

    2. LiveMarkdownRuntime worker thread (holds LiveMarkdownRuntime lock -> waits for UI runtime lock):
      In src/components/RNMarkdownTextInput.tsx:38 and 62-81, mentionsSharedVal is a Reanimated SharedValue (useSharedValue<string[]>(availableLoginsList)):

      const parserWorklet = useCallback(
          (text: string) => {
              'worklet';
      
              if (parser) {
                  return parser(text);
              }
      
              return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
          },
          [currentUserMentions, mentionsSharedVal, parser],
      );

      In react-native-reanimated, reading mentionsSharedVal.get() from a non-UI worker runtime (LiveMarkdownRuntime) calls getSync() (mutables.native.ts), which synchronously dispatches into the UI runtime and waits to acquire the UI runtime lock.

    3. Why a simple useEffect-only runtime global is not enough on initial mount:
      If mentionsSharedVal is removed and replaced only by a globalThis assignment inside useEffect(() => { scheduleOnLiveMarkdownRuntime(...); }, [availableLoginsList]), the very first synchronous parse during Fabric mount (RCTMountingManager Insert mutation) executes before React's useEffect and before the asynchronous scheduleOnRuntime task runs on LiveMarkdownRuntime. At that moment, the runtime global would still be undefined, causing existing draft @shortMentions to fail to highlight on initial mount.

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

    In src/components/RNMarkdownTextInput.tsx:

    1. Remove useSharedValue (mentionsSharedVal) completely so parserWorklet never makes a synchronous cross-runtime getSync() call into the UI runtime while holding the LiveMarkdownRuntime lock.
    2. Store availableLoginsList in a LiveMarkdownRuntime-local global (globalThis.__liveMarkdownAvailableLogins) WITH an initial closure fallback for synchronous mount parses:
      • Keep a ref (const initialAvailableLoginsRef = useRef(availableLoginsList);) captured once when parserWorklet is created so that if parserWorklet runs synchronously during the initial Fabric mount (before useEffect / scheduleOnLiveMarkdownRuntime has executed), globalThis.__liveMarkdownAvailableLogins ?? initialAvailableLogins already has the initial list without re-creating parserWorklet on every availableLoginsList update.
      • In useEffect, use scheduleOnLiveMarkdownRuntime to update globalThis.__liveMarkdownAvailableLogins = availableLoginsList directly on the markdown runtime whenever availableLoginsList changes:
      const initialAvailableLoginsRef = useRef(availableLoginsList);
      
      const parserWorklet = useCallback(
          (text: string) => {
              'worklet';
      
              if (parser) {
                  return parser(text);
              }
      
              const initialLogins = initialAvailableLoginsRef.current;
              const logins = (globalThis as {__liveMarkdownAvailableLogins?: string[]}).__liveMarkdownAvailableLogins ?? initialLogins;
              return parseExpensiMarkWithShortMentions(text, logins, currentUserMentions);
          },
          [currentUserMentions, parser],
      );
      
      useEffect(() => {
          scheduleOnLiveMarkdownRuntime((nextLogins: string[]) => {
              'worklet';
      
              (globalThis as {__liveMarkdownAvailableLogins?: string[]}).__liveMarkdownAvailableLogins = nextLogins;
          }, availableLoginsList);
      }, [availableLoginsList]);
      (Note: scheduleOnLiveMarkdownRuntime in src/libs/scheduleOnLiveMarkdownRuntime/index.native.tsx accepts (worklet, ...args) and forwards ...args to scheduleOnRuntime, avoiding recreating the worklet closure on every render.)

    What alternative solutions did you explore? (Optional)

    • Capturing availableLoginsList directly in parserWorklet's useCallback dependency array ([availableLoginsList, currentUserMentions, parser]): Rejected because availableLoginsList can contain up to 20,000 logins on large accounts; re-registering a new parserId and re-serializing the full array on every personal-details update would add unnecessary overhead.
    • Setting globalThis.__liveMarkdownAvailableLogins in useEffect without an initial fallback: Rejected because Fabric mount (updateProps -> applyNewStyles) invokes parserWorklet synchronously before useEffect runs, which would leave logins empty on initial mount.
  11. TaduJR commented on Oct 25, 2026

    @TaduJR
    Contributor

    @MelvinBot

    The root cause is confirmed. I reproduced the hang on the iOS simulator and read the lock owners with lldb: the main thread waits on the Live Markdown runtime lock held by the JS thread, and the JS thread waits on the UI runtime lock held by the main thread. Please update your proposal with the points below, then implement it with the diff in the details.

    Details
    1. SearchAutocompleteInput has the same bug. Its parser reads five SharedValues through parseForLiveMarkdown and filterOutRangesWithCorrectValue, several times per parse, so the Search bar can freeze the same way. Fix it the same way.
    2. Use the closure option, not a global on the markdown runtime. Live Markdown caches ranges by text and parser id, so a global keeps stale highlights after a list changes, and the first parse can run before the effect sets it. With the closure, a list change gives the input a new parser id and it re-parses.
    3. Keep the parsers stable while typing. Once the lists live in the closure, a list with a new identity means a new parser id, a new registration and an uncached parse on the main thread. useExportedToFilterOptions returned a new array on every render, because React Compiler drops memoization for a value made before a hook call and used after it, so the Search parser changed on every keystroke. Read the policy IDs again after useCombinedExportTemplates, select categories and tags with useOnyx selectors, and pass a custom parser straight through instead of wrapping it.
    4. Pass the Search lookup values to parseForLiveMarkdown as one KnownFilterValues object of Sets. Build each Set where its list is created, because building it later in the component hits the same React Compiler issue.
    5. Remove the scheduleOnLiveMarkdownRuntime effects, the helper and its mock in jest/setup.ts. Update SearchAutocompleteUtilsTest, and add tests that the exported-to options and the Search parser keep their identity across re-renders.
    6. Keep the library change out of this PR. It will be a separate follow-up in react-native-live-markdown.
    7. Update the timeline. The deadlock started with the Reanimated 4.3.1 upgrade on 2026-06-01, which made SharedValue reads on other runtimes synchronous calls into the UI runtime. APP-EF1 was the same deadlock, and live-markdown 0.1.336 only changed where it shows up. APP-M0W was first seen on 9.4.74-2, and the JS thread holds the markdown lock in all 25 events. The same hang appears on Android as APP-KA6, APP-KY2, APP-KH2, APP-NC2 and APP-M9K, and this change fixes those too.
    Diff to implement
    diff --git a/jest/setup.ts b/jest/setup.ts
    index 4589474f054..e7b5c3d036a 100644
    --- a/jest/setup.ts
    +++ b/jest/setup.ts
    @@ -166,13 +166,6 @@ jest.mock('react-native-keyboard-controller', () => require<typeof RNKeyboardCon
     
     jest.mock('react-native-app-logs', () => require<typeof RNAppLogs>('react-native-app-logs/jest'));
     
    -jest.mock('@libs/scheduleOnLiveMarkdownRuntime', () => {
    -    const scheduleOnLiveMarkdownRuntime = <Args extends unknown[], ReturnValue>(worklet: (...args: Args) => ReturnValue, ...args: Args): void => {
    -        worklet(...args);
    -    };
    -    return scheduleOnLiveMarkdownRuntime;
    -});
    -
     jest.mock('@src/setup/telemetry', () => ({
         __esModule: true,
         default: jest.fn(),
    diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx
    index b2b679ae5a4..8ab9d1b6cc4 100644
    --- a/src/components/RNMarkdownTextInput.tsx
    +++ b/src/components/RNMarkdownTextInput.tsx
    @@ -6,7 +6,6 @@ import useThemeStyles from '@hooks/useThemeStyles';
     import toggleSelectionFormat from '@libs/FormatSelectionUtils';
     import type {ForwardedFSClassProps} from '@libs/Fullstory/types';
     import {parseExpensiMarkWithShortMentions} from '@libs/ParsingUtils';
    -import scheduleOnLiveMarkdownRuntime from '@libs/scheduleOnLiveMarkdownRuntime';
     
     import CONST from '@src/CONST';
     
    @@ -14,9 +13,9 @@ import type {MarkdownTextInputProps} from '@expensify/react-native-live-markdown
     import type {ForwardedRef} from 'react';
     
     import {MarkdownTextInput} from '@expensify/react-native-live-markdown';
    -import React, {useCallback, useEffect, useRef} from 'react';
    +import React, {useEffect, useRef} from 'react';
     import {View} from 'react-native';
    -import Animated, {useSharedValue} from 'react-native-reanimated';
    +import Animated from 'react-native-reanimated';
     
     // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet
     const AnimatedMarkdownTextInput = Animated.createAnimatedComponent(MarkdownTextInput);
    @@ -35,7 +34,6 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ref, forwardedFSClass =
         const styles = useThemeStyles();
     
         const {availableLoginsList, currentUserMentions} = useShortMentionsList();
    -    const mentionsSharedVal = useSharedValue<string[]>(availableLoginsList);
         const inputRef = useRef<AnimatedMarkdownTextInputRef>(null);
     
         // Expose the ref to the parent component
    @@ -58,27 +56,12 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ref, forwardedFSClass =
             }
         }, [props.value, isCursorAtEnd]);
     
    -    // If `parser` prop was passed down we use it directly, otherwise we default to parsing with ExpensiMark
    -    const parserWorklet = useCallback(
    -        (text: string) => {
    -            'worklet';
    +    // The parser runs on the locked Live Markdown runtime, so reading a SharedValue here can deadlock the app.
    +    const defaultParser = (text: string) => {
    +        'worklet';
     
    -            if (parser) {
    -                return parser(text);
    -            }
    -
    -            return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
    -        },
    -        [currentUserMentions, mentionsSharedVal, parser],
    -    );
    -
    -    useEffect(() => {
    -        scheduleOnLiveMarkdownRuntime(() => {
    -            'worklet';
    -
    -            mentionsSharedVal.set(availableLoginsList);
    -        });
    -    }, [availableLoginsList, mentionsSharedVal]);
    +        return parseExpensiMarkWithShortMentions(text, availableLoginsList, currentUserMentions);
    +    };
     
         return (
             // We need this wrapper view in order to apply Fullstory masking since the Markdown input
    @@ -92,7 +75,7 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ref, forwardedFSClass =
                     allowFontScaling={false}
                     textBreakStrategy="simple"
                     keyboardAppearance={theme.colorScheme}
    -                parser={parserWorklet}
    +                parser={parser ?? defaultParser}
                     ref={inputRef}
                     formatSelection={toggleSelectionFormat}
                     // eslint-disable-next-line react/forbid-component-props
    diff --git a/src/components/Search/SearchAutocompleteInput.tsx b/src/components/Search/SearchAutocompleteInput.tsx
    index aa57010ec48..72b6ce348b2 100644
    --- a/src/components/Search/SearchAutocompleteInput.tsx
    +++ b/src/components/Search/SearchAutocompleteInput.tsx
    @@ -13,7 +13,6 @@ import useTheme from '@hooks/useTheme';
     import useThemeStyles from '@hooks/useThemeStyles';
     
     import {setSearchContext} from '@libs/actions/Search';
    -import scheduleOnLiveMarkdownRuntime from '@libs/scheduleOnLiveMarkdownRuntime';
     import {getAutocompleteCategories, getAutocompleteTags, parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils';
     import {expensifyLoginsSelector} from '@libs/UserUtils';
     
    @@ -23,12 +22,14 @@ import ONYXKEYS from '@src/ONYXKEYS';
     import type {ForwardedRef} from 'react';
     import type {StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native';
     
    -import React, {useEffect, useRef} from 'react';
    +import React, {useRef} from 'react';
     import {View} from 'react-native';
     import Animated, {interpolateColor, useAnimatedStyle, useSharedValue} from 'react-native-reanimated';
     
     import type {SubstitutionMap} from './SearchRouter/getQueryWithSubstitutions';
     
    +const EMPTY_AUTOCOMPLETE_LIST: string[] = [];
    +
     type SearchAutocompleteInputProps = {
         /** Value of TextInput */
         value: string;
    @@ -103,23 +104,19 @@ function SearchAutocompleteInput({
         const inputEventTimeStampRef = useRef<number | undefined>(undefined);
         const autoFocusAfterNav = useFocusAfterNav(inputRef, shouldDelayFocus);
         const {currencyList} = useCurrencyListState();
    -    const currencyAutocompleteList = Object.keys(currencyList).filter((currencyCode) => !currencyList[currencyCode]?.retired);
    -    const currencySharedValue = useSharedValue(currencyAutocompleteList);
    +    const currencies = new Set(Object.keys(currencyList).filter((currencyCode) => !currencyList[currencyCode]?.retired));
     
    -    const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
    -    const categoryAutocompleteList = getAutocompleteCategories(allPolicyCategories);
    -    const categorySharedValue = useSharedValue(categoryAutocompleteList);
    +    const [categoryNames = EMPTY_AUTOCOMPLETE_LIST] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {selector: getAutocompleteCategories});
    +    const categories = new Set(categoryNames);
     
    -    const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
    -    const tagAutocompleteList = getAutocompleteTags(allPoliciesTags);
    -    const tagSharedValue = useSharedValue(tagAutocompleteList);
    +    const [tagNames = EMPTY_AUTOCOMPLETE_LIST] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: getAutocompleteTags});
    +    const tags = new Set(tagNames);
     
         const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector});
    -    const emailList = Object.keys(loginList ?? {});
    -    const emailListSharedValue = useSharedValue(emailList);
    +    const userLogins = new Set(Object.keys(loginList ?? {}));
     
         const {exportedToFilterOptions} = useExportedToFilterOptions();
    -    const exportedToSharedValue = useSharedValue(exportedToFilterOptions);
    +    const exportedTo = new Set(exportedToFilterOptions);
     
         const offlineMessage: string = isOffline && shouldShowOfflineMessage ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';
     
    @@ -139,51 +136,13 @@ function SearchAutocompleteInput({
             };
         });
     
    -    useEffect(() => {
    -        scheduleOnLiveMarkdownRuntime(() => {
    -            'worklet';
    -
    -            emailListSharedValue.set(emailList);
    -        });
    -    }, [emailList, emailListSharedValue]);
    -
    -    useEffect(() => {
    -        scheduleOnLiveMarkdownRuntime(() => {
    -            'worklet';
    -
    -            currencySharedValue.set(currencyAutocompleteList);
    -        });
    -    }, [currencyAutocompleteList, currencySharedValue]);
    -
    -    useEffect(() => {
    -        scheduleOnLiveMarkdownRuntime(() => {
    -            'worklet';
    -
    -            categorySharedValue.set(categoryAutocompleteList);
    -        });
    -    }, [categorySharedValue, categoryAutocompleteList]);
    -
    -    useEffect(() => {
    -        scheduleOnLiveMarkdownRuntime(() => {
    -            'worklet';
    -
    -            tagSharedValue.set(tagAutocompleteList);
    -        });
    -    }, [tagSharedValue, tagAutocompleteList]);
    -
    -    useEffect(() => {
    -        scheduleOnLiveMarkdownRuntime(() => {
    -            'worklet';
    -
    -            exportedToSharedValue.set(exportedToFilterOptions);
    -        });
    -    }, [exportedToSharedValue, exportedToFilterOptions]);
    -
         const currentUserDisplayName = currentUserPersonalDetails.displayName ?? '';
    +
    +    // The parser runs on the locked Live Markdown runtime, so reading a SharedValue here can deadlock the app.
         const parser = (input: string) => {
             'worklet';
     
    -        return parseForLiveMarkdown(input, currentUserDisplayName, substitutionMap, emailListSharedValue, currencySharedValue, categorySharedValue, tagSharedValue, exportedToSharedValue);
    +        return parseForLiveMarkdown(input, currentUserDisplayName, substitutionMap, {userLogins, currencies, categories, tags, exportedTo});
         };
     
         const clearInput = () => {
    diff --git a/src/hooks/useExportedToFilterOptions.ts b/src/hooks/useExportedToFilterOptions.ts
    index 1fdf5a56830..e190f008117 100644
    --- a/src/hooks/useExportedToFilterOptions.ts
    +++ b/src/hooks/useExportedToFilterOptions.ts
    @@ -50,12 +50,10 @@ function exportedToPoliciesSelector(policies: OnyxCollection<Policy>): OnyxColle
      */
     export default function useExportedToFilterOptions(): UseExportedToFilterDataResult {
         const {currentSearchQueryJSON} = useSearchQueryContext();
    -    const policyIDs = getFilterFromQuery(currentSearchQueryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID);
    -
         const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: exportedToPoliciesSelector});
     
         // When search is scoped to workspaces, use only those policies otherwise use all.
    -    const policiesToUse = getAllPolicyValues(policyIDs, ONYXKEYS.COLLECTION.POLICY, policies);
    +    const policiesToUse = getAllPolicyValues(getFilterFromQuery(currentSearchQueryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID), ONYXKEYS.COLLECTION.POLICY, policies);
         const combinedExportTemplates = useCombinedExportTemplates(policiesToUse);
     
         const integrationConnectionNamesSet = new Set<string>(CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES);
    @@ -71,6 +69,8 @@ export default function useExportedToFilterOptions(): UseExportedToFilterDataRes
             standardAndCustomExportTemplates.push(filterValue);
         }
     
    +    // React Compiler stops memoizing a value made before a hook call and used after it, so the policy IDs are read again here.
    +    const policyIDs = getFilterFromQuery(currentSearchQueryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID);
         const connectedIntegrationNames = policyIDs.value?.length === 0 ? new Set<string>() : getConnectedIntegrationNamesForPolicies(policies, policyIDs);
     
         const connectedIntegrationSearchValues = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.flatMap((connectionName) => {
    diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts
    index d1655df8114..044ff135350 100644
    --- a/src/libs/SearchAutocompleteUtils.ts
    +++ b/src/libs/SearchAutocompleteUtils.ts
    @@ -6,7 +6,6 @@ import type {PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyU
     
     import type {MarkdownRange} from '@expensify/react-native-live-markdown';
     import type {OnyxCollection} from 'react-native-onyx';
    -import type {SharedValue} from 'react-native-reanimated/lib/typescript/commonTypes';
     
     import {getTagNamesFromTagsLists} from './PolicyUtils';
     import {parse} from './SearchParser/autocompleteParser';
    @@ -24,6 +23,13 @@ const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS: SearchFilterKey[] = [
     
     type AutocompleteParserRange = Pick<SearchAutocompleteQueryRange, 'value' | 'start' | 'length'> & {key: string};
     type AutocompleteParserResult = {autocomplete: AutocompleteParserRange | null; ranges: AutocompleteParserRange[]};
    +type KnownFilterValues = {
    +    userLogins: Set<string>;
    +    currencies: Set<string>;
    +    categories: Set<string>;
    +    tags: Set<string>;
    +    exportedTo: Set<string>;
    +};
     
     /** The generated parser also produces non-canonical keys. Only prove the fields used by ordinary query consumers. */
     function isAutocompleteParserResult(value: unknown): value is AutocompleteParserResult {
    @@ -195,16 +201,7 @@ const userFriendlyColumnList = new Set(
      * @private
      * Determines if a specific value in the search syntax can/should be highlighted as valid or not
      */
    -function filterOutRangesWithCorrectValue(
    -    range: AutocompleteParserRange,
    -    substitutionMap: SubstitutionMap,
    -    userLogins: SharedValue<string[]>,
    -    currencyList: SharedValue<string[]>,
    -    categoryList: SharedValue<string[]>,
    -    tagList: SharedValue<string[]>,
    -    exportedToList: SharedValue<string[]>,
    -    currentType: string,
    -) {
    +function filterOutRangesWithCorrectValue(range: AutocompleteParserRange, substitutionMap: SubstitutionMap, knownValues: KnownFilterValues, currentType: string) {
         'worklet';
     
         const typeList = Object.values(CONST.SEARCH.DATA_TYPES) as string[];
    @@ -245,12 +242,12 @@ function filterOutRangesWithCorrectValue(
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY:
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER:
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE:
    -            return substitutionMap[`${range.key}:${range.value}`] !== undefined || userLogins.get().includes(range.value) || range.value === CONST.SEARCH.ME;
    +            return substitutionMap[`${range.key}:${range.value}`] !== undefined || knownValues.userLogins.has(range.value) || range.value === CONST.SEARCH.ME;
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY:
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY:
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY:
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY:
    -            return currencyList.get().includes(range.value);
    +            return knownValues.currencies.has(range.value);
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT:
                 return footerCountList.includes(range.value);
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL:
    @@ -274,9 +271,9 @@ function filterOutRangesWithCorrectValue(
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION:
                 return actionList.includes(range.value);
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY:
    -            return categoryList.get().includes(range.value) || range.value === CONST.SEARCH.CATEGORY_EMPTY_VALUE;
    +            return knownValues.categories.has(range.value) || range.value === CONST.SEARCH.CATEGORY_EMPTY_VALUE;
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG:
    -            return tagList.get().includes(range.value);
    +            return knownValues.tags.has(range.value);
             case CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY:
                 if (currentType !== CONST.SEARCH.DATA_TYPES.EXPENSE && currentType !== CONST.SEARCH.DATA_TYPES.INVOICE && currentType !== CONST.SEARCH.DATA_TYPES.TRIP) {
                     return false;
    @@ -305,7 +302,7 @@ function filterOutRangesWithCorrectValue(
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE:
                 return range.value.length > 0;
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO:
    -            return exportedToList.get().includes(range.value);
    +            return knownValues.exportedTo.has(range.value);
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID:
             case CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID:
                 return !['', 'null', 'undefined', '0', '-1'].includes(range.value);
    @@ -333,16 +330,7 @@ function filterOutRangesWithCorrectValue(
      * Parses input string using the autocomplete parser and returns array of markdown ranges that can be used by RNMarkdownTextInput.
      * It is a simpler version of search parser that can be run on UI thread.
      */
    -function parseForLiveMarkdown(
    -    input: string,
    -    currentUserName: string,
    -    map: SubstitutionMap,
    -    userLogins: SharedValue<string[]>,
    -    currencyList: SharedValue<string[]>,
    -    categoryList: SharedValue<string[]>,
    -    tagList: SharedValue<string[]>,
    -    exportedToList: SharedValue<string[]>,
    -): MarkdownRange[] {
    +function parseForLiveMarkdown(input: string, currentUserName: string, map: SubstitutionMap, knownValues: KnownFilterValues): MarkdownRange[] {
         'worklet';
     
         const parsedAutocomplete: unknown = parse(input);
    @@ -354,9 +342,9 @@ function parseForLiveMarkdown(
         const currentType = typeRange?.value ?? CONST.SEARCH.DATA_TYPES.EXPENSE;
     
         return ranges
    -        .filter((range) => filterOutRangesWithCorrectValue(range, map, userLogins, currencyList, categoryList, tagList, exportedToList, currentType))
    +        .filter((range) => filterOutRangesWithCorrectValue(range, map, knownValues, currentType))
             .map((range) => {
    -            const isCurrentUserMention = userLogins.get().includes(range.value) || range.value === currentUserName || range.value === CONST.SEARCH.ME;
    +            const isCurrentUserMention = knownValues.userLogins.has(range.value) || range.value === currentUserName || range.value === CONST.SEARCH.ME;
                 const type = isCurrentUserMention ? 'mention-here' : 'mention-user';
     
                 return {start: range.start, type, length: range.length};
    @@ -417,3 +405,4 @@ export {
         parseForAutocomplete,
         parseForLiveMarkdown,
     };
    +export type {KnownFilterValues};
    diff --git a/src/libs/scheduleOnLiveMarkdownRuntime/index.native.tsx b/src/libs/scheduleOnLiveMarkdownRuntime/index.native.tsx
    deleted file mode 100644
    index 650f577bd5a..00000000000
    --- a/src/libs/scheduleOnLiveMarkdownRuntime/index.native.tsx
    +++ /dev/null
    @@ -1,8 +0,0 @@
    -import {getWorkletRuntime} from '@expensify/react-native-live-markdown';
    -import {scheduleOnRuntime} from 'react-native-worklets';
    -
    -function scheduleOnLiveMarkdownRuntime<Args extends unknown[], ReturnType>(worklet: (...args: Args) => ReturnType, ...args: Args): void {
    -    scheduleOnRuntime(getWorkletRuntime(), worklet, ...args);
    -}
    -
    -export default scheduleOnLiveMarkdownRuntime;
    diff --git a/src/libs/scheduleOnLiveMarkdownRuntime/index.tsx b/src/libs/scheduleOnLiveMarkdownRuntime/index.tsx
    deleted file mode 100644
    index 55f2dc09544..00000000000
    --- a/src/libs/scheduleOnLiveMarkdownRuntime/index.tsx
    +++ /dev/null
    @@ -1,6 +0,0 @@
    -// scheduleOnRuntime() isn't supported in web by react-native-worklets
    -function scheduleOnLiveMarkdownRuntime<Args extends unknown[], ReturnType>(worklet: (...args: Args) => ReturnType, ...args: Args): void {
    -    worklet(...args);
    -}
    -
    -export default scheduleOnLiveMarkdownRuntime;
    diff --git a/tests/ui/components/SearchAutocompleteInputTest.tsx b/tests/ui/components/SearchAutocompleteInputTest.tsx
    index 016653b4ab9..96a6abd062b 100644
    --- a/tests/ui/components/SearchAutocompleteInputTest.tsx
    +++ b/tests/ui/components/SearchAutocompleteInputTest.tsx
    @@ -34,8 +34,14 @@ jest.mock('@hooks/useThemeStyles', () => () => ({searchRouterTextInputContainer:
     jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key}));
     jest.mock('@hooks/useNetwork', () => () => ({isOffline: false}));
     jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({displayName: 'Current User'}));
    -jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListState: () => ({currencyList: {}})}));
    -jest.mock('@hooks/useExportedToFilterOptions', () => () => ({exportedToFilterOptions: []}));
    +jest.mock('@hooks/useCurrencyList', () => {
    +    const currencyListState = {currencyList: {}};
    +    return {useCurrencyListState: () => currencyListState};
    +});
    +jest.mock('@hooks/useExportedToFilterOptions', () => {
    +    const exportedToFilterData = {exportedToFilterOptions: []};
    +    return () => exportedToFilterData;
    +});
     jest.mock('@hooks/useOnyx', () => () => [undefined]);
     jest.mock('@hooks/useFocusAfterNav', () => jest.fn(() => false));
     jest.mock('@libs/actions/Search', () => ({setSearchContext: jest.fn()}));
    @@ -226,4 +232,30 @@ describe('SearchAutocompleteInput', () => {
             expect(changes.mock.invocationCallOrder.at(0)).toBeLessThan(contextCallOrder);
             expect(props?.autoFocus).toBe(true);
         });
    +
    +    it('keeps the same markdown parser while the query changes', () => {
    +        // Given the input rendered with a query and unchanged autocomplete data
    +        const substitutionMap = {};
    +        const {rerender} = render(
    +            <SearchAutocompleteInput
    +                value="from:"
    +                onSearchQueryChange={jest.fn()}
    +                substitutionMap={substitutionMap}
    +            />,
    +        );
    +        const firstParser = input.mock.lastCall?.[0].parser;
    +
    +        // When the user types and the query changes
    +        rerender(
    +            <SearchAutocompleteInput
    +                value="from:me"
    +                onSearchQueryChange={jest.fn()}
    +                substitutionMap={substitutionMap}
    +            />,
    +        );
    +
    +        // Then the input gets the same parser, so Live Markdown doesn't register a new one on every keystroke
    +        expect(firstParser).toBeDefined();
    +        expect(input.mock.lastCall?.[0].parser).toBe(firstParser);
    +    });
     });
    diff --git a/tests/unit/SearchAutocompleteUtilsTest.ts b/tests/unit/SearchAutocompleteUtilsTest.ts
    index f23f1679d08..0b8cb9cba7b 100644
    --- a/tests/unit/SearchAutocompleteUtilsTest.ts
    +++ b/tests/unit/SearchAutocompleteUtilsTest.ts
    @@ -2,12 +2,11 @@ import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWith
     
     import {getExportLabelForConnection, getStandardExportTemplateDisplayName, isStandardExportTemplate, isStandardExportTemplateLabel} from '@libs/AccountingUtils';
     import {getTrimmedUserSearchQueryPreservingComma, parseForAutocomplete, parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils';
    +import type {KnownFilterValues} from '@libs/SearchAutocompleteUtils';
     import {parse} from '@libs/SearchParser/autocompleteParser';
     
     import CONST from '@src/CONST';
     
    -import createSharedValueMock from '../utils/createSharedValueMock';
    -
     describe('SearchAutocompleteUtils', () => {
         describe('parseForLiveMarkdown', () => {
             const currentUserName = 'currentuser@example.com';
    @@ -20,20 +19,22 @@ describe('SearchAutocompleteUtils', () => {
                 'policyID:ABC123': 'Test Policy',
             };
     
    -        const mockUserLogins = createSharedValueMock(['john@example.com', 'jane@example.com', 'currentuser@example.com']);
    -        const mockCurrencyList = createSharedValueMock(['USD', 'EUR', 'GBP']);
    -        const mockCategoryList = createSharedValueMock(['Travel', 'Meals', 'Office Supplies']);
    -        const mockTagList = createSharedValueMock(['Project A', 'Project B', 'Urgent']);
    -        const mockExportedToList = createSharedValueMock([
    -            ...CONST.POLICY.CONNECTIONS.EXPORTED_TO_INTEGRATION_DISPLAY_NAMES,
    -            CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT,
    -            CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT,
    -        ] as string[]);
    +        const mockKnownValues: KnownFilterValues = {
    +            userLogins: new Set(['john@example.com', 'jane@example.com', 'currentuser@example.com']),
    +            currencies: new Set(['USD', 'EUR', 'GBP']),
    +            categories: new Set(['Travel', 'Meals', 'Office Supplies']),
    +            tags: new Set(['Project A', 'Project B', 'Urgent']),
    +            exportedTo: new Set<string>([
    +                ...CONST.POLICY.CONNECTIONS.EXPORTED_TO_INTEGRATION_DISPLAY_NAMES,
    +                CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT,
    +                CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT,
    +            ]),
    +        };
     
             it('should highlight valid filters with correct values', () => {
                 const input = 'type:expense from:john@example.com currency:USD';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 5, type: 'mention-user', length: 7}, // type:expense
    @@ -45,7 +46,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight current user mentions with mention-here type', () => {
                 const input = 'from:currentuser@example.com';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 5, type: 'mention-here', length: 23}, // from:currentuser@example.com (length is 23, not 24)
    @@ -55,7 +56,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight the Spend footer selections in either spelling', () => {
                 const input = 'footer-count:reports footerTotal:non-reimbursable footer-currency:EUR';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 13, type: 'mention-user', length: 7}, // footer-count:reports
    @@ -67,7 +68,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight a Spend footer selection the footer cannot render', () => {
                 const input = 'footer-count:pandas footerTotal:whatever footer-currency:NOPE';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -75,7 +76,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight new PURCHASE_CURRENCY filter', () => {
                 const input = 'purchaseCurrency:USD';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 17, type: 'mention-user', length: 3}, // purchaseCurrency:USD
    @@ -85,7 +86,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight new PURCHASE_AMOUNT filter with valid amount', () => {
                 const input = 'purchaseAmount:100.50';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 15, type: 'mention-user', length: 6}, // purchaseAmount:100.50
    @@ -95,7 +96,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight PURCHASE_AMOUNT filter with invalid amount', () => {
                 const input = 'purchaseAmount:invalid';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -103,7 +104,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight WITHDRAWAL_STATUS filter with a valid settlement status value', () => {
                 const input = 'withdrawal-status:pending';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 18, type: 'mention-user', length: 7}, // withdrawal-status:pending
    @@ -113,7 +114,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight WITHDRAWAL_STATUS filter with invalid value', () => {
                 const input = 'withdrawal-status:invalid';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -121,7 +122,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight WITHDRAWAL_ID filter with valid ID because it is not in autocomplete parser', () => {
                 const input = 'withdrawalID:12345';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 // withdrawalID is not in the autocomplete parser grammar
                 expect(result).toEqual([
    @@ -132,7 +133,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight WITHDRAWAL_ID filter because it is not supported in autocomplete parser', () => {
                 const input = 'withdrawalID:12345';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 // withdrawalID is not in the autocomplete parser grammar, so it won't be highlighted
                 expect(result).toEqual([
    @@ -143,7 +144,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight new TITLE filter with non-empty value', () => {
                 const input = 'title:"Project Meeting"';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 6, type: 'mention-user', length: 17}, // title:"Project Meeting"
    @@ -153,7 +154,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight TITLE filter with empty value', () => {
                 const input = 'title:';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -161,7 +162,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight EXPORTED_TO filter when value is in predefined integrations or custom template list', () => {
                 const input = 'exported-to:Xero';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([{start: 12, type: 'mention-user', length: 4}]);
             });
    @@ -169,7 +170,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight EXPORTED_TO filter for valid custom template', () => {
                 const input = 'exported-to:"All Data - Report Level Export"';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([{start: 12, type: 'mention-user', length: 32}]);
             });
    @@ -177,7 +178,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight EXPORTED_TO filter for expense level export template', () => {
                 const input = 'exported-to:"All Data - Expense Level Export"';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([{start: 12, type: 'mention-user', length: 33}]);
             });
    @@ -185,7 +186,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight EXPORTED_TO filter when value is not in list', () => {
                 const input = 'exported-to:RandomInvalidValue';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -193,7 +194,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight new ATTENDEE filter with valid user', () => {
                 const input = 'attendee:john@example.com';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 9, type: 'mention-here', length: 16}, // attendee:john@example.com (john is treated as current user context)
    @@ -203,7 +204,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight ATTENDEE filter with current user as mention-here', () => {
                 const input = 'attendee:currentuser@example.com';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 9, type: 'mention-here', length: 23}, // attendee:currentuser@example.com (length is 23)
    @@ -213,7 +214,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should highlight FROM filter with the "me" keyword as mention-here', () => {
                 const input = 'from:me';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 5, type: 'mention-here', length: 2}, // from:me
    @@ -231,7 +232,7 @@ describe('SearchAutocompleteUtils', () => {
                 ];
     
                 for (const [input, start] of inputs) {
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
                     expect(result).toEqual([{start, type: 'mention-here', length: 2}]);
                 }
             });
    @@ -239,7 +240,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should handle complex queries with multiple new filters', () => {
                 const input = 'type:expense purchaseCurrency:USD purchaseAmount:50.00 title:"Expense Report" attendee:john@example.com';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 5, type: 'mention-user', length: 7}, // type:expense
    @@ -253,7 +254,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should handle mixed valid and invalid filter values', () => {
                 const input = 'purchaseAmount:invalid title:"Valid Title" purchaseCurrency:INVALID';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 29, type: 'mention-user', length: 13}, // title:"Valid Title" (adjusted position)
    @@ -266,7 +267,7 @@ describe('SearchAutocompleteUtils', () => {
                 for (const amount of validAmounts) {
                     const input = `purchaseAmount:${amount}`;
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toHaveLength(1);
                     expect(result.at(0)?.type).toBe('mention-user');
    @@ -279,7 +280,7 @@ describe('SearchAutocompleteUtils', () => {
                 for (const amount of invalidAmounts) {
                     const input = `purchaseAmount:${amount}`;
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([]);
                 }
    @@ -294,16 +295,7 @@ describe('SearchAutocompleteUtils', () => {
     
                 const input = 'attendee:emp123';
     
    -            const result = parseForLiveMarkdown(
    -                input,
    -                currentUserName,
    -                mockSubstitutionMapWithNewFilters,
    -                mockUserLogins,
    -                mockCurrencyList,
    -                mockCategoryList,
    -                mockTagList,
    -                mockExportedToList,
    -            );
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMapWithNewFilters, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 9, type: 'mention-user', length: 6}, // attendee:emp123
    @@ -311,7 +303,7 @@ describe('SearchAutocompleteUtils', () => {
             });
     
             it('should return empty array for empty input', () => {
    -            const result = parseForLiveMarkdown('', currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown('', currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -319,7 +311,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should handle queries with only free text (no filters)', () => {
                 const input = 'just some random text without filters';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -332,7 +324,7 @@ describe('SearchAutocompleteUtils', () => {
                     'bankAccount:Chase': '42',
                 };
     
    -            const result = parseForLiveMarkdown(input, currentUserName, substitutionMapWithBankAccount, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, substitutionMapWithBankAccount, mockKnownValues);
     
                 expect(result).toEqual([{start: 12, type: 'mention-user', length: 5}]);
             });
    @@ -340,7 +332,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight bankAccount filter when value is missing from substitution map', () => {
                 const input = 'bankAccount:99';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([]);
             });
    @@ -349,7 +341,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('highlights valid positive integer', () => {
                     const input = 'limit:10';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([{start: 6, type: 'mention-user', length: 2}]);
                 });
    @@ -357,7 +349,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('does not highlight zero value', () => {
                     const input = 'limit:0';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([]);
                 });
    @@ -365,7 +357,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('does not highlight non-integer value', () => {
                     const input = 'limit:10.5';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([]);
                 });
    @@ -373,7 +365,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('does not highlight negative value', () => {
                     const input = 'limit:-5';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([]);
                 });
    @@ -381,7 +373,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('highlights limit in complex query with other filters', () => {
                     const input = 'type:expense limit:50 currency:USD';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([
                         {start: 5, type: 'mention-user', length: 7}, // type:expense
    @@ -393,7 +385,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('does not highlight empty limit value', () => {
                     const input = 'limit:';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([]);
                 });
    @@ -402,7 +394,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should handle valid AMOUNT filters but not invalid TOTAL amounts', () => {
                 const input = 'amount:-50.25';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 expect(result).toEqual([
                     {start: 7, type: 'mention-user', length: 6}, // amount:-50.25
    @@ -412,7 +404,7 @@ describe('SearchAutocompleteUtils', () => {
             it('should not highlight TOTAL filter with amounts exceeding AMOUNT_MAX_LENGTH digits', () => {
                 const input = 'total:99999999999';
     
    -            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +            const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                 // Total amounts with more than AMOUNT_MAX_LENGTH digits fail validation
                 expect(result).toEqual([]);
    @@ -425,7 +417,7 @@ describe('SearchAutocompleteUtils', () => {
                     for (const view of validViews) {
                         const input = `view:${view}`;
     
    -                    const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                    const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                         expect(result).toEqual([{start: 5, type: 'mention-user', length: view.length}]);
                     }
    @@ -434,7 +426,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('does not highlight invalid view values', () => {
                     const input = 'view:invalid';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([]);
                 });
    @@ -442,7 +434,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('highlights view in complex query with other filters', () => {
                     const input = 'type:expense view:bar category:Travel';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([
                         {start: 5, type: 'mention-user', length: 7}, // type:expense
    @@ -454,7 +446,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('does not highlight empty view value', () => {
                     const input = 'view:';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([]);
                 });
    @@ -462,7 +454,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('highlights view:table in query', () => {
                     const input = 'view:table';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([{start: 5, type: 'mention-user', length: 5}]);
                 });
    @@ -470,7 +462,7 @@ describe('SearchAutocompleteUtils', () => {
                 it('highlights view:bar in query', () => {
                     const input = 'view:bar';
     
    -                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList);
    +                const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockKnownValues);
     
                     expect(result).toEqual([{start: 5, type: 'mention-user', length: 3}]);
                 });
    @@ -648,26 +640,26 @@ describe('ordinary autocomplete parser boundaries', () => {
             // Given an erased result that cannot prove the consumed ordinary fields.
             const parser = jest.spyOn(jest.requireActual<{parse: typeof parse}>('@libs/SearchParser/autocompleteParser'), 'parse').mockReturnValue(result);
             const log = jest.spyOn(console, 'error').mockImplementation(() => {});
    -        const emptyList = createSharedValueMock<string[]>([]);
    +        const noKnownValues: KnownFilterValues = {userLogins: new Set(), currencies: new Set(), categories: new Set(), tags: new Set(), exportedTo: new Set()};
             // When each existing consumer handles the same boundary result.
             expect(parseForAutocomplete('from:alice')).toBeUndefined();
             // Then autocomplete logs once while live markdown safely skips highlighting.
             expect(log).toHaveBeenCalledTimes(1);
    -        expect(parseForLiveMarkdown('from:alice', '', {}, emptyList, emptyList, emptyList, emptyList, emptyList)).toEqual([]);
    +        expect(parseForLiveMarkdown('from:alice', '', {}, noKnownValues)).toEqual([]);
             expect(parser).toHaveBeenCalledTimes(2);
         });
     
         it('keeps real parser exceptions caught for autocomplete and propagated for live markdown', () => {
             // Given the real parser rejecting an unsupported input type at its public boundary.
             const log = jest.spyOn(console, 'error').mockImplementation(() => {});
    -        const emptyList = createSharedValueMock<string[]>([]);
    +        const noKnownValues: KnownFilterValues = {userLogins: new Set(), currencies: new Set(), categories: new Set(), tags: new Set(), exportedTo: new Set()};
             // When a deliberately malformed input reaches the existing consumers.
             // @ts-expect-error This malformed runtime input tests the parser's existing exception handling.
             expect(parseForAutocomplete(null)).toBeUndefined();
             // Then the same production parser exception propagates through live markdown.
             expect(() => {
                 // @ts-expect-error Keep the exception limited to the invalid parser input.
    -            parseForLiveMarkdown(null, '', {}, emptyList, emptyList, emptyList, emptyList, emptyList);
    +            parseForLiveMarkdown(null, '', {}, noKnownValues);
             }).toThrow();
             expect(log).toHaveBeenCalledTimes(1);
         });
    @@ -680,9 +672,9 @@ describe('ordinary autocomplete parser boundaries', () => {
             ['merchant:""', []],
         ])('highlights the real parser result for %s', (query, expected) => {
             // Given actual generated ranges and empty lookup data, without substituting the parser.
    -        const emptyList = createSharedValueMock<string[]>([]);
    +        const noKnownValues: KnownFilterValues = {userLogins: new Set(), currencies: new Set(), categories: new Set(), tags: new Set(), exportedTo: new Set()};
             // When live markdown applies its existing highlighting rules.
    -        const result = parseForLiveMarkdown(query, '', {}, emptyList, emptyList, emptyList, emptyList, emptyList);
    +        const result = parseForLiveMarkdown(query, '', {}, noKnownValues);
             // Then column equality, dynamic keys and unsupported key spelling retain their outcomes.
             expect(result).toEqual(expected);
         });
    diff --git a/tests/unit/hooks/useExportedToFilterOptions.test.ts b/tests/unit/hooks/useExportedToFilterOptions.test.ts
    index f5872782b24..5695b8f16e6 100644
    --- a/tests/unit/hooks/useExportedToFilterOptions.test.ts
    +++ b/tests/unit/hooks/useExportedToFilterOptions.test.ts
    @@ -156,4 +156,17 @@ describe('useExportedToFilterOptions', () => {
             // Then the reconciliation flag is on, since the selector keeps the role and card product fields
             expect(mockGetExportTemplates.mock.calls.at(0)?.at(8)).toBe(true);
         });
    +
    +    it('returns the same options array when it re-renders with unchanged data', async () => {
    +        // Given a connected policy, so the options include an integration label next to the export templates
    +        await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, buildPolicyWithConnection('1', CONST.POLICY.CONNECTIONS.NAME.QBO));
    +        const {result, rerender} = renderHook(() => useExportedToFilterOptions());
    +        const firstOptions = result.current.exportedToFilterOptions;
    +
    +        // When the hook re-renders without a data change, as it does on every keystroke in the Search input
    +        rerender({});
    +
    +        // Then it returns the same array, so the Search input keeps its markdown parser instead of registering a new one
    +        expect(result.current.exportedToFilterOptions).toBe(firstOptions);
    +    });
     });
  12. MelvinBot commented on Oct 25, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: Stop reading SharedValues in Live Markdown parsers to prevent an App Hang deadlock
    Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.

    I implemented your diff as written and updated my proposal with all 7 points, including the corrected timeline and the Android Sentry IDs.

    Checks run:

    • 3 changed test files: 105 tests passed. The new useExportedToFilterOptions identity test fails without the hook change and passes with it.
    • Related tests (Search|Composer|Markdown|ExportedTo): 149 suites, 3,279 tests passed.
    • typecheck, lint-changed, the React Compiler check, and oxfmt all passed on the changed files.
    • Not run: the Android emulator test. The CI emulator was unreachable (adb: device 'emulator-5554' not found), so the device check of composer and Search bar highlighting still needs to be done by hand.

    I don't have push access to your fork, so the branch is on upstream. The PR has steps to move it to your fork.

  13. melvin-bot commented on Oct 25, 2026

    @melvin-bot

    ❌ There was an error making the offer to @TaduJR for the Reviewer role. The BZ member will need to manually hire the contributor.

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.ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions