diff --git a/android/src/main/cpp/MarkdownParser.cpp b/android/src/main/cpp/MarkdownParser.cpp index 4edd28ec9..65457084c 100644 --- a/android/src/main/cpp/MarkdownParser.cpp +++ b/android/src/main/cpp/MarkdownParser.cpp @@ -11,11 +11,14 @@ namespace livemarkdown { jni::alias_ref jThis, jni::alias_ref text, const int parserId) { + const auto markdownWorklet = expensify::livemarkdown::findMarkdownWorklet(parserId); + if (markdownWorklet == nullptr) { + return jni::make_jstring("[]"); + } + 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/apple/MarkdownParser.mm b/apple/MarkdownParser.mm index 89bb209b4..ef1ce690b 100644 --- a/apple/MarkdownParser.mm +++ b/apple/MarkdownParser.mm @@ -209,16 +209,14 @@ - (void)drainPendingWarmups - (NSArray *)parseUncached:(nonnull NSString *)text withParserId:(nonnull NSNumber *)parserId { - 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 = expensify::livemarkdown::findMarkdownWorklet([parserId intValue]); + 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/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)}