From b2c6747ea99bfdb9b1658d225c4e35552b30de80 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 8 Sep 2026 15:19:18 +0200 Subject: [PATCH 01/10] fix: keep the parser registration alive across effect remounts MarkdownTextInput registered its parser worklet from a useMemo and only unregistered it from an effect cleanup. Whenever React remounted the effects without unmounting the component (StrictMode in development, a hidden React that is revealed again), the cleanup erased the entry from the C++ registry while the decorator view kept the same parserId and the re-run of the effect registered nothing. Every later parse resolved the id with std::unordered_map::at and threw: iOS caught std::out_of_range and returned no ranges, Android let it cross the JNI boundary where fbjni turns it into a Java exception that MarkdownParser.java swallows. The input silently stopped formatting markdown for the rest of its life. Registration and unregistration now live in one layout effect and the new id reaches the decorator view through state. The first registration stays in the first render, so a mount still carries a resolvable id in its first commit and does not pay a second commit and a re-measure of the input. The effect body also replaces a registration whose parser changed identity while the effects were not mounted (an input inside a hidden ) and unregisters the stale one. A layout effect keeps the re-registration in the same task as the commit that ran the cleanup, so the two commits usually collapse into one native transaction instead of leaving the view on the erased id for a frame. No native change is needed: both parsers already return no ranges for an id the registry cannot resolve, and the ranges are cached per (text, parserId), so the replacement id re-parses the same text as soon as the view receives it. The new Jest suite renders the component through react-dom into jsdom and covers mount, unmount, StrictMode, a hidden and revealed , a parser identity change inside a hidden and a plain parser identity change; @types/react-dom is added so the suite typechecks. --- package-lock.json | 11 ++ package.json | 1 + src/MarkdownTextInput.tsx | 49 ++++++- src/__tests__/parserRegistration.test.tsx | 163 ++++++++++++++++++++++ 4 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/parserRegistration.test.tsx diff --git a/package-lock.json b/package-lock.json index 04a55a4bd..b522fb14b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@release-it/conventional-changelog": "^5.0.0", "@types/jest": "^29.5.14", "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.7", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", "del-cli": "^5.0.0", @@ -5288,6 +5289,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", diff --git a/package.json b/package.json index 7c67d13e0..566d13020 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@release-it/conventional-changelog": "^5.0.0", "@types/jest": "^29.5.14", "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.7", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", "del-cli": "^5.0.0", diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index 935b05548..520917681 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -70,6 +70,47 @@ type FormatSelectionResult = { type MarkdownTextInput = TextInput & React.Component; +type ParserRegistration = { + parser: MarkdownTextInputProps['parser']; + parserId: number; +}; + +// The effect body registers and its cleanup unregisters, so a remount of the effects alone (StrictMode, a hidden +// `` that is revealed) hands the decorator view a fresh id instead of leaving it on the erased one. The first +// registration happens in render, which spares every mount a second commit and a re-measure of the input. +// A layout effect flushes the replacement id in the same task as the commit that ran the cleanup, so both usually reach +// the mounting layer as one native transaction. The native parser formats nothing for an id it cannot resolve. +function useParserId(parser: MarkdownTextInputProps['parser']): number { + const initialRegistrationRef = React.useRef(null); + if (initialRegistrationRef.current === null) { + initialRegistrationRef.current = {parser, parserId: registerParser(parser)}; + } + const [parserId, setParserId] = React.useState(initialRegistrationRef.current.parserId); + const liveRegistrationRef = React.useRef(initialRegistrationRef.current); + + React.useLayoutEffect(() => { + const unregisterLiveParser = () => { + if (liveRegistrationRef.current === null) { + return; + } + unregisterParser(liveRegistrationRef.current.parserId); + liveRegistrationRef.current = null; + }; + + if (liveRegistrationRef.current?.parser === parser) { + return unregisterLiveParser; + } + + unregisterLiveParser(); + const nextParserId = registerParser(parser); + liveRegistrationRef.current = {parser, parserId: nextParserId}; + setParserId(nextParserId); + return unregisterLiveParser; + }, [parser]); + + return parserId; +} + function processColorsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { const output = JSON.parse(JSON.stringify(input)); @@ -104,13 +145,7 @@ const MarkdownTextInput = React.forwardRef { - return registerParser(props.parser); - }, [props.parser]); - - React.useEffect(() => { - return () => unregisterParser(parserId); - }, [parserId]); + const parserId = useParserId(props.parser); return ( `, so the component renders in jsdom through `react-dom`. + */ +const liveParserIds = new Set(); +let nextParserId = 1; +let registerCallCount = 0; + +jest.mock('react-native', () => ({ + Platform: {OS: 'ios', select: (options: {ios?: unknown; default?: unknown}) => options.ios ?? options.default}, + StyleSheet: {create: (styles: T) => styles}, + TextInput: (props: {testID?: string}) => , + TurboModuleRegistry: {get: () => null}, + processColor: (color: unknown) => color, +})); + +jest.mock('react-native-worklets', () => ({ + createSerializable: (worklet: unknown) => worklet, + createWorkletRuntime: () => ({}), +})); + +jest.mock('../MarkdownTextInputDecoratorViewNativeComponent', () => ({ + __esModule: true, + default: (props: {parserId: number; children: React.ReactNode}) =>
{props.children}
, +})); + +// The component refuses a parser that is not a worklet, and the worklets babel plugin does not run under Jest, so the +// hash that marks a function as a worklet is attached by hand. +function createParserWorklet(workletHash: number) { + return Object.assign((): MarkdownRange[] => [], {__workletHash: workletHash}); +} + +const parser = createParserWorklet(1); + +let container: HTMLDivElement; +let root: Root; + +function renderIntoRoot(element: React.ReactElement) { + act(() => { + root.render(element); + }); +} + +function renderInActivity(isHidden: boolean, currentParser: MarkdownTextInputProps['parser'] = parser) { + renderIntoRoot( + + + , + ); +} + +function getDecoratorParserId(): number { + const decorator = container.querySelector('[data-parser-id]'); + const parserId = Number(decorator?.getAttribute('data-parser-id')); + if (!Number.isInteger(parserId)) { + throw new Error('The decorator view rendered without a parser id'); + } + return parserId; +} + +function expectDecoratorOnTheOnlyLiveParserId() { + expect(liveParserIds.has(getDecoratorParserId())).toBe(true); + expect(liveParserIds.size).toBe(1); +} + +describe('MarkdownTextInput parser registration', () => { + beforeEach(() => { + liveParserIds.clear(); + nextParserId = 1; + registerCallCount = 0; + global.jsi_setMarkdownRuntime = jest.fn(); + global.jsi_registerMarkdownWorklet = () => { + const parserId = nextParserId; + nextParserId += 1; + registerCallCount += 1; + liveParserIds.add(parserId); + return parserId; + }; + global.jsi_unregisterMarkdownWorklet = (parserId: number) => { + liveParserIds.delete(parserId); + }; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('registers the parser once and renders its id on mount', () => { + renderIntoRoot(); + + expect(registerCallCount).toBe(1); + expectDecoratorOnTheOnlyLiveParserId(); + }); + + it('unregisters the parser on unmount', () => { + renderIntoRoot(); + + act(() => { + root.unmount(); + }); + + expect(liveParserIds.size).toBe(0); + }); + + it('keeps the decorator on a live id under StrictMode', () => { + renderIntoRoot( + + + , + ); + + expectDecoratorOnTheOnlyLiveParserId(); + }); + + it('keeps the decorator on a live id after a hidden is revealed', () => { + renderInActivity(false); + expectDecoratorOnTheOnlyLiveParserId(); + + renderInActivity(true); + renderInActivity(false); + expectDecoratorOnTheOnlyLiveParserId(); + + renderInActivity(true); + renderInActivity(false); + expectDecoratorOnTheOnlyLiveParserId(); + }); + + it('drops the initial registration when the parser changes identity inside a hidden ', () => { + const nextParser = createParserWorklet(2); + + renderInActivity(true); + renderInActivity(true, nextParser); + renderInActivity(false, nextParser); + + expectDecoratorOnTheOnlyLiveParserId(); + }); + + it('moves the decorator to a live id and drops the previous one when the parser changes identity', () => { + renderIntoRoot(); + const initialParserId = getDecoratorParserId(); + + renderIntoRoot(); + + expect(getDecoratorParserId()).not.toBe(initialParserId); + expectDecoratorOnTheOnlyLiveParserId(); + }); +}); From d2930c0ffb8627ceed01800f705a9d6032ea094c Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 8 Sep 2026 17:42:19 +0200 Subject: [PATCH 02/10] refactor: trim the parser registration comment and test helpers --- src/MarkdownTextInput.tsx | 8 +++----- src/__tests__/parserRegistration.test.tsx | 18 +++++++----------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index 520917681..8be2fc63f 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -75,11 +75,9 @@ type ParserRegistration = { parserId: number; }; -// The effect body registers and its cleanup unregisters, so a remount of the effects alone (StrictMode, a hidden -// `` that is revealed) hands the decorator view a fresh id instead of leaving it on the erased one. The first -// registration happens in render, which spares every mount a second commit and a re-measure of the input. -// A layout effect flushes the replacement id in the same task as the commit that ran the cleanup, so both usually reach -// the mounting layer as one native transaction. The native parser formats nothing for an id it cannot resolve. +// The first registration happens in render so the first commit already carries a resolvable id. The layout effect +// re-registers after its own cleanup (StrictMode, a revealed ``) and hands the fresh id to the decorator. +// `initialRegistrationRef` is never cleared, otherwise a render inside a hidden `` would register again. function useParserId(parser: MarkdownTextInputProps['parser']): number { const initialRegistrationRef = React.useRef(null); if (initialRegistrationRef.current === null) { diff --git a/src/__tests__/parserRegistration.test.tsx b/src/__tests__/parserRegistration.test.tsx index 1ec21946a..f6115ae1c 100644 --- a/src/__tests__/parserRegistration.test.tsx +++ b/src/__tests__/parserRegistration.test.tsx @@ -13,7 +13,6 @@ import type {MarkdownTextInputProps} from '../MarkdownTextInput'; */ const liveParserIds = new Set(); let nextParserId = 1; -let registerCallCount = 0; jest.mock('react-native', () => ({ Platform: {OS: 'ios', select: (options: {ios?: unknown; default?: unknown}) => options.ios ?? options.default}, @@ -33,13 +32,12 @@ jest.mock('../MarkdownTextInputDecoratorViewNativeComponent', () => ({ default: (props: {parserId: number; children: React.ReactNode}) =>
{props.children}
, })); -// The component refuses a parser that is not a worklet, and the worklets babel plugin does not run under Jest, so the -// hash that marks a function as a worklet is attached by hand. -function createParserWorklet(workletHash: number) { - return Object.assign((): MarkdownRange[] => [], {__workletHash: workletHash}); +// The worklets babel plugin does not run under Jest, so the hash that marks a function as a worklet is attached by hand. +function createParserWorklet() { + return Object.assign((): MarkdownRange[] => [], {__workletHash: 1}); } -const parser = createParserWorklet(1); +const parser = createParserWorklet(); let container: HTMLDivElement; let root: Root; @@ -76,12 +74,10 @@ describe('MarkdownTextInput parser registration', () => { beforeEach(() => { liveParserIds.clear(); nextParserId = 1; - registerCallCount = 0; global.jsi_setMarkdownRuntime = jest.fn(); global.jsi_registerMarkdownWorklet = () => { const parserId = nextParserId; nextParserId += 1; - registerCallCount += 1; liveParserIds.add(parserId); return parserId; }; @@ -104,7 +100,7 @@ describe('MarkdownTextInput parser registration', () => { it('registers the parser once and renders its id on mount', () => { renderIntoRoot(); - expect(registerCallCount).toBe(1); + expect(nextParserId).toBe(2); expectDecoratorOnTheOnlyLiveParserId(); }); @@ -142,7 +138,7 @@ describe('MarkdownTextInput parser registration', () => { }); it('drops the initial registration when the parser changes identity inside a hidden ', () => { - const nextParser = createParserWorklet(2); + const nextParser = createParserWorklet(); renderInActivity(true); renderInActivity(true, nextParser); @@ -155,7 +151,7 @@ describe('MarkdownTextInput parser registration', () => { renderIntoRoot(); const initialParserId = getDecoratorParserId(); - renderIntoRoot(); + renderIntoRoot(); expect(getDecoratorParserId()).not.toBe(initialParserId); expectDecoratorOnTheOnlyLiveParserId(); From 090c4a458d8c73625de12cf3547ca084be721e94 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 9 Sep 2026 16:15:41 +0200 Subject: [PATCH 03/10] fix: keep the parser worklet alive in the native view after JS unregisters it JS unregisters the parser id when React cleans up effects, which also happens for an input that is hidden but still mounted. The native parser now looks the worklet up once, when the id prop changes, and keeps it alive for as long as the view lives, so a parse in that window still formats markdown. The registry is only a handoff from JS to native. On iOS `MarkdownParser` holds the worklet and both `RCTMarkdownUtils` paths pass the id through. On Android `MarkdownParser` becomes an fbjni hybrid that holds it, and the decorator view owns the parser so the `MarkdownUtils` recreated on every attach does not drop it. Claude-Session: https://claude.ai/code/session_01F1MRNtwY27QsvZcV1GwQJ9 --- android/src/main/cpp/MarkdownParser.cpp | 42 ++++++++++++++++-- android/src/main/cpp/MarkdownParser.h | 30 +++++++++++-- .../livemarkdown/MarkdownParser.java | 19 ++++++++ .../MarkdownTextInputDecoratorView.java | 9 +++- .../expensify/livemarkdown/MarkdownUtils.java | 7 ++- apple/MarkdownParser.h | 6 +++ apple/MarkdownParser.mm | 43 +++++++++++++++++-- apple/RCTMarkdownUtils.mm | 7 +++ cpp/MarkdownGlobal.cpp | 5 ++- cpp/MarkdownGlobal.h | 5 ++- 10 files changed, 156 insertions(+), 17 deletions(-) diff --git a/android/src/main/cpp/MarkdownParser.cpp b/android/src/main/cpp/MarkdownParser.cpp index 4edd28ec9..dadeb7ed2 100644 --- a/android/src/main/cpp/MarkdownParser.cpp +++ b/android/src/main/cpp/MarkdownParser.cpp @@ -7,14 +7,46 @@ using namespace facebook; namespace expensify { namespace livemarkdown { + jni::local_ref MarkdownParser::initHybrid(jni::alias_ref) { + return makeCxxInstance(); + } + + void MarkdownParser::nativeSetParserId(const int parserId) { + std::unique_lock lock(mutex_); + if (parserId_ == parserId) { + return; + } + const auto markdownWorklet = findMarkdownWorklet(parserId); + if (markdownWorklet == nullptr) { + return; + } + parserId_ = parserId; + markdownWorklet_ = markdownWorklet; + } + + // A parse for the current id uses the worklet kept alive by `nativeSetParserId`. + // Any other id is looked up in the registry the way it always was. + std::shared_ptr MarkdownParser::workletForParserId(const int parserId) { + { + std::unique_lock lock(mutex_); + if (parserId_ == parserId) { + return markdownWorklet_; + } + } + + return findMarkdownWorklet(parserId); + } + jni::local_ref MarkdownParser::nativeParse( - jni::alias_ref jThis, jni::alias_ref text, const int parserId) { - const auto markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); - jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); + const auto markdownWorklet = workletForParserId(parserId); + if (markdownWorklet == nullptr) { + return jni::make_jstring("[]"); + } - const auto markdownWorklet = expensify::livemarkdown::getMarkdownWorklet(parserId); + const auto markdownRuntime = getMarkdownRuntime(); + jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); const auto input = jsi::String::createFromUtf8(rt, text->toStdString()); const auto output = markdownRuntime->runGuarded(markdownWorklet, input); @@ -25,6 +57,8 @@ namespace livemarkdown { void MarkdownParser::registerNatives() { registerHybrid({ + makeNativeMethod("initHybrid", MarkdownParser::initHybrid), + makeNativeMethod("nativeSetParserId", MarkdownParser::nativeSetParserId), makeNativeMethod("nativeParse", MarkdownParser::nativeParse)}); } diff --git a/android/src/main/cpp/MarkdownParser.h b/android/src/main/cpp/MarkdownParser.h index fc3313b54..c27e1c3fc 100644 --- a/android/src/main/cpp/MarkdownParser.h +++ b/android/src/main/cpp/MarkdownParser.h @@ -10,19 +10,33 @@ #include #include +#include + +#include +#include + using namespace facebook; +using namespace worklets; namespace expensify { namespace livemarkdown { - class MarkdownParser : public jni::HybridClass, - public jsi::HostObject { + class MarkdownParser : public jni::HybridClass { public: static constexpr auto kJavaDescriptor = "Lcom/expensify/livemarkdown/MarkdownParser;"; - static jni::local_ref nativeParse( - jni::alias_ref jThis, + static jni::local_ref initHybrid(jni::alias_ref); + + // Looks up the worklet registered under `parserId` and keeps it alive until + // another registered id is set or this parser is released. JS unregisters + // the id when React cleans up effects, which also happens for an input that + // is hidden but still mounted, so the registry can't be asked again at + // parse time. An id the registry doesn't know leaves the previous worklet + // in place. + void nativeSetParserId(const int parserId); + + jni::local_ref nativeParse( jni::alias_ref text, const int parserId); @@ -30,6 +44,14 @@ namespace livemarkdown { private: friend HybridBase; + + MarkdownParser() = default; + + std::shared_ptr workletForParserId(const int parserId); + + std::mutex mutex_; + int parserId_ = 0; + std::shared_ptr markdownWorklet_; }; } // namespace livemarkdown diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java index 3e108db70..0588df55c 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -2,6 +2,8 @@ import androidx.annotation.NonNull; +import com.facebook.jni.HybridData; +import com.facebook.jni.annotations.DoNotStrip; import com.facebook.react.bridge.ReactContext; import com.facebook.react.util.RNLog; import com.facebook.soloader.SoLoader; @@ -20,6 +22,10 @@ public class MarkdownParser { SoLoader.loadLibrary("livemarkdown"); } + @DoNotStrip + @SuppressWarnings("unused") + private final HybridData mHybridData; + private final @NonNull ReactContext mReactContext; private String mPrevText; private int mPrevParserId; @@ -27,10 +33,23 @@ public class MarkdownParser { public MarkdownParser(@NonNull ReactContext reactContext) { mReactContext = reactContext; + mHybridData = initHybrid(); } + private static native HybridData initHybrid(); + + private native void nativeSetParserId(int parserId); + private native String nativeParse(@NonNull String text, int parserId); + /** + * Keeps the worklet registered under {@code parserId} alive in native code for as long as this parser lives, so a + * later parse still works after JS has unregistered the id. See {@code MarkdownParser.h} for why that happens. + */ + public synchronized void setParserId(int parserId) { + nativeSetParserId(parserId); + } + public synchronized List parse(@NonNull String text, int parserId) { try { Systrace.beginSection(0, "parse"); diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java index 69427e8e6..5e8a0dcd0 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java @@ -15,12 +15,18 @@ public class MarkdownTextInputDecoratorView extends ReactViewGroup { public MarkdownTextInputDecoratorView(Context context) { super(context); + mMarkdownParser = new MarkdownParser((ReactContext) context); } private MarkdownStyle mMarkdownStyle; private int mParserId; + // Owned by the view rather than by `mMarkdownUtils`, which is recreated every + // time the view is attached, so the parser worklet stays alive for as long as + // the view is mounted. + private final MarkdownParser mMarkdownParser; + private MarkdownUtils mMarkdownUtils; private ReactEditText mReactEditText; @@ -33,7 +39,7 @@ protected void onAttachedToWindow() { View child = getChildAt(0); if (child instanceof ReactEditText) { - mMarkdownUtils = new MarkdownUtils((ReactContext) getContext()); + mMarkdownUtils = new MarkdownUtils((ReactContext) getContext(), mMarkdownParser); mMarkdownUtils.setMarkdownStyle(mMarkdownStyle); mMarkdownUtils.setParserId(mParserId); mReactEditText = (ReactEditText) child; @@ -64,6 +70,7 @@ protected void setMarkdownStyle(MarkdownStyle markdownStyle) { protected void setParserId(int parserId) { mParserId = parserId; + mMarkdownParser.setParserId(parserId); if (mMarkdownUtils != null) { mMarkdownUtils.setParserId(mParserId); } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java index 6877e46f9..34e7a4586 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java @@ -11,7 +11,11 @@ public class MarkdownUtils { public MarkdownUtils(@NonNull ReactContext reactContext) { - mMarkdownParser = new MarkdownParser(reactContext); + this(reactContext, new MarkdownParser(reactContext)); + } + + public MarkdownUtils(@NonNull ReactContext reactContext, @NonNull MarkdownParser markdownParser) { + mMarkdownParser = markdownParser; mMarkdownFormatter = new MarkdownFormatter(reactContext.getAssets()); } @@ -27,6 +31,7 @@ public void setMarkdownStyle(@NonNull MarkdownStyle markdownStyle) { public void setParserId(int parserId) { mParserId = parserId; + mMarkdownParser.setParserId(parserId); } public void applyMarkdownFormatting(SpannableStringBuilder ssb) { diff --git a/apple/MarkdownParser.h b/apple/MarkdownParser.h index 1d73622d6..743492442 100644 --- a/apple/MarkdownParser.h +++ b/apple/MarkdownParser.h @@ -5,6 +5,12 @@ NS_ASSUME_NONNULL_BEGIN @interface MarkdownParser : NSObject +// Looks up the worklet registered under `parserId` and keeps it alive until +// another registered id is set or this parser is released. JS unregisters the +// id when React cleans up effects, which also happens for an input that is +// hidden but still mounted, so the registry can't be asked again at parse time. +- (void)setParserId:(nonnull NSNumber *)parserId; + - (NSArray *)parse:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId; diff --git a/apple/MarkdownParser.mm b/apple/MarkdownParser.mm index 89bb209b4..721ada31e 100644 --- a/apple/MarkdownParser.mm +++ b/apple/MarkdownParser.mm @@ -56,6 +56,10 @@ @implementation MarkdownParser { NSNumber *_pendingParserId; void (^_pendingCompletion)(void); BOOL _warmupScheduled; + + // The worklet registered under `_parserId`, kept alive here (see the header). + NSNumber *_parserId; + std::shared_ptr _markdownWorklet; } - (instancetype)init @@ -82,6 +86,39 @@ + (dispatch_queue_t)cacheWarmupQueue return queue; } +// An id the registry doesn't know leaves the previous worklet in place. The +// measure path shares one parser between shadow node clones, so a clone that +// still carries an older, already unregistered id must not drop the worklet +// the current id resolved to. +- (void)setParserId:(nonnull NSNumber *)parserId +{ + @synchronized (self) { + if ([_parserId isEqualToNumber:parserId]) { + return; + } + const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet([parserId intValue]); + if (markdownWorklet == nullptr) { + return; + } + _parserId = parserId; + _markdownWorklet = markdownWorklet; + } +} + +// A parse for the current id uses the worklet kept alive by `setParserId:`. +// Any other id comes from a shadow node clone that still carries an older id, +// so it is looked up in the registry the way it always was. +- (std::shared_ptr)workletForParserId:(nonnull NSNumber *)parserId +{ + @synchronized (self) { + if ([_parserId isEqualToNumber:parserId]) { + return _markdownWorklet; + } + } + + return expensify::livemarkdown::findMarkdownWorklet([parserId intValue]); +} + - (nullable NSArray *)cachedRangesForText:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId { @@ -212,10 +249,8 @@ - (void)drainPendingWarmups const auto &markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); - std::shared_ptr markdownWorklet; - try { - markdownWorklet = expensify::livemarkdown::getMarkdownWorklet([parserId intValue]); - } catch (const std::out_of_range &error) { + const auto markdownWorklet = [self workletForParserId:parserId]; + if (markdownWorklet == nullptr) { return @[]; } diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index aa034dfe2..2e28cb7c3 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -17,6 +17,12 @@ - (instancetype)init return self; } +- (void)setParserId:(NSNumber *)parserId +{ + _parserId = parserId; + [_markdownParser setParserId:parserId]; +} + - (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedString withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes { @@ -49,6 +55,7 @@ - (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedS _markdownStyle = markdownStyle; _parserId = parserId; } + [_markdownParser setParserId:parserId]; NSString *text = attributedString.string; NSArray *markdownRanges = [_markdownParser cachedRangesForText:text withParserId:parserId]; diff --git a/cpp/MarkdownGlobal.cpp b/cpp/MarkdownGlobal.cpp index 67f93eb43..c83d818a2 100644 --- a/cpp/MarkdownGlobal.cpp +++ b/cpp/MarkdownGlobal.cpp @@ -34,9 +34,10 @@ void unregisterMarkdownWorklet(const int parserId) { globalMarkdownShareableWorklets.erase(parserId); } -std::shared_ptr getMarkdownWorklet(const int parserId) { +std::shared_ptr findMarkdownWorklet(const int parserId) { std::unique_lock lock(globalMarkdownShareableWorkletsMutex); - return globalMarkdownShareableWorklets.at(parserId); + const auto it = globalMarkdownShareableWorklets.find(parserId); + return it == globalMarkdownShareableWorklets.end() ? nullptr : it->second; } } // namespace livemarkdown diff --git a/cpp/MarkdownGlobal.h b/cpp/MarkdownGlobal.h index e18172613..a9102bcde 100644 --- a/cpp/MarkdownGlobal.h +++ b/cpp/MarkdownGlobal.h @@ -18,7 +18,10 @@ const int registerMarkdownWorklet(const std::shared_ptr &ma void unregisterMarkdownWorklet(const int parserId); -std::shared_ptr getMarkdownWorklet(const int parserId); +// Returns nullptr when nothing is registered under `parserId`. Callers keep the +// result: JS drops the entry when React cleans up effects, which also happens +// for an input that is hidden but still mounted. +std::shared_ptr findMarkdownWorklet(const int parserId); } // namespace livemarkdown } // namespace expensify From a69d6dd4cf77b85c85f1a8d255fa5fb1d191bc98 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 11 Sep 2026 14:51:35 +0200 Subject: [PATCH 04/10] fix: register the parser only after commit Registering in render leaked an entry whenever React abandoned the render (a suspended tree, an removed while still hidden), and the extra bookkeeping in useParserId existed only to pair that render registration with the effect. The layout effect now registers, its cleanup unregisters, and the id reaches the decorator through state. The first commit carries 0, which both native parsers treat as no parser; on iOS parseUncached resolves the worklet before touching the worklet runtime, since that first commit can arrive before the runtime exists. The jsdom suite gains cases for a same-parser rerender, an Activity that is removed while hidden, an abandoned suspended render, a replacement parser in a previously visible Activity, and two inputs sharing one parser, and asserts that every test leaves the registry empty. --- apple/MarkdownParser.mm | 8 +- src/MarkdownTextInput.tsx | 33 +----- src/__tests__/parserRegistration.test.tsx | 136 +++++++++++++++++++--- 3 files changed, 127 insertions(+), 50 deletions(-) diff --git a/apple/MarkdownParser.mm b/apple/MarkdownParser.mm index 721ada31e..4c13ec7ef 100644 --- a/apple/MarkdownParser.mm +++ b/apple/MarkdownParser.mm @@ -246,14 +246,16 @@ - (void)drainPendingWarmups - (NSArray *)parseUncached:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId { - const auto &markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); - jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); - + // The first commit carries parserId 0, before JS has created the worklet runtime. + // Resolve the worklet before accessing that runtime. const auto markdownWorklet = [self workletForParserId:parserId]; if (markdownWorklet == nullptr) { return @[]; } + const auto &markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); + jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); + const auto &input = jsi::String::createFromUtf8(rt, [text UTF8String]); jsi::Value output; diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index 8be2fc63f..093e6baa8 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -70,40 +70,15 @@ type FormatSelectionResult = { type MarkdownTextInput = TextInput & React.Component; -type ParserRegistration = { - parser: MarkdownTextInputProps['parser']; - parserId: number; -}; - -// The first registration happens in render so the first commit already carries a resolvable id. The layout effect -// re-registers after its own cleanup (StrictMode, a revealed ``) and hands the fresh id to the decorator. -// `initialRegistrationRef` is never cleared, otherwise a render inside a hidden `` would register again. +// Register only after commit: a suspended or initially hidden render may never run an effect cleanup. +// Zero means no parser yet. A layout effect publishes the registered id and restores it when effects reconnect. function useParserId(parser: MarkdownTextInputProps['parser']): number { - const initialRegistrationRef = React.useRef(null); - if (initialRegistrationRef.current === null) { - initialRegistrationRef.current = {parser, parserId: registerParser(parser)}; - } - const [parserId, setParserId] = React.useState(initialRegistrationRef.current.parserId); - const liveRegistrationRef = React.useRef(initialRegistrationRef.current); + const [parserId, setParserId] = React.useState(0); React.useLayoutEffect(() => { - const unregisterLiveParser = () => { - if (liveRegistrationRef.current === null) { - return; - } - unregisterParser(liveRegistrationRef.current.parserId); - liveRegistrationRef.current = null; - }; - - if (liveRegistrationRef.current?.parser === parser) { - return unregisterLiveParser; - } - - unregisterLiveParser(); const nextParserId = registerParser(parser); - liveRegistrationRef.current = {parser, parserId: nextParserId}; setParserId(nextParserId); - return unregisterLiveParser; + return () => unregisterParser(nextParserId); }, [parser]); return parserId; diff --git a/src/__tests__/parserRegistration.test.tsx b/src/__tests__/parserRegistration.test.tsx index f6115ae1c..4fbb5b2e5 100644 --- a/src/__tests__/parserRegistration.test.tsx +++ b/src/__tests__/parserRegistration.test.tsx @@ -1,5 +1,5 @@ import {expect} from '@jest/globals'; -import React, {Activity, StrictMode, act} from 'react'; +import React, {Activity, StrictMode, Suspense, act} from 'react'; import {createRoot} from 'react-dom/client'; import type {Root} from 'react-dom/client'; import type {MarkdownRange} from '../commonTypes'; @@ -8,10 +8,10 @@ import type {MarkdownTextInputProps} from '../MarkdownTextInput'; /** * The parser worklet lives in a C++ registry keyed by the `parserId` prop the decorator view carries. The registry is - * replaced by a set of ids here, the decorator view by an element that exposes its `parserId` as a DOM attribute, and + * replaced by a map of worklets here, the decorator view by an element that exposes its `parserId` as a DOM attribute, and * the native text input by a plain ``, so the component renders in jsdom through `react-dom`. */ -const liveParserIds = new Set(); +const liveParsers = new Map(); let nextParserId = 1; jest.mock('react-native', () => ({ @@ -58,31 +58,32 @@ function renderInActivity(isHidden: boolean, currentParser: MarkdownTextInputPro function getDecoratorParserId(): number { const decorator = container.querySelector('[data-parser-id]'); - const parserId = Number(decorator?.getAttribute('data-parser-id')); - if (!Number.isInteger(parserId)) { + const attribute = decorator?.getAttribute('data-parser-id'); + const parserId = Number(attribute); + if (attribute == null || !Number.isInteger(parserId) || parserId < 0) { throw new Error('The decorator view rendered without a parser id'); } return parserId; } -function expectDecoratorOnTheOnlyLiveParserId() { - expect(liveParserIds.has(getDecoratorParserId())).toBe(true); - expect(liveParserIds.size).toBe(1); +function expectDecoratorOnTheOnlyLiveParserId(expectedParser: MarkdownTextInputProps['parser'] = parser) { + expect(liveParsers.get(getDecoratorParserId())).toBe(expectedParser); + expect(liveParsers.size).toBe(1); } describe('MarkdownTextInput parser registration', () => { beforeEach(() => { - liveParserIds.clear(); + liveParsers.clear(); nextParserId = 1; global.jsi_setMarkdownRuntime = jest.fn(); - global.jsi_registerMarkdownWorklet = () => { + global.jsi_registerMarkdownWorklet = (worklet) => { const parserId = nextParserId; nextParserId += 1; - liveParserIds.add(parserId); + liveParsers.set(parserId, worklet as unknown as MarkdownTextInputProps['parser']); return parserId; }; global.jsi_unregisterMarkdownWorklet = (parserId: number) => { - liveParserIds.delete(parserId); + liveParsers.delete(parserId); }; container = document.createElement('div'); @@ -95,9 +96,10 @@ describe('MarkdownTextInput parser registration', () => { root.unmount(); }); container.remove(); + expect(liveParsers.size).toBe(0); }); - it('registers the parser once and renders its id on mount', () => { + it('registers the parser once and publishes its id after mount', () => { renderIntoRoot(); expect(nextParserId).toBe(2); @@ -111,7 +113,58 @@ describe('MarkdownTextInput parser registration', () => { root.unmount(); }); - expect(liveParserIds.size).toBe(0); + expect(liveParsers.size).toBe(0); + }); + + it('keeps the registration when rerendering with the same parser', () => { + renderIntoRoot(); + const initialParserId = getDecoratorParserId(); + + renderIntoRoot( + , + ); + + expect(getDecoratorParserId()).toBe(initialParserId); + expect(nextParserId).toBe(2); + expectDecoratorOnTheOnlyLiveParserId(); + }); + + it('does not register a parser for an Activity that is removed without ever becoming visible', () => { + renderInActivity(true); + + expect(getDecoratorParserId()).toBe(0); + expect(liveParsers.size).toBe(0); + + renderIntoRoot(
); + + expect(nextParserId).toBe(1); + expect(liveParsers.size).toBe(0); + }); + + it('does not leak a registration when React abandons a suspended render', () => { + const pending = new Promise(() => { + // Keep the subtree suspended until its render is abandoned. + }); + function Suspend(): React.ReactNode { + throw pending; + } + + renderIntoRoot( + Loading}> + + + , + ); + + expect(container.textContent).toBe('Loading'); + expect(nextParserId).toBe(1); + expect(liveParsers.size).toBe(0); + + renderIntoRoot(); + expectDecoratorOnTheOnlyLiveParserId(); }); it('keeps the decorator on a live id under StrictMode', () => { @@ -137,23 +190,70 @@ describe('MarkdownTextInput parser registration', () => { expectDecoratorOnTheOnlyLiveParserId(); }); - it('drops the initial registration when the parser changes identity inside a hidden ', () => { + it('registers a replacement parser when a previously visible Activity is revealed', () => { + renderInActivity(false); + renderInActivity(true); + expect(liveParsers.size).toBe(0); + + const nextParser = createParserWorklet(); + renderInActivity(true, nextParser); + expect(liveParsers.size).toBe(0); + + renderInActivity(false, nextParser); + expectDecoratorOnTheOnlyLiveParserId(nextParser); + }); + + it('keeps registrations independent for inputs sharing the same parser', () => { + renderIntoRoot( +
+ + +
, + ); + const ids = Array.from(container.querySelectorAll('[data-parser-id]'), (element) => Number(element.getAttribute('data-parser-id'))); + expect(new Set(ids).size).toBe(2); + expect(liveParsers.size).toBe(2); + ids.forEach((id) => expect(liveParsers.get(id)).toBe(parser)); + + renderIntoRoot( +
+ +
, + ); + expect(getDecoratorParserId()).toBe(ids[1]); + expectDecoratorOnTheOnlyLiveParserId(); + }); + + it('registers only the latest parser when an initially hidden is revealed', () => { const nextParser = createParserWorklet(); renderInActivity(true); + expect(liveParsers.size).toBe(0); renderInActivity(true, nextParser); + expect(liveParsers.size).toBe(0); renderInActivity(false, nextParser); - expectDecoratorOnTheOnlyLiveParserId(); + expect(nextParserId).toBe(2); + expectDecoratorOnTheOnlyLiveParserId(nextParser); }); it('moves the decorator to a live id and drops the previous one when the parser changes identity', () => { renderIntoRoot(); const initialParserId = getDecoratorParserId(); - renderIntoRoot(); + const nextParser = createParserWorklet(); + renderIntoRoot(); expect(getDecoratorParserId()).not.toBe(initialParserId); - expectDecoratorOnTheOnlyLiveParserId(); + expectDecoratorOnTheOnlyLiveParserId(nextParser); }); }); From 0d3388a85b5c8d3eacce534eaf0517bfeb113cad Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 16 Sep 2026 16:38:02 +0200 Subject: [PATCH 05/10] fix: register the parser worklet in an insertion effect The parser id is now picked in JS, one per worklet identity, and the worklet is registered in `useInsertionEffect`. That effect runs before the host tree commits, so the first measure already sees the worklet, and a hidden `` leaves it connected, so the id the native view holds never goes stale. Inputs sharing a parser share one registration through a retain count. This removes the extra render and commit of the layout effect approach and the native keep-alive of the worklet, which is no longer needed. The C++ registry accepts the id from JS and returns nullptr for unknown ids instead of throwing. --- android/src/main/cpp/MarkdownParser.cpp | 37 +---- android/src/main/cpp/MarkdownParser.h | 30 +--- .../livemarkdown/MarkdownParser.java | 19 --- .../MarkdownTextInputDecoratorView.java | 9 +- .../expensify/livemarkdown/MarkdownUtils.java | 7 +- apple/MarkdownParser.h | 6 - apple/MarkdownParser.mm | 41 +---- apple/RCTMarkdownUtils.mm | 7 - cpp/MarkdownGlobal.cpp | 5 +- cpp/MarkdownGlobal.h | 8 +- cpp/RuntimeDecorator.cpp | 7 +- src/MarkdownTextInput.tsx | 71 +------- src/__tests__/parserRegistration.test.tsx | 157 +++++++++++------- src/parserRegistry.ts | 45 +++++ src/useParserId.ts | 19 +++ src/workletRuntime.ts | 45 +++++ 16 files changed, 231 insertions(+), 282 deletions(-) create mode 100644 src/parserRegistry.ts create mode 100644 src/useParserId.ts create mode 100644 src/workletRuntime.ts diff --git a/android/src/main/cpp/MarkdownParser.cpp b/android/src/main/cpp/MarkdownParser.cpp index dadeb7ed2..65457084c 100644 --- a/android/src/main/cpp/MarkdownParser.cpp +++ b/android/src/main/cpp/MarkdownParser.cpp @@ -7,45 +7,16 @@ using namespace facebook; namespace expensify { namespace livemarkdown { - jni::local_ref MarkdownParser::initHybrid(jni::alias_ref) { - return makeCxxInstance(); - } - - void MarkdownParser::nativeSetParserId(const int parserId) { - std::unique_lock lock(mutex_); - if (parserId_ == parserId) { - return; - } - const auto markdownWorklet = findMarkdownWorklet(parserId); - if (markdownWorklet == nullptr) { - return; - } - parserId_ = parserId; - markdownWorklet_ = markdownWorklet; - } - - // A parse for the current id uses the worklet kept alive by `nativeSetParserId`. - // Any other id is looked up in the registry the way it always was. - std::shared_ptr MarkdownParser::workletForParserId(const int parserId) { - { - std::unique_lock lock(mutex_); - if (parserId_ == parserId) { - return markdownWorklet_; - } - } - - return findMarkdownWorklet(parserId); - } - jni::local_ref MarkdownParser::nativeParse( + jni::alias_ref jThis, jni::alias_ref text, const int parserId) { - const auto markdownWorklet = workletForParserId(parserId); + const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet(parserId); if (markdownWorklet == nullptr) { return jni::make_jstring("[]"); } - const auto markdownRuntime = getMarkdownRuntime(); + const auto markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); const auto input = jsi::String::createFromUtf8(rt, text->toStdString()); @@ -57,8 +28,6 @@ namespace livemarkdown { void MarkdownParser::registerNatives() { registerHybrid({ - makeNativeMethod("initHybrid", MarkdownParser::initHybrid), - makeNativeMethod("nativeSetParserId", MarkdownParser::nativeSetParserId), makeNativeMethod("nativeParse", MarkdownParser::nativeParse)}); } diff --git a/android/src/main/cpp/MarkdownParser.h b/android/src/main/cpp/MarkdownParser.h index c27e1c3fc..fc3313b54 100644 --- a/android/src/main/cpp/MarkdownParser.h +++ b/android/src/main/cpp/MarkdownParser.h @@ -10,33 +10,19 @@ #include #include -#include - -#include -#include - using namespace facebook; -using namespace worklets; namespace expensify { namespace livemarkdown { - class MarkdownParser : public jni::HybridClass { + class MarkdownParser : public jni::HybridClass, + public jsi::HostObject { public: static constexpr auto kJavaDescriptor = "Lcom/expensify/livemarkdown/MarkdownParser;"; - static jni::local_ref initHybrid(jni::alias_ref); - - // Looks up the worklet registered under `parserId` and keeps it alive until - // another registered id is set or this parser is released. JS unregisters - // the id when React cleans up effects, which also happens for an input that - // is hidden but still mounted, so the registry can't be asked again at - // parse time. An id the registry doesn't know leaves the previous worklet - // in place. - void nativeSetParserId(const int parserId); - - jni::local_ref nativeParse( + static jni::local_ref nativeParse( + jni::alias_ref jThis, jni::alias_ref text, const int parserId); @@ -44,14 +30,6 @@ namespace livemarkdown { private: friend HybridBase; - - MarkdownParser() = default; - - std::shared_ptr workletForParserId(const int parserId); - - std::mutex mutex_; - int parserId_ = 0; - std::shared_ptr markdownWorklet_; }; } // namespace livemarkdown diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java index 0588df55c..3e108db70 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -2,8 +2,6 @@ import androidx.annotation.NonNull; -import com.facebook.jni.HybridData; -import com.facebook.jni.annotations.DoNotStrip; import com.facebook.react.bridge.ReactContext; import com.facebook.react.util.RNLog; import com.facebook.soloader.SoLoader; @@ -22,10 +20,6 @@ public class MarkdownParser { SoLoader.loadLibrary("livemarkdown"); } - @DoNotStrip - @SuppressWarnings("unused") - private final HybridData mHybridData; - private final @NonNull ReactContext mReactContext; private String mPrevText; private int mPrevParserId; @@ -33,23 +27,10 @@ public class MarkdownParser { public MarkdownParser(@NonNull ReactContext reactContext) { mReactContext = reactContext; - mHybridData = initHybrid(); } - private static native HybridData initHybrid(); - - private native void nativeSetParserId(int parserId); - private native String nativeParse(@NonNull String text, int parserId); - /** - * Keeps the worklet registered under {@code parserId} alive in native code for as long as this parser lives, so a - * later parse still works after JS has unregistered the id. See {@code MarkdownParser.h} for why that happens. - */ - public synchronized void setParserId(int parserId) { - nativeSetParserId(parserId); - } - public synchronized List parse(@NonNull String text, int parserId) { try { Systrace.beginSection(0, "parse"); diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java index 5e8a0dcd0..69427e8e6 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownTextInputDecoratorView.java @@ -15,18 +15,12 @@ public class MarkdownTextInputDecoratorView extends ReactViewGroup { public MarkdownTextInputDecoratorView(Context context) { super(context); - mMarkdownParser = new MarkdownParser((ReactContext) context); } private MarkdownStyle mMarkdownStyle; private int mParserId; - // Owned by the view rather than by `mMarkdownUtils`, which is recreated every - // time the view is attached, so the parser worklet stays alive for as long as - // the view is mounted. - private final MarkdownParser mMarkdownParser; - private MarkdownUtils mMarkdownUtils; private ReactEditText mReactEditText; @@ -39,7 +33,7 @@ protected void onAttachedToWindow() { View child = getChildAt(0); if (child instanceof ReactEditText) { - mMarkdownUtils = new MarkdownUtils((ReactContext) getContext(), mMarkdownParser); + mMarkdownUtils = new MarkdownUtils((ReactContext) getContext()); mMarkdownUtils.setMarkdownStyle(mMarkdownStyle); mMarkdownUtils.setParserId(mParserId); mReactEditText = (ReactEditText) child; @@ -70,7 +64,6 @@ protected void setMarkdownStyle(MarkdownStyle markdownStyle) { protected void setParserId(int parserId) { mParserId = parserId; - mMarkdownParser.setParserId(parserId); if (mMarkdownUtils != null) { mMarkdownUtils.setParserId(mParserId); } diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java index 34e7a4586..6877e46f9 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownUtils.java @@ -11,11 +11,7 @@ public class MarkdownUtils { public MarkdownUtils(@NonNull ReactContext reactContext) { - this(reactContext, new MarkdownParser(reactContext)); - } - - public MarkdownUtils(@NonNull ReactContext reactContext, @NonNull MarkdownParser markdownParser) { - mMarkdownParser = markdownParser; + mMarkdownParser = new MarkdownParser(reactContext); mMarkdownFormatter = new MarkdownFormatter(reactContext.getAssets()); } @@ -31,7 +27,6 @@ public void setMarkdownStyle(@NonNull MarkdownStyle markdownStyle) { public void setParserId(int parserId) { mParserId = parserId; - mMarkdownParser.setParserId(parserId); } public void applyMarkdownFormatting(SpannableStringBuilder ssb) { diff --git a/apple/MarkdownParser.h b/apple/MarkdownParser.h index 743492442..1d73622d6 100644 --- a/apple/MarkdownParser.h +++ b/apple/MarkdownParser.h @@ -5,12 +5,6 @@ NS_ASSUME_NONNULL_BEGIN @interface MarkdownParser : NSObject -// Looks up the worklet registered under `parserId` and keeps it alive until -// another registered id is set or this parser is released. JS unregisters the -// id when React cleans up effects, which also happens for an input that is -// hidden but still mounted, so the registry can't be asked again at parse time. -- (void)setParserId:(nonnull NSNumber *)parserId; - - (NSArray *)parse:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId; diff --git a/apple/MarkdownParser.mm b/apple/MarkdownParser.mm index 4c13ec7ef..ef1ce690b 100644 --- a/apple/MarkdownParser.mm +++ b/apple/MarkdownParser.mm @@ -56,10 +56,6 @@ @implementation MarkdownParser { NSNumber *_pendingParserId; void (^_pendingCompletion)(void); BOOL _warmupScheduled; - - // The worklet registered under `_parserId`, kept alive here (see the header). - NSNumber *_parserId; - std::shared_ptr _markdownWorklet; } - (instancetype)init @@ -86,39 +82,6 @@ + (dispatch_queue_t)cacheWarmupQueue return queue; } -// An id the registry doesn't know leaves the previous worklet in place. The -// measure path shares one parser between shadow node clones, so a clone that -// still carries an older, already unregistered id must not drop the worklet -// the current id resolved to. -- (void)setParserId:(nonnull NSNumber *)parserId -{ - @synchronized (self) { - if ([_parserId isEqualToNumber:parserId]) { - return; - } - const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet([parserId intValue]); - if (markdownWorklet == nullptr) { - return; - } - _parserId = parserId; - _markdownWorklet = markdownWorklet; - } -} - -// A parse for the current id uses the worklet kept alive by `setParserId:`. -// Any other id comes from a shadow node clone that still carries an older id, -// so it is looked up in the registry the way it always was. -- (std::shared_ptr)workletForParserId:(nonnull NSNumber *)parserId -{ - @synchronized (self) { - if ([_parserId isEqualToNumber:parserId]) { - return _markdownWorklet; - } - } - - return expensify::livemarkdown::findMarkdownWorklet([parserId intValue]); -} - - (nullable NSArray *)cachedRangesForText:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId { @@ -246,9 +209,7 @@ - (void)drainPendingWarmups - (NSArray *)parseUncached:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId { - // The first commit carries parserId 0, before JS has created the worklet runtime. - // Resolve the worklet before accessing that runtime. - const auto markdownWorklet = [self workletForParserId:parserId]; + const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet([parserId intValue]); if (markdownWorklet == nullptr) { return @[]; } diff --git a/apple/RCTMarkdownUtils.mm b/apple/RCTMarkdownUtils.mm index 2e28cb7c3..aa034dfe2 100644 --- a/apple/RCTMarkdownUtils.mm +++ b/apple/RCTMarkdownUtils.mm @@ -17,12 +17,6 @@ - (instancetype)init return self; } -- (void)setParserId:(NSNumber *)parserId -{ - _parserId = parserId; - [_markdownParser setParserId:parserId]; -} - - (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedString withDefaultTextAttributes:(nonnull NSDictionary *)defaultTextAttributes { @@ -55,7 +49,6 @@ - (void)applyMarkdownFormatting:(nonnull NSMutableAttributedString *)attributedS _markdownStyle = markdownStyle; _parserId = parserId; } - [_markdownParser setParserId:parserId]; NSString *text = attributedString.string; NSArray *markdownRanges = [_markdownParser cachedRangesForText:text withParserId:parserId]; diff --git a/cpp/MarkdownGlobal.cpp b/cpp/MarkdownGlobal.cpp index c83d818a2..cd1658694 100644 --- a/cpp/MarkdownGlobal.cpp +++ b/cpp/MarkdownGlobal.cpp @@ -19,14 +19,11 @@ std::shared_ptr getMarkdownRuntime() { std::unordered_map> globalMarkdownShareableWorklets; std::mutex globalMarkdownShareableWorkletsMutex; -int nextParserId = 1; -const int registerMarkdownWorklet(const std::shared_ptr &markdownWorklet) { +void registerMarkdownWorklet(const int parserId, const std::shared_ptr &markdownWorklet) { assert(markdownWorklet != nullptr); - auto parserId = nextParserId++; std::unique_lock lock(globalMarkdownShareableWorkletsMutex); globalMarkdownShareableWorklets[parserId] = markdownWorklet; - return parserId; } void unregisterMarkdownWorklet(const int parserId) { diff --git a/cpp/MarkdownGlobal.h b/cpp/MarkdownGlobal.h index a9102bcde..0eef42774 100644 --- a/cpp/MarkdownGlobal.h +++ b/cpp/MarkdownGlobal.h @@ -14,13 +14,13 @@ void setMarkdownRuntime(const std::shared_ptr &markdownWorkletRu std::shared_ptr getMarkdownRuntime(); -const int registerMarkdownWorklet(const std::shared_ptr &markdownWorklet); +// JS picks the id, one per parser worklet, so the decorator view can carry it +// in the same commit that registers the worklet. +void registerMarkdownWorklet(const int parserId, const std::shared_ptr &markdownWorklet); void unregisterMarkdownWorklet(const int parserId); -// Returns nullptr when nothing is registered under `parserId`. Callers keep the -// result: JS drops the entry when React cleans up effects, which also happens -// for an input that is hidden but still mounted. +// Returns nullptr when nothing is registered under `parserId`. std::shared_ptr findMarkdownWorklet(const int parserId); } // namespace livemarkdown diff --git a/cpp/RuntimeDecorator.cpp b/cpp/RuntimeDecorator.cpp index f7ed5f91b..211b286cc 100644 --- a/cpp/RuntimeDecorator.cpp +++ b/cpp/RuntimeDecorator.cpp @@ -21,10 +21,11 @@ void injectJSIBindings(jsi::Runtime &rt) { rt.global().setProperty(rt, "jsi_registerMarkdownWorklet", jsi::Function::createFromHostFunction( rt, jsi::PropNameID::forAscii(rt, "jsi_registerMarkdownWorklet"), - 1, + 2, [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t count) -> jsi::Value { - const auto parserId = registerMarkdownWorklet(extractSerializableOrThrow(rt, args[0])); - return jsi::Value(parserId); + const auto parserId = static_cast(args[0].asNumber()); + registerMarkdownWorklet(parserId, extractSerializableOrThrow(rt, args[1])); + return jsi::Value::undefined(); })); rt.global().setProperty(rt, "jsi_unregisterMarkdownWorklet", jsi::Function::createFromHostFunction( diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index 093e6baa8..cebb79898 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -1,66 +1,19 @@ import {StyleSheet, TextInput, processColor} from 'react-native'; import React from 'react'; import type {TextInputProps} from 'react-native'; -import {createSerializable, createWorkletRuntime} from 'react-native-worklets'; -import type {SerializableRef, WorkletFunction, WorkletRuntime} from 'react-native-worklets'; import MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; -import NativeLiveMarkdownModule from './NativeLiveMarkdownModule'; import {mergeMarkdownStyleWithDefault} from './styleUtils'; import type {PartialMarkdownStyle} from './styleUtils'; -import type {InlineImagesInputProps, MarkdownRange} from './commonTypes'; - -declare global { - // eslint-disable-next-line no-var - var jsi_setMarkdownRuntime: (runtime: WorkletRuntime) => void; - // eslint-disable-next-line no-var - var jsi_registerMarkdownWorklet: (shareableWorklet: SerializableRef>) => number; - // eslint-disable-next-line no-var - var jsi_unregisterMarkdownWorklet: (parserId: number) => void; -} - -let initialized = false; -let workletRuntime: WorkletRuntime | undefined; - -function getWorkletRuntime(): WorkletRuntime { - if (workletRuntime === undefined) { - throw new Error( - "[react-native-live-markdown] Worklet runtime hasn't been created yet. Please avoid calling `getWorkletRuntime()` in top-level scope. Instead, call `getWorkletRuntime()` directly in `runOnRuntime` arguments list.", - ); - } - return workletRuntime; -} - -function initializeLiveMarkdownIfNeeded() { - if (initialized) { - return; - } - if (NativeLiveMarkdownModule) { - NativeLiveMarkdownModule.install(); - } - if (!global.jsi_setMarkdownRuntime) { - throw new Error('[react-native-live-markdown] global.jsi_setMarkdownRuntime is not available'); - } - workletRuntime = createWorkletRuntime({name: 'LiveMarkdownRuntime'}); - global.jsi_setMarkdownRuntime(workletRuntime); - initialized = true; -} - -function registerParser(parser: (input: string) => MarkdownRange[]): number { - initializeLiveMarkdownIfNeeded(); - const serializableWorklet = createSerializable(parser as WorkletFunction<[string], MarkdownRange[]>); - const parserId = global.jsi_registerMarkdownWorklet(serializableWorklet); - return parserId; -} - -function unregisterParser(parserId: number) { - global.jsi_unregisterMarkdownWorklet(parserId); -} +import type {InlineImagesInputProps} from './commonTypes'; +import useParserId from './useParserId'; +import {getWorkletRuntime} from './workletRuntime'; +import type {ParserWorklet} from './workletRuntime'; interface MarkdownTextInputProps extends TextInputProps, InlineImagesInputProps { markdownStyle?: PartialMarkdownStyle; formatSelection?: (text: string, selectionStart: number, selectionEnd: number, formatCommand: string) => FormatSelectionResult; - parser: (value: string) => MarkdownRange[]; + parser: ParserWorklet; } type FormatSelectionResult = { @@ -70,20 +23,6 @@ type FormatSelectionResult = { type MarkdownTextInput = TextInput & React.Component; -// Register only after commit: a suspended or initially hidden render may never run an effect cleanup. -// Zero means no parser yet. A layout effect publishes the registered id and restores it when effects reconnect. -function useParserId(parser: MarkdownTextInputProps['parser']): number { - const [parserId, setParserId] = React.useState(0); - - React.useLayoutEffect(() => { - const nextParserId = registerParser(parser); - setParserId(nextParserId); - return () => unregisterParser(nextParserId); - }, [parser]); - - return parserId; -} - function processColorsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { const output = JSON.parse(JSON.stringify(input)); diff --git a/src/__tests__/parserRegistration.test.tsx b/src/__tests__/parserRegistration.test.tsx index 4fbb5b2e5..dd496f2b9 100644 --- a/src/__tests__/parserRegistration.test.tsx +++ b/src/__tests__/parserRegistration.test.tsx @@ -2,17 +2,27 @@ import {expect} from '@jest/globals'; import React, {Activity, StrictMode, Suspense, act} from 'react'; import {createRoot} from 'react-dom/client'; import type {Root} from 'react-dom/client'; +import type {SerializableRef} from 'react-native-worklets'; import type {MarkdownRange} from '../commonTypes'; import MarkdownTextInput from '../MarkdownTextInput'; import type {MarkdownTextInputProps} from '../MarkdownTextInput'; /** * The parser worklet lives in a C++ registry keyed by the `parserId` prop the decorator view carries. The registry is - * replaced by a map of worklets here, the decorator view by an element that exposes its `parserId` as a DOM attribute, and - * the native text input by a plain ``, so the component renders in jsdom through `react-dom`. + * replaced by a map of worklets here, the decorator view by an element that exposes its `parserId` as a DOM attribute and + * counts its renders, and the native text input by a plain ``, so the component renders in jsdom through `react-dom`. */ -const liveParsers = new Map(); -let nextParserId = 1; +type Parser = MarkdownTextInputProps['parser']; + +// `createSerializable` is mocked to return the worklet itself, so the map holds the parser functions. +const liveParsers = new Map>(); +const registerWorklet = jest.fn((parserId: number, worklet: SerializableRef) => { + liveParsers.set(parserId, worklet); +}); +const unregisterWorklet = jest.fn((parserId: number) => { + liveParsers.delete(parserId); +}); +let decoratorRenderCount = 0; jest.mock('react-native', () => ({ Platform: {OS: 'ios', select: (options: {ios?: unknown; default?: unknown}) => options.ios ?? options.default}, @@ -29,7 +39,10 @@ jest.mock('react-native-worklets', () => ({ jest.mock('../MarkdownTextInputDecoratorViewNativeComponent', () => ({ __esModule: true, - default: (props: {parserId: number; children: React.ReactNode}) =>
{props.children}
, + default: (props: {parserId: number; children: React.ReactNode}) => { + decoratorRenderCount += 1; + return
{props.children}
; + }, })); // The worklets babel plugin does not run under Jest, so the hash that marks a function as a worklet is attached by hand. @@ -48,7 +61,7 @@ function renderIntoRoot(element: React.ReactElement) { }); } -function renderInActivity(isHidden: boolean, currentParser: MarkdownTextInputProps['parser'] = parser) { +function renderInActivity(isHidden: boolean, currentParser: Parser = parser) { renderIntoRoot( @@ -56,17 +69,27 @@ function renderInActivity(isHidden: boolean, currentParser: MarkdownTextInputPro ); } +function getDecoratorParserIds(): number[] { + return Array.from(container.querySelectorAll('[data-parser-id]'), (element) => { + const attribute = element.getAttribute('data-parser-id'); + const parserId = Number(attribute); + if (attribute == null || !Number.isInteger(parserId) || parserId <= 0) { + throw new Error('The decorator view rendered without a parser id'); + } + return parserId; + }); +} + function getDecoratorParserId(): number { - const decorator = container.querySelector('[data-parser-id]'); - const attribute = decorator?.getAttribute('data-parser-id'); - const parserId = Number(attribute); - if (attribute == null || !Number.isInteger(parserId) || parserId < 0) { - throw new Error('The decorator view rendered without a parser id'); + const parserIds = getDecoratorParserIds(); + const [parserId] = parserIds; + if (parserId === undefined || parserIds.length !== 1) { + throw new Error(`Expected one decorator view, found ${parserIds.length}`); } return parserId; } -function expectDecoratorOnTheOnlyLiveParserId(expectedParser: MarkdownTextInputProps['parser'] = parser) { +function expectDecoratorOnTheOnlyLiveParserId(expectedParser: Parser = parser) { expect(liveParsers.get(getDecoratorParserId())).toBe(expectedParser); expect(liveParsers.size).toBe(1); } @@ -74,17 +97,12 @@ function expectDecoratorOnTheOnlyLiveParserId(expectedParser: MarkdownTextInputP describe('MarkdownTextInput parser registration', () => { beforeEach(() => { liveParsers.clear(); - nextParserId = 1; + registerWorklet.mockClear(); + unregisterWorklet.mockClear(); + decoratorRenderCount = 0; global.jsi_setMarkdownRuntime = jest.fn(); - global.jsi_registerMarkdownWorklet = (worklet) => { - const parserId = nextParserId; - nextParserId += 1; - liveParsers.set(parserId, worklet as unknown as MarkdownTextInputProps['parser']); - return parserId; - }; - global.jsi_unregisterMarkdownWorklet = (parserId: number) => { - liveParsers.delete(parserId); - }; + global.jsi_registerMarkdownWorklet = registerWorklet; + global.jsi_unregisterMarkdownWorklet = unregisterWorklet; container = document.createElement('div'); document.body.appendChild(container); @@ -99,20 +117,23 @@ describe('MarkdownTextInput parser registration', () => { expect(liveParsers.size).toBe(0); }); - it('registers the parser once and publishes its id after mount', () => { + it('registers the parser once and carries its id in the first render', () => { renderIntoRoot(); - expect(nextParserId).toBe(2); + expect(decoratorRenderCount).toBe(1); + expect(registerWorklet).toHaveBeenCalledTimes(1); expectDecoratorOnTheOnlyLiveParserId(); }); it('unregisters the parser on unmount', () => { renderIntoRoot(); + const parserId = getDecoratorParserId(); act(() => { root.unmount(); }); + expect(unregisterWorklet).toHaveBeenCalledWith(parserId); expect(liveParsers.size).toBe(0); }); @@ -128,23 +149,21 @@ describe('MarkdownTextInput parser registration', () => { ); expect(getDecoratorParserId()).toBe(initialParserId); - expect(nextParserId).toBe(2); + expect(registerWorklet).toHaveBeenCalledTimes(1); + expect(unregisterWorklet).not.toHaveBeenCalled(); expectDecoratorOnTheOnlyLiveParserId(); }); - it('does not register a parser for an Activity that is removed without ever becoming visible', () => { + it('registers the parser of an initially hidden and drops it when the Activity is removed while hidden', () => { renderInActivity(true); - - expect(getDecoratorParserId()).toBe(0); - expect(liveParsers.size).toBe(0); + expectDecoratorOnTheOnlyLiveParserId(); renderIntoRoot(
); - expect(nextParserId).toBe(1); expect(liveParsers.size).toBe(0); }); - it('does not leak a registration when React abandons a suspended render', () => { + it('does not register a parser when React abandons a suspended render', () => { const pending = new Promise(() => { // Keep the subtree suspended until its render is abandoned. }); @@ -160,50 +179,77 @@ describe('MarkdownTextInput parser registration', () => { ); expect(container.textContent).toBe('Loading'); - expect(nextParserId).toBe(1); + expect(registerWorklet).not.toHaveBeenCalled(); expect(liveParsers.size).toBe(0); renderIntoRoot(); expectDecoratorOnTheOnlyLiveParserId(); }); - it('keeps the decorator on a live id under StrictMode', () => { + it('registers once under StrictMode', () => { renderIntoRoot( , ); + expect(registerWorklet).toHaveBeenCalledTimes(1); + expect(unregisterWorklet).not.toHaveBeenCalled(); expectDecoratorOnTheOnlyLiveParserId(); }); - it('keeps the decorator on a live id after a hidden is revealed', () => { + it('keeps the same registration while a is hidden and revealed', () => { renderInActivity(false); - expectDecoratorOnTheOnlyLiveParserId(); + const parserId = getDecoratorParserId(); renderInActivity(true); - renderInActivity(false); + expect(getDecoratorParserId()).toBe(parserId); expectDecoratorOnTheOnlyLiveParserId(); - renderInActivity(true); + const renderCountBeforeReveal = decoratorRenderCount; renderInActivity(false); + + expect(getDecoratorParserId()).toBe(parserId); + expect(decoratorRenderCount).toBe(renderCountBeforeReveal + 1); + expect(registerWorklet).toHaveBeenCalledTimes(1); + expect(unregisterWorklet).not.toHaveBeenCalled(); expectDecoratorOnTheOnlyLiveParserId(); }); - it('registers a replacement parser when a previously visible Activity is revealed', () => { + it('switches to a replacement parser while the is still hidden', () => { renderInActivity(false); - renderInActivity(true); - expect(liveParsers.size).toBe(0); + const initialParserId = getDecoratorParserId(); + renderInActivity(true); const nextParser = createParserWorklet(); renderInActivity(true, nextParser); - expect(liveParsers.size).toBe(0); + + expect(getDecoratorParserId()).not.toBe(initialParserId); + expect(unregisterWorklet).toHaveBeenCalledWith(initialParserId); + expectDecoratorOnTheOnlyLiveParserId(nextParser); renderInActivity(false, nextParser); + + expect(registerWorklet).toHaveBeenCalledTimes(2); expectDecoratorOnTheOnlyLiveParserId(nextParser); }); - it('keeps registrations independent for inputs sharing the same parser', () => { + it('unregisters an input removed inside a hidden ', () => { + renderInActivity(false); + renderInActivity(true); + const parserId = getDecoratorParserId(); + + renderIntoRoot( + +
+ , + ); + + expect(unregisterWorklet).toHaveBeenCalledWith(parserId); + expect(liveParsers.size).toBe(0); + }); + + it('shares one registration between inputs using the same parser', () => { renderIntoRoot(
{ />
, ); - const ids = Array.from(container.querySelectorAll('[data-parser-id]'), (element) => Number(element.getAttribute('data-parser-id'))); - expect(new Set(ids).size).toBe(2); - expect(liveParsers.size).toBe(2); - ids.forEach((id) => expect(liveParsers.get(id)).toBe(parser)); + const ids = getDecoratorParserIds(); + expect(ids).toHaveLength(2); + expect(ids[0]).toBe(ids[1]); + expect(registerWorklet).toHaveBeenCalledTimes(1); + expect(liveParsers.size).toBe(1); renderIntoRoot(
@@ -229,24 +276,15 @@ describe('MarkdownTextInput parser registration', () => { />
, ); - expect(getDecoratorParserId()).toBe(ids[1]); + expect(unregisterWorklet).not.toHaveBeenCalled(); expectDecoratorOnTheOnlyLiveParserId(); - }); - - it('registers only the latest parser when an initially hidden is revealed', () => { - const nextParser = createParserWorklet(); - renderInActivity(true); - expect(liveParsers.size).toBe(0); - renderInActivity(true, nextParser); + renderIntoRoot(
); + expect(unregisterWorklet).toHaveBeenCalledTimes(1); expect(liveParsers.size).toBe(0); - renderInActivity(false, nextParser); - - expect(nextParserId).toBe(2); - expectDecoratorOnTheOnlyLiveParserId(nextParser); }); - it('moves the decorator to a live id and drops the previous one when the parser changes identity', () => { + it('moves the decorator to a new id and drops the previous one when the parser changes identity', () => { renderIntoRoot(); const initialParserId = getDecoratorParserId(); @@ -254,6 +292,7 @@ describe('MarkdownTextInput parser registration', () => { renderIntoRoot(); expect(getDecoratorParserId()).not.toBe(initialParserId); + expect(unregisterWorklet).toHaveBeenCalledWith(initialParserId); expectDecoratorOnTheOnlyLiveParserId(nextParser); }); }); diff --git a/src/parserRegistry.ts b/src/parserRegistry.ts new file mode 100644 index 000000000..678ea2bb4 --- /dev/null +++ b/src/parserRegistry.ts @@ -0,0 +1,45 @@ +import {createSerializable} from 'react-native-worklets'; +import {initializeLiveMarkdownIfNeeded} from './workletRuntime'; +import type {ParserWorklet} from './workletRuntime'; + +const parserIds = new WeakMap(); +let nextParserId = 1; + +// Idempotent, so a render can read the id before any effect registers the worklet under it. +function getParserId(parser: ParserWorklet): number { + const knownParserId = parserIds.get(parser); + if (knownParserId !== undefined) { + return knownParserId; + } + const parserId = nextParserId; + nextParserId += 1; + parserIds.set(parser, parserId); + return parserId; +} + +// Inputs sharing a parser share its id, so the worklet stays registered until the last one lets go. +const retainCounts = new Map(); + +function retainParser(parser: ParserWorklet, parserId: number) { + const count = retainCounts.get(parserId) ?? 0; + if (count === 0) { + initializeLiveMarkdownIfNeeded(); + global.jsi_registerMarkdownWorklet(parserId, createSerializable(parser)); + } + retainCounts.set(parserId, count + 1); +} + +function releaseParser(parserId: number) { + const count = retainCounts.get(parserId) ?? 0; + if (count === 0) { + return; + } + if (count === 1) { + retainCounts.delete(parserId); + global.jsi_unregisterMarkdownWorklet(parserId); + return; + } + retainCounts.set(parserId, count - 1); +} + +export {getParserId, releaseParser, retainParser}; diff --git a/src/useParserId.ts b/src/useParserId.ts new file mode 100644 index 000000000..ac69e1b6d --- /dev/null +++ b/src/useParserId.ts @@ -0,0 +1,19 @@ +import React from 'react'; +import {getParserId, releaseParser, retainParser} from './parserRegistry'; +import type {ParserWorklet} from './workletRuntime'; + +// The worklet is registered in an insertion effect on purpose. It is the only effect that runs before the host tree +// commits, so the worklet exists before the first measure, and the only one a hidden leaves connected, so +// the id the native view holds stays valid until the input unmounts or the parser changes. +function useParserId(parser: ParserWorklet): number { + const parserId = getParserId(parser); + + React.useInsertionEffect(() => { + retainParser(parser, parserId); + return () => releaseParser(parserId); + }, [parser, parserId]); + + return parserId; +} + +export default useParserId; diff --git a/src/workletRuntime.ts b/src/workletRuntime.ts new file mode 100644 index 000000000..aa1815e35 --- /dev/null +++ b/src/workletRuntime.ts @@ -0,0 +1,45 @@ +import {createWorkletRuntime} from 'react-native-worklets'; +import type {SerializableRef, WorkletRuntime} from 'react-native-worklets'; +import NativeLiveMarkdownModule from './NativeLiveMarkdownModule'; +import type {MarkdownRange} from './commonTypes'; + +type ParserWorklet = (input: string) => MarkdownRange[]; + +declare global { + // eslint-disable-next-line no-var + var jsi_setMarkdownRuntime: (runtime: WorkletRuntime) => void; + // eslint-disable-next-line no-var + var jsi_registerMarkdownWorklet: (parserId: number, shareableWorklet: SerializableRef) => void; + // eslint-disable-next-line no-var + var jsi_unregisterMarkdownWorklet: (parserId: number) => void; +} + +let initialized = false; +let workletRuntime: WorkletRuntime | undefined; + +function getWorkletRuntime(): WorkletRuntime { + if (workletRuntime === undefined) { + throw new Error( + "[react-native-live-markdown] Worklet runtime hasn't been created yet. Please avoid calling `getWorkletRuntime()` in top-level scope. Instead, call `getWorkletRuntime()` directly in `runOnRuntime` arguments list.", + ); + } + return workletRuntime; +} + +function initializeLiveMarkdownIfNeeded() { + if (initialized) { + return; + } + if (NativeLiveMarkdownModule) { + NativeLiveMarkdownModule.install(); + } + if (!global.jsi_setMarkdownRuntime) { + throw new Error('[react-native-live-markdown] global.jsi_setMarkdownRuntime is not available'); + } + workletRuntime = createWorkletRuntime({name: 'LiveMarkdownRuntime'}); + global.jsi_setMarkdownRuntime(workletRuntime); + initialized = true; +} + +export type {ParserWorklet}; +export {getWorkletRuntime, initializeLiveMarkdownIfNeeded}; From 82cb85cee182c60a73d495544c40763c219865e5 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 17 Sep 2026 16:16:54 +0200 Subject: [PATCH 06/10] docs: describe the parser registry contract and the JSI binding boundary --- src/parserRegistry.ts | 4 +++- src/useParserId.ts | 7 ++++--- src/workletRuntime.ts | 4 ++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/parserRegistry.ts b/src/parserRegistry.ts index 678ea2bb4..a3623e7d5 100644 --- a/src/parserRegistry.ts +++ b/src/parserRegistry.ts @@ -2,10 +2,12 @@ import {createSerializable} from 'react-native-worklets'; import {initializeLiveMarkdownIfNeeded} from './workletRuntime'; import type {ParserWorklet} from './workletRuntime'; +// Every parser worklet gets one id that is chosen here and stays the same for as long as the function is alive, so a +// render can hand it to the native view before any effect runs. The worklet is registered natively while at least one +// mounted input retains it and unregistered when the last one releases it. const parserIds = new WeakMap(); let nextParserId = 1; -// Idempotent, so a render can read the id before any effect registers the worklet under it. function getParserId(parser: ParserWorklet): number { const knownParserId = parserIds.get(parser); if (knownParserId !== undefined) { diff --git a/src/useParserId.ts b/src/useParserId.ts index ac69e1b6d..65b527e96 100644 --- a/src/useParserId.ts +++ b/src/useParserId.ts @@ -2,9 +2,10 @@ import React from 'react'; import {getParserId, releaseParser, retainParser} from './parserRegistry'; import type {ParserWorklet} from './workletRuntime'; -// The worklet is registered in an insertion effect on purpose. It is the only effect that runs before the host tree -// commits, so the worklet exists before the first measure, and the only one a hidden leaves connected, so -// the id the native view holds stays valid until the input unmounts or the parser changes. +// Returns the id the native decorator view must carry for `parser` and keeps the worklet registered under that id while +// the input is mounted. The registration lives in an insertion effect because it is the only effect React runs before +// the host tree commits, so the first measure already has the worklet. It is also the only effect a hidden +// keeps connected and the only one StrictMode does not run twice, so the id the native view holds stays valid. function useParserId(parser: ParserWorklet): number { const parserId = getParserId(parser); diff --git a/src/workletRuntime.ts b/src/workletRuntime.ts index aa1815e35..4f87228d1 100644 --- a/src/workletRuntime.ts +++ b/src/workletRuntime.ts @@ -3,8 +3,12 @@ import type {SerializableRef, WorkletRuntime} from 'react-native-worklets'; import NativeLiveMarkdownModule from './NativeLiveMarkdownModule'; import type {MarkdownRange} from './commonTypes'; +// A function compiled with the 'worklet' directive. The type cannot express that, so `MarkdownTextInput` checks it at +// runtime and throws otherwise. type ParserWorklet = (input: string) => MarkdownRange[]; +// `NativeLiveMarkdownModule.install()` puts these functions on the global object from `injectJSIBindings` in +// `cpp/RuntimeDecorator.cpp`. Both sides must agree on the signatures. declare global { // eslint-disable-next-line no-var var jsi_setMarkdownRuntime: (runtime: WorkletRuntime) => void; From 5a582b769fdb987c0342bef835095abc0f58c953 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 17 Sep 2026 17:00:34 +0200 Subject: [PATCH 07/10] feat: add Activity and parser toggles to the example app Two buttons hide and reveal the input in a React Activity, one with a plain tree and one with AlwaysPaintedView, a view whose display style is pinned to contents so the input stays painted while its JS side is hidden. A third button swaps the parser prop between ExpensiMark and a strikethrough worklet. The example passes an explicit max length to parseExpensiMark because the default parameter is evaluated before the worklet closure with react-native-worklets 0.10.2. --- example/src/AlwaysPaintedView.tsx | 25 +++++++ example/src/App.tsx | 110 +++++++++++++++++++++++++----- 2 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 example/src/AlwaysPaintedView.tsx diff --git a/example/src/AlwaysPaintedView.tsx b/example/src/AlwaysPaintedView.tsx new file mode 100644 index 000000000..0abbf8dba --- /dev/null +++ b/example/src/AlwaysPaintedView.tsx @@ -0,0 +1,25 @@ +import type {ViewProps} from 'react-native'; +import {NativeComponentRegistry} from 'react-native'; + +// RN declares the view config types but does not export them, so they are read back off the registry signature. +type ViewConfigProvider = Parameters[1]; +type StyleAttributes = NonNullable< + NonNullable['validAttributes']>['style'] +>; + +// React hides a host view under a hidden by setting `display: none` on it. +// Pinning `display` to `contents` in the view config drops that write, so the subtree stays +// painted and native code keeps applying styles to an input whose JS side is hidden. +const pinnedDisplayStyleAttributes: StyleAttributes = { + display: {process: () => 'contents'}, +}; + +const AlwaysPaintedView = NativeComponentRegistry.get( + 'AlwaysPaintedView', + () => ({ + uiViewClassName: 'RCTView', + validAttributes: {style: pinnedDisplayStyleAttributes}, + }), +); + +export default AlwaysPaintedView; diff --git a/example/src/App.tsx b/example/src/App.tsx index ce6b1db83..02b4a8def 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -4,9 +4,30 @@ import { MarkdownTextInput, parseExpensiMark, } from '@expensify/react-native-live-markdown'; +import type {MarkdownRange} from '@expensify/react-native-live-markdown'; import * as TEST_CONST from './testConstants'; import {PlatformInfo} from './PlatformInfo'; import {handleFormatSelection} from './formatSelectionUtils'; +import AlwaysPaintedView from './AlwaysPaintedView'; + +// Passes an explicit max length: with react-native-worklets 0.10.2 the default parameter of `parseExpensiMark` is +// evaluated before the worklet closure is available and throws on the worklet runtime. +function parser(input: string) { + 'worklet'; + + return parseExpensiMark(input, 4000); +} + +function strikethroughParser(input: string): MarkdownRange[] { + 'worklet'; + + return input.length === 0 + ? [] + : [{type: 'strikethrough', start: 0, length: input.length}]; +} + +// Choosing a wrapper while the input is visible remounts it, so each hide and reveal cycle runs with a fixed wrapper. +type ActivityWrapper = 'none' | 'alwaysPainted'; export default function App() { const [value, setValue] = React.useState(TEST_CONST.EXAMPLE_CONTENT); @@ -16,6 +37,11 @@ export default function App() { const [textFontSizeState, setTextFontSizeState] = React.useState(false); const [emojiFontSizeState, setEmojiFontSizeState] = React.useState(false); const [caretHidden, setCaretHidden] = React.useState(false); + const [activityWrapper, setActivityWrapper] = + React.useState('none'); + const [activityHidden, setActivityHidden] = React.useState(false); + const [useStrikethroughParser, setUseStrikethroughParser] = + React.useState(false); const [selection, setSelection] = React.useState({start: 0, end: 0}); const style = React.useMemo(() => { @@ -36,27 +62,56 @@ export default function App() { const ref = React.useRef(null); + function toggleActivity(wrapper: ActivityWrapper) { + if (activityWrapper === wrapper) { + setActivityHidden(prev => !prev); + return; + } + setActivityWrapper(wrapper); + setActivityHidden(true); + } + + function activityButtonTitle(wrapper: ActivityWrapper) { + const action = + activityWrapper === wrapper && activityHidden ? 'Show' : 'Hide'; + return wrapper === 'alwaysPainted' + ? `${action} Activity (AlwaysPaintedView)` + : `${action} Activity`; + } + + const input = ( + setSelection(e.nativeEvent.selection)} + selection={selection} + id={TEST_CONST.INPUT_ID} + maxLength={30000} + /> + ); + return ( {multiline ? 'multiline' : 'singleline'} - setSelection(e.nativeEvent.selection)} - selection={selection} - id={TEST_CONST.INPUT_ID} - maxLength={30000} - /> + + {activityWrapper === 'alwaysPainted' ? ( + + {input} + + ) : ( + input + )} + {JSON.stringify(value)}