diff --git a/android/src/main/cpp/MarkdownParser.cpp b/android/src/main/cpp/MarkdownParser.cpp index 4edd28ec9..c98baa770 100644 --- a/android/src/main/cpp/MarkdownParser.cpp +++ b/android/src/main/cpp/MarkdownParser.cpp @@ -11,11 +11,15 @@ namespace livemarkdown { jni::alias_ref jThis, jni::alias_ref text, const int parserId) { + const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet(parserId); + if (markdownWorklet == nullptr) { + // Null tells the Java side apart from a parser that returned no ranges. + return nullptr; + } + const auto markdownRuntime = expensify::livemarkdown::getMarkdownRuntime(); jsi::Runtime &rt = markdownRuntime->getJSIRuntime(); - const auto markdownWorklet = expensify::livemarkdown::getMarkdownWorklet(parserId); - const auto input = jsi::String::createFromUtf8(rt, text->toStdString()); const auto output = markdownRuntime->runGuarded(markdownWorklet, input); diff --git a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java index 3e108db70..260925b92 100644 --- a/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java +++ b/android/src/main/java/com/expensify/livemarkdown/MarkdownParser.java @@ -1,6 +1,7 @@ package com.expensify.livemarkdown; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.facebook.react.bridge.ReactContext; import com.facebook.react.util.RNLog; @@ -29,7 +30,8 @@ public MarkdownParser(@NonNull ReactContext reactContext) { mReactContext = reactContext; } - private native String nativeParse(@NonNull String text, int parserId); + // Returns null when no parser is registered under `parserId`. + private native @Nullable String nativeParse(@NonNull String text, int parserId); public synchronized List parse(@NonNull String text, int parserId) { try { @@ -53,6 +55,13 @@ public synchronized List parse(@NonNull String text, int parserId Systrace.endSection(0); } + if (json == null) { + // The parser is registered before the view is committed, so this points at a broken registration. Leave the + // cache alone so the next parse picks the parser up once it is registered. + RNLog.w(mReactContext, "[react-native-live-markdown] No parser registered for parserId " + parserId); + return Collections.emptyList(); + } + List markdownRanges = new LinkedList<>(); try { Systrace.beginSection(0, "markdownRanges"); diff --git a/apple/MarkdownParser.h b/apple/MarkdownParser.h index 1d73622d6..f506f5ffc 100644 --- a/apple/MarkdownParser.h +++ b/apple/MarkdownParser.h @@ -23,7 +23,9 @@ NS_ASSUME_NONNULL_BEGIN // is parsed as soon as it finishes. // // `completion` runs on the background queue once the text is cached. It is -// skipped if a newer call replaced this one, since that call reports instead. +// skipped if a newer call replaced this one, since that call reports instead, +// and if no parser is registered under `parserId`, since nothing was cached +// and a new measure would find nothing either. - (void)warmCacheAsyncForText:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId completion:(nullable void (^)(void))completion; diff --git a/apple/MarkdownParser.mm b/apple/MarkdownParser.mm index 89bb209b4..3c438f766 100644 --- a/apple/MarkdownParser.mm +++ b/apple/MarkdownParser.mm @@ -171,13 +171,13 @@ - (void)drainPendingWarmups _pendingCompletion = nil; } - [self parse:text withParserId:parserId]; + BOOL cached = [self parseIfRegistered:text withParserId:parserId] != nil; BOOL superseded; @synchronized (self) { superseded = _pendingText != nil; } - if (completion != nil && !superseded) { + if (completion != nil && !superseded && cached) { completion(); } } @@ -185,6 +185,14 @@ - (void)drainPendingWarmups - (NSArray *)parse:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId +{ + return [self parseIfRegistered:text withParserId:parserId] ?: @[]; +} + +// Returns nil when no parser is registered under `parserId`. Nothing is cached +// then, so the next parse picks the parser up once it is registered. +- (nullable NSArray *)parseIfRegistered:(nonnull NSString *)text + withParserId:(nonnull NSNumber *)parserId { NSArray *cached = [self cachedRangesForText:text withParserId:parserId]; if (cached != nil) { @@ -200,25 +208,29 @@ - (void)drainPendingWarmups // Two threads may end up parsing the same text at the same time. That is // fine: they run one after the other and produce the same result. NSArray *markdownRanges = [self parseUncached:text withParserId:parserId]; + if (markdownRanges == nil) { + return nil; + } [self cacheMarkdownRanges:markdownRanges forText:text withParserId:parserId]; return markdownRanges; } -- (NSArray *)parseUncached:(nonnull NSString *)text - withParserId:(nonnull NSNumber *)parserId +- (nullable NSArray *)parseUncached:(nonnull NSString *)text + withParserId:(nonnull NSNumber *)parserId { + const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet([parserId intValue]); + if (markdownWorklet == nullptr) { + // The parser is registered before the view is committed, but a queued + // warmup may run after it is unregistered on unmount or a parser change. + RCTLogWarn(@"[react-native-live-markdown] No parser registered for parserId %@", parserId); + return nil; + } + 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) { - return @[]; - } - const auto &input = jsi::String::createFromUtf8(rt, [text UTF8String]); jsi::Value output; diff --git a/cpp/MarkdownGlobal.cpp b/cpp/MarkdownGlobal.cpp index 67f93eb43..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) { @@ -34,9 +31,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..739d7b6e3 100644 --- a/cpp/MarkdownGlobal.h +++ b/cpp/MarkdownGlobal.h @@ -14,11 +14,14 @@ 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 mounted input, 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); -std::shared_ptr getMarkdownWorklet(const int parserId); +// Returns nullptr when nothing is registered under `parserId`. +std::shared_ptr findMarkdownWorklet(const int parserId); } // namespace livemarkdown } // namespace expensify 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/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..edd53e954 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,39 @@ export default function App() { const ref = React.useRef(null); + 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)}